@erdemtuna/doc-review 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/sdk.js CHANGED
@@ -7,17 +7,21 @@
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
+ import { acceptedOpenGeneration, createHoverIntent, groupCommentTargets, nextCommentId, normalizeSelectionRange, pointInCommentApproach, sameRange, targetMessage } from "./comment-target.js";
11
11
  import { classifyHref, externalHref, linkStyleFixup, listCommandFor, listStyleFixup, normalizeHref } from "./editing.js";
12
12
  import { frameMessage, initializeChannelFromDocument, matchesFrameMessage } from "./frame-channel.js";
13
13
  import { iconMarkup } from "./icons.js";
14
14
  import { keepBodyInReviewMode } from "./review-mode.js";
15
+ import { captureSemanticSnapshot } from "./semantic-snapshot.js";
15
16
  import { serializeDocument, UI_ATTR, MARK_ATTR } from "./serialize.js";
17
+ import { observeView, sameObservedView } from "./view-identity.js";
16
18
 
17
19
  initializeChannelFromDocument();
18
20
 
19
21
  const SAVE_DEBOUNCE_MS = 700;
20
22
  const EDIT_FLUSH_MS = 500;
23
+ const CAPTURE_READY_TIMEOUT_MS = 2500;
24
+ const CAPTURE_STABLE_MS = 300;
21
25
  const MEDIA = /^(img|svg|canvas|video|picture|iframe|hr|figure)$/i;
22
26
 
23
27
  // The chrome page lives on the other loopback hostname (a separate origin, so
@@ -50,6 +54,15 @@ let lastTargetRelation = null;
50
54
  let geometryWatchTimer = null;
51
55
  let watchedGeometrySignature = "";
52
56
  let selectionTimer = null;
57
+ let hoverIntent = null;
58
+ let pointerSelecting = false;
59
+ let composing = false;
60
+ let disposed = false;
61
+ let viewObserver = null;
62
+ let viewChangeTimer = null;
63
+ let lastObservedView = null;
64
+ const blockTargets = new Map();
65
+ const blockMarkers = new Map();
53
66
  /** True when the page's own scripts rewrote the DOM before any user edit. */
54
67
  let dynamic = false;
55
68
 
@@ -66,8 +79,21 @@ shadow.innerHTML = `
66
79
  <style>
67
80
  :host { all: initial; }
68
81
  .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; }
82
+ .outline { border: 1px dashed #96938c; animation: outline-in 90ms ease-out; }
83
+ @keyframes outline-in { from { opacity: .2; } to { opacity: 1; } }
84
+ .active { border: 2px solid #b46a00; box-shadow: 0 0 0 1px #fff9; }
85
+ .block-marker { position: fixed; pointer-events: none; z-index: 2147483644;
86
+ box-sizing: border-box; border-left: 3px solid #a96100; background: #d58b0012; }
87
+ .block-marker[data-active="true"] { border-left-width: 6px; background: #d58b0026; }
88
+ .block-marker[data-dark="true"] { border-left-color: #ffcb66; background: #ffcb6617; }
89
+ .block-marker[data-dark="true"][data-active="true"] { background: #ffcb6630; }
90
+ .block-badge { position: fixed; z-index: 2147483647; pointer-events: auto;
91
+ height: 24px; min-width: 32px; box-sizing: border-box; padding: 2px 6px;
92
+ border: 1px solid #965900; border-radius: 5px; background: #fff3d3; color: #663b00;
93
+ font: 600 12px/18px system-ui, sans-serif; cursor: pointer; }
94
+ .block-badge[data-dark="true"] { background: #493517; color: #ffe0a0; border-color: #ffcb66; }
95
+ .block-badge[aria-pressed="true"] { border-width: 2px; text-decoration: underline; }
96
+ .block-badge:focus-visible { outline: 3px solid #718cff; outline-offset: 2px; }
71
97
  .chips {
72
98
  position: fixed; z-index: 2147483647; display: none; gap: 4px;
73
99
  pointer-events: auto;
@@ -129,7 +155,7 @@ shadow.innerHTML = `
129
155
  .comment-action:hover, .comment-action:focus-visible { background: #34312b; outline: 3px solid rgba(90,99,216,.32); }
130
156
  .selection-cues { position: fixed; inset: 0; pointer-events: none; z-index: 2147483645; }
131
157
  .selection-cue { position: fixed; border-radius: 2px; background: rgba(245,196,0,.2); }
132
- @media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
158
+ @media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
133
159
  </style>
134
160
  <div class="box outline" id="outline"></div>
135
161
  <div class="box active" id="activeBox"></div>
@@ -146,6 +172,7 @@ shadow.innerHTML = `
146
172
  <div class="mover" id="mover" title="Drag to move this block">&#10303;</div>
147
173
  <div class="dropline" id="dropline"></div>
148
174
  <div class="selection-cues" id="selectionCues"></div>
175
+ <div id="blockAnnotations" role="group" aria-label="Saved block comments"></div>
149
176
  <button class="comment-action" id="commentAction" title="Comment on this target" aria-label="Comment on this target">${iconMarkup("messageSquarePlus", { size: 17 })}</button>
150
177
  `;
151
178
 
@@ -166,8 +193,96 @@ const mountOverlay = () => {
166
193
  els.dropline = shadow.getElementById("dropline");
167
194
  els.selectionCues = shadow.getElementById("selectionCues");
168
195
  els.commentAction = shadow.getElementById("commentAction");
196
+ els.blockAnnotations = shadow.getElementById("blockAnnotations");
169
197
  };
170
198
 
199
+ function darkSurface(element) {
200
+ for (let probe = element; probe; probe = probe.parentElement) {
201
+ const color = getComputedStyle(probe).backgroundColor.match(/[\d.]+/g)?.map(Number);
202
+ if (color?.length >= 3 && (color.length < 4 || color[3] >= .5)) {
203
+ return color[0] * .2126 + color[1] * .7152 + color[2] * .0722 < 128;
204
+ }
205
+ }
206
+ return matchMedia("(prefers-color-scheme: dark)").matches;
207
+ }
208
+
209
+ function positionBlockBadge(badge, rect) {
210
+ const width = badge.offsetWidth || 36;
211
+ const height = 24;
212
+ const candidates = [
213
+ [rect.left - width - 5, rect.top], [rect.right + 5, rect.top],
214
+ [rect.right - width, rect.top - height - 5], [rect.right - width, rect.bottom + 5],
215
+ [innerWidth - width - 4, rect.top], [4, rect.top],
216
+ ];
217
+ const controls = "a[href], button, input, select, textarea, summary, [role=button], [role=tab], [contenteditable=true]";
218
+ const occupied = [...blockMarkers.values()].filter((entry) => entry.badge !== badge && entry.badge.style.display !== "none")
219
+ .map((entry) => entry.badge.getBoundingClientRect());
220
+ for (const [x, y] of candidates) {
221
+ if (x < 2 || y < 2 || x + width > innerWidth - 2 || y + height > innerHeight - 2) continue;
222
+ if (occupied.some((other) => x < other.right && x + width > other.left && y < other.bottom && y + height > other.top)) continue;
223
+ const blocked = [[x, y], [x + width / 2, y + height / 2], [x + width, y + height], [x, y + height], [x + width, y]]
224
+ .some(([left, top]) => document.elementsFromPoint(left, top).some((element) =>
225
+ !isOurs(element) && element !== document.body && element.closest(controls) !== document.body && element.closest(controls)));
226
+ if (blocked) continue;
227
+ badge.style.left = `${x}px`;
228
+ badge.style.top = `${y}px`;
229
+ badge.style.display = "block";
230
+ return;
231
+ }
232
+ // Never cover an authored control; the drawer remains an accessible fallback.
233
+ badge.style.display = "none";
234
+ }
235
+
236
+ function renderBlockAnnotations() {
237
+ if (!els.blockAnnotations || disposed) return;
238
+ const groups = groupCommentTargets(blockTargets);
239
+ for (const [element, entry] of blockMarkers) {
240
+ if (groups.has(element)) continue;
241
+ entry.marker.remove();
242
+ entry.badge.remove();
243
+ blockMarkers.delete(element);
244
+ }
245
+ for (const [element, ids] of groups) {
246
+ let entry = blockMarkers.get(element);
247
+ if (!entry) {
248
+ const marker = document.createElement("div");
249
+ marker.className = "block-marker";
250
+ marker.setAttribute("aria-hidden", "true");
251
+ const badge = document.createElement("button");
252
+ badge.className = "block-badge";
253
+ badge.type = "button";
254
+ badge.addEventListener("pointerdown", (event) => event.stopPropagation());
255
+ badge.addEventListener("click", (event) => {
256
+ event.stopPropagation();
257
+ hoverIntent?.cancel();
258
+ const currentIds = groupCommentTargets(blockTargets).get(element) || [];
259
+ const id = nextCommentId(currentIds, activeCommentId);
260
+ if (id) post("eh:activate", { id });
261
+ });
262
+ els.blockAnnotations.append(marker, badge);
263
+ entry = { marker, badge };
264
+ blockMarkers.set(element, entry);
265
+ }
266
+ const { marker, badge } = entry;
267
+ const active = ids.includes(activeCommentId);
268
+ const dark = darkSurface(element);
269
+ marker.dataset.active = String(active);
270
+ marker.dataset.dark = badge.dataset.dark = String(dark);
271
+ badge.setAttribute("aria-pressed", String(active));
272
+ const label = commentTargetFor(element)?.label || element.tagName.toLowerCase();
273
+ const name = `${ids.length} block comment${ids.length === 1 ? "" : "s"} on ${label}${ids.length > 1 ? "; activate to open next comment" : ""}`;
274
+ badge.setAttribute("aria-label", name);
275
+ badge.title = name;
276
+ badge.textContent = `◧ ${ids.length}`;
277
+ const rect = visibleRects([rectData(element.getBoundingClientRect())], { kind: "element", element })[0];
278
+ marker.style.display = badge.style.display = "none";
279
+ if (!rect) continue;
280
+ Object.assign(marker.style, { display: "block", left: `${rect.left}px`, top: `${rect.top}px`, width: `${rect.width}px`, height: `${rect.height}px` });
281
+ positionBlockBadge(badge, rect);
282
+ }
283
+ refreshGeometryWatch();
284
+ }
285
+
171
286
  function showMover(el) {
172
287
  if (!el || !el.isConnected) {
173
288
  els.mover.style.display = "none";
@@ -494,7 +609,7 @@ function geometryWatchSignature() {
494
609
  let activeState = null;
495
610
  if (activeCommentId) {
496
611
  const marks = marksFor(activeCommentId);
497
- const element = marks[0] || document.querySelector(`[data-eh-el="${CSS.escape(activeCommentId)}"]`);
612
+ const element = marks[0] || blockTargets.get(activeCommentId);
498
613
  if (element) {
499
614
  const rects = marks.length
500
615
  ? marks.map((mark) => rectData(mark.getBoundingClientRect()))
@@ -523,11 +638,16 @@ function geometryWatchSignature() {
523
638
  return JSON.stringify({
524
639
  target: normalize(targetState),
525
640
  active: normalize(activeState),
641
+ blocks: [...groupCommentTargets(blockTargets).keys()].map((element) => {
642
+ const rect = element.getBoundingClientRect();
643
+ const clip = effectiveClipRect({ kind: "element", element });
644
+ return [rect.left, rect.top, rect.width, rect.height, clip, darkSurface(element)];
645
+ }),
526
646
  });
527
647
  }
528
648
 
529
649
  function refreshGeometryWatch() {
530
- const shouldWatch = !!(pending || retarget || activeCommentId);
650
+ const shouldWatch = !disposed && !!(pending || retarget || activeCommentId || blockTargets.size);
531
651
  if (!shouldWatch) {
532
652
  clearInterval(geometryWatchTimer);
533
653
  geometryWatchTimer = null;
@@ -537,7 +657,7 @@ function refreshGeometryWatch() {
537
657
  if (geometryWatchTimer) return;
538
658
  watchedGeometrySignature = geometryWatchSignature();
539
659
  geometryWatchTimer = setInterval(() => {
540
- if (!(pending || retarget || activeCommentId)) {
660
+ if (disposed || !(pending || retarget || activeCommentId || blockTargets.size)) {
541
661
  refreshGeometryWatch();
542
662
  return;
543
663
  }
@@ -545,6 +665,7 @@ function refreshGeometryWatch() {
545
665
  if (next === watchedGeometrySignature) return;
546
666
  watchedGeometrySignature = next;
547
667
  scheduleTargetGeometry();
668
+ renderBlockAnnotations();
548
669
  if (activeCommentId) activate(activeCommentId, false);
549
670
  }, 100);
550
671
  }
@@ -616,17 +737,6 @@ function flatten() {
616
737
  return { text, map };
617
738
  }
618
739
 
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
740
  /** Wrap a global offset span in <mark> elements, one per text node touched. */
631
741
  function wrapOffsets(map, start, end, id) {
632
742
  const marks = [];
@@ -917,6 +1027,129 @@ function flushSave() {
917
1027
  emitSave();
918
1028
  }
919
1029
 
1030
+ let activeCaptureRequest = null;
1031
+
1032
+ function watchObservedView() {
1033
+ if (!lastObservedView) lastObservedView = observeView(document);
1034
+ const scheduleViewCheck = () => {
1035
+ clearTimeout(viewChangeTimer);
1036
+ viewChangeTimer = setTimeout(() => {
1037
+ viewChangeTimer = null;
1038
+ if (disposed) return;
1039
+ const view = observeView(document);
1040
+ if (sameObservedView(lastObservedView, view)) return;
1041
+ lastObservedView = view;
1042
+ hoverIntent?.reset();
1043
+ post("eh:viewChanged", { view });
1044
+ }, 200);
1045
+ };
1046
+ viewObserver?.disconnect();
1047
+ viewObserver = new MutationObserver(scheduleViewCheck);
1048
+ viewObserver.observe(document.documentElement, {
1049
+ subtree: true,
1050
+ childList: true,
1051
+ attributes: true,
1052
+ attributeFilter: ["aria-selected", "hidden", "id", "aria-controls", "role", "aria-hidden"],
1053
+ });
1054
+ scheduleViewCheck();
1055
+ }
1056
+
1057
+ async function captureSnapshot(msg) {
1058
+ const requestId = typeof msg.requestId === "string" && msg.requestId.length <= 128 ? msg.requestId : null;
1059
+ const fail = (code, message) => post("eh:snapshot", { requestId, error: { code, message } });
1060
+ if (!requestId?.trim() || (msg.requireStable !== undefined && typeof msg.requireStable !== "boolean")) {
1061
+ fail("CAPTURE_INVALID_REQUEST", "A valid capture request ID and stability option are required.");
1062
+ return;
1063
+ }
1064
+ if (activeCaptureRequest) {
1065
+ if (activeCaptureRequest !== requestId) fail("CAPTURE_BUSY", "Another capture is in progress. Retry when it finishes.");
1066
+ return;
1067
+ }
1068
+ activeCaptureRequest = requestId;
1069
+ const started = Date.now();
1070
+ const href = location.href;
1071
+ let signature = null;
1072
+ let unchangedSince = started;
1073
+ let documentReady = false;
1074
+ try {
1075
+ flushSave();
1076
+ for (;;) {
1077
+ if (disposed || location.href !== href) {
1078
+ fail("CAPTURE_NAVIGATED", "The page navigated during capture. Recapture the current page.");
1079
+ return;
1080
+ }
1081
+ documentReady = document.readyState !== "loading" && !!document.body;
1082
+ if (documentReady) {
1083
+ const beforeView = observeView(document);
1084
+ const snapshot = captureSemanticSnapshot(document);
1085
+ const view = observeView(document);
1086
+ if (!sameObservedView(beforeView, view)) {
1087
+ fail("VIEW_CHANGED", "The visible tab changed during capture. Retry after the view settles.");
1088
+ return;
1089
+ }
1090
+ const capturedAt = Date.now();
1091
+ const next = JSON.stringify({ snapshot, view });
1092
+ const now = Date.now();
1093
+ if (next !== signature) {
1094
+ signature = next;
1095
+ unchangedSince = now;
1096
+ }
1097
+ if (msg.requireStable === false || now - unchangedSince >= CAPTURE_STABLE_MS) {
1098
+ // This orders queued edit messages before the snapshot; chrome still
1099
+ // owns the durable edit/autosave acknowledgement.
1100
+ flushSave();
1101
+ post("eh:snapshot", { requestId, snapshot, capturedAt, view });
1102
+ return;
1103
+ }
1104
+ }
1105
+ if (Date.now() - started >= CAPTURE_READY_TIMEOUT_MS) {
1106
+ fail(
1107
+ documentReady ? "CAPTURE_UNSTABLE" : "CAPTURE_NOT_READY",
1108
+ documentReady
1109
+ ? "The page is still changing. Retry, or explicitly capture its current state."
1110
+ : "The document is not ready. Retry after it has loaded."
1111
+ );
1112
+ return;
1113
+ }
1114
+ await new Promise((resolve) => setTimeout(resolve, 100));
1115
+ }
1116
+ } catch (error) {
1117
+ const semanticError = typeof error?.code === "string" && error.code.startsWith("SEMANTIC_CAPTURE_");
1118
+ fail(
1119
+ semanticError ? error.code : "CAPTURE_FAILED",
1120
+ semanticError ? String(error.message).slice(0, 500) : "The page could not be captured. Retry from the current page."
1121
+ );
1122
+ } finally {
1123
+ activeCaptureRequest = null;
1124
+ }
1125
+ }
1126
+
1127
+ function jumpToHistoryTarget(msg) {
1128
+ let success = false;
1129
+ try {
1130
+ if (typeof msg.selector === "string" && msg.selector.length > 0 && msg.selector.length <= 4096 &&
1131
+ typeof msg.text === "string" && msg.text.trim() && msg.text.length <= 200000) {
1132
+ const matches = document.querySelectorAll(msg.selector);
1133
+ if (matches.length === 1 && document.body.contains(matches[0]) && !isOurs(matches[0])) {
1134
+ const snapshot = captureSemanticSnapshot(document);
1135
+ const exact = snapshot.blocks.some((block) => {
1136
+ if (block.text !== msg.text) return false;
1137
+ if (typeof block.selector !== "string") return false;
1138
+ const current = document.querySelectorAll(block.selector);
1139
+ return current.length === 1 && current[0] === matches[0];
1140
+ });
1141
+ if (exact) {
1142
+ matches[0].scrollIntoView({ block: "center", inline: "nearest", behavior: "instant" });
1143
+ success = true;
1144
+ }
1145
+ }
1146
+ }
1147
+ } catch {
1148
+ // Stale, ambiguous, hidden, and invalid selectors remain unresolved.
1149
+ }
1150
+ post("eh:historyJumpResult", { success, ...(!success ? { reason: "unresolved" } : {}) });
1151
+ }
1152
+
920
1153
  // ------------------------------------------------------------- edit tracking
921
1154
 
922
1155
  /**
@@ -950,6 +1183,7 @@ function queueEdit(payload) {
950
1183
  // ------------------------------------------------------------- interactions
951
1184
 
952
1185
  function clearPending({ keepRetarget = false } = {}) {
1186
+ hoverIntent?.reset();
953
1187
  commentOpenRequestGeneration = null;
954
1188
  pending = null;
955
1189
  if (!keepRetarget) retarget = null;
@@ -1003,17 +1237,32 @@ function settleSelection() {
1003
1237
  if (!quote.trim()) return false;
1004
1238
 
1005
1239
  const { text, map } = flatten();
1006
- const offsets = offsetsFromRange(map, range);
1240
+ const offsets = normalizeSelectionRange(map, range);
1007
1241
  if (!offsets) return false;
1008
1242
 
1009
1243
  const context = buildContext(text, offsets.start, offsets.end);
1244
+ if (!context.quote.trim()) return false;
1245
+ const existing = [retarget, pending].find((target) =>
1246
+ target?.kind === "selection" && sameRange(target.range, offsets.range) &&
1247
+ target.context.quote === context.quote &&
1248
+ target.context.prefix === context.prefix &&
1249
+ target.context.suffix === context.suffix
1250
+ );
1251
+ if (existing) {
1252
+ if (existing === pending && retarget) {
1253
+ retarget = null;
1254
+ observePendingTarget(pending);
1255
+ }
1256
+ scheduleTargetGeometry();
1257
+ return true;
1258
+ }
1010
1259
  targetGeneration += 1;
1011
1260
  const target = {
1012
1261
  kind: "selection",
1013
1262
  quote,
1014
1263
  anchor: { ...context, selector: cssPath(range.commonAncestorContainer.parentElement || document.body) },
1015
1264
  context,
1016
- range: range.cloneRange(),
1265
+ range: offsets.range,
1017
1266
  generation: targetGeneration,
1018
1267
  };
1019
1268
  if (composeOpen && pending) retarget = target;
@@ -1053,16 +1302,21 @@ function setElementTarget(container) {
1053
1302
  }
1054
1303
 
1055
1304
  function openPendingCompose() {
1056
- if (!pending && !retarget) {
1305
+ hoverIntent?.cancel();
1306
+ if (composing || disposed) return false;
1307
+ const selection = document.getSelection();
1308
+ if (selection && !selection.isCollapsed && selection.rangeCount) {
1057
1309
  if (!settleSelection()) return false;
1058
- }
1310
+ } else if (!pending && !retarget) return false;
1059
1311
  const target = retarget || pending;
1312
+ clearTimeout(selectionTimer);
1060
1313
  commentOpenRequestGeneration = target.generation;
1061
1314
  postTarget("eh:openComment", target);
1062
1315
  return true;
1063
1316
  }
1064
1317
 
1065
1318
  function acceptCommentOpen(msg) {
1319
+ hoverIntent?.cancel();
1066
1320
  const requested = Number(msg.requestedGeneration);
1067
1321
  if (commentOpenRequestGeneration === requested) commentOpenRequestGeneration = null;
1068
1322
  if (!msg.accepted) {
@@ -1115,6 +1369,7 @@ function deactivateComment() {
1115
1369
  activeCommentId = null;
1116
1370
  for (const mark of document.querySelectorAll(`mark[${MARK_ATTR}]`)) mark.classList.remove("eh-active");
1117
1371
  place(els.activeBox, null);
1372
+ renderBlockAnnotations();
1118
1373
  refreshGeometryWatch();
1119
1374
  }
1120
1375
 
@@ -1153,7 +1408,7 @@ function revealTarget(generation) {
1153
1408
  function commitPending(id, generation, { restoreFocus = false } = {}) {
1154
1409
  if (!pending || (generation && generation !== pending.generation)) return;
1155
1410
  if (pending.kind === "element") {
1156
- if (pending.element && pending.element.isConnected) pending.element.setAttribute("data-eh-el", id);
1411
+ if (pending.element?.isConnected) blockTargets.set(id, pending.element);
1157
1412
  } else {
1158
1413
  const { text, map } = flatten();
1159
1414
  const hit = findQuote(text, pending.context);
@@ -1165,6 +1420,7 @@ function commitPending(id, generation, { restoreFocus = false } = {}) {
1165
1420
  if (sel) sel.removeAllRanges();
1166
1421
  }
1167
1422
  clearPending({ keepRetarget: true });
1423
+ renderBlockAnnotations();
1168
1424
  }
1169
1425
 
1170
1426
  function reanchor(comments) {
@@ -1176,8 +1432,11 @@ function reanchor(comments) {
1176
1432
  }
1177
1433
  for (const id of staleMarks) unwrap(id);
1178
1434
  for (const element of document.querySelectorAll("[data-eh-el]")) {
1179
- if (!authoritative.has(element.getAttribute("data-eh-el"))) element.removeAttribute("data-eh-el");
1435
+ const id = element.getAttribute("data-eh-el");
1436
+ if (authoritative.has(id) && !blockTargets.has(id)) blockTargets.set(id, element);
1437
+ element.removeAttribute("data-eh-el");
1180
1438
  }
1439
+ for (const id of blockTargets.keys()) if (!authoritative.has(id)) blockTargets.delete(id);
1181
1440
  if (activeCommentId && !authoritative.has(activeCommentId)) {
1182
1441
  const staleId = activeCommentId;
1183
1442
  activeCommentId = null;
@@ -1193,9 +1452,13 @@ function reanchor(comments) {
1193
1452
  continue;
1194
1453
  }
1195
1454
  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);
1455
+ let el = blockTargets.get(comment.id);
1456
+ if (!el?.isConnected) {
1457
+ try { el = comment.anchor?.selector ? document.querySelector(comment.anchor.selector) : null; }
1458
+ catch { el = null; }
1459
+ }
1460
+ if (el && !isOurs(el)) blockTargets.set(comment.id, el);
1461
+ else { el = null; blockTargets.delete(comment.id); }
1199
1462
  (el ? resolved : orphaned).push(comment.id);
1200
1463
  continue;
1201
1464
  }
@@ -1209,6 +1472,7 @@ function reanchor(comments) {
1209
1472
  (marks.length ? resolved : orphaned).push(comment.id);
1210
1473
  }
1211
1474
  post("eh:anchorStatus", { resolved, orphaned });
1475
+ renderBlockAnnotations();
1212
1476
  }
1213
1477
 
1214
1478
  function activate(id, scroll) {
@@ -1216,12 +1480,14 @@ function activate(id, scroll) {
1216
1480
  const marks = marksFor(id);
1217
1481
  let target = marks[0] || null;
1218
1482
  if (!target) {
1219
- const el = document.querySelector(`[data-eh-el="${CSS.escape(id)}"]`);
1483
+ const el = blockTargets.get(id);
1220
1484
  if (el) target = el;
1221
1485
  }
1222
1486
  for (const mark of document.querySelectorAll(`mark[${MARK_ATTR}]`)) mark.classList.remove("eh-active");
1223
1487
  for (const mark of marks) mark.classList.add("eh-active");
1224
1488
  place(els.activeBox, target);
1489
+ els.activeBox.style.borderColor = target && darkSurface(target) ? "#ffcb66" : "#a96100";
1490
+ renderBlockAnnotations();
1225
1491
  const rects = marks.length
1226
1492
  ? marks.map((mark) => rectData(mark.getBoundingClientRect()))
1227
1493
  : target
@@ -1259,8 +1525,8 @@ function activate(id, scroll) {
1259
1525
  }
1260
1526
  // scrollIntoView walks every scrollable ancestor, so targets inside an
1261
1527
  // 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);
1528
+ target.scrollIntoView({ block: "center", behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
1529
+ setTimeout(() => { if (!disposed && activeCommentId === id) activate(id, false); }, 180);
1264
1530
  }
1265
1531
  }
1266
1532
 
@@ -1284,6 +1550,7 @@ function boot() {
1284
1550
  baseline = serialize();
1285
1551
  bootSnapshot = baseline;
1286
1552
  watchSelfRendering();
1553
+ watchObservedView();
1287
1554
 
1288
1555
  const notifyChromeInteraction = (event) => {
1289
1556
  if (isOurs(event.target)) return;
@@ -1297,7 +1564,14 @@ function boot() {
1297
1564
  if (isOurs(event.target) || resizing || Date.now() < suppressUntil) return;
1298
1565
  setTimeout(() => {
1299
1566
  if (settleSelection()) return;
1300
- if (!composeOpen && pending?.kind === "selection") clearPending();
1567
+ if (!composeOpen && !commentOpenRequestGeneration) {
1568
+ if (pending?.kind === "selection") clearPending();
1569
+ if (!selectionIsActive()) {
1570
+ const target = event.target?.isConnected && commentTargetFor(event.target);
1571
+ if (target) setElementTarget(target);
1572
+ else restoreElementCommentTarget();
1573
+ }
1574
+ }
1301
1575
  }, 0);
1302
1576
  });
1303
1577
 
@@ -1409,8 +1683,27 @@ function boot() {
1409
1683
  post("eh:navigate", { href });
1410
1684
  });
1411
1685
 
1412
- document.addEventListener("mouseover", (event) => {
1413
- if (isOurs(event.target) || resizing || moving) return;
1686
+ const inCommentApproach = (event) => pending?.kind === "element" &&
1687
+ pending.element.isConnected &&
1688
+ pointInCommentApproach(
1689
+ { x: event.clientX, y: event.clientY },
1690
+ pending.element.getBoundingClientRect(),
1691
+ els.commentAction.getBoundingClientRect()
1692
+ );
1693
+ const clearHover = () => {
1694
+ hoverTarget = hoverMove = hoverMedia = null;
1695
+ place(els.outline, null);
1696
+ showChip(null);
1697
+ showMover(null);
1698
+ showGrip(null);
1699
+ showHint("");
1700
+ if (!composeOpen && !commentOpenRequestGeneration && pending?.kind === "element" &&
1701
+ !pending.element?.contains(document.activeElement)) clearPending();
1702
+ };
1703
+ hoverIntent = createHoverIntent((event) => {
1704
+ if (disposed || pointerSelecting || composing || composeOpen || selectionIsActive()) return;
1705
+ if (!event) { clearHover(); return; }
1706
+ if (!event.target?.isConnected) { hoverIntent.reset(); clearHover(); return; }
1414
1707
  const target = commentTargetFor(event.target);
1415
1708
  hoverTarget = target ? target.el : null;
1416
1709
  // The move handle works per element, not per labeled container, so each
@@ -1419,7 +1712,7 @@ function boot() {
1419
1712
  hoverMedia = event.target.closest ? event.target.closest("img, video") : null;
1420
1713
  place(els.outline, hoverTarget);
1421
1714
  const selectionActive = selectionIsActive();
1422
- if (!selectionActive && target && !composeOpen) setElementTarget(target);
1715
+ if (!selectionActive && target && !composeOpen && !commentOpenRequestGeneration) setElementTarget(target);
1423
1716
  if (reviewMode !== "edit" || controlsAreSuppressed()) hideActionControls();
1424
1717
  else {
1425
1718
  showChip(hoverTarget);
@@ -1430,29 +1723,71 @@ function boot() {
1430
1723
  const draggable = hoverMedia && hoverMedia.tagName === "IMG";
1431
1724
  showHint(reviewMode === "edit" && interactive ? "⌘-click to open" : reviewMode === "edit" && draggable ? "Drag to move" : "", event.clientX, event.clientY);
1432
1725
  });
1726
+ const updateHover = (event) => {
1727
+ if (disposed || resizing || moving || pointerSelecting || composing || composeOpen ||
1728
+ commentOpenRequestGeneration || selectionIsActive()) { hoverIntent.cancel(); return; }
1729
+ if (isOurs(event.target)) { hoverIntent.cancel(); return; }
1730
+ // The committed paragraph owns the entire trip to its action, including
1731
+ // intervening whitespace/containers. Leaving the corridor starts new dwell.
1732
+ if (pending?.kind === "element" && !pending.element.contains(event.target) && inCommentApproach(event)) {
1733
+ hoverIntent.cancel();
1734
+ return;
1735
+ }
1736
+ const target = commentTargetFor(event.target);
1737
+ hoverIntent.request(target?.el || null, target ? {
1738
+ target: event.target, clientX: event.clientX, clientY: event.clientY,
1739
+ } : null);
1740
+ };
1741
+ document.addEventListener("mouseover", updateHover);
1742
+ document.addEventListener("mousemove", updateHover);
1743
+
1744
+ document.addEventListener("pointerdown", (event) => {
1745
+ hoverIntent.cancel();
1746
+ if (!isOurs(event.target) && event.button === 0) pointerSelecting = true;
1747
+ }, true);
1748
+ document.addEventListener("pointerup", () => { pointerSelecting = false; }, true);
1749
+ document.addEventListener("pointercancel", () => { pointerSelecting = false; hoverIntent.reset(); }, true);
1750
+ document.addEventListener("compositionstart", () => {
1751
+ composing = true;
1752
+ hoverIntent.reset();
1753
+ place(els.outline, null);
1754
+ }, true);
1755
+ document.addEventListener("compositionend", () => { composing = false; }, true);
1433
1756
 
1434
1757
  document.addEventListener("focusin", (event) => {
1435
- if (isOurs(event.target) || selectionIsActive() || composeOpen) return;
1758
+ hoverIntent.reset();
1759
+ if (isOurs(event.target) || selectionIsActive() || composeOpen || composing) return;
1436
1760
  const target = commentTargetFor(event.target);
1437
1761
  if (target) setElementTarget(target);
1438
1762
  });
1439
1763
 
1440
1764
  document.addEventListener("mouseleave", () => {
1441
1765
  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();
1766
+ hoverIntent.request(null, null);
1767
+ });
1768
+ const cancelHover = () => {
1769
+ hoverIntent.reset();
1770
+ pointerSelecting = false;
1771
+ clearHover();
1772
+ };
1773
+ window.addEventListener("blur", cancelHover);
1774
+ window.addEventListener("hashchange", cancelHover);
1775
+ window.addEventListener("popstate", cancelHover);
1776
+ window.addEventListener("pagehide", () => {
1777
+ disposed = true;
1778
+ cancelHover();
1779
+ clearTimeout(selectionTimer);
1780
+ clearTimeout(viewChangeTimer);
1781
+ viewObserver?.disconnect();
1782
+ clearTimeout(controlRestoreTimer);
1783
+ clearInterval(geometryWatchTimer);
1784
+ geometryWatchTimer = null;
1785
+ disconnectTargetObservers();
1786
+ });
1787
+ window.addEventListener("pageshow", () => {
1788
+ disposed = false;
1789
+ watchObservedView();
1790
+ renderBlockAnnotations();
1456
1791
  });
1457
1792
 
1458
1793
  els.commentAction.addEventListener("pointerdown", (event) => {
@@ -1565,6 +1900,15 @@ function boot() {
1565
1900
  return !!(sel && !sel.isCollapsed && sel.rangeCount && document.body.contains(sel.getRangeAt(0).commonAncestorContainer));
1566
1901
  };
1567
1902
 
1903
+ const restoreElementCommentTarget = () => {
1904
+ if (composeOpen || composing || pointerSelecting || commentOpenRequestGeneration || selectionIsActive()) return;
1905
+ const focused = document.activeElement;
1906
+ const target = focused && focused !== document.body && focused !== document.documentElement && !isOurs(focused)
1907
+ ? commentTargetFor(focused)
1908
+ : hoverTarget?.isConnected ? commentTargetFor(hoverTarget) : null;
1909
+ if (target) setElementTarget(target);
1910
+ };
1911
+
1568
1912
  const controlsAreSuppressed = () => Date.now() < typingUntil || selectionIsActive();
1569
1913
 
1570
1914
  const hideActionControls = () => {
@@ -1599,16 +1943,20 @@ function boot() {
1599
1943
  );
1600
1944
 
1601
1945
  document.addEventListener("selectionchange", () => {
1946
+ hoverIntent.cancel();
1947
+ if (selectionIsActive()) place(els.outline, null);
1602
1948
  clearTimeout(selectionTimer);
1603
- if (!composeOpen && selectionIsActive()) clearPending();
1604
- else els.commentAction.style.display = "none";
1949
+ if (!commentOpenRequestGeneration) els.commentAction.style.display = "none";
1605
1950
  selectionTimer = setTimeout(() => {
1606
1951
  if (selectionIsActive()) {
1607
1952
  hideActionControls();
1608
- settleSelection();
1953
+ if (!settleSelection() && !composeOpen && !commentOpenRequestGeneration) clearPending();
1609
1954
  } else {
1610
1955
  restoreActionControls();
1611
- if (!composeOpen && pending?.kind === "selection") clearPending();
1956
+ if (!composeOpen && !commentOpenRequestGeneration) {
1957
+ if (pending?.kind === "selection") clearPending();
1958
+ restoreElementCommentTarget();
1959
+ }
1612
1960
  }
1613
1961
  }, 40);
1614
1962
  });
@@ -2097,7 +2445,9 @@ function boot() {
2097
2445
  });
2098
2446
 
2099
2447
  const reposition = () => {
2448
+ hoverIntent.cancel();
2100
2449
  place(els.outline, hoverTarget);
2450
+ renderBlockAnnotations();
2101
2451
  if (reviewMode === "edit" && !controlsAreSuppressed()) {
2102
2452
  showChip(hoverTarget);
2103
2453
  showMover(moving ? null : hoverMove);
@@ -2184,11 +2534,14 @@ function boot() {
2184
2534
  break;
2185
2535
  case "eh:modeMenuState":
2186
2536
  modeMenuOpen = !!msg.open;
2537
+ hoverIntent.reset();
2187
2538
  break;
2188
2539
  case "eh:remove":
2189
2540
  unwrap(msg.id);
2190
- document.querySelectorAll(`[data-eh-el="${CSS.escape(msg.id)}"]`).forEach((el) => el.removeAttribute("data-eh-el"));
2541
+ blockTargets.delete(msg.id);
2542
+ if (activeCommentId === msg.id) activeCommentId = null;
2191
2543
  place(els.activeBox, null);
2544
+ renderBlockAnnotations();
2192
2545
  break;
2193
2546
  case "eh:activate":
2194
2547
  activate(msg.id, !!msg.scroll);
@@ -2197,7 +2550,17 @@ function boot() {
2197
2550
  flushSave();
2198
2551
  // Posted after the flushed eh:edit/eh:html messages, so when the
2199
2552
  // chrome sees it, everything pending has already been handed over.
2200
- post("eh:flushed", {});
2553
+ post("eh:flushed", {
2554
+ ...(typeof msg.requestId === "string" && msg.requestId.length > 0 && msg.requestId.length <= 128
2555
+ ? { requestId: msg.requestId }
2556
+ : {}),
2557
+ });
2558
+ break;
2559
+ case "eh:captureSnapshot":
2560
+ void captureSnapshot(msg);
2561
+ break;
2562
+ case "eh:historyJump":
2563
+ jumpToHistoryTarget(msg);
2201
2564
  break;
2202
2565
  case "eh:abortSave":
2203
2566
  // A revert is in flight: anything queued would re-write the edits.
@@ -2209,6 +2572,7 @@ function boot() {
2209
2572
  if (savePolicy === "writable") checkDynamic(String(msg.html || ""));
2210
2573
  break;
2211
2574
  case "eh:configureReview": {
2575
+ cancelHover();
2212
2576
  reviewMode = msg.mode === "edit" ? "edit" : "view";
2213
2577
  savePolicy = msg.savePolicy === "feedback-only" ? "feedback-only" : "writable";
2214
2578
  modeController.setMode(reviewMode);