/** * human-review artifact SDK. * * Runs inside the sandboxed iframe alongside the artifact. It owns the * document: editing, highlights, target resolution and serialization. It never * talks to the server — everything crosses to the chrome page by postMessage. */ import { buildContext, findQuote } from "./anchor-text.js"; import { hashClickAction, inPageControl, navigationHref } from "./click-target.js"; import { linkStyleFixup, listCommandFor, listStyleFixup, normalizeHref } from "./editing.js"; import { keepBodyEditable, serializeDocument, UI_ATTR, MARK_ATTR } from "./serialize.js"; /** * True when an "Enter" keydown is really an IME confirming its composition * (e.g. finalizing kanji conversion), not the user asking to submit. */ function isImeCommitEnter(event) { return Boolean(event.isComposing || event.keyCode === 229); } const SAVE_DEBOUNCE_MS = 700; const EDIT_FLUSH_MS = 500; const MEDIA = /^(img|svg|canvas|video|picture|iframe|hr|figure)$/i; // The chrome page lives on the other loopback hostname (a separate origin, so // the reviewed document can never touch it directly). Address it explicitly so // nothing we post can be read by any other embedder. const CHROME_ORIGIN = `${location.protocol}//${location.hostname === "127.0.0.1" ? "localhost" : "127.0.0.1"}:${location.port}`; const post = (type, payload) => parent.postMessage({ ...payload, type }, CHROME_ORIGIN); let pending = null; // { id, marks } for an uncommitted highlight let hoverTarget = null; let hoverMove = null; // the innermost block under the cursor, movable via the handle let hoverMedia = null; // the img/video under the cursor, resizable via the grip let resizing = null; // live drag state while the grip is held let suppressUntil = 0; // ignore the mouseup/click that ends a resize drag let saveTimer = null; let composeOpen = false; /** True when the page's own scripts rewrote the DOM before any user edit. */ let dynamic = false; // ------------------------------------------------------------------ overlay const host = document.createElement("div"); host.setAttribute(UI_ATTR, ""); const shadow = host.attachShadow({ mode: "open" }); shadow.innerHTML = `
⠿
`; const els = {}; const mountOverlay = () => { if (!host.isConnected) document.documentElement.appendChild(host); els.outline = shadow.getElementById("outline"); els.activeBox = shadow.getElementById("activeBox"); els.chips = shadow.getElementById("chips"); els.chipDelete = shadow.getElementById("chipDelete"); els.grip = shadow.getElementById("grip"); els.hint = shadow.getElementById("hint"); els.linkbox = shadow.getElementById("linkbox"); els.linkInput = shadow.getElementById("linkInput"); els.linkApply = shadow.getElementById("linkApply"); els.linkRemove = shadow.getElementById("linkRemove"); els.mover = shadow.getElementById("mover"); els.dropline = shadow.getElementById("dropline"); els.dropTarget = shadow.getElementById("dropTarget"); els.dragLabel = shadow.getElementById("dragLabel"); }; function showMover(el) { if (!el || !el.isConnected) { els.mover.style.display = "none"; return; } const r = el.getBoundingClientRect(); if (!r.width && !r.height) { els.mover.style.display = "none"; return; } els.mover.style.display = "flex"; // Half-overlap the block's edge: the pointer can travel from text to handle // without ever leaving the block, so the hover state never drops. els.mover.style.left = `${Math.max(4, r.left - 9)}px`; els.mover.style.top = `${Math.max(4, r.top + 1)}px`; } /** * Where a dragged block will land: a bold line on the edge it drops against, * a dashed outline on the block that edge belongs to, and a label by the * pointer saying the same in words. Three cues, because a thin line alone * vanishes against borders and bullets. */ function showDropline(drop, pointer, movingLabel) { if (!drop || !drop.ref.isConnected) { els.dropline.style.display = "none"; els.dropTarget.style.display = "none"; els.dragLabel.style.display = "none"; return; } const r = drop.ref.getBoundingClientRect(); els.dropline.style.display = "block"; els.dropline.style.left = `${r.left}px`; els.dropline.style.width = `${r.width}px`; els.dropline.style.top = `${(drop.before ? r.top : r.bottom) - 2}px`; place(els.dropTarget, drop.ref, 3); if (!pointer) return; els.dragLabel.textContent = ""; const verb = document.createTextNode(`Move “${clip(movingLabel, 28)}” ${drop.before ? "above" : "below"} `); const target = document.createElement("b"); target.textContent = `“${clip(drop.ref.textContent, 32)}”`; els.dragLabel.append(verb, target); els.dragLabel.style.display = "block"; const below = pointer.y + 44 < window.innerHeight; els.dragLabel.style.left = `${Math.max(8, Math.min(pointer.x + 14, window.innerWidth - 372))}px`; els.dragLabel.style.top = `${below ? pointer.y + 18 : pointer.y - 34}px`; } function place(box, el, pad = 2) { if (!el || !el.isConnected) { box.style.display = "none"; return; } const r = el.getBoundingClientRect(); if (!r.width && !r.height) { box.style.display = "none"; return; } box.style.display = "block"; box.style.left = `${r.left - pad}px`; box.style.top = `${r.top - pad}px`; box.style.width = `${r.width + pad * 2}px`; box.style.height = `${r.height + pad * 2}px`; } function showChip(el) { if (!el || !el.isConnected) { els.chips.style.display = "none"; return; } const r = el.getBoundingClientRect(); // A target in a collapsed tab has no box; don't strand the chips in a corner. if (!r.width && !r.height) { els.chips.style.display = "none"; return; } els.chips.style.display = "flex"; els.chips.style.left = `${Math.max(4, r.right - 11)}px`; els.chips.style.top = `${Math.max(4, r.top - 11)}px`; } function showGrip(el) { if (!el || !el.isConnected) { els.grip.style.display = "none"; return; } const r = el.getBoundingClientRect(); if (!r.width && !r.height) { els.grip.style.display = "none"; return; } els.grip.style.display = "block"; els.grip.style.left = `${r.right - 7}px`; els.grip.style.top = `${r.bottom - 7}px`; } function showHint(text, x, y) { if (!text) { els.hint.style.display = "none"; return; } els.hint.textContent = text; els.hint.style.display = "block"; els.hint.style.left = `${x + 12}px`; els.hint.style.top = `${y + 16}px`; } // ------------------------------------------------------------ text plumbing const isOurs = (node) => { const el = node && node.nodeType === 1 ? node : node && node.parentElement; return !!(el && el.closest && el.closest(`[${UI_ATTR}]`)); }; /** Flatten the body's text nodes into one string plus an offset map. */ function flatten() { const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, { acceptNode(node) { if (!node.nodeValue) return NodeFilter.FILTER_REJECT; const parent = node.parentElement; if (!parent) return NodeFilter.FILTER_REJECT; if (isOurs(parent)) return NodeFilter.FILTER_REJECT; if (/^(script|style|noscript|template)$/i.test(parent.tagName)) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; }, }); let text = ""; const map = []; let node = walker.nextNode(); while (node) { const start = text.length; text += node.nodeValue; map.push({ node, start, end: text.length }); node = walker.nextNode(); } return { text, map }; } function offsetsFromRange(map, range) { let start = null; let end = null; for (const entry of map) { if (entry.node === range.startContainer) start = entry.start + range.startOffset; if (entry.node === range.endContainer) end = entry.start + range.endOffset; } if (start === null || end === null || end <= start) return null; return { start, end }; } /** Wrap a global offset span in elements, one per text node touched. */ function wrapOffsets(map, start, end, id) { const marks = []; for (const entry of map) { if (entry.end <= start || entry.start >= end) continue; const from = Math.max(0, start - entry.start); const to = Math.min(entry.node.nodeValue.length, end - entry.start); if (to <= from) continue; const range = document.createRange(); try { range.setStart(entry.node, from); range.setEnd(entry.node, to); const mark = document.createElement("mark"); mark.setAttribute(MARK_ATTR, id); range.surroundContents(mark); marks.push(mark); } catch { // A node that shifted under us is skipped rather than corrupting the DOM. } } return marks; } function marksFor(id) { return [...document.querySelectorAll(`mark[${MARK_ATTR}="${CSS.escape(id)}"]`)]; } function unwrap(id) { for (const mark of marksFor(id)) { const parent = mark.parentNode; if (!parent) continue; while (mark.firstChild) parent.insertBefore(mark.firstChild, mark); parent.removeChild(mark); parent.normalize(); } } // ------------------------------------------------------- target resolution function isBlock(el) { if (!el || el.nodeType !== 1) return false; const display = getComputedStyle(el).display; return /^(block|flex|grid|list-item|table|flow-root)$/.test(display); } function hasOwnText(el) { return [...el.childNodes].some((n) => n.nodeType === 3 && n.nodeValue.trim()); } /** A body-anchored, fully indexed path, so it resolves to one element only. */ function cssPath(el) { const parts = []; let node = el; while (node && node.nodeType === 1 && node !== document.body && parts.length < 8) { if (node.id) { parts.unshift(`#${CSS.escape(node.id)}`); return parts.join(" > "); } const tag = node.tagName.toLowerCase(); const twins = node.parentElement ? [...node.parentElement.children].filter((c) => c.tagName === node.tagName) : []; parts.unshift(twins.length > 1 ? `${tag}:nth-of-type(${twins.indexOf(node) + 1})` : tag); node = node.parentElement; } return ["body", ...parts].join(" > "); } /** The heading a block sits under, used to name edits in arbitrary HTML. */ function precedingHeading(el) { let node = el; while (node && node !== document.body) { let sib = node.previousElementSibling; while (sib) { if (/^h[1-6]$/i.test(sib.tagName) && sib.textContent.trim()) return sib.textContent.trim(); const nested = sib.querySelectorAll ? sib.querySelectorAll("h1,h2,h3,h4,h5,h6") : []; for (let i = nested.length - 1; i >= 0; i -= 1) { if (nested[i].textContent.trim()) return nested[i].textContent.trim(); } sib = sib.previousElementSibling; } node = node.parentElement; } return ""; } const clip = (text, limit = 40) => { const flat = String(text || "").replace(/\s+/g, " ").trim(); return flat.length > limit ? `${flat.slice(0, limit - 1).trimEnd()}…` : flat; }; /** * A block's label is pinned the first time it is computed. Labels derived * from the block's own text or position would drift as the user types or * deletes siblings, splitting one block's edit into several contradictory * rows keyed on each intermediate wording. */ const pinnedLabels = new WeakMap(); /** * Sibling order as the page loaded. Ordinals in labels ("p 3") come from * here, so deleting a paragraph does not renumber the ones after it and hand * two different blocks the same label within one review. */ const bootChildren = new WeakMap(); function snapshotOrder() { for (const el of [document.body, ...document.body.querySelectorAll("*")]) { if (el.children.length > 1 && !isOurs(el)) bootChildren.set(el, [...el.children]); } } /** The block a hover/edit belongs to, plus a human label for the edit list. */ function targetFor(node) { const el = node && node.nodeType === 1 ? node : node && node.parentElement; if (!el || isOurs(el)) return null; const authored = el.closest("[data-container],[data-block]"); if (authored) { const label = authored.getAttribute("data-container") || authored.getAttribute("data-block"); return { el: authored, label: clip(label), authored: true }; } let block = el; while (block && block !== document.body && !isBlock(block)) block = block.parentElement; if (!block || block === document.body || !block.textContent.trim()) { if (el !== document.body && MEDIA.test(el.tagName)) block = el; else return null; } if (!pinnedLabels.has(block)) { // A heading names itself; the heading before it would mislabel the edit. const heading = /^h[1-6]$/i.test(block.tagName) ? "" : precedingHeading(block); const tag = block.tagName.toLowerCase(); // Siblings of the same tag would otherwise share a label and collapse into // one edit row, so number them — by their order at load, not right now. const parent = block.parentElement; const live = parent ? [...parent.children] : []; const booted = parent ? bootChildren.get(parent) : null; const siblings = booted && booted.includes(block) ? booted : live; const twins = siblings.filter((c) => c.tagName === block.tagName); const ordinal = twins.length > 1 ? ` ${twins.indexOf(block) + 1}` : ""; pinnedLabels.set(block, heading ? `${clip(heading, 26)} · ${tag}${ordinal}` : clip(block.textContent, 40) || tag); } return { el: block, label: pinnedLabels.get(block), authored: false }; } /** * The nearest rendered block around a node, ignoring authored data-block * containers. List conversion and drop targeting need the line the caret is * actually on, not the labeled region it reports edits under. */ function innermostBlock(node) { let el = node && node.nodeType === 1 ? node : node && node.parentElement; while (el && el !== document.body && !isBlock(el)) el = el.parentElement; return el && el !== document.body ? el : null; } /** * After a list command: hoist the fresh list out of the paragraph Chrome * sometimes nests it in (invalid HTML that a reparse would restructure), and * make sure the page's CSS reset can't hide it. */ function polishNewList(sel) { const node = sel && sel.anchorNode; const el = node && (node.nodeType === 1 ? node : node.parentElement); const item = el && el.closest ? el.closest("li") : null; const list = item ? item.closest("ul, ol") : null; if (!list) return; const wrap = list.parentElement; if (wrap && /^(p|h[1-6])$/i.test(wrap.tagName) && wrap.childNodes.length === 1) { wrap.replaceWith(list); } const patch = listStyleFixup(list.tagName, getComputedStyle(list)); if (patch.listStyleType) list.style.listStyleType = patch.listStyleType; if (patch.paddingLeft) list.style.paddingLeft = patch.paddingLeft; } /** The caret position a drag at (x, y) would drop into. */ function caretRangeAt(x, y) { if (document.caretRangeFromPoint) return document.caretRangeFromPoint(x, y); if (document.caretPositionFromPoint) { const pos = document.caretPositionFromPoint(x, y); if (!pos) return null; const range = document.createRange(); range.setStart(pos.offsetNode, pos.offset); return range; } return null; } // ------------------------------------------------------------ serialization const serialize = () => serializeDocument(document); /** * A single block's HTML with every review artifact removed. outerHTML, not * innerHTML: changes to the block's own attributes (an image's new width, for * one) live on the element itself, and a void element has no inner markup. */ function blockHtml(el) { const clone = el.cloneNode(true); clone.querySelectorAll(`[${UI_ATTR}]`).forEach((n) => n.remove()); clone.querySelectorAll(`mark[${MARK_ATTR}]`).forEach((mark) => { const parent = mark.parentNode; if (!parent) return; while (mark.firstChild) parent.insertBefore(mark.firstChild, mark); parent.removeChild(mark); }); clone.querySelectorAll("[data-eh-el]").forEach((n) => n.removeAttribute("data-eh-el")); clone.removeAttribute("data-eh-el"); clone.normalize(); return clone.outerHTML; } /** * Serialization is lossy about formatting, so a save that would not change the * document is skipped outright. Opening a file must never rewrite it. */ let baseline = null; /** What the document looked like at boot, before any user edit. */ let bootSnapshot = null; /** * Compare the live document against the file on disk (parsed the same way, * without running its scripts). A mismatch means the page renders itself — * writing the live DOM back would bake that output into the file, so saves * are disabled and edits travel to the agent as feedback only. */ function checkDynamic(diskHtml) { try { const parsed = new DOMParser().parseFromString(diskHtml, "text/html"); if (serializeDocument(parsed) !== bootSnapshot) markDynamic(); } catch { // If the comparison itself fails, keep saving as normal. } } function markDynamic() { if (dynamic) return; dynamic = true; clearTimeout(saveTimer); post("eh:dynamic", {}); } /** True once the user has actually edited, as opposed to the page's own scripts. */ let userEdited = false; /** * The boot comparison only catches scripts that rewrote the DOM before this * module ran. Pages that render on load or after a fetch (charts, mermaid, * client-rendered apps) mutate later — watch for any serialization drift that * happens before the first real user edit and treat it the same way. */ function watchSelfRendering() { const observer = new MutationObserver(() => { if (dynamic || userEdited) { observer.disconnect(); return; } if (serialize() !== bootSnapshot) { observer.disconnect(); markDynamic(); } }); observer.observe(document.documentElement, { childList: true, subtree: true, characterData: true, attributes: true }); } function emitSave() { if (dynamic) { post("eh:dynamic", {}); return; } const html = serialize(); if (html === baseline) { post("eh:clean", {}); return; } baseline = html; post("eh:html", { html }); } function scheduleSave() { clearTimeout(saveTimer); if (!dynamic) post("eh:saving", {}); saveTimer = setTimeout(emitSave, SAVE_DEBOUNCE_MS); } function flushSave() { clearTimeout(saveTimer); flushEdits(); emitSave(); } // ------------------------------------------------------------- edit tracking /** * One POST per keystroke would hammer the server (each save rewrites the whole * state file), so edits queue up and flush together. The store dedupes on * label+kind and only the latest `after` matters, so coalescing loses nothing. */ const editQueue = new Map(); let editTimer = null; function flushEdits() { clearTimeout(editTimer); editTimer = null; for (const payload of editQueue.values()) post("eh:edit", payload); editQueue.clear(); } function queueEdit(payload) { const key = `${payload.label}\u0000${payload.kind}`; const queued = editQueue.get(key); if (queued && (queued.staged_assets || payload.staged_assets)) { const assets = [...(queued.staged_assets || []), ...(payload.staged_assets || [])]; payload.staged_assets = [...new Map(assets.map((asset) => [asset.path || asset.id, asset])).values()]; } editQueue.set(key, payload); clearTimeout(editTimer); editTimer = setTimeout(flushEdits, EDIT_FLUSH_MS); } // ------------------------------------------------------------- interactions function clearPending() { pending = null; place(els.activeBox, null); } /** * A settled selection opens the compose card but is otherwise left completely * alone: still selected, still editable. Nothing is written into the document * until the comment is actually committed, so typing or Backspace behaves * exactly as it would in any editable page. */ function settleSelection() { const sel = document.getSelection(); if (!sel || sel.isCollapsed || !sel.rangeCount) return false; const range = sel.getRangeAt(0); if (!document.body.contains(range.commonAncestorContainer)) return false; if (isOurs(range.commonAncestorContainer)) return false; const quote = sel.toString(); if (!quote.trim()) return false; const { text, map } = flatten(); const offsets = offsetsFromRange(map, range); if (!offsets) return false; const context = buildContext(text, offsets.start, offsets.end); pending = { kind: "selection", context }; post("eh:compose", { kind: "selection", quote, anchor: { ...context, selector: cssPath(range.commonAncestorContainer.parentElement || document.body) }, }); return true; } function openElementCompose(container) { if (pending && pending.element === container.el) return; pending = { kind: "element", element: container.el }; place(els.activeBox, container.el); post("eh:compose", { kind: "element", quote: container.label, anchor: { selector: cssPath(container.el), label: container.label }, }); } /** Commit turns the remembered range into real wrappers. */ function commitPending(id) { if (!pending) return; if (pending.kind === "element") { if (pending.element && pending.element.isConnected) pending.element.setAttribute("data-eh-el", id); } else { const { text, map } = flatten(); const hit = findQuote(text, pending.context); if (hit) wrapOffsets(map, hit.start, hit.end, id); } const sel = document.getSelection(); if (sel) sel.removeAllRanges(); clearPending(); } function reanchor(comments) { const resolved = []; const orphaned = []; for (const comment of comments) { if (marksFor(comment.id).length) { resolved.push(comment.id); continue; } if (comment.kind === "element") { const el = comment.anchor && comment.anchor.selector ? document.querySelector(comment.anchor.selector) : null; // Re-stamp the marker, or "Jump to" has nothing to find after a reload. if (el) el.setAttribute("data-eh-el", comment.id); (el ? resolved : orphaned).push(comment.id); continue; } const { text, map } = flatten(); const hit = comment.anchor ? findQuote(text, comment.anchor) : null; if (!hit) { orphaned.push(comment.id); continue; } const marks = wrapOffsets(map, hit.start, hit.end, comment.id); (marks.length ? resolved : orphaned).push(comment.id); } post("eh:anchorStatus", { resolved, orphaned }); } function activate(id, scroll) { const marks = marksFor(id); let target = marks[0] || null; if (!target) { const el = document.querySelector(`[data-eh-el="${CSS.escape(id)}"]`); if (el) target = el; } for (const mark of document.querySelectorAll(`mark[${MARK_ATTR}]`)) mark.classList.remove("eh-active"); for (const mark of marks) mark.classList.add("eh-active"); place(els.activeBox, target); if (target && scroll) { // Reveal a target hidden inside a collapsed tab or accordion. let hidden = target.closest("[hidden]") || null; if (!hidden) { let probe = target.parentElement; while (probe && probe !== document.body) { if (getComputedStyle(probe).display === "none") { hidden = probe; break; } probe = probe.parentElement; } } if (hidden) { const control = hidden.id ? document.querySelector(`[aria-controls="${CSS.escape(hidden.id)}"]`) : null; if (control) control.click(); } const rect = target.getBoundingClientRect(); if (!rect.width && !rect.height) { post("eh:notInView", { id }); return; } // scrollIntoView walks every scrollable ancestor, so targets inside an // app's inner scroll container are reached too, not just window-scrolled ones. target.scrollIntoView({ block: "center", behavior: "smooth" }); } } // -------------------------------------------------------------------- boot function boot() { mountOverlay(); const style = document.createElement("style"); style.setAttribute("data-eh-sdk", ""); style.textContent = ` mark[${MARK_ATTR}] { background: rgba(245,196,0,.32); border-radius: 2px; color: inherit; cursor: pointer; } mark[${MARK_ATTR}]:hover { background: rgba(243,176,0,.5); } mark[${MARK_ATTR}].eh-active { background: rgba(255,180,0,.55); } body[contenteditable]:focus { outline: none; } ::selection { background: rgba(245,196,0,.42); } `; document.head.appendChild(style); // React/Next.js hydration may remove an attribute that was added after the // server rendered the page. Keep edit mode on after hydration settles so a // real app stays directly editable, not just its initial HTML response. keepBodyEditable(document.body); document.body.spellcheck = false; snapshotOrder(); baseline = serialize(); bootSnapshot = baseline; watchSelfRendering(); document.addEventListener("mouseup", (event) => { if (isOurs(event.target) || resizing || Date.now() < suppressUntil) return; setTimeout(() => { if (settleSelection()) return; const target = targetFor(event.target); if (target) { openElementCompose(target); return; } post("eh:dismiss", {}); }, 0); }); // A selection made with the keyboard (Shift+arrows, Shift+Home/End) never // produces a mouseup, so settle it when the key is released instead. The // wait lets a selection being extended settle once, not on every keystroke. let keySelectTimer = null; document.addEventListener( "keyup", (event) => { if (isOurs(event.target) || resizing || moving || linkState) return; const extending = event.shiftKey && /^(Arrow(Left|Right|Up|Down)|Home|End|PageUp|PageDown)$/.test(event.key); const selectAll = (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "a"; if (event.key !== "Shift" && !extending && !selectAll) return; clearTimeout(keySelectTimer); keySelectTimer = setTimeout(() => { if (Date.now() < suppressUntil || composeOpen) return; settleSelection(); }, 250); }, true ); document.addEventListener( "click", (event) => { if (isOurs(event.target)) return; const modified = event.metaKey || event.ctrlKey; const href = navigationHref(event.target); if (modified) { if (href) { event.preventDefault(); event.stopPropagation(); if (/^(https?:)?\/\//i.test(href) || /^mailto:/i.test(href)) post("eh:external", { href: new URL(href, document.baseURI).href }); else if (href.startsWith("#")) { const action = hashClickAction(href, location.hash); if (action.kind === "scroll") { const el = document.getElementById(action.id) || document.getElementsByName(action.id)[0] || document.body; el.scrollIntoView({ behavior: "smooth" }); } else { location.hash = action.hash; } } else { // This document is about to be torn down: anything still sitting in // the debounce windows must ship first or it is lost. Message order // is guaranteed, so the edits land before the navigation does. flushSave(); post("eh:navigate", { href }); } } return; // let the artifact's own handlers run } // Plain clicks belong to editing: never navigate, never fire artifact JS. // A , tab, or other in-page toggle keeps working: collapsed // content must stay reachable, and its text stays editable either way. const button = event.target.closest && event.target.closest("button, [role='button']"); if (href || (button && !inPageControl(event.target))) { event.preventDefault(); event.stopPropagation(); } const mark = event.target.closest && event.target.closest(`mark[${MARK_ATTR}]`); if (mark) post("eh:activate", { id: mark.getAttribute(MARK_ATTR) }); }, true ); document.addEventListener("submit", (event) => { if (!event.metaKey && !event.ctrlKey) event.preventDefault(); }, true); document.addEventListener("mouseover", (event) => { if (isOurs(event.target) || resizing || moving) return; const target = targetFor(event.target); hoverTarget = target ? target.el : null; // The move handle works per element, not per labeled container, so each // paragraph inside a card can travel on its own. hoverMove = hoverTarget ? innermostBlock(event.target) || hoverTarget : null; hoverMedia = event.target.closest ? event.target.closest("img, video") : null; place(els.outline, hoverTarget); if (controlsAreSuppressed()) hideActionControls(); else { showChip(hoverTarget); showMover(hoverMove); } showGrip(hoverMedia); const interactive = event.target.closest && event.target.closest("a[href], [data-href], button, [role='button']") && (navigationHref(event.target) || !inPageControl(event.target)); const draggable = hoverMedia && hoverMedia.tagName === "IMG"; showHint(interactive ? "⌘-click to open" : draggable ? "Drag to move" : "", event.clientX, event.clientY); }); document.addEventListener("mouseleave", () => { if (resizing || moving) return; hoverTarget = null; hoverMove = null; hoverMedia = null; place(els.outline, null); showChip(null); showMover(null); showGrip(null); showHint(""); }); // ------------------------------------------------------------ image resize els.grip.addEventListener("pointerdown", (event) => { if (!hoverMedia || !hoverMedia.isConnected) return; event.preventDefault(); event.stopPropagation(); // From here on, DOM changes are the human resizing, not the page rendering. userEdited = true; const target = targetFor(hoverMedia); const blockEl = target ? target.el : hoverMedia; resizing = { el: hoverMedia, startX: event.clientX, startWidth: hoverMedia.getBoundingClientRect().width, label: target ? target.label : "Image", blockEl, beforeText: blockEl.textContent, beforeHtml: blockHtml(blockEl), }; if (els.grip.setPointerCapture) els.grip.setPointerCapture(event.pointerId); }); window.addEventListener("pointermove", (event) => { if (!resizing) return; event.preventDefault(); const width = Math.max(24, Math.round(resizing.startWidth + (event.clientX - resizing.startX))); resizing.el.style.width = `${width}px`; resizing.el.style.height = "auto"; place(els.outline, hoverTarget); showGrip(resizing.el); }); window.addEventListener("pointerup", () => { if (!resizing) return; const { blockEl, label, beforeText, beforeHtml } = resizing; resizing = null; suppressUntil = Date.now() + 250; queueEdit({ label, kind: "edited", before: beforeText, after: blockEl.textContent, before_html: beforeHtml, after_html: blockHtml(blockEl), }); scheduleSave(); }); /** * Delete and move are the two gestures with no native undo, so the last * one is kept restorable: the element itself, detached, plus where it was. */ /** * Every delete and move stays restorable for the life of the page: the * element itself, detached or relocated, plus where it came from. The * chrome's per-row Undo and ⌘Z both come here. */ const undoStack = []; // [{ kind, el, parent, next, label, at }] const remember = (entry) => { undoStack.push({ ...entry, at: Date.now() }); if (undoStack.length > 50) undoStack.shift(); }; /** Timestamp of the last keystroke, so ⌘Z after typing stays the browser's own undo. */ let lastInputAt = 0; els.chipDelete.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); if (!hoverTarget) return; userEdited = true; const target = targetFor(hoverTarget); const label = target ? target.label : "Element"; const before = hoverTarget.textContent; remember({ kind: "deleted", el: hoverTarget, parent: hoverTarget.parentNode, prev: hoverTarget.previousSibling, next: hoverTarget.nextSibling, label }); hoverTarget.remove(); hoverTarget = null; place(els.outline, null); showChip(null); queueEdit({ label, kind: "deleted", before, after: "" }); flushSave(); post("eh:undoable", { label, kind: "deleted" }); }); const restoreBlock = (label, kind) => { let index = -1; for (let i = undoStack.length - 1; i >= 0; i -= 1) { if (undoStack[i].label === label && undoStack[i].kind === kind) { index = i; break; } } const undo = index === -1 ? null : undoStack[index]; if (!undo || !undo.parent || !undo.parent.isConnected) { // Nothing to put back: the page reloaded since, or the spot is gone. post("eh:undoFailed", { label, kind }); return false; } undoStack.splice(index, 1); // Either neighbor may have been deleted or moved since; use whichever is // still in place, and only fall back to the end when both are gone. const next = undo.next && undo.next.parentNode === undo.parent ? undo.next : null; const prev = undo.prev && undo.prev.parentNode === undo.parent ? undo.prev : null; undo.parent.insertBefore(undo.el, next || (prev ? prev.nextSibling : null)); undo.el.style.opacity = ""; // The chrome drops the row on eh:undone; the block is back to its captured // original, so there is nothing new to report — only the file to write again. flushSave(); post("eh:undone", { label, kind }); return true; }; // beforeinput still sees the untouched wording, so capture it once per block. const originalText = new WeakMap(); const originalHtml = new WeakMap(); const captureOriginal = (el) => { if (!originalText.has(el)) { originalText.set(el, el.textContent); originalHtml.set(el, blockHtml(el)); } }; /** * A list command replaces the paragraph with fresh
  • /
      elements. They * inherit its label and captured original, so the edit row still reads * "this paragraph became a list" instead of a row for an unknown block with * no `before`. */ const adoptIdentity = (source, created) => { if (!source || !created || created === source.el) return; // The input event fired inside execCommand, before this ran, and the // fresh element named itself ("li 3", no `before`). Retract that row. const stale = pinnedLabels.get(created); if (stale && stale !== source.label) editQueue.delete(`${stale}\u0000edited`); pinnedLabels.set(created, source.label); if (!originalText.has(created) && originalText.has(source.el)) { originalText.set(created, originalText.get(source.el)); originalHtml.set(created, originalHtml.get(source.el)); } }; const afterListCommand = (source) => { const sel = document.getSelection(); polishNewList(sel); if (!source || source.authored) return; const node = sel && sel.anchorNode; const el = node && (node.nodeType === 1 ? node : node.parentElement); const item = el && el.closest ? el.closest("li") : null; const list = item ? item.closest("ul, ol") : null; adoptIdentity(source, item); adoptIdentity(source, list); if (item) emitBlockEdit(item, source.label); }; /** * Every block a selection touches. Typing over a selection that runs from * one paragraph into the next changes both — the second may vanish * entirely — and each needs its own row. */ const blocksInRange = (range) => { const found = []; const add = (node) => { const target = targetFor(node); if (target && !found.some((t) => t.el === target.el)) found.push(target); }; add(range.startContainer); add(range.endContainer); if (range.collapsed) return found; const root = range.commonAncestorContainer; const scope = root.nodeType === 1 ? root : root.parentElement; if (!scope) return found; const walker = document.createTreeWalker(scope, NodeFilter.SHOW_ELEMENT, { acceptNode: (el) => (isOurs(el) || !range.intersectsNode(el) ? NodeFilter.FILTER_REJECT : isBlock(el) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP), }); let el = walker.nextNode(); let budget = 400; while (el && budget > 0) { add(el); el = walker.nextNode(); budget -= 1; } return found; }; let affected = null; // targets of a multi-block selection, until the input event reports them /** An edit row for a block changed outside the input-event flow (attribute * set, link removal, drag move) — the same shape the input listener emits. */ const emitBlockEdit = (blockEl, fallbackLabel, extra = {}) => { const connected = blockEl.isConnected; const target = connected ? targetFor(blockEl) : null; queueEdit({ label: (target && target.label) || fallbackLabel || "Document body", kind: "edited", before: originalText.get(blockEl), after: connected ? blockEl.textContent : "", before_html: originalHtml.get(blockEl), after_html: connected ? blockHtml(blockEl) : "", ...extra, }); }; let typingUntil = 0; let controlRestoreTimer = null; const selectionIsActive = () => { const sel = document.getSelection(); return !!(sel && !sel.isCollapsed && sel.rangeCount && document.body.contains(sel.getRangeAt(0).commonAncestorContainer)); }; const controlsAreSuppressed = () => Date.now() < typingUntil || selectionIsActive(); const hideActionControls = () => { showChip(null); showMover(null); }; const restoreActionControls = () => { if (controlsAreSuppressed()) return; showChip(hoverTarget); showMover(hoverMove); }; const suppressActionControlsWhileTyping = () => { typingUntil = Date.now() + 800; hideActionControls(); clearTimeout(controlRestoreTimer); controlRestoreTimer = setTimeout(restoreActionControls, 810); }; document.addEventListener( "beforeinput", (event) => { if (isOurs(event.target)) return; suppressActionControlsWhileTyping(); // From here on, DOM drift is the human typing, not the page rendering. userEdited = true; const sel = document.getSelection(); const range = sel && sel.rangeCount ? sel.getRangeAt(0) : null; const blocks = range ? blocksInRange(range) : []; const target = blocks[0] || targetFor(event.target); if (target) captureOriginal(target.el); for (const block of blocks) captureOriginal(block.el); affected = blocks.length > 1 ? blocks : null; }, true ); document.addEventListener("selectionchange", () => { if (selectionIsActive()) hideActionControls(); else restoreActionControls(); }); // ------------------------------------------------------------------ links let linkState = null; // { range, anchor, blockEl, label } while the ⌘K popup is open function closeLinkbox(restoreSelection) { if (!linkState) return; const { range } = linkState; linkState = null; els.linkbox.style.display = "none"; if (restoreSelection && range) { const sel = document.getSelection(); sel.removeAllRanges(); sel.addRange(range); document.body.focus({ preventScroll: true }); } } function openLinkbox() { const sel = document.getSelection(); if (!sel || !sel.rangeCount) return false; let range = sel.getRangeAt(0).cloneRange(); const container = range.commonAncestorContainer; if (!document.body.contains(container) || isOurs(container)) return false; const caretEl = container.nodeType === 1 ? container : container.parentElement; const anchor = caretEl && caretEl.closest ? caretEl.closest("a") : null; if (sel.isCollapsed) { // A bare caret can only mean "edit the link I'm inside". if (!anchor) return false; range = document.createRange(); range.selectNodeContents(anchor); } const target = targetFor(anchor || container); linkState = { range, anchor, blockEl: target ? target.el : null, label: target ? target.label : "Document body", }; if (linkState.blockEl) captureOriginal(linkState.blockEl); // The compose card and the link popup fight over the same selection. clearPending(); post("eh:dismiss", {}); const r = range.getBoundingClientRect(); const below = r.bottom + 44 < window.innerHeight; els.linkbox.style.display = "flex"; els.linkbox.style.left = `${Math.max(8, Math.min(r.left, window.innerWidth - 320))}px`; els.linkbox.style.top = `${below ? r.bottom + 8 : Math.max(8, r.top - 44)}px`; els.linkRemove.style.display = anchor ? "" : "none"; els.linkInput.value = anchor ? anchor.getAttribute("href") || "" : ""; els.linkInput.focus(); els.linkInput.select(); return true; } function applyLink() { if (!linkState) return; const href = normalizeHref(els.linkInput.value); const { range, anchor, blockEl, label } = linkState; if (!href) { // Nothing typed → dismiss. Something unusable typed → stay open so the // input isn't silently thrown away. if (!els.linkInput.value.trim()) closeLinkbox(true); else els.linkInput.select(); return; } userEdited = true; linkState = null; els.linkbox.style.display = "none"; if (anchor && anchor.isConnected) { // Editing an existing link never rewraps — just retarget it. anchor.setAttribute("href", href); if (blockEl) emitBlockEdit(blockEl, label); } else { const sel = document.getSelection(); sel.removeAllRanges(); sel.addRange(range); document.body.focus({ preventScroll: true }); document.execCommand("createLink", false, href); // On pages that reset anchor styling the new link looks like plain // prose — underline it so the user sees that it took. const node = sel.anchorNode; const el = node && (node.nodeType === 1 ? node : node.parentElement); const created = el && el.closest ? el.closest("a") : null; if (created) { const patch = linkStyleFixup( getComputedStyle(created), created.parentElement ? getComputedStyle(created.parentElement) : null ); if (patch.textDecoration) created.style.textDecoration = patch.textDecoration; } } scheduleSave(); } function removeLink() { if (!linkState) return; const { anchor, blockEl, label } = linkState; linkState = null; els.linkbox.style.display = "none"; if (!anchor || !anchor.isConnected) return; userEdited = true; const parent = anchor.parentNode; while (anchor.firstChild) parent.insertBefore(anchor.firstChild, anchor); parent.removeChild(anchor); parent.normalize(); if (blockEl) emitBlockEdit(blockEl, label); scheduleSave(); } els.linkInput.addEventListener("keydown", (event) => { event.stopPropagation(); if (event.key === "Enter") { if (isImeCommitEnter(event)) return; event.preventDefault(); applyLink(); } else if (event.key === "Escape") { event.preventDefault(); closeLinkbox(true); } }); els.linkApply.addEventListener("click", applyLink); els.linkRemove.addEventListener("click", removeLink); // A click anywhere in the page means the popup lost the argument. document.addEventListener("pointerdown", (event) => { if (linkState && !isOurs(event.target)) closeLinkbox(false); }); // ------------------------------------------------------------------ lists // execCommand mutations fire `input` (so edit rows and saves flow as usual) // but not `beforeinput`, so originals are captured here by hand. document.addEventListener( "keydown", (event) => { if (isOurs(event.target)) return; const meta = event.metaKey || event.ctrlKey; const sel = document.getSelection(); const anchor = sel ? sel.anchorNode : null; // ⌘Z right after a block delete or move undoes that, not the last // keystroke — unless typing came after it, which stays the browser's undo. if (meta && !event.shiftKey && !event.altKey && (event.key === "z" || event.key === "Z")) { const last = undoStack[undoStack.length - 1]; if (last && last.at > lastInputAt) { event.preventDefault(); event.stopPropagation(); restoreBlock(last.label, last.kind); } return; } // ⌘K links the selection, like Docs, Word, and Notion. if (meta && !event.shiftKey && !event.altKey && (event.key === "k" || event.key === "K")) { if (openLinkbox()) { event.preventDefault(); event.stopPropagation(); } return; } if (event.key === "Escape" && linkState) { closeLinkbox(true); return; } // ⌘⇧8 bulleted, ⌘⇧7 numbered — the shortcuts people know from Docs. if (meta && event.shiftKey && (event.code === "Digit7" || event.code === "Digit8")) { const target = targetFor(anchor || event.target); if (!target) return; event.preventDefault(); event.stopPropagation(); userEdited = true; captureOriginal(target.el); document.execCommand(event.code === "Digit7" ? "insertOrderedList" : "insertUnorderedList"); afterListCommand(target); scheduleSave(); return; } // Tab indents and Shift+Tab outdents inside a list, instead of leaving the page. if (event.key === "Tab" && anchor) { const el = anchor.nodeType === 1 ? anchor : anchor.parentElement; const item = el && el.closest ? el.closest("li") : null; if (!item || isOurs(item)) return; event.preventDefault(); event.stopPropagation(); userEdited = true; const target = targetFor(item); if (target) captureOriginal(target.el); document.execCommand(event.shiftKey ? "outdent" : "indent"); polishNewList(document.getSelection()); scheduleSave(); return; } // "- ", "* ", "1. ", or "1) " at the start of a line becomes a list. // Only the text between the block's start and the caret matters, so this // works inside authored data-block containers and in front of existing // text — not just in blocks that hold nothing but the marker. if (event.key === " " && sel && sel.isCollapsed && anchor) { const host = anchor.nodeType === 1 ? anchor : anchor.parentElement; if (!host || host.closest("li, ul, ol")) return; const block = innermostBlock(anchor); // isBlock matches display:table but not table-cell, so a caret in a // table's first cell resolves the whole table — never convert that. if (block && /^(table|thead|tbody|tfoot|tr)$/i.test(block.tagName)) return; const lead = document.createRange(); // Inline content directly under (a bare text node beside an // image, say) has no block; the caret's own node is the line then. if (block) lead.selectNodeContents(block); else lead.setStart(anchor, 0); try { lead.setEnd(sel.anchorNode, sel.anchorOffset); } catch { return; } const command = listCommandFor(lead.toString()); if (!command) return; const target = targetFor(anchor); event.preventDefault(); event.stopPropagation(); userEdited = true; if (target) captureOriginal(target.el); sel.removeAllRanges(); sel.addRange(lead); document.execCommand("delete"); document.execCommand(command); afterListCommand(target); scheduleSave(); } }, true ); // ------------------------------------------------------------- image drag // Chromium moves an image dropped inside contenteditable on its own; this // code only aims the drop (indicator outline), blocks payloads that have // nowhere to go (files from the desktop), and writes the edit rows for the // block the image left and the block it landed in. let dragging = null; // { el, fromBlock, fromLabel, overBlock, dropped } during an image drag document.addEventListener("dragstart", (event) => { const img = event.target && event.target.nodeType === 1 && event.target.tagName === "IMG" ? event.target : null; if (!img || isOurs(img)) return; const target = targetFor(img); if (target) captureOriginal(target.el); dragging = { el: img, src: img.src, // The rendered size often comes from the container being left behind // (figure/card CSS); remember it so the move can't balloon the image. width: img.getBoundingClientRect().width, fromBlock: target ? target.el : null, fromLabel: target ? target.label : "Image", overBlock: null, dropped: false, }; }); document.addEventListener("dragover", (event) => { if (!dragging) { // External payloads would navigate the frame or paste file:// markup. if (event.dataTransfer && [...(event.dataTransfer.types || [])].includes("Files")) { event.preventDefault(); event.dataTransfer.dropEffect = "none"; } return; } const range = caretRangeAt(event.clientX, event.clientY); const block = range ? innermostBlock(range.startContainer) : null; if (block && !isOurs(block)) { captureOriginal(block); dragging.overBlock = block; } place(els.outline, block && !isOurs(block) ? block : null); }); const finalizeImageDrag = () => { const drag = dragging; if (!drag || !drag.dropped) return; dragging = null; place(els.outline, null); // Chrome re-inserts a copy on drop, so find the landed image by source, // then pin it to the size it had before the move — an image dragged out // of the container that was constraining it would otherwise render at // its natural size. let landed = drag.el.isConnected ? drag.el : null; if (!landed && drag.overBlock && drag.overBlock.isConnected) { landed = [...drag.overBlock.querySelectorAll("img")].find((i) => i.src === drag.src) || null; } if (landed && drag.width && Math.abs(landed.getBoundingClientRect().width - drag.width) > 1) { landed.style.width = `${Math.round(drag.width)}px`; landed.style.height = "auto"; landed.style.maxWidth = "100%"; } const rows = new Map(); if (drag.fromBlock) rows.set(drag.fromBlock, drag.fromLabel); if (drag.overBlock && !rows.has(drag.overBlock)) rows.set(drag.overBlock, null); for (const [block, label] of rows) emitBlockEdit(block, label); scheduleSave(); }; document.addEventListener("drop", (event) => { if (dragging) { // The move itself is the drop's default action, so the edit rows can // only be read after it has run. userEdited = true; dragging.dropped = true; setTimeout(finalizeImageDrag, 0); return; } if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length) event.preventDefault(); }); document.addEventListener("dragend", () => { // A cancelled drag (Esc, or dropped outside the page) still needs cleanup. if (dragging && !dragging.dropped) { dragging = null; place(els.outline, null); } }); // ------------------------------------------------------------- block moves // The handle on a block's left edge moves the whole block. Pointer-based, // not HTML5 drag: the element is relocated on drop, never cloned, so // identity (labels, captured originals, comment anchors) survives the move. let moving = null; // { el, label, drop: { ref, before } | null } while the handle is held /** Within this many pixels of a container's outer edge, the drop targets the container. */ const CONTAINER_EDGE = 10; const dropPointFor = (x, y) => { const under = document.elementFromPoint(x, y); if (!under || isOurs(under)) return null; const block = innermostBlock(under); if (!block || block === moving.el || moving.el.contains(block)) return null; // Near the top or bottom edge of a card or section, the whole container // is the target, so a block can be dropped past it rather than only // beside one of its children. The outermost qualifying ancestor wins. let chosen = block; let ancestor = block.parentElement; while (ancestor && ancestor !== document.body && !isOurs(ancestor)) { if (ancestor !== moving.el && !moving.el.contains(ancestor) && isBlock(ancestor)) { const box = ancestor.getBoundingClientRect(); if (y - box.top <= CONTAINER_EDGE || box.bottom - y <= CONTAINER_EDGE) chosen = ancestor; } ancestor = ancestor.parentElement; } const r = chosen.getBoundingClientRect(); return { ref: chosen, before: y < r.top + r.height / 2 }; }; els.mover.addEventListener("pointerdown", (event) => { const el = hoverMove && hoverMove.isConnected ? hoverMove : hoverTarget; if (!el || !el.isConnected) return; event.preventDefault(); event.stopPropagation(); const target = targetFor(el); moving = { el, label: target ? target.label : "Block", drop: null, pointer: { x: event.clientX, y: event.clientY } }; captureOriginal(moving.el); // Dimming the block is our doing, not the page rendering itself: say so // before touching it, or a move as the first action reads as self-render // and switches saves off for the rest of the review. userEdited = true; moving.el.style.opacity = "0.4"; place(els.outline, null); showChip(null); requestAnimationFrame(autoScroll); try { els.mover.setPointerCapture(event.pointerId); } catch { // Window-level listeners track the move even without capture. } }); /** * Near the top or bottom edge the page scrolls on its own, so a block can * travel further than one screen. Runs per frame while the handle is held. */ const EDGE = 56; const autoScroll = () => { if (!moving) return; const { x, y } = moving.pointer; let step = 0; if (y < EDGE) step = -Math.ceil((EDGE - y) / 4); else if (y > window.innerHeight - EDGE) step = Math.ceil((y - (window.innerHeight - EDGE)) / 4); if (step) { window.scrollBy(0, step); const next = dropPointFor(x, y); if (next) moving.drop = next; showDropline(moving.drop, moving.pointer, moving.label); } requestAnimationFrame(autoScroll); }; window.addEventListener("pointermove", (event) => { if (!moving) return; event.preventDefault(); moving.pointer = { x: event.clientX, y: event.clientY }; // Over a margin or gutter there is no block under the pointer; keep the // last real target rather than blinking the indicator off and on. const next = dropPointFor(event.clientX, event.clientY); if (next) moving.drop = next; showDropline(moving.drop, moving.pointer, moving.label); }); window.addEventListener("pointerup", () => { if (!moving) return; const { el, label, drop } = moving; moving = null; showDropline(null); showMover(null); el.style.opacity = ""; suppressUntil = Date.now() + 250; if (!drop || !drop.ref.isConnected || !el.isConnected) return; // Dropping right back where it came from is a no-op, not an edit. const next = drop.before ? drop.ref : drop.ref.nextElementSibling; if (next === el || (drop.before ? drop.ref.previousElementSibling : drop.ref) === el) return; userEdited = true; remember({ kind: "moved", el, parent: el.parentNode, prev: el.previousSibling, next: el.nextSibling, label }); drop.ref.parentNode.insertBefore(el, next); const prev = el.previousElementSibling; const following = el.nextElementSibling; queueEdit({ label, kind: "moved", before: originalText.get(el), after: el.textContent, before_html: originalHtml.get(el), after_html: blockHtml(el), moved_after: prev ? clip(prev.textContent, 90) : "", moved_before: following ? clip(following.textContent, 90) : "", }); flushSave(); post("eh:undoable", { label, kind: "moved" }); }); // ------------------------------------------------------------- image paste // Pasted images cross to the chrome page as bytes; the server writes them // into assets/ next to the reviewed file and the confirmed relative path // comes back to be inserted where the caret was. let pasteSeq = 0; const pendingPastes = new Map(); // id → collapsed caret range to insert at document.addEventListener( "paste", (event) => { if (isOurs(event.target)) return; const items = event.clipboardData ? [...event.clipboardData.items] : []; const images = items.filter((item) => item.kind === "file" && /^image\//.test(item.type)); if (!images.length) return; event.preventDefault(); event.stopPropagation(); const sel = document.getSelection(); const caret = sel && sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null; for (const item of images) { const file = item.getAsFile(); if (!file) continue; pasteSeq += 1; const id = `paste_${pasteSeq}`; pendingPastes.set(id, caret); file.arrayBuffer().then((bytes) => post("eh:asset", { id, assetType: file.type, bytes })); } }, true ); const insertPastedImage = (id, src, stagedId) => { const caret = pendingPastes.get(id); pendingPastes.delete(id); userEdited = true; const img = document.createElement("img"); img.setAttribute("src", src); img.style.maxWidth = "100%"; const anchored = caret && caret.startContainer && document.body.contains(caret.startContainer) && !isOurs(caret.startContainer); if (anchored) { const target = targetFor(caret.startContainer); if (target) captureOriginal(target.el); caret.collapse(false); caret.insertNode(img); } else { document.body.appendChild(img); } const landed = targetFor(img); emitBlockEdit(landed ? landed.el : img, landed ? landed.label : "Pasted image", { ...(stagedId ? { staged_assets: [{ id: stagedId, preview_src: src }] } : {}), }); flushSave(); }; document.addEventListener("input", (event) => { if (isOurs(event.target)) return; lastInputAt = Date.now(); // A drop fires deleteByDrag/insertFromDrop input events whose selection // points anywhere; finalizeImageDrag writes the real rows instead. if (dragging) return; // Typing over a selection is an edit, not a comment: retire the card. if (pending) { clearPending(); post("eh:dismiss", {}); } if (affected) { // A selection spanning blocks: the first absorbed the rest, and any // block that is gone now is a deletion in its own right. const blocks = affected; affected = null; for (const block of blocks) { if (block.el.isConnected) emitBlockEdit(block.el, block.label); else queueEdit({ label: block.label, kind: "deleted", before: originalText.get(block.el), after: "", before_html: originalHtml.get(block.el), after_html: "" }); } scheduleSave(); return; } const sel = document.getSelection(); const node = sel && sel.anchorNode ? sel.anchorNode : event.target; const target = targetFor(node); // Text alone loses formatting-only edits (bold, italic, underline change // markup, not textContent), so the block's cleaned HTML travels too. queueEdit({ label: target ? target.label : "Document body", kind: "edited", before: target ? originalText.get(target.el) : undefined, after: target ? target.el.textContent : undefined, before_html: target ? originalHtml.get(target.el) : undefined, after_html: target ? blockHtml(target.el) : undefined, }); scheduleSave(); }); const reposition = () => { place(els.outline, hoverTarget); showChip(hoverTarget); showMover(moving ? null : hoverMove); showGrip(resizing ? resizing.el : hoverMedia); // The popup is viewport-fixed; scrolled away from its text it just lies. if (linkState) closeLinkbox(false); }; // getBoundingClientRect on every scroll event forces layout mid-scroll; // one reposition per frame is plenty. let repositionQueued = false; const scheduleReposition = () => { if (repositionQueued) return; repositionQueued = true; requestAnimationFrame(() => { repositionQueued = false; reposition(); }); }; window.addEventListener("scroll", scheduleReposition, true); window.addEventListener("resize", scheduleReposition); let scrollQueued = false; window.addEventListener( "scroll", () => { if (scrollQueued) return; scrollQueued = true; requestAnimationFrame(() => { scrollQueued = false; post("eh:scroll", { x: window.scrollX, y: window.scrollY }); }); }, { passive: true } ); window.addEventListener("message", (event) => { // Only the chrome page may drive the SDK — not popups the artifact opened, // and not the artifact's own scripts. if (event.source !== window.parent || event.origin !== CHROME_ORIGIN) return; const msg = event.data || {}; switch (msg.type) { case "eh:anchors": reanchor(msg.comments || []); break; case "eh:commit": commitPending(msg.id); composeOpen = false; break; case "eh:cancel": clearPending(); composeOpen = false; break; case "eh:composeOpen": composeOpen = true; break; case "eh:remove": unwrap(msg.id); document.querySelectorAll(`[data-eh-el="${CSS.escape(msg.id)}"]`).forEach((el) => el.removeAttribute("data-eh-el")); place(els.activeBox, null); break; case "eh:activate": activate(msg.id, !!msg.scroll); break; case "eh:flush": flushSave(); // Posted after the flushed eh:edit/eh:html messages, so when the // chrome sees it, everything pending has already been handed over. post("eh:flushed", {}); break; case "eh:abortSave": // A revert is in flight: anything queued would re-write the edits. clearTimeout(saveTimer); clearTimeout(editTimer); editQueue.clear(); break; case "eh:raw": checkDynamic(String(msg.html || "")); break; case "eh:feedbackOnly": // The chrome already knows saves must not happen (e.g. a Markdown // source rendered for review); no comparison needed. dynamic = true; break; case "eh:restoreScroll": window.scrollTo(msg.x || 0, msg.y || 0); break; case "eh:assetSaved": insertPastedImage(msg.id, String(msg.src || ""), String(msg.stagedId || "")); break; case "eh:assetFailed": pendingPastes.delete(msg.id); break; case "eh:undo": restoreBlock(String(msg.label || ""), String(msg.kind || "")); break; default: break; } }); post("eh:ready", { scrollHeight: document.body.scrollHeight }); } if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot); else boot();