@erdemtuna/doc-review 0.12.0 → 0.13.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 (49) hide show
  1. package/README.md +23 -25
  2. package/lib/SKILL.md +65 -202
  3. package/lib/agent-handoff.js +24 -0
  4. package/lib/agent-output.js +287 -0
  5. package/lib/anchor-text.js +31 -19
  6. package/lib/chrome-api.js +23 -4
  7. package/lib/chrome.html +0 -25
  8. package/lib/cli.js +282 -104
  9. package/lib/comment-target.js +4 -0
  10. package/lib/contracts/agent.js +122 -0
  11. package/lib/contracts/feedback.js +369 -1
  12. package/lib/contracts/frame.js +81 -0
  13. package/lib/contracts/history.js +189 -1
  14. package/lib/contracts/index.js +7 -2
  15. package/lib/contracts/page-boundary.js +187 -0
  16. package/lib/contracts/validation.js +200 -0
  17. package/lib/conversation-anchor-controller.js +84 -0
  18. package/lib/conversation-capture.js +81 -0
  19. package/lib/conversation-controller.js +798 -0
  20. package/lib/conversation-save.js +175 -0
  21. package/lib/conversation-server.js +184 -0
  22. package/lib/conversation-shell.js +1013 -0
  23. package/lib/conversation-store.js +809 -0
  24. package/lib/frame-controller.js +8 -8
  25. package/lib/frame-policy.js +1 -0
  26. package/lib/history-policy.js +4 -15
  27. package/lib/history-server.js +42 -326
  28. package/lib/html-transform.js +19 -4
  29. package/lib/icons.js +323 -0
  30. package/lib/new-message-target.js +27 -0
  31. package/lib/paths.js +2 -2
  32. package/lib/poll-transport.js +109 -26
  33. package/lib/positioning.js +51 -0
  34. package/lib/references/context-and-recovery.md +130 -0
  35. package/lib/references/response-contract.md +108 -0
  36. package/lib/references/source-edits.md +47 -0
  37. package/lib/revision-store.js +1 -1
  38. package/lib/save-controller.js +28 -14
  39. package/lib/sdk.js +357 -35
  40. package/lib/server.js +73 -595
  41. package/lib/setup.js +17 -48
  42. package/lib/state.js +21 -7
  43. package/lib/thread-anchor-controller.js +56 -0
  44. package/lib/toolbar-controller.js +3 -3
  45. package/lib/ui/THIRD_PARTY_NOTICES.md +127 -8
  46. package/lib/ui/chrome.css +1079 -2782
  47. package/lib/ui/chrome.js +81 -17
  48. package/package.json +2 -2
  49. package/lib/chrome-client.js +0 -1706
package/lib/sdk.js CHANGED
@@ -5,9 +5,10 @@
5
5
  * document: editing, highlights, target resolution and serialization. It never
6
6
  * talks to the server — everything crosses to the chrome page by postMessage.
7
7
  */
8
- import { buildContext, findQuote } from "./anchor-text.js";
8
+ import { buildContext, findQuote, resolveQuote } from "./anchor-text.js";
9
+ import { createThreadAnchorController } from "./thread-anchor-controller.js";
9
10
  import { hashClickAction, navigationHref } from "./click-target.js";
10
- import { acceptedOpenGeneration, createHoverIntent, groupCommentTargets, nextCommentId, normalizeSelectionRange, pointInCommentApproach, sameRange, targetMessage } from "./comment-target.js";
11
+ import { acceptedOpenGeneration, createHoverIntent, groupCommentTargets, nextCommentId, normalizeSelectionRange, pointInCommentApproach, sameRange, sameThreadTarget, targetMessage } from "./comment-target.js";
11
12
  import { classifyHref, externalHref, linkStyleFixup, listCommandFor, listStyleFixup, normalizeHref } from "./editing.js";
12
13
  import { frameMessage, initializeChannelFromDocument, matchesFrameMessage } from "./frame-channel.js";
13
14
  import { iconMarkup } from "./icons.js";
@@ -32,6 +33,7 @@ let targetGeneration = 0;
32
33
  let targetObserver = null;
33
34
  let targetIntersection = null;
34
35
  let reviewMode = "view";
36
+ let canComment = false;
35
37
  let savePolicy = "writable";
36
38
  let modeController = null;
37
39
  let hoverTarget = null;
@@ -41,6 +43,7 @@ let resizing = null; // live drag state while the grip is held
41
43
  let suppressUntil = 0; // ignore the mouseup/click that ends a resize drag
42
44
  let saveTimer = null;
43
45
  let composeOpen = false;
46
+ let retiredComposeGeneration = 0;
44
47
  let commentOpenRequestGeneration = null;
45
48
  let activeCommentId = null;
46
49
  let modeMenuOpen = false;
@@ -61,6 +64,8 @@ let themeRevision = 0;
61
64
  let themeWarnings = 0;
62
65
  const blockTargets = new Map();
63
66
  const blockMarkers = new Map();
67
+ let threadAnchors = null;
68
+ const threadTargets = new Map();
64
69
  /** True when the page's own scripts rewrote the DOM before any user edit. */
65
70
  let dynamic = false;
66
71
  const diagnostic = (event, detail = {}) => {
@@ -175,7 +180,7 @@ shadow.innerHTML = `
175
180
  .active { border: 2px solid var(--review-annotation-border); }
176
181
  .block-marker { position: fixed; pointer-events: none; z-index: 2147483644;
177
182
  box-sizing: border-box; border-left: 3px solid var(--review-annotation-border); background: var(--review-annotation-tint); }
178
- .block-marker[data-active="true"] { border-left-width: 6px; background: var(--review-annotation-active-tint); }
183
+ .block-marker[data-active="true"] { border-left-width: 3px; background: var(--review-annotation-active-tint); }
179
184
  .block-badge { position: fixed; z-index: 2147483647; pointer-events: auto;
180
185
  height: 24px; min-width: 32px; box-sizing: border-box; padding: 2px 6px;
181
186
  border: 1px solid var(--review-annotation-border); border-radius: 5px; background: var(--review-annotation-background); color: var(--review-annotation-foreground);
@@ -242,9 +247,12 @@ shadow.innerHTML = `
242
247
  button:focus-visible, input:focus-visible, .mover:focus-visible, .grip:focus-visible {
243
248
  outline: 3px solid var(--review-ring); outline-offset: 2px;
244
249
  }
245
- .box, .block-marker, .block-badge, .chip, .grip, .hint, .linkbox, .mover, .dropline, .comment-action {
250
+ .chip, .grip, .hint, .linkbox, .mover, .dropline, .comment-action {
246
251
  box-shadow: 0 0 0 1px var(--review-halo-light), 0 0 0 2px var(--review-halo-dark), 0 3px 10px var(--review-review-shadow-color);
247
252
  }
253
+ .block-marker { box-shadow: 0 0 0 1px var(--review-halo-light); }
254
+ .block-badge { box-shadow: 0 1px 4px var(--review-review-shadow-color); }
255
+ .box.active { box-shadow: 0 0 0 1px var(--review-halo-light); border-width: 1px; }
248
256
  .selection-cues { position: fixed; inset: 0; pointer-events: none; z-index: 2147483645; }
249
257
  .selection-cue { position: fixed; border-radius: 2px; background: var(--review-annotation-tint);
250
258
  box-shadow: inset 0 -2px var(--review-annotation-border), 0 1px var(--review-halo-light), 0 2px var(--review-halo-dark); }
@@ -267,7 +275,7 @@ shadow.innerHTML = `
267
275
  <div class="dropline" id="dropline"></div>
268
276
  <div class="selection-cues" id="selectionCues"></div>
269
277
  <div id="blockAnnotations" role="group" aria-label="Saved block comments"></div>
270
- <button class="comment-action" id="commentAction" title="Comment on this target" aria-label="Comment on this target">${iconMarkup("messageSquarePlus", { size: 17 })}</button>
278
+ <button class="comment-action" id="commentAction" disabled title="Comment on this target" aria-label="Comment on this target">${iconMarkup("messageSquarePlus", { size: 17 })}</button>
271
279
  `;
272
280
  const els = {};
273
281
  const mountOverlay = () => {
@@ -295,7 +303,8 @@ function positionBlockBadge(badge, rect) {
295
303
  const candidates = [
296
304
  [rect.left - width - 5, rect.top], [rect.right + 5, rect.top],
297
305
  [rect.right - width, rect.top - height - 5], [rect.right - width, rect.bottom + 5],
298
- [innerWidth - width - 4, rect.top], [4, rect.top],
306
+ [innerWidth - width - 4, Math.max(4, Math.min(innerHeight - height - 4, rect.top))],
307
+ [4, Math.max(4, Math.min(innerHeight - height - 4, rect.top))],
299
308
  ];
300
309
  const controls = "a[href], button, input, select, textarea, summary, [role=button], [role=tab], [contenteditable=true]";
301
310
  const occupied = [...blockMarkers.values()].filter((entry) => entry.badge !== badge && entry.badge.style.display !== "none")
@@ -344,7 +353,7 @@ function renderBlockAnnotations() {
344
353
  const currentIds = groupCommentTargets(blockTargets).get(element) || [];
345
354
  const id = nextCommentId(currentIds, activeCommentId);
346
355
  if (id)
347
- post("eh:activate", { id });
356
+ requestThreadActivation(id);
348
357
  });
349
358
  els.blockAnnotations.append(marker, badge);
350
359
  entry = { marker, badge };
@@ -355,9 +364,9 @@ function renderBlockAnnotations() {
355
364
  marker.dataset.active = String(active);
356
365
  badge.setAttribute("aria-pressed", String(active));
357
366
  const label = commentTargetFor(element)?.label || element.tagName.toLowerCase();
358
- const name = `${ids.length} block comment${ids.length === 1 ? "" : "s"} on ${label}${ids.length > 1 ? "; activate to open next comment" : ""}`;
367
+ const name = ids.length > 1 ? `Open ${ids.length} conversations` : "Open conversation";
359
368
  badge.setAttribute("aria-label", name);
360
- badge.title = name;
369
+ badge.title = `${name} on ${label}`;
361
370
  badge.textContent = `◧ ${ids.length}`;
362
371
  const rect = visibleRects([rectData(element.getBoundingClientRect())], { kind: "element", element })[0];
363
372
  marker.style.display = badge.style.display = "none";
@@ -366,6 +375,8 @@ function renderBlockAnnotations() {
366
375
  Object.assign(marker.style, { display: "block", left: `${rect.left}px`, top: `${rect.top}px`, width: `${rect.width}px`, height: `${rect.height}px` });
367
376
  positionBlockBadge(badge, rect);
368
377
  }
378
+ if (pending || retarget)
379
+ positionCommentAction(targetRects(retarget || pending));
369
380
  refreshGeometryWatch();
370
381
  }
371
382
  function showMover(el) {
@@ -602,13 +613,23 @@ function renderSelectionCues(rects) {
602
613
  function positionCommentAction(rects, target = retarget || pending) {
603
614
  const visible = visibleRects(rects, target);
604
615
  const rect = visible[visible.length - 1];
605
- if (!rect || (composeOpen && !retarget)) {
616
+ if (!canComment || !rect || (composeOpen && !retarget)) {
617
+ els.commentAction.style.display = "none";
618
+ return false;
619
+ }
620
+ const x = Math.max(4, Math.min(window.innerWidth - 34, rect.right + 6));
621
+ const y = Math.max(4, Math.min(window.innerHeight - 34, rect.top - 4));
622
+ const badges = [...blockMarkers.values()].filter(({ badge }) => badge.style.display !== "none")
623
+ .map(({ badge }) => badge.getBoundingClientRect());
624
+ const position = [[x, y], [x, y + 38], [x, y - 38], [x - 38, y]].find(([left, top]) => left >= 4 && top >= 4 && left + 30 <= innerWidth - 4 && top + 30 <= innerHeight - 4 &&
625
+ !badges.some((badge) => left < badge.right && left + 30 > badge.left && top < badge.bottom && top + 30 > badge.top));
626
+ if (!position) {
606
627
  els.commentAction.style.display = "none";
607
628
  return false;
608
629
  }
609
630
  els.commentAction.style.display = "flex";
610
- els.commentAction.style.left = `${Math.max(4, Math.min(window.innerWidth - 34, rect.right + 6))}px`;
611
- els.commentAction.style.top = `${Math.max(4, Math.min(window.innerHeight - 34, rect.top - 4))}px`;
631
+ els.commentAction.style.left = `${position[0]}px`;
632
+ els.commentAction.style.top = `${position[1]}px`;
612
633
  return true;
613
634
  }
614
635
  function postTarget(type, target = pending) {
@@ -714,7 +735,7 @@ function geometryWatchSignature() {
714
735
  });
715
736
  }
716
737
  function refreshGeometryWatch() {
717
- const shouldWatch = !disposed && !!(pending || retarget || activeCommentId || blockTargets.size);
738
+ const shouldWatch = !disposed && !!(pending || retarget || activeCommentId || blockTargets.size || threadAnchors?.projection?.anchors.length);
718
739
  if (!shouldWatch) {
719
740
  clearInterval(geometryWatchTimer);
720
741
  geometryWatchTimer = null;
@@ -725,10 +746,18 @@ function refreshGeometryWatch() {
725
746
  return;
726
747
  watchedGeometrySignature = geometryWatchSignature();
727
748
  geometryWatchTimer = setInterval(() => {
728
- if (disposed || !(pending || retarget || activeCommentId || blockTargets.size)) {
749
+ if (disposed || !(pending || retarget || activeCommentId || blockTargets.size || threadAnchors?.projection?.anchors.length)) {
729
750
  refreshGeometryWatch();
730
751
  return;
731
752
  }
753
+ if (threadAnchors?.projection) {
754
+ try {
755
+ threadAnchors.refresh();
756
+ }
757
+ catch (error) {
758
+ diagnostic("thread-boundary-rejected", { message: error.message });
759
+ }
760
+ }
732
761
  const next = geometryWatchSignature();
733
762
  if (next === watchedGeometrySignature)
734
763
  return;
@@ -878,6 +907,8 @@ function cssPath(el) {
878
907
  }
879
908
  /** The heading a block sits under, used to name edits in arbitrary HTML. */
880
909
  function precedingHeading(el) {
910
+ if (/^h[1-6]$/i.test(el.tagName) && el.textContent.trim())
911
+ return el.textContent.trim();
881
912
  let node = el;
882
913
  while (node && node !== document.body) {
883
914
  let sib = node.previousElementSibling;
@@ -932,7 +963,9 @@ function targetFor(node) {
932
963
  // one edit row, so number them.
933
964
  const twins = block.parentElement ? [...block.parentElement.children].filter((c) => c.tagName === block.tagName) : [];
934
965
  const ordinal = twins.length > 1 ? ` ${twins.indexOf(block) + 1}` : "";
935
- pinnedLabels.set(block, heading ? `${clip(heading, 26)} · ${tag}${ordinal}` : clip(block.textContent, 40) || tag);
966
+ const excerpt = clip(block.textContent.replace(/\s+/g, " ").trim(), 40);
967
+ pinnedLabels.set(block, heading ? `${clip(heading, 26)} · ${tag}${ordinal}: ${excerpt}`
968
+ : excerpt ? `${excerpt}${ordinal ? ` · ${tag}${ordinal}` : ""}` : `${tag}${ordinal}`);
936
969
  }
937
970
  return { el: block, label: pinnedLabels.get(block), authored: false };
938
971
  }
@@ -1267,9 +1300,12 @@ function restoreTargetFocus(target) {
1267
1300
  return;
1268
1301
  if (target.kind === "element" && target.element?.isConnected) {
1269
1302
  const element = target.element;
1303
+ const focused = document.activeElement;
1304
+ if (document.hasFocus() && focused !== document.body && focused !== element && !isOurs(focused))
1305
+ return;
1270
1306
  element.focus({ preventScroll: true });
1271
1307
  requestAnimationFrame(() => {
1272
- if (element.isConnected && document.activeElement !== element) {
1308
+ if (element.isConnected && document.activeElement === document.body) {
1273
1309
  element.focus({ preventScroll: true });
1274
1310
  }
1275
1311
  });
@@ -1297,6 +1333,8 @@ function restoreTargetFocus(target) {
1297
1333
  * contextual action or keyboard shortcut owns that transition.
1298
1334
  */
1299
1335
  function settleSelection() {
1336
+ if (!canComment)
1337
+ return false;
1300
1338
  const sel = document.getSelection();
1301
1339
  if (!sel || sel.isCollapsed || !sel.rangeCount)
1302
1340
  return false;
@@ -1347,6 +1385,8 @@ function settleSelection() {
1347
1385
  return true;
1348
1386
  }
1349
1387
  function setElementTarget(container) {
1388
+ if (!canComment)
1389
+ return;
1350
1390
  if (!container?.el ||
1351
1391
  (pending && pending.kind === "element" && pending.element === container.el) ||
1352
1392
  (retarget && retarget.kind === "element" && retarget.element === container.el)) {
@@ -1373,7 +1413,7 @@ function setElementTarget(container) {
1373
1413
  }
1374
1414
  function openPendingCompose() {
1375
1415
  hoverIntent?.cancel();
1376
- if (composing || disposed || !themeRevision)
1416
+ if (!canComment || composing || disposed || !themeRevision)
1377
1417
  return false;
1378
1418
  const selection = document.getSelection();
1379
1419
  if (selection && !selection.isCollapsed && selection.rangeCount) {
@@ -1391,6 +1431,8 @@ function openPendingCompose() {
1391
1431
  function acceptCommentOpen(msg) {
1392
1432
  hoverIntent?.cancel();
1393
1433
  const requested = Number(msg.requestedGeneration);
1434
+ if (requested <= retiredComposeGeneration)
1435
+ return;
1394
1436
  if (commentOpenRequestGeneration === requested)
1395
1437
  commentOpenRequestGeneration = null;
1396
1438
  if (!msg.accepted) {
@@ -1535,7 +1577,8 @@ function reanchor(comments) {
1535
1577
  let el = blockTargets.get(comment.id);
1536
1578
  if (!el?.isConnected) {
1537
1579
  try {
1538
- el = comment.anchor?.selector ? document.querySelector(comment.anchor.selector) : null;
1580
+ const matches = comment.anchor?.selector ? [...document.querySelectorAll(comment.anchor.selector)].filter((element) => !isOurs(element)) : [];
1581
+ el = matches.length === 1 ? matches[0] : null;
1539
1582
  }
1540
1583
  catch {
1541
1584
  el = null;
@@ -1559,9 +1602,145 @@ function reanchor(comments) {
1559
1602
  const marks = wrapOffsets(map, hit.start, hit.end, comment.id);
1560
1603
  (marks.length ? resolved : orphaned).push(comment.id);
1561
1604
  }
1562
- post("eh:anchorStatus", { resolved, orphaned });
1605
+ if (!threadAnchors?.projection)
1606
+ post("eh:anchorStatus", { resolved, orphaned });
1563
1607
  renderBlockAnnotations();
1564
1608
  }
1609
+ function hiddenThreadTarget(element) {
1610
+ for (let node = element; node; node = node.parentElement) {
1611
+ const style = getComputedStyle(node);
1612
+ if (node.hidden || node.getAttribute("aria-hidden") === "true" || style.display === "none" ||
1613
+ style.visibility === "hidden" || style.visibility === "collapse" ||
1614
+ style.contentVisibility === "hidden" || Number(style.opacity) === 0)
1615
+ return true;
1616
+ }
1617
+ return false;
1618
+ }
1619
+ const threadElements = new Map();
1620
+ function resolveThreadAnchor({ threadId, target }) {
1621
+ threadTargets.delete(threadId);
1622
+ const unavailable = (reason) => ({ threadId, state: "unavailable", reason });
1623
+ if (!document.body || document.readyState === "loading")
1624
+ return unavailable("render-loading");
1625
+ let resolved;
1626
+ if (target.kind === "element") {
1627
+ let candidates;
1628
+ try {
1629
+ candidates = [...document.querySelectorAll(target.anchor.selector)]
1630
+ .filter((element) => document.body.contains(element) && !isOurs(element));
1631
+ }
1632
+ catch (error) {
1633
+ if (error.name !== "SyntaxError")
1634
+ throw error;
1635
+ return unavailable("invalid-selector");
1636
+ }
1637
+ if (!candidates.length)
1638
+ return { threadId, state: "missing" };
1639
+ if (candidates.length > 1)
1640
+ return { threadId, state: "ambiguous", candidateCount: candidates.length };
1641
+ const previous = threadElements.get(threadId);
1642
+ if (previous && previous !== candidates[0])
1643
+ return unavailable("render-changed");
1644
+ threadElements.set(threadId, candidates[0]);
1645
+ if (hiddenThreadTarget(candidates[0]))
1646
+ return unavailable("hidden");
1647
+ resolved = { kind: "element", element: candidates[0] };
1648
+ }
1649
+ else {
1650
+ const { text, map } = flatten();
1651
+ const hit = resolveQuote(text, target.anchor);
1652
+ if (hit.state !== "found")
1653
+ return { threadId, ...hit };
1654
+ const entries = map.filter((entry) => entry.end > hit.start && entry.start < hit.end);
1655
+ if (!entries.length)
1656
+ return unavailable("not-measurable");
1657
+ if (entries.some((entry) => hiddenThreadTarget(entry.node.parentElement)))
1658
+ return unavailable("hidden");
1659
+ const range = document.createRange();
1660
+ range.setStart(entries[0].node, hit.start - entries[0].start);
1661
+ range.setEnd(entries.at(-1).node, hit.end - entries.at(-1).start);
1662
+ resolved = { kind: "selection", range };
1663
+ }
1664
+ const raw = targetRects(resolved).filter((rect) => rect.width > 0 && rect.height > 0);
1665
+ if (!raw.length)
1666
+ return unavailable("not-measurable");
1667
+ const viewport = viewportData();
1668
+ const relationTo = (clip) => raw.every((rect) => rect.bottom <= clip.top) ? "above"
1669
+ : raw.every((rect) => rect.top >= clip.bottom) ? "below"
1670
+ : raw.every((rect) => rect.right <= clip.left) ? "left"
1671
+ : raw.every((rect) => rect.left >= clip.right) ? "right" : "visible";
1672
+ const viewportRelation = relationTo({ left: 0, top: 0, right: viewport.width, bottom: viewport.height });
1673
+ const clip = effectiveClipRect(resolved);
1674
+ const relation = viewportRelation !== "visible" ? viewportRelation : clip ? relationTo(clip) : null;
1675
+ if (!relation)
1676
+ return unavailable("not-measurable");
1677
+ const rects = relation === "visible" ? raw.map((rect) => intersectRects(rect, clip)).filter(Boolean) : raw;
1678
+ if (!rects.length)
1679
+ return unavailable("not-measurable");
1680
+ threadTargets.set(threadId, resolved);
1681
+ return { threadId, state: "found", rects, viewport, relation };
1682
+ }
1683
+ threadAnchors = createThreadAnchorController({
1684
+ channel: frameMessage("scope"),
1685
+ resolve: resolveThreadAnchor,
1686
+ reconcile(projection, states) {
1687
+ const open = new Set(projection.anchors.filter(anchor => !anchor.resolved).map(anchor => anchor.threadId));
1688
+ const found = new Set(states.anchors.filter((anchor) => anchor.state === "found" && open.has(anchor.threadId)).map((anchor) => anchor.threadId));
1689
+ for (const threadId of found) {
1690
+ const target = threadTargets.get(threadId);
1691
+ if (target.kind === "element")
1692
+ blockTargets.set(threadId, target.element);
1693
+ else {
1694
+ const marks = marksFor(threadId);
1695
+ if (marks.length && (!marks.some((mark) => mark.contains(target.range.startContainer)) ||
1696
+ !marks.some((mark) => mark.contains(target.range.endContainer)) ||
1697
+ marks.map((mark) => mark.textContent).join("") !== target.range.toString()))
1698
+ unwrap(threadId);
1699
+ }
1700
+ }
1701
+ reanchor(projection.anchors.filter((anchor) => found.has(anchor.threadId)).map(({ threadId, target }) => ({
1702
+ id: threadId, kind: target.kind, anchor: target.anchor,
1703
+ })));
1704
+ for (const threadId of found) {
1705
+ const state = states.anchors.find((item) => item.threadId === threadId);
1706
+ const peers = states.anchors.filter((item) => found.has(item.threadId) && sameThreadTarget(item, state)).map((item) => item.threadId);
1707
+ const marks = marksFor(threadId);
1708
+ for (const [index, mark] of marks.entries()) {
1709
+ const nestedPeer = [...mark.querySelectorAll(`mark[${MARK_ATTR}]`)].some((nested) => peers.includes(nested.getAttribute(MARK_ATTR)));
1710
+ const interactive = index === 0 && !nestedPeer;
1711
+ if (mark.tabIndex !== (interactive ? 0 : -1))
1712
+ mark.tabIndex = interactive ? 0 : -1;
1713
+ const role = interactive ? "button" : "presentation";
1714
+ if (mark.getAttribute("role") !== role)
1715
+ mark.setAttribute("role", role);
1716
+ const label = peers.length > 1 ? `Open ${peers.length} conversations` : "Open conversation";
1717
+ if (interactive && mark.getAttribute("aria-label") !== label)
1718
+ mark.setAttribute("aria-label", label);
1719
+ if (!interactive && mark.hasAttribute("aria-label"))
1720
+ mark.removeAttribute("aria-label");
1721
+ }
1722
+ }
1723
+ },
1724
+ changed(_projection, states) {
1725
+ post("eh:threadAnchorStates", states);
1726
+ },
1727
+ });
1728
+ function threadAction(action, threadId) {
1729
+ const { anchors: _anchors, type: _type, ...scope } = threadAnchors.projection;
1730
+ return threadAnchors.action({ type: "eh:threadAction", ...scope, action, threadId });
1731
+ }
1732
+ function requestThreadActivation(id) {
1733
+ if (!threadAnchors.projection) {
1734
+ post("eh:activate", { id });
1735
+ return;
1736
+ }
1737
+ try {
1738
+ post("eh:threadAction", threadAction("activate", id));
1739
+ }
1740
+ catch (error) {
1741
+ diagnostic("thread-boundary-rejected", { message: error.message });
1742
+ }
1743
+ }
1565
1744
  function activate(id, scroll) {
1566
1745
  activeCommentId = id || null;
1567
1746
  const marks = marksFor(id);
@@ -1627,17 +1806,43 @@ const REVIEW_DOCUMENT_COLORS = {
1627
1806
  mark[${MARK_ATTR}] { background: #FFF1D9; color: #75470E; text-decoration: underline #915B13; }
1628
1807
  mark[${MARK_ATTR}]:hover, mark[${MARK_ATTR}].eh-active { background: #F5D6A3; }
1629
1808
  mark[${MARK_ATTR}].eh-active { outline: 2px solid #915B13; outline-offset: 1px; }
1809
+ body[contenteditable="true"] mark[${MARK_ATTR}] { color: inherit; background: transparent; text-decoration: none; outline: 1px dashed #915B13; outline-offset: 1px; }
1810
+ body[contenteditable="true"] mark[${MARK_ATTR}].eh-active { outline: 2px solid #915B13; }
1630
1811
  ::selection { background: #F5D6A3; color: #75470E; }
1631
1812
  `,
1632
1813
  dark: `
1633
1814
  mark[${MARK_ATTR}] { background: #3D3020; color: #F1CC8E; text-decoration: underline #E7BB72; }
1634
1815
  mark[${MARK_ATTR}]:hover, mark[${MARK_ATTR}].eh-active { background: #594325; }
1635
1816
  mark[${MARK_ATTR}].eh-active { outline: 2px solid #E7BB72; outline-offset: 1px; }
1817
+ body[contenteditable="true"] mark[${MARK_ATTR}] { color: inherit; background: transparent; text-decoration: none; outline: 1px dashed #E7BB72; outline-offset: 1px; }
1818
+ body[contenteditable="true"] mark[${MARK_ATTR}].eh-active { outline: 2px solid #E7BB72; }
1636
1819
  ::selection { background: #594325; color: #F1CC8E; }
1637
1820
  `,
1638
1821
  };
1639
1822
  /* REVIEW_THEME_DOCUMENT_END */
1640
1823
  function boot() {
1824
+ document.addEventListener("keydown", (event) => {
1825
+ if (!threadAnchors.projection || event.isComposing || event.keyCode === 229)
1826
+ return;
1827
+ const mark = event.target.closest?.(`mark[${MARK_ATTR}]`);
1828
+ if (mark && (event.key === "Enter" || event.key === " ")) {
1829
+ event.preventDefault();
1830
+ event.stopPropagation();
1831
+ requestThreadActivation(mark.getAttribute(MARK_ATTR));
1832
+ }
1833
+ else if (event.key === "Escape" && activeCommentId &&
1834
+ (isOurs(event.target) || !event.target.closest?.("input,textarea,select,[contenteditable='true']"))) {
1835
+ try {
1836
+ post("eh:threadAction", threadAction("dismiss", activeCommentId));
1837
+ deactivateComment();
1838
+ event.preventDefault();
1839
+ event.stopPropagation();
1840
+ }
1841
+ catch (error) {
1842
+ diagnostic("thread-boundary-rejected", { message: error.message });
1843
+ }
1844
+ }
1845
+ });
1641
1846
  mountOverlay();
1642
1847
  const style = document.createElement("style");
1643
1848
  style.setAttribute("data-eh-sdk", "");
@@ -1709,7 +1914,7 @@ function boot() {
1709
1914
  const mark = event.target.closest && event.target.closest(`mark[${MARK_ATTR}]`);
1710
1915
  if (mark && themeRevision) {
1711
1916
  event.preventDefault();
1712
- post("eh:activate", { id: mark.getAttribute(MARK_ATTR) });
1917
+ requestThreadActivation(mark.getAttribute(MARK_ATTR));
1713
1918
  return;
1714
1919
  }
1715
1920
  if (reviewMode === "view") {
@@ -2000,20 +2205,23 @@ function boot() {
2000
2205
  const target = targetFor(hoverTarget);
2001
2206
  const label = target ? target.label : "Element";
2002
2207
  const before = hoverTarget.textContent;
2208
+ const before_html = blockHtml(hoverTarget);
2003
2209
  hoverTarget.remove();
2004
2210
  hoverTarget = null;
2005
2211
  place(els.outline, null);
2006
2212
  showChip(null);
2007
- queueEdit({ label, kind: "deleted", before, after: "" });
2213
+ queueEdit({ label, kind: "deleted", before, before_html, after: "" });
2008
2214
  flushSave();
2009
2215
  });
2010
2216
  // beforeinput still sees the untouched wording, so capture it once per block.
2011
2217
  const originalText = new WeakMap();
2012
2218
  const originalHtml = new WeakMap();
2219
+ const capturedBlocks = new Set();
2013
2220
  const captureOriginal = (el) => {
2014
2221
  if (!originalText.has(el)) {
2015
2222
  originalText.set(el, el.textContent);
2016
2223
  originalHtml.set(el, blockHtml(el));
2224
+ capturedBlocks.add(el);
2017
2225
  }
2018
2226
  };
2019
2227
  /** An edit row for a block changed outside the input-event flow (attribute
@@ -2269,6 +2477,11 @@ function boot() {
2269
2477
  event.key.toLowerCase() === "m") {
2270
2478
  if (selectionIsActive())
2271
2479
  settleSelection();
2480
+ else if (event.target !== document.body && event.target !== document.documentElement) {
2481
+ const target = commentTargetFor(event.target);
2482
+ if (target)
2483
+ setElementTarget(target);
2484
+ }
2272
2485
  if (openPendingCompose()) {
2273
2486
  event.preventDefault();
2274
2487
  event.stopPropagation();
@@ -2463,6 +2676,16 @@ function boot() {
2463
2676
  // not HTML5 drag: the element is relocated on drop, never cloned, so
2464
2677
  // identity (labels, captured originals, comment anchors) survives the move.
2465
2678
  let moving = null; // { el, label, drop: { ref, before } | null } while the handle is held
2679
+ const cancelMove = () => {
2680
+ if (!moving)
2681
+ return;
2682
+ moving.preview.cancel();
2683
+ moving = null;
2684
+ showDropline(null);
2685
+ showMover(null);
2686
+ };
2687
+ window.addEventListener("pointercancel", cancelMove);
2688
+ window.addEventListener("blur", cancelMove);
2466
2689
  const dropPointFor = (x, y) => {
2467
2690
  const under = document.elementFromPoint(x, y);
2468
2691
  if (!under || isOurs(under))
@@ -2482,9 +2705,12 @@ function boot() {
2482
2705
  event.preventDefault();
2483
2706
  event.stopPropagation();
2484
2707
  const target = targetFor(el);
2485
- moving = { el, label: target ? target.label : "Block", drop: null };
2708
+ moving = {
2709
+ el, label: target ? target.label : "Block", drop: null,
2710
+ before: el.textContent, beforeHtml: blockHtml(el),
2711
+ preview: el.animate([{ opacity: 0.4 }, { opacity: 0.4 }], { duration: 1000, iterations: Infinity }),
2712
+ };
2486
2713
  captureOriginal(moving.el);
2487
- moving.el.style.opacity = "0.4";
2488
2714
  place(els.outline, null);
2489
2715
  showChip(null);
2490
2716
  try {
@@ -2504,11 +2730,11 @@ function boot() {
2504
2730
  window.addEventListener("pointerup", () => {
2505
2731
  if (!moving)
2506
2732
  return;
2507
- const { el, label, drop } = moving;
2733
+ const { el, label, drop, before, beforeHtml, preview } = moving;
2508
2734
  moving = null;
2509
2735
  showDropline(null);
2510
2736
  showMover(null);
2511
- el.style.opacity = "";
2737
+ preview.cancel();
2512
2738
  suppressUntil = Date.now() + 250;
2513
2739
  if (!drop || !drop.ref.isConnected || !el.isConnected)
2514
2740
  return;
@@ -2523,9 +2749,9 @@ function boot() {
2523
2749
  queueEdit({
2524
2750
  label,
2525
2751
  kind: "moved",
2526
- before: originalText.get(el),
2752
+ before,
2527
2753
  after: el.textContent,
2528
- before_html: originalHtml.get(el),
2754
+ before_html: beforeHtml,
2529
2755
  after_html: blockHtml(el),
2530
2756
  moved_after: prev ? clip(prev.textContent, 90) : "",
2531
2757
  moved_before: following ? clip(following.textContent, 90) : "",
@@ -2643,9 +2869,44 @@ function boot() {
2643
2869
  reposition();
2644
2870
  });
2645
2871
  };
2646
- document.addEventListener("scroll", scheduleReposition, true);
2647
- window.addEventListener("scroll", scheduleReposition, true);
2648
- window.addEventListener("resize", scheduleReposition);
2872
+ let readingAnchor = null;
2873
+ let readingWidth = innerWidth;
2874
+ const rememberPassage = () => {
2875
+ const selection = window.getSelection();
2876
+ let range = selection?.rangeCount && !selection.isCollapsed ? selection.getRangeAt(0).cloneRange() : null;
2877
+ const selected = range?.getBoundingClientRect();
2878
+ if (!selected || selected.bottom <= 0 || selected.top >= innerHeight) {
2879
+ range = document.caretRangeFromPoint?.(innerWidth / 2, innerHeight / 2) ?? null;
2880
+ }
2881
+ if (!range || range.startContainer.getRootNode() !== document || !range.startContainer.isConnected) {
2882
+ readingAnchor = null;
2883
+ return;
2884
+ }
2885
+ const rect = range.getBoundingClientRect();
2886
+ readingAnchor = rect.height ? { range, top: rect.top } : null;
2887
+ readingWidth = innerWidth;
2888
+ };
2889
+ const scrolled = () => {
2890
+ scheduleReposition();
2891
+ // A resize-generated scroll must not replace the anchor before restoration.
2892
+ if (innerWidth === readingWidth)
2893
+ rememberPassage();
2894
+ };
2895
+ document.addEventListener("scroll", scrolled, true);
2896
+ window.addEventListener("scroll", scrolled, true);
2897
+ document.addEventListener("selectionchange", () => { if (innerWidth === readingWidth)
2898
+ rememberPassage(); });
2899
+ window.addEventListener("resize", () => {
2900
+ if (innerWidth !== readingWidth && readingAnchor?.range.startContainer.isConnected) {
2901
+ const delta = readingAnchor.range.getBoundingClientRect().top - readingAnchor.top;
2902
+ if (Number.isFinite(delta))
2903
+ window.scrollBy({ top: delta, behavior: "instant" });
2904
+ }
2905
+ readingWidth = innerWidth;
2906
+ rememberPassage();
2907
+ scheduleReposition();
2908
+ });
2909
+ rememberPassage();
2649
2910
  let scrollQueued = false;
2650
2911
  window.addEventListener("scroll", () => {
2651
2912
  if (scrollQueued)
@@ -2665,11 +2926,40 @@ function boot() {
2665
2926
  if (!matchesFrameMessage(msg))
2666
2927
  return;
2667
2928
  switch (msg.type) {
2929
+ case "eh:threadAnchors":
2930
+ try {
2931
+ threadAnchors.project(msg);
2932
+ refreshGeometryWatch();
2933
+ }
2934
+ catch (error) {
2935
+ diagnostic("thread-boundary-rejected", { message: error.message });
2936
+ }
2937
+ break;
2938
+ case "eh:threadAction":
2939
+ try {
2940
+ const action = threadAnchors.action(msg);
2941
+ if (action.action === "dismiss")
2942
+ deactivateComment();
2943
+ else {
2944
+ if (action.action === "reveal")
2945
+ targetElement(threadTargets.get(action.threadId))?.scrollIntoView({ block: "center", inline: "nearest", behavior: "instant" });
2946
+ if (threadAnchors.projection.anchors.find(anchor => anchor.threadId === action.threadId)?.resolved)
2947
+ deactivateComment();
2948
+ else
2949
+ activate(action.threadId, false);
2950
+ threadAnchors.refresh();
2951
+ }
2952
+ }
2953
+ catch (error) {
2954
+ diagnostic("thread-boundary-rejected", { message: error.message });
2955
+ }
2956
+ break;
2668
2957
  case "eh:setTheme":
2669
2958
  applyReviewTheme(msg);
2670
2959
  break;
2671
2960
  case "eh:anchors":
2672
- reanchor(msg.comments || []);
2961
+ if (!threadAnchors.projection)
2962
+ reanchor(msg.comments || []);
2673
2963
  break;
2674
2964
  case "eh:commit":
2675
2965
  if (msg.targetGeneration && pending && msg.targetGeneration !== pending.generation)
@@ -2681,7 +2971,15 @@ function boot() {
2681
2971
  if (msg.targetGeneration && pending && msg.targetGeneration !== pending.generation)
2682
2972
  break;
2683
2973
  {
2974
+ // Cancellation can arrive before the new selection's debounced update.
2975
+ const selection = document.getSelection();
2976
+ if (selection?.rangeCount && !selection.isCollapsed &&
2977
+ (pending?.kind !== "selection" || !sameRange(selection.getRangeAt(0), pending.range)))
2978
+ settleSelection();
2684
2979
  const discardThrough = Number(msg.discardThroughGeneration) || Number(msg.targetGeneration) || 0;
2980
+ retiredComposeGeneration = Math.max(retiredComposeGeneration, discardThrough);
2981
+ if (commentOpenRequestGeneration <= discardThrough)
2982
+ commentOpenRequestGeneration = null;
2685
2983
  const newerRetarget = retarget && retarget.generation > discardThrough ? retarget : null;
2686
2984
  if (msg.restoreFocus && !newerRetarget)
2687
2985
  restoreTargetFocus(pending);
@@ -2719,7 +3017,8 @@ function boot() {
2719
3017
  renderBlockAnnotations();
2720
3018
  break;
2721
3019
  case "eh:activate":
2722
- activate(msg.id, !!msg.scroll);
3020
+ if (!threadAnchors.projection)
3021
+ activate(msg.id, !!msg.scroll);
2723
3022
  break;
2724
3023
  case "eh:flush":
2725
3024
  flushSave();
@@ -2748,9 +3047,20 @@ function boot() {
2748
3047
  checkDynamic(String(msg.html || ""));
2749
3048
  break;
2750
3049
  case "eh:configureReview": {
3050
+ if ((msg.mode !== "edit" && msg.mode !== "view") ||
3051
+ (msg.savePolicy !== "writable" && msg.savePolicy !== "feedback-only") || typeof msg.canComment !== "boolean") {
3052
+ console.warn("[doc-review-frame] Rejected invalid review configuration.");
3053
+ break;
3054
+ }
2751
3055
  cancelHover();
2752
- reviewMode = msg.mode === "edit" ? "edit" : "view";
2753
- savePolicy = msg.savePolicy === "feedback-only" ? "feedback-only" : "writable";
3056
+ reviewMode = msg.mode;
3057
+ savePolicy = msg.savePolicy;
3058
+ canComment = msg.canComment;
3059
+ els.commentAction.disabled = !canComment;
3060
+ if (!canComment) {
3061
+ clearTimeout(selectionTimer);
3062
+ clearPending();
3063
+ }
2754
3064
  modeController.setMode(reviewMode);
2755
3065
  if (reviewMode === "view") {
2756
3066
  showChip(null);
@@ -2759,7 +3069,7 @@ function boot() {
2759
3069
  els.linkbox.style.display = "none";
2760
3070
  }
2761
3071
  scheduleTargetGeometry();
2762
- post("eh:configurationApplied", { mode: reviewMode, savePolicy });
3072
+ post("eh:configurationApplied", { mode: reviewMode, savePolicy, canComment });
2763
3073
  break;
2764
3074
  }
2765
3075
  case "eh:restoreScroll":
@@ -2771,6 +3081,18 @@ function boot() {
2771
3081
  case "eh:assetFailed":
2772
3082
  pendingPastes.delete(msg.id);
2773
3083
  break;
3084
+ case "eh:submittedEdits": {
3085
+ if (!Array.isArray(msg.labels) || msg.labels.some((label) => typeof label !== "string"))
3086
+ break;
3087
+ const labels = new Set(msg.labels);
3088
+ for (const element of capturedBlocks)
3089
+ if (labels.has(pinnedLabels.get(element))) {
3090
+ originalText.delete(element);
3091
+ originalHtml.delete(element);
3092
+ capturedBlocks.delete(element);
3093
+ }
3094
+ break;
3095
+ }
2774
3096
  default:
2775
3097
  break;
2776
3098
  }