@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.
- package/README.md +88 -2
- package/package.json +1 -1
- package/src/build/index.d.ts +2 -1
- package/src/build/index.js +2 -1
- package/src/build/store-shots.d.ts +177 -0
- package/src/build/store-shots.js +173 -0
- package/src/build/test-build.d.ts +13 -1
- package/src/build/test-build.js +41 -2
- package/src/build/vendor.js +1 -1
- package/src/content/elements.d.ts +50 -0
- package/src/content/elements.js +88 -0
- package/src/content/highlight.d.ts +54 -0
- package/src/content/highlight.js +92 -0
- package/src/content/overlay.d.ts +57 -0
- package/src/content/overlay.js +52 -0
- package/src/content/page-toast.d.ts +26 -0
- package/src/content/page-toast.js +72 -0
- package/src/content/slices.d.ts +16 -1
- package/src/content/slices.js +29 -10
- package/src/dom/copy.d.ts +36 -0
- package/src/dom/copy.js +84 -0
- package/src/dom/i18n.d.ts +17 -0
- package/src/dom/i18n.js +15 -0
- package/src/dom/make.d.ts +53 -0
- package/src/dom/make.js +71 -0
- package/src/frame/window.d.ts +76 -7
- package/src/frame/window.js +119 -15
- package/src/index.d.ts +33 -5
- package/src/index.js +25 -5
- package/src/panel/side-panel.d.ts +35 -0
- package/src/panel/side-panel.js +41 -0
- package/src/scripting/call.d.ts +63 -0
- package/src/scripting/call.js +57 -0
- package/src/scripting/tab.d.ts +68 -0
- package/src/scripting/tab.js +81 -0
- package/src/styles/chrome-ui.css +7 -0
- package/src/testing/index.d.ts +6 -0
- package/src/testing/index.js +4 -0
- package/src/testing/live-run.d.ts +130 -0
- package/src/testing/live-run.js +160 -0
- package/src/testing/pages.d.ts +114 -0
- package/src/testing/pages.js +109 -0
- package/src/ui/notice.d.ts +58 -0
- package/src/ui/notice.js +43 -0
|
@@ -0,0 +1,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
|
+
}
|
package/src/content/slices.d.ts
CHANGED
|
@@ -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,
|
|
78
|
+
export declare function inSlices(root: Node, work: (block: Node) => void, { blockChars, ...options }?: SliceOptions): Promise<void>;
|
package/src/content/slices.js
CHANGED
|
@@ -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
|
|
71
|
-
|
|
72
|
-
|
|
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>;
|