@plannotator/ui 0.52.1 → 0.53.1

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 (76) hide show
  1. package/HANDOFF.md +284 -29
  2. package/README.md +45 -5
  3. package/components/AnnotationPanel.tsx +195 -118
  4. package/components/AnnotationToolbar.tsx +17 -14
  5. package/components/AnnotationToolstrip.tsx +54 -40
  6. package/components/BlockRenderer.tsx +20 -14
  7. package/components/CodeFilePopout.tsx +132 -69
  8. package/components/CommentPopover.tsx +8 -349
  9. package/components/ComposerTokenTextarea.tsx +379 -0
  10. package/components/ExportModal.tsx +6 -1
  11. package/components/HostTitle.tsx +108 -0
  12. package/components/HtmlSurfaceControls.tsx +2 -1
  13. package/components/ImageLightbox.tsx +288 -17
  14. package/components/ImagePeek.tsx +255 -0
  15. package/components/ImageResolverContext.tsx +31 -0
  16. package/components/InlineMarkdown.tsx +123 -15
  17. package/components/LocalLinkContext.tsx +49 -0
  18. package/components/MentionPicker.tsx +51 -0
  19. package/components/MenuVersionSection.tsx +51 -25
  20. package/components/OpenInAppButton.tsx +18 -49
  21. package/components/PlanHeaderMenu.tsx +2 -8
  22. package/components/ProviderIcons.tsx +10 -4
  23. package/components/RenderedMarkdown.tsx +40 -4
  24. package/components/Settings.tsx +33 -5
  25. package/components/SkillReferenceMenu.tsx +67 -18
  26. package/components/Tooltip.tsx +51 -4
  27. package/components/Viewer.tsx +220 -49
  28. package/components/ai/AIProviderBar.tsx +11 -0
  29. package/components/ai/AskSessionSetupHint.tsx +12 -0
  30. package/components/ai/DocumentAIChatPanel.tsx +17 -5
  31. package/components/ai/SessionAskNotice.tsx +4 -3
  32. package/components/blocks/BlockquoteBlock.tsx +159 -0
  33. package/components/blocks/HtmlBlock.tsx +121 -9
  34. package/components/blocks/QuestionBlock.tsx +183 -96
  35. package/components/blocks/QuestionContext.tsx +3 -0
  36. package/components/diagram/DiagramViewer.tsx +41 -4
  37. package/components/diagram/index.ts +1 -1
  38. package/components/html-viewer/HtmlViewer.tsx +95 -3
  39. package/components/html-viewer/bridge-script.asset.js +413 -7
  40. package/components/html-viewer/bridge-script.lite.ts +1 -1
  41. package/components/html-viewer/bridge-script.ts +454 -7
  42. package/components/html-viewer/useHtmlAnnotation.ts +51 -3
  43. package/components/icons/AgentIcons.tsx +9 -7
  44. package/components/plan-diff/PlanCleanDiffView.tsx +239 -36
  45. package/components/plan-diff/PlanDiffViewer.tsx +6 -1
  46. package/configure.ts +27 -1
  47. package/hooks/useAgentJobs.ts +8 -3
  48. package/hooks/useAnnotationHighlighter.ts +128 -23
  49. package/hooks/useExternalAnnotations.ts +6 -0
  50. package/hooks/useMentionAutocomplete.ts +1 -1
  51. package/hooks/useOpenInApps.ts +88 -0
  52. package/hooks/useSkillReferenceAutocomplete.ts +7 -3
  53. package/hooks/useUpdateCheck.ts +38 -98
  54. package/hooks/useUpdateCheckSetting.ts +43 -0
  55. package/hooks/useUpdateToast.ts +101 -0
  56. package/hooks/useVimSelection.ts +9 -0
  57. package/package.json +2 -2
  58. package/shortcuts/code-review/markdownPreview.shortcuts.ts +18 -0
  59. package/shortcuts/index.ts +1 -0
  60. package/shortcuts/runtime.ts +10 -3
  61. package/styles.css +1 -1
  62. package/types.ts +48 -0
  63. package/utils/agentClosed.ts +26 -0
  64. package/utils/agentToolSetting.ts +16 -9
  65. package/utils/aiProvider.ts +7 -0
  66. package/utils/askSessionSetupHint.ts +27 -0
  67. package/utils/blockTargeting.ts +14 -3
  68. package/utils/mentions.ts +34 -5
  69. package/utils/parser.ts +65 -51
  70. package/utils/pasteInsert.ts +234 -0
  71. package/utils/planDiffEngine.ts +272 -2
  72. package/utils/skillCatalog.ts +15 -2
  73. package/utils/skillReferences.ts +50 -13
  74. package/utils/storage.ts +5 -0
  75. package/utils/textCut.ts +41 -0
  76. package/utils/updateNotice.ts +120 -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.
@@ -461,6 +502,8 @@ export const BRIDGE_SCRIPT = `(function() {
461
502
  // marker buttons keep their own clicks. BOTH surface kinds start ARMED —
462
503
  // Esc (or the header pen) drops to Interact.
463
504
  var annotateModeActive = true;
505
+ // A native host decides link navigations itself (HtmlViewer hostNavigates).
506
+ var hostNavigates = false;
464
507
  function updatePinpointCursor() {
465
508
  if (!document.body) return;
466
509
  if (annotateModeActive && currentInputMethod === 'pinpoint') {
@@ -766,6 +809,21 @@ export const BRIDGE_SCRIPT = `(function() {
766
809
  flashMultiTarget(typeof e.data.key === 'string' ? e.data.key : '');
767
810
  }
768
811
 
812
+ else if (type === PREFIX + 'step-pin') {
813
+ // A host's Parent or Child (a phone's comment sheet): move the pinned
814
+ // draft to the element around it, or back in. Re-pins through
815
+ // annotateElement, so the parent sees an ordinary pinpoint selection.
816
+ stepPin(e.data.direction === 'child' ? 'child' : 'parent');
817
+ }
818
+
819
+ else if (type === PREFIX + 'reveal-pin') {
820
+ // A host whose own panel covers the page's lower part (a phone's comment
821
+ // sheet) shrank the frame: bring the pinned draft back into view.
822
+ if (pendingPinEl && pendingPinEl.isConnected) {
823
+ try { pendingPinEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (ex) {}
824
+ }
825
+ }
826
+
769
827
  else if (type === PREFIX + 'scroll-to') {
770
828
  // Selecting an annotation scrolls its first resolved target into view
771
829
  // and flashes the overlay focus highlight over EVERY rect of EVERY
@@ -809,6 +867,10 @@ export const BRIDGE_SCRIPT = `(function() {
809
867
  if (vimEnabled) updateVimUi();
810
868
  }
811
869
 
870
+ else if (type === PREFIX + 'set-host-navigates') {
871
+ hostNavigates = e.data.active === true;
872
+ }
873
+
812
874
  else if (type === PREFIX + 'set-annotate-mode') {
813
875
  var nextAnnotateActive = e.data.active === true;
814
876
  if (nextAnnotateActive !== annotateModeActive) {
@@ -1336,6 +1398,14 @@ export const BRIDGE_SCRIPT = `(function() {
1336
1398
  // changes); shared by mousemove and the scroll/resize reconcile pass.
1337
1399
  // Per-event hit-testing only — never builds the semantic graph.
1338
1400
  function updatePinpointHover(x, y, fallbackNode) {
1401
+ // A link a plain click follows shows no pin outline unless Cmd/Ctrl is
1402
+ // held: the outline always matches what a click there will do.
1403
+ if (!pinModifierHeld && !pendingPinEl && (followableLinkAt(fallbackNode) || followableLinkAt(deepElementFromPoint(x, y)))) {
1404
+ pinpointHover = null;
1405
+ hidePinpointBox();
1406
+ hidePinpointLabel();
1407
+ return;
1408
+ }
1339
1409
  var el = pinpointLeafAt(x, y, fallbackNode);
1340
1410
  // The 25px marker bubble owns clicks over it (they select its comment),
1341
1411
  // so hover must advertise the MARKER's own identity — no annotate box,
@@ -1366,9 +1436,36 @@ export const BRIDGE_SCRIPT = `(function() {
1366
1436
  positionPinpointLabel(el, pinpointHoverLabel(el));
1367
1437
  }
1368
1438
 
1439
+ // Cmd or Ctrl held (the pin modifier): a link under the pointer shows the
1440
+ // pin outline and the crosshair instead of the follow pointer. Read from
1441
+ // the key itself (as the token-hover gate does) and from every pointer
1442
+ // move, so a key pressed with focus outside this frame still counts.
1443
+ var pinModifierHeld = false;
1444
+ function setPinModifier(held) {
1445
+ if (held === pinModifierHeld) return;
1446
+ pinModifierHeld = held;
1447
+ if (document.body) {
1448
+ if (held) document.body.setAttribute('data-plannotator-pin-modifier', '');
1449
+ else document.body.removeAttribute('data-plannotator-pin-modifier');
1450
+ }
1451
+ if (annotateModeActive && currentInputMethod === 'pinpoint' && lastPointer && !(vimEnabled && vimPhase !== 'inactive')) {
1452
+ updatePinpointHover(lastPointer.x, lastPointer.y, lastPointerNode || pinpointHover);
1453
+ }
1454
+ }
1455
+ document.addEventListener('keydown', function(e) {
1456
+ if (e.key === 'Meta' || e.key === 'Control') setPinModifier(true);
1457
+ }, true);
1458
+ document.addEventListener('keyup', function(e) {
1459
+ if (e.key === 'Meta' || e.key === 'Control') setPinModifier(e.metaKey || e.ctrlKey);
1460
+ }, true);
1461
+ window.addEventListener('blur', function() { setPinModifier(false); });
1462
+
1369
1463
  var lastPointer = null;
1464
+ var lastPointerNode = null;
1370
1465
  document.addEventListener('mousemove', function(e) {
1371
1466
  lastPointer = { x: e.clientX, y: e.clientY };
1467
+ lastPointerNode = e.target && e.target.nodeType === 1 ? e.target : null;
1468
+ if (!!(e.metaKey || e.ctrlKey) !== pinModifierHeld) setPinModifier(!!(e.metaKey || e.ctrlKey));
1372
1469
  updateDragYield(e);
1373
1470
  if (!annotateModeActive) return; // Interact mode: no hover affordances
1374
1471
  if (currentInputMethod !== 'pinpoint') {
@@ -3760,6 +3857,41 @@ export const BRIDGE_SCRIPT = `(function() {
3760
3857
  });
3761
3858
  }
3762
3859
 
3860
+ // Parent and Child for a pinned draft. The trail holds the elements stepped
3861
+ // out of, so Child goes back the way Parent came; past it, Child takes the
3862
+ // first element child. A new pin by click starts a new trail.
3863
+ var pinStepTrail = [];
3864
+ var steppingPin = false;
3865
+ function stepPin(direction) {
3866
+ if (!pendingPinEl || !pendingPinEl.isConnected) return;
3867
+ var from = pendingPinEl;
3868
+ var next = null;
3869
+ if (direction === 'parent') {
3870
+ var up = from.parentElement;
3871
+ if (up && up !== document.body && up !== document.documentElement) next = up;
3872
+ } else {
3873
+ var back = pinStepTrail[pinStepTrail.length - 1];
3874
+ if (back && back.isConnected && from.contains(back)) next = back;
3875
+ else {
3876
+ for (var child = from.firstElementChild; child; child = child.nextElementSibling) {
3877
+ if (isViewerOverlayNode(child)) continue;
3878
+ if (/^(SCRIPT|STYLE|TEMPLATE|NOSCRIPT)$/.test(child.tagName)) continue;
3879
+ next = child;
3880
+ break;
3881
+ }
3882
+ }
3883
+ }
3884
+ if (!next) return;
3885
+ var trail = pinStepTrail.slice();
3886
+ if (direction === 'parent') trail.push(from);
3887
+ else if (trail[trail.length - 1] === next) trail.pop();
3888
+ else trail = [];
3889
+ steppingPin = true;
3890
+ try { annotateElement(next, undefined, pendingPinViaPinpoint, null); }
3891
+ finally { steppingPin = false; }
3892
+ pinStepTrail = trail;
3893
+ }
3894
+
3763
3895
  // Pin an element: select its text if possible (so a <mark> can wrap it), else
3764
3896
  // post its text + box directly so the toolbar still anchors (e.g. an SVG node,
3765
3897
  // whose <text> doesn't select like HTML text). Either way the element stays
@@ -3767,6 +3899,7 @@ export const BRIDGE_SCRIPT = `(function() {
3767
3899
  // rides along so the annotation can restore to this exact element later.
3768
3900
  function annotateElement(el, modeOverride, viaPinpoint, clickPoint) {
3769
3901
  if (!el) return false;
3902
+ if (!steppingPin) pinStepTrail = [];
3770
3903
  pinpointHover = null;
3771
3904
  hidePinpointLabel();
3772
3905
  clearMultiTargets(); // a new primary starts a fresh draft
@@ -3842,6 +3975,230 @@ export const BRIDGE_SCRIPT = `(function() {
3842
3975
  return true;
3843
3976
  }
3844
3977
 
3978
+ // --- Touch: a tap on a link follows it, press-and-hold pins it ---
3979
+ // The owner's amendment to html-link-click (2026-10-20): on a phone there is
3980
+ // no Cmd/Ctrl, so while armed a TAP on a link follows it (the click handlers
3981
+ // below take it as a plain click) and a PRESS-AND-HOLD of HOLD_PIN_MS opens
3982
+ // a comment on it, pinned exactly as a Cmd-click pins it. Every surface
3983
+ // alike: srcdoc pages (annotate, plan review's HTML, the Inbox) and live apps.
3984
+ // A finger that moves past HOLD_SLOP_PX (a scroll), a second finger, or a
3985
+ // scroll cancels the hold. The release of a hold that pinned never follows
3986
+ // the link and never reaches the page: its pointerup and touchend are
3987
+ // stopped at the window (registered before a live app's own listeners),
3988
+ // touchend is cancelled so no click is synthesized, and a click that still
3989
+ // arrives is swallowed once. That swallow ends at the next touch, mouse
3990
+ // press or key, so it never eats an unrelated click.
3991
+ // While a hold is in progress, link text is not selectable (the CSS above),
3992
+ // so the platform's long-press starts no native selection under the finger;
3993
+ // Android's contextmenu, its long-press signal, fires the hold at once.
3994
+ var HOLD_PIN_MS = 500;
3995
+ var HOLD_SLOP_PX = 10;
3996
+ var HOLD_SWALLOW_MAX_MS = 10000; // a release never seen (an app stopped touchend)
3997
+ var linkHold = null; // { timer, x, y, id, target }
3998
+ // After a hold pinned: { pointerId, released, until } until its release's
3999
+ // click (if any) is swallowed or the next gesture starts.
4000
+ var heldGesture = null;
4001
+ var lastTouchPointerId = null;
4002
+ function holdArmed() {
4003
+ return annotateModeActive && currentInputMethod === 'pinpoint' && !(vimEnabled && vimPhase !== 'inactive');
4004
+ }
4005
+ function setLinkHoldStyle(on) {
4006
+ if (!document.body) return;
4007
+ if (on) document.body.setAttribute('data-plannotator-link-hold', '');
4008
+ else document.body.removeAttribute('data-plannotator-link-hold');
4009
+ }
4010
+ function cancelLinkHold() {
4011
+ if (!linkHold) return;
4012
+ clearTimeout(linkHold.timer);
4013
+ linkHold = null;
4014
+ setLinkHoldStyle(false);
4015
+ }
4016
+ function fireLinkHold() {
4017
+ var hold = linkHold;
4018
+ if (!hold) return false;
4019
+ clearTimeout(hold.timer);
4020
+ linkHold = null;
4021
+ if (!holdArmed() || !hold.target.isConnected) {
4022
+ setLinkHoldStyle(false);
4023
+ return false;
4024
+ }
4025
+ var el = resolvePinpointTargetAt(hold.x, hold.y, hold.target);
4026
+ if (!el) {
4027
+ setLinkHoldStyle(false);
4028
+ return false;
4029
+ }
4030
+ 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 };
4031
+ // The same pin a Cmd-click makes: the link's own words as a text range.
4032
+ // Selection is back on for that, then off again until the finger lifts.
4033
+ setLinkHoldStyle(false);
4034
+ try { window.getSelection().removeAllRanges(); } catch (ex) {}
4035
+ annotateElement(el, undefined, true, { x: hold.x, y: hold.y });
4036
+ setLinkHoldStyle(true);
4037
+ return true;
4038
+ }
4039
+ function endHeldGesture() {
4040
+ heldGesture = null;
4041
+ }
4042
+ function touchById(list, id) {
4043
+ for (var i = 0; list && i < list.length; i++) if (list[i].identifier === id) return list[i];
4044
+ return null;
4045
+ }
4046
+ // The release of a hold is stopped, so the page never sees it end: a press
4047
+ // library (React Aria's usePress, Motion's whileTap, dnd-kit) would keep the
4048
+ // press open. In its place the page gets a pointercancel and a touchcancel
4049
+ // on the element the finger went down on, which end a press without
4050
+ // activating it. The bridge's own listeners ignore those two.
4051
+ var bridgeSynthetic = typeof WeakSet === 'function' ? new WeakSet() : null;
4052
+ function isBridgeSynthetic(e) {
4053
+ return !!(bridgeSynthetic && bridgeSynthetic.has(e));
4054
+ }
4055
+ function dispatchSynthetic(target, event) {
4056
+ if (bridgeSynthetic) bridgeSynthetic.add(event);
4057
+ try { target.dispatchEvent(event); } catch (ex) {}
4058
+ }
4059
+ function sendPointerCancel(g, pointerId) {
4060
+ if (g.pointerEnded || pointerId === null || pointerId === undefined) return;
4061
+ g.pointerEnded = true;
4062
+ var target = g.target && g.target.isConnected ? g.target : document.body;
4063
+ if (!target) return;
4064
+ var init = { bubbles: true, cancelable: false, composed: true, pointerId: pointerId, pointerType: 'touch', isPrimary: true, clientX: g.x, clientY: g.y };
4065
+ var event = null;
4066
+ try { if (typeof PointerEvent === 'function') event = new PointerEvent('pointercancel', init); } catch (ex) {}
4067
+ if (!event) {
4068
+ event = new Event('pointercancel', { bubbles: true, cancelable: false, composed: true });
4069
+ try {
4070
+ Object.defineProperty(event, 'pointerId', { value: pointerId });
4071
+ Object.defineProperty(event, 'pointerType', { value: 'touch' });
4072
+ Object.defineProperty(event, 'isPrimary', { value: true });
4073
+ } catch (ex) {}
4074
+ }
4075
+ dispatchSynthetic(target, event);
4076
+ }
4077
+ function sendTouchCancel(g) {
4078
+ if (g.touchEnded) return;
4079
+ g.touchEnded = true;
4080
+ var target = g.target && g.target.isConnected ? g.target : document.body;
4081
+ if (!target) return;
4082
+ var event = null;
4083
+ try {
4084
+ if (typeof TouchEvent === 'function' && typeof Touch === 'function') {
4085
+ var t = new Touch({ identifier: g.touchId, target: target, clientX: g.x, clientY: g.y });
4086
+ event = new TouchEvent('touchcancel', { bubbles: true, cancelable: false, composed: true, touches: [], targetTouches: [], changedTouches: [t] });
4087
+ }
4088
+ } catch (ex) { event = null; }
4089
+ if (!event) {
4090
+ event = new Event('touchcancel', { bubbles: true, cancelable: false, composed: true });
4091
+ var point = { identifier: g.touchId, target: target, clientX: g.x, clientY: g.y };
4092
+ try {
4093
+ Object.defineProperty(event, 'touches', { value: [] });
4094
+ Object.defineProperty(event, 'targetTouches', { value: [] });
4095
+ Object.defineProperty(event, 'changedTouches', { value: [point] });
4096
+ } catch (ex) {}
4097
+ }
4098
+ dispatchSynthetic(target, event);
4099
+ }
4100
+ // Pointer events come before touch events; the touch's pointerId names the
4101
+ // release's pointerup.
4102
+ window.addEventListener('pointerdown', function(e) {
4103
+ if (isBridgeSynthetic(e)) return;
4104
+ if (e.pointerType === 'touch') {
4105
+ lastTouchPointerId = e.pointerId;
4106
+ if (heldGesture && heldGesture.released) endHeldGesture();
4107
+ return;
4108
+ }
4109
+ // A mouse or pen press: whatever click comes next is its own.
4110
+ endHeldGesture();
4111
+ }, true);
4112
+ window.addEventListener('keydown', function() { if (heldGesture && heldGesture.released) endHeldGesture(); }, true);
4113
+ function heldPointer(e) {
4114
+ if (!heldGesture || e.pointerType !== 'touch') return false;
4115
+ return heldGesture.pointerId === null || e.pointerId === heldGesture.pointerId;
4116
+ }
4117
+ window.addEventListener('pointerup', function(e) {
4118
+ if (isBridgeSynthetic(e) || !heldPointer(e) || heldGesture.pointerUpSeen) return;
4119
+ heldGesture.pointerUpSeen = true;
4120
+ e.stopImmediatePropagation();
4121
+ sendPointerCancel(heldGesture, e.pointerId);
4122
+ }, true);
4123
+ // The browser ended the held pointer itself (it took the touch): nothing to stand in for.
4124
+ window.addEventListener('pointercancel', function(e) {
4125
+ if (isBridgeSynthetic(e) || !heldPointer(e)) return;
4126
+ heldGesture.pointerUpSeen = true;
4127
+ heldGesture.pointerEnded = true;
4128
+ }, true);
4129
+ window.addEventListener('touchend', function(e) {
4130
+ if (isBridgeSynthetic(e)) return;
4131
+ if (heldGesture && !heldGesture.released) {
4132
+ // The release of the hold that pinned: no click, no navigation, and
4133
+ // the page's own touchend never runs on it; it gets cancels instead.
4134
+ if (e.cancelable) e.preventDefault();
4135
+ e.stopImmediatePropagation();
4136
+ var g = heldGesture;
4137
+ g.released = true;
4138
+ g.until = Date.now() + 800;
4139
+ setLinkHoldStyle(false);
4140
+ // Engines that send touchend before pointerup: the pointer ends here
4141
+ // (its own pointerup, when it comes, is still stopped).
4142
+ sendPointerCancel(g, g.pointerId);
4143
+ sendTouchCancel(g);
4144
+ return;
4145
+ }
4146
+ cancelLinkHold(); // a tap: the click that follows it follows the link
4147
+ }, { capture: true, passive: false });
4148
+ window.addEventListener('touchcancel', function(e) {
4149
+ if (isBridgeSynthetic(e)) return;
4150
+ cancelLinkHold();
4151
+ setLinkHoldStyle(false);
4152
+ if (heldGesture) {
4153
+ heldGesture.released = true;
4154
+ heldGesture.touchEnded = true;
4155
+ heldGesture.until = Date.now() + 800;
4156
+ }
4157
+ }, true);
4158
+ document.addEventListener('touchstart', function(e) {
4159
+ if (document.body && !document.body.hasAttribute('data-plannotator-touch')) {
4160
+ document.body.setAttribute('data-plannotator-touch', '');
4161
+ }
4162
+ cancelLinkHold();
4163
+ // A new touch: whatever click comes next is its own, even if the last
4164
+ // hold's release was never seen.
4165
+ endHeldGesture();
4166
+ setLinkHoldStyle(false);
4167
+ if (!holdArmed() || !e.touches || e.touches.length !== 1) return;
4168
+ if (isViewerOverlayNode(e.target) || !followableLinkAt(e.target)) return;
4169
+ var t = e.touches[0];
4170
+ linkHold = { x: t.clientX, y: t.clientY, id: t.identifier, target: e.target, timer: 0 };
4171
+ linkHold.timer = setTimeout(fireLinkHold, HOLD_PIN_MS);
4172
+ setLinkHoldStyle(true);
4173
+ }, { capture: true, passive: true });
4174
+ document.addEventListener('touchmove', function(e) {
4175
+ if (!linkHold) return;
4176
+ var t = touchById(e.touches, linkHold.id);
4177
+ if (!t || e.touches.length !== 1 || Math.abs(t.clientX - linkHold.x) > HOLD_SLOP_PX || Math.abs(t.clientY - linkHold.y) > HOLD_SLOP_PX) {
4178
+ cancelLinkHold();
4179
+ }
4180
+ }, { capture: true, passive: true });
4181
+ window.addEventListener('scroll', cancelLinkHold, true);
4182
+ document.addEventListener('contextmenu', function(e) {
4183
+ if (linkHold) {
4184
+ e.preventDefault();
4185
+ fireLinkHold();
4186
+ return;
4187
+ }
4188
+ if (heldGesture && Date.now() < heldGesture.until) e.preventDefault();
4189
+ }, true);
4190
+ // On the window, before every other click handler of the bridge and a live
4191
+ // app's own, so the release of a hold reaches neither the link handler nor
4192
+ // the pinpoint one, nor the page (a live app would navigate).
4193
+ window.addEventListener('click', function(e) {
4194
+ if (!heldGesture) return;
4195
+ var swallow = Date.now() < heldGesture.until;
4196
+ endHeldGesture();
4197
+ if (!swallow) return;
4198
+ e.preventDefault();
4199
+ e.stopImmediatePropagation();
4200
+ }, true);
4201
+
3845
4202
  // --- Local-site link navigation (srcdoc sessions only) ---
3846
4203
  // A srcdoc document has no URL of its own: its base URL is the PARENT page's,
3847
4204
  // which is the Plannotator server. So a plain link to 02-detail.html resolves
@@ -3853,8 +4210,14 @@ export const BRIDGE_SCRIPT = `(function() {
3853
4210
  // The frame therefore never navigates itself. In-page fragments scroll here;
3854
4211
  // everything else is handed to the parent, which owns resolution against the
3855
4212
  // current document's directory and is the trust boundary for the href.
3856
- // Registered BEFORE the pinpoint handler and never stopping propagation, so
3857
- // an armed click still pins the link element exactly as it always did.
4213
+ // Registered BEFORE the pinpoint handler and never stopping propagation.
4214
+ //
4215
+ // One rule while armed (the owner's ruling, html-link-click, 2026-10-20):
4216
+ // a plain click on a link FOLLOWS it, exactly as in Interact; a click with
4217
+ // the platform modifier (Cmd on macOS, Ctrl elsewhere: either is taken)
4218
+ // PINS it for a comment through the pinpoint handler below, as does a
4219
+ // shift-click that adds it to an open multi-select draft. A drag that ended
4220
+ // on the link owns its click: it neither follows nor pins.
3858
4221
  //
3859
4222
  // Live app sessions are excluded outright: they navigate a real origin
3860
4223
  // through the proxy, which is the whole point of that surface.
@@ -3877,6 +4240,25 @@ export const BRIDGE_SCRIPT = `(function() {
3877
4240
  return true;
3878
4241
  }
3879
4242
 
4243
+ // While armed, whether a click on a link pins it instead of following it:
4244
+ // the platform modifier (Cmd or Ctrl), a shift-click adding to an open
4245
+ // multi-select draft, or the trailing click of a drag that selected text.
4246
+ function armedClickPins(e) {
4247
+ if (e.metaKey || e.ctrlKey || dragEndedClick) return true;
4248
+ return !!(e.shiftKey && pendingPinEl && multiSelectArmed && pendingSelection);
4249
+ }
4250
+
4251
+ // A link with a real destination: one a plain click follows while armed.
4252
+ // No href, "", "#" and javascript: go nowhere, so a click on them pins
4253
+ // the element as on any other (agents' mocks use <a href="#"> for buttons).
4254
+ function isFollowableHref(raw) {
4255
+ return !!raw && raw !== '#' && !/^javascript:/i.test(raw);
4256
+ }
4257
+
4258
+ function followableLinkAt(node) {
4259
+ return !!node && isFollowableHref(navigableLinkHref(node));
4260
+ }
4261
+
3880
4262
  function navigableLinkHref(node) {
3881
4263
  var el = node && node.nodeType === 1 ? node : node && node.parentElement;
3882
4264
  if (!el || !el.closest) return '';
@@ -3896,16 +4278,30 @@ export const BRIDGE_SCRIPT = `(function() {
3896
4278
  if (!raw) return;
3897
4279
  // The page's own scripting, not a navigation: leave it alone.
3898
4280
  if (/^javascript:/i.test(raw)) return;
4281
+ // Armed and pinning (a link that goes nowhere, the modifier, or a
4282
+ // drag's trailing click): the frame never navigates, and the pinpoint
4283
+ // handler below takes the click.
4284
+ if (annotateModeActive && currentInputMethod === 'pinpoint' && (!isFollowableHref(raw) || armedClickPins(e))) {
4285
+ e.preventDefault();
4286
+ return;
4287
+ }
3899
4288
  if (raw.charAt(0) === '#') {
3900
4289
  e.preventDefault();
3901
4290
  scrollToLocalFragment(raw.slice(1));
3902
4291
  return;
3903
4292
  }
3904
4293
  e.preventDefault();
3905
- // Armed pinpoint: the click belongs to annotation, and the capture-phase
3906
- // pinpoint handler below is about to pin this element. Navigation is
3907
- // already suppressed above, which is all this surface owes the click.
3908
- if (annotateModeActive && currentInputMethod === 'pinpoint') return;
4294
+ // Only a person's click follows a link: a page's own script calling
4295
+ // a.click() never navigates the viewer or pushes the host's history.
4296
+ if (!e.isTrusted) return;
4297
+ // A native host decides links itself: a person's tap opens the link as a
4298
+ // new window, which the host's web view hands to its own delegate (the
4299
+ // frame never navigates; the host page's CSP would refuse it anyway).
4300
+ if (hostNavigates) {
4301
+ var target = (e.target && e.target.closest) ? e.target.closest('a,area') : null;
4302
+ try { window.open(target && target.href ? target.href : raw, '_blank', 'noopener'); } catch (ex) {}
4303
+ return;
4304
+ }
3909
4305
  postToParent({ type: PREFIX + 'link-click', href: raw.slice(0, 2048) });
3910
4306
  }, true);
3911
4307
  }
@@ -3924,7 +4320,12 @@ export const BRIDGE_SCRIPT = `(function() {
3924
4320
  // and mousedown clears a prior pin's leftover selection, so stale
3925
4321
  // selection state never blocks the next plain re-pin click. One-shot:
3926
4322
  // only the drag's own trailing click is suppressed.
3927
- if (dragEndedClick) { dragEndedClick = false; return; }
4323
+ if (dragEndedClick) {
4324
+ dragEndedClick = false;
4325
+ // Never a navigation either (a live app's link would otherwise follow).
4326
+ if (navigableLinkHref(e.target)) e.preventDefault();
4327
+ return;
4328
+ }
3928
4329
  // Shift-click while an ARMED pinpoint draft is open: toggle the element
3929
4330
  // in/out of the SAME draft comment instead of replacing the selection.
3930
4331
  // Unarmed drafts (modes the parent does not mirror, e.g. quickLabel)
@@ -3938,6 +4339,9 @@ export const BRIDGE_SCRIPT = `(function() {
3938
4339
  if (multiEl) toggleMultiTarget(multiEl, { x: e.clientX, y: e.clientY });
3939
4340
  return;
3940
4341
  }
4342
+ // A plain click on a link follows it (the link handler above posted it;
4343
+ // in a live app the browser navigates for real). The modifier pins it.
4344
+ if (isFollowableHref(navigableLinkHref(e.target)) && !armedClickPins(e)) return;
3941
4345
  // Click what the hover box shows: the currently hovered element is the
3942
4346
  // target the user aimed at. Fall back to a fresh hit-test at the click
3943
4347
  // point (e.g. a click with no preceding mousemove).
@@ -3965,12 +4369,36 @@ export const BRIDGE_SCRIPT = `(function() {
3965
4369
  window.getSelection().removeAllRanges();
3966
4370
  renderAnnotationOverlay();
3967
4371
  }
4372
+ // An Escape in Interact that the page did not use (no handler cancelled it
4373
+ // or stopped it on its way up, focus not in one of its text fields) is
4374
+ // offered to the host as page-escape, after every handler had its turn: the
4375
+ // Inbox leaves full screen with it. The page still got the key first.
4376
+ var escapeReachedWindow = null;
4377
+ window.addEventListener('keydown', function(e) {
4378
+ if (e.key === 'Escape') escapeReachedWindow = e;
4379
+ });
4380
+ function offerUnusedEscape(e) {
4381
+ if (e.repeat || e.isComposing || e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
4382
+ var t = e.target;
4383
+ if (t && t.nodeType === 1) {
4384
+ var tag = t.tagName;
4385
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return;
4386
+ if (t.closest && t.closest('[contenteditable]:not([contenteditable="false"])')) return;
4387
+ }
4388
+ // The page's own dialog closes on this Escape (the browser's default, not a handler).
4389
+ if (document.querySelector('dialog[open]')) return;
4390
+ setTimeout(function() {
4391
+ if (escapeReachedWindow !== e || e.defaultPrevented) return;
4392
+ postToParent({ type: PREFIX + 'page-escape' });
4393
+ }, 0);
4394
+ }
3968
4395
  document.addEventListener('keydown', function(e) {
3969
4396
  if (e.key !== 'Escape' || vimEnabled) return;
3970
4397
  if (!annotateModeActive) {
3971
4398
  // Interact mode: Esc belongs to the page — except an open drag-comment
3972
4399
  // draft (drag-selection stays live in Interact) still closes first.
3973
4400
  if (pendingSelection) closePendingDraft();
4401
+ else offerUnusedEscape(e);
3974
4402
  return;
3975
4403
  }
3976
4404
  if (pendingSelection) {
@@ -4002,6 +4430,25 @@ export const BRIDGE_SCRIPT = `(function() {
4002
4430
  postToParent({ type: PREFIX + message });
4003
4431
  }, true);
4004
4432
 
4433
+ // A plain ] or [ while armed (Annotate): the host may step to its next or
4434
+ // previous file (the Inbox steps through a thread's attachments). Never in
4435
+ // Interact (the page owns its keys), never while the page's focus is in a
4436
+ // text field, with a modifier, on auto-repeat or mid-composition. The page
4437
+ // still receives the key; a host without a step handler ignores the
4438
+ // message. Capture phase, like the chords above.
4439
+ document.addEventListener('keydown', function(e) {
4440
+ if (e.key !== ']' && e.key !== '[') return;
4441
+ if (!annotateModeActive || vimEnabled) return;
4442
+ if (e.metaKey || e.ctrlKey || e.altKey || e.repeat || e.isComposing) return;
4443
+ var t = e.target;
4444
+ if (t && t.nodeType === 1) {
4445
+ var tag = t.tagName;
4446
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return;
4447
+ if (t.closest && t.closest('[contenteditable]:not([contenteditable="false"])')) return;
4448
+ }
4449
+ postToParent({ type: PREFIX + 'step', dir: e.key === ']' ? 1 : -1 });
4450
+ }, true);
4451
+
4005
4452
  // Author opt-in: a plain click on any element tagged [data-annotate] pops the
4006
4453
  // toolbar — no pinpoint mode. Lets an HTML doc (e.g. a flow graph) wire its own
4007
4454
  // nodes to Plannotator's toolbar. Bubble phase so the page's own click handlers