@arraypress/extension-kit 1.9.0 → 1.11.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 +145 -18
- package/bin/extension-kit.js +2 -1
- package/package.json +1 -1
- package/src/ai/host.d.ts +183 -0
- package/src/ai/host.js +225 -0
- package/src/ai/model.d.ts +2 -1
- package/src/ai/model.js +10 -2
- 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/build/vendor.d.ts +29 -8
- package/src/build/vendor.js +136 -40
- package/src/content/elements.d.ts +50 -0
- package/src/content/elements.js +88 -0
- package/src/content/highlight.d.ts +63 -0
- package/src/content/highlight.js +117 -0
- package/src/content/overlay.d.ts +31 -6
- package/src/content/overlay.js +56 -8
- package/src/content/page-toast.d.ts +26 -0
- package/src/content/page-toast.js +72 -0
- package/src/content/slices.d.ts +3 -1
- package/src/content/slices.js +29 -14
- package/src/content/watch.d.ts +4 -2
- package/src/content/watch.js +17 -3
- package/src/dom/copy.d.ts +36 -0
- package/src/dom/copy.js +84 -0
- package/src/dom/focus.d.ts +11 -0
- package/src/dom/focus.js +15 -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 +8 -1
- package/src/frame/window.js +11 -8
- package/src/index.d.ts +31 -6
- package/src/index.js +24 -6
- package/src/panel/show-on-page.d.ts +82 -0
- package/src/panel/show-on-page.js +83 -0
- package/src/scripting/call.d.ts +29 -9
- package/src/scripting/call.js +35 -11
- package/src/scripting/context-menus.d.ts +45 -0
- package/src/scripting/context-menus.js +53 -0
- package/src/scripting/registration.d.ts +2 -1
- package/src/scripting/registration.js +2 -1
- package/src/scripting/tab.d.ts +80 -0
- package/src/scripting/tab.js +89 -0
- package/src/settings/saver.js +16 -6
- package/src/settings/storage.d.ts +33 -2
- package/src/settings/storage.js +73 -3
- package/src/settings/store.d.ts +17 -0
- package/src/settings/store.js +87 -20
- package/src/sites/sites.d.ts +6 -1
- package/src/sites/sites.js +20 -3
- package/src/styles/chrome-ui.css +63 -30
- package/src/testing/e2e.d.ts +35 -4
- package/src/testing/e2e.js +67 -5
- package/src/testing/fake-model.d.ts +9 -1
- package/src/testing/fake-model.js +44 -23
- package/src/testing/fake-storage.d.ts +41 -13
- package/src/testing/fake-storage.js +163 -51
- package/src/testing/index.d.ts +8 -2
- package/src/testing/index.js +8 -2
- package/src/testing/live-run.d.ts +151 -0
- package/src/testing/live-run.js +206 -0
- package/src/testing/messages.d.ts +7 -3
- package/src/testing/messages.js +50 -9
- package/src/testing/package.d.ts +56 -7
- package/src/testing/package.js +57 -18
- package/src/testing/pages.d.ts +114 -0
- package/src/testing/pages.js +109 -0
- package/src/testing/prompt.d.ts +4 -2
- package/src/testing/prompt.js +7 -3
- package/src/time/pool.d.ts +2 -1
- package/src/time/pool.js +4 -2
- package/src/toolbar/theme.d.ts +10 -2
- package/src/toolbar/theme.js +12 -4
- package/src/ui/menu.d.ts +13 -3
- package/src/ui/menu.js +31 -11
- package/src/ui/model-row.js +5 -1
- package/src/ui/notice.d.ts +58 -0
- package/src/ui/notice.js +43 -0
- package/src/ui/site-list.js +12 -3
- package/src/ui/text-dialog.d.ts +3 -2
- package/src/ui/text-dialog.js +10 -3
- package/src/ui/toast.js +2 -0
|
@@ -0,0 +1,117 @@
|
|
|
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, Esc, or the page being left) and
|
|
5
|
+
* `onClear` is told which, so a panel's pressed button can follow an Esc on
|
|
6
|
+
* the page. The first can be scrolled into view. The boxes go in the extension's own overlay (`pageOverlay`), so
|
|
7
|
+
* the page's styles can't touch them.
|
|
8
|
+
*
|
|
9
|
+
* @module content/highlight
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The most elements boxed at once. */
|
|
13
|
+
export const MAX_BOXES = 500;
|
|
14
|
+
|
|
15
|
+
/** The boxes' styles, for the overlay's stylesheet. @type {string} */
|
|
16
|
+
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; }';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A page's highlight: one set of elements boxed at a time.
|
|
20
|
+
*
|
|
21
|
+
* @typedef {object} Highlighter
|
|
22
|
+
* @property {(elements: Element[], options?: {reveal?: boolean}) => number} show Box elements (the
|
|
23
|
+
* ones still in the page, the first {@link MAX_BOXES}), clearing any boxed before; `reveal`
|
|
24
|
+
* scrolls the first into view. Returns how many are boxed.
|
|
25
|
+
* @property {() => void} clear Take the boxes away (`onClear('asked')`).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Why the boxes went: Esc on the page, another set shown in their place, `clear()` called, or
|
|
30
|
+
* the page left (`pagehide`: a page kept for Back would come back with stale boxes).
|
|
31
|
+
*
|
|
32
|
+
* @typedef {'escape' | 'replaced' | 'asked' | 'pagehide'} ClearReason
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A highlighter for a page.
|
|
37
|
+
*
|
|
38
|
+
* @param {Document} doc - The page.
|
|
39
|
+
* @param {object} options
|
|
40
|
+
* @param {() => ParentNode} options.root - Where boxes go: the overlay's shadow root, made when first needed.
|
|
41
|
+
* @param {(reason: ClearReason) => void} [options.onClear] - After the boxes go, and why (take an empty
|
|
42
|
+
* overlay away; tell the panel when it's `'escape'` or `'pagehide'`, which it didn't ask for).
|
|
43
|
+
* @param {number} [options.max] - The most elements boxed. Default {@link MAX_BOXES}.
|
|
44
|
+
* @returns {Highlighter} It.
|
|
45
|
+
* @example
|
|
46
|
+
* const highlight = createHighlighter(document, { root: () => layer(document), onClear: tidyLayer });
|
|
47
|
+
* highlight.show(elements, { reveal: true });
|
|
48
|
+
*/
|
|
49
|
+
export function createHighlighter(doc, { root, onClear = () => {}, max = MAX_BOXES }) {
|
|
50
|
+
const view = /** @type {Window} */ (doc.defaultView);
|
|
51
|
+
/** @type {{elements: Element[], boxes: HTMLElement[]} | null} */
|
|
52
|
+
let shown = null;
|
|
53
|
+
let pending = 0;
|
|
54
|
+
|
|
55
|
+
/** Put a box on each rectangle in the window, reusing the boxes there are: nothing is made or removed while scrolling. */
|
|
56
|
+
const draw = () => {
|
|
57
|
+
pending = 0;
|
|
58
|
+
if (!shown) return;
|
|
59
|
+
const rects = shown.elements.flatMap((element) => [...element.getClientRects()].filter((rect) => rect.width && rect.height
|
|
60
|
+
&& rect.bottom > 0 && rect.top < view.innerHeight && rect.right > 0 && rect.left < view.innerWidth));
|
|
61
|
+
const { boxes } = shown;
|
|
62
|
+
for (const box of boxes.splice(rects.length)) box.remove();
|
|
63
|
+
/** @type {HTMLElement[]} */
|
|
64
|
+
const added = [];
|
|
65
|
+
rects.forEach((rect, index) => {
|
|
66
|
+
let box = boxes[index];
|
|
67
|
+
if (!box) {
|
|
68
|
+
box = doc.createElement('div');
|
|
69
|
+
box.className = 'box';
|
|
70
|
+
boxes.push(box);
|
|
71
|
+
added.push(box);
|
|
72
|
+
}
|
|
73
|
+
Object.assign(box.style, { left: `${rect.left - 2}px`, top: `${rect.top - 2}px`, width: `${rect.width + 4}px`, height: `${rect.height + 4}px` });
|
|
74
|
+
});
|
|
75
|
+
if (added.length) root().append(...added);
|
|
76
|
+
};
|
|
77
|
+
const update = () => {
|
|
78
|
+
if (!pending) pending = view.requestAnimationFrame(draw);
|
|
79
|
+
};
|
|
80
|
+
/** @param {KeyboardEvent} event */
|
|
81
|
+
const key = (event) => {
|
|
82
|
+
if (event.key === 'Escape') end('escape');
|
|
83
|
+
};
|
|
84
|
+
const left = () => end('pagehide');
|
|
85
|
+
|
|
86
|
+
/** @param {ClearReason} reason */
|
|
87
|
+
function end(reason) {
|
|
88
|
+
if (!shown) return;
|
|
89
|
+
view.removeEventListener('scroll', update, true);
|
|
90
|
+
view.removeEventListener('resize', update);
|
|
91
|
+
view.removeEventListener('keydown', key, true);
|
|
92
|
+
view.removeEventListener('pagehide', left);
|
|
93
|
+
if (pending) view.cancelAnimationFrame(pending);
|
|
94
|
+
pending = 0;
|
|
95
|
+
for (const box of shown.boxes) box.remove();
|
|
96
|
+
shown = null;
|
|
97
|
+
onClear(reason);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** @type {Highlighter['show']} */
|
|
101
|
+
function show(elements, { reveal = false } = {}) {
|
|
102
|
+
end('replaced');
|
|
103
|
+
const some = elements.filter((element) => element.isConnected).slice(0, max);
|
|
104
|
+
if (!some.length) return 0;
|
|
105
|
+
if (reveal) some[0].scrollIntoView({ block: 'center', inline: 'nearest' });
|
|
106
|
+
shown = { elements: some, boxes: [] };
|
|
107
|
+
view.addEventListener('scroll', update, { capture: true, passive: true });
|
|
108
|
+
view.addEventListener('resize', update, { passive: true });
|
|
109
|
+
view.addEventListener('keydown', key, true);
|
|
110
|
+
view.addEventListener('pagehide', left);
|
|
111
|
+
draw();
|
|
112
|
+
return some.length;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { show, clear: () => end('asked') };
|
|
116
|
+
|
|
117
|
+
}
|
package/src/content/overlay.d.ts
CHANGED
|
@@ -19,6 +19,10 @@ export type PageOverlay = {
|
|
|
19
19
|
* Take it off the page.
|
|
20
20
|
*/
|
|
21
21
|
remove: () => void;
|
|
22
|
+
/**
|
|
23
|
+
* With `topLayer`: put it over whatever entered the top layer since.
|
|
24
|
+
*/
|
|
25
|
+
raise: () => void;
|
|
22
26
|
};
|
|
23
27
|
/**
|
|
24
28
|
* A page overlay.
|
|
@@ -27,21 +31,42 @@ export type PageOverlay = {
|
|
|
27
31
|
* @property {HTMLElement} host The host element, on the page's root element.
|
|
28
32
|
* @property {ShadowRoot} root Its closed shadow root, the styles first: append to it.
|
|
29
33
|
* @property {() => void} remove Take it off the page.
|
|
34
|
+
* @property {() => void} raise With `topLayer`: put it over whatever entered the top layer since.
|
|
30
35
|
*/
|
|
31
36
|
/**
|
|
32
37
|
* Put an overlay on the page, replacing one of the same name a run before left.
|
|
33
|
-
*
|
|
34
|
-
*
|
|
38
|
+
*
|
|
39
|
+
* The host's own styles are reset inline (`all: initial !important`), so the page's rules for
|
|
40
|
+
* unknown elements can't move or hide it. That reset also beats a plain `:host { … }` rule in
|
|
41
|
+
* `css` and an inline `host.style.x = …`: give the host's own styles as `hostStyle`, set inline
|
|
42
|
+
* with `!important` after the reset. `css` styles what's inside (and `:host` custom properties,
|
|
43
|
+
* which the reset doesn't touch).
|
|
44
|
+
*
|
|
45
|
+
* With `topLayer` the host is a manual popover, shown: it draws in the top layer, over the page's
|
|
46
|
+
* modal dialogs, popovers and fullscreen element, which no `z-index` reaches. The top layer
|
|
47
|
+
* paints in the order things entered it, so the overlay goes back on top (`raise`) whenever the
|
|
48
|
+
* page opens a dialog or a popover or goes fullscreen after it. What a modal dialog makes inert
|
|
49
|
+
* stays inert, the overlay with it: it shows over the dialog, but can't be clicked while the
|
|
50
|
+
* dialog is open. The popover's own backdrop is taken away (first in the overlay's styles), so a
|
|
51
|
+
* page's `::backdrop` rule can't tint the window through it.
|
|
35
52
|
*
|
|
36
53
|
* @param {Document} doc - The page.
|
|
37
|
-
* @param {{name: string, css: string
|
|
38
|
-
* (never registered: a plain element); `css`: the
|
|
54
|
+
* @param {{name: string, css: string, hostStyle?: Record<string, string>, topLayer?: boolean}} options -
|
|
55
|
+
* `name`: the host's element name, with a hyphen (never registered: a plain element); `css`: the
|
|
56
|
+
* overlay's styles; `hostStyle`: the host's own styles by CSS property name (`'z-index'`), each
|
|
57
|
+
* `!important`; `topLayer`: draw over the page's dialogs and fullscreen element. Default false.
|
|
39
58
|
* @returns {PageOverlay} The overlay.
|
|
40
59
|
* @example
|
|
41
|
-
* const { root, remove } = pageOverlay(document, {
|
|
60
|
+
* const { root, remove } = pageOverlay(document, {
|
|
61
|
+
* name: 'better-fonts-inspector',
|
|
62
|
+
* hostStyle: { position: 'fixed', inset: '0', 'z-index': '2147483647', 'pointer-events': 'none' },
|
|
63
|
+
* css: '.card { position: absolute; }',
|
|
64
|
+
* });
|
|
42
65
|
* root.append(card);
|
|
43
66
|
*/
|
|
44
|
-
export declare function pageOverlay(doc: Document, { name, css }: {
|
|
67
|
+
export declare function pageOverlay(doc: Document, { name, css, hostStyle, topLayer }: {
|
|
45
68
|
name: string;
|
|
46
69
|
css: string;
|
|
70
|
+
hostStyle?: Record<string, string>;
|
|
71
|
+
topLayer?: boolean;
|
|
47
72
|
}): PageOverlay;
|
package/src/content/overlay.js
CHANGED
|
@@ -14,29 +14,77 @@
|
|
|
14
14
|
* @property {HTMLElement} host The host element, on the page's root element.
|
|
15
15
|
* @property {ShadowRoot} root Its closed shadow root, the styles first: append to it.
|
|
16
16
|
* @property {() => void} remove Take it off the page.
|
|
17
|
+
* @property {() => void} raise With `topLayer`: put it over whatever entered the top layer since.
|
|
17
18
|
*/
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Put an overlay on the page, replacing one of the same name a run before left.
|
|
21
|
-
*
|
|
22
|
-
*
|
|
22
|
+
*
|
|
23
|
+
* The host's own styles are reset inline (`all: initial !important`), so the page's rules for
|
|
24
|
+
* unknown elements can't move or hide it. That reset also beats a plain `:host { … }` rule in
|
|
25
|
+
* `css` and an inline `host.style.x = …`: give the host's own styles as `hostStyle`, set inline
|
|
26
|
+
* with `!important` after the reset. `css` styles what's inside (and `:host` custom properties,
|
|
27
|
+
* which the reset doesn't touch).
|
|
28
|
+
*
|
|
29
|
+
* With `topLayer` the host is a manual popover, shown: it draws in the top layer, over the page's
|
|
30
|
+
* modal dialogs, popovers and fullscreen element, which no `z-index` reaches. The top layer
|
|
31
|
+
* paints in the order things entered it, so the overlay goes back on top (`raise`) whenever the
|
|
32
|
+
* page opens a dialog or a popover or goes fullscreen after it. What a modal dialog makes inert
|
|
33
|
+
* stays inert, the overlay with it: it shows over the dialog, but can't be clicked while the
|
|
34
|
+
* dialog is open. The popover's own backdrop is taken away (first in the overlay's styles), so a
|
|
35
|
+
* page's `::backdrop` rule can't tint the window through it.
|
|
23
36
|
*
|
|
24
37
|
* @param {Document} doc - The page.
|
|
25
|
-
* @param {{name: string, css: string
|
|
26
|
-
* (never registered: a plain element); `css`: the
|
|
38
|
+
* @param {{name: string, css: string, hostStyle?: Record<string, string>, topLayer?: boolean}} options -
|
|
39
|
+
* `name`: the host's element name, with a hyphen (never registered: a plain element); `css`: the
|
|
40
|
+
* overlay's styles; `hostStyle`: the host's own styles by CSS property name (`'z-index'`), each
|
|
41
|
+
* `!important`; `topLayer`: draw over the page's dialogs and fullscreen element. Default false.
|
|
27
42
|
* @returns {PageOverlay} The overlay.
|
|
28
43
|
* @example
|
|
29
|
-
* const { root, remove } = pageOverlay(document, {
|
|
44
|
+
* const { root, remove } = pageOverlay(document, {
|
|
45
|
+
* name: 'better-fonts-inspector',
|
|
46
|
+
* hostStyle: { position: 'fixed', inset: '0', 'z-index': '2147483647', 'pointer-events': 'none' },
|
|
47
|
+
* css: '.card { position: absolute; }',
|
|
48
|
+
* });
|
|
30
49
|
* root.append(card);
|
|
31
50
|
*/
|
|
32
|
-
export function pageOverlay(doc, { name, css }) {
|
|
51
|
+
export function pageOverlay(doc, { name, css, hostStyle = {}, topLayer = false }) {
|
|
33
52
|
for (const old of doc.querySelectorAll(name)) old.remove();
|
|
34
53
|
const host = doc.createElement(name);
|
|
35
54
|
host.style.setProperty('all', 'initial', 'important');
|
|
55
|
+
for (const [property, value] of Object.entries(hostStyle)) host.style.setProperty(property, value, 'important');
|
|
36
56
|
const root = host.attachShadow({ mode: 'closed' });
|
|
37
57
|
const style = doc.createElement('style');
|
|
38
|
-
|
|
58
|
+
// A shown popover has a backdrop over the whole window, which a page's bare `::backdrop { … }` rule would tint.
|
|
59
|
+
style.textContent = topLayer ? `:host::backdrop { display: none !important; }\n${css}` : css;
|
|
39
60
|
root.append(style);
|
|
40
61
|
doc.documentElement.append(host);
|
|
41
|
-
return { host, root, remove: () => host.remove() };
|
|
62
|
+
if (!topLayer || typeof host.showPopover !== 'function') return { host, root, remove: () => host.remove(), raise: () => {} };
|
|
63
|
+
|
|
64
|
+
host.popover = 'manual';
|
|
65
|
+
const raise = () => {
|
|
66
|
+
if (!host.isConnected) return;
|
|
67
|
+
host.hidePopover();
|
|
68
|
+
host.showPopover();
|
|
69
|
+
};
|
|
70
|
+
/** Something of the page's entered the top layer: a dialog or popover opened, or the page went fullscreen. */
|
|
71
|
+
const entered = (/** @type {Event} */ event) => {
|
|
72
|
+
if (event.target === host) return;
|
|
73
|
+
if (event.type === 'toggle' && /** @type {ToggleEvent} */ (event).newState !== 'open') return;
|
|
74
|
+
raise();
|
|
75
|
+
};
|
|
76
|
+
const view = doc.defaultView;
|
|
77
|
+
view?.addEventListener('toggle', entered, true);
|
|
78
|
+
view?.addEventListener('fullscreenchange', entered, true);
|
|
79
|
+
host.showPopover();
|
|
80
|
+
return {
|
|
81
|
+
host,
|
|
82
|
+
root,
|
|
83
|
+
raise,
|
|
84
|
+
remove() {
|
|
85
|
+
view?.removeEventListener('toggle', entered, true);
|
|
86
|
+
view?.removeEventListener('fullscreenchange', entered, true);
|
|
87
|
+
host.remove();
|
|
88
|
+
},
|
|
89
|
+
};
|
|
42
90
|
}
|
|
@@ -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
|
+
}
|
package/src/content/slices.d.ts
CHANGED
|
@@ -14,7 +14,9 @@ export declare const BLOCK_CHARS = 4000;
|
|
|
14
14
|
export declare const SLICE_MS = 40;
|
|
15
15
|
/**
|
|
16
16
|
* The blocks of a part of the page: itself if small enough, else its
|
|
17
|
-
* children's blocks (its own text nodes as blocks of their own).
|
|
17
|
+
* children's blocks (its own text nodes as blocks of their own). Scripts,
|
|
18
|
+
* styles and templates inside a part that's cut are left out: their text
|
|
19
|
+
* isn't the page's.
|
|
18
20
|
*
|
|
19
21
|
* @param {Node} root - The part.
|
|
20
22
|
* @param {number} [blockChars] - The most text a block holds. Default {@link BLOCK_CHARS}.
|
package/src/content/slices.js
CHANGED
|
@@ -15,9 +15,35 @@ export const BLOCK_CHARS = 4000;
|
|
|
15
15
|
/** How long one slice may run before the page gets the main thread back (ms), by default. Shorter slices cost more layout in all. */
|
|
16
16
|
export const SLICE_MS = 40;
|
|
17
17
|
|
|
18
|
+
/** Elements whose text is never the page's content: not cut into, nor handed over as blocks. */
|
|
19
|
+
const NOT_CONTENT = new Set(['script', 'style', 'noscript', 'template']);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The blocks of a part of the page, one at a time as they're asked for: the cutting (each
|
|
23
|
+
* level's text measured) is spread over the slices with the work, not done whole before the
|
|
24
|
+
* first. A level's children are listed when it's reached, so what the work adds beside a block
|
|
25
|
+
* isn't handed back to it.
|
|
26
|
+
*
|
|
27
|
+
* @param {Node} node - The part.
|
|
28
|
+
* @param {number} blockChars - The most text a block holds.
|
|
29
|
+
* @returns {Generator<Node, void, void>} The blocks, in page order.
|
|
30
|
+
*/
|
|
31
|
+
function* cut(node, blockChars) {
|
|
32
|
+
if (node.nodeType !== 1 || (node.textContent ?? '').length <= blockChars || !node.firstChild) {
|
|
33
|
+
yield node;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
for (const child of [...node.childNodes]) {
|
|
37
|
+
if (child.nodeType === 1 && NOT_CONTENT.has(/** @type {Element} */ (child).localName)) continue;
|
|
38
|
+
yield* cut(child, blockChars);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
18
42
|
/**
|
|
19
43
|
* The blocks of a part of the page: itself if small enough, else its
|
|
20
|
-
* children's blocks (its own text nodes as blocks of their own).
|
|
44
|
+
* children's blocks (its own text nodes as blocks of their own). Scripts,
|
|
45
|
+
* styles and templates inside a part that's cut are left out: their text
|
|
46
|
+
* isn't the page's.
|
|
21
47
|
*
|
|
22
48
|
* @param {Node} root - The part.
|
|
23
49
|
* @param {number} [blockChars] - The most text a block holds. Default {@link BLOCK_CHARS}.
|
|
@@ -26,18 +52,7 @@ export const SLICE_MS = 40;
|
|
|
26
52
|
* blocksOf(document.body).length; // 1 for a short page, one per card for a long listing
|
|
27
53
|
*/
|
|
28
54
|
export function blocksOf(root, blockChars = BLOCK_CHARS) {
|
|
29
|
-
|
|
30
|
-
const blocks = [];
|
|
31
|
-
/** @param {Node} node */
|
|
32
|
-
const cut = (node) => {
|
|
33
|
-
if (node.nodeType !== 1 || (node.textContent ?? '').length <= blockChars || !node.firstChild) {
|
|
34
|
-
blocks.push(node);
|
|
35
|
-
return;
|
|
36
|
-
}
|
|
37
|
-
for (const child of node.childNodes) cut(child);
|
|
38
|
-
};
|
|
39
|
-
cut(root);
|
|
40
|
-
return blocks;
|
|
55
|
+
return [...cut(root, blockChars)];
|
|
41
56
|
}
|
|
42
57
|
|
|
43
58
|
/**
|
|
@@ -95,5 +110,5 @@ export async function eachInSlices(items, work, { sliceMs = SLICE_MS, pause = ne
|
|
|
95
110
|
*/
|
|
96
111
|
export function inSlices(root, work, { blockChars = BLOCK_CHARS, ...options } = {}) {
|
|
97
112
|
// A block the work before took out of the page is skipped.
|
|
98
|
-
return eachInSlices(
|
|
113
|
+
return eachInSlices(cut(root, blockChars), (block) => { if (block.isConnected) work(block); }, options);
|
|
99
114
|
}
|
package/src/content/watch.d.ts
CHANGED
|
@@ -24,7 +24,8 @@ export type WatchHandlers = {
|
|
|
24
24
|
*/
|
|
25
25
|
removed?: (node: Node) => void;
|
|
26
26
|
/**
|
|
27
|
-
* Whether a node
|
|
27
|
+
* Whether a node is the extension's own: one added or removed, and
|
|
28
|
+
* anything that changes inside one (its text set again), is left out.
|
|
28
29
|
*/
|
|
29
30
|
ignore?: (node: Node) => boolean;
|
|
30
31
|
/**
|
|
@@ -48,7 +49,8 @@ export type WatchHandlers = {
|
|
|
48
49
|
* none inside another.
|
|
49
50
|
* @property {(node: Node) => void} [removed] A node that left (at once, so what the extension added for it
|
|
50
51
|
* can go with it).
|
|
51
|
-
* @property {(node: Node) => boolean} [ignore] Whether a node
|
|
52
|
+
* @property {(node: Node) => boolean} [ignore] Whether a node is the extension's own: one added or removed, and
|
|
53
|
+
* anything that changes inside one (its text set again), is left out.
|
|
52
54
|
* @property {number} [settleMs] How long a batch waits for more. Default {@link SETTLE_MS}.
|
|
53
55
|
* @property {number} [maxWaitMs] The longest a batch waits. Default {@link MAX_WAIT_MS}.
|
|
54
56
|
* @property {number} [regroupChildren] An element whose children changed is handed over again whole
|
package/src/content/watch.js
CHANGED
|
@@ -21,7 +21,8 @@ export const MAX_WAIT_MS = 600;
|
|
|
21
21
|
* none inside another.
|
|
22
22
|
* @property {(node: Node) => void} [removed] A node that left (at once, so what the extension added for it
|
|
23
23
|
* can go with it).
|
|
24
|
-
* @property {(node: Node) => boolean} [ignore] Whether a node
|
|
24
|
+
* @property {(node: Node) => boolean} [ignore] Whether a node is the extension's own: one added or removed, and
|
|
25
|
+
* anything that changes inside one (its text set again), is left out.
|
|
25
26
|
* @property {number} [settleMs] How long a batch waits for more. Default {@link SETTLE_MS}.
|
|
26
27
|
* @property {number} [maxWaitMs] The longest a batch waits. Default {@link MAX_WAIT_MS}.
|
|
27
28
|
* @property {number} [regroupChildren] An element whose children changed is handed over again whole
|
|
@@ -56,14 +57,27 @@ export function watchChanges(root, {
|
|
|
56
57
|
firstAt = 0;
|
|
57
58
|
const nodes = [...pending].filter((node) => node.isConnected);
|
|
58
59
|
pending = new Set();
|
|
59
|
-
// Each subtree once: a node inside another changed node goes with it.
|
|
60
|
-
|
|
60
|
+
// Each subtree once: a node inside another changed node goes with it. Found by walking up from
|
|
61
|
+
// each (its depth), not by asking every pair: a feed adding thousands at once would stall the page.
|
|
62
|
+
const batch = new Set(nodes);
|
|
63
|
+
const tops = nodes.filter((node) => {
|
|
64
|
+
for (let above = node.parentNode; above; above = above.parentNode) if (batch.has(above)) return false;
|
|
65
|
+
return true;
|
|
66
|
+
});
|
|
61
67
|
if (tops.length) changed(tops);
|
|
62
68
|
};
|
|
63
69
|
|
|
70
|
+
/** Whether a node is the extension's own, or inside one that is. @param {Node | null} node */
|
|
71
|
+
const within = (node) => {
|
|
72
|
+
for (let at = node; at; at = at.parentNode) if (ignore(at)) return true;
|
|
73
|
+
return false;
|
|
74
|
+
};
|
|
75
|
+
|
|
64
76
|
const observer = new MutationObserver((mutations) => {
|
|
65
77
|
for (const mutation of mutations) {
|
|
66
78
|
const target = mutation.target;
|
|
79
|
+
// What happens inside the extension's own elements (its note's text set again) isn't the page changing.
|
|
80
|
+
if (within(target)) continue;
|
|
67
81
|
if (mutation.type === 'characterData') {
|
|
68
82
|
pending.add(target);
|
|
69
83
|
continue;
|
|
@@ -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/focus.d.ts
CHANGED
|
@@ -19,6 +19,17 @@
|
|
|
19
19
|
* focusedElement(); // → the button
|
|
20
20
|
*/
|
|
21
21
|
export declare function focusedElement(root?: Document | ShadowRoot): Element | null;
|
|
22
|
+
/**
|
|
23
|
+
* The focused element as seen from where `node` is: its shadow root's (a
|
|
24
|
+
* closed one too, which the document's `activeElement` stops at) or its
|
|
25
|
+
* document's, followed into open shadow roots below.
|
|
26
|
+
*
|
|
27
|
+
* @param {Node} node - A node in the page or in a shadow root.
|
|
28
|
+
* @returns {Element | null} The focused element, or null when nothing there has it.
|
|
29
|
+
* @example
|
|
30
|
+
* focusedIn(menu); // the menu's focused item, in a content script's closed shadow root too
|
|
31
|
+
*/
|
|
32
|
+
export declare function focusedIn(node: Node): Element | null;
|
|
22
33
|
/**
|
|
23
34
|
* The document a component is made in: `root`'s, or the page's.
|
|
24
35
|
*
|
package/src/dom/focus.js
CHANGED
|
@@ -25,6 +25,21 @@ export function focusedElement(root = document) {
|
|
|
25
25
|
return active;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The focused element as seen from where `node` is: its shadow root's (a
|
|
30
|
+
* closed one too, which the document's `activeElement` stops at) or its
|
|
31
|
+
* document's, followed into open shadow roots below.
|
|
32
|
+
*
|
|
33
|
+
* @param {Node} node - A node in the page or in a shadow root.
|
|
34
|
+
* @returns {Element | null} The focused element, or null when nothing there has it.
|
|
35
|
+
* @example
|
|
36
|
+
* focusedIn(menu); // the menu's focused item, in a content script's closed shadow root too
|
|
37
|
+
*/
|
|
38
|
+
export function focusedIn(node) {
|
|
39
|
+
const root = /** @type {Document | ShadowRoot} */ (node.getRootNode());
|
|
40
|
+
return 'activeElement' in root ? focusedElement(root) : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
28
43
|
/**
|
|
29
44
|
* The document a component is made in: `root`'s, or the page's.
|
|
30
45
|
*
|