@arraypress/extension-kit 1.10.0 → 1.11.1

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 (69) hide show
  1. package/README.md +103 -21
  2. package/bin/extension-kit.js +2 -1
  3. package/package.json +1 -1
  4. package/src/ai/host.d.ts +183 -0
  5. package/src/ai/host.js +225 -0
  6. package/src/ai/model.d.ts +2 -1
  7. package/src/ai/model.js +10 -2
  8. package/src/build/vendor.d.ts +29 -8
  9. package/src/build/vendor.js +136 -40
  10. package/src/content/highlight.d.ts +15 -6
  11. package/src/content/highlight.js +42 -17
  12. package/src/content/overlay.d.ts +19 -4
  13. package/src/content/overlay.js +44 -6
  14. package/src/content/slices.d.ts +3 -1
  15. package/src/content/slices.js +29 -14
  16. package/src/content/watch.d.ts +4 -2
  17. package/src/content/watch.js +17 -3
  18. package/src/dom/focus.d.ts +11 -0
  19. package/src/dom/focus.js +15 -0
  20. package/src/frame/window.d.ts +1 -1
  21. package/src/frame/window.js +8 -7
  22. package/src/index.d.ts +10 -3
  23. package/src/index.js +8 -3
  24. package/src/panel/show-on-page.d.ts +82 -0
  25. package/src/panel/show-on-page.js +83 -0
  26. package/src/scripting/call.d.ts +29 -9
  27. package/src/scripting/call.js +35 -11
  28. package/src/scripting/context-menus.d.ts +45 -0
  29. package/src/scripting/context-menus.js +53 -0
  30. package/src/scripting/registration.d.ts +2 -1
  31. package/src/scripting/registration.js +2 -1
  32. package/src/scripting/tab.d.ts +17 -5
  33. package/src/scripting/tab.js +15 -7
  34. package/src/settings/saver.js +16 -6
  35. package/src/settings/storage.d.ts +33 -2
  36. package/src/settings/storage.js +73 -3
  37. package/src/settings/store.d.ts +17 -0
  38. package/src/settings/store.js +87 -20
  39. package/src/sites/sites.d.ts +6 -1
  40. package/src/sites/sites.js +20 -3
  41. package/src/styles/chrome-ui.css +59 -30
  42. package/src/testing/e2e.d.ts +35 -4
  43. package/src/testing/e2e.js +67 -5
  44. package/src/testing/fake-model.d.ts +9 -1
  45. package/src/testing/fake-model.js +44 -23
  46. package/src/testing/fake-storage.d.ts +41 -13
  47. package/src/testing/fake-storage.js +163 -51
  48. package/src/testing/index.d.ts +3 -3
  49. package/src/testing/index.js +5 -3
  50. package/src/testing/live-run.d.ts +23 -2
  51. package/src/testing/live-run.js +64 -18
  52. package/src/testing/messages.d.ts +7 -3
  53. package/src/testing/messages.js +50 -9
  54. package/src/testing/package.d.ts +56 -7
  55. package/src/testing/package.js +57 -18
  56. package/src/testing/prompt.d.ts +4 -2
  57. package/src/testing/prompt.js +7 -3
  58. package/src/time/pool.d.ts +2 -1
  59. package/src/time/pool.js +4 -2
  60. package/src/toolbar/theme.d.ts +10 -2
  61. package/src/toolbar/theme.js +12 -4
  62. package/src/ui/menu.d.ts +13 -3
  63. package/src/ui/menu.js +48 -11
  64. package/src/ui/model-row.js +5 -1
  65. package/src/ui/site-list.js +12 -3
  66. package/src/ui/text-dialog.d.ts +3 -2
  67. package/src/ui/text-dialog.js +10 -3
  68. package/src/ui/toast.d.ts +3 -0
  69. package/src/ui/toast.js +23 -1
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * “Show on page”: boxes in Chrome's blue (DevTools' highlight) around a set of
3
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
4
+ * extension's button again, another set, Esc, or the page being left) and
5
+ * `onClear` is told which, so a panel's pressed button can follow an Esc on
6
+ * the page. The first can be scrolled into view. The boxes go in the extension's own overlay (`pageOverlay`), so
6
7
  * the page's styles can't touch them.
7
8
  *
8
9
  * @module content/highlight
@@ -21,7 +22,14 @@ export const HIGHLIGHT_CSS = '.box { position: fixed; box-sizing: border-box; bo
21
22
  * @property {(elements: Element[], options?: {reveal?: boolean}) => number} show Box elements (the
22
23
  * ones still in the page, the first {@link MAX_BOXES}), clearing any boxed before; `reveal`
23
24
  * scrolls the first into view. Returns how many are boxed.
24
- * @property {() => void} clear Take the boxes away.
25
+ * @property {() => void} clear Take the boxes away (`onClear('asked')`).
26
+ */
27
+
28
+ /**
29
+ * Why the boxes went: Esc on the page, another set shown in their place, `clear()` called, or
30
+ * the page left (`pagehide`: a page kept for Back would come back with stale boxes).
31
+ *
32
+ * @typedef {'escape' | 'replaced' | 'asked' | 'pagehide'} ClearReason
25
33
  */
26
34
 
27
35
  /**
@@ -30,7 +38,8 @@ export const HIGHLIGHT_CSS = '.box { position: fixed; box-sizing: border-box; bo
30
38
  * @param {Document} doc - The page.
31
39
  * @param {object} options
32
40
  * @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).
41
+ * @param {(reason: ClearReason) => void} [options.onClear] - After the boxes go, and why (take an empty
42
+ * overlay away; tell the panel when it's `'escape'` or `'pagehide'`, which it didn't ask for).
34
43
  * @param {number} [options.max] - The most elements boxed. Default {@link MAX_BOXES}.
35
44
  * @returns {Highlighter} It.
36
45
  * @example
@@ -43,40 +52,54 @@ export function createHighlighter(doc, { root, onClear = () => {}, max = MAX_BOX
43
52
  let shown = null;
44
53
  let pending = 0;
45
54
 
55
+ /** Put a box on each rectangle in the window, reusing the boxes there are: nothing is made or removed while scrolling. */
46
56
  const draw = () => {
47
57
  pending = 0;
48
58
  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';
59
+ const rects = shown.elements.flatMap((element) => [...element.getClientRects()].filter((rect) => rect.width && rect.height
60
+ && rect.bottom > 0 && rect.top < view.innerHeight && rect.right > 0 && rect.left < view.innerWidth));
61
+ const { boxes } = shown;
62
+ for (const box of boxes.splice(rects.length)) box.remove();
63
+ /** @type {HTMLElement[]} */
64
+ const added = [];
65
+ rects.forEach((rect, index) => {
66
+ let box = boxes[index];
67
+ if (!box) {
68
+ box = doc.createElement('div');
69
+ box.className = 'box';
70
+ boxes.push(box);
71
+ added.push(box);
72
+ }
54
73
  Object.assign(box.style, { left: `${rect.left - 2}px`, top: `${rect.top - 2}px`, width: `${rect.width + 4}px`, height: `${rect.height + 4}px` });
55
- return box;
56
- }));
57
- root().append(...shown.boxes);
74
+ });
75
+ if (added.length) root().append(...added);
58
76
  };
59
77
  const update = () => {
60
78
  if (!pending) pending = view.requestAnimationFrame(draw);
61
79
  };
62
80
  /** @param {KeyboardEvent} event */
63
81
  const key = (event) => {
64
- if (event.key === 'Escape') clear();
82
+ if (event.key === 'Escape') end('escape');
65
83
  };
84
+ const left = () => end('pagehide');
66
85
 
67
- function clear() {
86
+ /** @param {ClearReason} reason */
87
+ function end(reason) {
68
88
  if (!shown) return;
69
89
  view.removeEventListener('scroll', update, true);
70
90
  view.removeEventListener('resize', update);
71
91
  view.removeEventListener('keydown', key, true);
92
+ view.removeEventListener('pagehide', left);
93
+ if (pending) view.cancelAnimationFrame(pending);
94
+ pending = 0;
72
95
  for (const box of shown.boxes) box.remove();
73
96
  shown = null;
74
- onClear();
97
+ onClear(reason);
75
98
  }
76
99
 
77
100
  /** @type {Highlighter['show']} */
78
101
  function show(elements, { reveal = false } = {}) {
79
- clear();
102
+ end('replaced');
80
103
  const some = elements.filter((element) => element.isConnected).slice(0, max);
81
104
  if (!some.length) return 0;
82
105
  if (reveal) some[0].scrollIntoView({ block: 'center', inline: 'nearest' });
@@ -84,9 +107,11 @@ export function createHighlighter(doc, { root, onClear = () => {}, max = MAX_BOX
84
107
  view.addEventListener('scroll', update, { capture: true, passive: true });
85
108
  view.addEventListener('resize', update, { passive: true });
86
109
  view.addEventListener('keydown', key, true);
110
+ view.addEventListener('pagehide', left);
87
111
  draw();
88
112
  return some.length;
89
113
  }
90
114
 
91
- return { show, clear };
115
+ return { show, clear: () => end('asked') };
116
+
92
117
  }
@@ -19,6 +19,10 @@ export type PageOverlay = {
19
19
  * Take it off the page.
20
20
  */
21
21
  remove: () => void;
22
+ /**
23
+ * With `topLayer`: put it over whatever entered the top layer since.
24
+ */
25
+ raise: () => void;
22
26
  };
23
27
  /**
24
28
  * A page overlay.
@@ -27,6 +31,7 @@ export type PageOverlay = {
27
31
  * @property {HTMLElement} host The host element, on the page's root element.
28
32
  * @property {ShadowRoot} root Its closed shadow root, the styles first: append to it.
29
33
  * @property {() => void} remove Take it off the page.
34
+ * @property {() => void} raise With `topLayer`: put it over whatever entered the top layer since.
30
35
  */
31
36
  /**
32
37
  * Put an overlay on the page, replacing one of the same name a run before left.
@@ -37,10 +42,19 @@ export type PageOverlay = {
37
42
  * with `!important` after the reset. `css` styles what's inside (and `:host` custom properties,
38
43
  * which the reset doesn't touch).
39
44
  *
45
+ * With `topLayer` the host is a manual popover, shown: it draws in the top layer, over the page's
46
+ * modal dialogs, popovers and fullscreen element, which no `z-index` reaches. The top layer
47
+ * paints in the order things entered it, so the overlay goes back on top (`raise`) whenever the
48
+ * page opens a dialog or a popover or goes fullscreen after it. What a modal dialog makes inert
49
+ * stays inert, the overlay with it: it shows over the dialog, but can't be clicked while the
50
+ * dialog is open. The popover's own backdrop is taken away (first in the overlay's styles), so a
51
+ * page's `::backdrop` rule can't tint the window through it.
52
+ *
40
53
  * @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`.
54
+ * @param {{name: string, css: string, hostStyle?: Record<string, string>, topLayer?: boolean}} options -
55
+ * `name`: the host's element name, with a hyphen (never registered: a plain element); `css`: the
56
+ * overlay's styles; `hostStyle`: the host's own styles by CSS property name (`'z-index'`), each
57
+ * `!important`; `topLayer`: draw over the page's dialogs and fullscreen element. Default false.
44
58
  * @returns {PageOverlay} The overlay.
45
59
  * @example
46
60
  * const { root, remove } = pageOverlay(document, {
@@ -50,8 +64,9 @@ export type PageOverlay = {
50
64
  * });
51
65
  * root.append(card);
52
66
  */
53
- export declare function pageOverlay(doc: Document, { name, css, hostStyle }: {
67
+ export declare function pageOverlay(doc: Document, { name, css, hostStyle, topLayer }: {
54
68
  name: string;
55
69
  css: string;
56
70
  hostStyle?: Record<string, string>;
71
+ topLayer?: boolean;
57
72
  }): PageOverlay;
@@ -14,6 +14,7 @@
14
14
  * @property {HTMLElement} host The host element, on the page's root element.
15
15
  * @property {ShadowRoot} root Its closed shadow root, the styles first: append to it.
16
16
  * @property {() => void} remove Take it off the page.
17
+ * @property {() => void} raise With `topLayer`: put it over whatever entered the top layer since.
17
18
  */
18
19
 
19
20
  /**
@@ -25,10 +26,19 @@
25
26
  * with `!important` after the reset. `css` styles what's inside (and `:host` custom properties,
26
27
  * which the reset doesn't touch).
27
28
  *
29
+ * With `topLayer` the host is a manual popover, shown: it draws in the top layer, over the page's
30
+ * modal dialogs, popovers and fullscreen element, which no `z-index` reaches. The top layer
31
+ * paints in the order things entered it, so the overlay goes back on top (`raise`) whenever the
32
+ * page opens a dialog or a popover or goes fullscreen after it. What a modal dialog makes inert
33
+ * stays inert, the overlay with it: it shows over the dialog, but can't be clicked while the
34
+ * dialog is open. The popover's own backdrop is taken away (first in the overlay's styles), so a
35
+ * page's `::backdrop` rule can't tint the window through it.
36
+ *
28
37
  * @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`.
38
+ * @param {{name: string, css: string, hostStyle?: Record<string, string>, topLayer?: boolean}} options -
39
+ * `name`: the host's element name, with a hyphen (never registered: a plain element); `css`: the
40
+ * overlay's styles; `hostStyle`: the host's own styles by CSS property name (`'z-index'`), each
41
+ * `!important`; `topLayer`: draw over the page's dialogs and fullscreen element. Default false.
32
42
  * @returns {PageOverlay} The overlay.
33
43
  * @example
34
44
  * const { root, remove } = pageOverlay(document, {
@@ -38,15 +48,43 @@
38
48
  * });
39
49
  * root.append(card);
40
50
  */
41
- export function pageOverlay(doc, { name, css, hostStyle = {} }) {
51
+ export function pageOverlay(doc, { name, css, hostStyle = {}, topLayer = false }) {
42
52
  for (const old of doc.querySelectorAll(name)) old.remove();
43
53
  const host = doc.createElement(name);
44
54
  host.style.setProperty('all', 'initial', 'important');
45
55
  for (const [property, value] of Object.entries(hostStyle)) host.style.setProperty(property, value, 'important');
46
56
  const root = host.attachShadow({ mode: 'closed' });
47
57
  const style = doc.createElement('style');
48
- style.textContent = css;
58
+ // A shown popover has a backdrop over the whole window, which a page's bare `::backdrop { … }` rule would tint.
59
+ style.textContent = topLayer ? `:host::backdrop { display: none !important; }\n${css}` : css;
49
60
  root.append(style);
50
61
  doc.documentElement.append(host);
51
- return { host, root, remove: () => host.remove() };
62
+ if (!topLayer || typeof host.showPopover !== 'function') return { host, root, remove: () => host.remove(), raise: () => {} };
63
+
64
+ host.popover = 'manual';
65
+ const raise = () => {
66
+ if (!host.isConnected) return;
67
+ host.hidePopover();
68
+ host.showPopover();
69
+ };
70
+ /** Something of the page's entered the top layer: a dialog or popover opened, or the page went fullscreen. */
71
+ const entered = (/** @type {Event} */ event) => {
72
+ if (event.target === host) return;
73
+ if (event.type === 'toggle' && /** @type {ToggleEvent} */ (event).newState !== 'open') return;
74
+ raise();
75
+ };
76
+ const view = doc.defaultView;
77
+ view?.addEventListener('toggle', entered, true);
78
+ view?.addEventListener('fullscreenchange', entered, true);
79
+ host.showPopover();
80
+ return {
81
+ host,
82
+ root,
83
+ raise,
84
+ remove() {
85
+ view?.removeEventListener('toggle', entered, true);
86
+ view?.removeEventListener('fullscreenchange', entered, true);
87
+ host.remove();
88
+ },
89
+ };
52
90
  }
@@ -14,7 +14,9 @@ export declare const BLOCK_CHARS = 4000;
14
14
  export declare const SLICE_MS = 40;
15
15
  /**
16
16
  * The blocks of a part of the page: itself if small enough, else its
17
- * children's blocks (its own text nodes as blocks of their own).
17
+ * children's blocks (its own text nodes as blocks of their own). Scripts,
18
+ * styles and templates inside a part that's cut are left out: their text
19
+ * isn't the page's.
18
20
  *
19
21
  * @param {Node} root - The part.
20
22
  * @param {number} [blockChars] - The most text a block holds. Default {@link BLOCK_CHARS}.
@@ -15,9 +15,35 @@ export const BLOCK_CHARS = 4000;
15
15
  /** How long one slice may run before the page gets the main thread back (ms), by default. Shorter slices cost more layout in all. */
16
16
  export const SLICE_MS = 40;
17
17
 
18
+ /** Elements whose text is never the page's content: not cut into, nor handed over as blocks. */
19
+ const NOT_CONTENT = new Set(['script', 'style', 'noscript', 'template']);
20
+
21
+ /**
22
+ * The blocks of a part of the page, one at a time as they're asked for: the cutting (each
23
+ * level's text measured) is spread over the slices with the work, not done whole before the
24
+ * first. A level's children are listed when it's reached, so what the work adds beside a block
25
+ * isn't handed back to it.
26
+ *
27
+ * @param {Node} node - The part.
28
+ * @param {number} blockChars - The most text a block holds.
29
+ * @returns {Generator<Node, void, void>} The blocks, in page order.
30
+ */
31
+ function* cut(node, blockChars) {
32
+ if (node.nodeType !== 1 || (node.textContent ?? '').length <= blockChars || !node.firstChild) {
33
+ yield node;
34
+ return;
35
+ }
36
+ for (const child of [...node.childNodes]) {
37
+ if (child.nodeType === 1 && NOT_CONTENT.has(/** @type {Element} */ (child).localName)) continue;
38
+ yield* cut(child, blockChars);
39
+ }
40
+ }
41
+
18
42
  /**
19
43
  * The blocks of a part of the page: itself if small enough, else its
20
- * children's blocks (its own text nodes as blocks of their own).
44
+ * children's blocks (its own text nodes as blocks of their own). Scripts,
45
+ * styles and templates inside a part that's cut are left out: their text
46
+ * isn't the page's.
21
47
  *
22
48
  * @param {Node} root - The part.
23
49
  * @param {number} [blockChars] - The most text a block holds. Default {@link BLOCK_CHARS}.
@@ -26,18 +52,7 @@ export const SLICE_MS = 40;
26
52
  * blocksOf(document.body).length; // 1 for a short page, one per card for a long listing
27
53
  */
28
54
  export function blocksOf(root, blockChars = BLOCK_CHARS) {
29
- /** @type {Node[]} */
30
- const blocks = [];
31
- /** @param {Node} node */
32
- const cut = (node) => {
33
- if (node.nodeType !== 1 || (node.textContent ?? '').length <= blockChars || !node.firstChild) {
34
- blocks.push(node);
35
- return;
36
- }
37
- for (const child of node.childNodes) cut(child);
38
- };
39
- cut(root);
40
- return blocks;
55
+ return [...cut(root, blockChars)];
41
56
  }
42
57
 
43
58
  /**
@@ -95,5 +110,5 @@ export async function eachInSlices(items, work, { sliceMs = SLICE_MS, pause = ne
95
110
  */
96
111
  export function inSlices(root, work, { blockChars = BLOCK_CHARS, ...options } = {}) {
97
112
  // A block the work before took out of the page is skipped.
98
- return eachInSlices(blocksOf(root, blockChars), (block) => { if (block.isConnected) work(block); }, options);
113
+ return eachInSlices(cut(root, blockChars), (block) => { if (block.isConnected) work(block); }, options);
99
114
  }
@@ -24,7 +24,8 @@ export type WatchHandlers = {
24
24
  */
25
25
  removed?: (node: Node) => void;
26
26
  /**
27
- * Whether a node added or removed is the extension's own.
27
+ * Whether a node is the extension's own: one added or removed, and
28
+ * anything that changes inside one (its text set again), is left out.
28
29
  */
29
30
  ignore?: (node: Node) => boolean;
30
31
  /**
@@ -48,7 +49,8 @@ export type WatchHandlers = {
48
49
  * none inside another.
49
50
  * @property {(node: Node) => void} [removed] A node that left (at once, so what the extension added for it
50
51
  * can go with it).
51
- * @property {(node: Node) => boolean} [ignore] Whether a node added or removed is the extension's own.
52
+ * @property {(node: Node) => boolean} [ignore] Whether a node is the extension's own: one added or removed, and
53
+ * anything that changes inside one (its text set again), is left out.
52
54
  * @property {number} [settleMs] How long a batch waits for more. Default {@link SETTLE_MS}.
53
55
  * @property {number} [maxWaitMs] The longest a batch waits. Default {@link MAX_WAIT_MS}.
54
56
  * @property {number} [regroupChildren] An element whose children changed is handed over again whole
@@ -21,7 +21,8 @@ export const MAX_WAIT_MS = 600;
21
21
  * none inside another.
22
22
  * @property {(node: Node) => void} [removed] A node that left (at once, so what the extension added for it
23
23
  * can go with it).
24
- * @property {(node: Node) => boolean} [ignore] Whether a node added or removed is the extension's own.
24
+ * @property {(node: Node) => boolean} [ignore] Whether a node is the extension's own: one added or removed, and
25
+ * anything that changes inside one (its text set again), is left out.
25
26
  * @property {number} [settleMs] How long a batch waits for more. Default {@link SETTLE_MS}.
26
27
  * @property {number} [maxWaitMs] The longest a batch waits. Default {@link MAX_WAIT_MS}.
27
28
  * @property {number} [regroupChildren] An element whose children changed is handed over again whole
@@ -56,14 +57,27 @@ export function watchChanges(root, {
56
57
  firstAt = 0;
57
58
  const nodes = [...pending].filter((node) => node.isConnected);
58
59
  pending = new Set();
59
- // Each subtree once: a node inside another changed node goes with it.
60
- const tops = nodes.filter((node) => !nodes.some((other) => other !== node && other.contains(node)));
60
+ // Each subtree once: a node inside another changed node goes with it. Found by walking up from
61
+ // each (its depth), not by asking every pair: a feed adding thousands at once would stall the page.
62
+ const batch = new Set(nodes);
63
+ const tops = nodes.filter((node) => {
64
+ for (let above = node.parentNode; above; above = above.parentNode) if (batch.has(above)) return false;
65
+ return true;
66
+ });
61
67
  if (tops.length) changed(tops);
62
68
  };
63
69
 
70
+ /** Whether a node is the extension's own, or inside one that is. @param {Node | null} node */
71
+ const within = (node) => {
72
+ for (let at = node; at; at = at.parentNode) if (ignore(at)) return true;
73
+ return false;
74
+ };
75
+
64
76
  const observer = new MutationObserver((mutations) => {
65
77
  for (const mutation of mutations) {
66
78
  const target = mutation.target;
79
+ // What happens inside the extension's own elements (its note's text set again) isn't the page changing.
80
+ if (within(target)) continue;
67
81
  if (mutation.type === 'characterData') {
68
82
  pending.add(target);
69
83
  continue;
@@ -19,6 +19,17 @@
19
19
  * focusedElement(); // → the button
20
20
  */
21
21
  export declare function focusedElement(root?: Document | ShadowRoot): Element | null;
22
+ /**
23
+ * The focused element as seen from where `node` is: its shadow root's (a
24
+ * closed one too, which the document's `activeElement` stops at) or its
25
+ * document's, followed into open shadow roots below.
26
+ *
27
+ * @param {Node} node - A node in the page or in a shadow root.
28
+ * @returns {Element | null} The focused element, or null when nothing there has it.
29
+ * @example
30
+ * focusedIn(menu); // the menu's focused item, in a content script's closed shadow root too
31
+ */
32
+ export declare function focusedIn(node: Node): Element | null;
22
33
  /**
23
34
  * The document a component is made in: `root`'s, or the page's.
24
35
  *
package/src/dom/focus.js CHANGED
@@ -25,6 +25,21 @@ export function focusedElement(root = document) {
25
25
  return active;
26
26
  }
27
27
 
28
+ /**
29
+ * The focused element as seen from where `node` is: its shadow root's (a
30
+ * closed one too, which the document's `activeElement` stops at) or its
31
+ * document's, followed into open shadow roots below.
32
+ *
33
+ * @param {Node} node - A node in the page or in a shadow root.
34
+ * @returns {Element | null} The focused element, or null when nothing there has it.
35
+ * @example
36
+ * focusedIn(menu); // the menu's focused item, in a content script's closed shadow root too
37
+ */
38
+ export function focusedIn(node) {
39
+ const root = /** @type {Document | ShadowRoot} */ (node.getRootNode());
40
+ return 'activeElement' in root ? focusedElement(root) : null;
41
+ }
42
+
28
43
  /**
29
44
  * The document a component is made in: `root`'s, or the page's.
30
45
  *
@@ -111,7 +111,7 @@ export type Tab = {
111
111
  */
112
112
  title: string;
113
113
  /**
114
- * Its favicon's URL.
114
+ * Its favicon's URL (escaped into the markup: an `&` in it is fine).
115
115
  */
116
116
  icon: string;
117
117
  /**
@@ -68,7 +68,7 @@ const icon = (name, size = 20) =>
68
68
  * @returns {string} A data: URL of the SVG.
69
69
  */
70
70
  export const letterIcon = (letter, bg, fg = '#fff') =>
71
- `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" rx="4" fill="${bg}"/><text x="8" y="12" font-family="-apple-system,Helvetica,Arial" font-size="11" font-weight="700" text-anchor="middle" fill="${fg}">${letter}</text></svg>`)}`;
71
+ `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" rx="4" fill="${esc(bg)}"/><text x="8" y="12" font-family="-apple-system,Helvetica,Arial" font-size="11" font-weight="700" text-anchor="middle" fill="${esc(fg)}">${esc(letter)}</text></svg>`)}`;
72
72
 
73
73
  /**
74
74
  * A window's shadow: soft and dropped below it, as macOS draws one (px; `blur` as CSS's and a
@@ -140,7 +140,7 @@ const trafficLights = ({ left, top, size, gap }) =>
140
140
  *
141
141
  * @typedef {object} Tab
142
142
  * @property {string} title Its title.
143
- * @property {string} icon Its favicon's URL.
143
+ * @property {string} icon Its favicon's URL (escaped into the markup: an `&` in it is fine).
144
144
  * @property {boolean} [active] The selected tab.
145
145
  * @property {boolean} [audible] Playing sound.
146
146
  * @property {boolean} [muted] Muted.
@@ -160,7 +160,7 @@ function tabLayout(tabs, w) {
160
160
  const right = 60;
161
161
  const max = 240;
162
162
  const avail = w - left - right - 36;
163
- const width = Math.min(max, Math.floor(avail / tabs.length));
163
+ const width = Math.min(max, Math.floor(avail / Math.max(1, tabs.length)));
164
164
  return tabs.map((_, i) => ({ x: left + i * width, width }));
165
165
  }
166
166
 
@@ -185,15 +185,16 @@ export function windowChrome({ tabs, url, urlHost, theme = 'light', w = WINDOW_W
185
185
  const alert = tab.muted ? icon('muted', 16) : tab.audible ? icon('sound', 16) : '';
186
186
  const showClose = tab.active || width >= 120;
187
187
  return `<div class="tab${tab.active ? ' active' : ''}" style="left:${x}px;width:${width}px">
188
- <img class="fav" src="${tab.icon}" width="16" height="16" alt=""/>
188
+ <img class="fav" src="${esc(tab.icon)}" width="16" height="16" alt=""/>
189
189
  <span class="title">${esc(tab.title)}</span>
190
190
  ${alert ? `<span class="alert">${alert}</span>` : ''}
191
191
  ${showClose ? `<span class="x">${icon('close', 14)}</span>` : ''}
192
192
  </div>${sepAfter ? `<span class="sep" style="left:${x + width - 1}px"></span>` : ''}`;
193
193
  }).join('');
194
- const last = layout[layout.length - 1];
195
- const plusX = last.x + last.width + 4;
196
- const extIcons = ext.map((src) => `<img src="${src}" width="16" height="16" alt="" style="margin:0 9px"/>`).join('');
194
+ // After the last tab, or where the first would be in a window drawn without any.
195
+ const last = layout.at(-1);
196
+ const plusX = last ? last.x + last.width + 4 : 84;
197
+ const extIcons = ext.map((src) => `<img src="${esc(src)}" width="16" height="16" alt="" style="margin:0 9px"/>`).join('');
197
198
  return `
198
199
  <div class="strip">
199
200
  ${trafficLights({ left: 16, top: 15, size: 12, gap: 8 })}
package/src/index.d.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  */
17
17
  export { githubAboutLinks, isPlaceholder } from './about/links.js';
18
18
  export { MODEL_MESSAGES, chromeLanguageModel, createModelSession, describeModel, downloadModel, modelAvailability, } from './ai/model.js';
19
+ export { HOST_GRACE_MS, MODEL_HOST, createHostedModelSession, serveModelOffscreen } from './ai/host.js';
19
20
  export { setShortcuts } from './commands/shortcuts.js';
20
21
  export { BLOCK_CHARS, SLICE_MS, blocksOf, eachInSlices, inSlices } from './content/slices.js';
21
22
  export { MAX_ELEMENTS, otherSiteFrames, renderedElements, shadowRootOf } from './content/elements.js';
@@ -24,18 +25,20 @@ export { pageOverlay } from './content/overlay.js';
24
25
  export { PAGE_TOAST_MS, showPageToast } from './content/page-toast.js';
25
26
  export { MAX_WAIT_MS, SETTLE_MS, watchChanges } from './content/watch.js';
26
27
  export { copyFormats } from './dom/copy.js';
27
- export { focusedElement } from './dom/focus.js';
28
+ export { focusedElement, focusedIn } from './dom/focus.js';
28
29
  export { iconButton, make, spriteIcon } from './dom/make.js';
29
30
  export { MAC_WINDOW, THEMES, WINDOW_BARS, WINDOW_W, letterIcon, macWindowSvg, windowChrome, windowChromeSvg } from './frame/window.js';
30
31
  export { counted, localize, translator } from './dom/i18n.js';
31
32
  export { byId, setAboutLinks, statusFlasher, valueRow } from './dom/page.js';
32
33
  export { setUpPage } from './dom/setup.js';
34
+ export { createShowOnPage } from './panel/show-on-page.js';
33
35
  export { openSidePanel, sidePanelPath } from './panel/side-panel.js';
34
- export { callInPage, importAndCall } from './scripting/call.js';
36
+ export { CALL_FAILED, callInPage, importAndCall } from './scripting/call.js';
37
+ export { contextMenuBuilder } from './scripting/context-menus.js';
35
38
  export { contentScriptSync } from './scripting/registration.js';
36
39
  export { callInTab, isBrowserPage } from './scripting/tab.js';
37
40
  export { createSettingsSaver } from './settings/saver.js';
38
- export { MAX_SYNC_ITEM_BYTES } from './settings/storage.js';
41
+ export { MAX_SYNC_ITEM_BYTES, syncItemBytes } from './settings/storage.js';
39
42
  export { createSettingsStore } from './settings/store.js';
40
43
  export { MAX_HOST_LENGTH, hostPatterns, onSite, originPattern, siteHost, siteLabel, siteOrigin, withSite, withSiteReplaced, withoutSite, } from './sites/sites.js';
41
44
  export { eachLimited } from './time/pool.js';
@@ -59,6 +62,8 @@ export type ModelSession = import('./ai/model.js').ModelSession;
59
62
  export type ModelStatus = import('./ai/model.js').ModelStatus;
60
63
  export type SliceOptions = import('./content/slices.js').SliceOptions;
61
64
  export type Highlighter = import('./content/highlight.js').Highlighter;
65
+ export type ClearReason = import('./content/highlight.js').ClearReason;
66
+ export type ShowOnPage = import('./panel/show-on-page.js').ShowOnPage;
62
67
  export type PageOverlay = import('./content/overlay.js').PageOverlay;
63
68
  export type ClipboardFormats = import('./dom/copy.js').ClipboardFormats;
64
69
  export type WatchHandlers = import('./content/watch.js').WatchHandlers;
@@ -93,6 +98,8 @@ export type Toast = import('./ui/toast.js').Toast;
93
98
  * @typedef {import('./ai/model.js').ModelStatus} ModelStatus
94
99
  * @typedef {import('./content/slices.js').SliceOptions} SliceOptions
95
100
  * @typedef {import('./content/highlight.js').Highlighter} Highlighter
101
+ * @typedef {import('./content/highlight.js').ClearReason} ClearReason
102
+ * @typedef {import('./panel/show-on-page.js').ShowOnPage} ShowOnPage
96
103
  * @typedef {import('./content/overlay.js').PageOverlay} PageOverlay
97
104
  * @typedef {import('./dom/copy.js').ClipboardFormats} ClipboardFormats
98
105
  * @typedef {import('./content/watch.js').WatchHandlers} WatchHandlers
package/src/index.js CHANGED
@@ -19,6 +19,7 @@ export { githubAboutLinks, isPlaceholder } from './about/links.js';
19
19
  export {
20
20
  MODEL_MESSAGES, chromeLanguageModel, createModelSession, describeModel, downloadModel, modelAvailability,
21
21
  } from './ai/model.js';
22
+ export { HOST_GRACE_MS, MODEL_HOST, createHostedModelSession, serveModelOffscreen } from './ai/host.js';
22
23
  export { setShortcuts } from './commands/shortcuts.js';
23
24
  export { BLOCK_CHARS, SLICE_MS, blocksOf, eachInSlices, inSlices } from './content/slices.js';
24
25
  export { MAX_ELEMENTS, otherSiteFrames, renderedElements, shadowRootOf } from './content/elements.js';
@@ -27,18 +28,20 @@ export { pageOverlay } from './content/overlay.js';
27
28
  export { PAGE_TOAST_MS, showPageToast } from './content/page-toast.js';
28
29
  export { MAX_WAIT_MS, SETTLE_MS, watchChanges } from './content/watch.js';
29
30
  export { copyFormats } from './dom/copy.js';
30
- export { focusedElement } from './dom/focus.js';
31
+ export { focusedElement, focusedIn } from './dom/focus.js';
31
32
  export { iconButton, make, spriteIcon } from './dom/make.js';
32
33
  export { MAC_WINDOW, THEMES, WINDOW_BARS, WINDOW_W, letterIcon, macWindowSvg, windowChrome, windowChromeSvg } from './frame/window.js';
33
34
  export { counted, localize, translator } from './dom/i18n.js';
34
35
  export { byId, setAboutLinks, statusFlasher, valueRow } from './dom/page.js';
35
36
  export { setUpPage } from './dom/setup.js';
37
+ export { createShowOnPage } from './panel/show-on-page.js';
36
38
  export { openSidePanel, sidePanelPath } from './panel/side-panel.js';
37
- export { callInPage, importAndCall } from './scripting/call.js';
39
+ export { CALL_FAILED, callInPage, importAndCall } from './scripting/call.js';
40
+ export { contextMenuBuilder } from './scripting/context-menus.js';
38
41
  export { contentScriptSync } from './scripting/registration.js';
39
42
  export { callInTab, isBrowserPage } from './scripting/tab.js';
40
43
  export { createSettingsSaver } from './settings/saver.js';
41
- export { MAX_SYNC_ITEM_BYTES } from './settings/storage.js';
44
+ export { MAX_SYNC_ITEM_BYTES, syncItemBytes } from './settings/storage.js';
42
45
  export { createSettingsStore } from './settings/store.js';
43
46
  export {
44
47
  MAX_HOST_LENGTH, hostPatterns, onSite, originPattern, siteHost, siteLabel, siteOrigin, withSite, withSiteReplaced, withoutSite,
@@ -70,6 +73,8 @@ export { TOAST_MS, createToast } from './ui/toast.js';
70
73
  * @typedef {import('./ai/model.js').ModelStatus} ModelStatus
71
74
  * @typedef {import('./content/slices.js').SliceOptions} SliceOptions
72
75
  * @typedef {import('./content/highlight.js').Highlighter} Highlighter
76
+ * @typedef {import('./content/highlight.js').ClearReason} ClearReason
77
+ * @typedef {import('./panel/show-on-page.js').ShowOnPage} ShowOnPage
73
78
  * @typedef {import('./content/overlay.js').PageOverlay} PageOverlay
74
79
  * @typedef {import('./dom/copy.js').ClipboardFormats} ClipboardFormats
75
80
  * @typedef {import('./content/watch.js').WatchHandlers} WatchHandlers