",
25 | "license": "MIT",
26 | "bugs": {
27 | "url": "https://github.com/joshdick/tabclip/issues"
28 | },
29 | "homepage": "https://joshdick.github.io/tabclip",
30 | "dependencies": {
31 | "@fortawesome/fontawesome": "^1.1.8",
32 | "@fortawesome/fontawesome-free-solid": "^5.0.13",
33 | "bootstrap-css-only": "^4.4.1",
34 | "url-regex-safe": "^2.0.2",
35 | "webextension-polyfill": "^0.7.0"
36 | },
37 | "devDependencies": {
38 | "clean-webpack-plugin": "^3.0.0",
39 | "copy-webpack-plugin": "^8.1.0",
40 | "css-loader": "^5.1.3",
41 | "eslint": "^7.22.0",
42 | "style-loader": "^2.0.0",
43 | "webpack": "^5.27.2",
44 | "webpack-cli": "^4.5.0",
45 | "zip-webpack-plugin": "^4.0.1"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/background.htm:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | tabclip - background
9 |
10 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/src/background.js:
--------------------------------------------------------------------------------
1 | const browser = require('webextension-polyfill')
2 | const shared = require('./shared')
3 |
4 | const showNotification = async (quantity, operation) => {
5 | const idSuffix = operation === shared.ALERT_OPERATIONS.COPY ? 'copy' : 'paste'
6 | const titleVerb = operation === shared.ALERT_OPERATIONS.COPY ? 'Copy' : 'Paste'
7 | const messageVerb = operation === shared.ALERT_OPERATIONS.COPY ? 'Copied' : 'Pasted'
8 | await browser.notifications.create(`tabclip-${idSuffix}`, {
9 | type: 'basic',
10 | iconUrl: browser.extension.getURL('img/tabclip_128.png'),
11 | title: `Tabclip ${titleVerb}`,
12 | message: `${messageVerb} ${quantity} URL${quantity === 1 ? '' : 's'}.`,
13 | })
14 | }
15 |
16 | const commandListener = async (command) => {
17 | if (command === 'copy-tabs') {
18 | const {
19 | [shared.PREFERENCE_NAMES.COPY_SCOPE]: copyScope,
20 | [shared.PREFERENCE_NAMES.INCLDUE_TITLES]: includeTitles,
21 | } = await shared.getPrefs()
22 | const tabCount = await shared.copyTabs(copyScope !== 'allWindows', !!includeTitles)
23 | await showNotification(tabCount, shared.ALERT_OPERATIONS.COPY)
24 | } else if (command === 'paste-tabs') {
25 | const {
26 | [shared.PREFERENCE_NAMES.BACKGROUND_PASTE]: inBackground,
27 | } = await shared.getPrefs()
28 | const tabCount = await shared.pasteTabs(!!inBackground)
29 | await showNotification(tabCount, shared.ALERT_OPERATIONS.PASTE)
30 | }
31 | }
32 |
33 | if (!browser.commands.onCommand.hasListener(commandListener)) {
34 | browser.commands.onCommand.addListener(commandListener)
35 | }
36 |
37 |
--------------------------------------------------------------------------------
/src/fonts.js:
--------------------------------------------------------------------------------
1 | /* eslint-env module */
2 | import faCopy from '@fortawesome/fontawesome-free-solid/faCopy'
3 | import faPaste from '@fortawesome/fontawesome-free-solid/faPaste'
4 | import fontawesome from '@fortawesome/fontawesome'
5 | fontawesome.library.add(faCopy, faPaste)
6 |
--------------------------------------------------------------------------------
/src/img/tabclip_128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joshdick/tabclip/b22e5f7b313b22922fae34641f50e8fcba187348/src/img/tabclip_128.png
--------------------------------------------------------------------------------
/src/img/tabclip_16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joshdick/tabclip/b22e5f7b313b22922fae34641f50e8fcba187348/src/img/tabclip_16.png
--------------------------------------------------------------------------------
/src/img/tabclip_32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joshdick/tabclip/b22e5f7b313b22922fae34641f50e8fcba187348/src/img/tabclip_32.png
--------------------------------------------------------------------------------
/src/img/tabclip_48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/joshdick/tabclip/b22e5f7b313b22922fae34641f50e8fcba187348/src/img/tabclip_48.png
--------------------------------------------------------------------------------
/src/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "manifest_version": 2,
3 | "name": "tabclip",
4 | "description": "Copy browser tabs to (or create them from) your clipboard.",
5 | "version": "1.4",
6 | "author": "Josh Dick",
7 | "icons": {
8 | "16": "img/tabclip_16.png",
9 | "32": "img/tabclip_32.png",
10 | "48": "img/tabclip_48.png",
11 | "128": "img/tabclip_128.png"
12 | },
13 | "homepage_url": "https://joshdick.github.io/tabclip",
14 | "browser_action": {
15 | "default_icon": "img/tabclip_128.png",
16 | "default_popup": "popup.htm",
17 | "default_title": "tabclip"
18 | },
19 | "background": {
20 | "page": "background.htm"
21 | },
22 | "commands": {
23 | "copy-tabs": {
24 | "suggested_key": {
25 | "default": "Ctrl+Shift+C",
26 | "mac": "MacCtrl+Shift+C"
27 | },
28 | "description": "Copy tab(s) to the clipboard"
29 | },
30 | "paste-tabs": {
31 | "suggested_key": {
32 | "default": "Ctrl+Shift+V",
33 | "mac": "MacCtrl+Shift+V"
34 | },
35 | "description": "Paste tab(s) from the clipboard"
36 | }
37 | },
38 | "permissions": [
39 | "clipboardRead", "clipboardWrite", "notifications", "storage", "tabs"
40 | ],
41 | "content_security_policy": "script-src 'self'; object-src 'self'"
42 | }
43 |
--------------------------------------------------------------------------------
/src/popup.htm:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | tabclip
9 |
10 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
71 |
72 |
73 |
74 |
75 |
76 |
--------------------------------------------------------------------------------
/src/popup.js:
--------------------------------------------------------------------------------
1 | require('bootstrap-css-only/css/bootstrap.min.css')
2 | const shared = require('./shared')
3 |
4 | const alert = document.querySelector('#alert')
5 | const copyButton = document.querySelector('#copyButton')
6 | const pasteButton = document.querySelector('#pasteButton')
7 | const includeTitlesCheckbox = document.querySelector('#includeTitles')
8 | const backgroundPasteCheckbox = document.querySelector('#backgroundPaste')
9 |
10 | // Waiting for DOMContentLoaded allows webextension-polyfill to load
11 | document.addEventListener('DOMContentLoaded', async () => {
12 | const {
13 | [shared.PREFERENCE_NAMES.COPY_SCOPE]: copyScope,
14 | [shared.PREFERENCE_NAMES.INCLUDE_TITLES]: includeTitles,
15 | [shared.PREFERENCE_NAMES.BACKGROUND_PASTE]: backgroundPaste
16 | } = await shared.getPrefs()
17 | if (copyScope) document.querySelector(`#${copyScope}`).checked = true
18 | includeTitlesCheckbox.checked = !!includeTitles
19 | backgroundPasteCheckbox.checked = !!backgroundPaste
20 | })
21 |
22 | const showAlert = async (quantity, operation) => {
23 | let verb
24 | switch (operation) {
25 | case shared.ALERT_OPERATIONS.COPY:
26 | verb = 'Copied'
27 | break
28 | case shared.ALERT_OPERATIONS.PASTE:
29 | verb = 'Pasted'
30 | break
31 | }
32 | alert.innerText = `${verb} ${quantity} URL${quantity === 1 ? '' : 's'}.`
33 | const className = 'show'
34 | alert.classList.add(className)
35 | await new Promise(resolve => setTimeout(resolve, 3000))
36 | .then(() => {
37 | alert.classList.remove(className)
38 | alert.innerText = ''
39 | })
40 | }
41 |
42 | copyButton.onclick = async () => {
43 | const currentWindow = document.querySelector('input[name="copyScope"]:checked').value === 'current'
44 | const includeTitles = includeTitlesCheckbox.checked
45 | const tabCount = await shared.copyTabs(currentWindow, includeTitles)
46 | await showAlert(tabCount, shared.ALERT_OPERATIONS.COPY)
47 | }
48 |
49 | pasteButton.onclick = async () => {
50 | const inBackground = backgroundPasteCheckbox.checked
51 | const tabCount = await shared.pasteTabs(inBackground)
52 | await showAlert(tabCount, shared.ALERT_OPERATIONS.PASTE)
53 | }
54 |
55 | backgroundPasteCheckbox.onchange = async () => {
56 | await shared.savePref(shared.PREFERENCE_NAMES.BACKGROUND_PASTE, backgroundPasteCheckbox.checked)
57 | }
58 |
59 | includeTitlesCheckbox.onchange = async () => {
60 | await shared.savePref(shared.PREFERENCE_NAMES.INCLUDE_TITLES, includeTitlesCheckbox.checked)
61 | }
62 |
63 | document.querySelectorAll('input[name="copyScope"]')
64 | .forEach(radioButton => radioButton.onchange = async (event) => {
65 | await shared.savePref(shared.PREFERENCE_NAMES.COPY_SCOPE, event.target.id)
66 | })
67 |
--------------------------------------------------------------------------------
/src/shared.js:
--------------------------------------------------------------------------------
1 | const browser = require('webextension-polyfill')
2 | const urlRegex = require('url-regex-safe')
3 |
4 | const clipboardBridge = document.querySelector('#clipboardBridge')
5 | clipboardBridge.contentEditable = true
6 |
7 | const ALERT_OPERATIONS = Object.freeze({
8 | COPY: Symbol('copy'),
9 | PASTE: Symbol('paste')
10 | })
11 |
12 | const readFromClipboard = async () => {
13 | let result = ''
14 |
15 | clipboardBridge.focus()
16 | document.execCommand('selectAll')
17 | document.execCommand('paste')
18 | result = clipboardBridge.innerText
19 | clipboardBridge.innerText = ''
20 |
21 | if (!result && navigator.clipboard) {
22 | try {
23 | // Can cause Chrome to block without throwing an error,
24 | // so try it only after attempting the method above
25 | result = await navigator.clipboard.readText()
26 | } catch (error) {
27 | // Disregard any error
28 | }
29 | }
30 |
31 | return result
32 | }
33 |
34 | const writeToClipboard = async (text) => {
35 | if (navigator.clipboard) {
36 | try {
37 | await navigator.clipboard.writeText(text)
38 | return
39 | } catch (error) {
40 | // Disregard any error; try alternate method below
41 | }
42 | }
43 |
44 | clipboardBridge.innerText = text
45 | clipboardBridge.focus()
46 | document.execCommand('selectAll')
47 | document.execCommand('copy')
48 | clipboardBridge.innerText = ''
49 | }
50 |
51 | const copyTabs = async (currentWindow, includeTitles) => {
52 | // Return an array where each element represents a window,
53 | // where a window is itself an array where each element is a tab.
54 | const getTabsByWindow = async () => {
55 | if (currentWindow) {
56 | const currentWindowTabs = await browser.tabs.query({ currentWindow })
57 | return [currentWindowTabs]
58 | } else {
59 | const tabsByWindow = []
60 | const windows = await browser.windows.getAll({ populate: true })
61 | for (const window of windows) {
62 | const tabs = []
63 | for (const tab of window.tabs) {
64 | tabs.push(tab)
65 | }
66 | tabsByWindow.push(tabs)
67 | }
68 | return tabsByWindow
69 | }
70 | }
71 |
72 | const tabsByWindow = await getTabsByWindow()
73 | let tabCount = 0
74 | const output =
75 | tabsByWindow.map(
76 | tabs => tabs.map(tab => {
77 | tabCount += 1
78 | const title = includeTitles ? ` | ${tab.title}` : ''
79 | return `${tab.url}${title}`
80 | }).join('\n') // Combine all tabs for one window into a string, one URL per line
81 | ).join('\n\n') // Combine each window's URL list, separating each list with an empty line
82 | await writeToClipboard(output)
83 | return tabCount
84 | }
85 |
86 | const pasteTabs = async (inBackground = false) => {
87 | const input = await readFromClipboard()
88 | const urls = input.match(urlRegex()) || []
89 | for (const url of urls) {
90 | browser.tabs.create({ url, active: !inBackground })
91 | }
92 | return urls.length
93 | }
94 |
95 | // User preferences
96 |
97 | const PREFERENCE_NAMES = Object.freeze({
98 | BACKGROUND_PASTE: 'backgroundPaste',
99 | COPY_SCOPE: 'copyScope',
100 | INCLUDE_TITLES: 'includeTitles',
101 | })
102 |
103 | const storage = browser.storage.local
104 |
105 | const savePref = async (name, value) => {
106 | await storage.set({
107 | [name]: value
108 | })
109 | }
110 |
111 | const getPrefs = async () => {
112 | const result = await storage.get([
113 | PREFERENCE_NAMES.BACKGROUND_PASTE,
114 | PREFERENCE_NAMES.COPY_SCOPE,
115 | PREFERENCE_NAMES.INCLUDE_TITLES
116 | ])
117 | return result
118 | }
119 |
120 | module.exports = {
121 | copyTabs,
122 | pasteTabs,
123 | getPrefs,
124 | savePref,
125 | PREFERENCE_NAMES,
126 | ALERT_OPERATIONS,
127 | }
128 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | /* eslint-env node */
2 |
3 | const path = require('path')
4 |
5 | const { CleanWebpackPlugin } = require('clean-webpack-plugin')
6 | const CopyWebpackPlugin = require('copy-webpack-plugin')
7 | const ZipPlugin = require('zip-webpack-plugin')
8 |
9 | const zipPluginConfig = {
10 | exclude: ['.DS_Store'],
11 | filename: 'tabclip.zip',
12 | // yazl Options
13 | // OPTIONAL: see https://github.com/thejoshwolfe/yazl#addfilerealpath-metadatapath-options
14 | fileOptions: {
15 | mtime: new Date(),
16 | mode: 0o100664,
17 | }
18 | }
19 |
20 | module.exports = {
21 | /*
22 | mode: 'development',
23 | devtool: 'cheap-module-source-map',
24 | */
25 | mode: 'production',
26 | entry: {
27 | background: './src/background.js',
28 | fonts: './src/fonts.js',
29 | popup: './src/popup.js',
30 | },
31 | output: {
32 | filename: '[name].bundle.js',
33 | path: path.resolve(__dirname, 'dist')
34 | },
35 | module: {
36 | rules: [
37 | {
38 | test: /\.css$/,
39 | use: ['style-loader', 'css-loader']
40 | }
41 | ]
42 | },
43 | plugins: [
44 | new CleanWebpackPlugin(),
45 | new CopyWebpackPlugin({
46 | patterns: [
47 | { from: 'manifest*.json', context: 'src' },
48 | { from: '*.htm', context: 'src' },
49 | { from: 'img/*png', context: 'src' }
50 | ]
51 | }),
52 | new ZipPlugin(zipPluginConfig)
53 | ]
54 | }
55 |
--------------------------------------------------------------------------------