@erdemtuna/doc-review 0.7.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/src/sdk.js ADDED
@@ -0,0 +1,1520 @@
1
+ /**
2
+ * doc-review artifact SDK.
3
+ *
4
+ * Runs inside the sandboxed iframe alongside the artifact. It owns the
5
+ * document: editing, highlights, target resolution and serialization. It never
6
+ * talks to the server — everything crosses to the chrome page by postMessage.
7
+ */
8
+ import { buildContext, findQuote } from "./anchor-text.js";
9
+ import { hashClickAction, navigationHref } from "./click-target.js";
10
+ import { classifyHref, externalHref, linkStyleFixup, listCommandFor, listStyleFixup, normalizeHref } from "./editing.js";
11
+ import { frameMessage, initializeChannelFromDocument, matchesFrameMessage } from "./frame-channel.js";
12
+ import { keepBodyEditable, serializeDocument, UI_ATTR, MARK_ATTR } from "./serialize.js";
13
+
14
+ initializeChannelFromDocument();
15
+
16
+ const SAVE_DEBOUNCE_MS = 700;
17
+ const EDIT_FLUSH_MS = 500;
18
+ const MEDIA = /^(img|svg|canvas|video|picture|iframe|hr|figure)$/i;
19
+
20
+ // The chrome page lives on the other loopback hostname (a separate origin, so
21
+ // the reviewed document can never touch it directly). Address it explicitly so
22
+ // nothing we post can be read by any other embedder.
23
+ const CHROME_ORIGIN = `${location.protocol}//${location.hostname === "127.0.0.1" ? "localhost" : "127.0.0.1"}:${location.port}`;
24
+
25
+ const post = (type, payload) => parent.postMessage(frameMessage(type, payload), CHROME_ORIGIN);
26
+
27
+ let pending = null; // { id, marks } for an uncommitted highlight
28
+ let hoverTarget = null;
29
+ let hoverMove = null; // the innermost block under the cursor, movable via the handle
30
+ let hoverMedia = null; // the img/video under the cursor, resizable via the grip
31
+ let resizing = null; // live drag state while the grip is held
32
+ let suppressUntil = 0; // ignore the mouseup/click that ends a resize drag
33
+ let saveTimer = null;
34
+ let composeOpen = false;
35
+ /** True when the page's own scripts rewrote the DOM before any user edit. */
36
+ let dynamic = false;
37
+
38
+ // ------------------------------------------------------------------ overlay
39
+
40
+ const host = document.createElement("div");
41
+ host.setAttribute(UI_ATTR, "");
42
+ const shadow = host.attachShadow({ mode: "open" });
43
+ shadow.innerHTML = `
44
+ <style>
45
+ :host { all: initial; }
46
+ .box { position: fixed; pointer-events: none; z-index: 2147483646; border-radius: 3px; display: none; }
47
+ .outline { border: 1px solid #c2beb4; }
48
+ .active { border: 1px solid #1b1a16; }
49
+ .chips {
50
+ position: fixed; z-index: 2147483647; display: none; gap: 4px;
51
+ pointer-events: auto;
52
+ }
53
+ .chip {
54
+ width: 23px; height: 23px; display: flex;
55
+ align-items: center; justify-content: center; padding: 0;
56
+ border: 1px solid #e4e2db; border-radius: 50%; background: #fff; color: #6b6862;
57
+ font: 11px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
58
+ cursor: pointer; box-shadow: 0 2px 8px rgba(27,26,22,.14);
59
+ }
60
+ .chip:hover { background: #1b1a16; color: #fff; border-color: #1b1a16; }
61
+ .chip.danger { color: #b23b2e; }
62
+ .chip.danger:hover { background: #b23b2e; color: #fff; border-color: #b23b2e; }
63
+ .chips .chip, .mover { opacity: .6; transition: opacity 100ms ease; }
64
+ .chips .chip:hover, .chips .chip:focus-visible, .mover:hover, .mover:focus-visible { opacity: 1; }
65
+ .grip {
66
+ position: fixed; z-index: 2147483647; width: 13px; height: 13px;
67
+ display: none; border: 1px solid #1b1a16; border-radius: 3px;
68
+ background: #fff; cursor: nwse-resize; pointer-events: auto;
69
+ box-shadow: 0 1px 4px rgba(27,26,22,.2);
70
+ }
71
+ .hint {
72
+ position: fixed; z-index: 2147483647; display: none; padding: 3px 7px;
73
+ border-radius: 5px; background: #1b1a16; color: #fbfaf7; white-space: nowrap;
74
+ font: 11px/1.3 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
75
+ pointer-events: none;
76
+ }
77
+ .linkbox {
78
+ position: fixed; z-index: 2147483647; display: none; gap: 4px; align-items: center;
79
+ padding: 5px; border: 1px solid #e4e2db; border-radius: 9px; background: #fff;
80
+ box-shadow: 0 4px 16px rgba(27,26,22,.16); pointer-events: auto;
81
+ }
82
+ .linkbox input {
83
+ width: 224px; padding: 4px 7px; border: none; outline: none; background: none;
84
+ font: 12px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #1b1a16;
85
+ }
86
+ .linkbox input::placeholder { color: #a29f95; }
87
+ .mover {
88
+ position: fixed; z-index: 2147483647; width: 18px; height: 24px;
89
+ display: none; align-items: center; justify-content: center;
90
+ border: 1px solid #e4e2db; border-radius: 6px; background: #fff; color: #a29f95;
91
+ font: 12px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
92
+ cursor: grab; pointer-events: auto; user-select: none;
93
+ box-shadow: 0 2px 8px rgba(27,26,22,.14);
94
+ }
95
+ .mover:hover { color: #1b1a16; border-color: #c2beb4; }
96
+ .mover:active { cursor: grabbing; }
97
+ .dropline {
98
+ position: fixed; z-index: 2147483646; height: 0; display: none;
99
+ border-top: 2px solid #1b1a16; border-radius: 1px; pointer-events: none;
100
+ }
101
+ </style>
102
+ <div class="box outline" id="outline"></div>
103
+ <div class="box active" id="activeBox"></div>
104
+ <div class="chips" id="chips">
105
+ <button class="chip danger" id="chipDelete" title="Delete this block" aria-label="Delete this block">&#10005;</button>
106
+ </div>
107
+ <div class="grip" id="grip" title="Drag to resize"></div>
108
+ <div class="hint" id="hint"></div>
109
+ <div class="linkbox" id="linkbox">
110
+ <input id="linkInput" type="text" placeholder="Link to&hellip;" spellcheck="false">
111
+ <button class="chip" id="linkApply" title="Apply link (&#9166;)" aria-label="Apply link">&#8629;</button>
112
+ <button class="chip danger" id="linkRemove" title="Remove link" aria-label="Remove link">&#10005;</button>
113
+ </div>
114
+ <div class="mover" id="mover" title="Drag to move this block">&#10303;</div>
115
+ <div class="dropline" id="dropline"></div>
116
+ `;
117
+
118
+ const els = {};
119
+ const mountOverlay = () => {
120
+ if (!host.isConnected) document.documentElement.appendChild(host);
121
+ els.outline = shadow.getElementById("outline");
122
+ els.activeBox = shadow.getElementById("activeBox");
123
+ els.chips = shadow.getElementById("chips");
124
+ els.chipDelete = shadow.getElementById("chipDelete");
125
+ els.grip = shadow.getElementById("grip");
126
+ els.hint = shadow.getElementById("hint");
127
+ els.linkbox = shadow.getElementById("linkbox");
128
+ els.linkInput = shadow.getElementById("linkInput");
129
+ els.linkApply = shadow.getElementById("linkApply");
130
+ els.linkRemove = shadow.getElementById("linkRemove");
131
+ els.mover = shadow.getElementById("mover");
132
+ els.dropline = shadow.getElementById("dropline");
133
+ };
134
+
135
+ function showMover(el) {
136
+ if (!el || !el.isConnected) {
137
+ els.mover.style.display = "none";
138
+ return;
139
+ }
140
+ const r = el.getBoundingClientRect();
141
+ if (!r.width && !r.height) {
142
+ els.mover.style.display = "none";
143
+ return;
144
+ }
145
+ els.mover.style.display = "flex";
146
+ // Half-overlap the block's edge: the pointer can travel from text to handle
147
+ // without ever leaving the block, so the hover state never drops.
148
+ els.mover.style.left = `${Math.max(4, r.left - 9)}px`;
149
+ els.mover.style.top = `${Math.max(4, r.top + 1)}px`;
150
+ }
151
+
152
+ function showDropline(drop) {
153
+ if (!drop || !drop.ref.isConnected) {
154
+ els.dropline.style.display = "none";
155
+ return;
156
+ }
157
+ const r = drop.ref.getBoundingClientRect();
158
+ els.dropline.style.display = "block";
159
+ els.dropline.style.left = `${r.left}px`;
160
+ els.dropline.style.width = `${r.width}px`;
161
+ els.dropline.style.top = `${(drop.before ? r.top : r.bottom) - 1}px`;
162
+ }
163
+
164
+ function place(box, el, pad = 2) {
165
+ if (!el || !el.isConnected) {
166
+ box.style.display = "none";
167
+ return;
168
+ }
169
+ const r = el.getBoundingClientRect();
170
+ if (!r.width && !r.height) {
171
+ box.style.display = "none";
172
+ return;
173
+ }
174
+ box.style.display = "block";
175
+ box.style.left = `${r.left - pad}px`;
176
+ box.style.top = `${r.top - pad}px`;
177
+ box.style.width = `${r.width + pad * 2}px`;
178
+ box.style.height = `${r.height + pad * 2}px`;
179
+ }
180
+
181
+ function showChip(el) {
182
+ if (!el || !el.isConnected) {
183
+ els.chips.style.display = "none";
184
+ return;
185
+ }
186
+ const r = el.getBoundingClientRect();
187
+ // A target in a collapsed tab has no box; don't strand the chips in a corner.
188
+ if (!r.width && !r.height) {
189
+ els.chips.style.display = "none";
190
+ return;
191
+ }
192
+ els.chips.style.display = "flex";
193
+ els.chips.style.left = `${Math.max(4, r.right - 11)}px`;
194
+ els.chips.style.top = `${Math.max(4, r.top - 11)}px`;
195
+ }
196
+
197
+ function showGrip(el) {
198
+ if (!el || !el.isConnected) {
199
+ els.grip.style.display = "none";
200
+ return;
201
+ }
202
+ const r = el.getBoundingClientRect();
203
+ if (!r.width && !r.height) {
204
+ els.grip.style.display = "none";
205
+ return;
206
+ }
207
+ els.grip.style.display = "block";
208
+ els.grip.style.left = `${r.right - 7}px`;
209
+ els.grip.style.top = `${r.bottom - 7}px`;
210
+ }
211
+
212
+ function showHint(text, x, y) {
213
+ if (!text) {
214
+ els.hint.style.display = "none";
215
+ return;
216
+ }
217
+ els.hint.textContent = text;
218
+ els.hint.style.display = "block";
219
+ els.hint.style.left = `${x + 12}px`;
220
+ els.hint.style.top = `${y + 16}px`;
221
+ }
222
+
223
+ // ------------------------------------------------------------ text plumbing
224
+
225
+ const isOurs = (node) => {
226
+ const el = node && node.nodeType === 1 ? node : node && node.parentElement;
227
+ return !!(el && el.closest && el.closest(`[${UI_ATTR}]`));
228
+ };
229
+
230
+ /** Flatten the body's text nodes into one string plus an offset map. */
231
+ function flatten() {
232
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
233
+ acceptNode(node) {
234
+ if (!node.nodeValue) return NodeFilter.FILTER_REJECT;
235
+ const parent = node.parentElement;
236
+ if (!parent) return NodeFilter.FILTER_REJECT;
237
+ if (isOurs(parent)) return NodeFilter.FILTER_REJECT;
238
+ if (/^(script|style|noscript|template)$/i.test(parent.tagName)) return NodeFilter.FILTER_REJECT;
239
+ return NodeFilter.FILTER_ACCEPT;
240
+ },
241
+ });
242
+ let text = "";
243
+ const map = [];
244
+ let node = walker.nextNode();
245
+ while (node) {
246
+ const start = text.length;
247
+ text += node.nodeValue;
248
+ map.push({ node, start, end: text.length });
249
+ node = walker.nextNode();
250
+ }
251
+ return { text, map };
252
+ }
253
+
254
+ function offsetsFromRange(map, range) {
255
+ let start = null;
256
+ let end = null;
257
+ for (const entry of map) {
258
+ if (entry.node === range.startContainer) start = entry.start + range.startOffset;
259
+ if (entry.node === range.endContainer) end = entry.start + range.endOffset;
260
+ }
261
+ if (start === null || end === null || end <= start) return null;
262
+ return { start, end };
263
+ }
264
+
265
+ /** Wrap a global offset span in <mark> elements, one per text node touched. */
266
+ function wrapOffsets(map, start, end, id) {
267
+ const marks = [];
268
+ for (const entry of map) {
269
+ if (entry.end <= start || entry.start >= end) continue;
270
+ const from = Math.max(0, start - entry.start);
271
+ const to = Math.min(entry.node.nodeValue.length, end - entry.start);
272
+ if (to <= from) continue;
273
+ const range = document.createRange();
274
+ try {
275
+ range.setStart(entry.node, from);
276
+ range.setEnd(entry.node, to);
277
+ const mark = document.createElement("mark");
278
+ mark.setAttribute(MARK_ATTR, id);
279
+ range.surroundContents(mark);
280
+ marks.push(mark);
281
+ } catch {
282
+ // A node that shifted under us is skipped rather than corrupting the DOM.
283
+ }
284
+ }
285
+ return marks;
286
+ }
287
+
288
+ function marksFor(id) {
289
+ return [...document.querySelectorAll(`mark[${MARK_ATTR}="${CSS.escape(id)}"]`)];
290
+ }
291
+
292
+ function unwrap(id) {
293
+ for (const mark of marksFor(id)) {
294
+ const parent = mark.parentNode;
295
+ if (!parent) continue;
296
+ while (mark.firstChild) parent.insertBefore(mark.firstChild, mark);
297
+ parent.removeChild(mark);
298
+ parent.normalize();
299
+ }
300
+ }
301
+
302
+ // ------------------------------------------------------- target resolution
303
+
304
+ function isBlock(el) {
305
+ if (!el || el.nodeType !== 1) return false;
306
+ const display = getComputedStyle(el).display;
307
+ return /^(block|flex|grid|list-item|table|flow-root)$/.test(display);
308
+ }
309
+
310
+ function hasOwnText(el) {
311
+ return [...el.childNodes].some((n) => n.nodeType === 3 && n.nodeValue.trim());
312
+ }
313
+
314
+ /** A body-anchored, fully indexed path, so it resolves to one element only. */
315
+ function cssPath(el) {
316
+ const parts = [];
317
+ let node = el;
318
+ while (node && node.nodeType === 1 && node !== document.body && parts.length < 8) {
319
+ if (node.id) {
320
+ parts.unshift(`#${CSS.escape(node.id)}`);
321
+ return parts.join(" > ");
322
+ }
323
+ const tag = node.tagName.toLowerCase();
324
+ const twins = node.parentElement ? [...node.parentElement.children].filter((c) => c.tagName === node.tagName) : [];
325
+ parts.unshift(twins.length > 1 ? `${tag}:nth-of-type(${twins.indexOf(node) + 1})` : tag);
326
+ node = node.parentElement;
327
+ }
328
+ return ["body", ...parts].join(" > ");
329
+ }
330
+
331
+ /** The heading a block sits under, used to name edits in arbitrary HTML. */
332
+ function precedingHeading(el) {
333
+ let node = el;
334
+ while (node && node !== document.body) {
335
+ let sib = node.previousElementSibling;
336
+ while (sib) {
337
+ if (/^h[1-6]$/i.test(sib.tagName) && sib.textContent.trim()) return sib.textContent.trim();
338
+ const nested = sib.querySelectorAll ? sib.querySelectorAll("h1,h2,h3,h4,h5,h6") : [];
339
+ for (let i = nested.length - 1; i >= 0; i -= 1) {
340
+ if (nested[i].textContent.trim()) return nested[i].textContent.trim();
341
+ }
342
+ sib = sib.previousElementSibling;
343
+ }
344
+ node = node.parentElement;
345
+ }
346
+ return "";
347
+ }
348
+
349
+ const clip = (text, limit = 40) => {
350
+ const flat = String(text || "").replace(/\s+/g, " ").trim();
351
+ return flat.length > limit ? `${flat.slice(0, limit - 1).trimEnd()}…` : flat;
352
+ };
353
+
354
+ /**
355
+ * A block's label is pinned the first time it is computed. Labels derived
356
+ * from the block's own text or position would drift as the user types or
357
+ * deletes siblings, splitting one block's edit into several contradictory
358
+ * rows keyed on each intermediate wording.
359
+ */
360
+ const pinnedLabels = new WeakMap();
361
+
362
+ /** The block a hover/edit belongs to, plus a human label for the edit list. */
363
+ function targetFor(node) {
364
+ const el = node && node.nodeType === 1 ? node : node && node.parentElement;
365
+ if (!el || isOurs(el)) return null;
366
+
367
+ const authored = el.closest("[data-container],[data-block]");
368
+ if (authored) {
369
+ const label = authored.getAttribute("data-container") || authored.getAttribute("data-block");
370
+ return { el: authored, label: clip(label), authored: true };
371
+ }
372
+
373
+ let block = el;
374
+ while (block && block !== document.body && !isBlock(block)) block = block.parentElement;
375
+ if (!block || block === document.body || !block.textContent.trim()) {
376
+ if (el !== document.body && MEDIA.test(el.tagName)) block = el;
377
+ else return null;
378
+ }
379
+
380
+ if (!pinnedLabels.has(block)) {
381
+ const heading = precedingHeading(block);
382
+ const tag = block.tagName.toLowerCase();
383
+ // Siblings of the same tag would otherwise share a label and collapse into
384
+ // one edit row, so number them.
385
+ const twins = block.parentElement ? [...block.parentElement.children].filter((c) => c.tagName === block.tagName) : [];
386
+ const ordinal = twins.length > 1 ? ` ${twins.indexOf(block) + 1}` : "";
387
+ pinnedLabels.set(block, heading ? `${clip(heading, 26)} · ${tag}${ordinal}` : clip(block.textContent, 40) || tag);
388
+ }
389
+ return { el: block, label: pinnedLabels.get(block), authored: false };
390
+ }
391
+
392
+ /**
393
+ * The nearest rendered block around a node, ignoring authored data-block
394
+ * containers. List conversion and drop targeting need the line the caret is
395
+ * actually on, not the labeled region it reports edits under.
396
+ */
397
+ function innermostBlock(node) {
398
+ let el = node && node.nodeType === 1 ? node : node && node.parentElement;
399
+ while (el && el !== document.body && !isBlock(el)) el = el.parentElement;
400
+ return el && el !== document.body ? el : null;
401
+ }
402
+
403
+ /**
404
+ * After a list command: hoist the fresh list out of the paragraph Chrome
405
+ * sometimes nests it in (invalid HTML that a reparse would restructure), and
406
+ * make sure the page's CSS reset can't hide it.
407
+ */
408
+ function polishNewList(sel) {
409
+ const node = sel && sel.anchorNode;
410
+ const el = node && (node.nodeType === 1 ? node : node.parentElement);
411
+ const item = el && el.closest ? el.closest("li") : null;
412
+ const list = item ? item.closest("ul, ol") : null;
413
+ if (!list) return;
414
+ const wrap = list.parentElement;
415
+ if (wrap && /^(p|h[1-6])$/i.test(wrap.tagName) && wrap.childNodes.length === 1) {
416
+ wrap.replaceWith(list);
417
+ }
418
+ const patch = listStyleFixup(list.tagName, getComputedStyle(list));
419
+ if (patch.listStyleType) list.style.listStyleType = patch.listStyleType;
420
+ if (patch.paddingLeft) list.style.paddingLeft = patch.paddingLeft;
421
+ }
422
+
423
+ /** The caret position a drag at (x, y) would drop into. */
424
+ function caretRangeAt(x, y) {
425
+ if (document.caretRangeFromPoint) return document.caretRangeFromPoint(x, y);
426
+ if (document.caretPositionFromPoint) {
427
+ const pos = document.caretPositionFromPoint(x, y);
428
+ if (!pos) return null;
429
+ const range = document.createRange();
430
+ range.setStart(pos.offsetNode, pos.offset);
431
+ return range;
432
+ }
433
+ return null;
434
+ }
435
+
436
+ // ------------------------------------------------------------ serialization
437
+
438
+ const serialize = () => serializeDocument(document);
439
+
440
+ /**
441
+ * A single block's HTML with every review artifact removed. outerHTML, not
442
+ * innerHTML: changes to the block's own attributes (an image's new width, for
443
+ * one) live on the element itself, and a void element has no inner markup.
444
+ */
445
+ function blockHtml(el) {
446
+ const clone = el.cloneNode(true);
447
+ clone.querySelectorAll(`[${UI_ATTR}]`).forEach((n) => n.remove());
448
+ clone.querySelectorAll(`mark[${MARK_ATTR}]`).forEach((mark) => {
449
+ const parent = mark.parentNode;
450
+ if (!parent) return;
451
+ while (mark.firstChild) parent.insertBefore(mark.firstChild, mark);
452
+ parent.removeChild(mark);
453
+ });
454
+ clone.querySelectorAll("[data-eh-el]").forEach((n) => n.removeAttribute("data-eh-el"));
455
+ clone.removeAttribute("data-eh-el");
456
+ clone.normalize();
457
+ return clone.outerHTML;
458
+ }
459
+
460
+ /**
461
+ * Serialization is lossy about formatting, so a save that would not change the
462
+ * document is skipped outright. Opening a file must never rewrite it.
463
+ */
464
+ let baseline = null;
465
+ /** What the document looked like at boot, before any user edit. */
466
+ let bootSnapshot = null;
467
+
468
+ /**
469
+ * Compare the live document against the file on disk (parsed the same way,
470
+ * without running its scripts). A mismatch means the page renders itself —
471
+ * writing the live DOM back would bake that output into the file, so saves
472
+ * are disabled and edits travel to the agent as feedback only.
473
+ */
474
+ function checkDynamic(diskHtml) {
475
+ try {
476
+ const parsed = new DOMParser().parseFromString(diskHtml, "text/html");
477
+ if (serializeDocument(parsed) !== bootSnapshot) markDynamic();
478
+ } catch {
479
+ // If the comparison itself fails, keep saving as normal.
480
+ }
481
+ }
482
+
483
+ function markDynamic() {
484
+ if (dynamic) return;
485
+ dynamic = true;
486
+ clearTimeout(saveTimer);
487
+ post("eh:dynamic", {});
488
+ }
489
+
490
+ /** True once the user has actually edited, as opposed to the page's own scripts. */
491
+ let userEdited = false;
492
+
493
+ /**
494
+ * The boot comparison only catches scripts that rewrote the DOM before this
495
+ * module ran. Pages that render on load or after a fetch (charts, mermaid,
496
+ * client-rendered apps) mutate later — watch for any serialization drift that
497
+ * happens before the first real user edit and treat it the same way.
498
+ */
499
+ function watchSelfRendering() {
500
+ const observer = new MutationObserver(() => {
501
+ if (dynamic || userEdited) {
502
+ observer.disconnect();
503
+ return;
504
+ }
505
+ if (serialize() !== bootSnapshot) {
506
+ observer.disconnect();
507
+ markDynamic();
508
+ }
509
+ });
510
+ observer.observe(document.documentElement, { childList: true, subtree: true, characterData: true, attributes: true });
511
+ }
512
+
513
+ function emitSave() {
514
+ if (dynamic) {
515
+ post("eh:dynamic", {});
516
+ return;
517
+ }
518
+ const html = serialize();
519
+ if (html === baseline) {
520
+ post("eh:clean", {});
521
+ return;
522
+ }
523
+ baseline = html;
524
+ post("eh:html", { html });
525
+ }
526
+
527
+ function scheduleSave() {
528
+ clearTimeout(saveTimer);
529
+ if (!dynamic) post("eh:saving", {});
530
+ saveTimer = setTimeout(emitSave, SAVE_DEBOUNCE_MS);
531
+ }
532
+
533
+ function flushSave() {
534
+ clearTimeout(saveTimer);
535
+ flushEdits();
536
+ emitSave();
537
+ }
538
+
539
+ // ------------------------------------------------------------- edit tracking
540
+
541
+ /**
542
+ * One POST per keystroke would hammer the server (each save rewrites the whole
543
+ * state file), so edits queue up and flush together. The store dedupes on
544
+ * label+kind and only the latest `after` matters, so coalescing loses nothing.
545
+ */
546
+ const editQueue = new Map();
547
+ let editTimer = null;
548
+
549
+ function flushEdits() {
550
+ clearTimeout(editTimer);
551
+ editTimer = null;
552
+ for (const payload of editQueue.values()) post("eh:edit", payload);
553
+ editQueue.clear();
554
+ }
555
+
556
+ function queueEdit(payload) {
557
+ const key = `${payload.label}\u0000${payload.kind}`;
558
+ const queued = editQueue.get(key);
559
+ if (queued && (queued.staged_assets || payload.staged_assets)) {
560
+ const assets = [...(queued.staged_assets || []), ...(payload.staged_assets || [])];
561
+ payload.staged_assets = [...new Map(assets.map((asset) => [asset.path || asset.id, asset])).values()];
562
+ }
563
+ editQueue.set(key, payload);
564
+ clearTimeout(editTimer);
565
+ editTimer = setTimeout(flushEdits, EDIT_FLUSH_MS);
566
+ }
567
+
568
+ // ------------------------------------------------------------- interactions
569
+
570
+ function clearPending() {
571
+ pending = null;
572
+ place(els.activeBox, null);
573
+ }
574
+
575
+ /**
576
+ * A settled selection opens the compose card but is otherwise left completely
577
+ * alone: still selected, still editable. Nothing is written into the document
578
+ * until the comment is actually committed, so typing or Backspace behaves
579
+ * exactly as it would in any editable page.
580
+ */
581
+ function settleSelection() {
582
+ const sel = document.getSelection();
583
+ if (!sel || sel.isCollapsed || !sel.rangeCount) return false;
584
+ const range = sel.getRangeAt(0);
585
+ if (!document.body.contains(range.commonAncestorContainer)) return false;
586
+ if (isOurs(range.commonAncestorContainer)) return false;
587
+ const quote = sel.toString();
588
+ if (!quote.trim()) return false;
589
+
590
+ const { text, map } = flatten();
591
+ const offsets = offsetsFromRange(map, range);
592
+ if (!offsets) return false;
593
+
594
+ const context = buildContext(text, offsets.start, offsets.end);
595
+ pending = { kind: "selection", context };
596
+
597
+ post("eh:compose", {
598
+ kind: "selection",
599
+ quote,
600
+ anchor: { ...context, selector: cssPath(range.commonAncestorContainer.parentElement || document.body) },
601
+ });
602
+ return true;
603
+ }
604
+
605
+ function openElementCompose(container) {
606
+ if (pending && pending.element === container.el) return;
607
+ pending = { kind: "element", element: container.el };
608
+ place(els.activeBox, container.el);
609
+ post("eh:compose", {
610
+ kind: "element",
611
+ quote: container.label,
612
+ anchor: { selector: cssPath(container.el), label: container.label },
613
+ });
614
+ }
615
+
616
+ /** Commit turns the remembered range into real <mark> wrappers. */
617
+ function commitPending(id) {
618
+ if (!pending) return;
619
+ if (pending.kind === "element") {
620
+ if (pending.element && pending.element.isConnected) pending.element.setAttribute("data-eh-el", id);
621
+ } else {
622
+ const { text, map } = flatten();
623
+ const hit = findQuote(text, pending.context);
624
+ if (hit) wrapOffsets(map, hit.start, hit.end, id);
625
+ }
626
+ const sel = document.getSelection();
627
+ if (sel) sel.removeAllRanges();
628
+ clearPending();
629
+ }
630
+
631
+ function reanchor(comments) {
632
+ const resolved = [];
633
+ const orphaned = [];
634
+ for (const comment of comments) {
635
+ if (marksFor(comment.id).length) {
636
+ resolved.push(comment.id);
637
+ continue;
638
+ }
639
+ if (comment.kind === "element") {
640
+ const el = comment.anchor && comment.anchor.selector ? document.querySelector(comment.anchor.selector) : null;
641
+ // Re-stamp the marker, or "Jump to" has nothing to find after a reload.
642
+ if (el) el.setAttribute("data-eh-el", comment.id);
643
+ (el ? resolved : orphaned).push(comment.id);
644
+ continue;
645
+ }
646
+ const { text, map } = flatten();
647
+ const hit = comment.anchor ? findQuote(text, comment.anchor) : null;
648
+ if (!hit) {
649
+ orphaned.push(comment.id);
650
+ continue;
651
+ }
652
+ const marks = wrapOffsets(map, hit.start, hit.end, comment.id);
653
+ (marks.length ? resolved : orphaned).push(comment.id);
654
+ }
655
+ post("eh:anchorStatus", { resolved, orphaned });
656
+ }
657
+
658
+ function activate(id, scroll) {
659
+ const marks = marksFor(id);
660
+ let target = marks[0] || null;
661
+ if (!target) {
662
+ const el = document.querySelector(`[data-eh-el="${CSS.escape(id)}"]`);
663
+ if (el) target = el;
664
+ }
665
+ for (const mark of document.querySelectorAll(`mark[${MARK_ATTR}]`)) mark.classList.remove("eh-active");
666
+ for (const mark of marks) mark.classList.add("eh-active");
667
+ place(els.activeBox, target);
668
+ if (target && scroll) {
669
+ // Reveal a target hidden inside a collapsed tab or accordion.
670
+ let hidden = target.closest("[hidden]") || null;
671
+ if (!hidden) {
672
+ let probe = target.parentElement;
673
+ while (probe && probe !== document.body) {
674
+ if (getComputedStyle(probe).display === "none") {
675
+ hidden = probe;
676
+ break;
677
+ }
678
+ probe = probe.parentElement;
679
+ }
680
+ }
681
+ if (hidden) {
682
+ const control = hidden.id ? document.querySelector(`[aria-controls="${CSS.escape(hidden.id)}"]`) : null;
683
+ if (control) control.click();
684
+ }
685
+ const rect = target.getBoundingClientRect();
686
+ if (!rect.width && !rect.height) {
687
+ post("eh:notInView", { id });
688
+ return;
689
+ }
690
+ // scrollIntoView walks every scrollable ancestor, so targets inside an
691
+ // app's inner scroll container are reached too, not just window-scrolled ones.
692
+ target.scrollIntoView({ block: "center", behavior: "smooth" });
693
+ }
694
+ }
695
+
696
+ // -------------------------------------------------------------------- boot
697
+
698
+ function boot() {
699
+ mountOverlay();
700
+
701
+ const style = document.createElement("style");
702
+ style.setAttribute("data-eh-sdk", "");
703
+ style.textContent = `
704
+ mark[${MARK_ATTR}] { background: rgba(245,196,0,.32); border-radius: 2px; color: inherit; cursor: pointer; }
705
+ mark[${MARK_ATTR}]:hover { background: rgba(243,176,0,.5); }
706
+ mark[${MARK_ATTR}].eh-active { background: rgba(255,180,0,.55); }
707
+ body[contenteditable]:focus { outline: none; }
708
+ ::selection { background: rgba(245,196,0,.42); }
709
+ `;
710
+ document.head.appendChild(style);
711
+
712
+ // React/Next.js hydration may remove an attribute that was added after the
713
+ // server rendered the page. Keep edit mode on after hydration settles so a
714
+ // real app stays directly editable, not just its initial HTML response.
715
+ keepBodyEditable(document.body);
716
+ document.body.spellcheck = false;
717
+ baseline = serialize();
718
+ bootSnapshot = baseline;
719
+ watchSelfRendering();
720
+
721
+ document.addEventListener("mouseup", (event) => {
722
+ if (isOurs(event.target) || resizing || Date.now() < suppressUntil) return;
723
+ setTimeout(() => {
724
+ if (settleSelection()) return;
725
+ const target = targetFor(event.target);
726
+ if (target) {
727
+ openElementCompose(target);
728
+ return;
729
+ }
730
+ post("eh:dismiss", {});
731
+ }, 0);
732
+ });
733
+
734
+ document.addEventListener(
735
+ "click",
736
+ (event) => {
737
+ if (isOurs(event.target)) return;
738
+ const modified = event.metaKey || event.ctrlKey;
739
+ const href = navigationHref(event.target);
740
+
741
+ if (modified) {
742
+ if (href) {
743
+ event.preventDefault();
744
+ event.stopPropagation();
745
+ const classification = classifyHref(href);
746
+ if (classification === "external") {
747
+ const external = externalHref(href, document.baseURI);
748
+ if (external) post("eh:external", { href: external });
749
+ }
750
+ else if (classification === "hash") {
751
+ const action = hashClickAction(href, location.hash);
752
+ if (action.kind === "scroll") {
753
+ const el = document.getElementById(action.id) || document.getElementsByName(action.id)[0] || document.body;
754
+ el.scrollIntoView({ behavior: "smooth" });
755
+ } else {
756
+ location.hash = action.hash;
757
+ }
758
+ }
759
+ else if (classification === "navigate") {
760
+ // This document is about to be torn down: anything still sitting in
761
+ // the debounce windows must ship first or it is lost. Message order
762
+ // is guaranteed, so the edits land before the navigation does.
763
+ flushSave();
764
+ post("eh:navigate", { href });
765
+ }
766
+ }
767
+ return; // let the artifact's own handlers run
768
+ }
769
+
770
+ // Plain clicks belong to editing: never navigate, never fire artifact JS.
771
+ if (href || (event.target.closest && event.target.closest("button, [role='button'], summary"))) {
772
+ event.preventDefault();
773
+ event.stopPropagation();
774
+ }
775
+ const mark = event.target.closest && event.target.closest(`mark[${MARK_ATTR}]`);
776
+ if (mark) post("eh:activate", { id: mark.getAttribute(MARK_ATTR) });
777
+ },
778
+ true
779
+ );
780
+
781
+ document.addEventListener("submit", (event) => {
782
+ if (!event.metaKey && !event.ctrlKey) event.preventDefault();
783
+ }, true);
784
+
785
+ document.addEventListener("mouseover", (event) => {
786
+ if (isOurs(event.target) || resizing || moving) return;
787
+ const target = targetFor(event.target);
788
+ hoverTarget = target ? target.el : null;
789
+ // The move handle works per element, not per labeled container, so each
790
+ // paragraph inside a card can travel on its own.
791
+ hoverMove = hoverTarget ? innermostBlock(event.target) || hoverTarget : null;
792
+ hoverMedia = event.target.closest ? event.target.closest("img, video") : null;
793
+ place(els.outline, hoverTarget);
794
+ if (controlsAreSuppressed()) hideActionControls();
795
+ else {
796
+ showChip(hoverTarget);
797
+ showMover(hoverMove);
798
+ }
799
+ showGrip(hoverMedia);
800
+ const interactive = event.target.closest && event.target.closest("a[href], [data-href], button, [role='button']");
801
+ const draggable = hoverMedia && hoverMedia.tagName === "IMG";
802
+ showHint(interactive ? "⌘-click to open" : draggable ? "Drag to move" : "", event.clientX, event.clientY);
803
+ });
804
+
805
+ document.addEventListener("mouseleave", () => {
806
+ if (resizing || moving) return;
807
+ hoverTarget = null;
808
+ hoverMove = null;
809
+ hoverMedia = null;
810
+ place(els.outline, null);
811
+ showChip(null);
812
+ showMover(null);
813
+ showGrip(null);
814
+ showHint("");
815
+ });
816
+
817
+ // ------------------------------------------------------------ image resize
818
+
819
+ els.grip.addEventListener("pointerdown", (event) => {
820
+ if (!hoverMedia || !hoverMedia.isConnected) return;
821
+ event.preventDefault();
822
+ event.stopPropagation();
823
+ // From here on, DOM changes are the human resizing, not the page rendering.
824
+ userEdited = true;
825
+ const target = targetFor(hoverMedia);
826
+ const blockEl = target ? target.el : hoverMedia;
827
+ resizing = {
828
+ el: hoverMedia,
829
+ startX: event.clientX,
830
+ startWidth: hoverMedia.getBoundingClientRect().width,
831
+ label: target ? target.label : "Image",
832
+ blockEl,
833
+ beforeText: blockEl.textContent,
834
+ beforeHtml: blockHtml(blockEl),
835
+ };
836
+ if (els.grip.setPointerCapture) els.grip.setPointerCapture(event.pointerId);
837
+ });
838
+
839
+ window.addEventListener("pointermove", (event) => {
840
+ if (!resizing) return;
841
+ event.preventDefault();
842
+ const width = Math.max(24, Math.round(resizing.startWidth + (event.clientX - resizing.startX)));
843
+ resizing.el.style.width = `${width}px`;
844
+ resizing.el.style.height = "auto";
845
+ place(els.outline, hoverTarget);
846
+ showGrip(resizing.el);
847
+ });
848
+
849
+ window.addEventListener("pointerup", () => {
850
+ if (!resizing) return;
851
+ const { blockEl, label, beforeText, beforeHtml } = resizing;
852
+ resizing = null;
853
+ suppressUntil = Date.now() + 250;
854
+ queueEdit({
855
+ label,
856
+ kind: "edited",
857
+ before: beforeText,
858
+ after: blockEl.textContent,
859
+ before_html: beforeHtml,
860
+ after_html: blockHtml(blockEl),
861
+ });
862
+ scheduleSave();
863
+ });
864
+
865
+ els.chipDelete.addEventListener("click", (event) => {
866
+ event.preventDefault();
867
+ event.stopPropagation();
868
+ if (!hoverTarget) return;
869
+ userEdited = true;
870
+ const target = targetFor(hoverTarget);
871
+ const label = target ? target.label : "Element";
872
+ const before = hoverTarget.textContent;
873
+ hoverTarget.remove();
874
+ hoverTarget = null;
875
+ place(els.outline, null);
876
+ showChip(null);
877
+ queueEdit({ label, kind: "deleted", before, after: "" });
878
+ flushSave();
879
+ });
880
+
881
+ // beforeinput still sees the untouched wording, so capture it once per block.
882
+ const originalText = new WeakMap();
883
+ const originalHtml = new WeakMap();
884
+ const captureOriginal = (el) => {
885
+ if (!originalText.has(el)) {
886
+ originalText.set(el, el.textContent);
887
+ originalHtml.set(el, blockHtml(el));
888
+ }
889
+ };
890
+
891
+ /** An edit row for a block changed outside the input-event flow (attribute
892
+ * set, link removal, drag move) — the same shape the input listener emits. */
893
+ const emitBlockEdit = (blockEl, fallbackLabel, extra = {}) => {
894
+ const connected = blockEl.isConnected;
895
+ const target = connected ? targetFor(blockEl) : null;
896
+ queueEdit({
897
+ label: (target && target.label) || fallbackLabel || "Document body",
898
+ kind: "edited",
899
+ before: originalText.get(blockEl),
900
+ after: connected ? blockEl.textContent : "",
901
+ before_html: originalHtml.get(blockEl),
902
+ after_html: connected ? blockHtml(blockEl) : "",
903
+ ...extra,
904
+ });
905
+ };
906
+
907
+ let typingUntil = 0;
908
+ let controlRestoreTimer = null;
909
+
910
+ const selectionIsActive = () => {
911
+ const sel = document.getSelection();
912
+ return !!(sel && !sel.isCollapsed && sel.rangeCount && document.body.contains(sel.getRangeAt(0).commonAncestorContainer));
913
+ };
914
+
915
+ const controlsAreSuppressed = () => Date.now() < typingUntil || selectionIsActive();
916
+
917
+ const hideActionControls = () => {
918
+ showChip(null);
919
+ showMover(null);
920
+ };
921
+
922
+ const restoreActionControls = () => {
923
+ if (controlsAreSuppressed()) return;
924
+ showChip(hoverTarget);
925
+ showMover(hoverMove);
926
+ };
927
+
928
+ const suppressActionControlsWhileTyping = () => {
929
+ typingUntil = Date.now() + 800;
930
+ hideActionControls();
931
+ clearTimeout(controlRestoreTimer);
932
+ controlRestoreTimer = setTimeout(restoreActionControls, 810);
933
+ };
934
+ document.addEventListener(
935
+ "beforeinput",
936
+ (event) => {
937
+ if (isOurs(event.target)) return;
938
+ suppressActionControlsWhileTyping();
939
+ // From here on, DOM drift is the human typing, not the page rendering.
940
+ userEdited = true;
941
+ const sel = document.getSelection();
942
+ const target = targetFor(sel && sel.anchorNode ? sel.anchorNode : event.target);
943
+ if (target) captureOriginal(target.el);
944
+ },
945
+ true
946
+ );
947
+
948
+ document.addEventListener("selectionchange", () => {
949
+ if (selectionIsActive()) hideActionControls();
950
+ else restoreActionControls();
951
+ });
952
+
953
+ // ------------------------------------------------------------------ links
954
+
955
+ let linkState = null; // { range, anchor, blockEl, label } while the ⌘K popup is open
956
+
957
+ function closeLinkbox(restoreSelection) {
958
+ if (!linkState) return;
959
+ const { range } = linkState;
960
+ linkState = null;
961
+ els.linkbox.style.display = "none";
962
+ if (restoreSelection && range) {
963
+ const sel = document.getSelection();
964
+ sel.removeAllRanges();
965
+ sel.addRange(range);
966
+ document.body.focus({ preventScroll: true });
967
+ }
968
+ }
969
+
970
+ function openLinkbox() {
971
+ const sel = document.getSelection();
972
+ if (!sel || !sel.rangeCount) return false;
973
+ let range = sel.getRangeAt(0).cloneRange();
974
+ const container = range.commonAncestorContainer;
975
+ if (!document.body.contains(container) || isOurs(container)) return false;
976
+
977
+ const caretEl = container.nodeType === 1 ? container : container.parentElement;
978
+ const anchor = caretEl && caretEl.closest ? caretEl.closest("a") : null;
979
+ if (sel.isCollapsed) {
980
+ // A bare caret can only mean "edit the link I'm inside".
981
+ if (!anchor) return false;
982
+ range = document.createRange();
983
+ range.selectNodeContents(anchor);
984
+ }
985
+ const target = targetFor(anchor || container);
986
+ linkState = {
987
+ range,
988
+ anchor,
989
+ blockEl: target ? target.el : null,
990
+ label: target ? target.label : "Document body",
991
+ };
992
+ if (linkState.blockEl) captureOriginal(linkState.blockEl);
993
+
994
+ // The compose card and the link popup fight over the same selection.
995
+ clearPending();
996
+ post("eh:dismiss", {});
997
+
998
+ const r = range.getBoundingClientRect();
999
+ const below = r.bottom + 44 < window.innerHeight;
1000
+ els.linkbox.style.display = "flex";
1001
+ els.linkbox.style.left = `${Math.max(8, Math.min(r.left, window.innerWidth - 320))}px`;
1002
+ els.linkbox.style.top = `${below ? r.bottom + 8 : Math.max(8, r.top - 44)}px`;
1003
+ els.linkRemove.style.display = anchor ? "" : "none";
1004
+ els.linkInput.value = anchor ? anchor.getAttribute("href") || "" : "";
1005
+ els.linkInput.focus();
1006
+ els.linkInput.select();
1007
+ return true;
1008
+ }
1009
+
1010
+ function applyLink() {
1011
+ if (!linkState) return;
1012
+ const href = normalizeHref(els.linkInput.value);
1013
+ const { range, anchor, blockEl, label } = linkState;
1014
+ if (!href) {
1015
+ // Nothing typed → dismiss. Something unusable typed → stay open so the
1016
+ // input isn't silently thrown away.
1017
+ if (!els.linkInput.value.trim()) closeLinkbox(true);
1018
+ else els.linkInput.select();
1019
+ return;
1020
+ }
1021
+ userEdited = true;
1022
+ linkState = null;
1023
+ els.linkbox.style.display = "none";
1024
+ if (anchor && anchor.isConnected) {
1025
+ // Editing an existing link never rewraps — just retarget it.
1026
+ anchor.setAttribute("href", href);
1027
+ if (blockEl) emitBlockEdit(blockEl, label);
1028
+ } else {
1029
+ const sel = document.getSelection();
1030
+ sel.removeAllRanges();
1031
+ sel.addRange(range);
1032
+ document.body.focus({ preventScroll: true });
1033
+ document.execCommand("createLink", false, href);
1034
+ // On pages that reset anchor styling the new link looks like plain
1035
+ // prose — underline it so the user sees that it took.
1036
+ const node = sel.anchorNode;
1037
+ const el = node && (node.nodeType === 1 ? node : node.parentElement);
1038
+ const created = el && el.closest ? el.closest("a") : null;
1039
+ if (created) {
1040
+ const patch = linkStyleFixup(
1041
+ getComputedStyle(created),
1042
+ created.parentElement ? getComputedStyle(created.parentElement) : null
1043
+ );
1044
+ if (patch.textDecoration) created.style.textDecoration = patch.textDecoration;
1045
+ }
1046
+ }
1047
+ scheduleSave();
1048
+ }
1049
+
1050
+ function removeLink() {
1051
+ if (!linkState) return;
1052
+ const { anchor, blockEl, label } = linkState;
1053
+ linkState = null;
1054
+ els.linkbox.style.display = "none";
1055
+ if (!anchor || !anchor.isConnected) return;
1056
+ userEdited = true;
1057
+ const parent = anchor.parentNode;
1058
+ while (anchor.firstChild) parent.insertBefore(anchor.firstChild, anchor);
1059
+ parent.removeChild(anchor);
1060
+ parent.normalize();
1061
+ if (blockEl) emitBlockEdit(blockEl, label);
1062
+ scheduleSave();
1063
+ }
1064
+
1065
+ els.linkInput.addEventListener("keydown", (event) => {
1066
+ event.stopPropagation();
1067
+ if (event.key === "Enter") {
1068
+ event.preventDefault();
1069
+ applyLink();
1070
+ } else if (event.key === "Escape") {
1071
+ event.preventDefault();
1072
+ closeLinkbox(true);
1073
+ }
1074
+ });
1075
+ els.linkApply.addEventListener("click", applyLink);
1076
+ els.linkRemove.addEventListener("click", removeLink);
1077
+
1078
+ // A click anywhere in the page means the popup lost the argument.
1079
+ document.addEventListener("pointerdown", (event) => {
1080
+ if (linkState && !isOurs(event.target)) closeLinkbox(false);
1081
+ });
1082
+
1083
+ // ------------------------------------------------------------------ lists
1084
+
1085
+ // execCommand mutations fire `input` (so edit rows and saves flow as usual)
1086
+ // but not `beforeinput`, so originals are captured here by hand.
1087
+ document.addEventListener(
1088
+ "keydown",
1089
+ (event) => {
1090
+ if (isOurs(event.target)) return;
1091
+ const meta = event.metaKey || event.ctrlKey;
1092
+ const sel = document.getSelection();
1093
+ const anchor = sel ? sel.anchorNode : null;
1094
+
1095
+ // ⌘K links the selection, like Docs, Word, and Notion.
1096
+ if (meta && !event.shiftKey && !event.altKey && (event.key === "k" || event.key === "K")) {
1097
+ if (openLinkbox()) {
1098
+ event.preventDefault();
1099
+ event.stopPropagation();
1100
+ }
1101
+ return;
1102
+ }
1103
+
1104
+ if (event.key === "Escape" && linkState) {
1105
+ closeLinkbox(true);
1106
+ return;
1107
+ }
1108
+
1109
+ // ⌘⇧8 bulleted, ⌘⇧7 numbered — the shortcuts people know from Docs.
1110
+ if (meta && event.shiftKey && (event.code === "Digit7" || event.code === "Digit8")) {
1111
+ const target = targetFor(anchor || event.target);
1112
+ if (!target) return;
1113
+ event.preventDefault();
1114
+ event.stopPropagation();
1115
+ userEdited = true;
1116
+ captureOriginal(target.el);
1117
+ document.execCommand(event.code === "Digit7" ? "insertOrderedList" : "insertUnorderedList");
1118
+ polishNewList(document.getSelection());
1119
+ scheduleSave();
1120
+ return;
1121
+ }
1122
+
1123
+ // Tab indents and Shift+Tab outdents inside a list, instead of leaving the page.
1124
+ if (event.key === "Tab" && anchor) {
1125
+ const el = anchor.nodeType === 1 ? anchor : anchor.parentElement;
1126
+ const item = el && el.closest ? el.closest("li") : null;
1127
+ if (!item || isOurs(item)) return;
1128
+ event.preventDefault();
1129
+ event.stopPropagation();
1130
+ userEdited = true;
1131
+ const target = targetFor(item);
1132
+ if (target) captureOriginal(target.el);
1133
+ document.execCommand(event.shiftKey ? "outdent" : "indent");
1134
+ polishNewList(document.getSelection());
1135
+ scheduleSave();
1136
+ return;
1137
+ }
1138
+
1139
+ // "- ", "* ", "1. ", or "1) " at the start of a line becomes a list.
1140
+ // Only the text between the block's start and the caret matters, so this
1141
+ // works inside authored data-block containers and in front of existing
1142
+ // text — not just in blocks that hold nothing but the marker.
1143
+ if (event.key === " " && sel && sel.isCollapsed && anchor) {
1144
+ const host = anchor.nodeType === 1 ? anchor : anchor.parentElement;
1145
+ if (!host || host.closest("li, ul, ol")) return;
1146
+ const block = innermostBlock(anchor);
1147
+ // isBlock matches display:table but not table-cell, so a caret in a
1148
+ // table's first cell resolves the whole table — never convert that.
1149
+ if (block && /^(table|thead|tbody|tfoot|tr)$/i.test(block.tagName)) return;
1150
+ const lead = document.createRange();
1151
+ // Inline content directly under <body> (a bare text node beside an
1152
+ // image, say) has no block; the caret's own node is the line then.
1153
+ if (block) lead.selectNodeContents(block);
1154
+ else lead.setStart(anchor, 0);
1155
+ try {
1156
+ lead.setEnd(sel.anchorNode, sel.anchorOffset);
1157
+ } catch {
1158
+ return;
1159
+ }
1160
+ const command = listCommandFor(lead.toString());
1161
+ if (!command) return;
1162
+ const target = targetFor(anchor);
1163
+ event.preventDefault();
1164
+ event.stopPropagation();
1165
+ userEdited = true;
1166
+ if (target) captureOriginal(target.el);
1167
+ sel.removeAllRanges();
1168
+ sel.addRange(lead);
1169
+ document.execCommand("delete");
1170
+ document.execCommand(command);
1171
+ polishNewList(document.getSelection());
1172
+ scheduleSave();
1173
+ }
1174
+ },
1175
+ true
1176
+ );
1177
+
1178
+ // ------------------------------------------------------------- image drag
1179
+
1180
+ // Chromium moves an image dropped inside contenteditable on its own; this
1181
+ // code only aims the drop (indicator outline), blocks payloads that have
1182
+ // nowhere to go (files from the desktop), and writes the edit rows for the
1183
+ // block the image left and the block it landed in.
1184
+ let dragging = null; // { el, fromBlock, fromLabel, overBlock, dropped } during an image drag
1185
+
1186
+ document.addEventListener("dragstart", (event) => {
1187
+ const img = event.target && event.target.nodeType === 1 && event.target.tagName === "IMG" ? event.target : null;
1188
+ if (!img || isOurs(img)) return;
1189
+ const target = targetFor(img);
1190
+ if (target) captureOriginal(target.el);
1191
+ dragging = {
1192
+ el: img,
1193
+ src: img.src,
1194
+ // The rendered size often comes from the container being left behind
1195
+ // (figure/card CSS); remember it so the move can't balloon the image.
1196
+ width: img.getBoundingClientRect().width,
1197
+ fromBlock: target ? target.el : null,
1198
+ fromLabel: target ? target.label : "Image",
1199
+ overBlock: null,
1200
+ dropped: false,
1201
+ };
1202
+ });
1203
+
1204
+ document.addEventListener("dragover", (event) => {
1205
+ if (!dragging) {
1206
+ // External payloads would navigate the frame or paste file:// markup.
1207
+ if (event.dataTransfer && [...(event.dataTransfer.types || [])].includes("Files")) {
1208
+ event.preventDefault();
1209
+ event.dataTransfer.dropEffect = "none";
1210
+ }
1211
+ return;
1212
+ }
1213
+ const range = caretRangeAt(event.clientX, event.clientY);
1214
+ const block = range ? innermostBlock(range.startContainer) : null;
1215
+ if (block && !isOurs(block)) {
1216
+ captureOriginal(block);
1217
+ dragging.overBlock = block;
1218
+ }
1219
+ place(els.outline, block && !isOurs(block) ? block : null);
1220
+ });
1221
+
1222
+ const finalizeImageDrag = () => {
1223
+ const drag = dragging;
1224
+ if (!drag || !drag.dropped) return;
1225
+ dragging = null;
1226
+ place(els.outline, null);
1227
+ // Chrome re-inserts a copy on drop, so find the landed image by source,
1228
+ // then pin it to the size it had before the move — an image dragged out
1229
+ // of the container that was constraining it would otherwise render at
1230
+ // its natural size.
1231
+ let landed = drag.el.isConnected ? drag.el : null;
1232
+ if (!landed && drag.overBlock && drag.overBlock.isConnected) {
1233
+ landed = [...drag.overBlock.querySelectorAll("img")].find((i) => i.src === drag.src) || null;
1234
+ }
1235
+ if (landed && drag.width && Math.abs(landed.getBoundingClientRect().width - drag.width) > 1) {
1236
+ landed.style.width = `${Math.round(drag.width)}px`;
1237
+ landed.style.height = "auto";
1238
+ landed.style.maxWidth = "100%";
1239
+ }
1240
+ const rows = new Map();
1241
+ if (drag.fromBlock) rows.set(drag.fromBlock, drag.fromLabel);
1242
+ if (drag.overBlock && !rows.has(drag.overBlock)) rows.set(drag.overBlock, null);
1243
+ for (const [block, label] of rows) emitBlockEdit(block, label);
1244
+ scheduleSave();
1245
+ };
1246
+
1247
+ document.addEventListener("drop", (event) => {
1248
+ if (dragging) {
1249
+ // The move itself is the drop's default action, so the edit rows can
1250
+ // only be read after it has run.
1251
+ userEdited = true;
1252
+ dragging.dropped = true;
1253
+ setTimeout(finalizeImageDrag, 0);
1254
+ return;
1255
+ }
1256
+ if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length) event.preventDefault();
1257
+ });
1258
+
1259
+ document.addEventListener("dragend", () => {
1260
+ // A cancelled drag (Esc, or dropped outside the page) still needs cleanup.
1261
+ if (dragging && !dragging.dropped) {
1262
+ dragging = null;
1263
+ place(els.outline, null);
1264
+ }
1265
+ });
1266
+
1267
+ // ------------------------------------------------------------- block moves
1268
+
1269
+ // The handle on a block's left edge moves the whole block. Pointer-based,
1270
+ // not HTML5 drag: the element is relocated on drop, never cloned, so
1271
+ // identity (labels, captured originals, comment anchors) survives the move.
1272
+ let moving = null; // { el, label, drop: { ref, before } | null } while the handle is held
1273
+
1274
+ const dropPointFor = (x, y) => {
1275
+ const under = document.elementFromPoint(x, y);
1276
+ if (!under || isOurs(under)) return null;
1277
+ const block = innermostBlock(under);
1278
+ if (!block || block === moving.el || moving.el.contains(block)) return null;
1279
+ const r = block.getBoundingClientRect();
1280
+ return { ref: block, before: y < r.top + r.height / 2 };
1281
+ };
1282
+
1283
+ els.mover.addEventListener("pointerdown", (event) => {
1284
+ const el = hoverMove && hoverMove.isConnected ? hoverMove : hoverTarget;
1285
+ if (!el || !el.isConnected) return;
1286
+ event.preventDefault();
1287
+ event.stopPropagation();
1288
+ const target = targetFor(el);
1289
+ moving = { el, label: target ? target.label : "Block", drop: null };
1290
+ captureOriginal(moving.el);
1291
+ moving.el.style.opacity = "0.4";
1292
+ place(els.outline, null);
1293
+ showChip(null);
1294
+ try {
1295
+ els.mover.setPointerCapture(event.pointerId);
1296
+ } catch {
1297
+ // Window-level listeners track the move even without capture.
1298
+ }
1299
+ });
1300
+
1301
+ window.addEventListener("pointermove", (event) => {
1302
+ if (!moving) return;
1303
+ event.preventDefault();
1304
+ moving.drop = dropPointFor(event.clientX, event.clientY);
1305
+ showDropline(moving.drop);
1306
+ });
1307
+
1308
+ window.addEventListener("pointerup", () => {
1309
+ if (!moving) return;
1310
+ const { el, label, drop } = moving;
1311
+ moving = null;
1312
+ showDropline(null);
1313
+ showMover(null);
1314
+ el.style.opacity = "";
1315
+ suppressUntil = Date.now() + 250;
1316
+ if (!drop || !drop.ref.isConnected || !el.isConnected) return;
1317
+ // Dropping right back where it came from is a no-op, not an edit.
1318
+ const next = drop.before ? drop.ref : drop.ref.nextElementSibling;
1319
+ if (next === el || (drop.before ? drop.ref.previousElementSibling : drop.ref) === el) return;
1320
+ userEdited = true;
1321
+ drop.ref.parentNode.insertBefore(el, next);
1322
+ const prev = el.previousElementSibling;
1323
+ const following = el.nextElementSibling;
1324
+ queueEdit({
1325
+ label,
1326
+ kind: "moved",
1327
+ before: originalText.get(el),
1328
+ after: el.textContent,
1329
+ before_html: originalHtml.get(el),
1330
+ after_html: blockHtml(el),
1331
+ moved_after: prev ? clip(prev.textContent, 90) : "",
1332
+ moved_before: following ? clip(following.textContent, 90) : "",
1333
+ });
1334
+ flushSave();
1335
+ });
1336
+
1337
+ // ------------------------------------------------------------- image paste
1338
+
1339
+ // Pasted images cross to the chrome page as bytes; the server writes them
1340
+ // into assets/ next to the reviewed file and the confirmed relative path
1341
+ // comes back to be inserted where the caret was.
1342
+ let pasteSeq = 0;
1343
+ const pendingPastes = new Map(); // id → collapsed caret range to insert at
1344
+
1345
+ document.addEventListener(
1346
+ "paste",
1347
+ (event) => {
1348
+ if (isOurs(event.target)) return;
1349
+ const items = event.clipboardData ? [...event.clipboardData.items] : [];
1350
+ const images = items.filter((item) => item.kind === "file" && /^image\//.test(item.type));
1351
+ if (!images.length) return;
1352
+ event.preventDefault();
1353
+ event.stopPropagation();
1354
+ const sel = document.getSelection();
1355
+ const caret = sel && sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null;
1356
+ for (const item of images) {
1357
+ const file = item.getAsFile();
1358
+ if (!file) continue;
1359
+ pasteSeq += 1;
1360
+ const id = `paste_${pasteSeq}`;
1361
+ pendingPastes.set(id, caret);
1362
+ file.arrayBuffer().then((bytes) => post("eh:asset", { id, assetType: file.type, bytes }));
1363
+ }
1364
+ },
1365
+ true
1366
+ );
1367
+
1368
+ const insertPastedImage = (id, src, stagedId) => {
1369
+ const caret = pendingPastes.get(id);
1370
+ pendingPastes.delete(id);
1371
+ userEdited = true;
1372
+ const img = document.createElement("img");
1373
+ img.setAttribute("src", src);
1374
+ img.style.maxWidth = "100%";
1375
+ const anchored = caret && caret.startContainer && document.body.contains(caret.startContainer) && !isOurs(caret.startContainer);
1376
+ if (anchored) {
1377
+ const target = targetFor(caret.startContainer);
1378
+ if (target) captureOriginal(target.el);
1379
+ caret.collapse(false);
1380
+ caret.insertNode(img);
1381
+ } else {
1382
+ document.body.appendChild(img);
1383
+ }
1384
+ const landed = targetFor(img);
1385
+ emitBlockEdit(landed ? landed.el : img, landed ? landed.label : "Pasted image", {
1386
+ ...(stagedId ? { staged_assets: [{ id: stagedId, preview_src: src }] } : {}),
1387
+ });
1388
+ flushSave();
1389
+ };
1390
+
1391
+ document.addEventListener("input", (event) => {
1392
+ if (isOurs(event.target)) return;
1393
+ // A drop fires deleteByDrag/insertFromDrop input events whose selection
1394
+ // points anywhere; finalizeImageDrag writes the real rows instead.
1395
+ if (dragging) return;
1396
+ // Typing over a selection is an edit, not a comment: retire the card.
1397
+ if (pending) {
1398
+ clearPending();
1399
+ post("eh:dismiss", {});
1400
+ }
1401
+ const sel = document.getSelection();
1402
+ const node = sel && sel.anchorNode ? sel.anchorNode : event.target;
1403
+ const target = targetFor(node);
1404
+ // Text alone loses formatting-only edits (bold, italic, underline change
1405
+ // markup, not textContent), so the block's cleaned HTML travels too.
1406
+ queueEdit({
1407
+ label: target ? target.label : "Document body",
1408
+ kind: "edited",
1409
+ before: target ? originalText.get(target.el) : undefined,
1410
+ after: target ? target.el.textContent : undefined,
1411
+ before_html: target ? originalHtml.get(target.el) : undefined,
1412
+ after_html: target ? blockHtml(target.el) : undefined,
1413
+ });
1414
+ scheduleSave();
1415
+ });
1416
+
1417
+ const reposition = () => {
1418
+ place(els.outline, hoverTarget);
1419
+ showChip(hoverTarget);
1420
+ showMover(moving ? null : hoverMove);
1421
+ showGrip(resizing ? resizing.el : hoverMedia);
1422
+ // The popup is viewport-fixed; scrolled away from its text it just lies.
1423
+ if (linkState) closeLinkbox(false);
1424
+ };
1425
+ // getBoundingClientRect on every scroll event forces layout mid-scroll;
1426
+ // one reposition per frame is plenty.
1427
+ let repositionQueued = false;
1428
+ const scheduleReposition = () => {
1429
+ if (repositionQueued) return;
1430
+ repositionQueued = true;
1431
+ requestAnimationFrame(() => {
1432
+ repositionQueued = false;
1433
+ reposition();
1434
+ });
1435
+ };
1436
+ window.addEventListener("scroll", scheduleReposition, true);
1437
+ window.addEventListener("resize", scheduleReposition);
1438
+
1439
+ let scrollQueued = false;
1440
+ window.addEventListener(
1441
+ "scroll",
1442
+ () => {
1443
+ if (scrollQueued) return;
1444
+ scrollQueued = true;
1445
+ requestAnimationFrame(() => {
1446
+ scrollQueued = false;
1447
+ post("eh:scroll", { x: window.scrollX, y: window.scrollY });
1448
+ });
1449
+ },
1450
+ { passive: true }
1451
+ );
1452
+
1453
+ window.addEventListener("message", (event) => {
1454
+ // Only the chrome page may drive the SDK — not popups the artifact opened,
1455
+ // and not the artifact's own scripts.
1456
+ if (event.source !== window.parent || event.origin !== CHROME_ORIGIN) return;
1457
+ const msg = event.data || {};
1458
+ if (!matchesFrameMessage(msg)) return;
1459
+ switch (msg.type) {
1460
+ case "eh:anchors":
1461
+ reanchor(msg.comments || []);
1462
+ break;
1463
+ case "eh:commit":
1464
+ commitPending(msg.id);
1465
+ composeOpen = false;
1466
+ break;
1467
+ case "eh:cancel":
1468
+ clearPending();
1469
+ composeOpen = false;
1470
+ break;
1471
+ case "eh:composeOpen":
1472
+ composeOpen = true;
1473
+ break;
1474
+ case "eh:remove":
1475
+ unwrap(msg.id);
1476
+ document.querySelectorAll(`[data-eh-el="${CSS.escape(msg.id)}"]`).forEach((el) => el.removeAttribute("data-eh-el"));
1477
+ place(els.activeBox, null);
1478
+ break;
1479
+ case "eh:activate":
1480
+ activate(msg.id, !!msg.scroll);
1481
+ break;
1482
+ case "eh:flush":
1483
+ flushSave();
1484
+ // Posted after the flushed eh:edit/eh:html messages, so when the
1485
+ // chrome sees it, everything pending has already been handed over.
1486
+ post("eh:flushed", {});
1487
+ break;
1488
+ case "eh:abortSave":
1489
+ // A revert is in flight: anything queued would re-write the edits.
1490
+ clearTimeout(saveTimer);
1491
+ clearTimeout(editTimer);
1492
+ editQueue.clear();
1493
+ break;
1494
+ case "eh:raw":
1495
+ checkDynamic(String(msg.html || ""));
1496
+ break;
1497
+ case "eh:feedbackOnly":
1498
+ // The chrome already knows saves must not happen (e.g. a Markdown
1499
+ // source rendered for review); no comparison needed.
1500
+ dynamic = true;
1501
+ break;
1502
+ case "eh:restoreScroll":
1503
+ window.scrollTo(msg.x || 0, msg.y || 0);
1504
+ break;
1505
+ case "eh:assetSaved":
1506
+ insertPastedImage(msg.id, String(msg.src || ""), String(msg.stagedId || ""));
1507
+ break;
1508
+ case "eh:assetFailed":
1509
+ pendingPastes.delete(msg.id);
1510
+ break;
1511
+ default:
1512
+ break;
1513
+ }
1514
+ });
1515
+
1516
+ post("eh:ready", { scrollHeight: document.body.scrollHeight });
1517
+ }
1518
+
1519
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
1520
+ else boot();