@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.
Files changed (88) hide show
  1. package/README.md +145 -18
  2. package/bin/extension-kit.js +2 -1
  3. package/package.json +1 -1
  4. package/src/ai/host.d.ts +183 -0
  5. package/src/ai/host.js +225 -0
  6. package/src/ai/model.d.ts +2 -1
  7. package/src/ai/model.js +10 -2
  8. package/src/build/index.d.ts +2 -1
  9. package/src/build/index.js +2 -1
  10. package/src/build/store-shots.d.ts +177 -0
  11. package/src/build/store-shots.js +173 -0
  12. package/src/build/test-build.d.ts +13 -1
  13. package/src/build/test-build.js +41 -2
  14. package/src/build/vendor.d.ts +29 -8
  15. package/src/build/vendor.js +136 -40
  16. package/src/content/elements.d.ts +50 -0
  17. package/src/content/elements.js +88 -0
  18. package/src/content/highlight.d.ts +63 -0
  19. package/src/content/highlight.js +117 -0
  20. package/src/content/overlay.d.ts +31 -6
  21. package/src/content/overlay.js +56 -8
  22. package/src/content/page-toast.d.ts +26 -0
  23. package/src/content/page-toast.js +72 -0
  24. package/src/content/slices.d.ts +3 -1
  25. package/src/content/slices.js +29 -14
  26. package/src/content/watch.d.ts +4 -2
  27. package/src/content/watch.js +17 -3
  28. package/src/dom/copy.d.ts +36 -0
  29. package/src/dom/copy.js +84 -0
  30. package/src/dom/focus.d.ts +11 -0
  31. package/src/dom/focus.js +15 -0
  32. package/src/dom/i18n.d.ts +17 -0
  33. package/src/dom/i18n.js +15 -0
  34. package/src/dom/make.d.ts +53 -0
  35. package/src/dom/make.js +71 -0
  36. package/src/frame/window.d.ts +8 -1
  37. package/src/frame/window.js +11 -8
  38. package/src/index.d.ts +31 -6
  39. package/src/index.js +24 -6
  40. package/src/panel/show-on-page.d.ts +82 -0
  41. package/src/panel/show-on-page.js +83 -0
  42. package/src/scripting/call.d.ts +29 -9
  43. package/src/scripting/call.js +35 -11
  44. package/src/scripting/context-menus.d.ts +45 -0
  45. package/src/scripting/context-menus.js +53 -0
  46. package/src/scripting/registration.d.ts +2 -1
  47. package/src/scripting/registration.js +2 -1
  48. package/src/scripting/tab.d.ts +80 -0
  49. package/src/scripting/tab.js +89 -0
  50. package/src/settings/saver.js +16 -6
  51. package/src/settings/storage.d.ts +33 -2
  52. package/src/settings/storage.js +73 -3
  53. package/src/settings/store.d.ts +17 -0
  54. package/src/settings/store.js +87 -20
  55. package/src/sites/sites.d.ts +6 -1
  56. package/src/sites/sites.js +20 -3
  57. package/src/styles/chrome-ui.css +63 -30
  58. package/src/testing/e2e.d.ts +35 -4
  59. package/src/testing/e2e.js +67 -5
  60. package/src/testing/fake-model.d.ts +9 -1
  61. package/src/testing/fake-model.js +44 -23
  62. package/src/testing/fake-storage.d.ts +41 -13
  63. package/src/testing/fake-storage.js +163 -51
  64. package/src/testing/index.d.ts +8 -2
  65. package/src/testing/index.js +8 -2
  66. package/src/testing/live-run.d.ts +151 -0
  67. package/src/testing/live-run.js +206 -0
  68. package/src/testing/messages.d.ts +7 -3
  69. package/src/testing/messages.js +50 -9
  70. package/src/testing/package.d.ts +56 -7
  71. package/src/testing/package.js +57 -18
  72. package/src/testing/pages.d.ts +114 -0
  73. package/src/testing/pages.js +109 -0
  74. package/src/testing/prompt.d.ts +4 -2
  75. package/src/testing/prompt.js +7 -3
  76. package/src/time/pool.d.ts +2 -1
  77. package/src/time/pool.js +4 -2
  78. package/src/toolbar/theme.d.ts +10 -2
  79. package/src/toolbar/theme.js +12 -4
  80. package/src/ui/menu.d.ts +13 -3
  81. package/src/ui/menu.js +31 -11
  82. package/src/ui/model-row.js +5 -1
  83. package/src/ui/notice.d.ts +58 -0
  84. package/src/ui/notice.js +43 -0
  85. package/src/ui/site-list.js +12 -3
  86. package/src/ui/text-dialog.d.ts +3 -2
  87. package/src/ui/text-dialog.js +10 -3
  88. 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
+ }
@@ -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
- * The host's own styles are reset (`all: initial`) before `css`, so the page's
34
- * rules for unknown elements can't move or hide it; `css` styles `:host` and what's inside.
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}} options - `name`: the host's element name, with a hyphen
38
- * (never registered: a plain element); `css`: the overlay's styles.
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, { name: 'better-fonts-inspector', css: ':host { position: fixed; inset: 0; pointer-events: none; }' });
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;
@@ -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
- * The host's own styles are reset (`all: initial`) before `css`, so the page's
22
- * rules for unknown elements can't move or hide it; `css` styles `:host` and what's inside.
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}} options - `name`: the host's element name, with a hyphen
26
- * (never registered: a plain element); `css`: the overlay's styles.
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, { name: 'better-fonts-inspector', css: ':host { position: fixed; inset: 0; pointer-events: none; }' });
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
- style.textContent = css;
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
+ }
@@ -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}.
@@ -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
- /** @type {Node[]} */
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(blocksOf(root, blockChars), (block) => { if (block.isConnected) work(block); }, options);
113
+ return eachInSlices(cut(root, blockChars), (block) => { if (block.isConnected) work(block); }, options);
99
114
  }
@@ -24,7 +24,8 @@ export type WatchHandlers = {
24
24
  */
25
25
  removed?: (node: Node) => void;
26
26
  /**
27
- * Whether a node added or removed is the extension's own.
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 added or removed is the extension's own.
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
@@ -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 added or removed is the extension's own.
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
- const tops = nodes.filter((node) => !nodes.some((other) => other !== node && other.contains(node)));
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>;
@@ -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
+ }
@@ -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
  *