@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
@@ -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,28 @@ 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
+
151
+ /**
152
+ * The srcdoc frame's `sandbox` attribute: the `sandbox` prop's value as given,
153
+ * plus `allow-popups` when `hostNavigates` needs it and the value lacks it.
154
+ * Anything that is not a string (`undefined`, and also `null` from a host's
155
+ * unset config, which a destructuring default does not catch) becomes
156
+ * `HTML_VIEWER_SANDBOX`, so the frame always carries a sandbox: React would
157
+ * drop a `null` attribute and run the page unsandboxed.
158
+ */
159
+ function srcdocSandbox(sandbox: unknown, hostNavigates: boolean): string {
160
+ const value = typeof sandbox === "string" ? sandbox : HTML_VIEWER_SANDBOX;
161
+ if (!hostNavigates || value.split(/\s+/).includes("allow-popups")) return value;
162
+ return value.trim() ? `${value} allow-popups` : "allow-popups";
163
+ }
164
+
142
165
  /** Default wait for the bridge's `ready` on the `bridgeScriptUrl` path. */
143
166
  export const DEFAULT_BRIDGE_READY_TIMEOUT_MS = 5000;
144
167
 
@@ -364,6 +387,26 @@ export interface HtmlViewerProps {
364
387
  * still pins. Absent → unchanged.
365
388
  */
366
389
  hostNavigates?: boolean;
390
+ /**
391
+ * The srcdoc frame's whole `sandbox` attribute value, for a host whose
392
+ * documents must not open dialogs: `"allow-scripts"` keeps a shared page's
393
+ * `alert`, `confirm` and `prompt` from drawing over the host's origin. Used
394
+ * as given, except that `hostNavigates` still adds `allow-popups`, which it
395
+ * needs. Never add `allow-same-origin` beside `allow-scripts`: the page
396
+ * could then remove its own sandbox. Live-app (`src`) mode has no sandbox
397
+ * and ignores this. Absent, or any non-string such as `null` →
398
+ * `HTML_VIEWER_SANDBOX` (`"allow-scripts allow-modals"`), unchanged.
399
+ *
400
+ * `""` (or only spaces) is kept as given: the strictest frame, with no
401
+ * scripts, so the annotation bridge never starts and the page cannot be
402
+ * annotated. It never falls back to the wider default.
403
+ *
404
+ * Read when the frame loads a document: browsers apply a changed `sandbox`
405
+ * only on the frame's next navigation (a new `rawHtml`, for example). To
406
+ * apply a new value to the document already shown, remount the viewer (for
407
+ * example, key it on the value).
408
+ */
409
+ sandbox?: string;
367
410
  }
368
411
 
369
412
  /**
@@ -420,6 +463,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
420
463
  bridgeErrorDisplay = "banner",
421
464
  onHostDraft,
422
465
  hostNavigates = false,
466
+ sandbox = HTML_VIEWER_SANDBOX,
423
467
  },
424
468
  ref,
425
469
  ) => {
@@ -901,9 +945,15 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
901
945
  return true;
902
946
  }, [readOnly, vimModeEnabled]);
903
947
 
948
+ // A composer hidden with its target (a slide the page hid) is not on
949
+ // screen: it neither blocks Vim's document focus nor counts as an open
950
+ // overlay. Escape still closes it, as its own rung (below).
951
+ const composerShown = !!hook.commentPopover && hook.pinVisible;
952
+ const composerHidden = !!hook.commentPopover && !hook.pinVisible;
953
+
904
954
  useVimDocumentFocus({
905
955
  enabled: !readOnly && vimModeEnabled,
906
- blocked: !!hook.toolbarState || !!hook.commentPopover || !!hook.quickLabelPicker,
956
+ blocked: !!hook.toolbarState || composerShown || !!hook.quickLabelPicker,
907
957
  focusDocument: focusVimDocument,
908
958
  });
909
959
 
@@ -1005,6 +1055,27 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1005
1055
  postToBridge({ type: `${PREFIX}set-host-navigates`, active: true });
1006
1056
  }, [iframeReadyVersion, hostNavigates]);
1007
1057
 
1058
+ // The hidden composer's Escape rung: with focus outside the iframe,
1059
+ // Escape closes the hidden draft (its text stays in the draft store) and
1060
+ // is used, so the next press reaches the rest of the ladder. Capture
1061
+ // phase, so it runs before the Vim document-focus rung. Inside the
1062
+ // iframe the bridge closes the draft and the hook closes the composer.
1063
+ const handleCommentCloseRef = useRef(hook.handleCommentClose);
1064
+ handleCommentCloseRef.current = hook.handleCommentClose;
1065
+ useEffect(() => {
1066
+ if (readOnly || !composerHidden) return;
1067
+ const onKeyDown = (e: KeyboardEvent) => {
1068
+ if (e.key !== 'Escape' || e.defaultPrevented || e.isComposing) return;
1069
+ const target = e.target as HTMLElement | null;
1070
+ if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;
1071
+ if (document.querySelector('[data-plannotator-confirm-dialog="true"]')) return;
1072
+ e.preventDefault();
1073
+ handleCommentCloseRef.current();
1074
+ };
1075
+ window.addEventListener('keydown', onKeyDown, true);
1076
+ return () => window.removeEventListener('keydown', onKeyDown, true);
1077
+ }, [readOnly, composerHidden]);
1078
+
1008
1079
  // Parent-side Esc rung: with focus outside the iframe the bridge never
1009
1080
  // sees the keydown. Any open composer/toolbar/picker still closes first —
1010
1081
  // their state is read from this render's closure, so an Esc that closed
@@ -1015,6 +1086,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1015
1086
  !!hook.toolbarState || !!hook.commentPopover || !!hook.quickLabelPicker || !!globalCommentPopover;
1016
1087
  const onKeyDown = (e: KeyboardEvent) => {
1017
1088
  if (e.key !== 'Escape' || e.defaultPrevented) return;
1089
+ // A hidden composer is closed by its own rung above.
1018
1090
  if (overlayOpen) return;
1019
1091
  // A text field or dialog owns its own Escape.
1020
1092
  const target = e.target as HTMLElement | null;
@@ -1057,7 +1129,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1057
1129
 
1058
1130
  const vimOverlayWasOpenRef = useRef(false);
1059
1131
  useEffect(() => {
1060
- const overlayOpen = !!hook.toolbarState || !!hook.commentPopover || !!hook.quickLabelPicker;
1132
+ const overlayOpen = !!hook.toolbarState || composerShown || !!hook.quickLabelPicker;
1061
1133
  const wasOpen = vimOverlayWasOpenRef.current;
1062
1134
  vimOverlayWasOpenRef.current = overlayOpen;
1063
1135
  if (
@@ -1072,7 +1144,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1072
1144
  { type: `${PREFIX}focus-vim` },
1073
1145
  );
1074
1146
  }
1075
- }, [hook.commentPopover, hook.quickLabelPicker, hook.toolbarState, readOnly, vimModeEnabled]);
1147
+ }, [composerShown, hook.quickLabelPicker, hook.toolbarState, readOnly, vimModeEnabled]);
1076
1148
 
1077
1149
  useEffect(() => {
1078
1150
  if (iframeReadyVersion === 0) return;
@@ -1258,7 +1330,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1258
1330
  same-origin XHR) and no srcdoc. Srcdoc mode is unchanged. */}
1259
1331
  <iframe
1260
1332
  ref={iframeRef}
1261
- {...(src ? { src } : { srcDoc: srcdoc, sandbox: "allow-scripts" + (hostNavigates ? " allow-popups" : "") })}
1333
+ {...(src ? { src } : { srcDoc: srcdoc, sandbox: srcdocSandbox(sandbox, hostNavigates) })}
1262
1334
  style={{
1263
1335
  width: "100%",
1264
1336
  height: fullViewport ? "100%" : `${iframeHeight}px`,
@@ -1315,6 +1387,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1315
1387
  positionMode="center-above"
1316
1388
  element={hook.toolbarState.element}
1317
1389
  copyText={hook.toolbarState.selectionText}
1390
+ getRichCopy={getRichCopyEnabled() ? hook.toolbarState.getRichCopy : undefined}
1318
1391
  // HTML/live surfaces are comment-only: no Delete, no label
1319
1392
  // picker, no Alt+digit labels (commentOnly). Exactly ONE label
1320
1393
  // affordance is restored: the hardcoded 👍 "Looks good". The
@@ -1333,8 +1406,10 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1333
1406
  document.body,
1334
1407
  )}
1335
1408
 
1336
- {/* Comment popover portal */}
1337
- {!readOnly && hook.commentPopover &&
1409
+ {/* Hidden with its target (a slide the page hid): CommentPopover's draft
1410
+ store keeps the text and images, and shown again it does not take
1411
+ focus from where the person is. */}
1412
+ {!readOnly && hook.pinVisible && hook.commentPopover &&
1338
1413
  createPortal(
1339
1414
  <CommentPopover
1340
1415
  anchorEl={hook.commentPopover.anchorEl}
@@ -1362,6 +1437,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1362
1437
  onRemoveTargetChip={targetChips ? hook.removeDraftTarget : undefined}
1363
1438
  onHoverTargetChip={targetChips ? hook.flashDraftTarget : undefined}
1364
1439
  refocusToken={targetChips ? hook.composerFocusToken : undefined}
1440
+ autoFocus={hook.composerAutoFocus}
1365
1441
  captureStrayKeys={multiSelectActive}
1366
1442
  yieldState={multiSelectActive ? composerYield : undefined}
1367
1443
  />,
@@ -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({