@erdemtuna/doc-review 0.8.1 → 0.10.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.
Files changed (82) hide show
  1. package/README.md +52 -137
  2. package/{src → lib}/SKILL.md +28 -4
  3. package/lib/anchor-text.js +155 -0
  4. package/lib/atomic-write.js +54 -0
  5. package/lib/chrome-api.js +78 -0
  6. package/lib/chrome-client.js +2255 -0
  7. package/lib/chrome-session.js +76 -0
  8. package/{src → lib}/chrome.css +253 -8
  9. package/lib/chrome.html +196 -0
  10. package/lib/cli.js +255 -0
  11. package/lib/click-target.js +28 -0
  12. package/lib/comment-anchor.js +23 -0
  13. package/lib/comment-target.js +164 -0
  14. package/lib/comparison-view.js +380 -0
  15. package/lib/contracts/feedback.js +1 -0
  16. package/lib/contracts/frame.js +7 -0
  17. package/lib/contracts/history.js +1 -0
  18. package/lib/contracts/index.js +2 -0
  19. package/lib/contracts/page.js +23 -0
  20. package/lib/document-execution.js +115 -0
  21. package/lib/document-trust.js +2 -0
  22. package/lib/edit-limits.js +24 -0
  23. package/lib/editing.js +107 -0
  24. package/lib/execution-client.js +69 -0
  25. package/lib/feedback-controller.js +106 -0
  26. package/lib/frame-channel.js +42 -0
  27. package/lib/frame-controller.js +313 -0
  28. package/lib/frame-host.js +136 -0
  29. package/lib/frame-policy.js +50 -0
  30. package/lib/history-client.js +112 -0
  31. package/lib/history-coordinator.js +210 -0
  32. package/lib/history-policy.js +43 -0
  33. package/lib/history-server.js +464 -0
  34. package/lib/html-transform.js +52 -0
  35. package/lib/icons.js +249 -0
  36. package/{src → lib}/markdown.js +25 -26
  37. package/lib/paths.js +83 -0
  38. package/lib/poll-transport.js +220 -0
  39. package/lib/positioning.js +97 -0
  40. package/lib/review-controller.js +93 -0
  41. package/lib/review-mode.js +66 -0
  42. package/lib/revision-diff.js +482 -0
  43. package/lib/revision-schema.js +235 -0
  44. package/lib/revision-store.js +190 -0
  45. package/lib/save-controller.js +300 -0
  46. package/lib/sdk.js +2629 -0
  47. package/lib/semantic-snapshot.js +268 -0
  48. package/lib/serialize.js +32 -0
  49. package/lib/server-entry.js +26 -0
  50. package/lib/server-lock.js +109 -0
  51. package/lib/server.js +1501 -0
  52. package/lib/setup-guidance.js +119 -0
  53. package/{src → lib}/setup.js +44 -55
  54. package/lib/state.js +733 -0
  55. package/lib/view-identity.js +112 -0
  56. package/package.json +24 -12
  57. package/src/anchor-text.js +0 -160
  58. package/src/atomic-write.js +0 -53
  59. package/src/chrome-client.js +0 -2006
  60. package/src/chrome-session.js +0 -85
  61. package/src/chrome.html +0 -129
  62. package/src/cli.js +0 -267
  63. package/src/click-target.js +0 -26
  64. package/src/comment-anchor.js +0 -22
  65. package/src/comment-target.js +0 -77
  66. package/src/edit-limits.js +0 -23
  67. package/src/editing.js +0 -99
  68. package/src/frame-channel.js +0 -44
  69. package/src/frame-policy.js +0 -16
  70. package/src/html-transform.js +0 -62
  71. package/src/icons.js +0 -251
  72. package/src/paths.js +0 -91
  73. package/src/poll-transport.js +0 -222
  74. package/src/positioning.js +0 -107
  75. package/src/review-mode.js +0 -59
  76. package/src/sdk.js +0 -2243
  77. package/src/serialize.js +0 -32
  78. package/src/server-entry.js +0 -26
  79. package/src/server-lock.js +0 -101
  80. package/src/server.js +0 -1298
  81. package/src/setup-guidance.js +0 -128
  82. package/src/state.js +0 -507
package/src/sdk.js DELETED
@@ -1,2243 +0,0 @@
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 { acceptedOpenGeneration, targetMessage } from "./comment-target.js";
11
- import { classifyHref, externalHref, linkStyleFixup, listCommandFor, listStyleFixup, normalizeHref } from "./editing.js";
12
- import { frameMessage, initializeChannelFromDocument, matchesFrameMessage } from "./frame-channel.js";
13
- import { iconMarkup } from "./icons.js";
14
- import { keepBodyInReviewMode } from "./review-mode.js";
15
- import { serializeDocument, UI_ATTR, MARK_ATTR } from "./serialize.js";
16
-
17
- initializeChannelFromDocument();
18
-
19
- const SAVE_DEBOUNCE_MS = 700;
20
- const EDIT_FLUSH_MS = 500;
21
- const MEDIA = /^(img|svg|canvas|video|picture|iframe|hr|figure)$/i;
22
-
23
- // The chrome page lives on the other loopback hostname (a separate origin, so
24
- // the reviewed document can never touch it directly). Address it explicitly so
25
- // nothing we post can be read by any other embedder.
26
- const CHROME_ORIGIN = `${location.protocol}//${location.hostname === "127.0.0.1" ? "localhost" : "127.0.0.1"}:${location.port}`;
27
-
28
- const post = (type, payload) => parent.postMessage(frameMessage(type, payload), CHROME_ORIGIN);
29
-
30
- let pending = null; // semantic target plus cloned range/element until commit or cancel
31
- let retarget = null;
32
- let targetGeneration = 0;
33
- let targetObserver = null;
34
- let targetIntersection = null;
35
- let reviewMode = "view";
36
- let savePolicy = "writable";
37
- let modeController = null;
38
- let hoverTarget = null;
39
- let hoverMove = null; // the innermost block under the cursor, movable via the handle
40
- let hoverMedia = null; // the img/video under the cursor, resizable via the grip
41
- let resizing = null; // live drag state while the grip is held
42
- let suppressUntil = 0; // ignore the mouseup/click that ends a resize drag
43
- let saveTimer = null;
44
- let composeOpen = false;
45
- let commentOpenRequestGeneration = null;
46
- let activeCommentId = null;
47
- let modeMenuOpen = false;
48
- let lastTargetGeometrySignature = "";
49
- let lastTargetRelation = null;
50
- let geometryWatchTimer = null;
51
- let watchedGeometrySignature = "";
52
- let selectionTimer = null;
53
- /** True when the page's own scripts rewrote the DOM before any user edit. */
54
- let dynamic = false;
55
-
56
- const diagnostic = (event, detail = {}) => {
57
- console.info("[doc-review-frame]", { event, ...detail });
58
- };
59
-
60
- // ------------------------------------------------------------------ overlay
61
-
62
- const host = document.createElement("div");
63
- host.setAttribute(UI_ATTR, "");
64
- const shadow = host.attachShadow({ mode: "open" });
65
- shadow.innerHTML = `
66
- <style>
67
- :host { all: initial; }
68
- .box { position: fixed; pointer-events: none; z-index: 2147483646; border-radius: 3px; display: none; }
69
- .outline { border: 1px solid #c2beb4; }
70
- .active { border: 1px solid #1b1a16; }
71
- .chips {
72
- position: fixed; z-index: 2147483647; display: none; gap: 4px;
73
- pointer-events: auto;
74
- }
75
- .chip {
76
- width: 23px; height: 23px; display: flex;
77
- align-items: center; justify-content: center; padding: 0;
78
- border: 1px solid #e4e2db; border-radius: 50%; background: #fff; color: #6b6862;
79
- font: 11px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
80
- cursor: pointer; box-shadow: 0 2px 8px rgba(27,26,22,.14);
81
- }
82
- .chip:hover { background: #1b1a16; color: #fff; border-color: #1b1a16; }
83
- .chip.danger { color: #b23b2e; }
84
- .chip.danger:hover { background: #b23b2e; color: #fff; border-color: #b23b2e; }
85
- .chips .chip, .mover { opacity: .6; transition: opacity 100ms ease; }
86
- .chips .chip:hover, .chips .chip:focus-visible, .mover:hover, .mover:focus-visible { opacity: 1; }
87
- .grip {
88
- position: fixed; z-index: 2147483647; width: 13px; height: 13px;
89
- display: none; border: 1px solid #1b1a16; border-radius: 3px;
90
- background: #fff; cursor: nwse-resize; pointer-events: auto;
91
- box-shadow: 0 1px 4px rgba(27,26,22,.2);
92
- }
93
- .hint {
94
- position: fixed; z-index: 2147483647; display: none; padding: 3px 7px;
95
- border-radius: 5px; background: #1b1a16; color: #fbfaf7; white-space: nowrap;
96
- font: 11px/1.3 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
97
- pointer-events: none;
98
- }
99
- .linkbox {
100
- position: fixed; z-index: 2147483647; display: none; gap: 4px; align-items: center;
101
- padding: 5px; border: 1px solid #e4e2db; border-radius: 9px; background: #fff;
102
- box-shadow: 0 4px 16px rgba(27,26,22,.16); pointer-events: auto;
103
- }
104
- .linkbox input {
105
- width: 224px; padding: 4px 7px; border: none; outline: none; background: none;
106
- font: 12px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #1b1a16;
107
- }
108
- .linkbox input::placeholder { color: #a29f95; }
109
- .mover {
110
- position: fixed; z-index: 2147483647; width: 18px; height: 24px;
111
- display: none; align-items: center; justify-content: center;
112
- border: 1px solid #e4e2db; border-radius: 6px; background: #fff; color: #a29f95;
113
- font: 12px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
114
- cursor: grab; pointer-events: auto; user-select: none;
115
- box-shadow: 0 2px 8px rgba(27,26,22,.14);
116
- }
117
- .mover:hover { color: #1b1a16; border-color: #c2beb4; }
118
- .mover:active { cursor: grabbing; }
119
- .dropline {
120
- position: fixed; z-index: 2147483646; height: 0; display: none;
121
- border-top: 2px solid #1b1a16; border-radius: 1px; pointer-events: none;
122
- }
123
- .comment-action {
124
- position: fixed; z-index: 2147483647; width: 30px; height: 30px;
125
- display: none; align-items: center; justify-content: center; padding: 0;
126
- border: 1px solid #d9d5c9; border-radius: 999px; background: #1b1a16; color: #fff;
127
- cursor: pointer; pointer-events: auto; box-shadow: 0 5px 16px rgba(27,26,22,.24);
128
- }
129
- .comment-action:hover, .comment-action:focus-visible { background: #34312b; outline: 3px solid rgba(90,99,216,.32); }
130
- .selection-cues { position: fixed; inset: 0; pointer-events: none; z-index: 2147483645; }
131
- .selection-cue { position: fixed; border-radius: 2px; background: rgba(245,196,0,.2); }
132
- @media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
133
- </style>
134
- <div class="box outline" id="outline"></div>
135
- <div class="box active" id="activeBox"></div>
136
- <div class="chips" id="chips">
137
- <button class="chip danger" id="chipDelete" title="Delete this block" aria-label="Delete this block">${iconMarkup("trash", { size: 14 })}</button>
138
- </div>
139
- <div class="grip" id="grip" title="Drag to resize"></div>
140
- <div class="hint" id="hint"></div>
141
- <div class="linkbox" id="linkbox">
142
- <input id="linkInput" type="text" placeholder="Link to&hellip;" spellcheck="false">
143
- <button class="chip" id="linkApply" title="Apply link" aria-label="Apply link">${iconMarkup("check", { size: 14 })}</button>
144
- <button class="chip danger" id="linkRemove" title="Remove link" aria-label="Remove link">${iconMarkup("x", { size: 14 })}</button>
145
- </div>
146
- <div class="mover" id="mover" title="Drag to move this block">&#10303;</div>
147
- <div class="dropline" id="dropline"></div>
148
- <div class="selection-cues" id="selectionCues"></div>
149
- <button class="comment-action" id="commentAction" title="Comment on this target" aria-label="Comment on this target">${iconMarkup("messageSquarePlus", { size: 17 })}</button>
150
- `;
151
-
152
- const els = {};
153
- const mountOverlay = () => {
154
- if (!host.isConnected) document.documentElement.appendChild(host);
155
- els.outline = shadow.getElementById("outline");
156
- els.activeBox = shadow.getElementById("activeBox");
157
- els.chips = shadow.getElementById("chips");
158
- els.chipDelete = shadow.getElementById("chipDelete");
159
- els.grip = shadow.getElementById("grip");
160
- els.hint = shadow.getElementById("hint");
161
- els.linkbox = shadow.getElementById("linkbox");
162
- els.linkInput = shadow.getElementById("linkInput");
163
- els.linkApply = shadow.getElementById("linkApply");
164
- els.linkRemove = shadow.getElementById("linkRemove");
165
- els.mover = shadow.getElementById("mover");
166
- els.dropline = shadow.getElementById("dropline");
167
- els.selectionCues = shadow.getElementById("selectionCues");
168
- els.commentAction = shadow.getElementById("commentAction");
169
- };
170
-
171
- function showMover(el) {
172
- if (!el || !el.isConnected) {
173
- els.mover.style.display = "none";
174
- return;
175
- }
176
- const r = el.getBoundingClientRect();
177
- if (!r.width && !r.height) {
178
- els.mover.style.display = "none";
179
- return;
180
- }
181
- els.mover.style.display = "flex";
182
- // Half-overlap the block's edge: the pointer can travel from text to handle
183
- // without ever leaving the block, so the hover state never drops.
184
- els.mover.style.left = `${Math.max(4, r.left - 9)}px`;
185
- els.mover.style.top = `${Math.max(4, r.top + 1)}px`;
186
- }
187
-
188
- function showDropline(drop) {
189
- if (!drop || !drop.ref.isConnected) {
190
- els.dropline.style.display = "none";
191
- return;
192
- }
193
- const r = drop.ref.getBoundingClientRect();
194
- els.dropline.style.display = "block";
195
- els.dropline.style.left = `${r.left}px`;
196
- els.dropline.style.width = `${r.width}px`;
197
- els.dropline.style.top = `${(drop.before ? r.top : r.bottom) - 1}px`;
198
- }
199
-
200
- function place(box, el, pad = 2) {
201
- if (!el || !el.isConnected) {
202
- box.style.display = "none";
203
- return;
204
- }
205
- const r = el.getBoundingClientRect();
206
- if (!r.width && !r.height) {
207
- box.style.display = "none";
208
- return;
209
- }
210
- box.style.display = "block";
211
- box.style.left = `${r.left - pad}px`;
212
- box.style.top = `${r.top - pad}px`;
213
- box.style.width = `${r.width + pad * 2}px`;
214
- box.style.height = `${r.height + pad * 2}px`;
215
- }
216
-
217
- function showChip(el) {
218
- if (!el || !el.isConnected) {
219
- els.chips.style.display = "none";
220
- return;
221
- }
222
- const r = el.getBoundingClientRect();
223
- // A target in a collapsed tab has no box; don't strand the chips in a corner.
224
- if (!r.width && !r.height) {
225
- els.chips.style.display = "none";
226
- return;
227
- }
228
- els.chips.style.display = "flex";
229
- els.chips.style.left = `${Math.max(4, r.right - 11)}px`;
230
- els.chips.style.top = `${Math.max(4, r.top - 11)}px`;
231
- }
232
-
233
- function showGrip(el) {
234
- if (!el || !el.isConnected) {
235
- els.grip.style.display = "none";
236
- return;
237
- }
238
- const r = el.getBoundingClientRect();
239
- if (!r.width && !r.height) {
240
- els.grip.style.display = "none";
241
- return;
242
- }
243
- els.grip.style.display = "block";
244
- els.grip.style.left = `${r.right - 7}px`;
245
- els.grip.style.top = `${r.bottom - 7}px`;
246
- }
247
-
248
- function showHint(text, x, y) {
249
- if (!text) {
250
- els.hint.style.display = "none";
251
- return;
252
- }
253
- els.hint.textContent = text;
254
- els.hint.style.display = "block";
255
- els.hint.style.left = `${x + 12}px`;
256
- els.hint.style.top = `${y + 16}px`;
257
- }
258
-
259
- const rectData = (rect) => ({
260
- left: rect.left,
261
- top: rect.top,
262
- right: rect.right,
263
- bottom: rect.bottom,
264
- width: rect.width,
265
- height: rect.height,
266
- });
267
-
268
- const viewportData = () => ({ width: window.innerWidth, height: window.innerHeight });
269
-
270
- function intersectRects(one, two) {
271
- const left = Math.max(one.left, two.left);
272
- const top = Math.max(one.top, two.top);
273
- const right = Math.min(one.right, two.right);
274
- const bottom = Math.min(one.bottom, two.bottom);
275
- if (right <= left || bottom <= top) return null;
276
- return { left, top, right, bottom, width: right - left, height: bottom - top };
277
- }
278
-
279
- function targetRects(target = pending) {
280
- if (!target) return [];
281
- if (target.kind === "selection" && target.range) {
282
- return [...target.range.getClientRects()].map(rectData);
283
- }
284
- if (target.element?.isConnected) return [rectData(target.element.getBoundingClientRect())];
285
- return [];
286
- }
287
-
288
- function targetElement(target) {
289
- if (!target) return null;
290
- if (target.kind === "element") return target.element || null;
291
- const node = target.range?.commonAncestorContainer;
292
- return node?.nodeType === 1 ? node : node?.parentElement || null;
293
- }
294
-
295
- function targetConnected(target) {
296
- if (!target) return false;
297
- if (target.kind === "element") return !!target.element?.isConnected;
298
- const range = target.range;
299
- return !!(
300
- range &&
301
- document.body.contains(range.startContainer) &&
302
- document.body.contains(range.endContainer)
303
- );
304
- }
305
-
306
- function clippingStartNode(target) {
307
- const element = targetElement(target);
308
- return target?.kind === "selection" ? element : element?.parentElement;
309
- }
310
-
311
- function effectiveClipRect(target) {
312
- let clip = {
313
- left: 0,
314
- top: 0,
315
- right: window.innerWidth,
316
- bottom: window.innerHeight,
317
- width: window.innerWidth,
318
- height: window.innerHeight,
319
- };
320
- let node = clippingStartNode(target);
321
- while (node && node !== document.body && node !== document.documentElement) {
322
- const style = getComputedStyle(node);
323
- const clipsX = /(auto|scroll|hidden|clip)/.test(`${style.overflow} ${style.overflowX}`);
324
- const clipsY = /(auto|scroll|hidden|clip)/.test(`${style.overflow} ${style.overflowY}`);
325
- if (clipsX || clipsY) {
326
- const rect = node.getBoundingClientRect();
327
- const ancestorClip = {
328
- left: clipsX ? rect.left + node.clientLeft : clip.left,
329
- right: clipsX ? rect.left + node.clientLeft + node.clientWidth : clip.right,
330
- top: clipsY ? rect.top + node.clientTop : clip.top,
331
- bottom: clipsY ? rect.top + node.clientTop + node.clientHeight : clip.bottom,
332
- };
333
- ancestorClip.width = ancestorClip.right - ancestorClip.left;
334
- ancestorClip.height = ancestorClip.bottom - ancestorClip.top;
335
- clip = intersectRects(clip, ancestorClip);
336
- if (!clip) return null;
337
- }
338
- node = node.parentElement;
339
- }
340
- return clip;
341
- }
342
-
343
- function targetGeometry(target = pending) {
344
- const clip = effectiveClipRect(target);
345
- const rects = targetRects(target).filter((rect) =>
346
- ["left", "top", "right", "bottom", "width", "height"].every((key) => Number.isFinite(rect[key])) &&
347
- rect.width >= 0 &&
348
- rect.height >= 0
349
- );
350
- if (!targetConnected(target) || !clip || !rects.length) {
351
- return { relation: "unavailable", clip: clip || {
352
- left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight,
353
- width: window.innerWidth, height: window.innerHeight,
354
- }, rects: [], horizontal: target?.horizontal ?? null, rejected: targetConnected(target) ? "invalid" : "disconnected" };
355
- }
356
- if (rects.some((rect) => rect.left < clip.left - 1 || rect.right > clip.right + 1)) {
357
- return { relation: "unavailable", clip, rects: [], horizontal: target?.horizontal ?? null, rejected: "horizontally-clipped" };
358
- }
359
- if (rects.every((rect) => rect.bottom <= clip.top)) {
360
- return { relation: "above", clip, rects: [], horizontal: target?.horizontal ?? null };
361
- }
362
- if (rects.every((rect) => rect.top >= clip.bottom)) {
363
- return { relation: "below", clip, rects: [], horizontal: target?.horizontal ?? null };
364
- }
365
- const visible = rects.map((rect) => intersectRects(rect, clip)).filter(Boolean);
366
- if (!visible.length) {
367
- return { relation: "unavailable", clip, rects: [], horizontal: target?.horizontal ?? null, rejected: "unavailable" };
368
- }
369
- const attached = visible[visible.length - 1];
370
- target.horizontal = attached.right;
371
- return { relation: "visible", clip, rects: visible, horizontal: target.horizontal };
372
- }
373
-
374
- function rectVisibleThroughAncestors(rect, target) {
375
- const viewport = viewportData();
376
- if (
377
- !Number.isFinite(rect.left) ||
378
- !Number.isFinite(rect.top) ||
379
- rect.right <= 0 ||
380
- rect.bottom <= 0 ||
381
- rect.left >= viewport.width ||
382
- rect.top >= viewport.height
383
- ) return false;
384
- let node = clippingStartNode(target);
385
- while (node && node !== document.body) {
386
- const style = getComputedStyle(node);
387
- if (/(auto|scroll|hidden|clip)/.test(`${style.overflow} ${style.overflowX} ${style.overflowY}`)) {
388
- const clip = node.getBoundingClientRect();
389
- if (rect.right <= clip.left || rect.left >= clip.right || rect.bottom <= clip.top || rect.top >= clip.bottom) {
390
- return false;
391
- }
392
- }
393
- node = node.parentElement;
394
- }
395
- return true;
396
- }
397
-
398
- function visibleRects(rects, target = pending) {
399
- return rects.filter((rect) => rectVisibleThroughAncestors(rect, target));
400
- }
401
-
402
- function renderSelectionCues(rects) {
403
- els.selectionCues.textContent = "";
404
- if (!composeOpen || !pending || pending.kind !== "selection") return;
405
- for (const rect of visibleRects(rects, pending)) {
406
- const cue = document.createElement("span");
407
- cue.className = "selection-cue";
408
- cue.style.left = `${rect.left}px`;
409
- cue.style.top = `${rect.top}px`;
410
- cue.style.width = `${rect.width}px`;
411
- cue.style.height = `${rect.height}px`;
412
- els.selectionCues.append(cue);
413
- }
414
- }
415
-
416
- function positionCommentAction(rects, target = retarget || pending) {
417
- const visible = visibleRects(rects, target);
418
- const rect = visible[visible.length - 1];
419
- if (!rect || (composeOpen && !retarget)) {
420
- els.commentAction.style.display = "none";
421
- return false;
422
- }
423
- els.commentAction.style.display = "flex";
424
- els.commentAction.style.left = `${Math.max(4, Math.min(window.innerWidth - 34, rect.right + 6))}px`;
425
- els.commentAction.style.top = `${Math.max(4, Math.min(window.innerHeight - 34, rect.top - 4))}px`;
426
- return true;
427
- }
428
-
429
- function postTarget(type, target = pending) {
430
- if (!target) return;
431
- const geometry = targetGeometry(target);
432
- const message = targetMessage(
433
- {
434
- kind: target.kind,
435
- quote: target.quote,
436
- anchor: target.anchor,
437
- rects: geometry.rects,
438
- generation: target.generation,
439
- relation: geometry.relation,
440
- clip: geometry.clip,
441
- horizontal: geometry.horizontal,
442
- },
443
- viewportData()
444
- );
445
- if (!message) return;
446
- const signature = JSON.stringify({
447
- generation: message.generation,
448
- relation: message.relation,
449
- clip: message.clip,
450
- rects: message.rects,
451
- horizontal: message.horizontal,
452
- });
453
- if (type !== "eh:openComment" && signature === lastTargetGeometrySignature) return;
454
- if (type !== "eh:openComment") lastTargetGeometrySignature = signature;
455
- if (lastTargetRelation !== message.relation) {
456
- diagnostic("anchor-relation-transition", {
457
- from: lastTargetRelation,
458
- to: message.relation,
459
- targetGeneration: message.generation,
460
- });
461
- lastTargetRelation = message.relation;
462
- }
463
- if (geometry.rejected) {
464
- diagnostic("geometry-rejected", {
465
- reason: geometry.rejected,
466
- targetGeneration: message.generation,
467
- });
468
- }
469
- post(type, {
470
- kind: message.kind,
471
- quote: message.quote,
472
- anchor: message.anchor,
473
- rects: message.rects,
474
- targetGeneration: message.generation,
475
- relation: message.relation,
476
- clip: message.clip,
477
- horizontal: message.horizontal,
478
- viewport: viewportData(),
479
- });
480
- }
481
-
482
- function disconnectTargetObservers() {
483
- targetObserver?.disconnect();
484
- targetIntersection?.disconnect();
485
- targetObserver = null;
486
- targetIntersection = null;
487
- }
488
-
489
- const roundedGeometry = (value) => Number.isFinite(value) ? Math.round(value * 2) / 2 : null;
490
-
491
- function geometryWatchSignature() {
492
- const target = retarget || pending;
493
- const targetState = target ? targetGeometry(target) : null;
494
- let activeState = null;
495
- if (activeCommentId) {
496
- const marks = marksFor(activeCommentId);
497
- const element = marks[0] || document.querySelector(`[data-eh-el="${CSS.escape(activeCommentId)}"]`);
498
- if (element) {
499
- const rects = marks.length
500
- ? marks.map((mark) => rectData(mark.getBoundingClientRect()))
501
- : [rectData(element.getBoundingClientRect())];
502
- activeState = {
503
- rects,
504
- clip: effectiveClipRect({ kind: "element", element }),
505
- };
506
- }
507
- }
508
- const normalize = (state) => state && {
509
- relation: state.relation,
510
- rects: (state.rects || []).map((rect) => [
511
- roundedGeometry(rect.left),
512
- roundedGeometry(rect.top),
513
- roundedGeometry(rect.right),
514
- roundedGeometry(rect.bottom),
515
- ]),
516
- clip: state.clip && [
517
- roundedGeometry(state.clip.left),
518
- roundedGeometry(state.clip.top),
519
- roundedGeometry(state.clip.right),
520
- roundedGeometry(state.clip.bottom),
521
- ],
522
- };
523
- return JSON.stringify({
524
- target: normalize(targetState),
525
- active: normalize(activeState),
526
- });
527
- }
528
-
529
- function refreshGeometryWatch() {
530
- const shouldWatch = !!(pending || retarget || activeCommentId);
531
- if (!shouldWatch) {
532
- clearInterval(geometryWatchTimer);
533
- geometryWatchTimer = null;
534
- watchedGeometrySignature = "";
535
- return;
536
- }
537
- if (geometryWatchTimer) return;
538
- watchedGeometrySignature = geometryWatchSignature();
539
- geometryWatchTimer = setInterval(() => {
540
- if (!(pending || retarget || activeCommentId)) {
541
- refreshGeometryWatch();
542
- return;
543
- }
544
- const next = geometryWatchSignature();
545
- if (next === watchedGeometrySignature) return;
546
- watchedGeometrySignature = next;
547
- scheduleTargetGeometry();
548
- if (activeCommentId) activate(activeCommentId, false);
549
- }, 100);
550
- }
551
-
552
- function observePendingTarget(target = pending) {
553
- disconnectTargetObservers();
554
- if (!target) return;
555
- const observed = target.kind === "element"
556
- ? target.element
557
- : target.range?.commonAncestorContainer?.nodeType === 1
558
- ? target.range.commonAncestorContainer
559
- : target.range?.commonAncestorContainer?.parentElement;
560
- if (!observed) return;
561
- if ("ResizeObserver" in window) {
562
- targetObserver = new ResizeObserver(scheduleTargetGeometry);
563
- targetObserver.observe(observed);
564
- }
565
- if ("IntersectionObserver" in window) {
566
- targetIntersection = new IntersectionObserver(scheduleTargetGeometry, { threshold: [0, .01, 1] });
567
- targetIntersection.observe(observed);
568
- }
569
- refreshGeometryWatch();
570
- }
571
-
572
- let targetGeometryQueued = false;
573
- function scheduleTargetGeometry() {
574
- if (targetGeometryQueued) return;
575
- targetGeometryQueued = true;
576
- requestAnimationFrame(() => {
577
- targetGeometryQueued = false;
578
- if (!pending && !retarget) return;
579
- const actionTarget = retarget || pending;
580
- const actionRects = targetRects(actionTarget);
581
- positionCommentAction(actionRects, actionTarget);
582
- renderSelectionCues(targetRects(pending));
583
- if (composeOpen) postTarget("eh:targetGeometry", pending);
584
- else postTarget("eh:target", pending);
585
- });
586
- }
587
-
588
- // ------------------------------------------------------------ text plumbing
589
-
590
- const isOurs = (node) => {
591
- const el = node && node.nodeType === 1 ? node : node && node.parentElement;
592
- return !!(el && el.closest && el.closest(`[${UI_ATTR}]`));
593
- };
594
-
595
- /** Flatten the body's text nodes into one string plus an offset map. */
596
- function flatten() {
597
- const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
598
- acceptNode(node) {
599
- if (!node.nodeValue) return NodeFilter.FILTER_REJECT;
600
- const parent = node.parentElement;
601
- if (!parent) return NodeFilter.FILTER_REJECT;
602
- if (isOurs(parent)) return NodeFilter.FILTER_REJECT;
603
- if (/^(script|style|noscript|template)$/i.test(parent.tagName)) return NodeFilter.FILTER_REJECT;
604
- return NodeFilter.FILTER_ACCEPT;
605
- },
606
- });
607
- let text = "";
608
- const map = [];
609
- let node = walker.nextNode();
610
- while (node) {
611
- const start = text.length;
612
- text += node.nodeValue;
613
- map.push({ node, start, end: text.length });
614
- node = walker.nextNode();
615
- }
616
- return { text, map };
617
- }
618
-
619
- function offsetsFromRange(map, range) {
620
- let start = null;
621
- let end = null;
622
- for (const entry of map) {
623
- if (entry.node === range.startContainer) start = entry.start + range.startOffset;
624
- if (entry.node === range.endContainer) end = entry.start + range.endOffset;
625
- }
626
- if (start === null || end === null || end <= start) return null;
627
- return { start, end };
628
- }
629
-
630
- /** Wrap a global offset span in <mark> elements, one per text node touched. */
631
- function wrapOffsets(map, start, end, id) {
632
- const marks = [];
633
- for (const entry of map) {
634
- if (entry.end <= start || entry.start >= end) continue;
635
- const from = Math.max(0, start - entry.start);
636
- const to = Math.min(entry.node.nodeValue.length, end - entry.start);
637
- if (to <= from) continue;
638
- const range = document.createRange();
639
- try {
640
- range.setStart(entry.node, from);
641
- range.setEnd(entry.node, to);
642
- const mark = document.createElement("mark");
643
- mark.setAttribute(MARK_ATTR, id);
644
- range.surroundContents(mark);
645
- marks.push(mark);
646
- } catch {
647
- // A node that shifted under us is skipped rather than corrupting the DOM.
648
- }
649
- }
650
- return marks;
651
- }
652
-
653
- function marksFor(id) {
654
- return [...document.querySelectorAll(`mark[${MARK_ATTR}="${CSS.escape(id)}"]`)];
655
- }
656
-
657
- function unwrap(id) {
658
- for (const mark of marksFor(id)) {
659
- const parent = mark.parentNode;
660
- if (!parent) continue;
661
- while (mark.firstChild) parent.insertBefore(mark.firstChild, mark);
662
- parent.removeChild(mark);
663
- parent.normalize();
664
- }
665
- }
666
-
667
- // ------------------------------------------------------- target resolution
668
-
669
- function isBlock(el) {
670
- if (!el || el.nodeType !== 1) return false;
671
- const display = getComputedStyle(el).display;
672
- return /^(block|flex|grid|list-item|table|flow-root)$/.test(display);
673
- }
674
-
675
- function hasOwnText(el) {
676
- return [...el.childNodes].some((n) => n.nodeType === 3 && n.nodeValue.trim());
677
- }
678
-
679
- /** A body-anchored, fully indexed path, so it resolves to one element only. */
680
- function cssPath(el) {
681
- const parts = [];
682
- let node = el;
683
- while (node && node.nodeType === 1 && node !== document.body && parts.length < 8) {
684
- if (node.id) {
685
- parts.unshift(`#${CSS.escape(node.id)}`);
686
- return parts.join(" > ");
687
- }
688
- const tag = node.tagName.toLowerCase();
689
- const twins = node.parentElement ? [...node.parentElement.children].filter((c) => c.tagName === node.tagName) : [];
690
- parts.unshift(twins.length > 1 ? `${tag}:nth-of-type(${twins.indexOf(node) + 1})` : tag);
691
- node = node.parentElement;
692
- }
693
- return ["body", ...parts].join(" > ");
694
- }
695
-
696
- /** The heading a block sits under, used to name edits in arbitrary HTML. */
697
- function precedingHeading(el) {
698
- let node = el;
699
- while (node && node !== document.body) {
700
- let sib = node.previousElementSibling;
701
- while (sib) {
702
- if (/^h[1-6]$/i.test(sib.tagName) && sib.textContent.trim()) return sib.textContent.trim();
703
- const nested = sib.querySelectorAll ? sib.querySelectorAll("h1,h2,h3,h4,h5,h6") : [];
704
- for (let i = nested.length - 1; i >= 0; i -= 1) {
705
- if (nested[i].textContent.trim()) return nested[i].textContent.trim();
706
- }
707
- sib = sib.previousElementSibling;
708
- }
709
- node = node.parentElement;
710
- }
711
- return "";
712
- }
713
-
714
- const clip = (text, limit = 40) => {
715
- const flat = String(text || "").replace(/\s+/g, " ").trim();
716
- return flat.length > limit ? `${flat.slice(0, limit - 1).trimEnd()}…` : flat;
717
- };
718
-
719
- /**
720
- * A block's label is pinned the first time it is computed. Labels derived
721
- * from the block's own text or position would drift as the user types or
722
- * deletes siblings, splitting one block's edit into several contradictory
723
- * rows keyed on each intermediate wording.
724
- */
725
- const pinnedLabels = new WeakMap();
726
-
727
- /** The block a hover/edit belongs to, plus a human label for the edit list. */
728
- function targetFor(node) {
729
- const el = node && node.nodeType === 1 ? node : node && node.parentElement;
730
- if (!el || isOurs(el)) return null;
731
-
732
- const authored = el.closest("[data-container],[data-block]");
733
- if (authored) {
734
- const label = authored.getAttribute("data-container") || authored.getAttribute("data-block");
735
- return { el: authored, label: clip(label), authored: true };
736
- }
737
-
738
- let block = el;
739
- while (block && block !== document.body && !isBlock(block)) block = block.parentElement;
740
- if (!block || block === document.body || !block.textContent.trim()) {
741
- if (el !== document.body && MEDIA.test(el.tagName)) block = el;
742
- else return null;
743
- }
744
-
745
- if (!pinnedLabels.has(block)) {
746
- const heading = precedingHeading(block);
747
- const tag = block.tagName.toLowerCase();
748
- // Siblings of the same tag would otherwise share a label and collapse into
749
- // one edit row, so number them.
750
- const twins = block.parentElement ? [...block.parentElement.children].filter((c) => c.tagName === block.tagName) : [];
751
- const ordinal = twins.length > 1 ? ` ${twins.indexOf(block) + 1}` : "";
752
- pinnedLabels.set(block, heading ? `${clip(heading, 26)} · ${tag}${ordinal}` : clip(block.textContent, 40) || tag);
753
- }
754
- return { el: block, label: pinnedLabels.get(block), authored: false };
755
- }
756
-
757
- function commentTargetFor(node) {
758
- const el = node && node.nodeType === 1 ? node : node && node.parentElement;
759
- const control = el?.closest?.("a[href], button, summary, input, select, textarea, [role='button'], [role='tab']");
760
- if (control && !isOurs(control)) {
761
- const label =
762
- control.getAttribute("aria-label") ||
763
- control.getAttribute("title") ||
764
- clip(control.textContent, 80) ||
765
- control.tagName.toLowerCase();
766
- return { el: control, label, authored: false };
767
- }
768
- return targetFor(node);
769
- }
770
-
771
- /**
772
- * The nearest rendered block around a node, ignoring authored data-block
773
- * containers. List conversion and drop targeting need the line the caret is
774
- * actually on, not the labeled region it reports edits under.
775
- */
776
- function innermostBlock(node) {
777
- let el = node && node.nodeType === 1 ? node : node && node.parentElement;
778
- while (el && el !== document.body && !isBlock(el)) el = el.parentElement;
779
- return el && el !== document.body ? el : null;
780
- }
781
-
782
- /**
783
- * After a list command: hoist the fresh list out of the paragraph Chrome
784
- * sometimes nests it in (invalid HTML that a reparse would restructure), and
785
- * make sure the page's CSS reset can't hide it.
786
- */
787
- function polishNewList(sel) {
788
- const node = sel && sel.anchorNode;
789
- const el = node && (node.nodeType === 1 ? node : node.parentElement);
790
- const item = el && el.closest ? el.closest("li") : null;
791
- const list = item ? item.closest("ul, ol") : null;
792
- if (!list) return;
793
- const wrap = list.parentElement;
794
- if (wrap && /^(p|h[1-6])$/i.test(wrap.tagName) && wrap.childNodes.length === 1) {
795
- wrap.replaceWith(list);
796
- }
797
- const patch = listStyleFixup(list.tagName, getComputedStyle(list));
798
- if (patch.listStyleType) list.style.listStyleType = patch.listStyleType;
799
- if (patch.paddingLeft) list.style.paddingLeft = patch.paddingLeft;
800
- }
801
-
802
- /** The caret position a drag at (x, y) would drop into. */
803
- function caretRangeAt(x, y) {
804
- if (document.caretRangeFromPoint) return document.caretRangeFromPoint(x, y);
805
- if (document.caretPositionFromPoint) {
806
- const pos = document.caretPositionFromPoint(x, y);
807
- if (!pos) return null;
808
- const range = document.createRange();
809
- range.setStart(pos.offsetNode, pos.offset);
810
- return range;
811
- }
812
- return null;
813
- }
814
-
815
- // ------------------------------------------------------------ serialization
816
-
817
- const serialize = () => serializeDocument(document);
818
-
819
- /**
820
- * A single block's HTML with every review artifact removed. outerHTML, not
821
- * innerHTML: changes to the block's own attributes (an image's new width, for
822
- * one) live on the element itself, and a void element has no inner markup.
823
- */
824
- function blockHtml(el) {
825
- const clone = el.cloneNode(true);
826
- clone.querySelectorAll(`[${UI_ATTR}]`).forEach((n) => n.remove());
827
- clone.querySelectorAll(`mark[${MARK_ATTR}]`).forEach((mark) => {
828
- const parent = mark.parentNode;
829
- if (!parent) return;
830
- while (mark.firstChild) parent.insertBefore(mark.firstChild, mark);
831
- parent.removeChild(mark);
832
- });
833
- clone.querySelectorAll("[data-eh-el]").forEach((n) => n.removeAttribute("data-eh-el"));
834
- clone.removeAttribute("data-eh-el");
835
- clone.normalize();
836
- return clone.outerHTML;
837
- }
838
-
839
- /**
840
- * Serialization is lossy about formatting, so a save that would not change the
841
- * document is skipped outright. Opening a file must never rewrite it.
842
- */
843
- let baseline = null;
844
- /** What the document looked like at boot, before any user edit. */
845
- let bootSnapshot = null;
846
-
847
- /**
848
- * Compare the live document against the file on disk (parsed the same way,
849
- * without running its scripts). A mismatch means the page renders itself —
850
- * writing the live DOM back would bake that output into the file, so saves
851
- * are disabled and edits travel to the agent as feedback only.
852
- */
853
- function checkDynamic(diskHtml) {
854
- try {
855
- const parsed = new DOMParser().parseFromString(diskHtml, "text/html");
856
- if (serializeDocument(parsed) !== bootSnapshot) markDynamic();
857
- } catch {
858
- // If the comparison itself fails, keep saving as normal.
859
- }
860
- }
861
-
862
- function markDynamic() {
863
- if (dynamic) return;
864
- dynamic = true;
865
- clearTimeout(saveTimer);
866
- post("eh:dynamic", {});
867
- }
868
-
869
- /** True once the user has actually edited, as opposed to the page's own scripts. */
870
- let userEdited = false;
871
-
872
- /**
873
- * The boot comparison only catches scripts that rewrote the DOM before this
874
- * module ran. Pages that render on load or after a fetch (charts, mermaid,
875
- * client-rendered apps) mutate later — watch for any serialization drift that
876
- * happens before the first real user edit and treat it the same way.
877
- */
878
- function watchSelfRendering() {
879
- const observer = new MutationObserver(() => {
880
- if (dynamic || userEdited) {
881
- observer.disconnect();
882
- return;
883
- }
884
- if (serialize() !== bootSnapshot) {
885
- observer.disconnect();
886
- markDynamic();
887
- }
888
- });
889
- observer.observe(document.documentElement, { childList: true, subtree: true, characterData: true, attributes: true });
890
- }
891
-
892
- function emitSave() {
893
- if (reviewMode !== "edit") return;
894
- if (savePolicy === "feedback-only" || dynamic) {
895
- post("eh:dynamic", {});
896
- return;
897
- }
898
- const html = serialize();
899
- if (html === baseline) {
900
- post("eh:clean", {});
901
- return;
902
- }
903
- baseline = html;
904
- post("eh:html", { html });
905
- }
906
-
907
- function scheduleSave() {
908
- if (reviewMode !== "edit") return;
909
- clearTimeout(saveTimer);
910
- if (savePolicy === "writable" && !dynamic) post("eh:saving", {});
911
- saveTimer = setTimeout(emitSave, SAVE_DEBOUNCE_MS);
912
- }
913
-
914
- function flushSave() {
915
- clearTimeout(saveTimer);
916
- flushEdits();
917
- emitSave();
918
- }
919
-
920
- // ------------------------------------------------------------- edit tracking
921
-
922
- /**
923
- * One POST per keystroke would hammer the server (each save rewrites the whole
924
- * state file), so edits queue up and flush together. The store dedupes on
925
- * label+kind and only the latest `after` matters, so coalescing loses nothing.
926
- */
927
- const editQueue = new Map();
928
- let editTimer = null;
929
-
930
- function flushEdits() {
931
- clearTimeout(editTimer);
932
- editTimer = null;
933
- for (const payload of editQueue.values()) post("eh:edit", payload);
934
- editQueue.clear();
935
- }
936
-
937
- function queueEdit(payload) {
938
- if (reviewMode !== "edit") return;
939
- const key = `${payload.label}\u0000${payload.kind}`;
940
- const queued = editQueue.get(key);
941
- if (queued && (queued.staged_assets || payload.staged_assets)) {
942
- const assets = [...(queued.staged_assets || []), ...(payload.staged_assets || [])];
943
- payload.staged_assets = [...new Map(assets.map((asset) => [asset.path || asset.id, asset])).values()];
944
- }
945
- editQueue.set(key, payload);
946
- clearTimeout(editTimer);
947
- editTimer = setTimeout(flushEdits, EDIT_FLUSH_MS);
948
- }
949
-
950
- // ------------------------------------------------------------- interactions
951
-
952
- function clearPending({ keepRetarget = false } = {}) {
953
- commentOpenRequestGeneration = null;
954
- pending = null;
955
- if (!keepRetarget) retarget = null;
956
- lastTargetGeometrySignature = "";
957
- lastTargetRelation = null;
958
- disconnectTargetObservers();
959
- place(els.activeBox, null);
960
- els.commentAction.style.display = "none";
961
- els.selectionCues.textContent = "";
962
- refreshGeometryWatch();
963
- }
964
-
965
- function restoreTargetFocus(target) {
966
- if (!target) return;
967
- if (target.kind === "element" && target.element?.isConnected) {
968
- const element = target.element;
969
- element.focus({ preventScroll: true });
970
- requestAnimationFrame(() => {
971
- if (element.isConnected && document.activeElement !== element) {
972
- element.focus({ preventScroll: true });
973
- }
974
- });
975
- return;
976
- }
977
- const range = target.range;
978
- if (!range || !document.body.contains(range.commonAncestorContainer)) return;
979
- const focusTarget = targetElement(target);
980
- if (focusTarget?.matches?.("a[href], button, input, select, textarea, summary, [tabindex]")) {
981
- focusTarget.focus({ preventScroll: true });
982
- } else if (reviewMode === "edit") {
983
- document.body.focus({ preventScroll: true });
984
- } else {
985
- window.focus();
986
- }
987
- const selection = document.getSelection();
988
- selection.removeAllRanges();
989
- selection.addRange(range);
990
- }
991
-
992
- /**
993
- * Remember a settled selection without opening composition. The explicit
994
- * contextual action or keyboard shortcut owns that transition.
995
- */
996
- function settleSelection() {
997
- const sel = document.getSelection();
998
- if (!sel || sel.isCollapsed || !sel.rangeCount) return false;
999
- const range = sel.getRangeAt(0);
1000
- if (!document.body.contains(range.commonAncestorContainer)) return false;
1001
- if (isOurs(range.commonAncestorContainer)) return false;
1002
- const quote = sel.toString();
1003
- if (!quote.trim()) return false;
1004
-
1005
- const { text, map } = flatten();
1006
- const offsets = offsetsFromRange(map, range);
1007
- if (!offsets) return false;
1008
-
1009
- const context = buildContext(text, offsets.start, offsets.end);
1010
- targetGeneration += 1;
1011
- const target = {
1012
- kind: "selection",
1013
- quote,
1014
- anchor: { ...context, selector: cssPath(range.commonAncestorContainer.parentElement || document.body) },
1015
- context,
1016
- range: range.cloneRange(),
1017
- generation: targetGeneration,
1018
- };
1019
- if (composeOpen && pending) retarget = target;
1020
- else {
1021
- pending = target;
1022
- retarget = null;
1023
- }
1024
- observePendingTarget(target);
1025
- scheduleTargetGeometry();
1026
- return true;
1027
- }
1028
-
1029
- function setElementTarget(container) {
1030
- if (
1031
- !container?.el ||
1032
- (pending && pending.kind === "element" && pending.element === container.el) ||
1033
- (retarget && retarget.kind === "element" && retarget.element === container.el)
1034
- ) {
1035
- if (pending || retarget) scheduleTargetGeometry();
1036
- return;
1037
- }
1038
- targetGeneration += 1;
1039
- const next = {
1040
- kind: "element",
1041
- element: container.el,
1042
- quote: container.label,
1043
- anchor: { selector: cssPath(container.el), label: container.label },
1044
- generation: targetGeneration,
1045
- };
1046
- if (composeOpen && pending) retarget = next;
1047
- else {
1048
- pending = next;
1049
- retarget = null;
1050
- }
1051
- observePendingTarget(next);
1052
- scheduleTargetGeometry();
1053
- }
1054
-
1055
- function openPendingCompose() {
1056
- if (!pending && !retarget) {
1057
- if (!settleSelection()) return false;
1058
- }
1059
- const target = retarget || pending;
1060
- commentOpenRequestGeneration = target.generation;
1061
- postTarget("eh:openComment", target);
1062
- return true;
1063
- }
1064
-
1065
- function acceptCommentOpen(msg) {
1066
- const requested = Number(msg.requestedGeneration);
1067
- if (commentOpenRequestGeneration === requested) commentOpenRequestGeneration = null;
1068
- if (!msg.accepted) {
1069
- const authoritative = Number(msg.targetGeneration);
1070
- if (pending?.generation === authoritative) {
1071
- composeOpen = true;
1072
- } else if (retarget?.generation === authoritative) {
1073
- pending = retarget;
1074
- retarget = null;
1075
- composeOpen = true;
1076
- observePendingTarget(pending);
1077
- scheduleTargetGeometry();
1078
- } else {
1079
- composeOpen = false;
1080
- }
1081
- diagnostic("composer-open-rejected", { targetGeneration: requested });
1082
- return;
1083
- }
1084
- if (Number(msg.targetGeneration) !== requested) {
1085
- diagnostic("geometry-rejected", { reason: "stale", targetGeneration: requested });
1086
- return;
1087
- }
1088
- const acceptedGeneration = acceptedOpenGeneration(
1089
- {
1090
- pendingGeneration: pending?.generation || null,
1091
- retargetGeneration: retarget?.generation || null,
1092
- },
1093
- { accepted: true, requestedGeneration: requested }
1094
- );
1095
- const candidate = retarget?.generation === acceptedGeneration
1096
- ? retarget
1097
- : pending?.generation === acceptedGeneration
1098
- ? pending
1099
- : null;
1100
- if (!candidate) {
1101
- diagnostic("geometry-rejected", { reason: "stale", targetGeneration: requested });
1102
- return;
1103
- }
1104
- pending = candidate;
1105
- retarget = null;
1106
- composeOpen = true;
1107
- lastTargetGeometrySignature = "";
1108
- observePendingTarget(pending);
1109
- positionCommentAction([]);
1110
- renderSelectionCues(targetRects(pending));
1111
- scheduleTargetGeometry();
1112
- }
1113
-
1114
- function deactivateComment() {
1115
- activeCommentId = null;
1116
- for (const mark of document.querySelectorAll(`mark[${MARK_ATTR}]`)) mark.classList.remove("eh-active");
1117
- place(els.activeBox, null);
1118
- refreshGeometryWatch();
1119
- }
1120
-
1121
- function revealTarget(generation) {
1122
- diagnostic("reveal-target-requested", { targetGeneration: generation });
1123
- if (!composeOpen || !pending || pending.generation !== generation || !targetConnected(pending)) {
1124
- diagnostic("reveal-target-failed", { targetGeneration: generation });
1125
- post("eh:revealTargetResult", { targetGeneration: generation, success: false });
1126
- return;
1127
- }
1128
- const target = pending.kind === "element"
1129
- ? pending.element
1130
- : pending.range.startContainer.nodeType === Node.ELEMENT_NODE
1131
- ? pending.range.startContainer
1132
- : pending.range.startContainer.parentElement;
1133
- if (!target?.isConnected) {
1134
- diagnostic("reveal-target-failed", { targetGeneration: generation });
1135
- post("eh:revealTargetResult", { targetGeneration: generation, success: false });
1136
- return;
1137
- }
1138
- target.scrollIntoView({ block: "center", inline: "nearest", behavior: "auto" });
1139
- requestAnimationFrame(() => {
1140
- requestAnimationFrame(() => {
1141
- const authoritative = composeOpen && pending && pending.generation === generation;
1142
- const revealed = authoritative && targetGeometry(pending).relation === "visible";
1143
- scheduleTargetGeometry();
1144
- diagnostic(revealed ? "reveal-target-succeeded" : "reveal-target-failed", {
1145
- targetGeneration: generation,
1146
- });
1147
- post("eh:revealTargetResult", { targetGeneration: generation, success: revealed });
1148
- });
1149
- });
1150
- }
1151
-
1152
- /** Commit turns the remembered range into real <mark> wrappers. */
1153
- function commitPending(id, generation, { restoreFocus = false } = {}) {
1154
- if (!pending || (generation && generation !== pending.generation)) return;
1155
- if (pending.kind === "element") {
1156
- if (pending.element && pending.element.isConnected) pending.element.setAttribute("data-eh-el", id);
1157
- } else {
1158
- const { text, map } = flatten();
1159
- const hit = findQuote(text, pending.context);
1160
- if (hit) wrapOffsets(map, hit.start, hit.end, id);
1161
- }
1162
- if (restoreFocus) restoreTargetFocus(pending);
1163
- else {
1164
- const sel = document.getSelection();
1165
- if (sel) sel.removeAllRanges();
1166
- }
1167
- clearPending({ keepRetarget: true });
1168
- }
1169
-
1170
- function reanchor(comments) {
1171
- const authoritative = new Set((comments || []).map((comment) => String(comment.id)));
1172
- const staleMarks = new Set();
1173
- for (const mark of document.querySelectorAll(`mark[${MARK_ATTR}]`)) {
1174
- const id = mark.getAttribute(MARK_ATTR);
1175
- if (id && !authoritative.has(id)) staleMarks.add(id);
1176
- }
1177
- for (const id of staleMarks) unwrap(id);
1178
- for (const element of document.querySelectorAll("[data-eh-el]")) {
1179
- if (!authoritative.has(element.getAttribute("data-eh-el"))) element.removeAttribute("data-eh-el");
1180
- }
1181
- if (activeCommentId && !authoritative.has(activeCommentId)) {
1182
- const staleId = activeCommentId;
1183
- activeCommentId = null;
1184
- place(els.activeBox, null);
1185
- post("eh:commentGeometry", { id: staleId, rects: [], visible: false, viewport: viewportData() });
1186
- }
1187
-
1188
- const resolved = [];
1189
- const orphaned = [];
1190
- for (const comment of comments) {
1191
- if (marksFor(comment.id).length) {
1192
- resolved.push(comment.id);
1193
- continue;
1194
- }
1195
- if (comment.kind === "element") {
1196
- const el = comment.anchor && comment.anchor.selector ? document.querySelector(comment.anchor.selector) : null;
1197
- // Re-stamp the marker, or "Jump to" has nothing to find after a reload.
1198
- if (el) el.setAttribute("data-eh-el", comment.id);
1199
- (el ? resolved : orphaned).push(comment.id);
1200
- continue;
1201
- }
1202
- const { text, map } = flatten();
1203
- const hit = comment.anchor ? findQuote(text, comment.anchor) : null;
1204
- if (!hit) {
1205
- orphaned.push(comment.id);
1206
- continue;
1207
- }
1208
- const marks = wrapOffsets(map, hit.start, hit.end, comment.id);
1209
- (marks.length ? resolved : orphaned).push(comment.id);
1210
- }
1211
- post("eh:anchorStatus", { resolved, orphaned });
1212
- }
1213
-
1214
- function activate(id, scroll) {
1215
- activeCommentId = id || null;
1216
- const marks = marksFor(id);
1217
- let target = marks[0] || null;
1218
- if (!target) {
1219
- const el = document.querySelector(`[data-eh-el="${CSS.escape(id)}"]`);
1220
- if (el) target = el;
1221
- }
1222
- for (const mark of document.querySelectorAll(`mark[${MARK_ATTR}]`)) mark.classList.remove("eh-active");
1223
- for (const mark of marks) mark.classList.add("eh-active");
1224
- place(els.activeBox, target);
1225
- const rects = marks.length
1226
- ? marks.map((mark) => rectData(mark.getBoundingClientRect()))
1227
- : target
1228
- ? [rectData(target.getBoundingClientRect())]
1229
- : [];
1230
- const visible = visibleRects(rects, { kind: "element", element: target });
1231
- post("eh:commentGeometry", {
1232
- id,
1233
- rects: visible,
1234
- visible: visible.length > 0,
1235
- viewport: viewportData(),
1236
- });
1237
- refreshGeometryWatch();
1238
- if (target && scroll) {
1239
- // Reveal a target hidden inside a collapsed tab or accordion.
1240
- let hidden = target.closest("[hidden]") || null;
1241
- if (!hidden) {
1242
- let probe = target.parentElement;
1243
- while (probe && probe !== document.body) {
1244
- if (getComputedStyle(probe).display === "none") {
1245
- hidden = probe;
1246
- break;
1247
- }
1248
- probe = probe.parentElement;
1249
- }
1250
- }
1251
- if (hidden) {
1252
- const control = hidden.id ? document.querySelector(`[aria-controls="${CSS.escape(hidden.id)}"]`) : null;
1253
- if (control) control.click();
1254
- }
1255
- const rect = target.getBoundingClientRect();
1256
- if (!rect.width && !rect.height) {
1257
- post("eh:notInView", { id });
1258
- return;
1259
- }
1260
- // scrollIntoView walks every scrollable ancestor, so targets inside an
1261
- // app's inner scroll container are reached too, not just window-scrolled ones.
1262
- target.scrollIntoView({ block: "center", behavior: "smooth" });
1263
- setTimeout(() => activate(id, false), 180);
1264
- }
1265
- }
1266
-
1267
- // -------------------------------------------------------------------- boot
1268
-
1269
- function boot() {
1270
- mountOverlay();
1271
-
1272
- const style = document.createElement("style");
1273
- style.setAttribute("data-eh-sdk", "");
1274
- style.textContent = `
1275
- mark[${MARK_ATTR}] { background: rgba(245,196,0,.32); border-radius: 2px; color: inherit; cursor: pointer; }
1276
- mark[${MARK_ATTR}]:hover { background: rgba(243,176,0,.5); }
1277
- mark[${MARK_ATTR}].eh-active { background: rgba(255,180,0,.55); }
1278
- body[contenteditable]:focus { outline: none; }
1279
- ::selection { background: rgba(245,196,0,.42); }
1280
- `;
1281
- document.head.appendChild(style);
1282
-
1283
- modeController = keepBodyInReviewMode(document.body, "view");
1284
- baseline = serialize();
1285
- bootSnapshot = baseline;
1286
- watchSelfRendering();
1287
-
1288
- const notifyChromeInteraction = (event) => {
1289
- if (isOurs(event.target)) return;
1290
- post("eh:interaction", { interaction: event.type });
1291
- };
1292
- document.addEventListener("pointerdown", notifyChromeInteraction, true);
1293
- document.addEventListener("focusin", notifyChromeInteraction, true);
1294
- window.addEventListener("focus", notifyChromeInteraction, true);
1295
-
1296
- document.addEventListener("mouseup", (event) => {
1297
- if (isOurs(event.target) || resizing || Date.now() < suppressUntil) return;
1298
- setTimeout(() => {
1299
- if (settleSelection()) return;
1300
- if (!composeOpen && pending?.kind === "selection") clearPending();
1301
- }, 0);
1302
- });
1303
-
1304
- document.addEventListener(
1305
- "click",
1306
- (event) => {
1307
- if (isOurs(event.target)) return;
1308
- const modified = event.metaKey || event.ctrlKey;
1309
- const href = navigationHref(event.target);
1310
- const mark = event.target.closest && event.target.closest(`mark[${MARK_ATTR}]`);
1311
- if (mark) {
1312
- event.preventDefault();
1313
- post("eh:activate", { id: mark.getAttribute(MARK_ATTR) });
1314
- return;
1315
- }
1316
-
1317
- if (reviewMode === "view") {
1318
- if (href) {
1319
- event.preventDefault();
1320
- event.stopPropagation();
1321
- const classification = classifyHref(href);
1322
- if (classification === "external") {
1323
- const external = externalHref(href, document.baseURI);
1324
- if (external) post("eh:external", { href: external });
1325
- }
1326
- else if (classification === "hash") {
1327
- const action = hashClickAction(href, location.hash);
1328
- if (action.kind === "scroll") {
1329
- const el = document.getElementById(action.id) || document.getElementsByName(action.id)[0] || document.body;
1330
- el.scrollIntoView({ behavior: "smooth" });
1331
- } else {
1332
- location.hash = action.hash;
1333
- }
1334
- }
1335
- else if (classification === "navigate") {
1336
- post("eh:navigate", { href });
1337
- }
1338
- }
1339
- return;
1340
- }
1341
-
1342
- if (modified) {
1343
- if (href) {
1344
- event.preventDefault();
1345
- event.stopPropagation();
1346
- const classification = classifyHref(href);
1347
- if (classification === "external") {
1348
- const external = externalHref(href, document.baseURI);
1349
- if (external) post("eh:external", { href: external });
1350
- } else if (classification === "hash") {
1351
- const action = hashClickAction(href, location.hash);
1352
- if (action.kind === "scroll") {
1353
- const el = document.getElementById(action.id) || document.getElementsByName(action.id)[0] || document.body;
1354
- el.scrollIntoView({ behavior: "smooth" });
1355
- } else {
1356
- location.hash = action.hash;
1357
- }
1358
- } else if (classification === "navigate") {
1359
- flushSave();
1360
- post("eh:navigate", { href });
1361
- }
1362
- }
1363
- return;
1364
- }
1365
-
1366
- // Edit keeps activation suppression so direct manipulation cannot navigate.
1367
- if (href || (event.target.closest && event.target.closest("button, [role='button'], summary"))) {
1368
- event.preventDefault();
1369
- event.stopPropagation();
1370
- }
1371
- },
1372
- true
1373
- );
1374
-
1375
- document.addEventListener("submit", (event) => {
1376
- if (event.defaultPrevented) return;
1377
- if (reviewMode === "edit") {
1378
- if (!event.metaKey && !event.ctrlKey) event.preventDefault();
1379
- return;
1380
- }
1381
- const form = event.target;
1382
- if (!(form instanceof HTMLFormElement)) return;
1383
- const submitter = event.submitter;
1384
- const method = String(submitter?.formMethod || form.method || "get").toLowerCase();
1385
- if (method === "dialog") return;
1386
- event.preventDefault();
1387
- if (method !== "get") {
1388
- post("eh:formBlocked", { reason: "Only GET form navigation is available in View mode." });
1389
- return;
1390
- }
1391
- const action =
1392
- submitter?.getAttribute("formaction") ??
1393
- form.getAttribute("action") ??
1394
- "";
1395
- if (!action) {
1396
- post("eh:formBlocked", { reason: "This form does not name a reviewable destination." });
1397
- return;
1398
- }
1399
- const query = new URLSearchParams();
1400
- for (const [name, value] of new FormData(form)) {
1401
- if (typeof value === "string") query.append(name, value);
1402
- }
1403
- if (submitter?.name) query.append(submitter.name, submitter.value);
1404
- const hashIndex = action.indexOf("#");
1405
- const hash = hashIndex >= 0 ? action.slice(hashIndex) : "";
1406
- const base = hashIndex >= 0 ? action.slice(0, hashIndex) : action;
1407
- const separator = base.includes("?") ? "&" : "?";
1408
- const href = query.size ? `${base}${separator}${query}${hash}` : action;
1409
- post("eh:navigate", { href });
1410
- });
1411
-
1412
- document.addEventListener("mouseover", (event) => {
1413
- if (isOurs(event.target) || resizing || moving) return;
1414
- const target = commentTargetFor(event.target);
1415
- hoverTarget = target ? target.el : null;
1416
- // The move handle works per element, not per labeled container, so each
1417
- // paragraph inside a card can travel on its own.
1418
- hoverMove = hoverTarget ? innermostBlock(event.target) || hoverTarget : null;
1419
- hoverMedia = event.target.closest ? event.target.closest("img, video") : null;
1420
- place(els.outline, hoverTarget);
1421
- const selectionActive = selectionIsActive();
1422
- if (!selectionActive && target && !composeOpen) setElementTarget(target);
1423
- if (reviewMode !== "edit" || controlsAreSuppressed()) hideActionControls();
1424
- else {
1425
- showChip(hoverTarget);
1426
- showMover(hoverMove);
1427
- }
1428
- showGrip(reviewMode === "edit" ? hoverMedia : null);
1429
- const interactive = event.target.closest && event.target.closest("a[href], [data-href], button, [role='button']");
1430
- const draggable = hoverMedia && hoverMedia.tagName === "IMG";
1431
- showHint(reviewMode === "edit" && interactive ? "⌘-click to open" : reviewMode === "edit" && draggable ? "Drag to move" : "", event.clientX, event.clientY);
1432
- });
1433
-
1434
- document.addEventListener("focusin", (event) => {
1435
- if (isOurs(event.target) || selectionIsActive() || composeOpen) return;
1436
- const target = commentTargetFor(event.target);
1437
- if (target) setElementTarget(target);
1438
- });
1439
-
1440
- document.addEventListener("mouseleave", () => {
1441
- if (resizing || moving) return;
1442
- hoverTarget = null;
1443
- hoverMove = null;
1444
- hoverMedia = null;
1445
- place(els.outline, null);
1446
- showChip(null);
1447
- showMover(null);
1448
- showGrip(null);
1449
- showHint("");
1450
- if (
1451
- !composeOpen &&
1452
- !commentOpenRequestGeneration &&
1453
- pending?.kind === "element" &&
1454
- !pending.element?.contains(document.activeElement)
1455
- ) clearPending();
1456
- });
1457
-
1458
- els.commentAction.addEventListener("pointerdown", (event) => {
1459
- event.preventDefault();
1460
- event.stopPropagation();
1461
- });
1462
-
1463
- els.commentAction.addEventListener("click", (event) => {
1464
- event.preventDefault();
1465
- event.stopPropagation();
1466
- openPendingCompose();
1467
- });
1468
-
1469
- // ------------------------------------------------------------ image resize
1470
-
1471
- els.grip.addEventListener("pointerdown", (event) => {
1472
- if (reviewMode !== "edit") return;
1473
- if (!hoverMedia || !hoverMedia.isConnected) return;
1474
- event.preventDefault();
1475
- event.stopPropagation();
1476
- // From here on, DOM changes are the human resizing, not the page rendering.
1477
- userEdited = true;
1478
- const target = targetFor(hoverMedia);
1479
- const blockEl = target ? target.el : hoverMedia;
1480
- resizing = {
1481
- el: hoverMedia,
1482
- startX: event.clientX,
1483
- startWidth: hoverMedia.getBoundingClientRect().width,
1484
- label: target ? target.label : "Image",
1485
- blockEl,
1486
- beforeText: blockEl.textContent,
1487
- beforeHtml: blockHtml(blockEl),
1488
- };
1489
- if (els.grip.setPointerCapture) els.grip.setPointerCapture(event.pointerId);
1490
- });
1491
-
1492
- window.addEventListener("pointermove", (event) => {
1493
- if (!resizing) return;
1494
- event.preventDefault();
1495
- const width = Math.max(24, Math.round(resizing.startWidth + (event.clientX - resizing.startX)));
1496
- resizing.el.style.width = `${width}px`;
1497
- resizing.el.style.height = "auto";
1498
- place(els.outline, hoverTarget);
1499
- showGrip(resizing.el);
1500
- });
1501
-
1502
- window.addEventListener("pointerup", () => {
1503
- if (!resizing) return;
1504
- const { blockEl, label, beforeText, beforeHtml } = resizing;
1505
- resizing = null;
1506
- suppressUntil = Date.now() + 250;
1507
- queueEdit({
1508
- label,
1509
- kind: "edited",
1510
- before: beforeText,
1511
- after: blockEl.textContent,
1512
- before_html: beforeHtml,
1513
- after_html: blockHtml(blockEl),
1514
- });
1515
- scheduleSave();
1516
- });
1517
-
1518
- els.chipDelete.addEventListener("click", (event) => {
1519
- event.preventDefault();
1520
- event.stopPropagation();
1521
- if (reviewMode !== "edit" || !hoverTarget) return;
1522
- userEdited = true;
1523
- const target = targetFor(hoverTarget);
1524
- const label = target ? target.label : "Element";
1525
- const before = hoverTarget.textContent;
1526
- hoverTarget.remove();
1527
- hoverTarget = null;
1528
- place(els.outline, null);
1529
- showChip(null);
1530
- queueEdit({ label, kind: "deleted", before, after: "" });
1531
- flushSave();
1532
- });
1533
-
1534
- // beforeinput still sees the untouched wording, so capture it once per block.
1535
- const originalText = new WeakMap();
1536
- const originalHtml = new WeakMap();
1537
- const captureOriginal = (el) => {
1538
- if (!originalText.has(el)) {
1539
- originalText.set(el, el.textContent);
1540
- originalHtml.set(el, blockHtml(el));
1541
- }
1542
- };
1543
-
1544
- /** An edit row for a block changed outside the input-event flow (attribute
1545
- * set, link removal, drag move) — the same shape the input listener emits. */
1546
- const emitBlockEdit = (blockEl, fallbackLabel, extra = {}) => {
1547
- const connected = blockEl.isConnected;
1548
- const target = connected ? targetFor(blockEl) : null;
1549
- queueEdit({
1550
- label: (target && target.label) || fallbackLabel || "Document body",
1551
- kind: "edited",
1552
- before: originalText.get(blockEl),
1553
- after: connected ? blockEl.textContent : "",
1554
- before_html: originalHtml.get(blockEl),
1555
- after_html: connected ? blockHtml(blockEl) : "",
1556
- ...extra,
1557
- });
1558
- };
1559
-
1560
- let typingUntil = 0;
1561
- let controlRestoreTimer = null;
1562
-
1563
- const selectionIsActive = () => {
1564
- const sel = document.getSelection();
1565
- return !!(sel && !sel.isCollapsed && sel.rangeCount && document.body.contains(sel.getRangeAt(0).commonAncestorContainer));
1566
- };
1567
-
1568
- const controlsAreSuppressed = () => Date.now() < typingUntil || selectionIsActive();
1569
-
1570
- const hideActionControls = () => {
1571
- showChip(null);
1572
- showMover(null);
1573
- };
1574
-
1575
- const restoreActionControls = () => {
1576
- if (controlsAreSuppressed()) return;
1577
- showChip(hoverTarget);
1578
- showMover(hoverMove);
1579
- };
1580
-
1581
- const suppressActionControlsWhileTyping = () => {
1582
- typingUntil = Date.now() + 800;
1583
- hideActionControls();
1584
- clearTimeout(controlRestoreTimer);
1585
- controlRestoreTimer = setTimeout(restoreActionControls, 810);
1586
- };
1587
- document.addEventListener(
1588
- "beforeinput",
1589
- (event) => {
1590
- if (isOurs(event.target) || reviewMode !== "edit") return;
1591
- suppressActionControlsWhileTyping();
1592
- // From here on, DOM drift is the human typing, not the page rendering.
1593
- userEdited = true;
1594
- const sel = document.getSelection();
1595
- const target = targetFor(sel && sel.anchorNode ? sel.anchorNode : event.target);
1596
- if (target) captureOriginal(target.el);
1597
- },
1598
- true
1599
- );
1600
-
1601
- document.addEventListener("selectionchange", () => {
1602
- clearTimeout(selectionTimer);
1603
- if (!composeOpen && selectionIsActive()) clearPending();
1604
- else els.commentAction.style.display = "none";
1605
- selectionTimer = setTimeout(() => {
1606
- if (selectionIsActive()) {
1607
- hideActionControls();
1608
- settleSelection();
1609
- } else {
1610
- restoreActionControls();
1611
- if (!composeOpen && pending?.kind === "selection") clearPending();
1612
- }
1613
- }, 40);
1614
- });
1615
-
1616
- // ------------------------------------------------------------------ links
1617
-
1618
- let linkState = null; // { range, anchor, blockEl, label } while the ⌘K popup is open
1619
-
1620
- function closeLinkbox(restoreSelection) {
1621
- if (!linkState) return;
1622
- const { range } = linkState;
1623
- linkState = null;
1624
- els.linkbox.style.display = "none";
1625
- if (restoreSelection && range) {
1626
- const sel = document.getSelection();
1627
- sel.removeAllRanges();
1628
- sel.addRange(range);
1629
- document.body.focus({ preventScroll: true });
1630
- }
1631
- }
1632
-
1633
- function openLinkbox() {
1634
- const sel = document.getSelection();
1635
- if (!sel || !sel.rangeCount) return false;
1636
- let range = sel.getRangeAt(0).cloneRange();
1637
- const container = range.commonAncestorContainer;
1638
- if (!document.body.contains(container) || isOurs(container)) return false;
1639
-
1640
- const caretEl = container.nodeType === 1 ? container : container.parentElement;
1641
- const anchor = caretEl && caretEl.closest ? caretEl.closest("a") : null;
1642
- if (sel.isCollapsed) {
1643
- // A bare caret can only mean "edit the link I'm inside".
1644
- if (!anchor) return false;
1645
- range = document.createRange();
1646
- range.selectNodeContents(anchor);
1647
- }
1648
- const target = targetFor(anchor || container);
1649
- linkState = {
1650
- range,
1651
- anchor,
1652
- blockEl: target ? target.el : null,
1653
- label: target ? target.label : "Document body",
1654
- };
1655
- if (linkState.blockEl) captureOriginal(linkState.blockEl);
1656
-
1657
- // The compose card and the link popup fight over the same selection.
1658
- clearPending();
1659
- post("eh:dismiss", {});
1660
-
1661
- const r = range.getBoundingClientRect();
1662
- const below = r.bottom + 44 < window.innerHeight;
1663
- els.linkbox.style.display = "flex";
1664
- els.linkbox.style.left = `${Math.max(8, Math.min(r.left, window.innerWidth - 320))}px`;
1665
- els.linkbox.style.top = `${below ? r.bottom + 8 : Math.max(8, r.top - 44)}px`;
1666
- els.linkRemove.style.display = anchor ? "" : "none";
1667
- els.linkInput.value = anchor ? anchor.getAttribute("href") || "" : "";
1668
- els.linkInput.focus();
1669
- els.linkInput.select();
1670
- return true;
1671
- }
1672
-
1673
- function applyLink() {
1674
- if (!linkState) return;
1675
- const href = normalizeHref(els.linkInput.value);
1676
- const { range, anchor, blockEl, label } = linkState;
1677
- if (!href) {
1678
- // Nothing typed → dismiss. Something unusable typed → stay open so the
1679
- // input isn't silently thrown away.
1680
- if (!els.linkInput.value.trim()) closeLinkbox(true);
1681
- else els.linkInput.select();
1682
- return;
1683
- }
1684
- userEdited = true;
1685
- linkState = null;
1686
- els.linkbox.style.display = "none";
1687
- if (anchor && anchor.isConnected) {
1688
- // Editing an existing link never rewraps — just retarget it.
1689
- anchor.setAttribute("href", href);
1690
- if (blockEl) emitBlockEdit(blockEl, label);
1691
- } else {
1692
- const sel = document.getSelection();
1693
- sel.removeAllRanges();
1694
- sel.addRange(range);
1695
- document.body.focus({ preventScroll: true });
1696
- document.execCommand("createLink", false, href);
1697
- // On pages that reset anchor styling the new link looks like plain
1698
- // prose — underline it so the user sees that it took.
1699
- const node = sel.anchorNode;
1700
- const el = node && (node.nodeType === 1 ? node : node.parentElement);
1701
- const created = el && el.closest ? el.closest("a") : null;
1702
- if (created) {
1703
- const patch = linkStyleFixup(
1704
- getComputedStyle(created),
1705
- created.parentElement ? getComputedStyle(created.parentElement) : null
1706
- );
1707
- if (patch.textDecoration) created.style.textDecoration = patch.textDecoration;
1708
- }
1709
- }
1710
- scheduleSave();
1711
- }
1712
-
1713
- function removeLink() {
1714
- if (!linkState) return;
1715
- const { anchor, blockEl, label } = linkState;
1716
- linkState = null;
1717
- els.linkbox.style.display = "none";
1718
- if (!anchor || !anchor.isConnected) return;
1719
- userEdited = true;
1720
- const parent = anchor.parentNode;
1721
- while (anchor.firstChild) parent.insertBefore(anchor.firstChild, anchor);
1722
- parent.removeChild(anchor);
1723
- parent.normalize();
1724
- if (blockEl) emitBlockEdit(blockEl, label);
1725
- scheduleSave();
1726
- }
1727
-
1728
- els.linkInput.addEventListener("keydown", (event) => {
1729
- event.stopPropagation();
1730
- if (event.key === "Enter") {
1731
- event.preventDefault();
1732
- applyLink();
1733
- } else if (event.key === "Escape") {
1734
- event.preventDefault();
1735
- closeLinkbox(true);
1736
- }
1737
- });
1738
- els.linkApply.addEventListener("click", applyLink);
1739
- els.linkRemove.addEventListener("click", removeLink);
1740
-
1741
- // A click anywhere in the page means the popup lost the argument.
1742
- document.addEventListener("pointerdown", (event) => {
1743
- if (linkState && !isOurs(event.target)) closeLinkbox(false);
1744
- });
1745
-
1746
- // ------------------------------------------------------------------ lists
1747
-
1748
- // execCommand mutations fire `input` (so edit rows and saves flow as usual)
1749
- // but not `beforeinput`, so originals are captured here by hand.
1750
- document.addEventListener(
1751
- "keydown",
1752
- (event) => {
1753
- if (isOurs(event.target)) return;
1754
- const meta = event.metaKey || event.ctrlKey;
1755
- const sel = document.getSelection();
1756
- const anchor = sel ? sel.anchorNode : null;
1757
-
1758
- if (
1759
- event.altKey &&
1760
- (event.ctrlKey || event.metaKey) &&
1761
- event.key.toLowerCase() === "m"
1762
- ) {
1763
- if (selectionIsActive()) settleSelection();
1764
- if (openPendingCompose()) {
1765
- event.preventDefault();
1766
- event.stopPropagation();
1767
- }
1768
- return;
1769
- }
1770
-
1771
- if (reviewMode !== "edit") return;
1772
-
1773
- // ⌘K links the selection, like Docs, Word, and Notion.
1774
- if (meta && !event.shiftKey && !event.altKey && (event.key === "k" || event.key === "K")) {
1775
- if (openLinkbox()) {
1776
- event.preventDefault();
1777
- event.stopPropagation();
1778
- }
1779
- return;
1780
- }
1781
-
1782
- if (event.key === "Escape" && linkState) {
1783
- closeLinkbox(true);
1784
- return;
1785
- }
1786
-
1787
- // ⌘⇧8 bulleted, ⌘⇧7 numbered — the shortcuts people know from Docs.
1788
- if (meta && event.shiftKey && (event.code === "Digit7" || event.code === "Digit8")) {
1789
- const target = targetFor(anchor || event.target);
1790
- if (!target) return;
1791
- event.preventDefault();
1792
- event.stopPropagation();
1793
- userEdited = true;
1794
- captureOriginal(target.el);
1795
- document.execCommand(event.code === "Digit7" ? "insertOrderedList" : "insertUnorderedList");
1796
- polishNewList(document.getSelection());
1797
- scheduleSave();
1798
- return;
1799
- }
1800
-
1801
- // Tab indents and Shift+Tab outdents inside a list, instead of leaving the page.
1802
- if (event.key === "Tab" && anchor) {
1803
- const el = anchor.nodeType === 1 ? anchor : anchor.parentElement;
1804
- const item = el && el.closest ? el.closest("li") : null;
1805
- if (!item || isOurs(item)) return;
1806
- event.preventDefault();
1807
- event.stopPropagation();
1808
- userEdited = true;
1809
- const target = targetFor(item);
1810
- if (target) captureOriginal(target.el);
1811
- document.execCommand(event.shiftKey ? "outdent" : "indent");
1812
- polishNewList(document.getSelection());
1813
- scheduleSave();
1814
- return;
1815
- }
1816
-
1817
- // "- ", "* ", "1. ", or "1) " at the start of a line becomes a list.
1818
- // Only the text between the block's start and the caret matters, so this
1819
- // works inside authored data-block containers and in front of existing
1820
- // text — not just in blocks that hold nothing but the marker.
1821
- if (event.key === " " && sel && sel.isCollapsed && anchor) {
1822
- const host = anchor.nodeType === 1 ? anchor : anchor.parentElement;
1823
- if (!host || host.closest("li, ul, ol")) return;
1824
- const block = innermostBlock(anchor);
1825
- // isBlock matches display:table but not table-cell, so a caret in a
1826
- // table's first cell resolves the whole table — never convert that.
1827
- if (block && /^(table|thead|tbody|tfoot|tr)$/i.test(block.tagName)) return;
1828
- const lead = document.createRange();
1829
- // Inline content directly under <body> (a bare text node beside an
1830
- // image, say) has no block; the caret's own node is the line then.
1831
- if (block) lead.selectNodeContents(block);
1832
- else lead.setStart(anchor, 0);
1833
- try {
1834
- lead.setEnd(sel.anchorNode, sel.anchorOffset);
1835
- } catch {
1836
- return;
1837
- }
1838
- const command = listCommandFor(lead.toString());
1839
- if (!command) return;
1840
- const target = targetFor(anchor);
1841
- event.preventDefault();
1842
- event.stopPropagation();
1843
- userEdited = true;
1844
- if (target) captureOriginal(target.el);
1845
- sel.removeAllRanges();
1846
- sel.addRange(lead);
1847
- document.execCommand("delete");
1848
- document.execCommand(command);
1849
- polishNewList(document.getSelection());
1850
- scheduleSave();
1851
- }
1852
- },
1853
- true
1854
- );
1855
-
1856
- // ------------------------------------------------------------- image drag
1857
-
1858
- // Chromium moves an image dropped inside contenteditable on its own; this
1859
- // code only aims the drop (indicator outline), blocks payloads that have
1860
- // nowhere to go (files from the desktop), and writes the edit rows for the
1861
- // block the image left and the block it landed in.
1862
- let dragging = null; // { el, fromBlock, fromLabel, overBlock, dropped } during an image drag
1863
-
1864
- document.addEventListener("dragstart", (event) => {
1865
- if (reviewMode !== "edit") return;
1866
- const img = event.target && event.target.nodeType === 1 && event.target.tagName === "IMG" ? event.target : null;
1867
- if (!img || isOurs(img)) return;
1868
- const target = targetFor(img);
1869
- if (target) captureOriginal(target.el);
1870
- dragging = {
1871
- el: img,
1872
- src: img.src,
1873
- // The rendered size often comes from the container being left behind
1874
- // (figure/card CSS); remember it so the move can't balloon the image.
1875
- width: img.getBoundingClientRect().width,
1876
- fromBlock: target ? target.el : null,
1877
- fromLabel: target ? target.label : "Image",
1878
- overBlock: null,
1879
- dropped: false,
1880
- };
1881
- });
1882
-
1883
- document.addEventListener("dragover", (event) => {
1884
- if (reviewMode !== "edit") return;
1885
- if (!dragging) {
1886
- // External payloads would navigate the frame or paste file:// markup.
1887
- if (event.dataTransfer && [...(event.dataTransfer.types || [])].includes("Files")) {
1888
- event.preventDefault();
1889
- event.dataTransfer.dropEffect = "none";
1890
- }
1891
- return;
1892
- }
1893
- const range = caretRangeAt(event.clientX, event.clientY);
1894
- const block = range ? innermostBlock(range.startContainer) : null;
1895
- if (block && !isOurs(block)) {
1896
- captureOriginal(block);
1897
- dragging.overBlock = block;
1898
- }
1899
- place(els.outline, block && !isOurs(block) ? block : null);
1900
- });
1901
-
1902
- const finalizeImageDrag = () => {
1903
- const drag = dragging;
1904
- if (!drag || !drag.dropped) return;
1905
- dragging = null;
1906
- place(els.outline, null);
1907
- // Chrome re-inserts a copy on drop, so find the landed image by source,
1908
- // then pin it to the size it had before the move — an image dragged out
1909
- // of the container that was constraining it would otherwise render at
1910
- // its natural size.
1911
- let landed = drag.el.isConnected ? drag.el : null;
1912
- if (!landed && drag.overBlock && drag.overBlock.isConnected) {
1913
- landed = [...drag.overBlock.querySelectorAll("img")].find((i) => i.src === drag.src) || null;
1914
- }
1915
- if (landed && drag.width && Math.abs(landed.getBoundingClientRect().width - drag.width) > 1) {
1916
- landed.style.width = `${Math.round(drag.width)}px`;
1917
- landed.style.height = "auto";
1918
- landed.style.maxWidth = "100%";
1919
- }
1920
- const rows = new Map();
1921
- if (drag.fromBlock) rows.set(drag.fromBlock, drag.fromLabel);
1922
- if (drag.overBlock && !rows.has(drag.overBlock)) rows.set(drag.overBlock, null);
1923
- for (const [block, label] of rows) emitBlockEdit(block, label);
1924
- scheduleSave();
1925
- };
1926
-
1927
- document.addEventListener("drop", (event) => {
1928
- if (reviewMode !== "edit") return;
1929
- if (dragging) {
1930
- // The move itself is the drop's default action, so the edit rows can
1931
- // only be read after it has run.
1932
- userEdited = true;
1933
- dragging.dropped = true;
1934
- setTimeout(finalizeImageDrag, 0);
1935
- return;
1936
- }
1937
- if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length) event.preventDefault();
1938
- });
1939
-
1940
- document.addEventListener("dragend", () => {
1941
- // A cancelled drag (Esc, or dropped outside the page) still needs cleanup.
1942
- if (dragging && !dragging.dropped) {
1943
- dragging = null;
1944
- place(els.outline, null);
1945
- }
1946
- });
1947
-
1948
- // ------------------------------------------------------------- block moves
1949
-
1950
- // The handle on a block's left edge moves the whole block. Pointer-based,
1951
- // not HTML5 drag: the element is relocated on drop, never cloned, so
1952
- // identity (labels, captured originals, comment anchors) survives the move.
1953
- let moving = null; // { el, label, drop: { ref, before } | null } while the handle is held
1954
-
1955
- const dropPointFor = (x, y) => {
1956
- const under = document.elementFromPoint(x, y);
1957
- if (!under || isOurs(under)) return null;
1958
- const block = innermostBlock(under);
1959
- if (!block || block === moving.el || moving.el.contains(block)) return null;
1960
- const r = block.getBoundingClientRect();
1961
- return { ref: block, before: y < r.top + r.height / 2 };
1962
- };
1963
-
1964
- els.mover.addEventListener("pointerdown", (event) => {
1965
- if (reviewMode !== "edit") return;
1966
- const el = hoverMove && hoverMove.isConnected ? hoverMove : hoverTarget;
1967
- if (!el || !el.isConnected) return;
1968
- event.preventDefault();
1969
- event.stopPropagation();
1970
- const target = targetFor(el);
1971
- moving = { el, label: target ? target.label : "Block", drop: null };
1972
- captureOriginal(moving.el);
1973
- moving.el.style.opacity = "0.4";
1974
- place(els.outline, null);
1975
- showChip(null);
1976
- try {
1977
- els.mover.setPointerCapture(event.pointerId);
1978
- } catch {
1979
- // Window-level listeners track the move even without capture.
1980
- }
1981
- });
1982
-
1983
- window.addEventListener("pointermove", (event) => {
1984
- if (!moving) return;
1985
- event.preventDefault();
1986
- moving.drop = dropPointFor(event.clientX, event.clientY);
1987
- showDropline(moving.drop);
1988
- });
1989
-
1990
- window.addEventListener("pointerup", () => {
1991
- if (!moving) return;
1992
- const { el, label, drop } = moving;
1993
- moving = null;
1994
- showDropline(null);
1995
- showMover(null);
1996
- el.style.opacity = "";
1997
- suppressUntil = Date.now() + 250;
1998
- if (!drop || !drop.ref.isConnected || !el.isConnected) return;
1999
- // Dropping right back where it came from is a no-op, not an edit.
2000
- const next = drop.before ? drop.ref : drop.ref.nextElementSibling;
2001
- if (next === el || (drop.before ? drop.ref.previousElementSibling : drop.ref) === el) return;
2002
- userEdited = true;
2003
- drop.ref.parentNode.insertBefore(el, next);
2004
- const prev = el.previousElementSibling;
2005
- const following = el.nextElementSibling;
2006
- queueEdit({
2007
- label,
2008
- kind: "moved",
2009
- before: originalText.get(el),
2010
- after: el.textContent,
2011
- before_html: originalHtml.get(el),
2012
- after_html: blockHtml(el),
2013
- moved_after: prev ? clip(prev.textContent, 90) : "",
2014
- moved_before: following ? clip(following.textContent, 90) : "",
2015
- });
2016
- flushSave();
2017
- });
2018
-
2019
- // ------------------------------------------------------------- image paste
2020
-
2021
- // Pasted images cross to the chrome page as bytes; the server writes them
2022
- // into assets/ next to the reviewed file and the confirmed relative path
2023
- // comes back to be inserted where the caret was.
2024
- let pasteSeq = 0;
2025
- const pendingPastes = new Map(); // id → collapsed caret range to insert at
2026
-
2027
- document.addEventListener(
2028
- "paste",
2029
- (event) => {
2030
- if (isOurs(event.target) || reviewMode !== "edit") return;
2031
- const items = event.clipboardData ? [...event.clipboardData.items] : [];
2032
- const images = items.filter((item) => item.kind === "file" && /^image\//.test(item.type));
2033
- if (!images.length) return;
2034
- event.preventDefault();
2035
- event.stopPropagation();
2036
- const sel = document.getSelection();
2037
- const caret = sel && sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null;
2038
- for (const item of images) {
2039
- const file = item.getAsFile();
2040
- if (!file) continue;
2041
- pasteSeq += 1;
2042
- const id = `paste_${pasteSeq}`;
2043
- pendingPastes.set(id, caret);
2044
- file.arrayBuffer().then((bytes) => post("eh:asset", { id, assetType: file.type, bytes }));
2045
- }
2046
- },
2047
- true
2048
- );
2049
-
2050
- const insertPastedImage = (id, src, stagedId) => {
2051
- const caret = pendingPastes.get(id);
2052
- pendingPastes.delete(id);
2053
- userEdited = true;
2054
- const img = document.createElement("img");
2055
- img.setAttribute("src", src);
2056
- img.style.maxWidth = "100%";
2057
- const anchored = caret && caret.startContainer && document.body.contains(caret.startContainer) && !isOurs(caret.startContainer);
2058
- if (anchored) {
2059
- const target = targetFor(caret.startContainer);
2060
- if (target) captureOriginal(target.el);
2061
- caret.collapse(false);
2062
- caret.insertNode(img);
2063
- } else {
2064
- document.body.appendChild(img);
2065
- }
2066
- const landed = targetFor(img);
2067
- emitBlockEdit(landed ? landed.el : img, landed ? landed.label : "Pasted image", {
2068
- ...(stagedId ? { staged_assets: [{ id: stagedId, preview_src: src }] } : {}),
2069
- });
2070
- flushSave();
2071
- };
2072
-
2073
- document.addEventListener("input", (event) => {
2074
- if (isOurs(event.target) || reviewMode !== "edit") return;
2075
- // A drop fires deleteByDrag/insertFromDrop input events whose selection
2076
- // points anywhere; finalizeImageDrag writes the real rows instead.
2077
- if (dragging) return;
2078
- // Typing over a selection is an edit, not a comment: retire the card.
2079
- if (pending) {
2080
- clearPending();
2081
- post("eh:dismiss", {});
2082
- }
2083
- const sel = document.getSelection();
2084
- const node = sel && sel.anchorNode ? sel.anchorNode : event.target;
2085
- const target = targetFor(node);
2086
- // Text alone loses formatting-only edits (bold, italic, underline change
2087
- // markup, not textContent), so the block's cleaned HTML travels too.
2088
- queueEdit({
2089
- label: target ? target.label : "Document body",
2090
- kind: "edited",
2091
- before: target ? originalText.get(target.el) : undefined,
2092
- after: target ? target.el.textContent : undefined,
2093
- before_html: target ? originalHtml.get(target.el) : undefined,
2094
- after_html: target ? blockHtml(target.el) : undefined,
2095
- });
2096
- scheduleSave();
2097
- });
2098
-
2099
- const reposition = () => {
2100
- place(els.outline, hoverTarget);
2101
- if (reviewMode === "edit" && !controlsAreSuppressed()) {
2102
- showChip(hoverTarget);
2103
- showMover(moving ? null : hoverMove);
2104
- showGrip(resizing ? resizing.el : hoverMedia);
2105
- } else {
2106
- hideActionControls();
2107
- showGrip(null);
2108
- }
2109
- scheduleTargetGeometry();
2110
- if (activeCommentId) activate(activeCommentId, false);
2111
- // The popup is viewport-fixed; scrolled away from its text it just lies.
2112
- if (linkState) closeLinkbox(false);
2113
- };
2114
- // getBoundingClientRect on every scroll event forces layout mid-scroll;
2115
- // one reposition per frame is plenty.
2116
- let repositionQueued = false;
2117
- const scheduleReposition = () => {
2118
- if (repositionQueued) return;
2119
- repositionQueued = true;
2120
- requestAnimationFrame(() => {
2121
- repositionQueued = false;
2122
- reposition();
2123
- });
2124
- };
2125
- document.addEventListener("scroll", scheduleReposition, true);
2126
- window.addEventListener("scroll", scheduleReposition, true);
2127
- window.addEventListener("resize", scheduleReposition);
2128
-
2129
- let scrollQueued = false;
2130
- window.addEventListener(
2131
- "scroll",
2132
- () => {
2133
- if (scrollQueued) return;
2134
- scrollQueued = true;
2135
- requestAnimationFrame(() => {
2136
- scrollQueued = false;
2137
- post("eh:scroll", { x: window.scrollX, y: window.scrollY });
2138
- });
2139
- },
2140
- { passive: true }
2141
- );
2142
-
2143
- window.addEventListener("message", (event) => {
2144
- // Only the chrome page may drive the SDK — not popups the artifact opened,
2145
- // and not the artifact's own scripts.
2146
- if (event.source !== window.parent || event.origin !== CHROME_ORIGIN) return;
2147
- const msg = event.data || {};
2148
- if (!matchesFrameMessage(msg)) return;
2149
- switch (msg.type) {
2150
- case "eh:anchors":
2151
- reanchor(msg.comments || []);
2152
- break;
2153
- case "eh:commit":
2154
- if (msg.targetGeneration && pending && msg.targetGeneration !== pending.generation) break;
2155
- commitPending(msg.id, msg.targetGeneration, { restoreFocus: !!msg.restoreFocus });
2156
- composeOpen = false;
2157
- break;
2158
- case "eh:cancel":
2159
- if (msg.targetGeneration && pending && msg.targetGeneration !== pending.generation) break;
2160
- {
2161
- const discardThrough = Number(msg.discardThroughGeneration) || Number(msg.targetGeneration) || 0;
2162
- const newerRetarget = retarget && retarget.generation > discardThrough ? retarget : null;
2163
- if (msg.restoreFocus && !newerRetarget) restoreTargetFocus(pending);
2164
- clearPending({ keepRetarget: !!newerRetarget || !!msg.preserveRetarget });
2165
- if (newerRetarget) {
2166
- pending = newerRetarget;
2167
- retarget = null;
2168
- }
2169
- }
2170
- composeOpen = false;
2171
- if (pending) {
2172
- observePendingTarget(pending);
2173
- scheduleTargetGeometry();
2174
- }
2175
- break;
2176
- case "eh:commentOpenResult":
2177
- acceptCommentOpen(msg);
2178
- break;
2179
- case "eh:deactivateComment":
2180
- deactivateComment();
2181
- break;
2182
- case "eh:revealTarget":
2183
- revealTarget(msg.targetGeneration);
2184
- break;
2185
- case "eh:modeMenuState":
2186
- modeMenuOpen = !!msg.open;
2187
- break;
2188
- case "eh:remove":
2189
- unwrap(msg.id);
2190
- document.querySelectorAll(`[data-eh-el="${CSS.escape(msg.id)}"]`).forEach((el) => el.removeAttribute("data-eh-el"));
2191
- place(els.activeBox, null);
2192
- break;
2193
- case "eh:activate":
2194
- activate(msg.id, !!msg.scroll);
2195
- break;
2196
- case "eh:flush":
2197
- flushSave();
2198
- // Posted after the flushed eh:edit/eh:html messages, so when the
2199
- // chrome sees it, everything pending has already been handed over.
2200
- post("eh:flushed", {});
2201
- break;
2202
- case "eh:abortSave":
2203
- // A revert is in flight: anything queued would re-write the edits.
2204
- clearTimeout(saveTimer);
2205
- clearTimeout(editTimer);
2206
- editQueue.clear();
2207
- break;
2208
- case "eh:raw":
2209
- if (savePolicy === "writable") checkDynamic(String(msg.html || ""));
2210
- break;
2211
- case "eh:configureReview": {
2212
- reviewMode = msg.mode === "edit" ? "edit" : "view";
2213
- savePolicy = msg.savePolicy === "feedback-only" ? "feedback-only" : "writable";
2214
- modeController.setMode(reviewMode);
2215
- if (reviewMode === "view") {
2216
- showChip(null);
2217
- showMover(null);
2218
- showGrip(null);
2219
- els.linkbox.style.display = "none";
2220
- }
2221
- scheduleTargetGeometry();
2222
- post("eh:configurationApplied", { mode: reviewMode, savePolicy });
2223
- break;
2224
- }
2225
- case "eh:restoreScroll":
2226
- window.scrollTo(msg.x || 0, msg.y || 0);
2227
- break;
2228
- case "eh:assetSaved":
2229
- insertPastedImage(msg.id, String(msg.src || ""), String(msg.stagedId || ""));
2230
- break;
2231
- case "eh:assetFailed":
2232
- pendingPastes.delete(msg.id);
2233
- break;
2234
- default:
2235
- break;
2236
- }
2237
- });
2238
-
2239
- post("eh:ready", { scrollHeight: document.body.scrollHeight });
2240
- }
2241
-
2242
- if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
2243
- else boot();