@arraypress/extension-kit 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +97 -20
  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 +56 -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 +31 -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.js +2 -0
@@ -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
@@ -0,0 +1,82 @@
1
+ /**
2
+ * A side panel's “Show on page”: which of the panel's items (a font, a
3
+ * colour) is boxed on the page, kept true whatever ends it. A click on the
4
+ * item's button shows it, a second click takes it away, another item's
5
+ * replaces it; a rescan or the panel closing clears the page's boxes
6
+ * (`reset`); and when the page clears them itself (Esc there, or the page
7
+ * left: the highlighter's `onClear` reasons `'escape'` and `'pagehide'`,
8
+ * sent to the panel as a message) the panel's button is released without
9
+ * calling the page back (`cleared`).
10
+ *
11
+ * @module panel/show-on-page
12
+ */
13
+ export type ShowOnPageSetup = {
14
+ /**
15
+ * Boxes these on
16
+ * the page (the panel's own call into the tab's frames), scrolling the first into view in
17
+ * `revealFrame`; null takes the boxes away.
18
+ */
19
+ show: (ids: string[] | null, revealFrame?: number) => Promise<unknown>;
20
+ /**
21
+ * Hears which item is shown now (press its
22
+ * button, release the others).
23
+ */
24
+ onChange: (shown: string | null) => void;
25
+ };
26
+ export type ShowOnPage = {
27
+ /**
28
+ * The item shown, or null.
29
+ */
30
+ shown: () => string | null;
31
+ /**
32
+ * Show the item
33
+ * `id` (the page's `ids`), or take it away if it's the one shown. Rejects when the page can't be
34
+ * reached, with nothing shown.
35
+ */
36
+ toggle: (id: string, ids: string[], revealFrame?: number) => Promise<void>;
37
+ /**
38
+ * A rescan started, or the panel is closing: take the page's boxes
39
+ * away if any are shown. The call to the page starts at once (a closing panel still gets it out)
40
+ * and a failure is ignored (the tab is gone).
41
+ */
42
+ reset: () => Promise<void>;
43
+ /**
44
+ * The page says its boxes went: nothing is shown, and the page isn't called.
45
+ */
46
+ cleared: () => void;
47
+ };
48
+ /**
49
+ * @typedef {object} ShowOnPageSetup
50
+ * @property {(ids: string[] | null, revealFrame?: number) => Promise<unknown>} show Boxes these on
51
+ * the page (the panel's own call into the tab's frames), scrolling the first into view in
52
+ * `revealFrame`; null takes the boxes away.
53
+ * @property {(shown: string | null) => void} onChange Hears which item is shown now (press its
54
+ * button, release the others).
55
+ */
56
+ /**
57
+ * @typedef {object} ShowOnPage
58
+ * @property {() => string | null} shown The item shown, or null.
59
+ * @property {(id: string, ids: string[], revealFrame?: number) => Promise<void>} toggle Show the item
60
+ * `id` (the page's `ids`), or take it away if it's the one shown. Rejects when the page can't be
61
+ * reached, with nothing shown.
62
+ * @property {() => Promise<void>} reset A rescan started, or the panel is closing: take the page's boxes
63
+ * away if any are shown. The call to the page starts at once (a closing panel still gets it out)
64
+ * and a failure is ignored (the tab is gone).
65
+ * @property {() => void} cleared The page says its boxes went: nothing is shown, and the page isn't called.
66
+ */
67
+ /**
68
+ * A panel's “Show on page” state.
69
+ *
70
+ * @param {ShowOnPageSetup} setup - How to box items on the page, and who hears what's shown.
71
+ * @returns {ShowOnPage} The controller.
72
+ * @example
73
+ * const onPage = createShowOnPage({
74
+ * show: (ids, frame) => callPage('highlight', [ids, frame]),
75
+ * onChange: (shown) => { for (const button of buttons) button.setAttribute('aria-pressed', String(button.dataset.id === shown)); },
76
+ * });
77
+ * button.addEventListener('click', () => onPage.toggle(font.id, [font.id], font.frameId));
78
+ * chrome.runtime.onMessage.addListener((message) => { if (message.type === 'boxes-cleared') onPage.cleared(); });
79
+ * addEventListener('pagehide', () => onPage.reset());
80
+ * await onPage.reset(); // before a rescan
81
+ */
82
+ export declare function createShowOnPage({ show, onChange }: ShowOnPageSetup): ShowOnPage;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * A side panel's “Show on page”: which of the panel's items (a font, a
3
+ * colour) is boxed on the page, kept true whatever ends it. A click on the
4
+ * item's button shows it, a second click takes it away, another item's
5
+ * replaces it; a rescan or the panel closing clears the page's boxes
6
+ * (`reset`); and when the page clears them itself (Esc there, or the page
7
+ * left: the highlighter's `onClear` reasons `'escape'` and `'pagehide'`,
8
+ * sent to the panel as a message) the panel's button is released without
9
+ * calling the page back (`cleared`).
10
+ *
11
+ * @module panel/show-on-page
12
+ */
13
+
14
+ /**
15
+ * @typedef {object} ShowOnPageSetup
16
+ * @property {(ids: string[] | null, revealFrame?: number) => Promise<unknown>} show Boxes these on
17
+ * the page (the panel's own call into the tab's frames), scrolling the first into view in
18
+ * `revealFrame`; null takes the boxes away.
19
+ * @property {(shown: string | null) => void} onChange Hears which item is shown now (press its
20
+ * button, release the others).
21
+ */
22
+
23
+ /**
24
+ * @typedef {object} ShowOnPage
25
+ * @property {() => string | null} shown The item shown, or null.
26
+ * @property {(id: string, ids: string[], revealFrame?: number) => Promise<void>} toggle Show the item
27
+ * `id` (the page's `ids`), or take it away if it's the one shown. Rejects when the page can't be
28
+ * reached, with nothing shown.
29
+ * @property {() => Promise<void>} reset A rescan started, or the panel is closing: take the page's boxes
30
+ * away if any are shown. The call to the page starts at once (a closing panel still gets it out)
31
+ * and a failure is ignored (the tab is gone).
32
+ * @property {() => void} cleared The page says its boxes went: nothing is shown, and the page isn't called.
33
+ */
34
+
35
+ /**
36
+ * A panel's “Show on page” state.
37
+ *
38
+ * @param {ShowOnPageSetup} setup - How to box items on the page, and who hears what's shown.
39
+ * @returns {ShowOnPage} The controller.
40
+ * @example
41
+ * const onPage = createShowOnPage({
42
+ * show: (ids, frame) => callPage('highlight', [ids, frame]),
43
+ * onChange: (shown) => { for (const button of buttons) button.setAttribute('aria-pressed', String(button.dataset.id === shown)); },
44
+ * });
45
+ * button.addEventListener('click', () => onPage.toggle(font.id, [font.id], font.frameId));
46
+ * chrome.runtime.onMessage.addListener((message) => { if (message.type === 'boxes-cleared') onPage.cleared(); });
47
+ * addEventListener('pagehide', () => onPage.reset());
48
+ * await onPage.reset(); // before a rescan
49
+ */
50
+ export function createShowOnPage({ show, onChange }) {
51
+ /** @type {string | null} */
52
+ let shown = null;
53
+ /** @param {string | null} id */
54
+ const set = (id) => {
55
+ if (shown === id) return;
56
+ shown = id;
57
+ onChange(id);
58
+ };
59
+
60
+ return {
61
+ shown: () => shown,
62
+ async toggle(id, ids, revealFrame) {
63
+ if (shown === id) {
64
+ set(null);
65
+ await show(null);
66
+ return;
67
+ }
68
+ set(id);
69
+ try {
70
+ await show(ids, revealFrame);
71
+ } catch (error) {
72
+ if (shown === id) set(null);
73
+ throw error;
74
+ }
75
+ },
76
+ async reset() {
77
+ if (shown === null) return;
78
+ set(null);
79
+ await show(null).catch(() => {});
80
+ },
81
+ cleared: () => set(null),
82
+ };
83
+ }