@plannotator/ui 0.53.1 → 0.55.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 (149) hide show
  1. package/HANDOFF.md +274 -14
  2. package/README.md +114 -11
  3. package/components/AgentToolAnnouncementDialog.tsx +1 -1
  4. package/components/AgentsTab.tsx +1 -1
  5. package/components/AnnotationPanel.tsx +142 -24
  6. package/components/AnnotationSidebar.tsx +1 -1
  7. package/components/AnnotationToolbar.tsx +64 -9
  8. package/components/ApproveDropdown.tsx +7 -9
  9. package/components/AskSessionAnnouncementDialog.tsx +1 -1
  10. package/components/AttachmentsButton.tsx +1 -1
  11. package/components/BlockRenderer.tsx +22 -5
  12. package/components/CodeFilePopout.tsx +139 -4
  13. package/components/CodeSnippetPreview.tsx +174 -0
  14. package/components/CommentPopover.tsx +44 -8
  15. package/components/DecisionControl.tsx +31 -5
  16. package/components/DeferredMarkdownContentsRail.tsx +38 -0
  17. package/components/DiagramBlock.tsx +17 -12
  18. package/components/DiffFileTree.tsx +7 -6
  19. package/components/DocumentContents.tsx +74 -0
  20. package/components/EditorAnnotationCard.tsx +1 -1
  21. package/components/ExportModal.tsx +77 -7
  22. package/components/HostTitle.tsx +0 -4
  23. package/components/ImageAnnotator/Toolbar.tsx +25 -1
  24. package/components/ImageAnnotator/index.tsx +24 -4
  25. package/components/ImageLightbox.tsx +722 -126
  26. package/components/ImagePeek.tsx +38 -4
  27. package/components/ImageRegions.tsx +369 -0
  28. package/components/ImageThumbnail.tsx +1 -1
  29. package/components/ImageZoomStage.tsx +796 -0
  30. package/components/InboxAnnouncementDialog.tsx +202 -0
  31. package/components/InlineMarkdown.tsx +167 -174
  32. package/components/KeyboardShortcuts.tsx +23 -0
  33. package/components/LazyBlockBoundary.tsx +100 -0
  34. package/components/ListMarker.tsx +3 -3
  35. package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
  36. package/components/MarkdownContentsRail.tsx +777 -0
  37. package/components/MenuVersionSection.tsx +31 -9
  38. package/components/PlanDecisionMenuExtras.tsx +129 -0
  39. package/components/PlanHeaderMenu.tsx +15 -1
  40. package/components/ProviderIcons.tsx +8 -0
  41. package/components/RefHoverCard.tsx +913 -0
  42. package/components/SendFailureToast.tsx +126 -0
  43. package/components/Settings.tsx +617 -126
  44. package/components/TableOfContents.tsx +20 -6
  45. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  46. package/components/ThemeProvider.tsx +14 -1
  47. package/components/ThemeTab.tsx +16 -24
  48. package/components/Tooltip.tsx +49 -18
  49. package/components/Viewer.tsx +288 -43
  50. package/components/ai/DocumentAIChatPanel.tsx +29 -3
  51. package/components/ai/NewChatButton.tsx +74 -0
  52. package/components/blocks/CodeBlock.tsx +1 -1
  53. package/components/blocks/HtmlBlock.tsx +7 -1
  54. package/components/blocks/QuestionBlock.tsx +532 -62
  55. package/components/diagram/DiagramComposer.tsx +5 -1
  56. package/components/diagram/DiagramPending.tsx +12 -2
  57. package/components/diagram/DiagramViewer.tsx +19 -3
  58. package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
  59. package/components/html-embed/SvgFenceBlock.tsx +46 -0
  60. package/components/html-embed/index.ts +3 -0
  61. package/components/html-embed/languages.ts +15 -0
  62. package/components/html-embed/loadGate.ts +107 -0
  63. package/components/html-viewer/HtmlViewer.tsx +82 -6
  64. package/components/html-viewer/bridge-script.asset.js +173 -1
  65. package/components/html-viewer/bridge-script.ts +173 -1
  66. package/components/html-viewer/useHtmlAnnotation.ts +125 -0
  67. package/components/imageZoom.ts +276 -0
  68. package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
  69. package/components/plan-diff/PlanDiffViewer.tsx +13 -2
  70. package/components/plan-diff/PlanRawDiffView.tsx +1 -1
  71. package/components/sidebar/FileBrowser.tsx +16 -2
  72. package/components/sidebar/SidebarTabs.tsx +3 -0
  73. package/config/index.ts +3 -0
  74. package/config/reviewView.ts +41 -1
  75. package/config/settings.ts +155 -4
  76. package/configure.ts +28 -0
  77. package/contents-rail.css +337 -0
  78. package/hooks/pfm/useCodeFilePopout.ts +7 -1
  79. package/hooks/useAIChat.ts +72 -25
  80. package/hooks/useAnnotationDraft.ts +71 -6
  81. package/hooks/useAnnotationHighlighter.ts +57 -3
  82. package/hooks/useCodeAnnotationDraft.ts +113 -17
  83. package/hooks/useCookieHostSetting.ts +96 -0
  84. package/hooks/useDocumentFont.ts +17 -0
  85. package/hooks/useDocumentTextSize.ts +20 -0
  86. package/hooks/useFileBrowser.ts +10 -1
  87. package/hooks/useHeadingScrollSpy.ts +77 -0
  88. package/hooks/useHoverPreview.ts +94 -0
  89. package/hooks/useLinkedDoc.ts +226 -13
  90. package/hooks/usePackageChangesSetting.ts +90 -0
  91. package/hooks/usePlanLookDialog.ts +67 -0
  92. package/hooks/usePrefersReducedMotion.ts +23 -0
  93. package/hooks/useSharing.ts +11 -7
  94. package/hooks/useSwipeStep.ts +597 -0
  95. package/hooks/useThemePaletteHalves.ts +37 -0
  96. package/hooks/useUpdateCheck.ts +22 -4
  97. package/hooks/useUpdateToast.ts +1 -1
  98. package/package.json +26 -23
  99. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
  100. package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
  101. package/shortcuts/core.ts +63 -4
  102. package/shortcuts/index.ts +1 -0
  103. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
  104. package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
  105. package/shortcuts/runtime.ts +4 -3
  106. package/styles.css +1 -1
  107. package/theme.css +179 -1
  108. package/themes/plannotator-graphite.css +66 -0
  109. package/types.ts +26 -9
  110. package/utils/agentSwitch.ts +41 -20
  111. package/utils/agentToolSetting.ts +1 -1
  112. package/utils/aiChatFormat.ts +23 -7
  113. package/utils/annotationHelpers.ts +32 -4
  114. package/utils/annotationOwnsHighlight.ts +4 -2
  115. package/utils/asciidocParser.ts +570 -0
  116. package/utils/autoUpdateNotice.ts +24 -0
  117. package/utils/belowTitleSlot.ts +174 -0
  118. package/utils/blockParse.ts +45 -0
  119. package/utils/codeLineScroll.ts +100 -0
  120. package/utils/commentSubmitKey.ts +48 -0
  121. package/utils/cssFontFamily.ts +65 -0
  122. package/utils/decisionSpec.ts +190 -2
  123. package/utils/defaultNotesApp.ts +1 -1
  124. package/utils/diffFonts.ts +4 -1
  125. package/utils/documentFont.ts +68 -0
  126. package/utils/documentTextSize.ts +54 -0
  127. package/utils/fontDetect.ts +63 -0
  128. package/utils/headingScrollSpy.ts +92 -0
  129. package/utils/inboxAnnouncement.ts +78 -0
  130. package/utils/lookAndFeelAnnouncement.ts +82 -5
  131. package/utils/markdownExtensions.ts +2 -2
  132. package/utils/mermaid.ts +20 -3
  133. package/utils/mermaidTheme.ts +3 -51
  134. package/utils/notion.ts +58 -0
  135. package/utils/parser.ts +28 -1
  136. package/utils/platform.ts +46 -0
  137. package/utils/refCard.ts +82 -0
  138. package/utils/richClipboard.ts +380 -0
  139. package/utils/richClipboardLists.ts +57 -0
  140. package/utils/richCopyPolicy.ts +22 -0
  141. package/utils/scrollAnchor.ts +82 -0
  142. package/utils/sectionRefs.ts +123 -0
  143. package/utils/selectionBoundary.ts +40 -0
  144. package/utils/sendFailure.ts +85 -0
  145. package/utils/sharing.ts +5 -2
  146. package/utils/themeRegistry.ts +10 -0
  147. package/utils/themeTokens.ts +74 -0
  148. package/utils/updateNotice.ts +30 -8
  149. package/utils/vscodePanel.ts +9 -0
@@ -460,6 +460,10 @@ export const BRIDGE_SCRIPT = `(function() {
460
460
  // --- Selection ---
461
461
  var pendingSelection = null;
462
462
  var pendingRange = null; // live range for the pending selection (scroll tracking)
463
+ // The pending text selection's own text, uncapped: what plain-text Copy
464
+ // writes. Range.toString() would lose the rendered line breaks between
465
+ // blocks and keep the source indentation.
466
+ var pendingCopyText = '';
463
467
  var pendingPinEl = null; // element pinned by a pinpoint click (outline + scroll tracking)
464
468
  var pendingPinAnchor = null; // serialized anchor for the pending pin
465
469
  var pendingPinKey = null; // target key for the primary pinpoint target (multi-select)
@@ -571,7 +575,8 @@ export const BRIDGE_SCRIPT = `(function() {
571
575
  }
572
576
  skipNextClear = false; // a real text selection happened
573
577
  var range = sel.getRangeAt(0);
574
- var text = capSelectionText(sel.toString().trim());
578
+ var fullText = sel.toString().trim();
579
+ var text = capSelectionText(fullText);
575
580
  if (!text) return false;
576
581
 
577
582
  // A draft now owns the surface: kill the click-to-select hover
@@ -580,6 +585,7 @@ export const BRIDGE_SCRIPT = `(function() {
580
585
 
581
586
  var rect = range.getBoundingClientRect();
582
587
  pendingRange = range;
588
+ pendingCopyText = fullText;
583
589
  pendingSelection = {
584
590
  text: text,
585
591
  startContainerPath: getNodePath(range.startContainer),
@@ -597,6 +603,13 @@ export const BRIDGE_SCRIPT = `(function() {
597
603
  targetKey: (extras && extras.targetKey) || undefined,
598
604
  targetLabel: (extras && extras.targetLabel) || undefined,
599
605
  context: (extras && extras.context) || undefined,
606
+ // This bridge answers rich-copy-request for a text selection (the
607
+ // toolbar's rich-text Copy). Optional and additive: an older bridge
608
+ // omits it and Copy stays plain text, so no protocol version bump.
609
+ richCopy: (extras && extras.pinpoint) ? undefined : true,
610
+ // The quote above was cut to MAX_SELECTION_TEXT: a plain-text copy made
611
+ // from it alone (no answer to rich-copy-request) says so.
612
+ textCut: text.length < fullText.length ? true : undefined,
600
613
  rect: { top: rect.top, left: rect.left, width: rect.width, height: rect.height }
601
614
  });
602
615
  renderAnnotationOverlay(); // draft selection highlight (overlay-projected)
@@ -645,6 +658,46 @@ export const BRIDGE_SCRIPT = `(function() {
645
658
  if (!e.data || !e.data.type) return;
646
659
  var type = e.data.type;
647
660
 
661
+ // Copy (rich text) was pressed in the parent: build the pending text
662
+ // range's markup now (never on selection). A throw or an oversized range
663
+ // answers without markup, so the parent copies plain text.
664
+ if (type === PREFIX + 'rich-copy-request') {
665
+ // Text and markup are each capped at 256 KiB: past it the text is cut
666
+ // (never at half a surrogate pair) and marked, and the markup is not
667
+ // built at all (too large for rich text).
668
+ var copyHtml = '';
669
+ var copyText = '';
670
+ var copyTruncated = false;
671
+ var copyTooLarge = false;
672
+ try {
673
+ if (pendingRange && pendingSelection && pendingSelection.startContainerPath) {
674
+ copyText = pendingCopyText || pendingRange.toString();
675
+ if (copyText.length > 262144) {
676
+ var copyCut = 262144;
677
+ var copyLast = copyText.charCodeAt(copyCut - 1);
678
+ if (copyLast >= 0xd800 && copyLast <= 0xdbff) copyCut -= 1;
679
+ copyText = copyText.slice(0, copyCut);
680
+ copyTruncated = true;
681
+ copyTooLarge = true;
682
+ } else {
683
+ copyHtml = htmlForRange(pendingRange);
684
+ if (copyHtml.length > 262144) copyTooLarge = true;
685
+ }
686
+ }
687
+ } catch (err) {
688
+ copyHtml = '';
689
+ }
690
+ postToParent({
691
+ type: PREFIX + 'rich-copy',
692
+ id: typeof e.data.id === 'string' ? e.data.id : '',
693
+ html: copyTooLarge ? '' : copyHtml,
694
+ text: copyText,
695
+ truncated: copyTruncated,
696
+ tooLarge: copyTooLarge
697
+ });
698
+ return;
699
+ }
700
+
648
701
  if (type === PREFIX + 'create-mark') {
649
702
  var id = e.data.id;
650
703
  var annType = e.data.annotationType || 'comment';
@@ -1362,6 +1415,10 @@ export const BRIDGE_SCRIPT = `(function() {
1362
1415
  return pinpointBoxEl;
1363
1416
  }
1364
1417
  function positionPinpointBox(el) {
1418
+ if (pendingPinHidden(el)) {
1419
+ if (pinpointBoxEl) pinpointBoxEl.style.display = 'none';
1420
+ return;
1421
+ }
1365
1422
  var r = el.getBoundingClientRect();
1366
1423
  var box = getPinpointBoxEl();
1367
1424
  box.style.display = 'block';
@@ -1515,6 +1572,8 @@ export const BRIDGE_SCRIPT = `(function() {
1515
1572
  if (annotateModeActive && currentInputMethod !== 'pinpoint' && lastPointer && !pendingPinEl && !pendingSelection) {
1516
1573
  setHoverHighlight(committedHighlightIdAtCached(lastPointer.x, lastPointer.y));
1517
1574
  }
1575
+ // Before the boxes: it may re-find a replaced target they then follow.
1576
+ reconcilePendingPinVisibility();
1518
1577
  positionMultiTargetBoxes();
1519
1578
  if (pendingPinEl && pendingPinEl.isConnected) {
1520
1579
  positionPinpointBox(pendingPinEl);
@@ -3507,7 +3566,91 @@ export const BRIDGE_SCRIPT = `(function() {
3507
3566
  }
3508
3567
  }
3509
3568
 
3569
+ // A draft's composer hides with its target and keeps the draft: a slide the
3570
+ // page hid (display:none, visibility:hidden, no box) is not a scroll-out.
3571
+ // Offscreen targets stay visible to support roaming multi-select. A target
3572
+ // the page REPLACED with a new element (a framework re-render, a live-app
3573
+ // route change) is found again through its anchor, the way restore re-finds
3574
+ // committed targets; one that cannot be found keeps the composer visible,
3575
+ // so a draft is never left where nobody can reach it.
3576
+ var pendingPinVisible = true;
3577
+ var pendingPinSearchGen = 0;
3578
+ function pendingPinHidden(el) {
3579
+ if (!el || !el.isConnected) return false;
3580
+ if (targetStyleHidden(el)) return true;
3581
+ var rect = el.getBoundingClientRect();
3582
+ return layoutActive() && !rect.width && !rect.height;
3583
+ }
3584
+
3585
+ function refindPendingTarget(anchor, taken) {
3586
+ var found = resolveAnchorElement(anchor);
3587
+ if (!found || isViewerOverlayNode(found)) return null;
3588
+ for (var i = 0; i < taken.length; i++) if (taken[i] === found) return null;
3589
+ return found;
3590
+ }
3591
+
3592
+ // Re-find detached draft targets, once per DOM generation each. Returns
3593
+ // whether the primary moved to a new element.
3594
+ function refreshPendingTargets() {
3595
+ var taken = [pendingPinEl];
3596
+ for (var t = 0; t < pendingMultiTargets.length; t++) taken.push(pendingMultiTargets[t].el);
3597
+ var moved = false;
3598
+ if (pendingPinEl && !pendingPinEl.isConnected && pendingPinAnchor && pendingPinSearchGen !== domGeneration) {
3599
+ pendingPinSearchGen = domGeneration;
3600
+ var found = refindPendingTarget(pendingPinAnchor, taken);
3601
+ if (found) {
3602
+ pendingPinEl = found;
3603
+ // The text range belonged to the replaced element: commit as an
3604
+ // element pin on the new one (as a promoted primary does).
3605
+ if (pendingRange && !rangeAlive(pendingRange)) pendingRange = null;
3606
+ if (pendingSelection && pendingSelection.startContainerPath) {
3607
+ pendingSelection = { element: true, text: pendingSelection.text };
3608
+ }
3609
+ moved = true;
3610
+ }
3611
+ }
3612
+ for (var i = 0; i < pendingMultiTargets.length; i++) {
3613
+ var entry = pendingMultiTargets[i];
3614
+ if (!entry.el || entry.el.isConnected || !entry.anchor || entry.searchGen === domGeneration) continue;
3615
+ entry.searchGen = domGeneration;
3616
+ var next = refindPendingTarget(entry.anchor, taken);
3617
+ if (next) { entry.el = next; taken.push(next); }
3618
+ }
3619
+ return moved;
3620
+ }
3621
+
3622
+ // Re-anchor the parent's composer on the pin (it cannot see the page).
3623
+ function postPendingPinRect() {
3624
+ if (!pendingSelection || !pendingPinEl || !pendingPinEl.isConnected) return;
3625
+ var tracked = pendingRange && rangeAlive(pendingRange) ? pendingRange : pendingPinEl;
3626
+ var r = tracked.getBoundingClientRect();
3627
+ postToParent({
3628
+ type: PREFIX + 'selection-rect',
3629
+ rect: { top: r.top, left: r.left, width: r.width, height: r.height }
3630
+ });
3631
+ }
3632
+
3633
+ function reconcilePendingPinVisibility() {
3634
+ if (!pendingPinEl) return;
3635
+ var moved = refreshPendingTargets();
3636
+ var visible = !pendingPinHidden(pendingPinEl);
3637
+ for (var i = 0; !visible && i < pendingMultiTargets.length; i++) {
3638
+ visible = !pendingPinHidden(pendingMultiTargets[i].el);
3639
+ }
3640
+ // A detached primary has no geometry: never leave its outline behind.
3641
+ if (!pendingPinEl.isConnected && pinpointBoxEl) pinpointBoxEl.style.display = 'none';
3642
+ var shown = visible && !pendingPinVisible;
3643
+ if (visible !== pendingPinVisible) {
3644
+ pendingPinVisible = visible;
3645
+ postToParent({ type: PREFIX + 'pin-visibility', visible: visible });
3646
+ if (!visible && pinpointBoxEl) pinpointBoxEl.style.display = 'none';
3647
+ }
3648
+ if (visible && (moved || shown)) postPendingPinRect();
3649
+ }
3650
+
3510
3651
  function clearPendingPin() {
3652
+ pendingPinVisible = true;
3653
+ pendingPinSearchGen = 0;
3511
3654
  pendingPinEl = null;
3512
3655
  pendingPinContext = null;
3513
3656
  pendingPinAnchor = null;
@@ -3563,6 +3706,7 @@ export const BRIDGE_SCRIPT = `(function() {
3563
3706
  }
3564
3707
 
3565
3708
  function positionBoxToEl(box, el) {
3709
+ if (pendingPinHidden(el)) { box.style.display = 'none'; return; }
3566
3710
  var r = el.getBoundingClientRect();
3567
3711
  box.style.display = 'block';
3568
3712
  box.style.left = r.left + 'px';
@@ -3694,6 +3838,7 @@ export const BRIDGE_SCRIPT = `(function() {
3694
3838
  */
3695
3839
  function removeMultiTargetByKey(key, echo) {
3696
3840
  if (!key) return;
3841
+ schedulePinpointReconcile();
3697
3842
  if (key === pendingPinKey) {
3698
3843
  if (!pendingMultiTargets.length) {
3699
3844
  // Last target gone — the draft is cancelled.
@@ -3710,6 +3855,7 @@ export const BRIDGE_SCRIPT = `(function() {
3710
3855
  var next = pendingMultiTargets.shift();
3711
3856
  destroyMultiTargetBox(next.box);
3712
3857
  pendingPinEl = next.el;
3858
+ pendingPinSearchGen = 0;
3713
3859
  pendingPinAnchor = next.anchor;
3714
3860
  pendingPinKey = next.key;
3715
3861
  pendingPinLabel = next.label;
@@ -3777,6 +3923,7 @@ export const BRIDGE_SCRIPT = `(function() {
3777
3923
  var key = makeTargetKey();
3778
3924
  var box = createMultiTargetBox(el);
3779
3925
  pendingMultiTargets.push({ key: key, el: el, anchor: anchor, label: label, text: text, point: point, context: context, box: box });
3926
+ schedulePinpointReconcile();
3780
3927
  postToParent({
3781
3928
  type: PREFIX + 'multi-target-added',
3782
3929
  key: key,
@@ -3911,6 +4058,8 @@ export const BRIDGE_SCRIPT = `(function() {
3911
4058
  multiSelectArmed = false;
3912
4059
  multiSelectMax = MAX_MULTI_TARGETS;
3913
4060
  pendingPinEl = el;
4061
+ pendingPinVisible = true;
4062
+ pendingPinSearchGen = 0;
3914
4063
  pendingPinAnchor = buildElementAnchor(el);
3915
4064
  pendingPinKey = makeTargetKey();
3916
4065
  pendingPinLabel = pinpointHoverLabel(el);
@@ -5312,6 +5461,29 @@ export const BRIDGE_SCRIPT = `(function() {
5312
5461
  if (document.body) document.body.removeAttribute('data-plannotator-vim-focus-owner');
5313
5462
  }
5314
5463
 
5464
+ // Selected markup for the parent's rich-text Copy button, built only when
5465
+ // the button is pressed (rich-copy-request). Keeps the enclosing formatting
5466
+ // that cloneContents omits for text endpoints (the same semantic ancestors
5467
+ // selectionRangeToHtml keeps) and absolutizes links.
5468
+ function htmlForRange(range) {
5469
+ var holder = document.createElement('div');
5470
+ var fragment = range.cloneContents();
5471
+ var ancestor = range.commonAncestorContainer.nodeType === 1
5472
+ ? range.commonAncestorContainer : range.commonAncestorContainer.parentElement;
5473
+ while (ancestor && ancestor !== document.body) {
5474
+ if (ancestor.matches('a,b,blockquote,code,del,em,h1,h2,h3,h4,h5,h6,i,li,ol,p,pre,s,strong,sub,sup,table,tbody,td,tfoot,th,thead,tr,u,ul')) {
5475
+ var wrapper = ancestor.cloneNode(false);
5476
+ while (fragment.firstChild) wrapper.appendChild(fragment.firstChild);
5477
+ fragment.appendChild(wrapper);
5478
+ }
5479
+ ancestor = ancestor.parentElement;
5480
+ }
5481
+ holder.appendChild(fragment);
5482
+ var links = holder.querySelectorAll('a[href]');
5483
+ for (var i = 0; i < links.length; i++) links[i].setAttribute('href', links[i].href);
5484
+ return holder.innerHTML;
5485
+ }
5486
+
5315
5487
  function copyVimText(text) {
5316
5488
  if (!text) return;
5317
5489
  postToParent({
@@ -9,6 +9,12 @@ import type {
9
9
  UseAnnotationHighlighterReturn,
10
10
  } from "../../hooks/useAnnotationHighlighter";
11
11
  import { BRIDGE_PROTOCOL_VERSION } from "./bridge-script";
12
+ import {
13
+ MAX_RICH_CLIPBOARD_HTML_LENGTH,
14
+ MAX_RICH_CLIPBOARD_OUTPUT_LENGTH,
15
+ sanitizeRichHtml,
16
+ type RichCopySource,
17
+ } from "../../utils/richClipboard";
12
18
  import {
13
19
  parseHtmlElementContext,
14
20
  MAX_ELEMENT_CONTEXT_BYTES,
@@ -90,8 +96,27 @@ interface BridgeSelectionMessage {
90
96
  targetLabel?: string;
91
97
  /** Agent-facing element description (pinpoint clicks) — validated, size-capped. */
92
98
  context?: HtmlElementContext;
99
+ /** The bridge answers `rich-copy-request` for this text selection. */
100
+ richCopy?: boolean;
101
+ /** `text` was cut to MAX_SELECTION_TEXT_LENGTH (the page's selection is longer). */
102
+ textCut?: boolean;
93
103
  }
94
104
 
105
+ /** The bridge's answer to `rich-copy-request`: the pending range's markup
106
+ * (sanitized again here) and its text up to 256 KiB (`truncated` past it),
107
+ * or too large for rich text. */
108
+ interface BridgeRichCopyMessage {
109
+ type: `${typeof PREFIX}rich-copy`;
110
+ id: string;
111
+ html: string;
112
+ text: string;
113
+ truncated: boolean;
114
+ tooLarge: boolean;
115
+ }
116
+
117
+ /** How long the rich-text Copy waits for the bridge before copying the quote as plain text. */
118
+ export const RICH_COPY_TIMEOUT_MS = 1500;
119
+
95
120
  /** One draft target in an in-flight multi-select comment. Index 0 is primary. */
96
121
  export interface HtmlDraftTarget {
97
122
  key: string;
@@ -119,10 +144,12 @@ interface BridgeRect {
119
144
 
120
145
  type BridgeMessage =
121
146
  | BridgeSelectionMessage
147
+ | BridgeRichCopyMessage
122
148
  | BridgeMultiTargetAddedMessage
123
149
  | { type: `${typeof PREFIX}multi-target-removed`; key: string }
124
150
  | { type: `${typeof PREFIX}pointer`; x: number; y: number; shift: boolean }
125
151
  | { type: `${typeof PREFIX}selection-clear` }
152
+ | { type: `${typeof PREFIX}pin-visibility`; visible: boolean }
126
153
  | { type: `${typeof PREFIX}selection-rect`; rect: BridgeRect }
127
154
  | { type: `${typeof PREFIX}keytype`; key: string }
128
155
  | { type: `${typeof PREFIX}mark-click`; id: string }
@@ -363,6 +390,8 @@ export function parseBridgeMessage(value: unknown): BridgeMessage | null {
363
390
  return {
364
391
  type: value.type,
365
392
  text: capSelectionText(value.text),
393
+ ...(value.richCopy === true ? { richCopy: true } : {}),
394
+ ...(value.textCut === true ? { textCut: true } : {}),
366
395
  rect,
367
396
  modeOverride: parseEditorMode(value.modeOverride),
368
397
  anchor: parseHtmlElementAnchor(value.anchor) ?? undefined,
@@ -395,6 +424,24 @@ export function parseBridgeMessage(value: unknown): BridgeMessage | null {
395
424
  : null;
396
425
  case `${PREFIX}selection-clear`:
397
426
  return { type: value.type };
427
+ case `${PREFIX}rich-copy`: {
428
+ // Oversized or malformed markup reads as none (plain text is copied).
429
+ if (typeof value.id !== "string" || value.id.length === 0 || value.id.length > 64) return null;
430
+ const text = typeof value.text === "string" && value.text.length <= MAX_RICH_CLIPBOARD_HTML_LENGTH
431
+ ? value.text : "";
432
+ const html = typeof value.html === "string" && value.html.length <= MAX_RICH_CLIPBOARD_HTML_LENGTH
433
+ ? value.html : "";
434
+ return {
435
+ type: value.type,
436
+ id: value.id,
437
+ html,
438
+ text,
439
+ truncated: value.truncated === true,
440
+ tooLarge: value.tooLarge === true,
441
+ };
442
+ }
443
+ case `${PREFIX}pin-visibility`:
444
+ return typeof value.visible === 'boolean' ? { type: value.type, visible: value.visible } : null;
398
445
  case `${PREFIX}selection-rect`: {
399
446
  const rect = parseBridgeRect(value.rect);
400
447
  return rect ? { type: value.type, rect } : null;
@@ -474,6 +521,12 @@ export function useHtmlAnnotation({
474
521
  UseAnnotationHighlighterReturn,
475
522
  "highlighterRef" | "highlightRange" | "highlightMathElement"
476
523
  > & {
524
+ /** Whether any pinned draft target is still displayed by the page. Always
525
+ * true while no composer is open. */
526
+ pinVisible: boolean;
527
+ /** False once this draft's composer was hidden with its target: shown
528
+ * again, it must not take focus from where the person is. */
529
+ composerAutoFocus: boolean;
477
530
  /** In-flight multi-select targets (index 0 = primary); empty outside pinpoint drafts. */
478
531
  draftTargets: HtmlDraftTarget[];
479
532
  /** Remove one draft target (chip X). Removing the last cancels the draft. */
@@ -495,6 +548,17 @@ export function useHtmlAnnotation({
495
548
  const [quickLabelPicker, setQuickLabelPicker] = useState<QuickLabelPickerState | null>(null);
496
549
  const [draftTargets, setDraftTargets] = useState<HtmlDraftTarget[]>([]);
497
550
  const [composerFocusToken, setComposerFocusToken] = useState(0);
551
+ const [pinVisible, setPinVisible] = useState(true);
552
+ const [composerWasHidden, setComposerWasHidden] = useState(false);
553
+ const pinVisibleRef = useRef(pinVisible);
554
+ pinVisibleRef.current = pinVisible;
555
+ // Visibility belongs to one draft: whatever closes the composer (save, 👍,
556
+ // close, Escape, a cancelled multi-select, disabling) resets it.
557
+ useEffect(() => {
558
+ if (commentPopover) return;
559
+ setPinVisible(true);
560
+ setComposerWasHidden(false);
561
+ }, [commentPopover]);
498
562
 
499
563
  const pendingTextRef = useRef<string>("");
500
564
  // Element anchor for the pending pinpoint selection — committed onto the
@@ -553,6 +617,9 @@ export function useHtmlAnnotation({
553
617
  hostCapRef.current = maxAdditionalTargets !== undefined;
554
618
 
555
619
  const anchorRef = useRef<HTMLDivElement | null>(null);
620
+ /** Rich-text Copy requests waiting for the bridge's markup, by id. An
621
+ * answer with an id not in here (unsolicited, or after its timeout) is ignored. */
622
+ const richCopyRequestsRef = useRef(new Map<string, (answer: BridgeRichCopyMessage) => void>());
556
623
 
557
624
  /** Post into the iframe with the live token + targetOrigin when a live
558
625
  * session is active; srcdoc posts keep targetOrigin "*" and no token. */
@@ -665,7 +732,22 @@ export function useHtmlAnnotation({
665
732
  return;
666
733
  }
667
734
 
735
+ if (type === `${PREFIX}rich-copy`) {
736
+ const answer = richCopyRequestsRef.current.get(message.id);
737
+ if (!answer) return;
738
+ richCopyRequestsRef.current.delete(message.id);
739
+ answer(message);
740
+ return;
741
+ }
742
+
743
+ if (type === `${PREFIX}pin-visibility`) {
744
+ setPinVisible(message.visible);
745
+ if (!message.visible && commentPopoverRef.current) setComposerWasHidden(true);
746
+ }
747
+
668
748
  if (type === `${PREFIX}selection`) {
749
+ setPinVisible(true);
750
+ setComposerWasHidden(false);
669
751
  pendingTextRef.current = message.text;
670
752
  pendingAnchorRef.current = message.anchor ?? null;
671
753
  pendingContextRef.current = message.context ?? null;
@@ -760,6 +842,35 @@ export function useHtmlAnnotation({
760
842
  element: anchor,
761
843
  source: null,
762
844
  selectionText: message.text,
845
+ // The markup is built only when Copy is pressed: ask
846
+ // the bridge for the pending range's HTML and wait a moment.
847
+ ...(message.richCopy ? {
848
+ getRichCopy: () => new Promise<RichCopySource>((resolve) => {
849
+ const id = `rc-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
850
+ const requests = richCopyRequestsRef.current;
851
+ // Without an answer only the quote is here: plain text, marked
852
+ // as cut when the bridge cut it.
853
+ const quote = { html: "", text: message.text, truncated: message.textCut === true };
854
+ requests.set(id, (answer) => {
855
+ const text = answer.text || quote.text;
856
+ const truncated = answer.text ? answer.truncated : quote.truncated;
857
+ if (answer.tooLarge) {
858
+ resolve({ tooLarge: true, text, truncated });
859
+ return;
860
+ }
861
+ const html = answer.html ? sanitizeRichHtml(answer.html) : null;
862
+ if (html && html.length > MAX_RICH_CLIPBOARD_OUTPUT_LENGTH) {
863
+ resolve({ tooLarge: true, text, truncated });
864
+ return;
865
+ }
866
+ resolve({ html: html ?? "", text, truncated });
867
+ });
868
+ window.setTimeout(() => {
869
+ if (requests.delete(id)) resolve(quote);
870
+ }, RICH_COPY_TIMEOUT_MS);
871
+ post({ type: `${PREFIX}rich-copy-request`, id });
872
+ }),
873
+ } : {}),
763
874
  });
764
875
  }
765
876
  }
@@ -803,6 +914,18 @@ export function useHtmlAnnotation({
803
914
  onHostDraftRef.current(null);
804
915
  }
805
916
  setToolbarState(null);
917
+ // The bridge closed the draft (its Escape rung) while the composer was
918
+ // hidden with its target: close the hidden composer too, or nothing on
919
+ // screen could ever close it. Its text stays in the draft store.
920
+ if (commentPopoverRef.current && !pinVisibleRef.current) {
921
+ post({ type: `${PREFIX}cancel-selection` });
922
+ setCommentPopover(null);
923
+ setDraftTargets([]);
924
+ pendingTextRef.current = "";
925
+ pendingAnchorRef.current = null;
926
+ pendingContextRef.current = null;
927
+ return;
928
+ }
806
929
  // Keep the captured text alive while a comment/quick-label is open: the user
807
930
  // is composing, and the selection collapsing or scrolling out of view must
808
931
  // not drop the annotation on submit. It's overwritten on the next selection.
@@ -1157,6 +1280,8 @@ export function useHtmlAnnotation({
1157
1280
 
1158
1281
  return {
1159
1282
  toolbarState,
1283
+ pinVisible: pinVisible || !commentPopover,
1284
+ composerAutoFocus: !composerWasHidden,
1160
1285
  commentPopover,
1161
1286
  quickLabelPicker,
1162
1287
  handleAnnotate,