@arraypress/extension-kit 1.9.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -5
- package/package.json +1 -1
- package/src/build/index.d.ts +2 -1
- package/src/build/index.js +2 -1
- package/src/build/store-shots.d.ts +177 -0
- package/src/build/store-shots.js +173 -0
- package/src/build/test-build.d.ts +13 -1
- package/src/build/test-build.js +41 -2
- package/src/content/elements.d.ts +50 -0
- package/src/content/elements.js +88 -0
- package/src/content/highlight.d.ts +54 -0
- package/src/content/highlight.js +92 -0
- package/src/content/overlay.d.ts +16 -6
- package/src/content/overlay.js +16 -6
- package/src/content/page-toast.d.ts +26 -0
- package/src/content/page-toast.js +72 -0
- package/src/dom/copy.d.ts +36 -0
- package/src/dom/copy.js +84 -0
- package/src/dom/i18n.d.ts +17 -0
- package/src/dom/i18n.js +15 -0
- package/src/dom/make.d.ts +53 -0
- package/src/dom/make.js +71 -0
- package/src/frame/window.d.ts +7 -0
- package/src/frame/window.js +3 -1
- package/src/index.d.ts +21 -3
- package/src/index.js +16 -3
- package/src/scripting/tab.d.ts +68 -0
- package/src/scripting/tab.js +81 -0
- package/src/styles/chrome-ui.css +7 -0
- package/src/testing/index.d.ts +6 -0
- package/src/testing/index.js +4 -0
- package/src/testing/live-run.d.ts +130 -0
- package/src/testing/live-run.js +160 -0
- package/src/testing/pages.d.ts +114 -0
- package/src/testing/pages.js +109 -0
- package/src/ui/notice.d.ts +58 -0
- package/src/ui/notice.js +43 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A page's rendered elements, for a content script that reads them all: in
|
|
3
|
+
* document order, into open shadow roots and closed ones (where Chrome lets
|
|
4
|
+
* an extension see them, `chrome.dom.openOrClosedShadowRoot`), leaving out
|
|
5
|
+
* what Chrome doesn't render and what holds no page content (scripts,
|
|
6
|
+
* styles, templates, `<head>`).
|
|
7
|
+
*
|
|
8
|
+
* @module content/elements
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Elements that are never drawn as the page's content, nor anything inside them. */
|
|
12
|
+
const NEVER_RENDERED = new Set(['script', 'style', 'noscript', 'template', 'head', 'title', 'meta', 'link']);
|
|
13
|
+
|
|
14
|
+
/** The most elements read (a very long page's first ones). */
|
|
15
|
+
export const MAX_ELEMENTS = 20_000;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* An element's shadow root, closed ones too where Chrome lets the extension see them.
|
|
19
|
+
*
|
|
20
|
+
* @param {Element} element - The element.
|
|
21
|
+
* @returns {ShadowRoot | null} Its shadow root.
|
|
22
|
+
* @example
|
|
23
|
+
* const root = shadowRootOf(host); // a closed root, from a content script
|
|
24
|
+
*/
|
|
25
|
+
export function shadowRootOf(element) {
|
|
26
|
+
const dom = /** @type {{dom?: {openOrClosedShadowRoot?: (element: HTMLElement) => ShadowRoot | null}}} */ (globalThis.chrome ?? {}).dom;
|
|
27
|
+
return (dom?.openOrClosedShadowRoot && element instanceof HTMLElement ? dom.openOrClosedShadowRoot(element) : element.shadowRoot) ?? null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Every rendered element from the body down, in document order, a shadow root's elements before
|
|
32
|
+
* its host's children. An element Chrome doesn't draw (`visibility: hidden`) is left out, but
|
|
33
|
+
* what's inside it is still read (a child can be visible again), as is what's inside a
|
|
34
|
+
* `display: contents` element (a slot), which has no box of its own; nothing inside a
|
|
35
|
+
* `display: none` or `content-visibility: hidden` part is.
|
|
36
|
+
*
|
|
37
|
+
* @param {Document} doc - The page.
|
|
38
|
+
* @param {{skip?: (element: Element) => boolean, limit?: number}} [options] - `skip`: elements left
|
|
39
|
+
* out with what's inside (the extension's overlay); `limit`: the most elements ({@link MAX_ELEMENTS}).
|
|
40
|
+
* @returns {Element[]} The elements.
|
|
41
|
+
* @example
|
|
42
|
+
* for (const element of renderedElements(document, { skip: isLayer })) read(element);
|
|
43
|
+
*/
|
|
44
|
+
export function renderedElements(doc, { skip = () => false, limit = MAX_ELEMENTS } = {}) {
|
|
45
|
+
/** @type {Element[]} */
|
|
46
|
+
const found = [];
|
|
47
|
+
/** @param {Element} element */
|
|
48
|
+
const visit = (element) => {
|
|
49
|
+
if (found.length >= limit || NEVER_RENDERED.has(element.localName) || skip(element)) return;
|
|
50
|
+
if (element.checkVisibility()) {
|
|
51
|
+
if (element.checkVisibility({ visibilityProperty: true })) found.push(element);
|
|
52
|
+
} else if (element.ownerDocument.defaultView?.getComputedStyle(element).display !== 'contents') {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const shadow = shadowRootOf(element);
|
|
56
|
+
if (shadow) for (const child of shadow.children) visit(child);
|
|
57
|
+
for (const child of element.children) visit(child);
|
|
58
|
+
};
|
|
59
|
+
if (doc.body) visit(doc.body);
|
|
60
|
+
return found;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The origins of a page's frames that are another site's: their content isn't the page's to read,
|
|
65
|
+
* and an extension reads them only with that site's access (`chrome.permissions.request`, from a
|
|
66
|
+
* click). Frames inside shadow roots are found too.
|
|
67
|
+
*
|
|
68
|
+
* @param {Document} doc - The page.
|
|
69
|
+
* @param {Element[]} [elements] - Its elements, when already listed ({@link renderedElements}).
|
|
70
|
+
* @returns {string[]} Their origins (`https://ads.example.net`), each once.
|
|
71
|
+
* @example
|
|
72
|
+
* otherSiteFrames(document); // ['https://www.youtube.com']
|
|
73
|
+
*/
|
|
74
|
+
export function otherSiteFrames(doc, elements = renderedElements(doc)) {
|
|
75
|
+
const own = doc.location?.origin;
|
|
76
|
+
/** @type {Set<string>} */
|
|
77
|
+
const sites = new Set();
|
|
78
|
+
for (const frame of elements) {
|
|
79
|
+
if (frame.localName !== 'iframe' && frame.localName !== 'frame') continue;
|
|
80
|
+
try {
|
|
81
|
+
const url = new URL(/** @type {HTMLIFrameElement} */ (frame).src);
|
|
82
|
+
if (/^https?:$/.test(url.protocol) && url.origin !== own) sites.add(url.origin);
|
|
83
|
+
} catch {
|
|
84
|
+
// No address (srcdoc, about:blank): the page's own.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return [...sites];
|
|
88
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* “Show on page”: boxes in Chrome's blue (DevTools' highlight) around a set of
|
|
3
|
+
* elements, kept on them as the page scrolls or resizes, until cleared (the
|
|
4
|
+
* extension's button again, another set, or Esc). The first can be scrolled
|
|
5
|
+
* into view. The boxes go in the extension's own overlay (`pageOverlay`), so
|
|
6
|
+
* the page's styles can't touch them.
|
|
7
|
+
*
|
|
8
|
+
* @module content/highlight
|
|
9
|
+
*/
|
|
10
|
+
/** The most elements boxed at once. */
|
|
11
|
+
export declare const MAX_BOXES = 500;
|
|
12
|
+
/** The boxes' styles, for the overlay's stylesheet. @type {string} */
|
|
13
|
+
export declare const HIGHLIGHT_CSS: string;
|
|
14
|
+
export type Highlighter = {
|
|
15
|
+
/**
|
|
16
|
+
* Box elements (the
|
|
17
|
+
* ones still in the page, the first {@link MAX_BOXES}), clearing any boxed before; `reveal`
|
|
18
|
+
* scrolls the first into view. Returns how many are boxed.
|
|
19
|
+
*/
|
|
20
|
+
show: (elements: Element[], options?: {
|
|
21
|
+
reveal?: boolean;
|
|
22
|
+
}) => number;
|
|
23
|
+
/**
|
|
24
|
+
* Take the boxes away.
|
|
25
|
+
*/
|
|
26
|
+
clear: () => void;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* A page's highlight: one set of elements boxed at a time.
|
|
30
|
+
*
|
|
31
|
+
* @typedef {object} Highlighter
|
|
32
|
+
* @property {(elements: Element[], options?: {reveal?: boolean}) => number} show Box elements (the
|
|
33
|
+
* ones still in the page, the first {@link MAX_BOXES}), clearing any boxed before; `reveal`
|
|
34
|
+
* scrolls the first into view. Returns how many are boxed.
|
|
35
|
+
* @property {() => void} clear Take the boxes away.
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* A highlighter for a page.
|
|
39
|
+
*
|
|
40
|
+
* @param {Document} doc - The page.
|
|
41
|
+
* @param {object} options
|
|
42
|
+
* @param {() => ParentNode} options.root - Where boxes go: the overlay's shadow root, made when first needed.
|
|
43
|
+
* @param {() => void} [options.onClear] - After the boxes go (take an empty overlay away).
|
|
44
|
+
* @param {number} [options.max] - The most elements boxed. Default {@link MAX_BOXES}.
|
|
45
|
+
* @returns {Highlighter} It.
|
|
46
|
+
* @example
|
|
47
|
+
* const highlight = createHighlighter(document, { root: () => layer(document), onClear: tidyLayer });
|
|
48
|
+
* highlight.show(elements, { reveal: true });
|
|
49
|
+
*/
|
|
50
|
+
export declare function createHighlighter(doc: Document, { root, onClear, max }: {
|
|
51
|
+
root: () => ParentNode;
|
|
52
|
+
onClear?: () => void;
|
|
53
|
+
max?: number;
|
|
54
|
+
}): Highlighter;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* “Show on page”: boxes in Chrome's blue (DevTools' highlight) around a set of
|
|
3
|
+
* elements, kept on them as the page scrolls or resizes, until cleared (the
|
|
4
|
+
* extension's button again, another set, or Esc). The first can be scrolled
|
|
5
|
+
* into view. The boxes go in the extension's own overlay (`pageOverlay`), so
|
|
6
|
+
* the page's styles can't touch them.
|
|
7
|
+
*
|
|
8
|
+
* @module content/highlight
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** The most elements boxed at once. */
|
|
12
|
+
export const MAX_BOXES = 500;
|
|
13
|
+
|
|
14
|
+
/** The boxes' styles, for the overlay's stylesheet. @type {string} */
|
|
15
|
+
export const HIGHLIGHT_CSS = '.box { position: fixed; box-sizing: border-box; border: 2px solid rgb(26 115 232); background: rgb(26 115 232 / 0.12); border-radius: 2px; pointer-events: none; }';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A page's highlight: one set of elements boxed at a time.
|
|
19
|
+
*
|
|
20
|
+
* @typedef {object} Highlighter
|
|
21
|
+
* @property {(elements: Element[], options?: {reveal?: boolean}) => number} show Box elements (the
|
|
22
|
+
* ones still in the page, the first {@link MAX_BOXES}), clearing any boxed before; `reveal`
|
|
23
|
+
* scrolls the first into view. Returns how many are boxed.
|
|
24
|
+
* @property {() => void} clear Take the boxes away.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A highlighter for a page.
|
|
29
|
+
*
|
|
30
|
+
* @param {Document} doc - The page.
|
|
31
|
+
* @param {object} options
|
|
32
|
+
* @param {() => ParentNode} options.root - Where boxes go: the overlay's shadow root, made when first needed.
|
|
33
|
+
* @param {() => void} [options.onClear] - After the boxes go (take an empty overlay away).
|
|
34
|
+
* @param {number} [options.max] - The most elements boxed. Default {@link MAX_BOXES}.
|
|
35
|
+
* @returns {Highlighter} It.
|
|
36
|
+
* @example
|
|
37
|
+
* const highlight = createHighlighter(document, { root: () => layer(document), onClear: tidyLayer });
|
|
38
|
+
* highlight.show(elements, { reveal: true });
|
|
39
|
+
*/
|
|
40
|
+
export function createHighlighter(doc, { root, onClear = () => {}, max = MAX_BOXES }) {
|
|
41
|
+
const view = /** @type {Window} */ (doc.defaultView);
|
|
42
|
+
/** @type {{elements: Element[], boxes: HTMLElement[]} | null} */
|
|
43
|
+
let shown = null;
|
|
44
|
+
let pending = 0;
|
|
45
|
+
|
|
46
|
+
const draw = () => {
|
|
47
|
+
pending = 0;
|
|
48
|
+
if (!shown) return;
|
|
49
|
+
for (const box of shown.boxes) box.remove();
|
|
50
|
+
shown.boxes = shown.elements.flatMap((element) => [...element.getClientRects()].filter((rect) => rect.width && rect.height
|
|
51
|
+
&& rect.bottom > 0 && rect.top < view.innerHeight && rect.right > 0 && rect.left < view.innerWidth).map((rect) => {
|
|
52
|
+
const box = doc.createElement('div');
|
|
53
|
+
box.className = 'box';
|
|
54
|
+
Object.assign(box.style, { left: `${rect.left - 2}px`, top: `${rect.top - 2}px`, width: `${rect.width + 4}px`, height: `${rect.height + 4}px` });
|
|
55
|
+
return box;
|
|
56
|
+
}));
|
|
57
|
+
root().append(...shown.boxes);
|
|
58
|
+
};
|
|
59
|
+
const update = () => {
|
|
60
|
+
if (!pending) pending = view.requestAnimationFrame(draw);
|
|
61
|
+
};
|
|
62
|
+
/** @param {KeyboardEvent} event */
|
|
63
|
+
const key = (event) => {
|
|
64
|
+
if (event.key === 'Escape') clear();
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function clear() {
|
|
68
|
+
if (!shown) return;
|
|
69
|
+
view.removeEventListener('scroll', update, true);
|
|
70
|
+
view.removeEventListener('resize', update);
|
|
71
|
+
view.removeEventListener('keydown', key, true);
|
|
72
|
+
for (const box of shown.boxes) box.remove();
|
|
73
|
+
shown = null;
|
|
74
|
+
onClear();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** @type {Highlighter['show']} */
|
|
78
|
+
function show(elements, { reveal = false } = {}) {
|
|
79
|
+
clear();
|
|
80
|
+
const some = elements.filter((element) => element.isConnected).slice(0, max);
|
|
81
|
+
if (!some.length) return 0;
|
|
82
|
+
if (reveal) some[0].scrollIntoView({ block: 'center', inline: 'nearest' });
|
|
83
|
+
shown = { elements: some, boxes: [] };
|
|
84
|
+
view.addEventListener('scroll', update, { capture: true, passive: true });
|
|
85
|
+
view.addEventListener('resize', update, { passive: true });
|
|
86
|
+
view.addEventListener('keydown', key, true);
|
|
87
|
+
draw();
|
|
88
|
+
return some.length;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { show, clear };
|
|
92
|
+
}
|
package/src/content/overlay.d.ts
CHANGED
|
@@ -30,18 +30,28 @@ export type PageOverlay = {
|
|
|
30
30
|
*/
|
|
31
31
|
/**
|
|
32
32
|
* Put an overlay on the page, replacing one of the same name a run before left.
|
|
33
|
-
*
|
|
34
|
-
*
|
|
33
|
+
*
|
|
34
|
+
* The host's own styles are reset inline (`all: initial !important`), so the page's rules for
|
|
35
|
+
* unknown elements can't move or hide it. That reset also beats a plain `:host { … }` rule in
|
|
36
|
+
* `css` and an inline `host.style.x = …`: give the host's own styles as `hostStyle`, set inline
|
|
37
|
+
* with `!important` after the reset. `css` styles what's inside (and `:host` custom properties,
|
|
38
|
+
* which the reset doesn't touch).
|
|
35
39
|
*
|
|
36
40
|
* @param {Document} doc - The page.
|
|
37
|
-
* @param {{name: string, css: string}} options - `name`: the
|
|
38
|
-
* (never registered: a plain element); `css`: the overlay's
|
|
41
|
+
* @param {{name: string, css: string, hostStyle?: Record<string, string>}} options - `name`: the
|
|
42
|
+
* host's element name, with a hyphen (never registered: a plain element); `css`: the overlay's
|
|
43
|
+
* styles; `hostStyle`: the host's own styles by CSS property name (`'z-index'`), each `!important`.
|
|
39
44
|
* @returns {PageOverlay} The overlay.
|
|
40
45
|
* @example
|
|
41
|
-
* const { root, remove } = pageOverlay(document, {
|
|
46
|
+
* const { root, remove } = pageOverlay(document, {
|
|
47
|
+
* name: 'better-fonts-inspector',
|
|
48
|
+
* hostStyle: { position: 'fixed', inset: '0', 'z-index': '2147483647', 'pointer-events': 'none' },
|
|
49
|
+
* css: '.card { position: absolute; }',
|
|
50
|
+
* });
|
|
42
51
|
* root.append(card);
|
|
43
52
|
*/
|
|
44
|
-
export declare function pageOverlay(doc: Document, { name, css }: {
|
|
53
|
+
export declare function pageOverlay(doc: Document, { name, css, hostStyle }: {
|
|
45
54
|
name: string;
|
|
46
55
|
css: string;
|
|
56
|
+
hostStyle?: Record<string, string>;
|
|
47
57
|
}): PageOverlay;
|
package/src/content/overlay.js
CHANGED
|
@@ -18,21 +18,31 @@
|
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* Put an overlay on the page, replacing one of the same name a run before left.
|
|
21
|
-
*
|
|
22
|
-
*
|
|
21
|
+
*
|
|
22
|
+
* The host's own styles are reset inline (`all: initial !important`), so the page's rules for
|
|
23
|
+
* unknown elements can't move or hide it. That reset also beats a plain `:host { … }` rule in
|
|
24
|
+
* `css` and an inline `host.style.x = …`: give the host's own styles as `hostStyle`, set inline
|
|
25
|
+
* with `!important` after the reset. `css` styles what's inside (and `:host` custom properties,
|
|
26
|
+
* which the reset doesn't touch).
|
|
23
27
|
*
|
|
24
28
|
* @param {Document} doc - The page.
|
|
25
|
-
* @param {{name: string, css: string}} options - `name`: the
|
|
26
|
-
* (never registered: a plain element); `css`: the overlay's
|
|
29
|
+
* @param {{name: string, css: string, hostStyle?: Record<string, string>}} options - `name`: the
|
|
30
|
+
* host's element name, with a hyphen (never registered: a plain element); `css`: the overlay's
|
|
31
|
+
* styles; `hostStyle`: the host's own styles by CSS property name (`'z-index'`), each `!important`.
|
|
27
32
|
* @returns {PageOverlay} The overlay.
|
|
28
33
|
* @example
|
|
29
|
-
* const { root, remove } = pageOverlay(document, {
|
|
34
|
+
* const { root, remove } = pageOverlay(document, {
|
|
35
|
+
* name: 'better-fonts-inspector',
|
|
36
|
+
* hostStyle: { position: 'fixed', inset: '0', 'z-index': '2147483647', 'pointer-events': 'none' },
|
|
37
|
+
* css: '.card { position: absolute; }',
|
|
38
|
+
* });
|
|
30
39
|
* root.append(card);
|
|
31
40
|
*/
|
|
32
|
-
export function pageOverlay(doc, { name, css }) {
|
|
41
|
+
export function pageOverlay(doc, { name, css, hostStyle = {} }) {
|
|
33
42
|
for (const old of doc.querySelectorAll(name)) old.remove();
|
|
34
43
|
const host = doc.createElement(name);
|
|
35
44
|
host.style.setProperty('all', 'initial', 'important');
|
|
45
|
+
for (const [property, value] of Object.entries(hostStyle)) host.style.setProperty(property, value, 'important');
|
|
36
46
|
const root = host.attachShadow({ mode: 'closed' });
|
|
37
47
|
const style = doc.createElement('style');
|
|
38
48
|
style.textContent = css;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chrome's own confirmation in a web page: the dark pill with a check mark that Chrome shows at
|
|
3
|
+
* the bottom of the window for “Link copied”, for a content script that just did something the
|
|
4
|
+
* person asked for from a menu or a shortcut. In a closed shadow root (the kit's `pageOverlay`)
|
|
5
|
+
* the page can't style, as a popover in the top layer, over anything the page stacks; light on
|
|
6
|
+
* a dark system. One at a time: a new one replaces the last.
|
|
7
|
+
*
|
|
8
|
+
* @module content/page-toast
|
|
9
|
+
*/
|
|
10
|
+
/** How long it shows, in ms (as Chrome's “Link copied”). */
|
|
11
|
+
export declare const PAGE_TOAST_MS = 4000;
|
|
12
|
+
/**
|
|
13
|
+
* Show Chrome's confirmation toast in a page.
|
|
14
|
+
*
|
|
15
|
+
* @param {Document} doc - The page.
|
|
16
|
+
* @param {string} text - What it says (“Link copied”).
|
|
17
|
+
* @param {{name?: string, ms?: number}} [options] - `name`: the overlay's element name (the
|
|
18
|
+
* extension's own, with a hyphen); `ms`: how long it shows. Defaults `extension-toast`, {@link PAGE_TOAST_MS}.
|
|
19
|
+
* @returns {() => void} Takes it away now.
|
|
20
|
+
* @example
|
|
21
|
+
* showPageToast(document, chrome.i18n.getMessage('copied'), { name: 'better-copy-toast' });
|
|
22
|
+
*/
|
|
23
|
+
export declare function showPageToast(doc: Document, text: string, { name, ms }?: {
|
|
24
|
+
name?: string;
|
|
25
|
+
ms?: number;
|
|
26
|
+
}): () => void;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chrome's own confirmation in a web page: the dark pill with a check mark that Chrome shows at
|
|
3
|
+
* the bottom of the window for “Link copied”, for a content script that just did something the
|
|
4
|
+
* person asked for from a menu or a shortcut. In a closed shadow root (the kit's `pageOverlay`)
|
|
5
|
+
* the page can't style, as a popover in the top layer, over anything the page stacks; light on
|
|
6
|
+
* a dark system. One at a time: a new one replaces the last.
|
|
7
|
+
*
|
|
8
|
+
* @module content/page-toast
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { pageOverlay } from './overlay.js';
|
|
12
|
+
|
|
13
|
+
/** How long it shows, in ms (as Chrome's “Link copied”). */
|
|
14
|
+
export const PAGE_TOAST_MS = 4000;
|
|
15
|
+
|
|
16
|
+
/** The toast's look: Chrome's cr-toast colours, its 48px pill, the check mark. */
|
|
17
|
+
const CSS = `
|
|
18
|
+
.toast { position: fixed; inset: auto auto 24px 50%; margin: 0; transform: translateX(-50%); z-index: 2147483647;
|
|
19
|
+
display: flex; align-items: center; gap: 12px; min-height: 48px; max-width: calc(100vw - 48px);
|
|
20
|
+
box-sizing: border-box; padding: 0 20px 0 16px; border: 0; border-radius: 24px; overflow: hidden;
|
|
21
|
+
background: rgb(48 48 48); color: rgb(242 242 242); box-shadow: 0 2px 4px 0 rgb(0 0 0 / 0.28);
|
|
22
|
+
font: 13px/20px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; letter-spacing: normal; text-align: start;
|
|
23
|
+
animation: in 150ms ease-out; }
|
|
24
|
+
.toast:not(:popover-open)[popover] { display: none; }
|
|
25
|
+
.text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
26
|
+
svg { width: 20px; height: 20px; fill: currentColor; flex: none; }
|
|
27
|
+
@media (prefers-color-scheme: dark) { .toast { background: rgb(227 227 227); color: rgb(31 31 31); } }
|
|
28
|
+
@keyframes in { from { opacity: 0; translate: 0 8px; } }
|
|
29
|
+
@media (prefers-reduced-motion: reduce) { .toast { animation: none; } }`;
|
|
30
|
+
|
|
31
|
+
/** Chrome's check mark (Material “check”). */
|
|
32
|
+
const CHECK = 'M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Show Chrome's confirmation toast in a page.
|
|
36
|
+
*
|
|
37
|
+
* @param {Document} doc - The page.
|
|
38
|
+
* @param {string} text - What it says (“Link copied”).
|
|
39
|
+
* @param {{name?: string, ms?: number}} [options] - `name`: the overlay's element name (the
|
|
40
|
+
* extension's own, with a hyphen); `ms`: how long it shows. Defaults `extension-toast`, {@link PAGE_TOAST_MS}.
|
|
41
|
+
* @returns {() => void} Takes it away now.
|
|
42
|
+
* @example
|
|
43
|
+
* showPageToast(document, chrome.i18n.getMessage('copied'), { name: 'better-copy-toast' });
|
|
44
|
+
*/
|
|
45
|
+
export function showPageToast(doc, text, { name = 'extension-toast', ms = PAGE_TOAST_MS } = {}) {
|
|
46
|
+
const { root, remove } = pageOverlay(doc, { name, css: CSS });
|
|
47
|
+
const toast = doc.createElement('div');
|
|
48
|
+
toast.className = 'toast';
|
|
49
|
+
toast.setAttribute('role', 'status');
|
|
50
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
51
|
+
const svg = doc.createElementNS(NS, 'svg');
|
|
52
|
+
svg.setAttribute('viewBox', '0 0 24 24');
|
|
53
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
54
|
+
const path = doc.createElementNS(NS, 'path');
|
|
55
|
+
path.setAttribute('d', CHECK);
|
|
56
|
+
svg.append(path);
|
|
57
|
+
const words = doc.createElement('span');
|
|
58
|
+
words.className = 'text';
|
|
59
|
+
words.textContent = text;
|
|
60
|
+
toast.append(svg, words);
|
|
61
|
+
root.append(toast);
|
|
62
|
+
// The top layer, over the page's own fixed bars and open dialogs, where popovers exist.
|
|
63
|
+
if (typeof toast.showPopover === 'function') {
|
|
64
|
+
toast.popover = 'manual';
|
|
65
|
+
toast.showPopover();
|
|
66
|
+
}
|
|
67
|
+
const timer = setTimeout(remove, ms);
|
|
68
|
+
return () => {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
remove();
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copying in several formats at once: plain text, and HTML for apps that paste it rich (a link
|
|
3
|
+
* with its title in Google Docs, Slack or Notion), from a page, a popup or a content script.
|
|
4
|
+
* Says whether it worked, so a caller can offer another way when the page won't let it.
|
|
5
|
+
*
|
|
6
|
+
* Chrome lets the focused page write without a click (it grants clipboard-write to the active
|
|
7
|
+
* tab), so a content script can copy from a context-menu click or a keyboard shortcut; a page
|
|
8
|
+
* served over plain HTTP has no clipboard API, and one with `Permissions-Policy:
|
|
9
|
+
* clipboard-write=()` refuses it: there the copy command is tried, which needs a click in the page.
|
|
10
|
+
*
|
|
11
|
+
* @module dom/copy
|
|
12
|
+
*/
|
|
13
|
+
export type ClipboardFormats = {
|
|
14
|
+
/**
|
|
15
|
+
* Plain text, for every app.
|
|
16
|
+
*/
|
|
17
|
+
text: string;
|
|
18
|
+
/**
|
|
19
|
+
* HTML, for apps that paste formatted text.
|
|
20
|
+
*/
|
|
21
|
+
html?: string;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Put text, and HTML when given, on the clipboard: the clipboard API (one item holding both
|
|
25
|
+
* formats), else the copy command.
|
|
26
|
+
*
|
|
27
|
+
* @param {Document} doc - The page, popup or content script's document.
|
|
28
|
+
* @param {ClipboardFormats} formats - Plain text, and HTML for rich pasting.
|
|
29
|
+
* @returns {Promise<boolean>} Whether it's on the clipboard.
|
|
30
|
+
* @example
|
|
31
|
+
* const link = document.createElement('a');
|
|
32
|
+
* link.href = url;
|
|
33
|
+
* link.textContent = title;
|
|
34
|
+
* if (!(await copyFormats(document, { text: `${title} – ${url}`, html: link.outerHTML }))) offerCopyButton();
|
|
35
|
+
*/
|
|
36
|
+
export declare function copyFormats(doc: Document, { text, html }: ClipboardFormats): Promise<boolean>;
|
package/src/dom/copy.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copying in several formats at once: plain text, and HTML for apps that paste it rich (a link
|
|
3
|
+
* with its title in Google Docs, Slack or Notion), from a page, a popup or a content script.
|
|
4
|
+
* Says whether it worked, so a caller can offer another way when the page won't let it.
|
|
5
|
+
*
|
|
6
|
+
* Chrome lets the focused page write without a click (it grants clipboard-write to the active
|
|
7
|
+
* tab), so a content script can copy from a context-menu click or a keyboard shortcut; a page
|
|
8
|
+
* served over plain HTTP has no clipboard API, and one with `Permissions-Policy:
|
|
9
|
+
* clipboard-write=()` refuses it: there the copy command is tried, which needs a click in the page.
|
|
10
|
+
*
|
|
11
|
+
* @module dom/copy
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What to put on the clipboard.
|
|
16
|
+
*
|
|
17
|
+
* @typedef {object} ClipboardFormats
|
|
18
|
+
* @property {string} text Plain text, for every app.
|
|
19
|
+
* @property {string} [html] HTML, for apps that paste formatted text.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The copy command, with a copy listener that writes the formats: it runs first (capture, on the
|
|
24
|
+
* window) and stops the page's own listeners from changing what's copied.
|
|
25
|
+
*
|
|
26
|
+
* @param {Document} doc - The page.
|
|
27
|
+
* @param {ClipboardFormats} formats - What to copy.
|
|
28
|
+
* @returns {boolean} Whether the command ran and wrote them.
|
|
29
|
+
*/
|
|
30
|
+
function copyCommand(doc, { text, html }) {
|
|
31
|
+
const view = doc.defaultView;
|
|
32
|
+
if (!view) return false;
|
|
33
|
+
let wrote = false;
|
|
34
|
+
/** @param {ClipboardEvent} event */
|
|
35
|
+
const onCopy = (event) => {
|
|
36
|
+
if (!event.clipboardData) return;
|
|
37
|
+
event.clipboardData.setData('text/plain', text);
|
|
38
|
+
if (html !== undefined) event.clipboardData.setData('text/html', html);
|
|
39
|
+
event.preventDefault();
|
|
40
|
+
event.stopImmediatePropagation();
|
|
41
|
+
wrote = true;
|
|
42
|
+
};
|
|
43
|
+
view.addEventListener('copy', onCopy, true);
|
|
44
|
+
try {
|
|
45
|
+
return doc.execCommand('copy') && wrote;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
} finally {
|
|
49
|
+
view.removeEventListener('copy', onCopy, true);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Put text, and HTML when given, on the clipboard: the clipboard API (one item holding both
|
|
55
|
+
* formats), else the copy command.
|
|
56
|
+
*
|
|
57
|
+
* @param {Document} doc - The page, popup or content script's document.
|
|
58
|
+
* @param {ClipboardFormats} formats - Plain text, and HTML for rich pasting.
|
|
59
|
+
* @returns {Promise<boolean>} Whether it's on the clipboard.
|
|
60
|
+
* @example
|
|
61
|
+
* const link = document.createElement('a');
|
|
62
|
+
* link.href = url;
|
|
63
|
+
* link.textContent = title;
|
|
64
|
+
* if (!(await copyFormats(document, { text: `${title} – ${url}`, html: link.outerHTML }))) offerCopyButton();
|
|
65
|
+
*/
|
|
66
|
+
export async function copyFormats(doc, { text, html }) {
|
|
67
|
+
const view = doc.defaultView;
|
|
68
|
+
const clipboard = view?.navigator.clipboard;
|
|
69
|
+
if (view && clipboard) {
|
|
70
|
+
try {
|
|
71
|
+
if (html === undefined) await clipboard.writeText(text);
|
|
72
|
+
else {
|
|
73
|
+
await clipboard.write([new view.ClipboardItem({
|
|
74
|
+
'text/plain': new view.Blob([text], { type: 'text/plain' }),
|
|
75
|
+
'text/html': new view.Blob([html], { type: 'text/html' }),
|
|
76
|
+
})]);
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
} catch {
|
|
80
|
+
// No focus, or the page's permissions policy: the copy command below.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return copyCommand(doc, { text, html });
|
|
84
|
+
}
|
package/src/dom/i18n.d.ts
CHANGED
|
@@ -45,3 +45,20 @@ export declare function translator(i18n: Pick<I18nApi, 'getMessage'>): (key: str
|
|
|
45
45
|
* @returns {void}
|
|
46
46
|
*/
|
|
47
47
|
export declare function localize(root: ParentNode, i18n: I18nApi): void;
|
|
48
|
+
/**
|
|
49
|
+
* A count in words, its number written as the person's language writes numbers: one message for
|
|
50
|
+
* one, another (with the number as `$1`) for any other count.
|
|
51
|
+
*
|
|
52
|
+
* @param {number} count - How many.
|
|
53
|
+
* @param {string} one - The message for one (`"1 colour"`).
|
|
54
|
+
* @param {string} many - The message for others (`"$COUNT$ colours"`, its placeholder `$1`).
|
|
55
|
+
* @param {{t: (key: string, substitutions?: string | string[]) => string, lang: string}} words - The
|
|
56
|
+
* page's message lookup and the UI language.
|
|
57
|
+
* @returns {string} The words.
|
|
58
|
+
* @example
|
|
59
|
+
* counted(1200, 'panelColoursOne', 'panelColours', { t, lang: 'en-GB' }); // '1,200 colours'
|
|
60
|
+
*/
|
|
61
|
+
export declare const counted: (count: number, one: string, many: string, { t, lang }: {
|
|
62
|
+
t: (key: string, substitutions?: string | string[]) => string;
|
|
63
|
+
lang: string;
|
|
64
|
+
}) => string;
|
package/src/dom/i18n.js
CHANGED
|
@@ -60,3 +60,18 @@ export function localize(root, i18n) {
|
|
|
60
60
|
}
|
|
61
61
|
if ('documentElement' in root) /** @type {Document} */ (root).documentElement.lang = i18n.getUILanguage();
|
|
62
62
|
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A count in words, its number written as the person's language writes numbers: one message for
|
|
66
|
+
* one, another (with the number as `$1`) for any other count.
|
|
67
|
+
*
|
|
68
|
+
* @param {number} count - How many.
|
|
69
|
+
* @param {string} one - The message for one (`"1 colour"`).
|
|
70
|
+
* @param {string} many - The message for others (`"$COUNT$ colours"`, its placeholder `$1`).
|
|
71
|
+
* @param {{t: (key: string, substitutions?: string | string[]) => string, lang: string}} words - The
|
|
72
|
+
* page's message lookup and the UI language.
|
|
73
|
+
* @returns {string} The words.
|
|
74
|
+
* @example
|
|
75
|
+
* counted(1200, 'panelColoursOne', 'panelColours', { t, lang: 'en-GB' }); // '1,200 colours'
|
|
76
|
+
*/
|
|
77
|
+
export const counted = (count, one, many, { t, lang }) => (count === 1 ? t(one) : t(many, [count.toLocaleString(lang)]));
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Making a page's elements in a line: an element with its class and text, an
|
|
3
|
+
* icon from the page's SVG sprite, and Chrome's 24px icon button with its
|
|
4
|
+
* tooltip. For extension pages and content-script overlays alike (pass the
|
|
5
|
+
* document the element belongs to).
|
|
6
|
+
*
|
|
7
|
+
* @module dom/make
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* An element with a class and text.
|
|
11
|
+
*
|
|
12
|
+
* @template {keyof HTMLElementTagNameMap} K
|
|
13
|
+
* @param {K} tag - Its tag.
|
|
14
|
+
* @param {string} [className] - Its class.
|
|
15
|
+
* @param {string} [text] - Its text.
|
|
16
|
+
* @param {Document} [doc] - Its document. Default `document`.
|
|
17
|
+
* @returns {HTMLElementTagNameMap[K]} It.
|
|
18
|
+
* @example
|
|
19
|
+
* make('span', 'meta', '12 colours');
|
|
20
|
+
*/
|
|
21
|
+
export declare function make<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, text?: string, doc?: Document): HTMLElementTagNameMap[K];
|
|
22
|
+
/**
|
|
23
|
+
* An icon from the page's SVG sprite (`<symbol id="i-copy">`), hidden from screen readers.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} id - The symbol's id, without `#`.
|
|
26
|
+
* @param {string} [className] - More classes beside `icon`.
|
|
27
|
+
* @param {Document} [doc] - Its document. Default `document`.
|
|
28
|
+
* @returns {SVGSVGElement} The icon.
|
|
29
|
+
* @example
|
|
30
|
+
* button.append(spriteIcon('i-copy'));
|
|
31
|
+
*/
|
|
32
|
+
export declare function spriteIcon(id: string, className?: string, doc?: Document): SVGSVGElement;
|
|
33
|
+
/**
|
|
34
|
+
* Chrome's icon button (chrome-ui.css `.icon-button`): an icon, its label as tooltip and for
|
|
35
|
+
* screen readers.
|
|
36
|
+
*
|
|
37
|
+
* @param {object} options
|
|
38
|
+
* @param {string} options.icon - The sprite symbol's id.
|
|
39
|
+
* @param {string} options.label - What it does.
|
|
40
|
+
* @param {() => void} options.onClick - Doing it.
|
|
41
|
+
* @param {string} [options.className] - More classes.
|
|
42
|
+
* @param {Document} [options.doc] - Its document. Default `document`.
|
|
43
|
+
* @returns {HTMLButtonElement} The button.
|
|
44
|
+
* @example
|
|
45
|
+
* iconButton({ icon: 'i-copy', label: 'Copy', onClick: () => copy(hex) });
|
|
46
|
+
*/
|
|
47
|
+
export declare function iconButton({ icon, label, onClick, className, doc }: {
|
|
48
|
+
icon: string;
|
|
49
|
+
label: string;
|
|
50
|
+
onClick: () => void;
|
|
51
|
+
className?: string;
|
|
52
|
+
doc?: Document;
|
|
53
|
+
}): HTMLButtonElement;
|