@arraypress/extension-kit 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
@@ -30,18 +30,28 @@ export type PageOverlay = {
30
30
  */
31
31
  /**
32
32
  * Put an overlay on the page, replacing one of the same name a run before left.
33
- * 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.
33
+ *
34
+ * The host's own styles are reset inline (`all: initial !important`), so the page's rules for
35
+ * unknown elements can't move or hide it. That reset also beats a plain `:host { … }` rule in
36
+ * `css` and an inline `host.style.x = …`: give the host's own styles as `hostStyle`, set inline
37
+ * with `!important` after the reset. `css` styles what's inside (and `:host` custom properties,
38
+ * which the reset doesn't touch).
35
39
  *
36
40
  * @param {Document} doc - The page.
37
- * @param {{name: string, css: string}} options - `name`: the host's element name, with a hyphen
38
- * (never registered: a plain element); `css`: the overlay's styles.
41
+ * @param {{name: string, css: string, hostStyle?: Record<string, string>}} options - `name`: the
42
+ * host's element name, with a hyphen (never registered: a plain element); `css`: the overlay's
43
+ * styles; `hostStyle`: the host's own styles by CSS property name (`'z-index'`), each `!important`.
39
44
  * @returns {PageOverlay} The overlay.
40
45
  * @example
41
- * const { root, remove } = pageOverlay(document, { name: 'better-fonts-inspector', css: ':host { position: fixed; inset: 0; pointer-events: none; }' });
46
+ * const { root, remove } = pageOverlay(document, {
47
+ * name: 'better-fonts-inspector',
48
+ * hostStyle: { position: 'fixed', inset: '0', 'z-index': '2147483647', 'pointer-events': 'none' },
49
+ * css: '.card { position: absolute; }',
50
+ * });
42
51
  * root.append(card);
43
52
  */
44
- export declare function pageOverlay(doc: Document, { name, css }: {
53
+ export declare function pageOverlay(doc: Document, { name, css, hostStyle }: {
45
54
  name: string;
46
55
  css: string;
56
+ hostStyle?: Record<string, string>;
47
57
  }): PageOverlay;
@@ -18,21 +18,31 @@
18
18
 
19
19
  /**
20
20
  * Put an overlay on the page, replacing one of the same name a run before left.
21
- * 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.
21
+ *
22
+ * The host's own styles are reset inline (`all: initial !important`), so the page's rules for
23
+ * unknown elements can't move or hide it. That reset also beats a plain `:host { … }` rule in
24
+ * `css` and an inline `host.style.x = …`: give the host's own styles as `hostStyle`, set inline
25
+ * with `!important` after the reset. `css` styles what's inside (and `:host` custom properties,
26
+ * which the reset doesn't touch).
23
27
  *
24
28
  * @param {Document} doc - The page.
25
- * @param {{name: string, css: string}} options - `name`: the host's element name, with a hyphen
26
- * (never registered: a plain element); `css`: the overlay's styles.
29
+ * @param {{name: string, css: string, hostStyle?: Record<string, string>}} options - `name`: the
30
+ * host's element name, with a hyphen (never registered: a plain element); `css`: the overlay's
31
+ * styles; `hostStyle`: the host's own styles by CSS property name (`'z-index'`), each `!important`.
27
32
  * @returns {PageOverlay} The overlay.
28
33
  * @example
29
- * const { root, remove } = pageOverlay(document, { name: 'better-fonts-inspector', css: ':host { position: fixed; inset: 0; pointer-events: none; }' });
34
+ * const { root, remove } = pageOverlay(document, {
35
+ * name: 'better-fonts-inspector',
36
+ * hostStyle: { position: 'fixed', inset: '0', 'z-index': '2147483647', 'pointer-events': 'none' },
37
+ * css: '.card { position: absolute; }',
38
+ * });
30
39
  * root.append(card);
31
40
  */
32
- export function pageOverlay(doc, { name, css }) {
41
+ export function pageOverlay(doc, { name, css, hostStyle = {} }) {
33
42
  for (const old of doc.querySelectorAll(name)) old.remove();
34
43
  const host = doc.createElement(name);
35
44
  host.style.setProperty('all', 'initial', 'important');
45
+ for (const [property, value] of Object.entries(hostStyle)) host.style.setProperty(property, value, 'important');
36
46
  const root = host.attachShadow({ mode: 'closed' });
37
47
  const style = doc.createElement('style');
38
48
  style.textContent = css;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Chrome's own confirmation in a web page: the dark pill with a check mark that Chrome shows at
3
+ * the bottom of the window for “Link copied”, for a content script that just did something the
4
+ * person asked for from a menu or a shortcut. In a closed shadow root (the kit's `pageOverlay`)
5
+ * the page can't style, as a popover in the top layer, over anything the page stacks; light on
6
+ * a dark system. One at a time: a new one replaces the last.
7
+ *
8
+ * @module content/page-toast
9
+ */
10
+ /** How long it shows, in ms (as Chrome's “Link copied”). */
11
+ export declare const PAGE_TOAST_MS = 4000;
12
+ /**
13
+ * Show Chrome's confirmation toast in a page.
14
+ *
15
+ * @param {Document} doc - The page.
16
+ * @param {string} text - What it says (“Link copied”).
17
+ * @param {{name?: string, ms?: number}} [options] - `name`: the overlay's element name (the
18
+ * extension's own, with a hyphen); `ms`: how long it shows. Defaults `extension-toast`, {@link PAGE_TOAST_MS}.
19
+ * @returns {() => void} Takes it away now.
20
+ * @example
21
+ * showPageToast(document, chrome.i18n.getMessage('copied'), { name: 'better-copy-toast' });
22
+ */
23
+ export declare function showPageToast(doc: Document, text: string, { name, ms }?: {
24
+ name?: string;
25
+ ms?: number;
26
+ }): () => void;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Chrome's own confirmation in a web page: the dark pill with a check mark that Chrome shows at
3
+ * the bottom of the window for “Link copied”, for a content script that just did something the
4
+ * person asked for from a menu or a shortcut. In a closed shadow root (the kit's `pageOverlay`)
5
+ * the page can't style, as a popover in the top layer, over anything the page stacks; light on
6
+ * a dark system. One at a time: a new one replaces the last.
7
+ *
8
+ * @module content/page-toast
9
+ */
10
+
11
+ import { pageOverlay } from './overlay.js';
12
+
13
+ /** How long it shows, in ms (as Chrome's “Link copied”). */
14
+ export const PAGE_TOAST_MS = 4000;
15
+
16
+ /** The toast's look: Chrome's cr-toast colours, its 48px pill, the check mark. */
17
+ const CSS = `
18
+ .toast { position: fixed; inset: auto auto 24px 50%; margin: 0; transform: translateX(-50%); z-index: 2147483647;
19
+ display: flex; align-items: center; gap: 12px; min-height: 48px; max-width: calc(100vw - 48px);
20
+ box-sizing: border-box; padding: 0 20px 0 16px; border: 0; border-radius: 24px; overflow: hidden;
21
+ background: rgb(48 48 48); color: rgb(242 242 242); box-shadow: 0 2px 4px 0 rgb(0 0 0 / 0.28);
22
+ font: 13px/20px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; letter-spacing: normal; text-align: start;
23
+ animation: in 150ms ease-out; }
24
+ .toast:not(:popover-open)[popover] { display: none; }
25
+ .text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
26
+ svg { width: 20px; height: 20px; fill: currentColor; flex: none; }
27
+ @media (prefers-color-scheme: dark) { .toast { background: rgb(227 227 227); color: rgb(31 31 31); } }
28
+ @keyframes in { from { opacity: 0; translate: 0 8px; } }
29
+ @media (prefers-reduced-motion: reduce) { .toast { animation: none; } }`;
30
+
31
+ /** Chrome's check mark (Material “check”). */
32
+ const CHECK = 'M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z';
33
+
34
+ /**
35
+ * Show Chrome's confirmation toast in a page.
36
+ *
37
+ * @param {Document} doc - The page.
38
+ * @param {string} text - What it says (“Link copied”).
39
+ * @param {{name?: string, ms?: number}} [options] - `name`: the overlay's element name (the
40
+ * extension's own, with a hyphen); `ms`: how long it shows. Defaults `extension-toast`, {@link PAGE_TOAST_MS}.
41
+ * @returns {() => void} Takes it away now.
42
+ * @example
43
+ * showPageToast(document, chrome.i18n.getMessage('copied'), { name: 'better-copy-toast' });
44
+ */
45
+ export function showPageToast(doc, text, { name = 'extension-toast', ms = PAGE_TOAST_MS } = {}) {
46
+ const { root, remove } = pageOverlay(doc, { name, css: CSS });
47
+ const toast = doc.createElement('div');
48
+ toast.className = 'toast';
49
+ toast.setAttribute('role', 'status');
50
+ const NS = 'http://www.w3.org/2000/svg';
51
+ const svg = doc.createElementNS(NS, 'svg');
52
+ svg.setAttribute('viewBox', '0 0 24 24');
53
+ svg.setAttribute('aria-hidden', 'true');
54
+ const path = doc.createElementNS(NS, 'path');
55
+ path.setAttribute('d', CHECK);
56
+ svg.append(path);
57
+ const words = doc.createElement('span');
58
+ words.className = 'text';
59
+ words.textContent = text;
60
+ toast.append(svg, words);
61
+ root.append(toast);
62
+ // The top layer, over the page's own fixed bars and open dialogs, where popovers exist.
63
+ if (typeof toast.showPopover === 'function') {
64
+ toast.popover = 'manual';
65
+ toast.showPopover();
66
+ }
67
+ const timer = setTimeout(remove, ms);
68
+ return () => {
69
+ clearTimeout(timer);
70
+ remove();
71
+ };
72
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Copying in several formats at once: plain text, and HTML for apps that paste it rich (a link
3
+ * with its title in Google Docs, Slack or Notion), from a page, a popup or a content script.
4
+ * Says whether it worked, so a caller can offer another way when the page won't let it.
5
+ *
6
+ * Chrome lets the focused page write without a click (it grants clipboard-write to the active
7
+ * tab), so a content script can copy from a context-menu click or a keyboard shortcut; a page
8
+ * served over plain HTTP has no clipboard API, and one with `Permissions-Policy:
9
+ * clipboard-write=()` refuses it: there the copy command is tried, which needs a click in the page.
10
+ *
11
+ * @module dom/copy
12
+ */
13
+ export type ClipboardFormats = {
14
+ /**
15
+ * Plain text, for every app.
16
+ */
17
+ text: string;
18
+ /**
19
+ * HTML, for apps that paste formatted text.
20
+ */
21
+ html?: string;
22
+ };
23
+ /**
24
+ * Put text, and HTML when given, on the clipboard: the clipboard API (one item holding both
25
+ * formats), else the copy command.
26
+ *
27
+ * @param {Document} doc - The page, popup or content script's document.
28
+ * @param {ClipboardFormats} formats - Plain text, and HTML for rich pasting.
29
+ * @returns {Promise<boolean>} Whether it's on the clipboard.
30
+ * @example
31
+ * const link = document.createElement('a');
32
+ * link.href = url;
33
+ * link.textContent = title;
34
+ * if (!(await copyFormats(document, { text: `${title} – ${url}`, html: link.outerHTML }))) offerCopyButton();
35
+ */
36
+ export declare function copyFormats(doc: Document, { text, html }: ClipboardFormats): Promise<boolean>;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Copying in several formats at once: plain text, and HTML for apps that paste it rich (a link
3
+ * with its title in Google Docs, Slack or Notion), from a page, a popup or a content script.
4
+ * Says whether it worked, so a caller can offer another way when the page won't let it.
5
+ *
6
+ * Chrome lets the focused page write without a click (it grants clipboard-write to the active
7
+ * tab), so a content script can copy from a context-menu click or a keyboard shortcut; a page
8
+ * served over plain HTTP has no clipboard API, and one with `Permissions-Policy:
9
+ * clipboard-write=()` refuses it: there the copy command is tried, which needs a click in the page.
10
+ *
11
+ * @module dom/copy
12
+ */
13
+
14
+ /**
15
+ * What to put on the clipboard.
16
+ *
17
+ * @typedef {object} ClipboardFormats
18
+ * @property {string} text Plain text, for every app.
19
+ * @property {string} [html] HTML, for apps that paste formatted text.
20
+ */
21
+
22
+ /**
23
+ * The copy command, with a copy listener that writes the formats: it runs first (capture, on the
24
+ * window) and stops the page's own listeners from changing what's copied.
25
+ *
26
+ * @param {Document} doc - The page.
27
+ * @param {ClipboardFormats} formats - What to copy.
28
+ * @returns {boolean} Whether the command ran and wrote them.
29
+ */
30
+ function copyCommand(doc, { text, html }) {
31
+ const view = doc.defaultView;
32
+ if (!view) return false;
33
+ let wrote = false;
34
+ /** @param {ClipboardEvent} event */
35
+ const onCopy = (event) => {
36
+ if (!event.clipboardData) return;
37
+ event.clipboardData.setData('text/plain', text);
38
+ if (html !== undefined) event.clipboardData.setData('text/html', html);
39
+ event.preventDefault();
40
+ event.stopImmediatePropagation();
41
+ wrote = true;
42
+ };
43
+ view.addEventListener('copy', onCopy, true);
44
+ try {
45
+ return doc.execCommand('copy') && wrote;
46
+ } catch {
47
+ return false;
48
+ } finally {
49
+ view.removeEventListener('copy', onCopy, true);
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Put text, and HTML when given, on the clipboard: the clipboard API (one item holding both
55
+ * formats), else the copy command.
56
+ *
57
+ * @param {Document} doc - The page, popup or content script's document.
58
+ * @param {ClipboardFormats} formats - Plain text, and HTML for rich pasting.
59
+ * @returns {Promise<boolean>} Whether it's on the clipboard.
60
+ * @example
61
+ * const link = document.createElement('a');
62
+ * link.href = url;
63
+ * link.textContent = title;
64
+ * if (!(await copyFormats(document, { text: `${title} – ${url}`, html: link.outerHTML }))) offerCopyButton();
65
+ */
66
+ export async function copyFormats(doc, { text, html }) {
67
+ const view = doc.defaultView;
68
+ const clipboard = view?.navigator.clipboard;
69
+ if (view && clipboard) {
70
+ try {
71
+ if (html === undefined) await clipboard.writeText(text);
72
+ else {
73
+ await clipboard.write([new view.ClipboardItem({
74
+ 'text/plain': new view.Blob([text], { type: 'text/plain' }),
75
+ 'text/html': new view.Blob([html], { type: 'text/html' }),
76
+ })]);
77
+ }
78
+ return true;
79
+ } catch {
80
+ // No focus, or the page's permissions policy: the copy command below.
81
+ }
82
+ }
83
+ return copyCommand(doc, { text, html });
84
+ }
package/src/dom/i18n.d.ts CHANGED
@@ -45,3 +45,20 @@ export declare function translator(i18n: Pick<I18nApi, 'getMessage'>): (key: str
45
45
  * @returns {void}
46
46
  */
47
47
  export declare function localize(root: ParentNode, i18n: I18nApi): void;
48
+ /**
49
+ * A count in words, its number written as the person's language writes numbers: one message for
50
+ * one, another (with the number as `$1`) for any other count.
51
+ *
52
+ * @param {number} count - How many.
53
+ * @param {string} one - The message for one (`"1 colour"`).
54
+ * @param {string} many - The message for others (`"$COUNT$ colours"`, its placeholder `$1`).
55
+ * @param {{t: (key: string, substitutions?: string | string[]) => string, lang: string}} words - The
56
+ * page's message lookup and the UI language.
57
+ * @returns {string} The words.
58
+ * @example
59
+ * counted(1200, 'panelColoursOne', 'panelColours', { t, lang: 'en-GB' }); // '1,200 colours'
60
+ */
61
+ export declare const counted: (count: number, one: string, many: string, { t, lang }: {
62
+ t: (key: string, substitutions?: string | string[]) => string;
63
+ lang: string;
64
+ }) => string;
package/src/dom/i18n.js CHANGED
@@ -60,3 +60,18 @@ export function localize(root, i18n) {
60
60
  }
61
61
  if ('documentElement' in root) /** @type {Document} */ (root).documentElement.lang = i18n.getUILanguage();
62
62
  }
63
+
64
+ /**
65
+ * A count in words, its number written as the person's language writes numbers: one message for
66
+ * one, another (with the number as `$1`) for any other count.
67
+ *
68
+ * @param {number} count - How many.
69
+ * @param {string} one - The message for one (`"1 colour"`).
70
+ * @param {string} many - The message for others (`"$COUNT$ colours"`, its placeholder `$1`).
71
+ * @param {{t: (key: string, substitutions?: string | string[]) => string, lang: string}} words - The
72
+ * page's message lookup and the UI language.
73
+ * @returns {string} The words.
74
+ * @example
75
+ * counted(1200, 'panelColoursOne', 'panelColours', { t, lang: 'en-GB' }); // '1,200 colours'
76
+ */
77
+ export const counted = (count, one, many, { t, lang }) => (count === 1 ? t(one) : t(many, [count.toLocaleString(lang)]));
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Making a page's elements in a line: an element with its class and text, an
3
+ * icon from the page's SVG sprite, and Chrome's 24px icon button with its
4
+ * tooltip. For extension pages and content-script overlays alike (pass the
5
+ * document the element belongs to).
6
+ *
7
+ * @module dom/make
8
+ */
9
+ /**
10
+ * An element with a class and text.
11
+ *
12
+ * @template {keyof HTMLElementTagNameMap} K
13
+ * @param {K} tag - Its tag.
14
+ * @param {string} [className] - Its class.
15
+ * @param {string} [text] - Its text.
16
+ * @param {Document} [doc] - Its document. Default `document`.
17
+ * @returns {HTMLElementTagNameMap[K]} It.
18
+ * @example
19
+ * make('span', 'meta', '12 colours');
20
+ */
21
+ export declare function make<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, text?: string, doc?: Document): HTMLElementTagNameMap[K];
22
+ /**
23
+ * An icon from the page's SVG sprite (`<symbol id="i-copy">`), hidden from screen readers.
24
+ *
25
+ * @param {string} id - The symbol's id, without `#`.
26
+ * @param {string} [className] - More classes beside `icon`.
27
+ * @param {Document} [doc] - Its document. Default `document`.
28
+ * @returns {SVGSVGElement} The icon.
29
+ * @example
30
+ * button.append(spriteIcon('i-copy'));
31
+ */
32
+ export declare function spriteIcon(id: string, className?: string, doc?: Document): SVGSVGElement;
33
+ /**
34
+ * Chrome's icon button (chrome-ui.css `.icon-button`): an icon, its label as tooltip and for
35
+ * screen readers.
36
+ *
37
+ * @param {object} options
38
+ * @param {string} options.icon - The sprite symbol's id.
39
+ * @param {string} options.label - What it does.
40
+ * @param {() => void} options.onClick - Doing it.
41
+ * @param {string} [options.className] - More classes.
42
+ * @param {Document} [options.doc] - Its document. Default `document`.
43
+ * @returns {HTMLButtonElement} The button.
44
+ * @example
45
+ * iconButton({ icon: 'i-copy', label: 'Copy', onClick: () => copy(hex) });
46
+ */
47
+ export declare function iconButton({ icon, label, onClick, className, doc }: {
48
+ icon: string;
49
+ label: string;
50
+ onClick: () => void;
51
+ className?: string;
52
+ doc?: Document;
53
+ }): HTMLButtonElement;