Skip to content

Commit

Permalink
Merge pull request #113 from yorkxin/chrome-keyboard-copy-hack
Browse files Browse the repository at this point in the history
 Inject an iframe to perform copy on Chrome + Keyboard Shortcut
  • Loading branch information
yorkxin authored Nov 17, 2022
2 parents 87e1ec8 + 3f27588 commit 67cbc4a
Show file tree
Hide file tree
Showing 4 changed files with 163 additions and 45 deletions.
12 changes: 11 additions & 1 deletion chrome/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,15 @@
},
"options_ui": {
"page": "./dist/ui/options.html"
}
},
"web_accessible_resources": [
{
"resources": [
"dist/iframe-copy.html"
],
"matches": [
"<all_urls>"
]
}
]
}
11 changes: 11 additions & 0 deletions src/iframe-copy.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Copy as Markdown (Content Script)</title>
</head>
<body>
<textarea id="copy"></textarea>
<script src="./iframe-copy.js"></script>
</body>
</html>
26 changes: 26 additions & 0 deletions src/iframe-copy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
window.addEventListener('message', (event) => {
switch (event.data.cmd) {
case 'copy': {
const { text } = event.data;
if (text === '' || !text) {
event.source.postMessage({ topic: 'iframe-copy-response', ok: false, reason: 'no text' }, event.origin);
}

const textBox = document.getElementById('copy');
textBox.innerHTML = text;
textBox.select();
const result = document.execCommand('Copy');
if (result) {
event.source.postMessage({ topic: 'iframe-copy-response', ok: true }, event.origin);
} else {
event.source.postMessage({ topic: 'iframe-copy-response', ok: false, reason: 'execCommand returned false' }, event.origin);
}
textBox.innerHTML = '';
break;
}

default: {
event.source.postMessage({ topic: 'iframe-copy-response', ok: false, reason: `unknown command ${event.data.cmd}` }, event.origin);
}
}
});
159 changes: 115 additions & 44 deletions src/lib/clipboard-access.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
/* eslint-disable max-len */
/* eslint-disable max-classes-per-file */

/**
* Before modifying anything here, read the following articles first:
* https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Interact_with_the_clipboard
Expand All @@ -13,68 +16,136 @@
* NOTE: the whole function must be passed to content script as a function literal.
* i.e. please do not extract any code to separate functions.
* @param text
* @returns {Promise<{ok: bool, errorMessage?: string, method: 'navigator_api'|'textarea'}>}
* @returns {Promise<{ok: boolean, errorMessage?: string, method: 'navigator_api'|'textarea'}>}
*/
async function copy(text) {
class KnownFailureError extends Error {}

const useClipboardAPI = async (t) => {
/** @type {PermissionStatus} */
let ret;
try {
await navigator.clipboard.writeText(t);
return Promise.resolve({ ok: true, method: 'navigator_api' });
} catch (error) {
return Promise.resolve({ ok: false, error: `${error.name} ${error.message}`, method: 'navigator_api' });
// XXX: In Chrome, clipboard-write permission is required in order to use
// navigator.clipboard.writeText() in Content Script.
//
// There are some inconsistent behaviors when navigator.clipboard is called
// via onCommand (Keyboard Shortcut) vs via onMenu (Context Menu).
// The keyboard shortcut _may_ trigger permission prompt while the context menu one almost
// don't.
//
// Here we behave conservatively -- if permission query don't return 'granted' then
// don't even bother to try calling navigator.clipboard.writeText().
//
// See https://web.dev/async-clipboard/#security-and-permissions
// See https://bugs.chromium.org/p/chromium/issues/detail?id=1382608#c4
ret = await navigator.permissions.query({
name: 'clipboard-write', allowWithoutGesture: true,
});
} catch (e) {
if (e instanceof TypeError) {
// ... And also `clipboard-write` is not a permission that can be queried in Firefox,
// but since navigator.clipboard.writeText() always work on Firefox as long as it is declared
// in manifest.json, we don't even bother handling it.
await navigator.clipboard.writeText(t);
return true;
}

throw e;
}
};

try {
// XXX: In Chrome, clipboard-write permission is required in order to use
// navigator.clipboard.writeText() in Content Script.
//
// There are some inconsistent behaviors when navigator.clipboard is called
// via onCommand (Keyboard Shortcut) vs via onMenu (Context Menu).
// The keyboard shortcut _may_ trigger permission prompt while the context menu one almost
// don't.
//
// Here we behave conservatively -- if permission query don't return 'granted' then
// don't even bother to try calling navigator.clipboard.writeText().
//
// See https://web.dev/async-clipboard/#security-and-permissions
// See https://bugs.chromium.org/p/chromium/issues/detail?id=1382608#c4
const ret = await navigator.permissions.query({
name: 'clipboard-write', allowWithoutGesture: true,
});
// state will be 'granted', 'denied' or 'prompt':
if (ret.state === 'granted') {
return useClipboardAPI(text);
if (ret && ret.state === 'granted') {
await navigator.clipboard.writeText(t);
return true;
}
console.debug(`clipboard-write permission state: ${ret.state}`);
// ... continue with textarea approach
} catch (e) {
if (e instanceof TypeError) {
// ... And also `clipboard-write` is not a permission that can be queried in Firefox,
// but since navigator.clipboard.writeText() always work on Firefox as long as it is declared
// in manifest.json, we don't even bother handling it.
return useClipboardAPI(text);
}
return Promise.resolve({ ok: false, error: `${e.name} ${e.message}`, method: 'navigator_api' });
}
throw new KnownFailureError('no permission to call navigator.clipboard API');
};

return new Promise((resolve) => {
const useOnPageTextarea = async (t) => {
/** @type {HTMLTextAreaElement} */
const textBox = document.createElement('textarea');
document.body.appendChild(textBox);
try {
/** @type {HTMLTextAreaElement} */
const textBox = document.createElement('textarea');
document.body.append(textBox);
textBox.innerHTML = text;
textBox.innerHTML = t;
textBox.select();
const result = document.execCommand('Copy');
document.body.removeChild(textBox);
if (result) {
resolve({ ok: true, method: 'textarea' });
return Promise.resolve(true);
}
resolve({ ok: false, error: 'document.execCommand returned false', method: 'textarea' });
return Promise.reject(new KnownFailureError('execCommand returned false'));
} catch (e) {
resolve({ ok: false, error: `${e.name} ${e.message}`, method: 'textarea' });
return Promise.reject(e);
} finally {
if (document.body.contains(textBox)) {
document.body.removeChild(textBox);
}
}
};

const useIframeTextarea = async (t) => new Promise((resolve, reject) => {
const iframe = document.createElement('iframe');
iframe.src = chrome.runtime.getURL('dist/iframe-copy.html');
iframe.width = '10';
iframe.height = '10';
iframe.style.position = 'absolute';
iframe.style.left = '-100px';
document.body.appendChild(iframe);
window.addEventListener('message', (event) => {
switch (event.data.topic) {
case 'iframe-copy-response': {
if (document.body.contains(iframe)) {
document.body.removeChild(iframe);
}
if (event.data.ok) {
resolve(true);
} else {
reject(new KnownFailureError(event.data.reason));
}
break;
}
default: {
reject(new Error(`unknown topic ${event.data.topic}`));
}
}
});

setTimeout(() => {
iframe.contentWindow.postMessage({ cmd: 'copy', text: t }, '*');
}, 100);
});

try {
await useClipboardAPI(text);
return Promise.resolve({ ok: true, method: 'navigator_api' });
} catch (error) {
if (error instanceof KnownFailureError) {
console.debug(error);
// try next method
} else {
return Promise.resolve({ ok: false, error: `${error.name} ${error.message}`, method: 'navigator_api' });
}
}

try {
await useOnPageTextarea(text);
return Promise.resolve({ ok: true, method: 'textarea' });
} catch (error) {
if (error instanceof KnownFailureError) {
console.debug(error);
// try next method
} else {
return Promise.resolve({ ok: false, error: `${error.name} ${error.message}`, method: 'textarea' });
}
}

try {
await useIframeTextarea(text);
return Promise.resolve({ ok: true, method: 'iframe' });
} catch (error) {
console.debug(error);
return Promise.resolve({ ok: false, error: `${error.name} ${error.message}`, method: 'iframe' });
}
}

/**
Expand Down

0 comments on commit 67cbc4a

Please sign in to comment.