@arraypress/extension-kit 1.8.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.
Files changed (44) hide show
  1. package/README.md +88 -2
  2. package/package.json +1 -1
  3. package/src/build/index.d.ts +2 -1
  4. package/src/build/index.js +2 -1
  5. package/src/build/store-shots.d.ts +177 -0
  6. package/src/build/store-shots.js +173 -0
  7. package/src/build/test-build.d.ts +13 -1
  8. package/src/build/test-build.js +41 -2
  9. package/src/build/vendor.js +1 -1
  10. package/src/content/elements.d.ts +50 -0
  11. package/src/content/elements.js +88 -0
  12. package/src/content/highlight.d.ts +54 -0
  13. package/src/content/highlight.js +92 -0
  14. package/src/content/overlay.d.ts +57 -0
  15. package/src/content/overlay.js +52 -0
  16. package/src/content/page-toast.d.ts +26 -0
  17. package/src/content/page-toast.js +72 -0
  18. package/src/content/slices.d.ts +16 -1
  19. package/src/content/slices.js +29 -10
  20. package/src/dom/copy.d.ts +36 -0
  21. package/src/dom/copy.js +84 -0
  22. package/src/dom/i18n.d.ts +17 -0
  23. package/src/dom/i18n.js +15 -0
  24. package/src/dom/make.d.ts +53 -0
  25. package/src/dom/make.js +71 -0
  26. package/src/frame/window.d.ts +76 -7
  27. package/src/frame/window.js +119 -15
  28. package/src/index.d.ts +33 -5
  29. package/src/index.js +25 -5
  30. package/src/panel/side-panel.d.ts +35 -0
  31. package/src/panel/side-panel.js +41 -0
  32. package/src/scripting/call.d.ts +63 -0
  33. package/src/scripting/call.js +57 -0
  34. package/src/scripting/tab.d.ts +68 -0
  35. package/src/scripting/tab.js +81 -0
  36. package/src/styles/chrome-ui.css +7 -0
  37. package/src/testing/index.d.ts +6 -0
  38. package/src/testing/index.js +4 -0
  39. package/src/testing/live-run.d.ts +130 -0
  40. package/src/testing/live-run.js +160 -0
  41. package/src/testing/pages.d.ts +114 -0
  42. package/src/testing/pages.js +109 -0
  43. package/src/ui/notice.d.ts +58 -0
  44. package/src/ui/notice.js +43 -0
@@ -0,0 +1,50 @@
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
+ /** The most elements read (a very long page's first ones). */
11
+ export declare const MAX_ELEMENTS = 20000;
12
+ /**
13
+ * An element's shadow root, closed ones too where Chrome lets the extension see them.
14
+ *
15
+ * @param {Element} element - The element.
16
+ * @returns {ShadowRoot | null} Its shadow root.
17
+ * @example
18
+ * const root = shadowRootOf(host); // a closed root, from a content script
19
+ */
20
+ export declare function shadowRootOf(element: Element): ShadowRoot | null;
21
+ /**
22
+ * Every rendered element from the body down, in document order, a shadow root's elements before
23
+ * its host's children. An element Chrome doesn't draw (`visibility: hidden`) is left out, but
24
+ * what's inside it is still read (a child can be visible again), as is what's inside a
25
+ * `display: contents` element (a slot), which has no box of its own; nothing inside a
26
+ * `display: none` or `content-visibility: hidden` part is.
27
+ *
28
+ * @param {Document} doc - The page.
29
+ * @param {{skip?: (element: Element) => boolean, limit?: number}} [options] - `skip`: elements left
30
+ * out with what's inside (the extension's overlay); `limit`: the most elements ({@link MAX_ELEMENTS}).
31
+ * @returns {Element[]} The elements.
32
+ * @example
33
+ * for (const element of renderedElements(document, { skip: isLayer })) read(element);
34
+ */
35
+ export declare function renderedElements(doc: Document, { skip, limit }?: {
36
+ skip?: (element: Element) => boolean;
37
+ limit?: number;
38
+ }): Element[];
39
+ /**
40
+ * The origins of a page's frames that are another site's: their content isn't the page's to read,
41
+ * and an extension reads them only with that site's access (`chrome.permissions.request`, from a
42
+ * click). Frames inside shadow roots are found too.
43
+ *
44
+ * @param {Document} doc - The page.
45
+ * @param {Element[]} [elements] - Its elements, when already listed ({@link renderedElements}).
46
+ * @returns {string[]} Their origins (`https://ads.example.net`), each once.
47
+ * @example
48
+ * otherSiteFrames(document); // ['https://www.youtube.com']
49
+ */
50
+ export declare function otherSiteFrames(doc: Document, elements?: Element[]): string[];
@@ -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
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * A content script's layer over the page: a host element of its own name on
3
+ * the page's root element, holding a closed shadow root with the script's
4
+ * styles, so the page's styles and scripts can't reach in and its styles
5
+ * can't reach out. Pickers, toasts, bars and inspectors draw in it.
6
+ *
7
+ * @module content/overlay
8
+ */
9
+ export type PageOverlay = {
10
+ /**
11
+ * The host element, on the page's root element.
12
+ */
13
+ host: HTMLElement;
14
+ /**
15
+ * Its closed shadow root, the styles first: append to it.
16
+ */
17
+ root: ShadowRoot;
18
+ /**
19
+ * Take it off the page.
20
+ */
21
+ remove: () => void;
22
+ };
23
+ /**
24
+ * A page overlay.
25
+ *
26
+ * @typedef {object} PageOverlay
27
+ * @property {HTMLElement} host The host element, on the page's root element.
28
+ * @property {ShadowRoot} root Its closed shadow root, the styles first: append to it.
29
+ * @property {() => void} remove Take it off the page.
30
+ */
31
+ /**
32
+ * Put an overlay on the page, replacing one of the same name a run before left.
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).
39
+ *
40
+ * @param {Document} doc - The page.
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`.
44
+ * @returns {PageOverlay} The overlay.
45
+ * @example
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
+ * });
51
+ * root.append(card);
52
+ */
53
+ export declare function pageOverlay(doc: Document, { name, css, hostStyle }: {
54
+ name: string;
55
+ css: string;
56
+ hostStyle?: Record<string, string>;
57
+ }): PageOverlay;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * A content script's layer over the page: a host element of its own name on
3
+ * the page's root element, holding a closed shadow root with the script's
4
+ * styles, so the page's styles and scripts can't reach in and its styles
5
+ * can't reach out. Pickers, toasts, bars and inspectors draw in it.
6
+ *
7
+ * @module content/overlay
8
+ */
9
+
10
+ /**
11
+ * A page overlay.
12
+ *
13
+ * @typedef {object} PageOverlay
14
+ * @property {HTMLElement} host The host element, on the page's root element.
15
+ * @property {ShadowRoot} root Its closed shadow root, the styles first: append to it.
16
+ * @property {() => void} remove Take it off the page.
17
+ */
18
+
19
+ /**
20
+ * Put an overlay on the page, replacing one of the same name a run before left.
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).
27
+ *
28
+ * @param {Document} doc - The page.
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`.
32
+ * @returns {PageOverlay} The overlay.
33
+ * @example
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
+ * });
39
+ * root.append(card);
40
+ */
41
+ export function pageOverlay(doc, { name, css, hostStyle = {} }) {
42
+ for (const old of doc.querySelectorAll(name)) old.remove();
43
+ const host = doc.createElement(name);
44
+ host.style.setProperty('all', 'initial', 'important');
45
+ for (const [property, value] of Object.entries(hostStyle)) host.style.setProperty(property, value, 'important');
46
+ const root = host.attachShadow({ mode: 'closed' });
47
+ const style = doc.createElement('style');
48
+ style.textContent = css;
49
+ root.append(style);
50
+ doc.documentElement.append(host);
51
+ return { host, root, remove: () => host.remove() };
52
+ }
@@ -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
+ }
@@ -48,6 +48,21 @@ export type SliceOptions = {
48
48
  * @property {() => Promise<void>} [pause] Hands the main thread back. Default: a macrotask.
49
49
  * @property {() => number} [now] The clock. Default `performance.now`.
50
50
  */
51
+ /**
52
+ * Work through any list in slices: `work(item, index)` on each, the main
53
+ * thread handed back whenever a slice has run `sliceMs`. For a content
54
+ * script's work that isn't cut by blocks of the page (each element's style
55
+ * read, each text measured).
56
+ *
57
+ * @template T
58
+ * @param {Iterable<T>} items - The items.
59
+ * @param {(item: T, index: number) => void} work - What to do with one.
60
+ * @param {Pick<SliceOptions, 'sliceMs' | 'pause' | 'now'>} [options] - Slice length, how to pause, the clock.
61
+ * @returns {Promise<void>} Once every item is done.
62
+ * @example
63
+ * await eachInSlices(elements, (element) => styles.push(getComputedStyle(element).fontFamily));
64
+ */
65
+ export declare function eachInSlices<T>(items: Iterable<T>, work: (item: T, index: number) => void, { sliceMs, pause, now }?: Pick<SliceOptions, 'sliceMs' | 'pause' | 'now'>): Promise<void>;
51
66
  /**
52
67
  * Work through a part of the page in slices: `work(block)` on each block
53
68
  * still in the page, the main thread handed back whenever a slice has run
@@ -60,4 +75,4 @@ export type SliceOptions = {
60
75
  * @example
61
76
  * await inSlices(document.body, (block) => scanner.scan(block));
62
77
  */
63
- export declare function inSlices(root: Node, work: (block: Node) => void, { blockChars, sliceMs, pause, now }?: SliceOptions): Promise<void>;
78
+ export declare function inSlices(root: Node, work: (block: Node) => void, { blockChars, ...options }?: SliceOptions): Promise<void>;
@@ -55,6 +55,32 @@ const nextTask = () => new Promise((resolve) => { setTimeout(resolve, 0); });
55
55
  * @property {() => number} [now] The clock. Default `performance.now`.
56
56
  */
57
57
 
58
+ /**
59
+ * Work through any list in slices: `work(item, index)` on each, the main
60
+ * thread handed back whenever a slice has run `sliceMs`. For a content
61
+ * script's work that isn't cut by blocks of the page (each element's style
62
+ * read, each text measured).
63
+ *
64
+ * @template T
65
+ * @param {Iterable<T>} items - The items.
66
+ * @param {(item: T, index: number) => void} work - What to do with one.
67
+ * @param {Pick<SliceOptions, 'sliceMs' | 'pause' | 'now'>} [options] - Slice length, how to pause, the clock.
68
+ * @returns {Promise<void>} Once every item is done.
69
+ * @example
70
+ * await eachInSlices(elements, (element) => styles.push(getComputedStyle(element).fontFamily));
71
+ */
72
+ export async function eachInSlices(items, work, { sliceMs = SLICE_MS, pause = nextTask, now = () => performance.now() } = {}) {
73
+ let started = now();
74
+ let index = 0;
75
+ for (const item of items) {
76
+ work(item, index++);
77
+ if (now() - started > sliceMs) {
78
+ await pause();
79
+ started = now();
80
+ }
81
+ }
82
+ }
83
+
58
84
  /**
59
85
  * Work through a part of the page in slices: `work(block)` on each block
60
86
  * still in the page, the main thread handed back whenever a slice has run
@@ -67,14 +93,7 @@ const nextTask = () => new Promise((resolve) => { setTimeout(resolve, 0); });
67
93
  * @example
68
94
  * await inSlices(document.body, (block) => scanner.scan(block));
69
95
  */
70
- export async function inSlices(root, work, { blockChars = BLOCK_CHARS, sliceMs = SLICE_MS, pause = nextTask, now = () => performance.now() } = {}) {
71
- let started = now();
72
- for (const block of blocksOf(root, blockChars)) {
73
- if (!block.isConnected) continue;
74
- work(block);
75
- if (now() - started > sliceMs) {
76
- await pause();
77
- started = now();
78
- }
79
- }
96
+ export function inSlices(root, work, { blockChars = BLOCK_CHARS, ...options } = {}) {
97
+ // 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);
80
99
  }
@@ -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>;