@erdemtuna/doc-review 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/sdk.js CHANGED
@@ -7,9 +7,12 @@
7
7
  */
8
8
  import { buildContext, findQuote } from "./anchor-text.js";
9
9
  import { hashClickAction, navigationHref } from "./click-target.js";
10
+ import { acceptedOpenGeneration, targetMessage } from "./comment-target.js";
10
11
  import { classifyHref, externalHref, linkStyleFixup, listCommandFor, listStyleFixup, normalizeHref } from "./editing.js";
11
12
  import { frameMessage, initializeChannelFromDocument, matchesFrameMessage } from "./frame-channel.js";
12
- import { keepBodyEditable, serializeDocument, UI_ATTR, MARK_ATTR } from "./serialize.js";
13
+ import { iconMarkup } from "./icons.js";
14
+ import { keepBodyInReviewMode } from "./review-mode.js";
15
+ import { serializeDocument, UI_ATTR, MARK_ATTR } from "./serialize.js";
13
16
 
14
17
  initializeChannelFromDocument();
15
18
 
@@ -24,7 +27,14 @@ const CHROME_ORIGIN = `${location.protocol}//${location.hostname === "127.0.0.1"
24
27
 
25
28
  const post = (type, payload) => parent.postMessage(frameMessage(type, payload), CHROME_ORIGIN);
26
29
 
27
- let pending = null; // { id, marks } for an uncommitted highlight
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;
28
38
  let hoverTarget = null;
29
39
  let hoverMove = null; // the innermost block under the cursor, movable via the handle
30
40
  let hoverMedia = null; // the img/video under the cursor, resizable via the grip
@@ -32,9 +42,21 @@ let resizing = null; // live drag state while the grip is held
32
42
  let suppressUntil = 0; // ignore the mouseup/click that ends a resize drag
33
43
  let saveTimer = null;
34
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;
35
53
  /** True when the page's own scripts rewrote the DOM before any user edit. */
36
54
  let dynamic = false;
37
55
 
56
+ const diagnostic = (event, detail = {}) => {
57
+ console.info("[doc-review-frame]", { event, ...detail });
58
+ };
59
+
38
60
  // ------------------------------------------------------------------ overlay
39
61
 
40
62
  const host = document.createElement("div");
@@ -98,21 +120,33 @@ shadow.innerHTML = `
98
120
  position: fixed; z-index: 2147483646; height: 0; display: none;
99
121
  border-top: 2px solid #1b1a16; border-radius: 1px; pointer-events: none;
100
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; } }
101
133
  </style>
102
134
  <div class="box outline" id="outline"></div>
103
135
  <div class="box active" id="activeBox"></div>
104
136
  <div class="chips" id="chips">
105
- <button class="chip danger" id="chipDelete" title="Delete this block" aria-label="Delete this block">&#10005;</button>
137
+ <button class="chip danger" id="chipDelete" title="Delete this block" aria-label="Delete this block">${iconMarkup("trash", { size: 14 })}</button>
106
138
  </div>
107
139
  <div class="grip" id="grip" title="Drag to resize"></div>
108
140
  <div class="hint" id="hint"></div>
109
141
  <div class="linkbox" id="linkbox">
110
142
  <input id="linkInput" type="text" placeholder="Link to&hellip;" spellcheck="false">
111
- <button class="chip" id="linkApply" title="Apply link (&#9166;)" aria-label="Apply link">&#8629;</button>
112
- <button class="chip danger" id="linkRemove" title="Remove link" aria-label="Remove link">&#10005;</button>
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>
113
145
  </div>
114
146
  <div class="mover" id="mover" title="Drag to move this block">&#10303;</div>
115
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>
116
150
  `;
117
151
 
118
152
  const els = {};
@@ -130,6 +164,8 @@ const mountOverlay = () => {
130
164
  els.linkRemove = shadow.getElementById("linkRemove");
131
165
  els.mover = shadow.getElementById("mover");
132
166
  els.dropline = shadow.getElementById("dropline");
167
+ els.selectionCues = shadow.getElementById("selectionCues");
168
+ els.commentAction = shadow.getElementById("commentAction");
133
169
  };
134
170
 
135
171
  function showMover(el) {
@@ -220,6 +256,335 @@ function showHint(text, x, y) {
220
256
  els.hint.style.top = `${y + 16}px`;
221
257
  }
222
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
+
223
588
  // ------------------------------------------------------------ text plumbing
224
589
 
225
590
  const isOurs = (node) => {
@@ -389,6 +754,20 @@ function targetFor(node) {
389
754
  return { el: block, label: pinnedLabels.get(block), authored: false };
390
755
  }
391
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
+
392
771
  /**
393
772
  * The nearest rendered block around a node, ignoring authored data-block
394
773
  * containers. List conversion and drop targeting need the line the caret is
@@ -511,7 +890,8 @@ function watchSelfRendering() {
511
890
  }
512
891
 
513
892
  function emitSave() {
514
- if (dynamic) {
893
+ if (reviewMode !== "edit") return;
894
+ if (savePolicy === "feedback-only" || dynamic) {
515
895
  post("eh:dynamic", {});
516
896
  return;
517
897
  }
@@ -525,8 +905,9 @@ function emitSave() {
525
905
  }
526
906
 
527
907
  function scheduleSave() {
908
+ if (reviewMode !== "edit") return;
528
909
  clearTimeout(saveTimer);
529
- if (!dynamic) post("eh:saving", {});
910
+ if (savePolicy === "writable" && !dynamic) post("eh:saving", {});
530
911
  saveTimer = setTimeout(emitSave, SAVE_DEBOUNCE_MS);
531
912
  }
532
913
 
@@ -554,6 +935,7 @@ function flushEdits() {
554
935
  }
555
936
 
556
937
  function queueEdit(payload) {
938
+ if (reviewMode !== "edit") return;
557
939
  const key = `${payload.label}\u0000${payload.kind}`;
558
940
  const queued = editQueue.get(key);
559
941
  if (queued && (queued.staged_assets || payload.staged_assets)) {
@@ -567,16 +949,49 @@ function queueEdit(payload) {
567
949
 
568
950
  // ------------------------------------------------------------- interactions
569
951
 
570
- function clearPending() {
952
+ function clearPending({ keepRetarget = false } = {}) {
953
+ commentOpenRequestGeneration = null;
571
954
  pending = null;
955
+ if (!keepRetarget) retarget = null;
956
+ lastTargetGeometrySignature = "";
957
+ lastTargetRelation = null;
958
+ disconnectTargetObservers();
572
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);
573
990
  }
574
991
 
575
992
  /**
576
- * A settled selection opens the compose card but is otherwise left completely
577
- * alone: still selected, still editable. Nothing is written into the document
578
- * until the comment is actually committed, so typing or Backspace behaves
579
- * exactly as it would in any editable page.
993
+ * Remember a settled selection without opening composition. The explicit
994
+ * contextual action or keyboard shortcut owns that transition.
580
995
  */
581
996
  function settleSelection() {
582
997
  const sel = document.getSelection();
@@ -592,30 +1007,151 @@ function settleSelection() {
592
1007
  if (!offsets) return false;
593
1008
 
594
1009
  const context = buildContext(text, offsets.start, offsets.end);
595
- pending = { kind: "selection", context };
596
-
597
- post("eh:compose", {
1010
+ targetGeneration += 1;
1011
+ const target = {
598
1012
  kind: "selection",
599
1013
  quote,
600
1014
  anchor: { ...context, selector: cssPath(range.commonAncestorContainer.parentElement || document.body) },
601
- });
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();
602
1026
  return true;
603
1027
  }
604
1028
 
605
- function openElementCompose(container) {
606
- if (pending && pending.element === container.el) return;
607
- pending = { kind: "element", element: container.el };
608
- place(els.activeBox, container.el);
609
- post("eh:compose", {
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 = {
610
1040
  kind: "element",
1041
+ element: container.el,
611
1042
  quote: container.label,
612
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
+ });
613
1149
  });
614
1150
  }
615
1151
 
616
1152
  /** Commit turns the remembered range into real <mark> wrappers. */
617
- function commitPending(id) {
618
- if (!pending) return;
1153
+ function commitPending(id, generation, { restoreFocus = false } = {}) {
1154
+ if (!pending || (generation && generation !== pending.generation)) return;
619
1155
  if (pending.kind === "element") {
620
1156
  if (pending.element && pending.element.isConnected) pending.element.setAttribute("data-eh-el", id);
621
1157
  } else {
@@ -623,12 +1159,32 @@ function commitPending(id) {
623
1159
  const hit = findQuote(text, pending.context);
624
1160
  if (hit) wrapOffsets(map, hit.start, hit.end, id);
625
1161
  }
626
- const sel = document.getSelection();
627
- if (sel) sel.removeAllRanges();
628
- clearPending();
1162
+ if (restoreFocus) restoreTargetFocus(pending);
1163
+ else {
1164
+ const sel = document.getSelection();
1165
+ if (sel) sel.removeAllRanges();
1166
+ }
1167
+ clearPending({ keepRetarget: true });
629
1168
  }
630
1169
 
631
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
+
632
1188
  const resolved = [];
633
1189
  const orphaned = [];
634
1190
  for (const comment of comments) {
@@ -656,6 +1212,7 @@ function reanchor(comments) {
656
1212
  }
657
1213
 
658
1214
  function activate(id, scroll) {
1215
+ activeCommentId = id || null;
659
1216
  const marks = marksFor(id);
660
1217
  let target = marks[0] || null;
661
1218
  if (!target) {
@@ -665,6 +1222,19 @@ function activate(id, scroll) {
665
1222
  for (const mark of document.querySelectorAll(`mark[${MARK_ATTR}]`)) mark.classList.remove("eh-active");
666
1223
  for (const mark of marks) mark.classList.add("eh-active");
667
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();
668
1238
  if (target && scroll) {
669
1239
  // Reveal a target hidden inside a collapsed tab or accordion.
670
1240
  let hidden = target.closest("[hidden]") || null;
@@ -690,6 +1260,7 @@ function activate(id, scroll) {
690
1260
  // scrollIntoView walks every scrollable ancestor, so targets inside an
691
1261
  // app's inner scroll container are reached too, not just window-scrolled ones.
692
1262
  target.scrollIntoView({ block: "center", behavior: "smooth" });
1263
+ setTimeout(() => activate(id, false), 180);
693
1264
  }
694
1265
  }
695
1266
 
@@ -709,25 +1280,24 @@ function boot() {
709
1280
  `;
710
1281
  document.head.appendChild(style);
711
1282
 
712
- // React/Next.js hydration may remove an attribute that was added after the
713
- // server rendered the page. Keep edit mode on after hydration settles so a
714
- // real app stays directly editable, not just its initial HTML response.
715
- keepBodyEditable(document.body);
716
- document.body.spellcheck = false;
1283
+ modeController = keepBodyInReviewMode(document.body, "view");
717
1284
  baseline = serialize();
718
1285
  bootSnapshot = baseline;
719
1286
  watchSelfRendering();
720
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
+
721
1296
  document.addEventListener("mouseup", (event) => {
722
1297
  if (isOurs(event.target) || resizing || Date.now() < suppressUntil) return;
723
1298
  setTimeout(() => {
724
1299
  if (settleSelection()) return;
725
- const target = targetFor(event.target);
726
- if (target) {
727
- openElementCompose(target);
728
- return;
729
- }
730
- post("eh:dismiss", {});
1300
+ if (!composeOpen && pending?.kind === "selection") clearPending();
731
1301
  }, 0);
732
1302
  });
733
1303
 
@@ -737,8 +1307,14 @@ function boot() {
737
1307
  if (isOurs(event.target)) return;
738
1308
  const modified = event.metaKey || event.ctrlKey;
739
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
+ }
740
1316
 
741
- if (modified) {
1317
+ if (reviewMode === "view") {
742
1318
  if (href) {
743
1319
  event.preventDefault();
744
1320
  event.stopPropagation();
@@ -757,49 +1333,108 @@ function boot() {
757
1333
  }
758
1334
  }
759
1335
  else if (classification === "navigate") {
760
- // This document is about to be torn down: anything still sitting in
761
- // the debounce windows must ship first or it is lost. Message order
762
- // is guaranteed, so the edits land before the navigation does.
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") {
763
1359
  flushSave();
764
1360
  post("eh:navigate", { href });
765
1361
  }
766
1362
  }
767
- return; // let the artifact's own handlers run
1363
+ return;
768
1364
  }
769
1365
 
770
- // Plain clicks belong to editing: never navigate, never fire artifact JS.
1366
+ // Edit keeps activation suppression so direct manipulation cannot navigate.
771
1367
  if (href || (event.target.closest && event.target.closest("button, [role='button'], summary"))) {
772
1368
  event.preventDefault();
773
1369
  event.stopPropagation();
774
1370
  }
775
- const mark = event.target.closest && event.target.closest(`mark[${MARK_ATTR}]`);
776
- if (mark) post("eh:activate", { id: mark.getAttribute(MARK_ATTR) });
777
1371
  },
778
1372
  true
779
1373
  );
780
1374
 
781
1375
  document.addEventListener("submit", (event) => {
782
- if (!event.metaKey && !event.ctrlKey) event.preventDefault();
783
- }, true);
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
+ });
784
1411
 
785
1412
  document.addEventListener("mouseover", (event) => {
786
1413
  if (isOurs(event.target) || resizing || moving) return;
787
- const target = targetFor(event.target);
1414
+ const target = commentTargetFor(event.target);
788
1415
  hoverTarget = target ? target.el : null;
789
1416
  // The move handle works per element, not per labeled container, so each
790
1417
  // paragraph inside a card can travel on its own.
791
1418
  hoverMove = hoverTarget ? innermostBlock(event.target) || hoverTarget : null;
792
1419
  hoverMedia = event.target.closest ? event.target.closest("img, video") : null;
793
1420
  place(els.outline, hoverTarget);
794
- if (controlsAreSuppressed()) hideActionControls();
1421
+ const selectionActive = selectionIsActive();
1422
+ if (!selectionActive && target && !composeOpen) setElementTarget(target);
1423
+ if (reviewMode !== "edit" || controlsAreSuppressed()) hideActionControls();
795
1424
  else {
796
1425
  showChip(hoverTarget);
797
1426
  showMover(hoverMove);
798
1427
  }
799
- showGrip(hoverMedia);
1428
+ showGrip(reviewMode === "edit" ? hoverMedia : null);
800
1429
  const interactive = event.target.closest && event.target.closest("a[href], [data-href], button, [role='button']");
801
1430
  const draggable = hoverMedia && hoverMedia.tagName === "IMG";
802
- showHint(interactive ? "⌘-click to open" : draggable ? "Drag to move" : "", event.clientX, event.clientY);
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);
803
1438
  });
804
1439
 
805
1440
  document.addEventListener("mouseleave", () => {
@@ -812,11 +1447,29 @@ function boot() {
812
1447
  showMover(null);
813
1448
  showGrip(null);
814
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();
815
1467
  });
816
1468
 
817
1469
  // ------------------------------------------------------------ image resize
818
1470
 
819
1471
  els.grip.addEventListener("pointerdown", (event) => {
1472
+ if (reviewMode !== "edit") return;
820
1473
  if (!hoverMedia || !hoverMedia.isConnected) return;
821
1474
  event.preventDefault();
822
1475
  event.stopPropagation();
@@ -865,7 +1518,7 @@ function boot() {
865
1518
  els.chipDelete.addEventListener("click", (event) => {
866
1519
  event.preventDefault();
867
1520
  event.stopPropagation();
868
- if (!hoverTarget) return;
1521
+ if (reviewMode !== "edit" || !hoverTarget) return;
869
1522
  userEdited = true;
870
1523
  const target = targetFor(hoverTarget);
871
1524
  const label = target ? target.label : "Element";
@@ -934,7 +1587,7 @@ function boot() {
934
1587
  document.addEventListener(
935
1588
  "beforeinput",
936
1589
  (event) => {
937
- if (isOurs(event.target)) return;
1590
+ if (isOurs(event.target) || reviewMode !== "edit") return;
938
1591
  suppressActionControlsWhileTyping();
939
1592
  // From here on, DOM drift is the human typing, not the page rendering.
940
1593
  userEdited = true;
@@ -946,8 +1599,18 @@ function boot() {
946
1599
  );
947
1600
 
948
1601
  document.addEventListener("selectionchange", () => {
949
- if (selectionIsActive()) hideActionControls();
950
- else restoreActionControls();
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);
951
1614
  });
952
1615
 
953
1616
  // ------------------------------------------------------------------ links
@@ -1092,6 +1755,21 @@ function boot() {
1092
1755
  const sel = document.getSelection();
1093
1756
  const anchor = sel ? sel.anchorNode : null;
1094
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
+
1095
1773
  // ⌘K links the selection, like Docs, Word, and Notion.
1096
1774
  if (meta && !event.shiftKey && !event.altKey && (event.key === "k" || event.key === "K")) {
1097
1775
  if (openLinkbox()) {
@@ -1184,6 +1862,7 @@ function boot() {
1184
1862
  let dragging = null; // { el, fromBlock, fromLabel, overBlock, dropped } during an image drag
1185
1863
 
1186
1864
  document.addEventListener("dragstart", (event) => {
1865
+ if (reviewMode !== "edit") return;
1187
1866
  const img = event.target && event.target.nodeType === 1 && event.target.tagName === "IMG" ? event.target : null;
1188
1867
  if (!img || isOurs(img)) return;
1189
1868
  const target = targetFor(img);
@@ -1202,6 +1881,7 @@ function boot() {
1202
1881
  });
1203
1882
 
1204
1883
  document.addEventListener("dragover", (event) => {
1884
+ if (reviewMode !== "edit") return;
1205
1885
  if (!dragging) {
1206
1886
  // External payloads would navigate the frame or paste file:// markup.
1207
1887
  if (event.dataTransfer && [...(event.dataTransfer.types || [])].includes("Files")) {
@@ -1245,6 +1925,7 @@ function boot() {
1245
1925
  };
1246
1926
 
1247
1927
  document.addEventListener("drop", (event) => {
1928
+ if (reviewMode !== "edit") return;
1248
1929
  if (dragging) {
1249
1930
  // The move itself is the drop's default action, so the edit rows can
1250
1931
  // only be read after it has run.
@@ -1281,6 +1962,7 @@ function boot() {
1281
1962
  };
1282
1963
 
1283
1964
  els.mover.addEventListener("pointerdown", (event) => {
1965
+ if (reviewMode !== "edit") return;
1284
1966
  const el = hoverMove && hoverMove.isConnected ? hoverMove : hoverTarget;
1285
1967
  if (!el || !el.isConnected) return;
1286
1968
  event.preventDefault();
@@ -1345,7 +2027,7 @@ function boot() {
1345
2027
  document.addEventListener(
1346
2028
  "paste",
1347
2029
  (event) => {
1348
- if (isOurs(event.target)) return;
2030
+ if (isOurs(event.target) || reviewMode !== "edit") return;
1349
2031
  const items = event.clipboardData ? [...event.clipboardData.items] : [];
1350
2032
  const images = items.filter((item) => item.kind === "file" && /^image\//.test(item.type));
1351
2033
  if (!images.length) return;
@@ -1389,7 +2071,7 @@ function boot() {
1389
2071
  };
1390
2072
 
1391
2073
  document.addEventListener("input", (event) => {
1392
- if (isOurs(event.target)) return;
2074
+ if (isOurs(event.target) || reviewMode !== "edit") return;
1393
2075
  // A drop fires deleteByDrag/insertFromDrop input events whose selection
1394
2076
  // points anywhere; finalizeImageDrag writes the real rows instead.
1395
2077
  if (dragging) return;
@@ -1416,9 +2098,16 @@ function boot() {
1416
2098
 
1417
2099
  const reposition = () => {
1418
2100
  place(els.outline, hoverTarget);
1419
- showChip(hoverTarget);
1420
- showMover(moving ? null : hoverMove);
1421
- showGrip(resizing ? resizing.el : hoverMedia);
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);
1422
2111
  // The popup is viewport-fixed; scrolled away from its text it just lies.
1423
2112
  if (linkState) closeLinkbox(false);
1424
2113
  };
@@ -1433,6 +2122,7 @@ function boot() {
1433
2122
  reposition();
1434
2123
  });
1435
2124
  };
2125
+ document.addEventListener("scroll", scheduleReposition, true);
1436
2126
  window.addEventListener("scroll", scheduleReposition, true);
1437
2127
  window.addEventListener("resize", scheduleReposition);
1438
2128
 
@@ -1461,15 +2151,39 @@ function boot() {
1461
2151
  reanchor(msg.comments || []);
1462
2152
  break;
1463
2153
  case "eh:commit":
1464
- commitPending(msg.id);
2154
+ if (msg.targetGeneration && pending && msg.targetGeneration !== pending.generation) break;
2155
+ commitPending(msg.id, msg.targetGeneration, { restoreFocus: !!msg.restoreFocus });
1465
2156
  composeOpen = false;
1466
2157
  break;
1467
2158
  case "eh:cancel":
1468
- clearPending();
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
+ }
1469
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();
1470
2181
  break;
1471
- case "eh:composeOpen":
1472
- composeOpen = true;
2182
+ case "eh:revealTarget":
2183
+ revealTarget(msg.targetGeneration);
2184
+ break;
2185
+ case "eh:modeMenuState":
2186
+ modeMenuOpen = !!msg.open;
1473
2187
  break;
1474
2188
  case "eh:remove":
1475
2189
  unwrap(msg.id);
@@ -1492,13 +2206,22 @@ function boot() {
1492
2206
  editQueue.clear();
1493
2207
  break;
1494
2208
  case "eh:raw":
1495
- checkDynamic(String(msg.html || ""));
2209
+ if (savePolicy === "writable") checkDynamic(String(msg.html || ""));
1496
2210
  break;
1497
- case "eh:feedbackOnly":
1498
- // The chrome already knows saves must not happen (e.g. a Markdown
1499
- // source rendered for review); no comparison needed.
1500
- dynamic = true;
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 });
1501
2223
  break;
2224
+ }
1502
2225
  case "eh:restoreScroll":
1503
2226
  window.scrollTo(msg.x || 0, msg.y || 0);
1504
2227
  break;