@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.
- package/README.md +103 -21
- package/bin/extension-kit.js +2 -1
- package/package.json +1 -1
- package/src/ai/host.d.ts +183 -0
- package/src/ai/host.js +225 -0
- package/src/ai/model.d.ts +2 -1
- package/src/ai/model.js +10 -2
- package/src/build/vendor.d.ts +29 -8
- package/src/build/vendor.js +136 -40
- package/src/content/highlight.d.ts +15 -6
- package/src/content/highlight.js +42 -17
- package/src/content/overlay.d.ts +19 -4
- package/src/content/overlay.js +44 -6
- package/src/content/slices.d.ts +3 -1
- package/src/content/slices.js +29 -14
- package/src/content/watch.d.ts +4 -2
- package/src/content/watch.js +17 -3
- package/src/dom/focus.d.ts +11 -0
- package/src/dom/focus.js +15 -0
- package/src/frame/window.d.ts +1 -1
- package/src/frame/window.js +8 -7
- package/src/index.d.ts +10 -3
- package/src/index.js +8 -3
- package/src/panel/show-on-page.d.ts +82 -0
- package/src/panel/show-on-page.js +83 -0
- package/src/scripting/call.d.ts +29 -9
- package/src/scripting/call.js +35 -11
- package/src/scripting/context-menus.d.ts +45 -0
- package/src/scripting/context-menus.js +53 -0
- package/src/scripting/registration.d.ts +2 -1
- package/src/scripting/registration.js +2 -1
- package/src/scripting/tab.d.ts +17 -5
- package/src/scripting/tab.js +15 -7
- package/src/settings/saver.js +16 -6
- package/src/settings/storage.d.ts +33 -2
- package/src/settings/storage.js +73 -3
- package/src/settings/store.d.ts +17 -0
- package/src/settings/store.js +87 -20
- package/src/sites/sites.d.ts +6 -1
- package/src/sites/sites.js +20 -3
- package/src/styles/chrome-ui.css +59 -30
- package/src/testing/e2e.d.ts +35 -4
- package/src/testing/e2e.js +67 -5
- package/src/testing/fake-model.d.ts +9 -1
- package/src/testing/fake-model.js +44 -23
- package/src/testing/fake-storage.d.ts +41 -13
- package/src/testing/fake-storage.js +163 -51
- package/src/testing/index.d.ts +3 -3
- package/src/testing/index.js +5 -3
- package/src/testing/live-run.d.ts +23 -2
- package/src/testing/live-run.js +64 -18
- package/src/testing/messages.d.ts +7 -3
- package/src/testing/messages.js +50 -9
- package/src/testing/package.d.ts +56 -7
- package/src/testing/package.js +57 -18
- package/src/testing/prompt.d.ts +4 -2
- package/src/testing/prompt.js +7 -3
- package/src/time/pool.d.ts +2 -1
- package/src/time/pool.js +4 -2
- package/src/toolbar/theme.d.ts +10 -2
- package/src/toolbar/theme.js +12 -4
- package/src/ui/menu.d.ts +13 -3
- package/src/ui/menu.js +48 -11
- package/src/ui/model-row.js +5 -1
- package/src/ui/site-list.js +12 -3
- package/src/ui/text-dialog.d.ts +3 -2
- package/src/ui/text-dialog.js +10 -3
- package/src/ui/toast.d.ts +3 -0
- package/src/ui/toast.js +23 -1
package/src/testing/package.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Helpers for an extension's packaging test: list the folder, list the real
|
|
3
3
|
* zip of it, and find every path the extension names that doesn't resolve to
|
|
4
|
-
* a file
|
|
4
|
+
* a file the zip holds (a broken import, a stylesheet link, a manifest entry).
|
|
5
5
|
*
|
|
6
6
|
* @module testing/package
|
|
7
7
|
*/
|
|
@@ -33,6 +33,10 @@ export type ManifestFiles = {
|
|
|
33
33
|
options_ui?: {
|
|
34
34
|
page?: string;
|
|
35
35
|
};
|
|
36
|
+
/**
|
|
37
|
+
* The options page, the older way.
|
|
38
|
+
*/
|
|
39
|
+
options_page?: string;
|
|
36
40
|
/**
|
|
37
41
|
* The popup and toolbar icon.
|
|
38
42
|
*/
|
|
@@ -56,6 +60,39 @@ export type ManifestFiles = {
|
|
|
56
60
|
web_accessible_resources?: {
|
|
57
61
|
resources: string[];
|
|
58
62
|
}[];
|
|
63
|
+
/**
|
|
64
|
+
* Scripts and styles Chrome puts in pages.
|
|
65
|
+
*/
|
|
66
|
+
content_scripts?: {
|
|
67
|
+
js?: string[];
|
|
68
|
+
css?: string[];
|
|
69
|
+
}[];
|
|
70
|
+
/**
|
|
71
|
+
* The locale whose `_locales/<locale>/messages.json` must be there.
|
|
72
|
+
*/
|
|
73
|
+
default_locale?: string;
|
|
74
|
+
/**
|
|
75
|
+
* Rule files.
|
|
76
|
+
*/
|
|
77
|
+
declarative_net_request?: {
|
|
78
|
+
rule_resources?: {
|
|
79
|
+
path: string;
|
|
80
|
+
}[];
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Pages that replace Chrome's (the new tab page).
|
|
84
|
+
*/
|
|
85
|
+
chrome_url_overrides?: Record<string, string>;
|
|
86
|
+
/**
|
|
87
|
+
* The DevTools page.
|
|
88
|
+
*/
|
|
89
|
+
devtools_page?: string;
|
|
90
|
+
/**
|
|
91
|
+
* Sandboxed pages.
|
|
92
|
+
*/
|
|
93
|
+
sandbox?: {
|
|
94
|
+
pages?: string[];
|
|
95
|
+
};
|
|
59
96
|
};
|
|
60
97
|
/**
|
|
61
98
|
* The parts of a manifest that name files.
|
|
@@ -63,16 +100,25 @@ export type ManifestFiles = {
|
|
|
63
100
|
* @typedef {object} ManifestFiles
|
|
64
101
|
* @property {{service_worker?: string}} [background] The worker.
|
|
65
102
|
* @property {{page?: string}} [options_ui] The options page.
|
|
103
|
+
* @property {string} [options_page] The options page, the older way.
|
|
66
104
|
* @property {{default_popup?: string, default_icon?: string | Record<string, string>}} [action] The popup and toolbar icon.
|
|
67
105
|
* @property {{default_path?: string}} [side_panel] The side panel.
|
|
68
106
|
* @property {Record<string, string>} [icons] The extension's icons.
|
|
69
107
|
* @property {{resources: string[]}[]} [web_accessible_resources] Files pages may load.
|
|
108
|
+
* @property {{js?: string[], css?: string[]}[]} [content_scripts] Scripts and styles Chrome puts in pages.
|
|
109
|
+
* @property {string} [default_locale] The locale whose `_locales/<locale>/messages.json` must be there.
|
|
110
|
+
* @property {{rule_resources?: {path: string}[]}} [declarative_net_request] Rule files.
|
|
111
|
+
* @property {Record<string, string>} [chrome_url_overrides] Pages that replace Chrome's (the new tab page).
|
|
112
|
+
* @property {string} [devtools_page] The DevTools page.
|
|
113
|
+
* @property {{pages?: string[]}} [sandbox] Sandboxed pages.
|
|
70
114
|
*/
|
|
71
115
|
/**
|
|
72
116
|
* The paths a manifest names, from the extension's root.
|
|
73
117
|
*
|
|
74
118
|
* @param {ManifestFiles} manifest - The parsed `manifest.json`.
|
|
75
|
-
* @returns {string[]} Worker, pages, icons, side panel
|
|
119
|
+
* @returns {string[]} Worker, pages, icons, side panel, content scripts and their styles, the default
|
|
120
|
+
* locale's messages, rule files, overridden and DevTools pages, and web-accessible resources
|
|
121
|
+
* (less patterns with `*`).
|
|
76
122
|
*/
|
|
77
123
|
export declare function manifestReferences(manifest: ManifestFiles): string[];
|
|
78
124
|
/**
|
|
@@ -84,11 +130,14 @@ export declare function manifestReferences(manifest: ManifestFiles): string[];
|
|
|
84
130
|
*/
|
|
85
131
|
export declare function referencedPath(reference: string): string;
|
|
86
132
|
/**
|
|
87
|
-
* Every path the extension names that isn't a file
|
|
88
|
-
*
|
|
89
|
-
* in its pages,
|
|
90
|
-
*
|
|
91
|
-
* `
|
|
133
|
+
* Every path the extension names that isn't a file it ships: relative imports in its scripts
|
|
134
|
+
* (`from`, `import()` and a bare `import './x.js'`), `chrome.runtime.getURL('…')` paths,
|
|
135
|
+
* `src`/`href` in its pages, `url(…)` and `@import` in its stylesheets, the manifest's entries
|
|
136
|
+
* ({@link manifestReferences}), and any `extra` paths. A file that's there but left out of the
|
|
137
|
+
* zip (`shipsFile`: an SVG under `icons/`, a declaration, a vendor stamp) is as missing as one
|
|
138
|
+
* that isn't: the store build won't have it. A `#fragment` or `?query` is ignored:
|
|
139
|
+
* `getURL('options.html#workspaces')` names `options.html`. A path starting `/` is from the
|
|
140
|
+
* extension's root. Files that don't ship aren't read.
|
|
92
141
|
*
|
|
93
142
|
* @param {string} dir - The extension folder.
|
|
94
143
|
* @param {readonly string[]} [extra] - More paths from the root (say, a script injected by name).
|
package/src/testing/package.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Helpers for an extension's packaging test: list the folder, list the real
|
|
3
3
|
* zip of it, and find every path the extension names that doesn't resolve to
|
|
4
|
-
* a file
|
|
4
|
+
* a file the zip holds (a broken import, a stylesheet link, a manifest entry).
|
|
5
5
|
*
|
|
6
6
|
* @module testing/package
|
|
7
7
|
*/
|
|
@@ -11,7 +11,7 @@ import { existsSync } from 'node:fs';
|
|
|
11
11
|
import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises';
|
|
12
12
|
import { tmpdir } from 'node:os';
|
|
13
13
|
import path from 'node:path';
|
|
14
|
-
import { zipExtension } from '../build/zip.js';
|
|
14
|
+
import { shipsFile, zipExtension } from '../build/zip.js';
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Every file under `dir`.
|
|
@@ -49,27 +49,43 @@ export async function zipEntries(from, exclude) {
|
|
|
49
49
|
* @typedef {object} ManifestFiles
|
|
50
50
|
* @property {{service_worker?: string}} [background] The worker.
|
|
51
51
|
* @property {{page?: string}} [options_ui] The options page.
|
|
52
|
+
* @property {string} [options_page] The options page, the older way.
|
|
52
53
|
* @property {{default_popup?: string, default_icon?: string | Record<string, string>}} [action] The popup and toolbar icon.
|
|
53
54
|
* @property {{default_path?: string}} [side_panel] The side panel.
|
|
54
55
|
* @property {Record<string, string>} [icons] The extension's icons.
|
|
55
56
|
* @property {{resources: string[]}[]} [web_accessible_resources] Files pages may load.
|
|
57
|
+
* @property {{js?: string[], css?: string[]}[]} [content_scripts] Scripts and styles Chrome puts in pages.
|
|
58
|
+
* @property {string} [default_locale] The locale whose `_locales/<locale>/messages.json` must be there.
|
|
59
|
+
* @property {{rule_resources?: {path: string}[]}} [declarative_net_request] Rule files.
|
|
60
|
+
* @property {Record<string, string>} [chrome_url_overrides] Pages that replace Chrome's (the new tab page).
|
|
61
|
+
* @property {string} [devtools_page] The DevTools page.
|
|
62
|
+
* @property {{pages?: string[]}} [sandbox] Sandboxed pages.
|
|
56
63
|
*/
|
|
57
64
|
|
|
58
65
|
/**
|
|
59
66
|
* The paths a manifest names, from the extension's root.
|
|
60
67
|
*
|
|
61
68
|
* @param {ManifestFiles} manifest - The parsed `manifest.json`.
|
|
62
|
-
* @returns {string[]} Worker, pages, icons, side panel
|
|
69
|
+
* @returns {string[]} Worker, pages, icons, side panel, content scripts and their styles, the default
|
|
70
|
+
* locale's messages, rule files, overridden and DevTools pages, and web-accessible resources
|
|
71
|
+
* (less patterns with `*`).
|
|
63
72
|
*/
|
|
64
73
|
export function manifestReferences(manifest) {
|
|
65
74
|
const icon = manifest.action?.default_icon;
|
|
66
75
|
return [
|
|
67
76
|
manifest.background?.service_worker,
|
|
68
77
|
manifest.options_ui?.page,
|
|
78
|
+
manifest.options_page,
|
|
69
79
|
manifest.action?.default_popup,
|
|
70
80
|
manifest.side_panel?.default_path,
|
|
81
|
+
manifest.devtools_page,
|
|
71
82
|
...Object.values(manifest.icons ?? {}),
|
|
72
83
|
...(typeof icon === 'string' ? [icon] : Object.values(icon ?? {})),
|
|
84
|
+
...(manifest.content_scripts ?? []).flatMap((entry) => [...(entry.js ?? []), ...(entry.css ?? [])]),
|
|
85
|
+
...(manifest.default_locale ? [`_locales/${manifest.default_locale}/messages.json`] : []),
|
|
86
|
+
...(manifest.declarative_net_request?.rule_resources ?? []).map((entry) => entry.path),
|
|
87
|
+
...Object.values(manifest.chrome_url_overrides ?? {}),
|
|
88
|
+
...(manifest.sandbox?.pages ?? []),
|
|
73
89
|
...(manifest.web_accessible_resources ?? []).flatMap((entry) => entry.resources),
|
|
74
90
|
].filter((ref) => typeof ref === 'string').filter((ref) => !ref.includes('*'));
|
|
75
91
|
}
|
|
@@ -88,12 +104,27 @@ export function referencedPath(reference) {
|
|
|
88
104
|
/** A reference with a scheme (`https:`, `data:`, `mailto:`) or protocol-relative: not a file of the extension. */
|
|
89
105
|
const ELSEWHERE = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i;
|
|
90
106
|
|
|
107
|
+
/** A module a script imports: `… from './x.js'`, `import('./x.js')`, or `import './x.js'` alone on its line; either quote. */
|
|
108
|
+
const IMPORTS = [/(?:from|import\()\s*(['"])(\.{1,2}\/[^'"]+)\1/g, /^\s*import\s*(['"])(\.{1,2}\/[^'"]+)\1/gm];
|
|
109
|
+
|
|
110
|
+
/** `getURL('…')`, either quote. */
|
|
111
|
+
const GET_URL = /getURL\(\s*(['"])([^'"]+)\1\s*\)/g;
|
|
112
|
+
|
|
113
|
+
/** A page's `src="…"` or `href="…"`, either quote. */
|
|
114
|
+
const ATTRIBUTES = /\s(?:src|href)\s*=\s*(['"])([^'"]+)\1/g;
|
|
115
|
+
|
|
116
|
+
/** A stylesheet's `url(…)` and `@import "…"`. */
|
|
117
|
+
const STYLE_URLS = [/url\(\s*(['"]?)([^'")]+)\1\s*\)/g, /@import\s+(['"])([^'"]+)\1/g];
|
|
118
|
+
|
|
91
119
|
/**
|
|
92
|
-
* Every path the extension names that isn't a file
|
|
93
|
-
*
|
|
94
|
-
* in its pages,
|
|
95
|
-
*
|
|
96
|
-
* `
|
|
120
|
+
* Every path the extension names that isn't a file it ships: relative imports in its scripts
|
|
121
|
+
* (`from`, `import()` and a bare `import './x.js'`), `chrome.runtime.getURL('…')` paths,
|
|
122
|
+
* `src`/`href` in its pages, `url(…)` and `@import` in its stylesheets, the manifest's entries
|
|
123
|
+
* ({@link manifestReferences}), and any `extra` paths. A file that's there but left out of the
|
|
124
|
+
* zip (`shipsFile`: an SVG under `icons/`, a declaration, a vendor stamp) is as missing as one
|
|
125
|
+
* that isn't: the store build won't have it. A `#fragment` or `?query` is ignored:
|
|
126
|
+
* `getURL('options.html#workspaces')` names `options.html`. A path starting `/` is from the
|
|
127
|
+
* extension's root. Files that don't ship aren't read.
|
|
97
128
|
*
|
|
98
129
|
* @param {string} dir - The extension folder.
|
|
99
130
|
* @param {readonly string[]} [extra] - More paths from the root (say, a script injected by name).
|
|
@@ -107,18 +138,26 @@ export async function missingReferences(dir, extra = []) {
|
|
|
107
138
|
const check = (from, target) => {
|
|
108
139
|
const name = referencedPath(target);
|
|
109
140
|
if (!name || ELSEWHERE.test(target)) return;
|
|
110
|
-
const file = path.resolve(root, from, name);
|
|
111
|
-
|
|
141
|
+
const file = name.startsWith('/') ? path.join(root, name) : path.resolve(root, from, name);
|
|
142
|
+
const inside = path.relative(root, file).split(path.sep).join('/');
|
|
143
|
+
if (inside.startsWith('..') || !existsSync(file) || !shipsFile(inside)) missing.push(`${from || 'root'} → ${target}`);
|
|
144
|
+
};
|
|
145
|
+
/** @param {string} source @param {readonly RegExp[]} patterns @param {string} from */
|
|
146
|
+
const each = (source, patterns, from) => {
|
|
147
|
+
for (const pattern of patterns) for (const match of source.matchAll(pattern)) check(from, match[2]);
|
|
112
148
|
};
|
|
113
|
-
for (const file of await filesUnder(root)) {
|
|
149
|
+
for (const file of (await filesUnder(root)).filter(shipsFile)) {
|
|
114
150
|
const from = path.posix.dirname(file);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
151
|
+
const kind = path.posix.extname(file);
|
|
152
|
+
if (kind !== '.js' && kind !== '.html' && kind !== '.css') continue;
|
|
153
|
+
const source = await readFile(path.join(root, file), 'utf8');
|
|
154
|
+
if (kind === '.js') {
|
|
155
|
+
each(source, IMPORTS, from);
|
|
156
|
+
each(source, [GET_URL], '');
|
|
157
|
+
} else if (kind === '.html') {
|
|
158
|
+
each(source, [ATTRIBUTES], from);
|
|
159
|
+
} else {
|
|
160
|
+
each(source, STYLE_URLS, from);
|
|
122
161
|
}
|
|
123
162
|
}
|
|
124
163
|
const manifest = JSON.parse(await readFile(path.join(root, 'manifest.json'), 'utf8'));
|
package/src/testing/prompt.d.ts
CHANGED
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
* Stand in for Chrome's permission prompt in a page: each `chrome.permissions.request` is
|
|
9
9
|
* recorded on `window.asked` (what was asked, and whether a click was behind it,
|
|
10
10
|
* `navigator.userActivation.isActive`) and answered from `answers` (the next one; the last is
|
|
11
|
-
* kept), as a person clicking “Allow” or “Deny” would.
|
|
12
|
-
*
|
|
11
|
+
* kept), as a person clicking “Allow” or “Deny” would. A request with no click behind it is
|
|
12
|
+
* refused as Chrome refuses it (“This function must be called during a user gesture”) and takes
|
|
13
|
+
* no answer. Runs in the page: give it to `page.evaluate` or `page.addInitScript`, so it takes
|
|
14
|
+
* no closures.
|
|
13
15
|
*
|
|
14
16
|
* @param {boolean[]} answers - The answers, in order.
|
|
15
17
|
* @example
|
package/src/testing/prompt.js
CHANGED
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
* Stand in for Chrome's permission prompt in a page: each `chrome.permissions.request` is
|
|
10
10
|
* recorded on `window.asked` (what was asked, and whether a click was behind it,
|
|
11
11
|
* `navigator.userActivation.isActive`) and answered from `answers` (the next one; the last is
|
|
12
|
-
* kept), as a person clicking “Allow” or “Deny” would.
|
|
13
|
-
*
|
|
12
|
+
* kept), as a person clicking “Allow” or “Deny” would. A request with no click behind it is
|
|
13
|
+
* refused as Chrome refuses it (“This function must be called during a user gesture”) and takes
|
|
14
|
+
* no answer. Runs in the page: give it to `page.evaluate` or `page.addInitScript`, so it takes
|
|
15
|
+
* no closures.
|
|
14
16
|
*
|
|
15
17
|
* @param {boolean[]} answers - The answers, in order.
|
|
16
18
|
* @example
|
|
@@ -23,7 +25,9 @@ export function playPermissionPrompt(answers) {
|
|
|
23
25
|
const asked = [];
|
|
24
26
|
Object.assign(window, { asked });
|
|
25
27
|
chrome.permissions.request = /** @type {typeof chrome.permissions.request} */ (/** @type {unknown} */ (async (/** @type {chrome.permissions.Permissions} */ permissions) => {
|
|
26
|
-
|
|
28
|
+
const gesture = navigator.userActivation.isActive;
|
|
29
|
+
asked.push({ permissions: permissions.permissions, origins: permissions.origins, gesture });
|
|
30
|
+
if (!gesture) throw new Error('This function must be called during a user gesture');
|
|
27
31
|
return answers.length > 1 ? answers.shift() : answers[0];
|
|
28
32
|
}));
|
|
29
33
|
}
|
package/src/time/pool.d.ts
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
*
|
|
14
14
|
* @template T
|
|
15
15
|
* @param {readonly T[]} items - The items.
|
|
16
|
-
* @param {number} limit - At most this many at once
|
|
16
|
+
* @param {number} limit - At most this many at once (at least one: a limit of 0 or less, or one
|
|
17
|
+
* that isn't a number, runs them one at a time rather than not at all).
|
|
17
18
|
* @param {(item: T, slot: number, index: number) => Promise<void>} work - Deals with one item;
|
|
18
19
|
* `slot` (0 to limit − 1) is the same for everything one slot does, for per-slot resources
|
|
19
20
|
* (a browser each).
|
package/src/time/pool.js
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
*
|
|
15
15
|
* @template T
|
|
16
16
|
* @param {readonly T[]} items - The items.
|
|
17
|
-
* @param {number} limit - At most this many at once
|
|
17
|
+
* @param {number} limit - At most this many at once (at least one: a limit of 0 or less, or one
|
|
18
|
+
* that isn't a number, runs them one at a time rather than not at all).
|
|
18
19
|
* @param {(item: T, slot: number, index: number) => Promise<void>} work - Deals with one item;
|
|
19
20
|
* `slot` (0 to limit − 1) is the same for everything one slot does, for per-slot resources
|
|
20
21
|
* (a browser each).
|
|
@@ -24,7 +25,8 @@
|
|
|
24
25
|
*/
|
|
25
26
|
export async function eachLimited(items, limit, work) {
|
|
26
27
|
let next = 0;
|
|
27
|
-
|
|
28
|
+
const slots = Math.max(1, Math.floor(limit) || 1);
|
|
29
|
+
await Promise.all(Array.from({ length: Math.min(slots, items.length) }, async (_, slot) => {
|
|
28
30
|
while (next < items.length) {
|
|
29
31
|
const index = next;
|
|
30
32
|
next += 1;
|
package/src/toolbar/theme.d.ts
CHANGED
|
@@ -132,9 +132,12 @@ export type OffscreenApi = {
|
|
|
132
132
|
*
|
|
133
133
|
* @param {OffscreenApi} api - `chrome.offscreen` and `chrome.runtime`.
|
|
134
134
|
* @param {string} [url] - The document, from the extension's root. Default {@link OFFSCREEN_PATH}.
|
|
135
|
+
* @param {string} [justification] - What the document is for, when it does more than watch the
|
|
136
|
+
* scheme (it answers for Chrome's on-device model too, `serveModelOffscreen`): Chrome keeps it
|
|
137
|
+
* with the document, so it has to be the whole truth.
|
|
135
138
|
* @returns {Promise<boolean>} Whether it opened one (false: already open).
|
|
136
139
|
*/
|
|
137
|
-
export declare function watchFromOffscreen({ offscreen, runtime }: OffscreenApi, url?: string): Promise<boolean>;
|
|
140
|
+
export declare function watchFromOffscreen({ offscreen, runtime }: OffscreenApi, url?: string, justification?: string): Promise<boolean>;
|
|
138
141
|
export type ToolbarWorkerApi = {
|
|
139
142
|
/**
|
|
140
143
|
* `chrome.storage`.
|
|
@@ -179,9 +182,14 @@ export type ToolbarWorkerApi = {
|
|
|
179
182
|
* worker wakes.
|
|
180
183
|
*
|
|
181
184
|
* @param {ToolbarWorkerApi} api - `chrome`, or a fake.
|
|
185
|
+
* @param {{url?: string, justification?: string}} [document] - The offscreen document and what
|
|
186
|
+
* it's for, when it isn't the kit's defaults ({@link watchFromOffscreen}).
|
|
182
187
|
* @returns {ToolbarTheme} The theme, for tests.
|
|
183
188
|
* @example
|
|
184
189
|
* // background.js
|
|
185
190
|
* startToolbarTheme(chrome);
|
|
186
191
|
*/
|
|
187
|
-
export declare function startToolbarTheme(api: ToolbarWorkerApi
|
|
192
|
+
export declare function startToolbarTheme(api: ToolbarWorkerApi, { url, justification }?: {
|
|
193
|
+
url?: string;
|
|
194
|
+
justification?: string;
|
|
195
|
+
}): ToolbarTheme;
|
package/src/toolbar/theme.js
CHANGED
|
@@ -116,6 +116,9 @@ export function createToolbarTheme({ storage, action, extensionId }) {
|
|
|
116
116
|
/** @type {Promise<boolean> | null} */
|
|
117
117
|
let opening = null;
|
|
118
118
|
|
|
119
|
+
/** Why the offscreen document is open, as Chrome is told. */
|
|
120
|
+
const SCHEME_JUSTIFICATION = 'Reads whether Chrome is in light or dark mode, to show the matching toolbar icon.';
|
|
121
|
+
|
|
119
122
|
/**
|
|
120
123
|
* Open the offscreen document that watches the scheme (reason MATCH_MEDIA),
|
|
121
124
|
* unless it's open. It stays open: it's one small page in the extension's own
|
|
@@ -125,16 +128,19 @@ let opening = null;
|
|
|
125
128
|
*
|
|
126
129
|
* @param {OffscreenApi} api - `chrome.offscreen` and `chrome.runtime`.
|
|
127
130
|
* @param {string} [url] - The document, from the extension's root. Default {@link OFFSCREEN_PATH}.
|
|
131
|
+
* @param {string} [justification] - What the document is for, when it does more than watch the
|
|
132
|
+
* scheme (it answers for Chrome's on-device model too, `serveModelOffscreen`): Chrome keeps it
|
|
133
|
+
* with the document, so it has to be the whole truth.
|
|
128
134
|
* @returns {Promise<boolean>} Whether it opened one (false: already open).
|
|
129
135
|
*/
|
|
130
|
-
export function watchFromOffscreen({ offscreen, runtime }, url = OFFSCREEN_PATH) {
|
|
136
|
+
export function watchFromOffscreen({ offscreen, runtime }, url = OFFSCREEN_PATH, justification = SCHEME_JUSTIFICATION) {
|
|
131
137
|
opening ??= (async () => {
|
|
132
138
|
const open = await runtime.getContexts({ contextTypes: ['OFFSCREEN_DOCUMENT'] });
|
|
133
139
|
if (open.length) return false;
|
|
134
140
|
await offscreen.createDocument({
|
|
135
141
|
url,
|
|
136
142
|
reasons: [offscreen.Reason.MATCH_MEDIA],
|
|
137
|
-
justification
|
|
143
|
+
justification,
|
|
138
144
|
});
|
|
139
145
|
return true;
|
|
140
146
|
})().finally(() => { opening = null; });
|
|
@@ -159,15 +165,17 @@ export function watchFromOffscreen({ offscreen, runtime }, url = OFFSCREEN_PATH)
|
|
|
159
165
|
* worker wakes.
|
|
160
166
|
*
|
|
161
167
|
* @param {ToolbarWorkerApi} api - `chrome`, or a fake.
|
|
168
|
+
* @param {{url?: string, justification?: string}} [document] - The offscreen document and what
|
|
169
|
+
* it's for, when it isn't the kit's defaults ({@link watchFromOffscreen}).
|
|
162
170
|
* @returns {ToolbarTheme} The theme, for tests.
|
|
163
171
|
* @example
|
|
164
172
|
* // background.js
|
|
165
173
|
* startToolbarTheme(chrome);
|
|
166
174
|
*/
|
|
167
|
-
export function startToolbarTheme(api) {
|
|
175
|
+
export function startToolbarTheme(api, { url, justification } = {}) {
|
|
168
176
|
const theme = createToolbarTheme({ storage: api.storage.local, action: api.action, extensionId: api.runtime.id });
|
|
169
177
|
theme.restore();
|
|
170
|
-
watchFromOffscreen(api).catch((error) => console.error('Opening the colour scheme watcher:', error));
|
|
178
|
+
watchFromOffscreen(api, url, justification).catch((error) => console.error('Opening the colour scheme watcher:', error));
|
|
171
179
|
api.runtime.onMessage.addListener((message, sender) => {
|
|
172
180
|
theme.onMessage(message, sender);
|
|
173
181
|
return false;
|
package/src/ui/menu.d.ts
CHANGED
|
@@ -99,8 +99,10 @@ export type Box = {
|
|
|
99
99
|
*
|
|
100
100
|
* @param {{width: number, height: number}} size - The menu's size.
|
|
101
101
|
* @param {{width: number, height: number}} viewport - The window's (`innerWidth`, `innerHeight`).
|
|
102
|
-
* @param {{anchor?: Box, x?: number, y?: number, placement?: 'over' | 'below'}} where - The
|
|
103
|
-
* box, or a point; `placement` says where it goes from an anchor (default `'over'`)
|
|
102
|
+
* @param {{anchor?: Box, x?: number, y?: number, placement?: 'over' | 'below', rtl?: boolean}} where - The
|
|
103
|
+
* anchor's box, or a point; `placement` says where it goes from an anchor (default `'over'`); `rtl`
|
|
104
|
+
* (a right-to-left page) lines its start up with the anchor's left edge and opens leftwards from a
|
|
105
|
+
* point, as Chrome mirrors its own.
|
|
104
106
|
* @returns {{left: number, top: number}} The menu's top-left, in px.
|
|
105
107
|
* @example
|
|
106
108
|
* menuPosition({width: 160, height: 120}, {width: 800, height: 600}, {anchor: button.getBoundingClientRect()});
|
|
@@ -112,11 +114,12 @@ export declare function menuPosition(size: {
|
|
|
112
114
|
}, viewport: {
|
|
113
115
|
width: number;
|
|
114
116
|
height: number;
|
|
115
|
-
}, { anchor, x, y, placement }: {
|
|
117
|
+
}, { anchor, x, y, placement, rtl }: {
|
|
116
118
|
anchor?: Box;
|
|
117
119
|
x?: number;
|
|
118
120
|
y?: number;
|
|
119
121
|
placement?: 'over' | 'below';
|
|
122
|
+
rtl?: boolean;
|
|
120
123
|
}): {
|
|
121
124
|
left: number;
|
|
122
125
|
top: number;
|
|
@@ -163,6 +166,11 @@ export type ActionMenu = {
|
|
|
163
166
|
* Close it, if open, and return the focus.
|
|
164
167
|
*/
|
|
165
168
|
close: () => void;
|
|
169
|
+
/**
|
|
170
|
+
* Take it off the page and stop listening to the window (a content
|
|
171
|
+
* script's overlay being removed).
|
|
172
|
+
*/
|
|
173
|
+
destroy: () => void;
|
|
166
174
|
};
|
|
167
175
|
/**
|
|
168
176
|
* What {@link createMenu} takes.
|
|
@@ -182,6 +190,8 @@ export type ActionMenu = {
|
|
|
182
190
|
* @property {(entries: readonly MenuEntry[], where: MenuPlace) => void} open Fill it with `entries`
|
|
183
191
|
* and open it (closing it first if it's open).
|
|
184
192
|
* @property {() => void} close Close it, if open, and return the focus.
|
|
193
|
+
* @property {() => void} destroy Take it off the page and stop listening to the window (a content
|
|
194
|
+
* script's overlay being removed).
|
|
185
195
|
*/
|
|
186
196
|
/**
|
|
187
197
|
* Make a page's action menu: one `<dialog class="menu">` that any number of
|
package/src/ui/menu.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* @module ui/menu
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { focusedElement, rootDocument } from '../dom/focus.js';
|
|
17
|
+
import { focusedElement, focusedIn, rootDocument } from '../dom/focus.js';
|
|
18
18
|
|
|
19
19
|
/** The gap kept between a menu and the window's edges, in px. */
|
|
20
20
|
export const MENU_EDGE = 8;
|
|
@@ -67,19 +67,21 @@ const BELOW_GAP = 4;
|
|
|
67
67
|
*
|
|
68
68
|
* @param {{width: number, height: number}} size - The menu's size.
|
|
69
69
|
* @param {{width: number, height: number}} viewport - The window's (`innerWidth`, `innerHeight`).
|
|
70
|
-
* @param {{anchor?: Box, x?: number, y?: number, placement?: 'over' | 'below'}} where - The
|
|
71
|
-
* box, or a point; `placement` says where it goes from an anchor (default `'over'`)
|
|
70
|
+
* @param {{anchor?: Box, x?: number, y?: number, placement?: 'over' | 'below', rtl?: boolean}} where - The
|
|
71
|
+
* anchor's box, or a point; `placement` says where it goes from an anchor (default `'over'`); `rtl`
|
|
72
|
+
* (a right-to-left page) lines its start up with the anchor's left edge and opens leftwards from a
|
|
73
|
+
* point, as Chrome mirrors its own.
|
|
72
74
|
* @returns {{left: number, top: number}} The menu's top-left, in px.
|
|
73
75
|
* @example
|
|
74
76
|
* menuPosition({width: 160, height: 120}, {width: 800, height: 600}, {anchor: button.getBoundingClientRect()});
|
|
75
77
|
* // → {left: anchor.right - 160, top: anchor.top}
|
|
76
78
|
*/
|
|
77
|
-
export function menuPosition(size, viewport, { anchor, x = 0, y = 0, placement = 'over' }) {
|
|
79
|
+
export function menuPosition(size, viewport, { anchor, x = 0, y = 0, placement = 'over', rtl = false }) {
|
|
78
80
|
const { width, height } = size;
|
|
79
81
|
const left = (/** @type {number} */ start) => Math.max(MENU_EDGE, Math.min(start, viewport.width - width - MENU_EDGE));
|
|
80
82
|
const room = (/** @type {number} */ top) => top + height <= viewport.height - MENU_EDGE;
|
|
81
|
-
if (!anchor) return { left: left(x), top: Math.max(MENU_EDGE, room(y) ? y : y - height) };
|
|
82
|
-
const start = left(anchor.right - width);
|
|
83
|
+
if (!anchor) return { left: left(rtl ? x - width : x), top: Math.max(MENU_EDGE, room(y) ? y : y - height) };
|
|
84
|
+
const start = left(rtl ? anchor.left : anchor.right - width);
|
|
83
85
|
if (placement === 'below') {
|
|
84
86
|
const below = anchor.bottom + BELOW_GAP;
|
|
85
87
|
return { left: start, top: Math.max(MENU_EDGE, room(below) ? below : anchor.top - height - BELOW_GAP) };
|
|
@@ -99,6 +101,20 @@ export function isInside(box, x, y) {
|
|
|
99
101
|
return x >= box.left && x <= box.right && y >= box.top && y <= box.bottom;
|
|
100
102
|
}
|
|
101
103
|
|
|
104
|
+
/**
|
|
105
|
+
* The box a menu opens from: the anchor's, or, when the anchor has none (it isn't drawn: a row's
|
|
106
|
+
* buttons shown only while the row is hovered or holds the focus), its nearest ancestor's that has.
|
|
107
|
+
*
|
|
108
|
+
* @param {Element} anchor - The button that opened the menu.
|
|
109
|
+
* @returns {DOMRect | undefined} The box; undefined when nothing up to the root is drawn.
|
|
110
|
+
*/
|
|
111
|
+
function anchorBox(anchor) {
|
|
112
|
+
for (let at = /** @type {Element | null} */ (anchor); at; at = at.parentElement) {
|
|
113
|
+
if (at.getClientRects().length) return at.getBoundingClientRect();
|
|
114
|
+
}
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
102
118
|
/**
|
|
103
119
|
* What {@link createMenu} takes.
|
|
104
120
|
*
|
|
@@ -118,6 +134,8 @@ export function isInside(box, x, y) {
|
|
|
118
134
|
* @property {(entries: readonly MenuEntry[], where: MenuPlace) => void} open Fill it with `entries`
|
|
119
135
|
* and open it (closing it first if it's open).
|
|
120
136
|
* @property {() => void} close Close it, if open, and return the focus.
|
|
137
|
+
* @property {() => void} destroy Take it off the page and stop listening to the window (a content
|
|
138
|
+
* script's overlay being removed).
|
|
121
139
|
*/
|
|
122
140
|
|
|
123
141
|
/**
|
|
@@ -168,21 +186,32 @@ export function createMenu({ id, placement = 'over', root, doc = rootDocument(ro
|
|
|
168
186
|
|
|
169
187
|
menu.addEventListener('keydown', (event) => {
|
|
170
188
|
const list = items();
|
|
171
|
-
|
|
189
|
+
// Asked of the menu's own root: a closed shadow root's focused element can't be seen from the document.
|
|
190
|
+
const at = list.indexOf(/** @type {HTMLButtonElement} */ (focusedIn(menu)));
|
|
172
191
|
/** @param {number} index */
|
|
173
192
|
const go = (index) => {
|
|
174
|
-
|
|
193
|
+
handled();
|
|
175
194
|
list[(index + list.length) % list.length]?.focus();
|
|
176
195
|
};
|
|
196
|
+
/** The key is the menu's: the page under a content script's menu doesn't act on it too (its own shortcuts). */
|
|
197
|
+
const handled = () => {
|
|
198
|
+
event.preventDefault();
|
|
199
|
+
event.stopPropagation();
|
|
200
|
+
};
|
|
177
201
|
if (event.key === 'ArrowDown') go(at + 1);
|
|
178
202
|
else if (event.key === 'ArrowUp') go(at < 0 ? -1 : at - 1);
|
|
179
203
|
else if (event.key === 'Home') go(0);
|
|
180
204
|
else if (event.key === 'End') go(-1);
|
|
181
205
|
else if (event.key === 'Escape' || event.key === 'Tab') {
|
|
182
|
-
|
|
206
|
+
handled();
|
|
183
207
|
close();
|
|
184
208
|
}
|
|
185
209
|
});
|
|
210
|
+
// Closed any other way (the browser's own close, another script): the button no longer says it's open.
|
|
211
|
+
menu.addEventListener('close', () => {
|
|
212
|
+
expanded?.setAttribute('aria-expanded', 'false');
|
|
213
|
+
expanded = null;
|
|
214
|
+
});
|
|
186
215
|
// Esc the browser handles itself (when the keydown above didn't see it).
|
|
187
216
|
menu.addEventListener('cancel', (event) => {
|
|
188
217
|
event.preventDefault();
|
|
@@ -219,22 +248,30 @@ export function createMenu({ id, placement = 'over', root, doc = rootDocument(ro
|
|
|
219
248
|
if (label) menu.setAttribute('aria-label', label);
|
|
220
249
|
else menu.removeAttribute('aria-label');
|
|
221
250
|
|
|
222
|
-
const active = focusedElement(doc);
|
|
251
|
+
const active = focusedIn(menu) ?? focusedElement(doc);
|
|
223
252
|
returnFocus = focusTo ?? anchor ?? (active instanceof view.HTMLElement ? active : null);
|
|
224
253
|
if (anchor?.hasAttribute('aria-haspopup')) {
|
|
225
254
|
anchor.setAttribute('aria-expanded', 'true');
|
|
226
255
|
expanded = anchor;
|
|
227
256
|
}
|
|
257
|
+
// Measured before the menu opens: it takes the focus, and a button shown only while its row
|
|
258
|
+
// holds the focus (`.sp-suffix`) has no box after that, so the menu would open in the corner.
|
|
259
|
+
const from = anchor && anchorBox(anchor);
|
|
228
260
|
menu.showModal();
|
|
229
261
|
const { width, height } = menu.getBoundingClientRect();
|
|
230
262
|
const { left, top } = menuPosition({ width, height }, { width: view.innerWidth, height: view.innerHeight }, {
|
|
231
|
-
anchor:
|
|
263
|
+
anchor: from, x, y, placement, rtl: view.getComputedStyle(menu).direction === 'rtl',
|
|
232
264
|
});
|
|
233
265
|
menu.style.left = `${left}px`;
|
|
234
266
|
menu.style.top = `${top}px`;
|
|
235
267
|
items()[0]?.focus();
|
|
236
268
|
},
|
|
237
269
|
close,
|
|
270
|
+
destroy() {
|
|
271
|
+
shut();
|
|
272
|
+
view.removeEventListener('blur', close);
|
|
273
|
+
menu.remove();
|
|
274
|
+
},
|
|
238
275
|
};
|
|
239
276
|
}
|
|
240
277
|
|
package/src/ui/model-row.js
CHANGED
|
@@ -94,7 +94,11 @@ export function createModelRow({ status, help, download, progress }, { options,
|
|
|
94
94
|
progress.value = loaded;
|
|
95
95
|
status.textContent = t(MODEL_MESSAGES.downloading, [String(Math.round(loaded * 100))]);
|
|
96
96
|
},
|
|
97
|
-
}).then(
|
|
97
|
+
}).then(
|
|
98
|
+
// The state as the code that uses it sees it; if it can't be asked (the worker didn't answer), as this page does.
|
|
99
|
+
async (here) => show(await state().catch(() => here)),
|
|
100
|
+
() => show('downloadable', t(MODEL_MESSAGES.downloadFailed)),
|
|
101
|
+
);
|
|
98
102
|
});
|
|
99
103
|
|
|
100
104
|
return {
|
package/src/ui/site-list.js
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* @module ui/site-list
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
+
import { focusedIn } from '../dom/focus.js';
|
|
30
31
|
import { createMenu, moreActionsButton } from './menu.js';
|
|
31
32
|
import { askText } from './text-dialog.js';
|
|
32
33
|
import { MAX_HOST_LENGTH, siteLabel } from '../sites/sites.js';
|
|
@@ -177,9 +178,11 @@ export function createSiteList({
|
|
|
177
178
|
form?.addEventListener('submit', async (event) => {
|
|
178
179
|
event.preventDefault();
|
|
179
180
|
const input = /** @type {HTMLInputElement} */ (form.querySelector('input'));
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
181
|
+
const typed = input.value;
|
|
182
|
+
const site = parse(typed);
|
|
183
|
+
if (!site) invalid?.(typed);
|
|
184
|
+
// Cleared only if it's still what was added: adding can wait on Chrome (a permission prompt), and the next site may be typed meanwhile.
|
|
185
|
+
else if (await add(site) !== false && input.value === typed) input.value = '';
|
|
183
186
|
});
|
|
184
187
|
addButton?.addEventListener('click', async () => {
|
|
185
188
|
const site = await ask(w.addSite, w.add);
|
|
@@ -219,6 +222,11 @@ export function createSiteList({
|
|
|
219
222
|
|
|
220
223
|
return {
|
|
221
224
|
render(sites) {
|
|
225
|
+
// The focus stays in the list across a redraw: on the same row's button (the one after a removed
|
|
226
|
+
// row, the last after the last), or on the way to add when no row is left. Without this, the
|
|
227
|
+
// row's button a dialog or menu gave the focus back to is replaced and the focus falls to the page.
|
|
228
|
+
const focused = focusedIn(list);
|
|
229
|
+
const at = focused && list.contains(focused) ? [...list.children].findIndex((row) => row.contains(focused)) : -1;
|
|
222
230
|
list.replaceChildren(...sites.map((site) => {
|
|
223
231
|
const row = doc.createElement('li');
|
|
224
232
|
row.className = 'row';
|
|
@@ -229,6 +237,7 @@ export function createSiteList({
|
|
|
229
237
|
return row;
|
|
230
238
|
}));
|
|
231
239
|
if (empty) empty.hidden = sites.length > 0;
|
|
240
|
+
if (at >= 0) (list.children[Math.min(at, sites.length - 1)]?.querySelector('button') ?? addButton ?? form?.querySelector('input'))?.focus();
|
|
232
241
|
},
|
|
233
242
|
};
|
|
234
243
|
}
|
package/src/ui/text-dialog.d.ts
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* folder's name (`askText`); several, as chrome://settings/searchEngines asks
|
|
5
5
|
* for a site search (`askFields`). The action button stays disabled while a
|
|
6
6
|
* field is blank or isn't valid, with the reason under that field. Enter
|
|
7
|
-
* means the action button, Esc and Cancel cancel
|
|
8
|
-
*
|
|
7
|
+
* means the action button, Esc and Cancel cancel (as does the dialog being
|
|
8
|
+
* closed any other way), and the focus goes back to what opened it. A reason
|
|
9
|
+
* is announced as it appears, and its field marked invalid. The page needs no markup: the dialog (`#text-dialog`) is
|
|
9
10
|
* made the first time, its fields each time it's asked.
|
|
10
11
|
*
|
|
11
12
|
* @module ui/text-dialog
|