mirror of
https://github.com/mvschwarz/openrig-studio.git
synced 2026-11-07 05:56:21 -10:00
review.r2 contract audit — the last two Important findings. I2 — the installable-app surface.url branch had no composition path. The app contract defines surface.url for a self-served app, mutually exclusive with path and with no entry to copy. Composition unconditionally joined m.surface.entry, so a schema-valid URL app could not compose — it was reported missing or threw. It now branches: a url app emits a URL row and copies nothing (it serves itself), and may still declare a provider and calls. Control: a surface.url app composes to a row carrying the url and no path. I3 — a cross-origin annotation target produced a FALSE anchor. On an unreadable cross-origin nested target the shell fell back to the outer surface document, and the annotation layer resolved the record's selector against it — so a host and its nested doc both carrying `#publish` would anchor to the wrong one, the exact false anchor the contract says it prevents. The shell now returns a spatial-only view for a cross-origin declared target (a rect, no readable doc) instead of falling back to the surface document, and the layer's view() honours spatial-only without falling back while elementAnchor refuses a doc-less view. A cross-origin surface stays spatial-only, as the contract requires. Structural controls updated (the fallback rename, the spatialOnly branch, the doc guard) with the positive control proving they can fail; the pixel-level behaviour remains browser-verified, as this repo has no browser harness. 365 -> 366 tests, 366/366 green. Audit round complete: all Criticals, Importants, and Minors fixed (I4 excluded — PM's lane).
412 lines
24 KiB
JavaScript
412 lines
24 KiB
JavaScript
const NS = "http://www.w3.org/2000/svg";
|
||
const SHAPES = new Set(["circle", "rect", "arrow", "text"]);
|
||
|
||
// "persisted" is a promise the user acts on: their marks survive a restart. The
|
||
// DISCLOSURE decides it, never the presence of a store. A memory-backed runtime
|
||
// is write-capable — a store exists and saves work within the session — yet
|
||
// loses everything on restart, so it must read "session only". Reducing this to
|
||
// Boolean(store) was the defect: it showed "persisted" over marks that vanish.
|
||
// Fail-safe by design: any disclosure not recognised as durable reads
|
||
// "session only", because a false "persisted" costs the user their work while a
|
||
// false "session only" costs nothing. `persistence` is meta.annotations.persistence
|
||
// ("memory" | "file"); only "file" survives a restart today.
|
||
export const marksPersist = (store, persistence) => Boolean(store) && persistence === "file";
|
||
const KEYS = { c: "circle", b: "rect", a: "arrow", t: "text" };
|
||
const clone = (value) => structuredClone(value);
|
||
const clamp = (value, min = 0, max = 1) => Math.max(min, Math.min(max, value));
|
||
const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||
const cssEscape = (value) => globalThis.CSS?.escape ? CSS.escape(String(value)) : String(value).replace(/[^a-zA-Z0-9_-]/g, (c) => `\\${c}`);
|
||
const id = () => globalThis.crypto?.randomUUID?.() ?? `ann-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||
|
||
function isTypingTarget(target) {
|
||
if (!(target instanceof Element)) return false;
|
||
return Boolean(target.closest("input, textarea, select, [contenteditable='true'], [contenteditable='']"));
|
||
}
|
||
|
||
function stableSelector(element) {
|
||
if (!(element instanceof Element)) return null;
|
||
if (element.id) return `#${cssEscape(element.id)}`;
|
||
for (const name of ["data-annotate-id", "data-testid", "name", "aria-label"]) {
|
||
const value = element.getAttribute(name);
|
||
if (value) return `[${name}="${cssEscape(value)}"]`;
|
||
}
|
||
const parts = [];
|
||
for (let node = element; node && node.nodeType === 1 && node !== node.ownerDocument.body; node = node.parentElement) {
|
||
const tag = node.tagName.toLowerCase();
|
||
const siblings = [...(node.parentElement?.children ?? [])].filter((item) => item.tagName === node.tagName);
|
||
parts.unshift(siblings.length > 1 ? `${tag}:nth-of-type(${siblings.indexOf(node) + 1})` : tag);
|
||
if (parts.length >= 5) break;
|
||
}
|
||
return parts.length ? parts.join(" > ") : null;
|
||
}
|
||
|
||
function sheet() {
|
||
const existing = document.querySelector("link[data-studio-annotations]");
|
||
if (existing) return existing;
|
||
const link = document.createElement("link");
|
||
link.rel = "stylesheet";
|
||
link.href = new URL("./annotations.css", import.meta.url).href;
|
||
link.dataset.studioAnnotations = "";
|
||
document.head.append(link);
|
||
return link;
|
||
}
|
||
|
||
export function attach(shell, { store, persistence = null } = {}) {
|
||
if (!shell?.overlay || typeof shell.active !== "function" || typeof shell.onMarkup !== "function" || typeof shell.onSurface !== "function") {
|
||
throw new TypeError("annotations need a studioShell with overlay, active(), onMarkup(), and onSurface()");
|
||
}
|
||
if (store && (typeof store?.load !== "function" || typeof store?.save !== "function")) {
|
||
throw new TypeError("annotation store must provide both load(surfaceId) and save(surfaceId, records)");
|
||
}
|
||
// The label follows the runtime's persistence DISCLOSURE, not the presence of
|
||
// a store — see marksPersist above.
|
||
const persistent = marksPersist(store, persistence);
|
||
sheet();
|
||
const memory = new Map();
|
||
const root = document.createElement("section");
|
||
root.className = "studio-annotations";
|
||
root.setAttribute("aria-label", "Shared annotations");
|
||
root.innerHTML = `
|
||
<svg class="studio-annotations__canvas" aria-label="Annotation canvas">
|
||
<defs>
|
||
<marker id="studio-ann-arrow-human" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="#ff6b5a"/></marker>
|
||
<marker id="studio-ann-arrow-agent" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="#8b93ff"/></marker>
|
||
</defs>
|
||
<g data-marks></g><g data-draft></g>
|
||
</svg>
|
||
<div class="studio-annotations__tools" role="toolbar" aria-label="Annotation tools">
|
||
<input class="studio-annotations__note" aria-label="Annotation note" placeholder="What should change?">
|
||
<button data-tool="circle">○ Circle <kbd>C</kbd></button>
|
||
<button data-tool="rect">□ Box <kbd>B</kbd></button>
|
||
<button data-tool="arrow">↗ Arrow <kbd>A</kbd></button>
|
||
<button data-tool="text">T Text <kbd>T</kbd></button>
|
||
<button data-action="undo" title="Undo">↶</button>
|
||
<button data-action="redo" title="Redo">↷</button>
|
||
</div>
|
||
<aside class="studio-annotations__thread">
|
||
<div class="studio-annotations__thread-head"><strong>Annotations</strong><span class="studio-annotations__status"></span></div>
|
||
<div class="studio-annotations__list"></div>
|
||
</aside>`;
|
||
shell.overlay.append(root);
|
||
|
||
const canvas = root.querySelector(".studio-annotations__canvas");
|
||
const marks = root.querySelector("[data-marks]");
|
||
const draftLayer = root.querySelector("[data-draft]");
|
||
const list = root.querySelector(".studio-annotations__list");
|
||
const status = root.querySelector(".studio-annotations__status");
|
||
const note = root.querySelector(".studio-annotations__note");
|
||
const state = { surfaceId: null, scope: null, records: [], tool: null, draft: null, drag: null, selected: null, undo: [], redo: [], loading: 0 };
|
||
const cleaners = [];
|
||
|
||
const bounds = () => {
|
||
const rect = root.getBoundingClientRect();
|
||
return { rect, width: Math.max(1, rect.width), height: Math.max(1, rect.height) };
|
||
};
|
||
const active = () => shell.active();
|
||
// THE PERSISTENCE KEY IS THE SHELL'S TO COMPOSE, not this layer's. A surface id
|
||
// is the default; a surface holding several documents behind one id declares a
|
||
// sub-context and the shell folds it in. Composing it here as well would be two
|
||
// places computing one property, which drifts silently.
|
||
//
|
||
// Falling back to the surface id keeps this working against a shell that
|
||
// predates the seam — the layer degrades to one board per surface rather than
|
||
// throwing, which is what it did before sub-contexts existed.
|
||
const scopeOf = () => (typeof shell.scope === "function" ? shell.scope() : active().id);
|
||
// WHERE TO LOOK FOR ELEMENTS, and that document's rect in top-window
|
||
// coordinates. Normally the active surface's frame; a surface that renders its
|
||
// real content in a SAME-ORIGIN NESTED frame may name it, and the shell resolves
|
||
// the chain. Without this an agent naming "#publish" resolves against the host
|
||
// surface's chrome instead of the document the human is looking at — the mark
|
||
// lands as "missing" and the core property (an agent names an element, the human
|
||
// sees a mark on THAT element) is lost.
|
||
//
|
||
// The shell composes it; this layer only consumes it. Falls back to the surface
|
||
// frame against a shell that predates the seam.
|
||
const view = () => {
|
||
if (typeof shell.target === "function") {
|
||
let v = null;
|
||
try { v = shell.target(); } catch { v = null; }
|
||
// A cross-origin declared target is SPATIAL-ONLY: a rect but no readable
|
||
// doc. Return it as-is and do NOT fall back to the surface document — the
|
||
// record's selector was meant for the cross-origin frame, and matching it
|
||
// against the outer host is the false anchor the contract forbids.
|
||
if (v?.spatialOnly && v?.rect) return { doc: null, rect: v.rect };
|
||
if (v?.doc && v?.rect) return v;
|
||
}
|
||
const frame = active().frame;
|
||
try { return frame?.contentDocument ? { doc: frame.contentDocument, rect: frame.getBoundingClientRect() } : null; }
|
||
catch { return null; }
|
||
};
|
||
const frameDocument = () => view()?.doc ?? null;
|
||
const point = (event) => {
|
||
const { rect, width, height } = bounds();
|
||
return { x: clamp((event.clientX - rect.left) / width), y: clamp((event.clientY - rect.top) / height) };
|
||
};
|
||
const elementAt = (event) => {
|
||
const v = view();
|
||
if (!v) return null;
|
||
try { return v.doc.elementFromPoint(event.clientX - v.rect.left, event.clientY - v.rect.top); } catch { return null; }
|
||
};
|
||
const elementAnchor = (selector) => {
|
||
const v = view();
|
||
// No readable doc (a cross-origin spatial-only target) means NO element
|
||
// anchoring: the selector stays unresolved and the mark is spatial/missing,
|
||
// never falsely anchored to the wrong document.
|
||
if (!v || !v.doc || !selector) return null;
|
||
let element;
|
||
try { element = v.doc.querySelector(selector); } catch { return null; }
|
||
if (!element) return null;
|
||
const outer = bounds();
|
||
const frameRect = v.rect;
|
||
const rect = element.getBoundingClientRect();
|
||
return {
|
||
x: (frameRect.left - outer.rect.left + rect.left) / outer.width,
|
||
y: (frameRect.top - outer.rect.top + rect.top) / outer.height,
|
||
width: rect.width / outer.width,
|
||
height: rect.height / outer.height,
|
||
text: element.innerText?.trim().replace(/\s+/g, " ").slice(0, 180) || element.getAttribute("aria-label") || "",
|
||
};
|
||
};
|
||
const displayRecord = (record) => {
|
||
const found = elementAnchor(record.selector);
|
||
if (!record.selector || !found) return { ...record, status: record.selector ? "missing" : "spatial" };
|
||
const offset = record.offset ?? { x: 0, y: 0, width: 0, height: 0 };
|
||
return { ...record, status: "anchored", text: found.text || record.text, anchor: {
|
||
x: found.x + (offset.x ?? 0), y: found.y + (offset.y ?? 0),
|
||
width: Math.max(.01, found.width + (offset.width ?? 0)), height: Math.max(.01, found.height + (offset.height ?? 0)),
|
||
} };
|
||
};
|
||
|
||
const shapeMarkup = (record, index, classes = "") => {
|
||
const shown = displayRecord(record);
|
||
const { width, height } = bounds();
|
||
const a = shown.anchor ?? { x: .4, y: .4, width: .2, height: .1 };
|
||
const x = a.x * width, y = a.y * height, w = a.width * width, h = a.height * height;
|
||
const source = shown.source === "agent" ? "agent" : "human";
|
||
let shape;
|
||
if (shown.shape === "circle") shape = `<ellipse class="shape" cx="${x + w / 2}" cy="${y + h / 2}" rx="${Math.abs(w / 2)}" ry="${Math.abs(h / 2)}"/>`;
|
||
else if (shown.shape === "arrow") shape = `<line class="shape" x1="${x}" y1="${y}" x2="${x + w}" y2="${y + h}" marker-end="url(#studio-ann-arrow-${source})"/>`;
|
||
else if (shown.shape === "text") shape = `<rect class="shape" x="${x}" y="${y}" width="${Math.abs(w)}" height="${Math.abs(h)}" rx="4"/><text class="studio-annotations__label" x="${x + 9}" y="${y + Math.min(20, Math.abs(h) - 7)}">${esc(shown.note || "Text")}</text>`;
|
||
else shape = `<rect class="shape" x="${Math.min(x, x + w)}" y="${Math.min(y, y + h)}" width="${Math.abs(w)}" height="${Math.abs(h)}" rx="3"/>`;
|
||
const badgeX = Math.min(x, x + w), badgeY = Math.min(y, y + h);
|
||
return `<g class="studio-annotations__mark ${source === "agent" ? "agent" : ""} ${state.selected === shown.id ? "selected" : ""} ${shown.status === "missing" ? "missing" : ""} ${classes}" data-id="${esc(shown.id)}">${shape}<circle class="studio-annotations__badge" cx="${badgeX}" cy="${badgeY}" r="9"/><text class="studio-annotations__badge-text" x="${badgeX}" y="${badgeY}">${shown.status === "missing" ? "?" : (source === "agent" ? "A" : "H")}</text>${index >= 0 ? `<text class="studio-annotations__label" x="${badgeX + 13}" y="${badgeY + 4}">${index + 1}</text>` : ""}</g>`;
|
||
};
|
||
const describe = (record) => {
|
||
const shown = displayRecord(record);
|
||
const target = shown.selector ? `${shown.shape} on ${shown.selector}${shown.text ? ` (\"${shown.text}\")` : ""}` : `${shown.shape} on the surface`;
|
||
return { shown, target };
|
||
};
|
||
function render() {
|
||
const { width, height } = bounds();
|
||
canvas.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||
marks.innerHTML = state.records.map((record, index) => shapeMarkup(record, index)).join("");
|
||
draftLayer.innerHTML = state.draft ? shapeMarkup({ ...state.draft, id: "draft", source: "human" }, -1, "studio-annotations__draft") : "";
|
||
root.classList.toggle("is-browsing", !state.tool);
|
||
root.querySelectorAll("[data-tool]").forEach((button) => button.classList.toggle("on", button.dataset.tool === state.tool));
|
||
status.textContent = persistent ? `${state.records.length} · persisted` : `${state.records.length} · session only`;
|
||
list.innerHTML = state.records.length ? state.records.map((record, index) => {
|
||
const { shown, target } = describe(record);
|
||
return `<article class="studio-annotations__item ${shown.source === "agent" ? "agent" : ""} ${state.selected === shown.id ? "selected" : ""}" data-item="${esc(shown.id)}"><div class="studio-annotations__item-top"><span>${index + 1}</span><span class="studio-annotations__source">${shown.source === "agent" ? "Agent" : "You"}</span><span>${esc(shown.shape)}</span>${shown.status === "missing" ? '<span class="studio-annotations__missing">anchor missing</span>' : ""}<button class="studio-annotations__delete" data-delete="${esc(shown.id)}" title="Delete annotation">×</button></div><p>${esc(shown.note || "Review this")}</p><small>${esc(target)}</small></article>`;
|
||
}).join("") : '<div class="studio-annotations__empty">No marks yet. Choose a tool, then draw on the surface.</div>';
|
||
}
|
||
|
||
const read = async (surfaceId) => {
|
||
const value = store?.load ? await store.load(surfaceId) : memory.get(surfaceId);
|
||
return clone(Array.isArray(value) ? value : value?.records ?? []);
|
||
};
|
||
const write = async () => {
|
||
const records = clone(state.records);
|
||
if (store?.save) await store.save(state.scope, records);
|
||
else memory.set(state.scope, records);
|
||
};
|
||
const checkpoint = () => {
|
||
state.undo.push(clone(state.records));
|
||
if (state.undo.length > 50) state.undo.shift();
|
||
state.redo.length = 0;
|
||
};
|
||
const travel = async (direction) => {
|
||
const source = direction === "undo" ? state.undo : state.redo;
|
||
const target = direction === "undo" ? state.redo : state.undo;
|
||
const snapshot = source.pop();
|
||
if (!snapshot) return false;
|
||
target.push(clone(state.records));
|
||
state.records = snapshot;
|
||
state.selected = null;
|
||
await write(); render(); return true;
|
||
};
|
||
const loadSurface = async () => {
|
||
const token = ++state.loading;
|
||
const surfaceId = active().id;
|
||
const scope = scopeOf();
|
||
state.surfaceId = surfaceId;
|
||
state.scope = scope;
|
||
state.records = scope ? await read(scope) : [];
|
||
if (token !== state.loading) return;
|
||
state.selected = null; state.draft = null; state.drag = null; state.undo = []; state.redo = [];
|
||
render();
|
||
};
|
||
const choose = (shape) => {
|
||
state.tool = SHAPES.has(shape) && state.tool !== shape ? shape : null;
|
||
state.selected = null; state.draft = null; render();
|
||
return state.tool;
|
||
};
|
||
const add = async ({ selector = null, shape = "circle", note: message = "Review this", source = "agent", anchor = null } = {}) => {
|
||
if (!SHAPES.has(shape)) throw new TypeError(`unknown annotation shape: ${shape}`);
|
||
const found = selector ? elementAnchor(selector) : null;
|
||
const placed = anchor ?? (found ? { x: found.x, y: found.y, width: found.width, height: found.height } : { x: .38, y: .38, width: .24, height: .14 });
|
||
checkpoint();
|
||
const record = { id: id(), surfaceId: state.surfaceId, selector, shape, note: message, source: source === "human" ? "human" : "agent", anchor: placed, offset: found ? { x: placed.x - found.x, y: placed.y - found.y, width: placed.width - found.width, height: placed.height - found.height } : null, text: found?.text ?? "", status: selector ? (found ? "anchored" : "missing") : "spatial", createdAt: new Date().toISOString() };
|
||
state.records.push(record); await write(); render(); return clone(record);
|
||
};
|
||
const remove = async (recordId) => {
|
||
const at = state.records.findIndex((record) => record.id === recordId);
|
||
if (at < 0) return false;
|
||
checkpoint(); state.records.splice(at, 1); state.selected = null; await write(); render(); return true;
|
||
};
|
||
|
||
const onPointerDown = (event) => {
|
||
if (!shell.markup()) return;
|
||
const mark = event.target.closest?.("[data-id]");
|
||
if (!state.tool && mark) {
|
||
const record = state.records.find((item) => item.id === mark.dataset.id);
|
||
if (!record?.anchor) return;
|
||
state.selected = record.id;
|
||
state.drag = { id: record.id, pointerId: event.pointerId, start: point(event), before: clone(record), moved: false };
|
||
canvas.setPointerCapture?.(event.pointerId); render(); event.preventDefault(); return;
|
||
}
|
||
if (!state.tool) return;
|
||
const start = point(event);
|
||
state.draft = { shape: state.tool, anchor: { x: start.x, y: start.y, width: state.tool === "text" ? .2 : 0, height: state.tool === "text" ? .05 : 0 }, start, selector: stableSelector(elementAt(event)) };
|
||
canvas.setPointerCapture?.(event.pointerId); render(); event.preventDefault();
|
||
};
|
||
const onPointerMove = (event) => {
|
||
if (state.drag?.pointerId === event.pointerId) {
|
||
const record = state.records.find((item) => item.id === state.drag.id);
|
||
if (!record) return;
|
||
const now = point(event), dx = now.x - state.drag.start.x, dy = now.y - state.drag.start.y;
|
||
record.anchor = { ...state.drag.before.anchor, x: state.drag.before.anchor.x + dx, y: state.drag.before.anchor.y + dy };
|
||
if (record.selector) record.offset = { ...(state.drag.before.offset ?? {}), x: (state.drag.before.offset?.x ?? 0) + dx, y: (state.drag.before.offset?.y ?? 0) + dy };
|
||
state.drag.moved ||= Math.abs(dx) + Math.abs(dy) > .002; render(); return;
|
||
}
|
||
if (!state.draft) return;
|
||
const now = point(event), start = state.draft.start;
|
||
if (state.draft.shape !== "text") state.draft.anchor = { x: start.x, y: start.y, width: now.x - start.x, height: now.y - start.y };
|
||
render();
|
||
};
|
||
const onPointerUp = async (event) => {
|
||
if (state.drag?.pointerId === event.pointerId) {
|
||
const drag = state.drag; state.drag = null;
|
||
if (drag.moved) { state.undo.push(state.records.map((item) => item.id === drag.id ? drag.before : clone(item))); state.redo.length = 0; await write(); }
|
||
render(); return;
|
||
}
|
||
if (!state.draft) return;
|
||
const draft = state.draft; state.draft = null;
|
||
const raw = draft.anchor;
|
||
let anchor = draft.shape === "rect" || draft.shape === "circle" ? { x: Math.min(raw.x, raw.x + raw.width), y: Math.min(raw.y, raw.y + raw.height), width: Math.max(.012, Math.abs(raw.width)), height: Math.max(.012, Math.abs(raw.height)) } : raw;
|
||
if (draft.shape === "text") anchor = { ...anchor, width: .22, height: .06 };
|
||
let message = note.value.trim();
|
||
if (draft.shape === "text" && !message) message = window.prompt("Text to place on the surface")?.trim() ?? "";
|
||
if (!message) message = draft.shape === "text" ? "Text" : "Review this";
|
||
const found = draft.selector ? elementAnchor(draft.selector) : null;
|
||
checkpoint();
|
||
state.records.push({ id: id(), surfaceId: state.surfaceId, selector: draft.selector, shape: draft.shape, note: message, source: "human", anchor, offset: found ? { x: anchor.x - found.x, y: anchor.y - found.y, width: anchor.width - found.width, height: anchor.height - found.height } : null, text: found?.text ?? "", status: draft.selector ? (found ? "anchored" : "missing") : "spatial", createdAt: new Date().toISOString() });
|
||
await write(); render();
|
||
};
|
||
|
||
canvas.addEventListener("pointerdown", onPointerDown);
|
||
canvas.addEventListener("pointermove", onPointerMove);
|
||
canvas.addEventListener("pointerup", (event) => onPointerUp(event).catch(console.error));
|
||
canvas.addEventListener("pointercancel", () => { if (state.drag) { const at = state.records.findIndex((item) => item.id === state.drag.id); if (at >= 0) state.records[at] = state.drag.before; } state.drag = null; state.draft = null; render(); });
|
||
root.querySelectorAll("[data-tool]").forEach((button) => button.addEventListener("click", () => choose(button.dataset.tool)));
|
||
root.querySelector("[data-action='undo']").addEventListener("click", () => travel("undo"));
|
||
root.querySelector("[data-action='redo']").addEventListener("click", () => travel("redo"));
|
||
list.addEventListener("click", (event) => {
|
||
const deleting = event.target.closest("[data-delete]");
|
||
if (deleting) { remove(deleting.dataset.delete); return; }
|
||
const item = event.target.closest("[data-item]");
|
||
if (item) { state.selected = item.dataset.item; render(); }
|
||
});
|
||
const onKey = (event) => {
|
||
if (!shell.markup() || isTypingTarget(event.target)) return;
|
||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z") { event.preventDefault(); travel(event.shiftKey ? "redo" : "undo"); return; }
|
||
if (event.key === "Escape") { choose(null); return; }
|
||
if ((event.key === "Delete" || event.key === "Backspace") && state.selected) { event.preventDefault(); remove(state.selected); return; }
|
||
const shape = KEYS[event.key.toLowerCase()];
|
||
if (shape && !event.metaKey && !event.ctrlKey && !event.altKey) { event.preventDefault(); choose(shape); }
|
||
};
|
||
window.addEventListener("keydown", onKey);
|
||
window.addEventListener("resize", render);
|
||
cleaners.push(shell.onMarkup((on) => { root.classList.toggle("is-marking", Boolean(on)); if (!on) { state.tool = null; state.draft = null; state.drag = null; } render(); }));
|
||
const frameLoad = () => render();
|
||
let observedFrame = null;
|
||
const observeFrame = () => {
|
||
observedFrame?.removeEventListener("load", frameLoad);
|
||
observedFrame = active().frame;
|
||
observedFrame?.addEventListener("load", frameLoad);
|
||
};
|
||
cleaners.push(shell.onSurface(() => { observeFrame(); loadSurface().catch(console.error); }));
|
||
// RE-MEASURE, NOT RELOAD. render() recomputes every anchor from its selector, so
|
||
// a repaint is all a moved element needs. Guarded because a shell that predates
|
||
// this seam has no onRefresh.
|
||
if (typeof shell.onRefresh === "function") cleaners.push(shell.onRefresh(() => render()));
|
||
// The cheap automatic half. These cover RESIZE and SCROLL, which are the cases
|
||
// the browser will tell us about for free.
|
||
//
|
||
// THEY ARE NOT SUFFICIENT AND THAT IS WHY THE EXPLICIT HOOK EXISTS: an element
|
||
// that MOVES within an unchanged viewport fires neither. A canvas item dragged
|
||
// across a board is exactly that case, and no observer short of polling every
|
||
// frame would catch it.
|
||
let observer = null;
|
||
const watchGeometry = () => {
|
||
observer?.disconnect();
|
||
observer = null;
|
||
const doc = frameDocument();
|
||
if (!doc?.documentElement || typeof ResizeObserver === "undefined") return;
|
||
try {
|
||
observer = new ResizeObserver(() => render());
|
||
observer.observe(doc.documentElement);
|
||
} catch { observer = null; }
|
||
};
|
||
const onScroll = () => render();
|
||
const watchScroll = () => {
|
||
const doc = frameDocument();
|
||
try { doc?.addEventListener("scroll", onScroll, { capture: true, passive: true }); } catch {}
|
||
};
|
||
const unwatchScroll = () => {
|
||
const doc = frameDocument();
|
||
try { doc?.removeEventListener("scroll", onScroll, { capture: true }); } catch {}
|
||
};
|
||
cleaners.push(shell.onSurface(() => { unwatchScroll(); watchGeometry(); watchScroll(); }));
|
||
watchGeometry(); watchScroll();
|
||
observeFrame();
|
||
root.classList.toggle("is-marking", Boolean(shell.markup()));
|
||
const ready = loadSurface();
|
||
ready.catch(console.error);
|
||
|
||
return Object.freeze({
|
||
detach() {
|
||
observer?.disconnect();
|
||
unwatchScroll();
|
||
cleaners.forEach((clean) => clean?.());
|
||
observedFrame?.removeEventListener("load", frameLoad);
|
||
window.removeEventListener("keydown", onKey);
|
||
window.removeEventListener("resize", render);
|
||
root.remove();
|
||
},
|
||
ready,
|
||
// Re-measure every anchor and repaint. Cheap, idempotent, and does NOT reload
|
||
// the board or disturb undo history — for a surface (or an agent) that moved
|
||
// something the layer cannot observe.
|
||
refresh: () => { render(); },
|
||
annotate: add,
|
||
list: () => clone(state.records),
|
||
remove,
|
||
undo: () => travel("undo"),
|
||
redo: () => travel("redo"),
|
||
tool: choose,
|
||
state: () => ({ surfaceId: state.surfaceId, scope: state.scope,
|
||
anchoringIn: (() => { const v = view(); return v ? (v.doc === active().frame?.contentDocument ? "surface" : "nested") : null; })(),
|
||
markup: shell.markup(), tool: state.tool, selected: state.selected, records: clone(state.records), persistence: persistent ? "store" : "session only" }),
|
||
});
|
||
}
|