@plannotator/ui 0.53.1 → 0.54.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 (135) hide show
  1. package/HANDOFF.md +217 -11
  2. package/README.md +65 -10
  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 +19 -5
  12. package/components/CodeFilePopout.tsx +139 -4
  13. package/components/CommentPopover.tsx +44 -8
  14. package/components/DecisionControl.tsx +31 -5
  15. package/components/DiagramBlock.tsx +17 -12
  16. package/components/EditorAnnotationCard.tsx +1 -1
  17. package/components/ExportModal.tsx +77 -7
  18. package/components/HostTitle.tsx +0 -4
  19. package/components/ImageAnnotator/Toolbar.tsx +25 -1
  20. package/components/ImageAnnotator/index.tsx +24 -4
  21. package/components/ImageLightbox.tsx +658 -120
  22. package/components/ImagePeek.tsx +38 -4
  23. package/components/ImageRegions.tsx +369 -0
  24. package/components/ImageThumbnail.tsx +1 -1
  25. package/components/ImageZoomStage.tsx +796 -0
  26. package/components/InboxAnnouncementDialog.tsx +202 -0
  27. package/components/InlineMarkdown.tsx +152 -24
  28. package/components/KeyboardShortcuts.tsx +23 -0
  29. package/components/LazyBlockBoundary.tsx +100 -0
  30. package/components/ListMarker.tsx +3 -3
  31. package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
  32. package/components/MenuVersionSection.tsx +31 -9
  33. package/components/PlanDecisionMenuExtras.tsx +129 -0
  34. package/components/PlanHeaderMenu.tsx +15 -1
  35. package/components/ProviderIcons.tsx +8 -0
  36. package/components/RefHoverCard.tsx +913 -0
  37. package/components/SendFailureToast.tsx +126 -0
  38. package/components/Settings.tsx +617 -126
  39. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  40. package/components/ThemeProvider.tsx +14 -1
  41. package/components/ThemeTab.tsx +16 -24
  42. package/components/Tooltip.tsx +49 -18
  43. package/components/Viewer.tsx +280 -41
  44. package/components/ai/DocumentAIChatPanel.tsx +29 -3
  45. package/components/ai/NewChatButton.tsx +74 -0
  46. package/components/blocks/CodeBlock.tsx +1 -1
  47. package/components/blocks/HtmlBlock.tsx +7 -1
  48. package/components/blocks/QuestionBlock.tsx +522 -62
  49. package/components/diagram/DiagramComposer.tsx +5 -1
  50. package/components/diagram/DiagramPending.tsx +12 -2
  51. package/components/diagram/DiagramViewer.tsx +19 -3
  52. package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
  53. package/components/html-embed/SvgFenceBlock.tsx +46 -0
  54. package/components/html-embed/index.ts +3 -0
  55. package/components/html-embed/languages.ts +15 -0
  56. package/components/html-embed/loadGate.ts +107 -0
  57. package/components/html-viewer/HtmlViewer.tsx +47 -6
  58. package/components/html-viewer/bridge-script.asset.js +173 -1
  59. package/components/html-viewer/bridge-script.ts +173 -1
  60. package/components/html-viewer/useHtmlAnnotation.ts +125 -0
  61. package/components/imageZoom.ts +265 -0
  62. package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
  63. package/components/plan-diff/PlanDiffViewer.tsx +13 -2
  64. package/components/plan-diff/PlanRawDiffView.tsx +1 -1
  65. package/components/sidebar/FileBrowser.tsx +16 -2
  66. package/components/sidebar/SidebarTabs.tsx +3 -0
  67. package/config/index.ts +3 -0
  68. package/config/reviewView.ts +41 -1
  69. package/config/settings.ts +155 -4
  70. package/configure.ts +28 -0
  71. package/hooks/pfm/useCodeFilePopout.ts +7 -1
  72. package/hooks/useAIChat.ts +72 -25
  73. package/hooks/useAnnotationDraft.ts +71 -6
  74. package/hooks/useAnnotationHighlighter.ts +57 -3
  75. package/hooks/useCodeAnnotationDraft.ts +113 -17
  76. package/hooks/useCookieHostSetting.ts +96 -0
  77. package/hooks/useDocumentFont.ts +17 -0
  78. package/hooks/useDocumentTextSize.ts +20 -0
  79. package/hooks/useFileBrowser.ts +10 -1
  80. package/hooks/useLinkedDoc.ts +226 -13
  81. package/hooks/usePackageChangesSetting.ts +90 -0
  82. package/hooks/usePlanLookDialog.ts +67 -0
  83. package/hooks/useSharing.ts +11 -7
  84. package/hooks/useThemePaletteHalves.ts +37 -0
  85. package/hooks/useUpdateCheck.ts +22 -4
  86. package/hooks/useUpdateToast.ts +1 -1
  87. package/package.json +23 -22
  88. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
  89. package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
  90. package/shortcuts/core.ts +63 -4
  91. package/shortcuts/index.ts +1 -0
  92. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
  93. package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
  94. package/shortcuts/runtime.ts +4 -3
  95. package/styles.css +1 -1
  96. package/theme.css +179 -1
  97. package/themes/plannotator-graphite.css +66 -0
  98. package/types.ts +26 -9
  99. package/utils/agentSwitch.ts +41 -20
  100. package/utils/agentToolSetting.ts +1 -1
  101. package/utils/aiChatFormat.ts +23 -7
  102. package/utils/annotationOwnsHighlight.ts +4 -2
  103. package/utils/asciidocParser.ts +570 -0
  104. package/utils/autoUpdateNotice.ts +24 -0
  105. package/utils/belowTitleSlot.ts +174 -0
  106. package/utils/codeLineScroll.ts +100 -0
  107. package/utils/commentSubmitKey.ts +48 -0
  108. package/utils/cssFontFamily.ts +65 -0
  109. package/utils/decisionSpec.ts +190 -2
  110. package/utils/defaultNotesApp.ts +1 -1
  111. package/utils/diffFonts.ts +4 -1
  112. package/utils/documentFont.ts +68 -0
  113. package/utils/documentTextSize.ts +54 -0
  114. package/utils/fontDetect.ts +63 -0
  115. package/utils/inboxAnnouncement.ts +78 -0
  116. package/utils/lookAndFeelAnnouncement.ts +82 -5
  117. package/utils/markdownExtensions.ts +2 -2
  118. package/utils/mermaid.ts +20 -3
  119. package/utils/mermaidTheme.ts +3 -51
  120. package/utils/notion.ts +58 -0
  121. package/utils/parser.ts +28 -1
  122. package/utils/platform.ts +46 -0
  123. package/utils/refCard.ts +82 -0
  124. package/utils/richClipboard.ts +380 -0
  125. package/utils/richClipboardLists.ts +57 -0
  126. package/utils/richCopyPolicy.ts +22 -0
  127. package/utils/scrollAnchor.ts +82 -0
  128. package/utils/sectionRefs.ts +123 -0
  129. package/utils/selectionBoundary.ts +40 -0
  130. package/utils/sendFailure.ts +85 -0
  131. package/utils/sharing.ts +5 -2
  132. package/utils/themeRegistry.ts +10 -0
  133. package/utils/themeTokens.ts +74 -0
  134. package/utils/updateNotice.ts +30 -8
  135. package/utils/vscodePanel.ts +9 -0
@@ -18,6 +18,7 @@ import {
18
18
  import type { Annotation, EditorMode, HostDraft, ImageAttachment, InputMethod } from "../../types";
19
19
  import { AnnotationType } from "../../types";
20
20
  import { copyTextPreservingFocus } from "../../utils/clipboard";
21
+ import { getRichCopyEnabled } from "../../utils/richCopyPolicy";
21
22
  import { elementIdentityForAskAI } from "../../utils/parser";
22
23
  import { getIdentity } from "../../utils/identity";
23
24
  import { THUMBS_UP_LABEL } from "../../utils/quickLabels";
@@ -139,6 +140,14 @@ function parseVimBridgeHelp(value: unknown): boolean | null {
139
140
 
140
141
  const MAX_VIM_COPY_TEXT_LENGTH = 2 * 1024 * 1024;
141
142
 
143
+ /**
144
+ * The srcdoc frame's sandbox. `allow-modals` lets the page's alert, confirm
145
+ * and prompt open (#2063: people's own pages and local sites). Never
146
+ * `allow-same-origin`: the page stays at an opaque origin, away from the
147
+ * session's API. Live-app mode is unsandboxed and does not use this.
148
+ */
149
+ export const HTML_VIEWER_SANDBOX = "allow-scripts allow-modals";
150
+
142
151
  /** Default wait for the bridge's `ready` on the `bridgeScriptUrl` path. */
143
152
  export const DEFAULT_BRIDGE_READY_TIMEOUT_MS = 5000;
144
153
 
@@ -901,9 +910,15 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
901
910
  return true;
902
911
  }, [readOnly, vimModeEnabled]);
903
912
 
913
+ // A composer hidden with its target (a slide the page hid) is not on
914
+ // screen: it neither blocks Vim's document focus nor counts as an open
915
+ // overlay. Escape still closes it, as its own rung (below).
916
+ const composerShown = !!hook.commentPopover && hook.pinVisible;
917
+ const composerHidden = !!hook.commentPopover && !hook.pinVisible;
918
+
904
919
  useVimDocumentFocus({
905
920
  enabled: !readOnly && vimModeEnabled,
906
- blocked: !!hook.toolbarState || !!hook.commentPopover || !!hook.quickLabelPicker,
921
+ blocked: !!hook.toolbarState || composerShown || !!hook.quickLabelPicker,
907
922
  focusDocument: focusVimDocument,
908
923
  });
909
924
 
@@ -1005,6 +1020,27 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1005
1020
  postToBridge({ type: `${PREFIX}set-host-navigates`, active: true });
1006
1021
  }, [iframeReadyVersion, hostNavigates]);
1007
1022
 
1023
+ // The hidden composer's Escape rung: with focus outside the iframe,
1024
+ // Escape closes the hidden draft (its text stays in the draft store) and
1025
+ // is used, so the next press reaches the rest of the ladder. Capture
1026
+ // phase, so it runs before the Vim document-focus rung. Inside the
1027
+ // iframe the bridge closes the draft and the hook closes the composer.
1028
+ const handleCommentCloseRef = useRef(hook.handleCommentClose);
1029
+ handleCommentCloseRef.current = hook.handleCommentClose;
1030
+ useEffect(() => {
1031
+ if (readOnly || !composerHidden) return;
1032
+ const onKeyDown = (e: KeyboardEvent) => {
1033
+ if (e.key !== 'Escape' || e.defaultPrevented || e.isComposing) return;
1034
+ const target = e.target as HTMLElement | null;
1035
+ if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;
1036
+ if (document.querySelector('[data-plannotator-confirm-dialog="true"]')) return;
1037
+ e.preventDefault();
1038
+ handleCommentCloseRef.current();
1039
+ };
1040
+ window.addEventListener('keydown', onKeyDown, true);
1041
+ return () => window.removeEventListener('keydown', onKeyDown, true);
1042
+ }, [readOnly, composerHidden]);
1043
+
1008
1044
  // Parent-side Esc rung: with focus outside the iframe the bridge never
1009
1045
  // sees the keydown. Any open composer/toolbar/picker still closes first —
1010
1046
  // their state is read from this render's closure, so an Esc that closed
@@ -1015,6 +1051,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1015
1051
  !!hook.toolbarState || !!hook.commentPopover || !!hook.quickLabelPicker || !!globalCommentPopover;
1016
1052
  const onKeyDown = (e: KeyboardEvent) => {
1017
1053
  if (e.key !== 'Escape' || e.defaultPrevented) return;
1054
+ // A hidden composer is closed by its own rung above.
1018
1055
  if (overlayOpen) return;
1019
1056
  // A text field or dialog owns its own Escape.
1020
1057
  const target = e.target as HTMLElement | null;
@@ -1057,7 +1094,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1057
1094
 
1058
1095
  const vimOverlayWasOpenRef = useRef(false);
1059
1096
  useEffect(() => {
1060
- const overlayOpen = !!hook.toolbarState || !!hook.commentPopover || !!hook.quickLabelPicker;
1097
+ const overlayOpen = !!hook.toolbarState || composerShown || !!hook.quickLabelPicker;
1061
1098
  const wasOpen = vimOverlayWasOpenRef.current;
1062
1099
  vimOverlayWasOpenRef.current = overlayOpen;
1063
1100
  if (
@@ -1072,7 +1109,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1072
1109
  { type: `${PREFIX}focus-vim` },
1073
1110
  );
1074
1111
  }
1075
- }, [hook.commentPopover, hook.quickLabelPicker, hook.toolbarState, readOnly, vimModeEnabled]);
1112
+ }, [composerShown, hook.quickLabelPicker, hook.toolbarState, readOnly, vimModeEnabled]);
1076
1113
 
1077
1114
  useEffect(() => {
1078
1115
  if (iframeReadyVersion === 0) return;
@@ -1258,7 +1295,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1258
1295
  same-origin XHR) and no srcdoc. Srcdoc mode is unchanged. */}
1259
1296
  <iframe
1260
1297
  ref={iframeRef}
1261
- {...(src ? { src } : { srcDoc: srcdoc, sandbox: "allow-scripts" + (hostNavigates ? " allow-popups" : "") })}
1298
+ {...(src ? { src } : { srcDoc: srcdoc, sandbox: HTML_VIEWER_SANDBOX + (hostNavigates ? " allow-popups" : "") })}
1262
1299
  style={{
1263
1300
  width: "100%",
1264
1301
  height: fullViewport ? "100%" : `${iframeHeight}px`,
@@ -1315,6 +1352,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1315
1352
  positionMode="center-above"
1316
1353
  element={hook.toolbarState.element}
1317
1354
  copyText={hook.toolbarState.selectionText}
1355
+ getRichCopy={getRichCopyEnabled() ? hook.toolbarState.getRichCopy : undefined}
1318
1356
  // HTML/live surfaces are comment-only: no Delete, no label
1319
1357
  // picker, no Alt+digit labels (commentOnly). Exactly ONE label
1320
1358
  // affordance is restored: the hardcoded 👍 "Looks good". The
@@ -1333,8 +1371,10 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1333
1371
  document.body,
1334
1372
  )}
1335
1373
 
1336
- {/* Comment popover portal */}
1337
- {!readOnly && hook.commentPopover &&
1374
+ {/* Hidden with its target (a slide the page hid): CommentPopover's draft
1375
+ store keeps the text and images, and shown again it does not take
1376
+ focus from where the person is. */}
1377
+ {!readOnly && hook.pinVisible && hook.commentPopover &&
1338
1378
  createPortal(
1339
1379
  <CommentPopover
1340
1380
  anchorEl={hook.commentPopover.anchorEl}
@@ -1362,6 +1402,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1362
1402
  onRemoveTargetChip={targetChips ? hook.removeDraftTarget : undefined}
1363
1403
  onHoverTargetChip={targetChips ? hook.flashDraftTarget : undefined}
1364
1404
  refocusToken={targetChips ? hook.composerFocusToken : undefined}
1405
+ autoFocus={hook.composerAutoFocus}
1365
1406
  captureStrayKeys={multiSelectActive}
1366
1407
  yieldState={multiSelectActive ? composerYield : undefined}
1367
1408
  />,
@@ -183,6 +183,10 @@
183
183
  // --- Selection ---
184
184
  var pendingSelection = null;
185
185
  var pendingRange = null; // live range for the pending selection (scroll tracking)
186
+ // The pending text selection's own text, uncapped: what plain-text Copy
187
+ // writes. Range.toString() would lose the rendered line breaks between
188
+ // blocks and keep the source indentation.
189
+ var pendingCopyText = '';
186
190
  var pendingPinEl = null; // element pinned by a pinpoint click (outline + scroll tracking)
187
191
  var pendingPinAnchor = null; // serialized anchor for the pending pin
188
192
  var pendingPinKey = null; // target key for the primary pinpoint target (multi-select)
@@ -294,7 +298,8 @@
294
298
  }
295
299
  skipNextClear = false; // a real text selection happened
296
300
  var range = sel.getRangeAt(0);
297
- var text = capSelectionText(sel.toString().trim());
301
+ var fullText = sel.toString().trim();
302
+ var text = capSelectionText(fullText);
298
303
  if (!text) return false;
299
304
 
300
305
  // A draft now owns the surface: kill the click-to-select hover
@@ -303,6 +308,7 @@
303
308
 
304
309
  var rect = range.getBoundingClientRect();
305
310
  pendingRange = range;
311
+ pendingCopyText = fullText;
306
312
  pendingSelection = {
307
313
  text: text,
308
314
  startContainerPath: getNodePath(range.startContainer),
@@ -320,6 +326,13 @@
320
326
  targetKey: (extras && extras.targetKey) || undefined,
321
327
  targetLabel: (extras && extras.targetLabel) || undefined,
322
328
  context: (extras && extras.context) || undefined,
329
+ // This bridge answers rich-copy-request for a text selection (the
330
+ // toolbar's rich-text Copy). Optional and additive: an older bridge
331
+ // omits it and Copy stays plain text, so no protocol version bump.
332
+ richCopy: (extras && extras.pinpoint) ? undefined : true,
333
+ // The quote above was cut to MAX_SELECTION_TEXT: a plain-text copy made
334
+ // from it alone (no answer to rich-copy-request) says so.
335
+ textCut: text.length < fullText.length ? true : undefined,
323
336
  rect: { top: rect.top, left: rect.left, width: rect.width, height: rect.height }
324
337
  });
325
338
  renderAnnotationOverlay(); // draft selection highlight (overlay-projected)
@@ -368,6 +381,46 @@
368
381
  if (!e.data || !e.data.type) return;
369
382
  var type = e.data.type;
370
383
 
384
+ // Copy (rich text) was pressed in the parent: build the pending text
385
+ // range's markup now (never on selection). A throw or an oversized range
386
+ // answers without markup, so the parent copies plain text.
387
+ if (type === PREFIX + 'rich-copy-request') {
388
+ // Text and markup are each capped at 256 KiB: past it the text is cut
389
+ // (never at half a surrogate pair) and marked, and the markup is not
390
+ // built at all (too large for rich text).
391
+ var copyHtml = '';
392
+ var copyText = '';
393
+ var copyTruncated = false;
394
+ var copyTooLarge = false;
395
+ try {
396
+ if (pendingRange && pendingSelection && pendingSelection.startContainerPath) {
397
+ copyText = pendingCopyText || pendingRange.toString();
398
+ if (copyText.length > 262144) {
399
+ var copyCut = 262144;
400
+ var copyLast = copyText.charCodeAt(copyCut - 1);
401
+ if (copyLast >= 0xd800 && copyLast <= 0xdbff) copyCut -= 1;
402
+ copyText = copyText.slice(0, copyCut);
403
+ copyTruncated = true;
404
+ copyTooLarge = true;
405
+ } else {
406
+ copyHtml = htmlForRange(pendingRange);
407
+ if (copyHtml.length > 262144) copyTooLarge = true;
408
+ }
409
+ }
410
+ } catch (err) {
411
+ copyHtml = '';
412
+ }
413
+ postToParent({
414
+ type: PREFIX + 'rich-copy',
415
+ id: typeof e.data.id === 'string' ? e.data.id : '',
416
+ html: copyTooLarge ? '' : copyHtml,
417
+ text: copyText,
418
+ truncated: copyTruncated,
419
+ tooLarge: copyTooLarge
420
+ });
421
+ return;
422
+ }
423
+
371
424
  if (type === PREFIX + 'create-mark') {
372
425
  var id = e.data.id;
373
426
  var annType = e.data.annotationType || 'comment';
@@ -1085,6 +1138,10 @@
1085
1138
  return pinpointBoxEl;
1086
1139
  }
1087
1140
  function positionPinpointBox(el) {
1141
+ if (pendingPinHidden(el)) {
1142
+ if (pinpointBoxEl) pinpointBoxEl.style.display = 'none';
1143
+ return;
1144
+ }
1088
1145
  var r = el.getBoundingClientRect();
1089
1146
  var box = getPinpointBoxEl();
1090
1147
  box.style.display = 'block';
@@ -1238,6 +1295,8 @@
1238
1295
  if (annotateModeActive && currentInputMethod !== 'pinpoint' && lastPointer && !pendingPinEl && !pendingSelection) {
1239
1296
  setHoverHighlight(committedHighlightIdAtCached(lastPointer.x, lastPointer.y));
1240
1297
  }
1298
+ // Before the boxes: it may re-find a replaced target they then follow.
1299
+ reconcilePendingPinVisibility();
1241
1300
  positionMultiTargetBoxes();
1242
1301
  if (pendingPinEl && pendingPinEl.isConnected) {
1243
1302
  positionPinpointBox(pendingPinEl);
@@ -3230,7 +3289,91 @@
3230
3289
  }
3231
3290
  }
3232
3291
 
3292
+ // A draft's composer hides with its target and keeps the draft: a slide the
3293
+ // page hid (display:none, visibility:hidden, no box) is not a scroll-out.
3294
+ // Offscreen targets stay visible to support roaming multi-select. A target
3295
+ // the page REPLACED with a new element (a framework re-render, a live-app
3296
+ // route change) is found again through its anchor, the way restore re-finds
3297
+ // committed targets; one that cannot be found keeps the composer visible,
3298
+ // so a draft is never left where nobody can reach it.
3299
+ var pendingPinVisible = true;
3300
+ var pendingPinSearchGen = 0;
3301
+ function pendingPinHidden(el) {
3302
+ if (!el || !el.isConnected) return false;
3303
+ if (targetStyleHidden(el)) return true;
3304
+ var rect = el.getBoundingClientRect();
3305
+ return layoutActive() && !rect.width && !rect.height;
3306
+ }
3307
+
3308
+ function refindPendingTarget(anchor, taken) {
3309
+ var found = resolveAnchorElement(anchor);
3310
+ if (!found || isViewerOverlayNode(found)) return null;
3311
+ for (var i = 0; i < taken.length; i++) if (taken[i] === found) return null;
3312
+ return found;
3313
+ }
3314
+
3315
+ // Re-find detached draft targets, once per DOM generation each. Returns
3316
+ // whether the primary moved to a new element.
3317
+ function refreshPendingTargets() {
3318
+ var taken = [pendingPinEl];
3319
+ for (var t = 0; t < pendingMultiTargets.length; t++) taken.push(pendingMultiTargets[t].el);
3320
+ var moved = false;
3321
+ if (pendingPinEl && !pendingPinEl.isConnected && pendingPinAnchor && pendingPinSearchGen !== domGeneration) {
3322
+ pendingPinSearchGen = domGeneration;
3323
+ var found = refindPendingTarget(pendingPinAnchor, taken);
3324
+ if (found) {
3325
+ pendingPinEl = found;
3326
+ // The text range belonged to the replaced element: commit as an
3327
+ // element pin on the new one (as a promoted primary does).
3328
+ if (pendingRange && !rangeAlive(pendingRange)) pendingRange = null;
3329
+ if (pendingSelection && pendingSelection.startContainerPath) {
3330
+ pendingSelection = { element: true, text: pendingSelection.text };
3331
+ }
3332
+ moved = true;
3333
+ }
3334
+ }
3335
+ for (var i = 0; i < pendingMultiTargets.length; i++) {
3336
+ var entry = pendingMultiTargets[i];
3337
+ if (!entry.el || entry.el.isConnected || !entry.anchor || entry.searchGen === domGeneration) continue;
3338
+ entry.searchGen = domGeneration;
3339
+ var next = refindPendingTarget(entry.anchor, taken);
3340
+ if (next) { entry.el = next; taken.push(next); }
3341
+ }
3342
+ return moved;
3343
+ }
3344
+
3345
+ // Re-anchor the parent's composer on the pin (it cannot see the page).
3346
+ function postPendingPinRect() {
3347
+ if (!pendingSelection || !pendingPinEl || !pendingPinEl.isConnected) return;
3348
+ var tracked = pendingRange && rangeAlive(pendingRange) ? pendingRange : pendingPinEl;
3349
+ var r = tracked.getBoundingClientRect();
3350
+ postToParent({
3351
+ type: PREFIX + 'selection-rect',
3352
+ rect: { top: r.top, left: r.left, width: r.width, height: r.height }
3353
+ });
3354
+ }
3355
+
3356
+ function reconcilePendingPinVisibility() {
3357
+ if (!pendingPinEl) return;
3358
+ var moved = refreshPendingTargets();
3359
+ var visible = !pendingPinHidden(pendingPinEl);
3360
+ for (var i = 0; !visible && i < pendingMultiTargets.length; i++) {
3361
+ visible = !pendingPinHidden(pendingMultiTargets[i].el);
3362
+ }
3363
+ // A detached primary has no geometry: never leave its outline behind.
3364
+ if (!pendingPinEl.isConnected && pinpointBoxEl) pinpointBoxEl.style.display = 'none';
3365
+ var shown = visible && !pendingPinVisible;
3366
+ if (visible !== pendingPinVisible) {
3367
+ pendingPinVisible = visible;
3368
+ postToParent({ type: PREFIX + 'pin-visibility', visible: visible });
3369
+ if (!visible && pinpointBoxEl) pinpointBoxEl.style.display = 'none';
3370
+ }
3371
+ if (visible && (moved || shown)) postPendingPinRect();
3372
+ }
3373
+
3233
3374
  function clearPendingPin() {
3375
+ pendingPinVisible = true;
3376
+ pendingPinSearchGen = 0;
3234
3377
  pendingPinEl = null;
3235
3378
  pendingPinContext = null;
3236
3379
  pendingPinAnchor = null;
@@ -3286,6 +3429,7 @@
3286
3429
  }
3287
3430
 
3288
3431
  function positionBoxToEl(box, el) {
3432
+ if (pendingPinHidden(el)) { box.style.display = 'none'; return; }
3289
3433
  var r = el.getBoundingClientRect();
3290
3434
  box.style.display = 'block';
3291
3435
  box.style.left = r.left + 'px';
@@ -3417,6 +3561,7 @@
3417
3561
  */
3418
3562
  function removeMultiTargetByKey(key, echo) {
3419
3563
  if (!key) return;
3564
+ schedulePinpointReconcile();
3420
3565
  if (key === pendingPinKey) {
3421
3566
  if (!pendingMultiTargets.length) {
3422
3567
  // Last target gone — the draft is cancelled.
@@ -3433,6 +3578,7 @@
3433
3578
  var next = pendingMultiTargets.shift();
3434
3579
  destroyMultiTargetBox(next.box);
3435
3580
  pendingPinEl = next.el;
3581
+ pendingPinSearchGen = 0;
3436
3582
  pendingPinAnchor = next.anchor;
3437
3583
  pendingPinKey = next.key;
3438
3584
  pendingPinLabel = next.label;
@@ -3500,6 +3646,7 @@
3500
3646
  var key = makeTargetKey();
3501
3647
  var box = createMultiTargetBox(el);
3502
3648
  pendingMultiTargets.push({ key: key, el: el, anchor: anchor, label: label, text: text, point: point, context: context, box: box });
3649
+ schedulePinpointReconcile();
3503
3650
  postToParent({
3504
3651
  type: PREFIX + 'multi-target-added',
3505
3652
  key: key,
@@ -3634,6 +3781,8 @@
3634
3781
  multiSelectArmed = false;
3635
3782
  multiSelectMax = MAX_MULTI_TARGETS;
3636
3783
  pendingPinEl = el;
3784
+ pendingPinVisible = true;
3785
+ pendingPinSearchGen = 0;
3637
3786
  pendingPinAnchor = buildElementAnchor(el);
3638
3787
  pendingPinKey = makeTargetKey();
3639
3788
  pendingPinLabel = pinpointHoverLabel(el);
@@ -5035,6 +5184,29 @@
5035
5184
  if (document.body) document.body.removeAttribute('data-plannotator-vim-focus-owner');
5036
5185
  }
5037
5186
 
5187
+ // Selected markup for the parent's rich-text Copy button, built only when
5188
+ // the button is pressed (rich-copy-request). Keeps the enclosing formatting
5189
+ // that cloneContents omits for text endpoints (the same semantic ancestors
5190
+ // selectionRangeToHtml keeps) and absolutizes links.
5191
+ function htmlForRange(range) {
5192
+ var holder = document.createElement('div');
5193
+ var fragment = range.cloneContents();
5194
+ var ancestor = range.commonAncestorContainer.nodeType === 1
5195
+ ? range.commonAncestorContainer : range.commonAncestorContainer.parentElement;
5196
+ while (ancestor && ancestor !== document.body) {
5197
+ 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')) {
5198
+ var wrapper = ancestor.cloneNode(false);
5199
+ while (fragment.firstChild) wrapper.appendChild(fragment.firstChild);
5200
+ fragment.appendChild(wrapper);
5201
+ }
5202
+ ancestor = ancestor.parentElement;
5203
+ }
5204
+ holder.appendChild(fragment);
5205
+ var links = holder.querySelectorAll('a[href]');
5206
+ for (var i = 0; i < links.length; i++) links[i].setAttribute('href', links[i].href);
5207
+ return holder.innerHTML;
5208
+ }
5209
+
5038
5210
  function copyVimText(text) {
5039
5211
  if (!text) return;
5040
5212
  postToParent({
@@ -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({