@plannotator/ui 0.53.0 → 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.
- package/HANDOFF.md +284 -27
- package/README.md +84 -14
- package/components/AgentToolAnnouncementDialog.tsx +1 -1
- package/components/AgentsTab.tsx +1 -1
- package/components/AnnotationPanel.tsx +177 -34
- package/components/AnnotationSidebar.tsx +1 -1
- package/components/AnnotationToolbar.tsx +64 -9
- package/components/AnnotationToolstrip.tsx +45 -40
- package/components/ApproveDropdown.tsx +7 -9
- package/components/AskSessionAnnouncementDialog.tsx +1 -1
- package/components/AttachmentsButton.tsx +1 -1
- package/components/BlockRenderer.tsx +19 -5
- package/components/CodeFilePopout.tsx +140 -5
- package/components/CommentPopover.tsx +46 -8
- package/components/DecisionControl.tsx +31 -5
- package/components/DiagramBlock.tsx +17 -12
- package/components/EditorAnnotationCard.tsx +1 -1
- package/components/ExportModal.tsx +77 -7
- package/components/HostTitle.tsx +0 -4
- package/components/HtmlSurfaceControls.tsx +2 -1
- package/components/ImageAnnotator/Toolbar.tsx +25 -1
- package/components/ImageAnnotator/index.tsx +24 -4
- package/components/ImageLightbox.tsx +658 -120
- 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 +152 -24
- 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/MentionPicker.tsx +51 -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/SkillReferenceMenu.tsx +39 -12
- 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 +316 -47
- 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 +522 -62
- package/components/blocks/QuestionContext.tsx +3 -0
- 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 +69 -6
- package/components/html-viewer/bridge-script.asset.js +504 -12
- package/components/html-viewer/bridge-script.lite.ts +1 -1
- package/components/html-viewer/bridge-script.ts +545 -12
- package/components/html-viewer/useHtmlAnnotation.ts +125 -0
- package/components/imageZoom.ts +265 -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/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/useLinkedDoc.ts +226 -13
- package/hooks/useMentionAutocomplete.ts +1 -1
- package/hooks/usePackageChangesSetting.ts +90 -0
- package/hooks/usePlanLookDialog.ts +67 -0
- package/hooks/useSharing.ts +11 -7
- package/hooks/useSkillReferenceAutocomplete.ts +7 -3
- package/hooks/useThemePaletteHalves.ts +37 -0
- package/hooks/useUpdateCheck.ts +22 -4
- package/hooks/useUpdateToast.ts +1 -1
- package/hooks/useVimSelection.ts +9 -0
- package/package.json +23 -22
- 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 +14 -6
- 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/annotationOwnsHighlight.ts +4 -2
- package/utils/asciidocParser.ts +570 -0
- package/utils/autoUpdateNotice.ts +24 -0
- package/utils/belowTitleSlot.ts +174 -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/inboxAnnouncement.ts +78 -0
- package/utils/lookAndFeelAnnouncement.ts +82 -5
- package/utils/markdownExtensions.ts +2 -2
- package/utils/mentions.ts +34 -5
- 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
|
@@ -60,6 +60,47 @@ body[data-plannotator-pinpoint-cursor],
|
|
|
60
60
|
body[data-plannotator-pinpoint-cursor] * {
|
|
61
61
|
cursor: crosshair !important;
|
|
62
62
|
}
|
|
63
|
+
/* A plain click on a link with a real destination follows it while armed
|
|
64
|
+
* (Cmd/Ctrl-click pins it), so such a link keeps the pointer that says so,
|
|
65
|
+
* until the modifier is held. A link that goes nowhere (no href, "", "#",
|
|
66
|
+
* javascript:) is not followed and keeps the crosshair. SVG links may carry
|
|
67
|
+
* only xlink:href, hence the any-namespace attribute. */
|
|
68
|
+
body[data-plannotator-pinpoint-cursor]:not([data-plannotator-pin-modifier]) :is(a, area)[href]:not([href=""]):not([href="#"]):not([href^="javascript:" i]),
|
|
69
|
+
body[data-plannotator-pinpoint-cursor]:not([data-plannotator-pin-modifier]) :is(a, area)[href]:not([href=""]):not([href="#"]):not([href^="javascript:" i]) *,
|
|
70
|
+
body[data-plannotator-pinpoint-cursor]:not([data-plannotator-pin-modifier]) svg a:not([href])[*|href]:not([*|href=""]):not([*|href="#"]):not([*|href^="javascript:" i]),
|
|
71
|
+
body[data-plannotator-pinpoint-cursor]:not([data-plannotator-pin-modifier]) svg a:not([href])[*|href]:not([*|href=""]):not([*|href="#"]):not([*|href^="javascript:" i]) * {
|
|
72
|
+
cursor: pointer !important;
|
|
73
|
+
}
|
|
74
|
+
/* Touch, armed: a tap on a link follows it and a press-and-hold pins it for
|
|
75
|
+
* a comment. iOS's link callout would take that hold, so it is off on links
|
|
76
|
+
* while armed, once a touch is seen (data-plannotator-touch) or on screens
|
|
77
|
+
* without hover. The callout is all: link text stays selectable, so a drag
|
|
78
|
+
* across a link (a trackpad, a touchscreen laptop) still quotes its words.
|
|
79
|
+
* Only while a hold on a link is in progress (data-plannotator-link-hold,
|
|
80
|
+
* from its touchstart to its release) is selection off too, so the platform's
|
|
81
|
+
* long-press does not start a native text selection under the finger. */
|
|
82
|
+
body[data-plannotator-pinpoint-cursor][data-plannotator-touch] :is(a, area),
|
|
83
|
+
body[data-plannotator-pinpoint-cursor][data-plannotator-touch] :is(a, area) *,
|
|
84
|
+
body[data-plannotator-pinpoint-cursor][data-plannotator-touch] svg a,
|
|
85
|
+
body[data-plannotator-pinpoint-cursor][data-plannotator-touch] svg a * {
|
|
86
|
+
-webkit-touch-callout: none !important;
|
|
87
|
+
}
|
|
88
|
+
@media (hover: none) {
|
|
89
|
+
body[data-plannotator-pinpoint-cursor] :is(a, area),
|
|
90
|
+
body[data-plannotator-pinpoint-cursor] :is(a, area) *,
|
|
91
|
+
body[data-plannotator-pinpoint-cursor] svg a,
|
|
92
|
+
body[data-plannotator-pinpoint-cursor] svg a * {
|
|
93
|
+
-webkit-touch-callout: none !important;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
body[data-plannotator-link-hold] :is(a, area),
|
|
97
|
+
body[data-plannotator-link-hold] :is(a, area) *,
|
|
98
|
+
body[data-plannotator-link-hold] svg a,
|
|
99
|
+
body[data-plannotator-link-hold] svg a * {
|
|
100
|
+
-webkit-touch-callout: none !important;
|
|
101
|
+
-webkit-user-select: none !important;
|
|
102
|
+
user-select: none !important;
|
|
103
|
+
}
|
|
63
104
|
/* Armed pinpoint over an EMBEDDED local document: the embed is one element
|
|
64
105
|
* from the outer page's point of view, and the bridge is never injected into a
|
|
65
106
|
* nested frame, so a click inside it would simply vanish into another document.
|
|
@@ -419,6 +460,10 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
419
460
|
// --- Selection ---
|
|
420
461
|
var pendingSelection = null;
|
|
421
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 = '';
|
|
422
467
|
var pendingPinEl = null; // element pinned by a pinpoint click (outline + scroll tracking)
|
|
423
468
|
var pendingPinAnchor = null; // serialized anchor for the pending pin
|
|
424
469
|
var pendingPinKey = null; // target key for the primary pinpoint target (multi-select)
|
|
@@ -530,7 +575,8 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
530
575
|
}
|
|
531
576
|
skipNextClear = false; // a real text selection happened
|
|
532
577
|
var range = sel.getRangeAt(0);
|
|
533
|
-
var
|
|
578
|
+
var fullText = sel.toString().trim();
|
|
579
|
+
var text = capSelectionText(fullText);
|
|
534
580
|
if (!text) return false;
|
|
535
581
|
|
|
536
582
|
// A draft now owns the surface: kill the click-to-select hover
|
|
@@ -539,6 +585,7 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
539
585
|
|
|
540
586
|
var rect = range.getBoundingClientRect();
|
|
541
587
|
pendingRange = range;
|
|
588
|
+
pendingCopyText = fullText;
|
|
542
589
|
pendingSelection = {
|
|
543
590
|
text: text,
|
|
544
591
|
startContainerPath: getNodePath(range.startContainer),
|
|
@@ -556,6 +603,13 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
556
603
|
targetKey: (extras && extras.targetKey) || undefined,
|
|
557
604
|
targetLabel: (extras && extras.targetLabel) || undefined,
|
|
558
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,
|
|
559
613
|
rect: { top: rect.top, left: rect.left, width: rect.width, height: rect.height }
|
|
560
614
|
});
|
|
561
615
|
renderAnnotationOverlay(); // draft selection highlight (overlay-projected)
|
|
@@ -604,6 +658,46 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
604
658
|
if (!e.data || !e.data.type) return;
|
|
605
659
|
var type = e.data.type;
|
|
606
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
|
+
|
|
607
701
|
if (type === PREFIX + 'create-mark') {
|
|
608
702
|
var id = e.data.id;
|
|
609
703
|
var annType = e.data.annotationType || 'comment';
|
|
@@ -1321,6 +1415,10 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
1321
1415
|
return pinpointBoxEl;
|
|
1322
1416
|
}
|
|
1323
1417
|
function positionPinpointBox(el) {
|
|
1418
|
+
if (pendingPinHidden(el)) {
|
|
1419
|
+
if (pinpointBoxEl) pinpointBoxEl.style.display = 'none';
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1324
1422
|
var r = el.getBoundingClientRect();
|
|
1325
1423
|
var box = getPinpointBoxEl();
|
|
1326
1424
|
box.style.display = 'block';
|
|
@@ -1357,6 +1455,14 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
1357
1455
|
// changes); shared by mousemove and the scroll/resize reconcile pass.
|
|
1358
1456
|
// Per-event hit-testing only — never builds the semantic graph.
|
|
1359
1457
|
function updatePinpointHover(x, y, fallbackNode) {
|
|
1458
|
+
// A link a plain click follows shows no pin outline unless Cmd/Ctrl is
|
|
1459
|
+
// held: the outline always matches what a click there will do.
|
|
1460
|
+
if (!pinModifierHeld && !pendingPinEl && (followableLinkAt(fallbackNode) || followableLinkAt(deepElementFromPoint(x, y)))) {
|
|
1461
|
+
pinpointHover = null;
|
|
1462
|
+
hidePinpointBox();
|
|
1463
|
+
hidePinpointLabel();
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1360
1466
|
var el = pinpointLeafAt(x, y, fallbackNode);
|
|
1361
1467
|
// The 25px marker bubble owns clicks over it (they select its comment),
|
|
1362
1468
|
// so hover must advertise the MARKER's own identity — no annotate box,
|
|
@@ -1387,9 +1493,36 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
1387
1493
|
positionPinpointLabel(el, pinpointHoverLabel(el));
|
|
1388
1494
|
}
|
|
1389
1495
|
|
|
1496
|
+
// Cmd or Ctrl held (the pin modifier): a link under the pointer shows the
|
|
1497
|
+
// pin outline and the crosshair instead of the follow pointer. Read from
|
|
1498
|
+
// the key itself (as the token-hover gate does) and from every pointer
|
|
1499
|
+
// move, so a key pressed with focus outside this frame still counts.
|
|
1500
|
+
var pinModifierHeld = false;
|
|
1501
|
+
function setPinModifier(held) {
|
|
1502
|
+
if (held === pinModifierHeld) return;
|
|
1503
|
+
pinModifierHeld = held;
|
|
1504
|
+
if (document.body) {
|
|
1505
|
+
if (held) document.body.setAttribute('data-plannotator-pin-modifier', '');
|
|
1506
|
+
else document.body.removeAttribute('data-plannotator-pin-modifier');
|
|
1507
|
+
}
|
|
1508
|
+
if (annotateModeActive && currentInputMethod === 'pinpoint' && lastPointer && !(vimEnabled && vimPhase !== 'inactive')) {
|
|
1509
|
+
updatePinpointHover(lastPointer.x, lastPointer.y, lastPointerNode || pinpointHover);
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
document.addEventListener('keydown', function(e) {
|
|
1513
|
+
if (e.key === 'Meta' || e.key === 'Control') setPinModifier(true);
|
|
1514
|
+
}, true);
|
|
1515
|
+
document.addEventListener('keyup', function(e) {
|
|
1516
|
+
if (e.key === 'Meta' || e.key === 'Control') setPinModifier(e.metaKey || e.ctrlKey);
|
|
1517
|
+
}, true);
|
|
1518
|
+
window.addEventListener('blur', function() { setPinModifier(false); });
|
|
1519
|
+
|
|
1390
1520
|
var lastPointer = null;
|
|
1521
|
+
var lastPointerNode = null;
|
|
1391
1522
|
document.addEventListener('mousemove', function(e) {
|
|
1392
1523
|
lastPointer = { x: e.clientX, y: e.clientY };
|
|
1524
|
+
lastPointerNode = e.target && e.target.nodeType === 1 ? e.target : null;
|
|
1525
|
+
if (!!(e.metaKey || e.ctrlKey) !== pinModifierHeld) setPinModifier(!!(e.metaKey || e.ctrlKey));
|
|
1393
1526
|
updateDragYield(e);
|
|
1394
1527
|
if (!annotateModeActive) return; // Interact mode: no hover affordances
|
|
1395
1528
|
if (currentInputMethod !== 'pinpoint') {
|
|
@@ -1439,6 +1572,8 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
1439
1572
|
if (annotateModeActive && currentInputMethod !== 'pinpoint' && lastPointer && !pendingPinEl && !pendingSelection) {
|
|
1440
1573
|
setHoverHighlight(committedHighlightIdAtCached(lastPointer.x, lastPointer.y));
|
|
1441
1574
|
}
|
|
1575
|
+
// Before the boxes: it may re-find a replaced target they then follow.
|
|
1576
|
+
reconcilePendingPinVisibility();
|
|
1442
1577
|
positionMultiTargetBoxes();
|
|
1443
1578
|
if (pendingPinEl && pendingPinEl.isConnected) {
|
|
1444
1579
|
positionPinpointBox(pendingPinEl);
|
|
@@ -3431,7 +3566,91 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3431
3566
|
}
|
|
3432
3567
|
}
|
|
3433
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
|
+
|
|
3434
3651
|
function clearPendingPin() {
|
|
3652
|
+
pendingPinVisible = true;
|
|
3653
|
+
pendingPinSearchGen = 0;
|
|
3435
3654
|
pendingPinEl = null;
|
|
3436
3655
|
pendingPinContext = null;
|
|
3437
3656
|
pendingPinAnchor = null;
|
|
@@ -3487,6 +3706,7 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3487
3706
|
}
|
|
3488
3707
|
|
|
3489
3708
|
function positionBoxToEl(box, el) {
|
|
3709
|
+
if (pendingPinHidden(el)) { box.style.display = 'none'; return; }
|
|
3490
3710
|
var r = el.getBoundingClientRect();
|
|
3491
3711
|
box.style.display = 'block';
|
|
3492
3712
|
box.style.left = r.left + 'px';
|
|
@@ -3618,6 +3838,7 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3618
3838
|
*/
|
|
3619
3839
|
function removeMultiTargetByKey(key, echo) {
|
|
3620
3840
|
if (!key) return;
|
|
3841
|
+
schedulePinpointReconcile();
|
|
3621
3842
|
if (key === pendingPinKey) {
|
|
3622
3843
|
if (!pendingMultiTargets.length) {
|
|
3623
3844
|
// Last target gone — the draft is cancelled.
|
|
@@ -3634,6 +3855,7 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3634
3855
|
var next = pendingMultiTargets.shift();
|
|
3635
3856
|
destroyMultiTargetBox(next.box);
|
|
3636
3857
|
pendingPinEl = next.el;
|
|
3858
|
+
pendingPinSearchGen = 0;
|
|
3637
3859
|
pendingPinAnchor = next.anchor;
|
|
3638
3860
|
pendingPinKey = next.key;
|
|
3639
3861
|
pendingPinLabel = next.label;
|
|
@@ -3701,6 +3923,7 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3701
3923
|
var key = makeTargetKey();
|
|
3702
3924
|
var box = createMultiTargetBox(el);
|
|
3703
3925
|
pendingMultiTargets.push({ key: key, el: el, anchor: anchor, label: label, text: text, point: point, context: context, box: box });
|
|
3926
|
+
schedulePinpointReconcile();
|
|
3704
3927
|
postToParent({
|
|
3705
3928
|
type: PREFIX + 'multi-target-added',
|
|
3706
3929
|
key: key,
|
|
@@ -3835,6 +4058,8 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3835
4058
|
multiSelectArmed = false;
|
|
3836
4059
|
multiSelectMax = MAX_MULTI_TARGETS;
|
|
3837
4060
|
pendingPinEl = el;
|
|
4061
|
+
pendingPinVisible = true;
|
|
4062
|
+
pendingPinSearchGen = 0;
|
|
3838
4063
|
pendingPinAnchor = buildElementAnchor(el);
|
|
3839
4064
|
pendingPinKey = makeTargetKey();
|
|
3840
4065
|
pendingPinLabel = pinpointHoverLabel(el);
|
|
@@ -3899,6 +4124,230 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3899
4124
|
return true;
|
|
3900
4125
|
}
|
|
3901
4126
|
|
|
4127
|
+
// --- Touch: a tap on a link follows it, press-and-hold pins it ---
|
|
4128
|
+
// The owner's amendment to html-link-click (2026-10-20): on a phone there is
|
|
4129
|
+
// no Cmd/Ctrl, so while armed a TAP on a link follows it (the click handlers
|
|
4130
|
+
// below take it as a plain click) and a PRESS-AND-HOLD of HOLD_PIN_MS opens
|
|
4131
|
+
// a comment on it, pinned exactly as a Cmd-click pins it. Every surface
|
|
4132
|
+
// alike: srcdoc pages (annotate, plan review's HTML, the Inbox) and live apps.
|
|
4133
|
+
// A finger that moves past HOLD_SLOP_PX (a scroll), a second finger, or a
|
|
4134
|
+
// scroll cancels the hold. The release of a hold that pinned never follows
|
|
4135
|
+
// the link and never reaches the page: its pointerup and touchend are
|
|
4136
|
+
// stopped at the window (registered before a live app's own listeners),
|
|
4137
|
+
// touchend is cancelled so no click is synthesized, and a click that still
|
|
4138
|
+
// arrives is swallowed once. That swallow ends at the next touch, mouse
|
|
4139
|
+
// press or key, so it never eats an unrelated click.
|
|
4140
|
+
// While a hold is in progress, link text is not selectable (the CSS above),
|
|
4141
|
+
// so the platform's long-press starts no native selection under the finger;
|
|
4142
|
+
// Android's contextmenu, its long-press signal, fires the hold at once.
|
|
4143
|
+
var HOLD_PIN_MS = 500;
|
|
4144
|
+
var HOLD_SLOP_PX = 10;
|
|
4145
|
+
var HOLD_SWALLOW_MAX_MS = 10000; // a release never seen (an app stopped touchend)
|
|
4146
|
+
var linkHold = null; // { timer, x, y, id, target }
|
|
4147
|
+
// After a hold pinned: { pointerId, released, until } until its release's
|
|
4148
|
+
// click (if any) is swallowed or the next gesture starts.
|
|
4149
|
+
var heldGesture = null;
|
|
4150
|
+
var lastTouchPointerId = null;
|
|
4151
|
+
function holdArmed() {
|
|
4152
|
+
return annotateModeActive && currentInputMethod === 'pinpoint' && !(vimEnabled && vimPhase !== 'inactive');
|
|
4153
|
+
}
|
|
4154
|
+
function setLinkHoldStyle(on) {
|
|
4155
|
+
if (!document.body) return;
|
|
4156
|
+
if (on) document.body.setAttribute('data-plannotator-link-hold', '');
|
|
4157
|
+
else document.body.removeAttribute('data-plannotator-link-hold');
|
|
4158
|
+
}
|
|
4159
|
+
function cancelLinkHold() {
|
|
4160
|
+
if (!linkHold) return;
|
|
4161
|
+
clearTimeout(linkHold.timer);
|
|
4162
|
+
linkHold = null;
|
|
4163
|
+
setLinkHoldStyle(false);
|
|
4164
|
+
}
|
|
4165
|
+
function fireLinkHold() {
|
|
4166
|
+
var hold = linkHold;
|
|
4167
|
+
if (!hold) return false;
|
|
4168
|
+
clearTimeout(hold.timer);
|
|
4169
|
+
linkHold = null;
|
|
4170
|
+
if (!holdArmed() || !hold.target.isConnected) {
|
|
4171
|
+
setLinkHoldStyle(false);
|
|
4172
|
+
return false;
|
|
4173
|
+
}
|
|
4174
|
+
var el = resolvePinpointTargetAt(hold.x, hold.y, hold.target);
|
|
4175
|
+
if (!el) {
|
|
4176
|
+
setLinkHoldStyle(false);
|
|
4177
|
+
return false;
|
|
4178
|
+
}
|
|
4179
|
+
heldGesture = { pointerId: lastTouchPointerId, released: false, pointerUpSeen: false, pointerEnded: false, touchEnded: false, target: hold.target, touchId: hold.id, x: hold.x, y: hold.y, until: Date.now() + HOLD_SWALLOW_MAX_MS };
|
|
4180
|
+
// The same pin a Cmd-click makes: the link's own words as a text range.
|
|
4181
|
+
// Selection is back on for that, then off again until the finger lifts.
|
|
4182
|
+
setLinkHoldStyle(false);
|
|
4183
|
+
try { window.getSelection().removeAllRanges(); } catch (ex) {}
|
|
4184
|
+
annotateElement(el, undefined, true, { x: hold.x, y: hold.y });
|
|
4185
|
+
setLinkHoldStyle(true);
|
|
4186
|
+
return true;
|
|
4187
|
+
}
|
|
4188
|
+
function endHeldGesture() {
|
|
4189
|
+
heldGesture = null;
|
|
4190
|
+
}
|
|
4191
|
+
function touchById(list, id) {
|
|
4192
|
+
for (var i = 0; list && i < list.length; i++) if (list[i].identifier === id) return list[i];
|
|
4193
|
+
return null;
|
|
4194
|
+
}
|
|
4195
|
+
// The release of a hold is stopped, so the page never sees it end: a press
|
|
4196
|
+
// library (React Aria's usePress, Motion's whileTap, dnd-kit) would keep the
|
|
4197
|
+
// press open. In its place the page gets a pointercancel and a touchcancel
|
|
4198
|
+
// on the element the finger went down on, which end a press without
|
|
4199
|
+
// activating it. The bridge's own listeners ignore those two.
|
|
4200
|
+
var bridgeSynthetic = typeof WeakSet === 'function' ? new WeakSet() : null;
|
|
4201
|
+
function isBridgeSynthetic(e) {
|
|
4202
|
+
return !!(bridgeSynthetic && bridgeSynthetic.has(e));
|
|
4203
|
+
}
|
|
4204
|
+
function dispatchSynthetic(target, event) {
|
|
4205
|
+
if (bridgeSynthetic) bridgeSynthetic.add(event);
|
|
4206
|
+
try { target.dispatchEvent(event); } catch (ex) {}
|
|
4207
|
+
}
|
|
4208
|
+
function sendPointerCancel(g, pointerId) {
|
|
4209
|
+
if (g.pointerEnded || pointerId === null || pointerId === undefined) return;
|
|
4210
|
+
g.pointerEnded = true;
|
|
4211
|
+
var target = g.target && g.target.isConnected ? g.target : document.body;
|
|
4212
|
+
if (!target) return;
|
|
4213
|
+
var init = { bubbles: true, cancelable: false, composed: true, pointerId: pointerId, pointerType: 'touch', isPrimary: true, clientX: g.x, clientY: g.y };
|
|
4214
|
+
var event = null;
|
|
4215
|
+
try { if (typeof PointerEvent === 'function') event = new PointerEvent('pointercancel', init); } catch (ex) {}
|
|
4216
|
+
if (!event) {
|
|
4217
|
+
event = new Event('pointercancel', { bubbles: true, cancelable: false, composed: true });
|
|
4218
|
+
try {
|
|
4219
|
+
Object.defineProperty(event, 'pointerId', { value: pointerId });
|
|
4220
|
+
Object.defineProperty(event, 'pointerType', { value: 'touch' });
|
|
4221
|
+
Object.defineProperty(event, 'isPrimary', { value: true });
|
|
4222
|
+
} catch (ex) {}
|
|
4223
|
+
}
|
|
4224
|
+
dispatchSynthetic(target, event);
|
|
4225
|
+
}
|
|
4226
|
+
function sendTouchCancel(g) {
|
|
4227
|
+
if (g.touchEnded) return;
|
|
4228
|
+
g.touchEnded = true;
|
|
4229
|
+
var target = g.target && g.target.isConnected ? g.target : document.body;
|
|
4230
|
+
if (!target) return;
|
|
4231
|
+
var event = null;
|
|
4232
|
+
try {
|
|
4233
|
+
if (typeof TouchEvent === 'function' && typeof Touch === 'function') {
|
|
4234
|
+
var t = new Touch({ identifier: g.touchId, target: target, clientX: g.x, clientY: g.y });
|
|
4235
|
+
event = new TouchEvent('touchcancel', { bubbles: true, cancelable: false, composed: true, touches: [], targetTouches: [], changedTouches: [t] });
|
|
4236
|
+
}
|
|
4237
|
+
} catch (ex) { event = null; }
|
|
4238
|
+
if (!event) {
|
|
4239
|
+
event = new Event('touchcancel', { bubbles: true, cancelable: false, composed: true });
|
|
4240
|
+
var point = { identifier: g.touchId, target: target, clientX: g.x, clientY: g.y };
|
|
4241
|
+
try {
|
|
4242
|
+
Object.defineProperty(event, 'touches', { value: [] });
|
|
4243
|
+
Object.defineProperty(event, 'targetTouches', { value: [] });
|
|
4244
|
+
Object.defineProperty(event, 'changedTouches', { value: [point] });
|
|
4245
|
+
} catch (ex) {}
|
|
4246
|
+
}
|
|
4247
|
+
dispatchSynthetic(target, event);
|
|
4248
|
+
}
|
|
4249
|
+
// Pointer events come before touch events; the touch's pointerId names the
|
|
4250
|
+
// release's pointerup.
|
|
4251
|
+
window.addEventListener('pointerdown', function(e) {
|
|
4252
|
+
if (isBridgeSynthetic(e)) return;
|
|
4253
|
+
if (e.pointerType === 'touch') {
|
|
4254
|
+
lastTouchPointerId = e.pointerId;
|
|
4255
|
+
if (heldGesture && heldGesture.released) endHeldGesture();
|
|
4256
|
+
return;
|
|
4257
|
+
}
|
|
4258
|
+
// A mouse or pen press: whatever click comes next is its own.
|
|
4259
|
+
endHeldGesture();
|
|
4260
|
+
}, true);
|
|
4261
|
+
window.addEventListener('keydown', function() { if (heldGesture && heldGesture.released) endHeldGesture(); }, true);
|
|
4262
|
+
function heldPointer(e) {
|
|
4263
|
+
if (!heldGesture || e.pointerType !== 'touch') return false;
|
|
4264
|
+
return heldGesture.pointerId === null || e.pointerId === heldGesture.pointerId;
|
|
4265
|
+
}
|
|
4266
|
+
window.addEventListener('pointerup', function(e) {
|
|
4267
|
+
if (isBridgeSynthetic(e) || !heldPointer(e) || heldGesture.pointerUpSeen) return;
|
|
4268
|
+
heldGesture.pointerUpSeen = true;
|
|
4269
|
+
e.stopImmediatePropagation();
|
|
4270
|
+
sendPointerCancel(heldGesture, e.pointerId);
|
|
4271
|
+
}, true);
|
|
4272
|
+
// The browser ended the held pointer itself (it took the touch): nothing to stand in for.
|
|
4273
|
+
window.addEventListener('pointercancel', function(e) {
|
|
4274
|
+
if (isBridgeSynthetic(e) || !heldPointer(e)) return;
|
|
4275
|
+
heldGesture.pointerUpSeen = true;
|
|
4276
|
+
heldGesture.pointerEnded = true;
|
|
4277
|
+
}, true);
|
|
4278
|
+
window.addEventListener('touchend', function(e) {
|
|
4279
|
+
if (isBridgeSynthetic(e)) return;
|
|
4280
|
+
if (heldGesture && !heldGesture.released) {
|
|
4281
|
+
// The release of the hold that pinned: no click, no navigation, and
|
|
4282
|
+
// the page's own touchend never runs on it; it gets cancels instead.
|
|
4283
|
+
if (e.cancelable) e.preventDefault();
|
|
4284
|
+
e.stopImmediatePropagation();
|
|
4285
|
+
var g = heldGesture;
|
|
4286
|
+
g.released = true;
|
|
4287
|
+
g.until = Date.now() + 800;
|
|
4288
|
+
setLinkHoldStyle(false);
|
|
4289
|
+
// Engines that send touchend before pointerup: the pointer ends here
|
|
4290
|
+
// (its own pointerup, when it comes, is still stopped).
|
|
4291
|
+
sendPointerCancel(g, g.pointerId);
|
|
4292
|
+
sendTouchCancel(g);
|
|
4293
|
+
return;
|
|
4294
|
+
}
|
|
4295
|
+
cancelLinkHold(); // a tap: the click that follows it follows the link
|
|
4296
|
+
}, { capture: true, passive: false });
|
|
4297
|
+
window.addEventListener('touchcancel', function(e) {
|
|
4298
|
+
if (isBridgeSynthetic(e)) return;
|
|
4299
|
+
cancelLinkHold();
|
|
4300
|
+
setLinkHoldStyle(false);
|
|
4301
|
+
if (heldGesture) {
|
|
4302
|
+
heldGesture.released = true;
|
|
4303
|
+
heldGesture.touchEnded = true;
|
|
4304
|
+
heldGesture.until = Date.now() + 800;
|
|
4305
|
+
}
|
|
4306
|
+
}, true);
|
|
4307
|
+
document.addEventListener('touchstart', function(e) {
|
|
4308
|
+
if (document.body && !document.body.hasAttribute('data-plannotator-touch')) {
|
|
4309
|
+
document.body.setAttribute('data-plannotator-touch', '');
|
|
4310
|
+
}
|
|
4311
|
+
cancelLinkHold();
|
|
4312
|
+
// A new touch: whatever click comes next is its own, even if the last
|
|
4313
|
+
// hold's release was never seen.
|
|
4314
|
+
endHeldGesture();
|
|
4315
|
+
setLinkHoldStyle(false);
|
|
4316
|
+
if (!holdArmed() || !e.touches || e.touches.length !== 1) return;
|
|
4317
|
+
if (isViewerOverlayNode(e.target) || !followableLinkAt(e.target)) return;
|
|
4318
|
+
var t = e.touches[0];
|
|
4319
|
+
linkHold = { x: t.clientX, y: t.clientY, id: t.identifier, target: e.target, timer: 0 };
|
|
4320
|
+
linkHold.timer = setTimeout(fireLinkHold, HOLD_PIN_MS);
|
|
4321
|
+
setLinkHoldStyle(true);
|
|
4322
|
+
}, { capture: true, passive: true });
|
|
4323
|
+
document.addEventListener('touchmove', function(e) {
|
|
4324
|
+
if (!linkHold) return;
|
|
4325
|
+
var t = touchById(e.touches, linkHold.id);
|
|
4326
|
+
if (!t || e.touches.length !== 1 || Math.abs(t.clientX - linkHold.x) > HOLD_SLOP_PX || Math.abs(t.clientY - linkHold.y) > HOLD_SLOP_PX) {
|
|
4327
|
+
cancelLinkHold();
|
|
4328
|
+
}
|
|
4329
|
+
}, { capture: true, passive: true });
|
|
4330
|
+
window.addEventListener('scroll', cancelLinkHold, true);
|
|
4331
|
+
document.addEventListener('contextmenu', function(e) {
|
|
4332
|
+
if (linkHold) {
|
|
4333
|
+
e.preventDefault();
|
|
4334
|
+
fireLinkHold();
|
|
4335
|
+
return;
|
|
4336
|
+
}
|
|
4337
|
+
if (heldGesture && Date.now() < heldGesture.until) e.preventDefault();
|
|
4338
|
+
}, true);
|
|
4339
|
+
// On the window, before every other click handler of the bridge and a live
|
|
4340
|
+
// app's own, so the release of a hold reaches neither the link handler nor
|
|
4341
|
+
// the pinpoint one, nor the page (a live app would navigate).
|
|
4342
|
+
window.addEventListener('click', function(e) {
|
|
4343
|
+
if (!heldGesture) return;
|
|
4344
|
+
var swallow = Date.now() < heldGesture.until;
|
|
4345
|
+
endHeldGesture();
|
|
4346
|
+
if (!swallow) return;
|
|
4347
|
+
e.preventDefault();
|
|
4348
|
+
e.stopImmediatePropagation();
|
|
4349
|
+
}, true);
|
|
4350
|
+
|
|
3902
4351
|
// --- Local-site link navigation (srcdoc sessions only) ---
|
|
3903
4352
|
// A srcdoc document has no URL of its own: its base URL is the PARENT page's,
|
|
3904
4353
|
// which is the Plannotator server. So a plain link to 02-detail.html resolves
|
|
@@ -3910,8 +4359,14 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3910
4359
|
// The frame therefore never navigates itself. In-page fragments scroll here;
|
|
3911
4360
|
// everything else is handed to the parent, which owns resolution against the
|
|
3912
4361
|
// current document's directory and is the trust boundary for the href.
|
|
3913
|
-
// Registered BEFORE the pinpoint handler and never stopping propagation
|
|
3914
|
-
//
|
|
4362
|
+
// Registered BEFORE the pinpoint handler and never stopping propagation.
|
|
4363
|
+
//
|
|
4364
|
+
// One rule while armed (the owner's ruling, html-link-click, 2026-10-20):
|
|
4365
|
+
// a plain click on a link FOLLOWS it, exactly as in Interact; a click with
|
|
4366
|
+
// the platform modifier (Cmd on macOS, Ctrl elsewhere: either is taken)
|
|
4367
|
+
// PINS it for a comment through the pinpoint handler below, as does a
|
|
4368
|
+
// shift-click that adds it to an open multi-select draft. A drag that ended
|
|
4369
|
+
// on the link owns its click: it neither follows nor pins.
|
|
3915
4370
|
//
|
|
3916
4371
|
// Live app sessions are excluded outright: they navigate a real origin
|
|
3917
4372
|
// through the proxy, which is the whole point of that surface.
|
|
@@ -3934,6 +4389,25 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3934
4389
|
return true;
|
|
3935
4390
|
}
|
|
3936
4391
|
|
|
4392
|
+
// While armed, whether a click on a link pins it instead of following it:
|
|
4393
|
+
// the platform modifier (Cmd or Ctrl), a shift-click adding to an open
|
|
4394
|
+
// multi-select draft, or the trailing click of a drag that selected text.
|
|
4395
|
+
function armedClickPins(e) {
|
|
4396
|
+
if (e.metaKey || e.ctrlKey || dragEndedClick) return true;
|
|
4397
|
+
return !!(e.shiftKey && pendingPinEl && multiSelectArmed && pendingSelection);
|
|
4398
|
+
}
|
|
4399
|
+
|
|
4400
|
+
// A link with a real destination: one a plain click follows while armed.
|
|
4401
|
+
// No href, "", "#" and javascript: go nowhere, so a click on them pins
|
|
4402
|
+
// the element as on any other (agents' mocks use <a href="#"> for buttons).
|
|
4403
|
+
function isFollowableHref(raw) {
|
|
4404
|
+
return !!raw && raw !== '#' && !/^javascript:/i.test(raw);
|
|
4405
|
+
}
|
|
4406
|
+
|
|
4407
|
+
function followableLinkAt(node) {
|
|
4408
|
+
return !!node && isFollowableHref(navigableLinkHref(node));
|
|
4409
|
+
}
|
|
4410
|
+
|
|
3937
4411
|
function navigableLinkHref(node) {
|
|
3938
4412
|
var el = node && node.nodeType === 1 ? node : node && node.parentElement;
|
|
3939
4413
|
if (!el || !el.closest) return '';
|
|
@@ -3953,26 +4427,30 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3953
4427
|
if (!raw) return;
|
|
3954
4428
|
// The page's own scripting, not a navigation: leave it alone.
|
|
3955
4429
|
if (/^javascript:/i.test(raw)) return;
|
|
4430
|
+
// Armed and pinning (a link that goes nowhere, the modifier, or a
|
|
4431
|
+
// drag's trailing click): the frame never navigates, and the pinpoint
|
|
4432
|
+
// handler below takes the click.
|
|
4433
|
+
if (annotateModeActive && currentInputMethod === 'pinpoint' && (!isFollowableHref(raw) || armedClickPins(e))) {
|
|
4434
|
+
e.preventDefault();
|
|
4435
|
+
return;
|
|
4436
|
+
}
|
|
3956
4437
|
if (raw.charAt(0) === '#') {
|
|
3957
4438
|
e.preventDefault();
|
|
3958
4439
|
scrollToLocalFragment(raw.slice(1));
|
|
3959
4440
|
return;
|
|
3960
4441
|
}
|
|
3961
4442
|
e.preventDefault();
|
|
4443
|
+
// Only a person's click follows a link: a page's own script calling
|
|
4444
|
+
// a.click() never navigates the viewer or pushes the host's history.
|
|
4445
|
+
if (!e.isTrusted) return;
|
|
3962
4446
|
// A native host decides links itself: a person's tap opens the link as a
|
|
3963
4447
|
// new window, which the host's web view hands to its own delegate (the
|
|
3964
4448
|
// frame never navigates; the host page's CSP would refuse it anyway).
|
|
3965
4449
|
if (hostNavigates) {
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
try { window.open(target && target.href ? target.href : raw, '_blank', 'noopener'); } catch (ex) {}
|
|
3969
|
-
}
|
|
4450
|
+
var target = (e.target && e.target.closest) ? e.target.closest('a,area') : null;
|
|
4451
|
+
try { window.open(target && target.href ? target.href : raw, '_blank', 'noopener'); } catch (ex) {}
|
|
3970
4452
|
return;
|
|
3971
4453
|
}
|
|
3972
|
-
// Armed pinpoint: the click belongs to annotation, and the capture-phase
|
|
3973
|
-
// pinpoint handler below is about to pin this element. Navigation is
|
|
3974
|
-
// already suppressed above, which is all this surface owes the click.
|
|
3975
|
-
if (annotateModeActive && currentInputMethod === 'pinpoint') return;
|
|
3976
4454
|
postToParent({ type: PREFIX + 'link-click', href: raw.slice(0, 2048) });
|
|
3977
4455
|
}, true);
|
|
3978
4456
|
}
|
|
@@ -3991,7 +4469,12 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3991
4469
|
// and mousedown clears a prior pin's leftover selection, so stale
|
|
3992
4470
|
// selection state never blocks the next plain re-pin click. One-shot:
|
|
3993
4471
|
// only the drag's own trailing click is suppressed.
|
|
3994
|
-
if (dragEndedClick) {
|
|
4472
|
+
if (dragEndedClick) {
|
|
4473
|
+
dragEndedClick = false;
|
|
4474
|
+
// Never a navigation either (a live app's link would otherwise follow).
|
|
4475
|
+
if (navigableLinkHref(e.target)) e.preventDefault();
|
|
4476
|
+
return;
|
|
4477
|
+
}
|
|
3995
4478
|
// Shift-click while an ARMED pinpoint draft is open: toggle the element
|
|
3996
4479
|
// in/out of the SAME draft comment instead of replacing the selection.
|
|
3997
4480
|
// Unarmed drafts (modes the parent does not mirror, e.g. quickLabel)
|
|
@@ -4005,6 +4488,9 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
4005
4488
|
if (multiEl) toggleMultiTarget(multiEl, { x: e.clientX, y: e.clientY });
|
|
4006
4489
|
return;
|
|
4007
4490
|
}
|
|
4491
|
+
// A plain click on a link follows it (the link handler above posted it;
|
|
4492
|
+
// in a live app the browser navigates for real). The modifier pins it.
|
|
4493
|
+
if (isFollowableHref(navigableLinkHref(e.target)) && !armedClickPins(e)) return;
|
|
4008
4494
|
// Click what the hover box shows: the currently hovered element is the
|
|
4009
4495
|
// target the user aimed at. Fall back to a fresh hit-test at the click
|
|
4010
4496
|
// point (e.g. a click with no preceding mousemove).
|
|
@@ -4032,12 +4518,36 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
4032
4518
|
window.getSelection().removeAllRanges();
|
|
4033
4519
|
renderAnnotationOverlay();
|
|
4034
4520
|
}
|
|
4521
|
+
// An Escape in Interact that the page did not use (no handler cancelled it
|
|
4522
|
+
// or stopped it on its way up, focus not in one of its text fields) is
|
|
4523
|
+
// offered to the host as page-escape, after every handler had its turn: the
|
|
4524
|
+
// Inbox leaves full screen with it. The page still got the key first.
|
|
4525
|
+
var escapeReachedWindow = null;
|
|
4526
|
+
window.addEventListener('keydown', function(e) {
|
|
4527
|
+
if (e.key === 'Escape') escapeReachedWindow = e;
|
|
4528
|
+
});
|
|
4529
|
+
function offerUnusedEscape(e) {
|
|
4530
|
+
if (e.repeat || e.isComposing || e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
|
|
4531
|
+
var t = e.target;
|
|
4532
|
+
if (t && t.nodeType === 1) {
|
|
4533
|
+
var tag = t.tagName;
|
|
4534
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return;
|
|
4535
|
+
if (t.closest && t.closest('[contenteditable]:not([contenteditable="false"])')) return;
|
|
4536
|
+
}
|
|
4537
|
+
// The page's own dialog closes on this Escape (the browser's default, not a handler).
|
|
4538
|
+
if (document.querySelector('dialog[open]')) return;
|
|
4539
|
+
setTimeout(function() {
|
|
4540
|
+
if (escapeReachedWindow !== e || e.defaultPrevented) return;
|
|
4541
|
+
postToParent({ type: PREFIX + 'page-escape' });
|
|
4542
|
+
}, 0);
|
|
4543
|
+
}
|
|
4035
4544
|
document.addEventListener('keydown', function(e) {
|
|
4036
4545
|
if (e.key !== 'Escape' || vimEnabled) return;
|
|
4037
4546
|
if (!annotateModeActive) {
|
|
4038
4547
|
// Interact mode: Esc belongs to the page — except an open drag-comment
|
|
4039
4548
|
// draft (drag-selection stays live in Interact) still closes first.
|
|
4040
4549
|
if (pendingSelection) closePendingDraft();
|
|
4550
|
+
else offerUnusedEscape(e);
|
|
4041
4551
|
return;
|
|
4042
4552
|
}
|
|
4043
4553
|
if (pendingSelection) {
|
|
@@ -4951,6 +5461,29 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
4951
5461
|
if (document.body) document.body.removeAttribute('data-plannotator-vim-focus-owner');
|
|
4952
5462
|
}
|
|
4953
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
|
+
|
|
4954
5487
|
function copyVimText(text) {
|
|
4955
5488
|
if (!text) return;
|
|
4956
5489
|
postToParent({
|