@arraypress/extension-kit 1.8.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -2
- package/package.json +1 -1
- package/src/build/index.d.ts +2 -1
- package/src/build/index.js +2 -1
- package/src/build/store-shots.d.ts +177 -0
- package/src/build/store-shots.js +173 -0
- package/src/build/test-build.d.ts +13 -1
- package/src/build/test-build.js +41 -2
- package/src/build/vendor.js +1 -1
- package/src/content/elements.d.ts +50 -0
- package/src/content/elements.js +88 -0
- package/src/content/highlight.d.ts +54 -0
- package/src/content/highlight.js +92 -0
- package/src/content/overlay.d.ts +57 -0
- package/src/content/overlay.js +52 -0
- package/src/content/page-toast.d.ts +26 -0
- package/src/content/page-toast.js +72 -0
- package/src/content/slices.d.ts +16 -1
- package/src/content/slices.js +29 -10
- package/src/dom/copy.d.ts +36 -0
- package/src/dom/copy.js +84 -0
- package/src/dom/i18n.d.ts +17 -0
- package/src/dom/i18n.js +15 -0
- package/src/dom/make.d.ts +53 -0
- package/src/dom/make.js +71 -0
- package/src/frame/window.d.ts +76 -7
- package/src/frame/window.js +119 -15
- package/src/index.d.ts +33 -5
- package/src/index.js +25 -5
- package/src/panel/side-panel.d.ts +35 -0
- package/src/panel/side-panel.js +41 -0
- package/src/scripting/call.d.ts +63 -0
- package/src/scripting/call.js +57 -0
- package/src/scripting/tab.d.ts +68 -0
- package/src/scripting/tab.js +81 -0
- package/src/styles/chrome-ui.css +7 -0
- package/src/testing/index.d.ts +6 -0
- package/src/testing/index.js +4 -0
- package/src/testing/live-run.d.ts +130 -0
- package/src/testing/live-run.js +160 -0
- package/src/testing/pages.d.ts +114 -0
- package/src/testing/pages.js +109 -0
- package/src/ui/notice.d.ts +58 -0
- package/src/ui/notice.js +43 -0
package/src/dom/copy.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copying in several formats at once: plain text, and HTML for apps that paste it rich (a link
|
|
3
|
+
* with its title in Google Docs, Slack or Notion), from a page, a popup or a content script.
|
|
4
|
+
* Says whether it worked, so a caller can offer another way when the page won't let it.
|
|
5
|
+
*
|
|
6
|
+
* Chrome lets the focused page write without a click (it grants clipboard-write to the active
|
|
7
|
+
* tab), so a content script can copy from a context-menu click or a keyboard shortcut; a page
|
|
8
|
+
* served over plain HTTP has no clipboard API, and one with `Permissions-Policy:
|
|
9
|
+
* clipboard-write=()` refuses it: there the copy command is tried, which needs a click in the page.
|
|
10
|
+
*
|
|
11
|
+
* @module dom/copy
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What to put on the clipboard.
|
|
16
|
+
*
|
|
17
|
+
* @typedef {object} ClipboardFormats
|
|
18
|
+
* @property {string} text Plain text, for every app.
|
|
19
|
+
* @property {string} [html] HTML, for apps that paste formatted text.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The copy command, with a copy listener that writes the formats: it runs first (capture, on the
|
|
24
|
+
* window) and stops the page's own listeners from changing what's copied.
|
|
25
|
+
*
|
|
26
|
+
* @param {Document} doc - The page.
|
|
27
|
+
* @param {ClipboardFormats} formats - What to copy.
|
|
28
|
+
* @returns {boolean} Whether the command ran and wrote them.
|
|
29
|
+
*/
|
|
30
|
+
function copyCommand(doc, { text, html }) {
|
|
31
|
+
const view = doc.defaultView;
|
|
32
|
+
if (!view) return false;
|
|
33
|
+
let wrote = false;
|
|
34
|
+
/** @param {ClipboardEvent} event */
|
|
35
|
+
const onCopy = (event) => {
|
|
36
|
+
if (!event.clipboardData) return;
|
|
37
|
+
event.clipboardData.setData('text/plain', text);
|
|
38
|
+
if (html !== undefined) event.clipboardData.setData('text/html', html);
|
|
39
|
+
event.preventDefault();
|
|
40
|
+
event.stopImmediatePropagation();
|
|
41
|
+
wrote = true;
|
|
42
|
+
};
|
|
43
|
+
view.addEventListener('copy', onCopy, true);
|
|
44
|
+
try {
|
|
45
|
+
return doc.execCommand('copy') && wrote;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
} finally {
|
|
49
|
+
view.removeEventListener('copy', onCopy, true);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Put text, and HTML when given, on the clipboard: the clipboard API (one item holding both
|
|
55
|
+
* formats), else the copy command.
|
|
56
|
+
*
|
|
57
|
+
* @param {Document} doc - The page, popup or content script's document.
|
|
58
|
+
* @param {ClipboardFormats} formats - Plain text, and HTML for rich pasting.
|
|
59
|
+
* @returns {Promise<boolean>} Whether it's on the clipboard.
|
|
60
|
+
* @example
|
|
61
|
+
* const link = document.createElement('a');
|
|
62
|
+
* link.href = url;
|
|
63
|
+
* link.textContent = title;
|
|
64
|
+
* if (!(await copyFormats(document, { text: `${title} – ${url}`, html: link.outerHTML }))) offerCopyButton();
|
|
65
|
+
*/
|
|
66
|
+
export async function copyFormats(doc, { text, html }) {
|
|
67
|
+
const view = doc.defaultView;
|
|
68
|
+
const clipboard = view?.navigator.clipboard;
|
|
69
|
+
if (view && clipboard) {
|
|
70
|
+
try {
|
|
71
|
+
if (html === undefined) await clipboard.writeText(text);
|
|
72
|
+
else {
|
|
73
|
+
await clipboard.write([new view.ClipboardItem({
|
|
74
|
+
'text/plain': new view.Blob([text], { type: 'text/plain' }),
|
|
75
|
+
'text/html': new view.Blob([html], { type: 'text/html' }),
|
|
76
|
+
})]);
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
} catch {
|
|
80
|
+
// No focus, or the page's permissions policy: the copy command below.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return copyCommand(doc, { text, html });
|
|
84
|
+
}
|
package/src/dom/i18n.d.ts
CHANGED
|
@@ -45,3 +45,20 @@ export declare function translator(i18n: Pick<I18nApi, 'getMessage'>): (key: str
|
|
|
45
45
|
* @returns {void}
|
|
46
46
|
*/
|
|
47
47
|
export declare function localize(root: ParentNode, i18n: I18nApi): void;
|
|
48
|
+
/**
|
|
49
|
+
* A count in words, its number written as the person's language writes numbers: one message for
|
|
50
|
+
* one, another (with the number as `$1`) for any other count.
|
|
51
|
+
*
|
|
52
|
+
* @param {number} count - How many.
|
|
53
|
+
* @param {string} one - The message for one (`"1 colour"`).
|
|
54
|
+
* @param {string} many - The message for others (`"$COUNT$ colours"`, its placeholder `$1`).
|
|
55
|
+
* @param {{t: (key: string, substitutions?: string | string[]) => string, lang: string}} words - The
|
|
56
|
+
* page's message lookup and the UI language.
|
|
57
|
+
* @returns {string} The words.
|
|
58
|
+
* @example
|
|
59
|
+
* counted(1200, 'panelColoursOne', 'panelColours', { t, lang: 'en-GB' }); // '1,200 colours'
|
|
60
|
+
*/
|
|
61
|
+
export declare const counted: (count: number, one: string, many: string, { t, lang }: {
|
|
62
|
+
t: (key: string, substitutions?: string | string[]) => string;
|
|
63
|
+
lang: string;
|
|
64
|
+
}) => string;
|
package/src/dom/i18n.js
CHANGED
|
@@ -60,3 +60,18 @@ export function localize(root, i18n) {
|
|
|
60
60
|
}
|
|
61
61
|
if ('documentElement' in root) /** @type {Document} */ (root).documentElement.lang = i18n.getUILanguage();
|
|
62
62
|
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A count in words, its number written as the person's language writes numbers: one message for
|
|
66
|
+
* one, another (with the number as `$1`) for any other count.
|
|
67
|
+
*
|
|
68
|
+
* @param {number} count - How many.
|
|
69
|
+
* @param {string} one - The message for one (`"1 colour"`).
|
|
70
|
+
* @param {string} many - The message for others (`"$COUNT$ colours"`, its placeholder `$1`).
|
|
71
|
+
* @param {{t: (key: string, substitutions?: string | string[]) => string, lang: string}} words - The
|
|
72
|
+
* page's message lookup and the UI language.
|
|
73
|
+
* @returns {string} The words.
|
|
74
|
+
* @example
|
|
75
|
+
* counted(1200, 'panelColoursOne', 'panelColours', { t, lang: 'en-GB' }); // '1,200 colours'
|
|
76
|
+
*/
|
|
77
|
+
export const counted = (count, one, many, { t, lang }) => (count === 1 ? t(one) : t(many, [count.toLocaleString(lang)]));
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Making a page's elements in a line: an element with its class and text, an
|
|
3
|
+
* icon from the page's SVG sprite, and Chrome's 24px icon button with its
|
|
4
|
+
* tooltip. For extension pages and content-script overlays alike (pass the
|
|
5
|
+
* document the element belongs to).
|
|
6
|
+
*
|
|
7
|
+
* @module dom/make
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* An element with a class and text.
|
|
11
|
+
*
|
|
12
|
+
* @template {keyof HTMLElementTagNameMap} K
|
|
13
|
+
* @param {K} tag - Its tag.
|
|
14
|
+
* @param {string} [className] - Its class.
|
|
15
|
+
* @param {string} [text] - Its text.
|
|
16
|
+
* @param {Document} [doc] - Its document. Default `document`.
|
|
17
|
+
* @returns {HTMLElementTagNameMap[K]} It.
|
|
18
|
+
* @example
|
|
19
|
+
* make('span', 'meta', '12 colours');
|
|
20
|
+
*/
|
|
21
|
+
export declare function make<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, text?: string, doc?: Document): HTMLElementTagNameMap[K];
|
|
22
|
+
/**
|
|
23
|
+
* An icon from the page's SVG sprite (`<symbol id="i-copy">`), hidden from screen readers.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} id - The symbol's id, without `#`.
|
|
26
|
+
* @param {string} [className] - More classes beside `icon`.
|
|
27
|
+
* @param {Document} [doc] - Its document. Default `document`.
|
|
28
|
+
* @returns {SVGSVGElement} The icon.
|
|
29
|
+
* @example
|
|
30
|
+
* button.append(spriteIcon('i-copy'));
|
|
31
|
+
*/
|
|
32
|
+
export declare function spriteIcon(id: string, className?: string, doc?: Document): SVGSVGElement;
|
|
33
|
+
/**
|
|
34
|
+
* Chrome's icon button (chrome-ui.css `.icon-button`): an icon, its label as tooltip and for
|
|
35
|
+
* screen readers.
|
|
36
|
+
*
|
|
37
|
+
* @param {object} options
|
|
38
|
+
* @param {string} options.icon - The sprite symbol's id.
|
|
39
|
+
* @param {string} options.label - What it does.
|
|
40
|
+
* @param {() => void} options.onClick - Doing it.
|
|
41
|
+
* @param {string} [options.className] - More classes.
|
|
42
|
+
* @param {Document} [options.doc] - Its document. Default `document`.
|
|
43
|
+
* @returns {HTMLButtonElement} The button.
|
|
44
|
+
* @example
|
|
45
|
+
* iconButton({ icon: 'i-copy', label: 'Copy', onClick: () => copy(hex) });
|
|
46
|
+
*/
|
|
47
|
+
export declare function iconButton({ icon, label, onClick, className, doc }: {
|
|
48
|
+
icon: string;
|
|
49
|
+
label: string;
|
|
50
|
+
onClick: () => void;
|
|
51
|
+
className?: string;
|
|
52
|
+
doc?: Document;
|
|
53
|
+
}): HTMLButtonElement;
|
package/src/dom/make.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Making a page's elements in a line: an element with its class and text, an
|
|
3
|
+
* icon from the page's SVG sprite, and Chrome's 24px icon button with its
|
|
4
|
+
* tooltip. For extension pages and content-script overlays alike (pass the
|
|
5
|
+
* document the element belongs to).
|
|
6
|
+
*
|
|
7
|
+
* @module dom/make
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* An element with a class and text.
|
|
12
|
+
*
|
|
13
|
+
* @template {keyof HTMLElementTagNameMap} K
|
|
14
|
+
* @param {K} tag - Its tag.
|
|
15
|
+
* @param {string} [className] - Its class.
|
|
16
|
+
* @param {string} [text] - Its text.
|
|
17
|
+
* @param {Document} [doc] - Its document. Default `document`.
|
|
18
|
+
* @returns {HTMLElementTagNameMap[K]} It.
|
|
19
|
+
* @example
|
|
20
|
+
* make('span', 'meta', '12 colours');
|
|
21
|
+
*/
|
|
22
|
+
export function make(tag, className = '', text = '', doc = document) {
|
|
23
|
+
const element = doc.createElement(tag);
|
|
24
|
+
if (className) element.className = className;
|
|
25
|
+
if (text) element.textContent = text;
|
|
26
|
+
return element;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* An icon from the page's SVG sprite (`<symbol id="i-copy">`), hidden from screen readers.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} id - The symbol's id, without `#`.
|
|
33
|
+
* @param {string} [className] - More classes beside `icon`.
|
|
34
|
+
* @param {Document} [doc] - Its document. Default `document`.
|
|
35
|
+
* @returns {SVGSVGElement} The icon.
|
|
36
|
+
* @example
|
|
37
|
+
* button.append(spriteIcon('i-copy'));
|
|
38
|
+
*/
|
|
39
|
+
export function spriteIcon(id, className = '', doc = document) {
|
|
40
|
+
const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
41
|
+
svg.setAttribute('class', `icon ${className}`.trim());
|
|
42
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
43
|
+
const use = doc.createElementNS('http://www.w3.org/2000/svg', 'use');
|
|
44
|
+
use.setAttribute('href', `#${id}`);
|
|
45
|
+
svg.append(use);
|
|
46
|
+
return svg;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Chrome's icon button (chrome-ui.css `.icon-button`): an icon, its label as tooltip and for
|
|
51
|
+
* screen readers.
|
|
52
|
+
*
|
|
53
|
+
* @param {object} options
|
|
54
|
+
* @param {string} options.icon - The sprite symbol's id.
|
|
55
|
+
* @param {string} options.label - What it does.
|
|
56
|
+
* @param {() => void} options.onClick - Doing it.
|
|
57
|
+
* @param {string} [options.className] - More classes.
|
|
58
|
+
* @param {Document} [options.doc] - Its document. Default `document`.
|
|
59
|
+
* @returns {HTMLButtonElement} The button.
|
|
60
|
+
* @example
|
|
61
|
+
* iconButton({ icon: 'i-copy', label: 'Copy', onClick: () => copy(hex) });
|
|
62
|
+
*/
|
|
63
|
+
export function iconButton({ icon, label, onClick, className = '', doc = document }) {
|
|
64
|
+
const button = make('button', `icon-button ${className}`.trim(), '', doc);
|
|
65
|
+
button.type = 'button';
|
|
66
|
+
button.title = label;
|
|
67
|
+
button.setAttribute('aria-label', label);
|
|
68
|
+
button.append(spriteIcon(icon, '', doc));
|
|
69
|
+
button.addEventListener('click', onClick);
|
|
70
|
+
return button;
|
|
71
|
+
}
|
package/src/frame/window.d.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Windows around a page, drawn as HTML: Chrome's (the macOS tab strip and toolbar) and a plain
|
|
3
|
+
* macOS window's title bar, light or dark, sharing the traffic lights, the colours and the
|
|
4
|
+
* shadow. Chrome's is shared by the store screenshots (`build/store-frame`, which lays it on a
|
|
5
|
+
* 1280×800 canvas in Playwright) and by extension pages, which draw either on a canvas around a
|
|
6
|
+
* capture through {@link windowChromeSvg} or {@link macWindowSvg} (an SVG image with the HTML
|
|
7
|
+
* inside, which Chrome draws without tainting the canvas). The markup is XML-clean so both can
|
|
8
|
+
* use it.
|
|
7
9
|
*
|
|
8
10
|
* Window metrics follow Chrome's 2023 refresh on macOS (tabs 34px high in a 42px strip, a 46px
|
|
9
|
-
* toolbar with a pill omnibox)
|
|
11
|
+
* toolbar with a pill omnibox) and AppKit's titled window as macOS 26 and later draw it, measured
|
|
12
|
+
* on macOS 27.2 (`NSThemeFrame`'s `cornerRadius` 16, a 32pt title bar, the window buttons 14pt
|
|
13
|
+
* at 9, 32 and 55 from the left and 9 from the top, the title in the 13pt headline font at 85,
|
|
14
|
+
* after the buttons).
|
|
10
15
|
*
|
|
11
16
|
* @module frame/window
|
|
12
17
|
*/
|
|
@@ -15,8 +20,20 @@ export declare const WINDOW_BARS: Readonly<{
|
|
|
15
20
|
strip: 42;
|
|
16
21
|
toolbar: 46;
|
|
17
22
|
}>;
|
|
23
|
+
/** A plain macOS window (macOS 26 and later): its title bar's height and its corners' radius (px). */
|
|
24
|
+
export declare const MAC_WINDOW: Readonly<{
|
|
25
|
+
titleBar: 32;
|
|
26
|
+
radius: 16;
|
|
27
|
+
}>;
|
|
18
28
|
/** The window's default width (px): the store screenshot's. */
|
|
19
29
|
export declare const WINDOW_W = 1152;
|
|
30
|
+
/**
|
|
31
|
+
* Text made safe for the drawn windows' HTML (and the store screenshots').
|
|
32
|
+
*
|
|
33
|
+
* @param {unknown} s - Text.
|
|
34
|
+
* @returns {string} It, escaped for HTML text and attributes.
|
|
35
|
+
*/
|
|
36
|
+
export declare const esc: (s: unknown) => string;
|
|
20
37
|
/**
|
|
21
38
|
* A letter favicon for made-up sites (no real brands).
|
|
22
39
|
*
|
|
@@ -26,7 +43,25 @@ export declare const WINDOW_W = 1152;
|
|
|
26
43
|
* @returns {string} A data: URL of the SVG.
|
|
27
44
|
*/
|
|
28
45
|
export declare const letterIcon: (letter: string, bg: string, fg?: string) => string;
|
|
29
|
-
|
|
46
|
+
export type WindowShadow = {
|
|
47
|
+
/**
|
|
48
|
+
* How far down it falls.
|
|
49
|
+
*/
|
|
50
|
+
y: number;
|
|
51
|
+
/**
|
|
52
|
+
* Its blur.
|
|
53
|
+
*/
|
|
54
|
+
blur: number;
|
|
55
|
+
/**
|
|
56
|
+
* Its colour.
|
|
57
|
+
*/
|
|
58
|
+
color: string;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Chrome's light and dark window colours, a macOS title bar's (`titleBar`, and `titleLine` below
|
|
62
|
+
* it, as AppKit draws them), the canvas behind, and the window's shadow (`windowShadow` for a
|
|
63
|
+
* canvas's `shadow*`, `shadow` for CSS).
|
|
64
|
+
*/
|
|
30
65
|
export declare const THEMES: {
|
|
31
66
|
light: {
|
|
32
67
|
bg: string;
|
|
@@ -43,6 +78,9 @@ export declare const THEMES: {
|
|
|
43
78
|
url: string;
|
|
44
79
|
urlDim: string;
|
|
45
80
|
contentLine: string;
|
|
81
|
+
titleBar: string;
|
|
82
|
+
titleLine: string;
|
|
83
|
+
windowShadow: WindowShadow;
|
|
46
84
|
shadow: string;
|
|
47
85
|
};
|
|
48
86
|
dark: {
|
|
@@ -60,6 +98,9 @@ export declare const THEMES: {
|
|
|
60
98
|
url: string;
|
|
61
99
|
urlDim: string;
|
|
62
100
|
contentLine: string;
|
|
101
|
+
titleBar: string;
|
|
102
|
+
titleLine: string;
|
|
103
|
+
windowShadow: WindowShadow;
|
|
63
104
|
shadow: string;
|
|
64
105
|
};
|
|
65
106
|
};
|
|
@@ -118,3 +159,31 @@ export declare function windowChrome({ tabs, url, urlHost, theme, w, ext }: {
|
|
|
118
159
|
* const svg = windowChromeSvg({ tabs: [{ title: 'Example', icon: letterIcon('E', '#1a73e8'), active: true }], url: 'example.com', w: 1280 });
|
|
119
160
|
*/
|
|
120
161
|
export declare function windowChromeSvg(options: Parameters<typeof windowChrome>[0]): string;
|
|
162
|
+
/**
|
|
163
|
+
* A plain macOS window's title bar, as HTML: the traffic lights at the left, the title after
|
|
164
|
+
* them (macOS 26 on), a hairline below.
|
|
165
|
+
*
|
|
166
|
+
* @param {object} options - What to draw.
|
|
167
|
+
* @param {string} options.title - The window's title.
|
|
168
|
+
* @param {Theme} [options.theme] - Default light.
|
|
169
|
+
* @param {number} [options.w] - The window's width. Default {@link WINDOW_W}.
|
|
170
|
+
* @returns {string} The HTML, with its own styles.
|
|
171
|
+
*/
|
|
172
|
+
declare function macTitleBar({ title, theme, w }: {
|
|
173
|
+
title: string;
|
|
174
|
+
theme?: Theme;
|
|
175
|
+
w?: number;
|
|
176
|
+
}): string;
|
|
177
|
+
/**
|
|
178
|
+
* A plain macOS window's title bar as a standalone SVG image, `w` wide and `MAC_WINDOW.titleBar`
|
|
179
|
+
* high (scale it when drawing), for an extension page to draw on a canvas as
|
|
180
|
+
* {@link windowChromeSvg}'s bars are drawn. The window's corners (`MAC_WINDOW.radius`) and its
|
|
181
|
+
* shadow (`THEMES[theme].windowShadow`) are the canvas's to draw.
|
|
182
|
+
*
|
|
183
|
+
* @param {Parameters<typeof macTitleBar>[0]} options - As {@link macTitleBar}.
|
|
184
|
+
* @returns {string} The SVG markup.
|
|
185
|
+
* @example
|
|
186
|
+
* const svg = macWindowSvg({ title: 'Example', theme: 'dark', w: 1280 });
|
|
187
|
+
*/
|
|
188
|
+
export declare function macWindowSvg(options: Parameters<typeof macTitleBar>[0]): string;
|
|
189
|
+
export {};
|
package/src/frame/window.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Windows around a page, drawn as HTML: Chrome's (the macOS tab strip and toolbar) and a plain
|
|
3
|
+
* macOS window's title bar, light or dark, sharing the traffic lights, the colours and the
|
|
4
|
+
* shadow. Chrome's is shared by the store screenshots (`build/store-frame`, which lays it on a
|
|
5
|
+
* 1280×800 canvas in Playwright) and by extension pages, which draw either on a canvas around a
|
|
6
|
+
* capture through {@link windowChromeSvg} or {@link macWindowSvg} (an SVG image with the HTML
|
|
7
|
+
* inside, which Chrome draws without tainting the canvas). The markup is XML-clean so both can
|
|
8
|
+
* use it.
|
|
7
9
|
*
|
|
8
10
|
* Window metrics follow Chrome's 2023 refresh on macOS (tabs 34px high in a 42px strip, a 46px
|
|
9
|
-
* toolbar with a pill omnibox)
|
|
11
|
+
* toolbar with a pill omnibox) and AppKit's titled window as macOS 26 and later draw it, measured
|
|
12
|
+
* on macOS 27.2 (`NSThemeFrame`'s `cornerRadius` 16, a 32pt title bar, the window buttons 14pt
|
|
13
|
+
* at 9, 32 and 55 from the left and 9 from the top, the title in the 13pt headline font at 85,
|
|
14
|
+
* after the buttons).
|
|
10
15
|
*
|
|
11
16
|
* @module frame/window
|
|
12
17
|
*/
|
|
@@ -14,14 +19,19 @@
|
|
|
14
19
|
/** The tab strip's and the toolbar's heights (px): the content starts below both. */
|
|
15
20
|
export const WINDOW_BARS = Object.freeze({ strip: 42, toolbar: 46 });
|
|
16
21
|
|
|
22
|
+
/** A plain macOS window (macOS 26 and later): its title bar's height and its corners' radius (px). */
|
|
23
|
+
export const MAC_WINDOW = Object.freeze({ titleBar: 32, radius: 16 });
|
|
24
|
+
|
|
17
25
|
/** The window's default width (px): the store screenshot's. */
|
|
18
26
|
export const WINDOW_W = 1152;
|
|
19
27
|
|
|
20
28
|
/**
|
|
29
|
+
* Text made safe for the drawn windows' HTML (and the store screenshots').
|
|
30
|
+
*
|
|
21
31
|
* @param {unknown} s - Text.
|
|
22
32
|
* @returns {string} It, escaped for HTML text and attributes.
|
|
23
33
|
*/
|
|
24
|
-
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
34
|
+
export const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
25
35
|
|
|
26
36
|
/** Material icon paths (24×24). */
|
|
27
37
|
const P = {
|
|
@@ -60,22 +70,69 @@ const icon = (name, size = 20) =>
|
|
|
60
70
|
export const letterIcon = (letter, bg, fg = '#fff') =>
|
|
61
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>`)}`;
|
|
62
72
|
|
|
63
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* A window's shadow: soft and dropped below it, as macOS draws one (px; `blur` as CSS's and a
|
|
75
|
+
* canvas's `shadowBlur`, twice the blur's standard deviation).
|
|
76
|
+
*
|
|
77
|
+
* @typedef {object} WindowShadow
|
|
78
|
+
* @property {number} y How far down it falls.
|
|
79
|
+
* @property {number} blur Its blur.
|
|
80
|
+
* @property {string} color Its colour.
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
/** @type {{light: WindowShadow, dark: WindowShadow}} */
|
|
84
|
+
const SHADOWS = {
|
|
85
|
+
light: { y: 24, blur: 60, color: 'rgba(31,45,70,.20)' },
|
|
86
|
+
dark: { y: 24, blur: 60, color: 'rgba(0,0,0,.55)' },
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A window's shadow and its hairline rim as a CSS `box-shadow`.
|
|
91
|
+
*
|
|
92
|
+
* @param {WindowShadow} shadow - The shadow.
|
|
93
|
+
* @param {string} rim - The rim's colour.
|
|
94
|
+
* @returns {string} The `box-shadow` value.
|
|
95
|
+
*/
|
|
96
|
+
const boxShadow = (shadow, rim) => `0 ${shadow.y}px ${shadow.blur}px ${shadow.color}, 0 0 0 .5px ${rim}`;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Chrome's light and dark window colours, a macOS title bar's (`titleBar`, and `titleLine` below
|
|
100
|
+
* it, as AppKit draws them), the canvas behind, and the window's shadow (`windowShadow` for a
|
|
101
|
+
* canvas's `shadow*`, `shadow` for CSS).
|
|
102
|
+
*/
|
|
64
103
|
export const THEMES = {
|
|
65
104
|
light: {
|
|
66
105
|
bg: '#eef2f8', caption: '#1f1f1f', sub: '#444746',
|
|
67
106
|
frame: '#dde3ea', tabText: '#1f1f1f', tabTextInactive: '#474747', tabSep: '#a8afb8', activeTab: '#ffffff',
|
|
68
107
|
toolbar: '#ffffff', toolbarIcon: '#474747', omnibox: '#e9eef6', url: '#1f1f1f', urlDim: '#5e5e5e',
|
|
69
|
-
contentLine: '#d3d7dc',
|
|
108
|
+
contentLine: '#d3d7dc', titleBar: '#ffffff', titleLine: '#eaeaea',
|
|
109
|
+
windowShadow: SHADOWS.light, shadow: boxShadow(SHADOWS.light, 'rgba(0,0,0,.18)'),
|
|
70
110
|
},
|
|
71
111
|
dark: {
|
|
72
112
|
bg: '#1b1c1f', caption: '#e3e3e3', sub: '#c4c7c5',
|
|
73
113
|
frame: '#1f1f1f', tabText: '#e3e3e3', tabTextInactive: '#c7c7c7', tabSep: '#5f6368', activeTab: '#3c3c3c',
|
|
74
114
|
toolbar: '#3c3c3c', toolbarIcon: '#c7c7c7', omnibox: '#282828', url: '#e3e3e3', urlDim: '#a8a8a8',
|
|
75
|
-
contentLine: '#2a2a2a',
|
|
115
|
+
contentLine: '#2a2a2a', titleBar: '#282828', titleLine: '#3c3c3c',
|
|
116
|
+
windowShadow: SHADOWS.dark, shadow: boxShadow(SHADOWS.dark, 'rgba(255,255,255,.14)'),
|
|
76
117
|
},
|
|
77
118
|
};
|
|
78
119
|
|
|
120
|
+
/** The traffic lights' colours: close, minimise, zoom. */
|
|
121
|
+
const LIGHTS = ['#ff5f57', '#febc2e', '#28c840'];
|
|
122
|
+
|
|
123
|
+
/** The traffic lights' styles: circles with a faint edge, their size and spacing set where they're placed. */
|
|
124
|
+
const LIGHTS_CSS = `.lights { position: absolute; display: flex; }
|
|
125
|
+
.lights i { width: var(--light); height: var(--light); border-radius: 50%; display: block; box-shadow: inset 0 0 0 .5px rgba(0,0,0,.12); }`;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* macOS's traffic lights (styled by {@link LIGHTS_CSS}).
|
|
129
|
+
*
|
|
130
|
+
* @param {{left: number, top: number, size: number, gap: number}} place - The first one's left edge, their top edge, their size and the space between them (px).
|
|
131
|
+
* @returns {string} The HTML.
|
|
132
|
+
*/
|
|
133
|
+
const trafficLights = ({ left, top, size, gap }) =>
|
|
134
|
+
`<span class="lights" style="left:${left}px;top:${top}px;gap:${gap}px;--light:${size}px">${LIGHTS.map((color) => `<i style="background:${color}"></i>`).join('')}</span>`;
|
|
135
|
+
|
|
79
136
|
/** @typedef {keyof typeof THEMES} Theme */
|
|
80
137
|
|
|
81
138
|
/**
|
|
@@ -139,7 +196,7 @@ export function windowChrome({ tabs, url, urlHost, theme = 'light', w = WINDOW_W
|
|
|
139
196
|
const extIcons = ext.map((src) => `<img src="${src}" width="16" height="16" alt="" style="margin:0 9px"/>`).join('');
|
|
140
197
|
return `
|
|
141
198
|
<div class="strip">
|
|
142
|
-
|
|
199
|
+
${trafficLights({ left: 16, top: 15, size: 12, gap: 8 })}
|
|
143
200
|
${tabHtml}
|
|
144
201
|
<span class="plus" style="left:${plusX}px">${icon('add', 18)}</span>
|
|
145
202
|
</div>
|
|
@@ -151,8 +208,7 @@ export function windowChrome({ tabs, url, urlHost, theme = 'light', w = WINDOW_W
|
|
|
151
208
|
</div>
|
|
152
209
|
<style>
|
|
153
210
|
.strip { position: relative; height: ${WINDOW_BARS.strip}px; background: ${t.frame}; }
|
|
154
|
-
|
|
155
|
-
.lights i { width: 12px; height: 12px; border-radius: 50%; display: block; box-shadow: inset 0 0 0 .5px rgba(0,0,0,.12); }
|
|
211
|
+
${LIGHTS_CSS}
|
|
156
212
|
.tab { position: absolute; bottom: 0; height: ${TAB_H}px; display: flex; align-items: center; gap: 8px; padding: 0 10px 0 14px; box-sizing: border-box; color: ${t.tabTextInactive}; font: 12px/16px system-ui; }
|
|
157
213
|
.tab.active { background: ${t.activeTab}; color: ${t.tabText}; border-radius: 10px 10px 0 0; z-index: 1; }
|
|
158
214
|
.tab.active::before, .tab.active::after { content: ''; position: absolute; bottom: 0; width: 10px; height: 10px; }
|
|
@@ -189,9 +245,57 @@ export function windowChrome({ tabs, url, urlHost, theme = 'light', w = WINDOW_W
|
|
|
189
245
|
*/
|
|
190
246
|
export function windowChromeSvg(options) {
|
|
191
247
|
const w = options.w ?? WINDOW_W;
|
|
192
|
-
|
|
248
|
+
return svgImage(windowChrome({ ...options, w }), w, WINDOW_BARS.strip + WINDOW_BARS.toolbar);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* A plain macOS window's title bar, as HTML: the traffic lights at the left, the title after
|
|
253
|
+
* them (macOS 26 on), a hairline below.
|
|
254
|
+
*
|
|
255
|
+
* @param {object} options - What to draw.
|
|
256
|
+
* @param {string} options.title - The window's title.
|
|
257
|
+
* @param {Theme} [options.theme] - Default light.
|
|
258
|
+
* @param {number} [options.w] - The window's width. Default {@link WINDOW_W}.
|
|
259
|
+
* @returns {string} The HTML, with its own styles.
|
|
260
|
+
*/
|
|
261
|
+
function macTitleBar({ title, theme = 'light', w = WINDOW_W }) {
|
|
262
|
+
const t = THEMES[theme];
|
|
263
|
+
return `
|
|
264
|
+
<div class="bar" style="width:${w}px">${trafficLights({ left: 9, top: 9, size: 14, gap: 9 })}<span class="title">${esc(title)}</span></div>
|
|
265
|
+
<style>
|
|
266
|
+
.bar { position: relative; height: ${MAC_WINDOW.titleBar}px; background: ${t.titleBar}; border-bottom: 1px solid ${t.titleLine}; box-sizing: border-box; }
|
|
267
|
+
${LIGHTS_CSS}
|
|
268
|
+
.title { position: absolute; left: 85px; right: 16px; top: 0; color: ${t.tabText}; font: 600 13px/${MAC_WINDOW.titleBar - 1}px system-ui; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
269
|
+
</style>`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* A plain macOS window's title bar as a standalone SVG image, `w` wide and `MAC_WINDOW.titleBar`
|
|
274
|
+
* high (scale it when drawing), for an extension page to draw on a canvas as
|
|
275
|
+
* {@link windowChromeSvg}'s bars are drawn. The window's corners (`MAC_WINDOW.radius`) and its
|
|
276
|
+
* shadow (`THEMES[theme].windowShadow`) are the canvas's to draw.
|
|
277
|
+
*
|
|
278
|
+
* @param {Parameters<typeof macTitleBar>[0]} options - As {@link macTitleBar}.
|
|
279
|
+
* @returns {string} The SVG markup.
|
|
280
|
+
* @example
|
|
281
|
+
* const svg = macWindowSvg({ title: 'Example', theme: 'dark', w: 1280 });
|
|
282
|
+
*/
|
|
283
|
+
export function macWindowSvg(options) {
|
|
284
|
+
const w = options.w ?? WINDOW_W;
|
|
285
|
+
return svgImage(macTitleBar({ ...options, w }), w, MAC_WINDOW.titleBar);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* HTML as a standalone SVG image, `w` × `h`.
|
|
290
|
+
*
|
|
291
|
+
* @param {string} html - The HTML (XML-clean).
|
|
292
|
+
* @param {number} w - Its width (px).
|
|
293
|
+
* @param {number} h - Its height.
|
|
294
|
+
* @returns {string} The SVG markup.
|
|
295
|
+
*/
|
|
296
|
+
function svgImage(html, w, h) {
|
|
193
297
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">`
|
|
194
298
|
+ `<foreignObject x="0" y="0" width="${w}" height="${h}">`
|
|
195
|
-
+ `<div xmlns="http://www.w3.org/1999/xhtml" style="width:${w}px;height:${h}px;margin:0;-webkit-font-smoothing:antialiased">${
|
|
299
|
+
+ `<div xmlns="http://www.w3.org/1999/xhtml" style="width:${w}px;height:${h}px;margin:0;-webkit-font-smoothing:antialiased">${html}</div>`
|
|
196
300
|
+ '</foreignObject></svg>';
|
|
197
301
|
}
|