@tio-canvas/canvas 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +106 -2
- package/bin/tio-canvas.mjs +12 -0
- package/client/arrange.js +41 -0
- package/client/arrow-geometry.js +56 -0
- package/client/arrow-selection.js +68 -0
- package/client/arrows.js +205 -0
- package/client/bridge.js +74 -0
- package/client/canvas.css +733 -0
- package/client/canvas.js +1009 -0
- package/client/corners.js +22 -0
- package/client/device-gate.js +15 -0
- package/client/fullscreen.js +118 -0
- package/client/geometry.js +79 -0
- package/client/history.js +37 -0
- package/client/hover-preview.js +35 -0
- package/client/index.html +112 -0
- package/client/layer-order.js +20 -0
- package/client/layers.js +154 -0
- package/client/menu-position.js +36 -0
- package/client/onboarding.js +302 -0
- package/client/page-picker.js +76 -0
- package/client/platform.js +16 -0
- package/client/range-selection.js +54 -0
- package/client/registry.js +31 -0
- package/client/screen-loader.js +67 -0
- package/client/screens.js +7 -0
- package/client/select-field.js +191 -0
- package/client/shortcuts.js +158 -0
- package/client/smart-handles.js +28 -0
- package/client/smart-selection.js +52 -0
- package/client/spacing.js +45 -0
- package/client/text-format.js +49 -0
- package/client/text.js +466 -0
- package/client/tio-logo.png +0 -0
- package/client/tooltip.js +128 -0
- package/client/touch-preview.js +112 -0
- package/docs/exploration-canvas.md +315 -0
- package/package.json +31 -4
- package/src/build.js +33 -0
- package/src/index.js +3 -0
- package/src/interface-kit-bundle.js +103 -0
- package/src/interface-kit-entry.js +16 -0
- package/src/project.js +29 -0
- package/src/server.js +70 -0
- package/src/thumbnails.js +95 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { build } from 'esbuild';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
// Interface Kit 0.1.3 exposes no positioning API. Patch its development-only
|
|
6
|
+
// floating panel at bundle time, failing if an upgrade changes the seam.
|
|
7
|
+
function replaceOnce(source, before, after) {
|
|
8
|
+
if (source.split(before).length !== 2) throw new Error('Interface Kit positioning patch no longer matches the installed version.');
|
|
9
|
+
return source.replace(before, after);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const positioning = {
|
|
13
|
+
name: 'interface-kit-positioning',
|
|
14
|
+
setup(builder) {
|
|
15
|
+
builder.onLoad({ filter: /interface-kit\/dist\/react\.js$/ }, async args => {
|
|
16
|
+
let source = await readFile(args.path, 'utf8');
|
|
17
|
+
// Keep Radix popovers inside Interface Kit's shadow root: its styles and
|
|
18
|
+
// selection exclusions otherwise do not apply to the picker.
|
|
19
|
+
source = replaceOnce(source, ' return /* @__PURE__ */ jsx20(dist_exports2.Portal, { children:', ' const container = usePortalContainer();\n return /* @__PURE__ */ jsx20(dist_exports2.Portal, { container, children:');
|
|
20
|
+
const start = source.indexOf(' const handlePointerDown = useCallback2(\n', source.indexOf('function FloatingPanel()'));
|
|
21
|
+
const end = source.indexOf(' const handleCopy = async () => {', start);
|
|
22
|
+
if (start < 0 || end < 0) throw new Error('Interface Kit drag handler not found.');
|
|
23
|
+
source = source.slice(0, start) + ` const handlePointerDown = useCallback2((e) => {
|
|
24
|
+
if (e.button !== 0 || (isExpanded && e.target.closest('button'))) return;
|
|
25
|
+
const startX = e.clientX, startY = e.clientY;
|
|
26
|
+
const startLeft = left.get(), startTop = top.get();
|
|
27
|
+
const size = isExpanded ? PANEL_WIDTH : COLLAPSED_SIZE;
|
|
28
|
+
let dragged = false;
|
|
29
|
+
const el = containerRef.current;
|
|
30
|
+
if (el) el.style.cursor = 'grabbing';
|
|
31
|
+
const move = event => {
|
|
32
|
+
const dx = event.clientX - startX, dy = event.clientY - startY;
|
|
33
|
+
if (!dragged && Math.hypot(dx, dy) < 4) return;
|
|
34
|
+
dragged = true;
|
|
35
|
+
left.set(clamp(startLeft + dx, EDGE_MARGIN, Math.max(EDGE_MARGIN, window.innerWidth - EDGE_MARGIN - size)));
|
|
36
|
+
if (!isExpanded) top.set(clamp(startTop + dy, EDGE_MARGIN, Math.max(EDGE_MARGIN, window.innerHeight - EDGE_MARGIN - COLLAPSED_SIZE)));
|
|
37
|
+
};
|
|
38
|
+
const cleanup = () => {
|
|
39
|
+
document.removeEventListener('pointermove', move);
|
|
40
|
+
document.removeEventListener('pointerup', up);
|
|
41
|
+
document.removeEventListener('pointercancel', cancel);
|
|
42
|
+
window.removeEventListener('blur', cancel);
|
|
43
|
+
dragCleanupRef.current = null;
|
|
44
|
+
if (el) el.style.cursor = '';
|
|
45
|
+
};
|
|
46
|
+
const finish = open => {
|
|
47
|
+
cleanup();
|
|
48
|
+
if (!dragged) { if (open && !isExpanded) handleOpen(); return; }
|
|
49
|
+
let right = left.get() + size / 2 >= window.innerWidth / 2;
|
|
50
|
+
let bottom = top.get() + COLLAPSED_SIZE / 2 >= window.innerHeight / 2;
|
|
51
|
+
if (!isExpanded) ({ right, bottom } = window.__interfaceKitCorner.claim(right, bottom));
|
|
52
|
+
if (isExpanded) setPanelSide(right ? 'right' : 'left');
|
|
53
|
+
animateMotionValue(left, right ? Math.max(EDGE_MARGIN, window.innerWidth - EDGE_MARGIN - size) : EDGE_MARGIN, springTransition);
|
|
54
|
+
animateMotionValue(top, !isExpanded && bottom ? Math.max(EDGE_MARGIN, window.innerHeight - EDGE_MARGIN - COLLAPSED_SIZE) : EDGE_MARGIN, springTransition);
|
|
55
|
+
};
|
|
56
|
+
const up = () => finish(true), cancel = () => finish(false);
|
|
57
|
+
document.addEventListener('pointermove', move);
|
|
58
|
+
document.addEventListener('pointerup', up);
|
|
59
|
+
document.addEventListener('pointercancel', cancel);
|
|
60
|
+
window.addEventListener('blur', cancel);
|
|
61
|
+
dragCleanupRef.current = cleanup;
|
|
62
|
+
}, [isExpanded, left, top, handleOpen, setPanelSide]);
|
|
63
|
+
` + source.slice(end);
|
|
64
|
+
source = replaceOnce(source, ` const targetX = (_a = saved == null ? void 0 : saved.x) != null ? _a : window.innerWidth - EDGE_MARGIN - COLLAPSED_SIZE;\n const targetY = (_b = saved == null ? void 0 : saved.y) != null ? _b : EDGE_MARGIN;`, ` const chosen = window.__interfaceKitCorner.claim(side === 'right', false);\n const targetX = chosen.right ? Math.max(EDGE_MARGIN, window.innerWidth - EDGE_MARGIN - COLLAPSED_SIZE) : EDGE_MARGIN;\n const targetY = chosen.bottom ? Math.max(EDGE_MARGIN, window.innerHeight - EDGE_MARGIN - COLLAPSED_SIZE) : EDGE_MARGIN;`);
|
|
65
|
+
source = replaceOnce(source, ' }, [left, top, widthMV, setActive, setSettingsOpen]);', ' }, [left, top, widthMV, setActive, setSettingsOpen, side]);');
|
|
66
|
+
source = replaceOnce(source, ' const dragCleanupRef = useRef2(null);', ' const dragCleanupRef = useRef2(null);\n const dockCornerRef = useRef2(null);\n if (!dockCornerRef.current) dockCornerRef.current = window.__interfaceKitCorner.claim(false, true);');
|
|
67
|
+
source = replaceOnce(source, 'typeof window !== "undefined" ? window.innerWidth - EDGE_MARGIN - COLLAPSED_SIZE : 0', 'dockCornerRef.current.right ? Math.max(EDGE_MARGIN, window.innerWidth - EDGE_MARGIN - COLLAPSED_SIZE) : EDGE_MARGIN');
|
|
68
|
+
source = replaceOnce(source, ' const top = useMotionValue(EDGE_MARGIN);', ' const top = useMotionValue(dockCornerRef.current.bottom ? Math.max(EDGE_MARGIN, window.innerHeight - EDGE_MARGIN - COLLAPSED_SIZE) : EDGE_MARGIN);');
|
|
69
|
+
source = replaceOnce(source, ' setActive(true);\n }, [left, top, widthMV, side, setPanelSide, setActive]);', ' window.__interfaceKitCorner.clear();\n setActive(true);\n }, [left, top, widthMV, side, setPanelSide, setActive]);');
|
|
70
|
+
source = replaceOnce(source, " if (isExpanded) setPanelSide(right ? 'right' : 'left');", " dockCornerRef.current = { right, bottom: isExpanded ? false : bottom };\n if (isExpanded) setPanelSide(right ? 'right' : 'left');");
|
|
71
|
+
source = replaceOnce(source, ' animateMotionValue(left, targetX, springTransition);\n animateMotionValue(top, targetY, springTransition);', ' dockCornerRef.current = chosen;\n animateMotionValue(left, targetX, springTransition);\n animateMotionValue(top, targetY, springTransition);');
|
|
72
|
+
source = replaceOnce(source, ' const handleCopy = async () => {', ` useEffect2(() => {
|
|
73
|
+
const resize = () => {
|
|
74
|
+
const size = isExpanded ? PANEL_WIDTH : COLLAPSED_SIZE;
|
|
75
|
+
left.set(dockCornerRef.current.right ? Math.max(EDGE_MARGIN, window.innerWidth - EDGE_MARGIN - size) : EDGE_MARGIN);
|
|
76
|
+
top.set(!isExpanded && dockCornerRef.current.bottom ? Math.max(EDGE_MARGIN, window.innerHeight - EDGE_MARGIN - COLLAPSED_SIZE) : EDGE_MARGIN);
|
|
77
|
+
};
|
|
78
|
+
const reconcile = () => {
|
|
79
|
+
if (!isExpanded) dockCornerRef.current = window.__interfaceKitCorner.claim(dockCornerRef.current.right, dockCornerRef.current.bottom);
|
|
80
|
+
resize();
|
|
81
|
+
};
|
|
82
|
+
window.addEventListener('resize', resize);
|
|
83
|
+
window.addEventListener('canvas-corners-changed', reconcile);
|
|
84
|
+
return () => { window.removeEventListener('resize', resize); window.removeEventListener('canvas-corners-changed', reconcile); };
|
|
85
|
+
}, [isExpanded, left, top]);
|
|
86
|
+
const handleCopy = async () => {`);
|
|
87
|
+
source = replaceOnce(source, ' children: [\n /* @__PURE__ */ jsx27("div", { className: "px-2 py-1.5 shrink-0"', ` children: [
|
|
88
|
+
/* @__PURE__ */ jsx27("div", { role: "presentation", title: "Drag to move panel", style: { height: 20, flexShrink: 0, cursor: "grab", touchAction: "none", display: "grid", placeItems: "center" }, onPointerDown: e => {
|
|
89
|
+
const toolbar = e.currentTarget.getRootNode().querySelector('[data-interface-kit][style*="border-radius: 22px"]');
|
|
90
|
+
toolbar?.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true, button: 0, clientX: e.clientX, clientY: e.clientY, pointerId: e.pointerId }));
|
|
91
|
+
}, children: /* @__PURE__ */ jsx27("span", { style: { width: 28, height: 3, borderRadius: 3, background: "rgba(255,255,255,.35)", pointerEvents: "none" } }) }),
|
|
92
|
+
/* @__PURE__ */ jsx27("div", { className: "px-2 py-1.5 shrink-0"`);
|
|
93
|
+
source = replaceOnce(source, ' cursor: isExpanded ? void 0 : "grab"', ' cursor: isExpanded ? void 0 : "grab", touchAction: "none"');
|
|
94
|
+
source = replaceOnce(source, ' onPointerDown: handlePointerDown,\n children:', ` onPointerDown: handlePointerDown,\n role: isExpanded ? void 0 : 'button',\n tabIndex: isExpanded ? void 0 : 0,\n 'aria-label': isExpanded ? void 0 : 'Open Interface Kit',\n onKeyDown: e => { if (!isExpanded && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); handleOpen(); } },\n children:`);
|
|
95
|
+
return { contents: source, loader: 'js', resolveDir: args.path.slice(0, args.path.lastIndexOf('/')) };
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export async function bundleInterfaceKit() {
|
|
101
|
+
const result = await build({ entryPoints: [fileURLToPath(new URL('./interface-kit-entry.js', import.meta.url))], bundle: true, write: false, platform: 'browser', format: 'esm', define: { 'process.env.NODE_ENV': '"development"' }, plugins: [positioning] });
|
|
102
|
+
return result.outputFiles[0].contents;
|
|
103
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createElement } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import { InterfaceKit } from 'interface-kit/react';
|
|
4
|
+
import { claimCorner } from '../client/corners.js';
|
|
5
|
+
|
|
6
|
+
const mount = document.createElement('div');
|
|
7
|
+
mount.dataset.interfaceKit = '';
|
|
8
|
+
document.body.append(mount);
|
|
9
|
+
window.__interfaceKitCorner = {
|
|
10
|
+
claim(right, bottom) {
|
|
11
|
+
const corner = claimCorner(mount, `${bottom ? 'bottom' : 'top'}-${right ? 'right' : 'left'}`);
|
|
12
|
+
return { right: corner.endsWith('right'), bottom: corner.startsWith('bottom') };
|
|
13
|
+
},
|
|
14
|
+
clear() { delete mount.dataset.floatingCorner; },
|
|
15
|
+
};
|
|
16
|
+
createRoot(mount).render(createElement(InterfaceKit, { enabled: true }));
|
package/src/project.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { validateScreens, presets, screenKinds } from "../client/registry.js";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_CONFIG = "dev/canvas/screens.js";
|
|
6
|
+
export const DEFAULT_STORAGE_KEY = "tio-canvas:v1";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Where a project keeps what is its own: the list of screens (a standalone ES
|
|
10
|
+
* module the browser loads as it stands, so it must import nothing) and the page
|
|
11
|
+
* thumbnails beside it.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveProject({ root = process.cwd(), config = DEFAULT_CONFIG, thumbnails } = {}) {
|
|
14
|
+
const configFile = path.resolve(root, config);
|
|
15
|
+
return {
|
|
16
|
+
root: path.resolve(root),
|
|
17
|
+
configFile,
|
|
18
|
+
projectDir: path.dirname(configFile),
|
|
19
|
+
thumbnailsDir: path.resolve(root, thumbnails ?? path.join(path.dirname(config), "thumbnails"))
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Import the project's config afresh and refuse a screen the canvas cannot show. */
|
|
24
|
+
export async function loadProject(project) {
|
|
25
|
+
const module = await import(`${pathToFileURL(project.configFile).href}?load=${Date.now()}`);
|
|
26
|
+
if (!Array.isArray(module.screens)) throw new Error(`${project.configFile} must export "screens", an array.`);
|
|
27
|
+
validateScreens(module.screens);
|
|
28
|
+
return { screens: module.screens, storageKey: module.storageKey ?? DEFAULT_STORAGE_KEY, presets, screenKinds };
|
|
29
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { resolveProject, DEFAULT_STORAGE_KEY } from "./project.js";
|
|
5
|
+
|
|
6
|
+
const CLIENT = fileURLToPath(new URL("../client", import.meta.url));
|
|
7
|
+
const DEFAULT_STORAGE_EXPORT = /export\s+(?:const|let|var)\s+storageKey\b|export\s*\{[^}]*\bstorageKey\b/;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The canvas as seen by a development server.
|
|
11
|
+
*
|
|
12
|
+
* It does not own a server. A host (the project's own `serve.mjs`) asks it four
|
|
13
|
+
* things and keeps everything else, so the canvas never decides how a project is
|
|
14
|
+
* served:
|
|
15
|
+
*
|
|
16
|
+
* locate(pathname) which directory, and which path inside it, holds a
|
|
17
|
+
* file under /canvas
|
|
18
|
+
* projectModule() the project's config, as the browser must receive it
|
|
19
|
+
* pageScripts(url, headers) the scripts to add to a page that is being framed
|
|
20
|
+
* interfaceKitBundle() the development-only Interface Kit script
|
|
21
|
+
*
|
|
22
|
+
* Nothing here ships: a production build is `copyCanvas` in ./build.js.
|
|
23
|
+
*/
|
|
24
|
+
export function createCanvasHost(options = {}) {
|
|
25
|
+
const project = resolveProject(options);
|
|
26
|
+
let kit;
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
project,
|
|
30
|
+
clientDir: CLIENT,
|
|
31
|
+
|
|
32
|
+
/** Directories to watch; a change in either reloads the workspace. */
|
|
33
|
+
watchTargets: () => [CLIENT, project.projectDir],
|
|
34
|
+
|
|
35
|
+
locate(pathname) {
|
|
36
|
+
const local = pathname.slice("/canvas".length) || "/";
|
|
37
|
+
if (local === "/project.js") return { directory: project.projectDir, localPath: `/${path.basename(project.configFile)}`, project: true };
|
|
38
|
+
if (local.startsWith("/thumbnails/")) return { directory: project.thumbnailsDir, localPath: local.slice("/thumbnails".length) };
|
|
39
|
+
return { directory: CLIENT, localPath: local };
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The project's config, plus the storage key when the config names none.
|
|
44
|
+
* The key decides where a browser keeps the saved layout, so two projects
|
|
45
|
+
* opened on one origin must not share one.
|
|
46
|
+
*/
|
|
47
|
+
async projectModule() {
|
|
48
|
+
const source = await readFile(project.configFile, "utf8");
|
|
49
|
+
return DEFAULT_STORAGE_EXPORT.test(source) ? source : `${source}\nexport const storageKey = ${JSON.stringify(DEFAULT_STORAGE_KEY)};\n`;
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
/** `bridge` goes into every framed screen; `tools` only into one framed by the canvas itself. */
|
|
53
|
+
pageScripts(url, headers) {
|
|
54
|
+
const bridge = url.searchParams.get("canvasScreen") === "1" ? '<script type="module" src="/canvas/bridge.js"></script>' : "";
|
|
55
|
+
const framed = bridge && headers["sec-fetch-dest"] === "iframe";
|
|
56
|
+
const tools = framed ? '<script type="module" src="/__dev/interface-kit.js"></script>' : "";
|
|
57
|
+
return { bridge, tools };
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
async interfaceKitBundle() {
|
|
61
|
+
try {
|
|
62
|
+
kit ??= (await import("./interface-kit-bundle.js")).bundleInterfaceKit();
|
|
63
|
+
return await kit;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
kit = undefined;
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Refresh the development-only page catalog snapshots with a running dev server:
|
|
2
|
+
// tio-canvas thumbnails [http://localhost:4321] [--missing|--check] [--config dev/canvas/screens.js] [--out dev/canvas/thumbnails]
|
|
3
|
+
// Uses the existing Chromium cache, without adding a browser dependency.
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { access, mkdir, readdir, writeFile, mkdtemp, rm } from 'node:fs/promises';
|
|
6
|
+
import { homedir, tmpdir } from 'node:os';
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
8
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
9
|
+
import { resolveProject, loadProject } from './project.js';
|
|
10
|
+
|
|
11
|
+
const args = process.argv.slice(2);
|
|
12
|
+
const option = (name) => { const at = args.indexOf(name); return at < 0 ? undefined : args.splice(at, 2)[1]; };
|
|
13
|
+
const config = option('--config'), out = option('--out');
|
|
14
|
+
const project = resolveProject({ config, thumbnails: out });
|
|
15
|
+
const { screens } = await loadProject(project);
|
|
16
|
+
const mode = args.includes('--check') ? 'check' : args.includes('--missing') ? 'missing' : 'all';
|
|
17
|
+
const base = args.find(arg => !arg.startsWith('--')) ?? 'http://localhost:4321';
|
|
18
|
+
const destination = pathToFileURL(project.thumbnailsDir + '/');
|
|
19
|
+
const missing = [];
|
|
20
|
+
for (const source of screens) {
|
|
21
|
+
try { await access(new URL(`${source.id}.webp`, destination)); }
|
|
22
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; missing.push(source); }
|
|
23
|
+
}
|
|
24
|
+
if (mode === 'check') {
|
|
25
|
+
if (missing.length) throw new Error(`Missing canvas thumbnails: ${missing.map(source => source.id).join(', ')}`);
|
|
26
|
+
console.log(`All ${screens.length} canvas thumbnails are present.`);
|
|
27
|
+
process.exit(0);
|
|
28
|
+
}
|
|
29
|
+
const targets = mode === 'missing' ? missing : screens;
|
|
30
|
+
if (!targets.length) { console.log('No canvas thumbnails are missing.'); process.exit(0); }
|
|
31
|
+
if (!(await fetch(`${base}/canvas/`)).ok) throw new Error('Start the development server before generating thumbnails.');
|
|
32
|
+
const cache = `${homedir()}/Library/Caches/ms-playwright`;
|
|
33
|
+
const shells = (await readdir(cache)).filter(name => name.startsWith('chromium_headless_shell-')).sort();
|
|
34
|
+
const binary = process.env.CHROME_BIN ?? `${cache}/${shells.at(-1)}/chrome-headless-shell-mac-arm64/chrome-headless-shell`;
|
|
35
|
+
const profile = await mkdtemp(`${tmpdir()}/canvas-thumbnails-`);
|
|
36
|
+
const chrome = spawn(binary, ['--headless', '--disable-gpu', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
37
|
+
let socket;
|
|
38
|
+
try {
|
|
39
|
+
const endpoint = await new Promise((resolve, reject) => {
|
|
40
|
+
const timer = setTimeout(() => reject(new Error('Chromium startup timed out.')), 10000);
|
|
41
|
+
let output = '';
|
|
42
|
+
chrome.stderr.on('data', chunk => {
|
|
43
|
+
output += chunk;
|
|
44
|
+
const match = output.match(/DevTools listening on (ws:\/\/[^\s]+)/);
|
|
45
|
+
if (match) { clearTimeout(timer); resolve(new URL(match[1]).origin.replace('ws:', 'http:')); }
|
|
46
|
+
});
|
|
47
|
+
chrome.once('error', error => { clearTimeout(timer); reject(error); });
|
|
48
|
+
});
|
|
49
|
+
const page = (await (await fetch(`${endpoint}/json/list`)).json()).find(item => item.type === 'page');
|
|
50
|
+
socket = new WebSocket(page.webSocketDebuggerUrl);
|
|
51
|
+
await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; });
|
|
52
|
+
let id = 0;
|
|
53
|
+
const pending = new Map();
|
|
54
|
+
socket.onmessage = event => {
|
|
55
|
+
const message = JSON.parse(event.data);
|
|
56
|
+
const request = pending.get(message.id);
|
|
57
|
+
if (!request) return;
|
|
58
|
+
pending.delete(message.id); clearTimeout(request.timer);
|
|
59
|
+
if (message.error) request.reject(new Error(message.error.message));
|
|
60
|
+
else request.resolve(message.result);
|
|
61
|
+
};
|
|
62
|
+
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
|
63
|
+
const key = ++id;
|
|
64
|
+
const timer = setTimeout(() => { pending.delete(key); reject(new Error(`${method} timed out.`)); }, 15000);
|
|
65
|
+
pending.set(key, { resolve, reject, timer });
|
|
66
|
+
socket.send(JSON.stringify({ id: key, method, params }));
|
|
67
|
+
});
|
|
68
|
+
await send('Page.enable');
|
|
69
|
+
await send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] });
|
|
70
|
+
await mkdir(destination, { recursive: true });
|
|
71
|
+
for (const source of targets) {
|
|
72
|
+
await send('Emulation.setDeviceMetricsOverride', { width: source.width, height: source.height, deviceScaleFactor: 1, mobile: false });
|
|
73
|
+
const url = new URL(source.src, base);
|
|
74
|
+
url.searchParams.set('canvasScreen', '1');
|
|
75
|
+
await send('Page.navigate', { url: url.href });
|
|
76
|
+
let ready = false;
|
|
77
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
78
|
+
await sleep(100);
|
|
79
|
+
const result = await send('Runtime.evaluate', { expression: 'location.href === '+JSON.stringify(url.href)+' && document.readyState !== "loading"', returnByValue: true });
|
|
80
|
+
if (result.result.value) { ready = true; break; }
|
|
81
|
+
}
|
|
82
|
+
if (!ready) throw new Error(`Page did not load: ${source.src}`);
|
|
83
|
+
const settled = await send('Runtime.evaluate', { expression: '(async()=>{await document.fonts.ready;await Promise.race([Promise.all([...document.images].map(image=>image.decode().catch(()=>{}))),new Promise(resolve=>setTimeout(resolve,4000))]);await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));return true})()', awaitPromise: true, returnByValue: true });
|
|
84
|
+
if (settled.exceptionDetails) throw new Error(`Page did not settle: ${source.src}`);
|
|
85
|
+
const { data } = await send('Page.captureScreenshot', { format: 'webp', quality: 80, clip: { x: 0, y: 0, width: source.width, height: source.height, scale: 160 / Math.min(source.width, source.height) } });
|
|
86
|
+
await writeFile(new URL(`${source.id}.webp`, destination), Buffer.from(data, 'base64'));
|
|
87
|
+
console.log(`Updated ${source.id}.webp`);
|
|
88
|
+
}
|
|
89
|
+
} finally {
|
|
90
|
+
socket?.close();
|
|
91
|
+
const exited = new Promise(resolve => chrome.once('exit', resolve));
|
|
92
|
+
chrome.kill();
|
|
93
|
+
await exited;
|
|
94
|
+
await rm(profile, { recursive: true, force: true });
|
|
95
|
+
}
|