Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tw9fjX7o2YfeGQE18MEtPu
60 lines
3.8 KiB
JavaScript
60 lines
3.8 KiB
JavaScript
// The page, as a service. A feature never names `document` or `window`: it asks `app.dom`.
|
|
// Only this file and the kit reach the page, so what a feature may do there is this short list.
|
|
export function createDom(doc = document) {
|
|
const win = doc.defaultView;
|
|
const field = /^(input|textarea)$/i;
|
|
const on = (target, type, fn, opts) => { target.addEventListener(type, fn, opts); return () => target.removeEventListener(type, fn, opts); };
|
|
const link = (href, download) => Object.assign(doc.createElement("a"), { href, download }).click();
|
|
return {
|
|
/** The caret is in a text field inside `node`: a redraw there would eat keystrokes. `only` narrows it to one tag. */
|
|
typingIn(node, only) { const a = doc.activeElement; return !!a && node.contains(a) && (only ? a.tagName === only : field.test(a.tagName)); },
|
|
/** `node` itself has the focus. */
|
|
focusOn: (node) => doc.activeElement === node,
|
|
/** The focus is `node` or something inside it. */
|
|
holdsFocus: (node) => !!doc.activeElement && node.contains(doc.activeElement),
|
|
/** Let go of whatever has the focus, so a field being typed in commits first. */
|
|
blur() { const a = doc.activeElement; if (a && a !== doc.body && typeof a.blur === "function") a.blur(); },
|
|
/** Listen on the page / the window. Each returns the function that stops listening. */
|
|
onPage: (type, fn, opts) => on(doc, type, fn, opts),
|
|
onWindow: (type, fn, opts) => on(win, type, fn, opts),
|
|
/** A dialog is open, so nothing behind it should react. */
|
|
dialogOpen: () => !!doc.querySelector('[role="dialog"]'),
|
|
/** Text is being selected inside `node` (a redraw would cancel the drag). */
|
|
selectingIn(node) { const s = doc.getSelection(); return !!s && !s.isCollapsed && node.contains(s.anchorNode); },
|
|
/** Save a file from a URL the browser may fetch. */
|
|
download: (href, name) => link(href, name),
|
|
/** Save something built in the page (a Blob). */
|
|
saveBlob(blob, name) { const href = URL.createObjectURL(blob); link(href, name); URL.revokeObjectURL(href); },
|
|
/** Open a URL in a new tab. */
|
|
openTab: (url) => win.open(url, "_blank", "noopener"),
|
|
/** Ask for a file with the browser's own chooser -> the File, or null when it was cancelled. */
|
|
pickFile: (accept) => new Promise((resolve) => {
|
|
const input = Object.assign(doc.createElement("input"), { type: "file", accept });
|
|
input.onchange = () => resolve(input.files[0] || null);
|
|
input.oncancel = () => resolve(null);
|
|
input.click();
|
|
}),
|
|
/** How long a media file is, from the browser's own reading of it (null when it cannot say). */
|
|
length: (kind, src) => new Promise((resolve) => {
|
|
const m = doc.createElement(kind === "audio" ? "audio" : "video");
|
|
const done = (v) => { m.removeAttribute("src"); m.load(); resolve(v); };
|
|
m.preload = "metadata";
|
|
m.onloadedmetadata = () => done(Number.isFinite(m.duration) ? m.duration : null);
|
|
m.onerror = () => done(null);
|
|
setTimeout(() => done(null), 8000);
|
|
m.src = src;
|
|
}),
|
|
/** A part of the screen by name: "menubar", "main", "zone:0" (Assets, Preview, Properties, Timeline) or "button:<label start>". */
|
|
landmark(name) {
|
|
if (name === "menubar") return doc.querySelector(".cx-frame-head");
|
|
if (name === "main") return doc.querySelector(".cx-frame-main");
|
|
const [kind, rest] = name.split(/:(.*)/s);
|
|
if (kind === "zone") return doc.querySelectorAll("section.cx-zone")[Number(rest)] || null;
|
|
if (kind === "button") return [...doc.querySelectorAll("button")].find((b) => rest.split("|").filter(Boolean).some((r) => b.textContent.trim().startsWith(r))) || null; // "a|b": either label (a button that is renamed while it works)
|
|
return null;
|
|
},
|
|
/** The page's body, as an anchor for things that float. */
|
|
body: doc.body,
|
|
};
|
|
}
|