@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.
- package/HANDOFF.md +274 -14
- package/README.md +114 -11
- package/components/AgentToolAnnouncementDialog.tsx +1 -1
- package/components/AgentsTab.tsx +1 -1
- package/components/AnnotationPanel.tsx +142 -24
- package/components/AnnotationSidebar.tsx +1 -1
- package/components/AnnotationToolbar.tsx +64 -9
- package/components/ApproveDropdown.tsx +7 -9
- package/components/AskSessionAnnouncementDialog.tsx +1 -1
- package/components/AttachmentsButton.tsx +1 -1
- package/components/BlockRenderer.tsx +22 -5
- package/components/CodeFilePopout.tsx +139 -4
- package/components/CodeSnippetPreview.tsx +174 -0
- package/components/CommentPopover.tsx +44 -8
- package/components/DecisionControl.tsx +31 -5
- package/components/DeferredMarkdownContentsRail.tsx +38 -0
- package/components/DiagramBlock.tsx +17 -12
- package/components/DiffFileTree.tsx +7 -6
- package/components/DocumentContents.tsx +74 -0
- package/components/EditorAnnotationCard.tsx +1 -1
- package/components/ExportModal.tsx +77 -7
- package/components/HostTitle.tsx +0 -4
- package/components/ImageAnnotator/Toolbar.tsx +25 -1
- package/components/ImageAnnotator/index.tsx +24 -4
- package/components/ImageLightbox.tsx +722 -126
- package/components/ImagePeek.tsx +38 -4
- package/components/ImageRegions.tsx +369 -0
- package/components/ImageThumbnail.tsx +1 -1
- package/components/ImageZoomStage.tsx +796 -0
- package/components/InboxAnnouncementDialog.tsx +202 -0
- package/components/InlineMarkdown.tsx +167 -174
- package/components/KeyboardShortcuts.tsx +23 -0
- package/components/LazyBlockBoundary.tsx +100 -0
- package/components/ListMarker.tsx +3 -3
- package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
- package/components/MarkdownContentsRail.tsx +777 -0
- package/components/MenuVersionSection.tsx +31 -9
- package/components/PlanDecisionMenuExtras.tsx +129 -0
- package/components/PlanHeaderMenu.tsx +15 -1
- package/components/ProviderIcons.tsx +8 -0
- package/components/RefHoverCard.tsx +913 -0
- package/components/SendFailureToast.tsx +126 -0
- package/components/Settings.tsx +617 -126
- package/components/TableOfContents.tsx +20 -6
- package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
- package/components/ThemeProvider.tsx +14 -1
- package/components/ThemeTab.tsx +16 -24
- package/components/Tooltip.tsx +49 -18
- package/components/Viewer.tsx +288 -43
- package/components/ai/DocumentAIChatPanel.tsx +29 -3
- package/components/ai/NewChatButton.tsx +74 -0
- package/components/blocks/CodeBlock.tsx +1 -1
- package/components/blocks/HtmlBlock.tsx +7 -1
- package/components/blocks/QuestionBlock.tsx +532 -62
- package/components/diagram/DiagramComposer.tsx +5 -1
- package/components/diagram/DiagramPending.tsx +12 -2
- package/components/diagram/DiagramViewer.tsx +19 -3
- package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
- package/components/html-embed/SvgFenceBlock.tsx +46 -0
- package/components/html-embed/index.ts +3 -0
- package/components/html-embed/languages.ts +15 -0
- package/components/html-embed/loadGate.ts +107 -0
- package/components/html-viewer/HtmlViewer.tsx +82 -6
- package/components/html-viewer/bridge-script.asset.js +173 -1
- package/components/html-viewer/bridge-script.ts +173 -1
- package/components/html-viewer/useHtmlAnnotation.ts +125 -0
- package/components/imageZoom.ts +276 -0
- package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
- package/components/plan-diff/PlanDiffViewer.tsx +13 -2
- package/components/plan-diff/PlanRawDiffView.tsx +1 -1
- package/components/sidebar/FileBrowser.tsx +16 -2
- package/components/sidebar/SidebarTabs.tsx +3 -0
- package/config/index.ts +3 -0
- package/config/reviewView.ts +41 -1
- package/config/settings.ts +155 -4
- package/configure.ts +28 -0
- package/contents-rail.css +337 -0
- package/hooks/pfm/useCodeFilePopout.ts +7 -1
- package/hooks/useAIChat.ts +72 -25
- package/hooks/useAnnotationDraft.ts +71 -6
- package/hooks/useAnnotationHighlighter.ts +57 -3
- package/hooks/useCodeAnnotationDraft.ts +113 -17
- package/hooks/useCookieHostSetting.ts +96 -0
- package/hooks/useDocumentFont.ts +17 -0
- package/hooks/useDocumentTextSize.ts +20 -0
- package/hooks/useFileBrowser.ts +10 -1
- package/hooks/useHeadingScrollSpy.ts +77 -0
- package/hooks/useHoverPreview.ts +94 -0
- package/hooks/useLinkedDoc.ts +226 -13
- package/hooks/usePackageChangesSetting.ts +90 -0
- package/hooks/usePlanLookDialog.ts +67 -0
- package/hooks/usePrefersReducedMotion.ts +23 -0
- package/hooks/useSharing.ts +11 -7
- package/hooks/useSwipeStep.ts +597 -0
- package/hooks/useThemePaletteHalves.ts +37 -0
- package/hooks/useUpdateCheck.ts +22 -4
- package/hooks/useUpdateToast.ts +1 -1
- package/package.json +26 -23
- package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
- package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
- package/shortcuts/core.ts +63 -4
- package/shortcuts/index.ts +1 -0
- package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
- package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
- package/shortcuts/runtime.ts +4 -3
- package/styles.css +1 -1
- package/theme.css +179 -1
- package/themes/plannotator-graphite.css +66 -0
- package/types.ts +26 -9
- package/utils/agentSwitch.ts +41 -20
- package/utils/agentToolSetting.ts +1 -1
- package/utils/aiChatFormat.ts +23 -7
- package/utils/annotationHelpers.ts +32 -4
- package/utils/annotationOwnsHighlight.ts +4 -2
- package/utils/asciidocParser.ts +570 -0
- package/utils/autoUpdateNotice.ts +24 -0
- package/utils/belowTitleSlot.ts +174 -0
- package/utils/blockParse.ts +45 -0
- package/utils/codeLineScroll.ts +100 -0
- package/utils/commentSubmitKey.ts +48 -0
- package/utils/cssFontFamily.ts +65 -0
- package/utils/decisionSpec.ts +190 -2
- package/utils/defaultNotesApp.ts +1 -1
- package/utils/diffFonts.ts +4 -1
- package/utils/documentFont.ts +68 -0
- package/utils/documentTextSize.ts +54 -0
- package/utils/fontDetect.ts +63 -0
- package/utils/headingScrollSpy.ts +92 -0
- package/utils/inboxAnnouncement.ts +78 -0
- package/utils/lookAndFeelAnnouncement.ts +82 -5
- package/utils/markdownExtensions.ts +2 -2
- package/utils/mermaid.ts +20 -3
- package/utils/mermaidTheme.ts +3 -51
- package/utils/notion.ts +58 -0
- package/utils/parser.ts +28 -1
- package/utils/platform.ts +46 -0
- package/utils/refCard.ts +82 -0
- package/utils/richClipboard.ts +380 -0
- package/utils/richClipboardLists.ts +57 -0
- package/utils/richCopyPolicy.ts +22 -0
- package/utils/scrollAnchor.ts +82 -0
- package/utils/sectionRefs.ts +123 -0
- package/utils/selectionBoundary.ts +40 -0
- package/utils/sendFailure.ts +85 -0
- package/utils/sharing.ts +5 -2
- package/utils/themeRegistry.ts +10 -0
- package/utils/themeTokens.ts +74 -0
- package/utils/updateNotice.ts +30 -8
- 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
|
|
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,
|