@plannotator/ui 0.53.0 → 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.
- package/HANDOFF.md +68 -17
- package/README.md +20 -5
- package/components/AnnotationPanel.tsx +37 -12
- package/components/AnnotationToolstrip.tsx +45 -40
- package/components/CodeFilePopout.tsx +1 -1
- package/components/CommentPopover.tsx +2 -0
- package/components/HtmlSurfaceControls.tsx +2 -1
- package/components/MentionPicker.tsx +51 -0
- package/components/SkillReferenceMenu.tsx +39 -12
- package/components/Viewer.tsx +36 -6
- package/components/blocks/QuestionContext.tsx +3 -0
- package/components/html-viewer/HtmlViewer.tsx +22 -0
- package/components/html-viewer/bridge-script.asset.js +331 -11
- package/components/html-viewer/bridge-script.lite.ts +1 -1
- package/components/html-viewer/bridge-script.ts +372 -11
- package/hooks/useMentionAutocomplete.ts +1 -1
- package/hooks/useSkillReferenceAutocomplete.ts +7 -3
- package/hooks/useVimSelection.ts +9 -0
- package/package.json +1 -1
- package/shortcuts/runtime.ts +10 -3
- package/utils/mentions.ts +34 -5
|
@@ -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.
|
|
@@ -1357,6 +1398,14 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
1357
1398
|
// changes); shared by mousemove and the scroll/resize reconcile pass.
|
|
1358
1399
|
// Per-event hit-testing only — never builds the semantic graph.
|
|
1359
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
|
+
}
|
|
1360
1409
|
var el = pinpointLeafAt(x, y, fallbackNode);
|
|
1361
1410
|
// The 25px marker bubble owns clicks over it (they select its comment),
|
|
1362
1411
|
// so hover must advertise the MARKER's own identity — no annotate box,
|
|
@@ -1387,9 +1436,36 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
1387
1436
|
positionPinpointLabel(el, pinpointHoverLabel(el));
|
|
1388
1437
|
}
|
|
1389
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
|
+
|
|
1390
1463
|
var lastPointer = null;
|
|
1464
|
+
var lastPointerNode = null;
|
|
1391
1465
|
document.addEventListener('mousemove', function(e) {
|
|
1392
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));
|
|
1393
1469
|
updateDragYield(e);
|
|
1394
1470
|
if (!annotateModeActive) return; // Interact mode: no hover affordances
|
|
1395
1471
|
if (currentInputMethod !== 'pinpoint') {
|
|
@@ -3899,6 +3975,230 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3899
3975
|
return true;
|
|
3900
3976
|
}
|
|
3901
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
|
+
|
|
3902
4202
|
// --- Local-site link navigation (srcdoc sessions only) ---
|
|
3903
4203
|
// A srcdoc document has no URL of its own: its base URL is the PARENT page's,
|
|
3904
4204
|
// which is the Plannotator server. So a plain link to 02-detail.html resolves
|
|
@@ -3910,8 +4210,14 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3910
4210
|
// The frame therefore never navigates itself. In-page fragments scroll here;
|
|
3911
4211
|
// everything else is handed to the parent, which owns resolution against the
|
|
3912
4212
|
// current document's directory and is the trust boundary for the href.
|
|
3913
|
-
// Registered BEFORE the pinpoint handler and never stopping propagation
|
|
3914
|
-
//
|
|
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.
|
|
3915
4221
|
//
|
|
3916
4222
|
// Live app sessions are excluded outright: they navigate a real origin
|
|
3917
4223
|
// through the proxy, which is the whole point of that surface.
|
|
@@ -3934,6 +4240,25 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3934
4240
|
return true;
|
|
3935
4241
|
}
|
|
3936
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
|
+
|
|
3937
4262
|
function navigableLinkHref(node) {
|
|
3938
4263
|
var el = node && node.nodeType === 1 ? node : node && node.parentElement;
|
|
3939
4264
|
if (!el || !el.closest) return '';
|
|
@@ -3953,26 +4278,30 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3953
4278
|
if (!raw) return;
|
|
3954
4279
|
// The page's own scripting, not a navigation: leave it alone.
|
|
3955
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
|
+
}
|
|
3956
4288
|
if (raw.charAt(0) === '#') {
|
|
3957
4289
|
e.preventDefault();
|
|
3958
4290
|
scrollToLocalFragment(raw.slice(1));
|
|
3959
4291
|
return;
|
|
3960
4292
|
}
|
|
3961
4293
|
e.preventDefault();
|
|
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;
|
|
3962
4297
|
// A native host decides links itself: a person's tap opens the link as a
|
|
3963
4298
|
// new window, which the host's web view hands to its own delegate (the
|
|
3964
4299
|
// frame never navigates; the host page's CSP would refuse it anyway).
|
|
3965
4300
|
if (hostNavigates) {
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
try { window.open(target && target.href ? target.href : raw, '_blank', 'noopener'); } catch (ex) {}
|
|
3969
|
-
}
|
|
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) {}
|
|
3970
4303
|
return;
|
|
3971
4304
|
}
|
|
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
4305
|
postToParent({ type: PREFIX + 'link-click', href: raw.slice(0, 2048) });
|
|
3977
4306
|
}, true);
|
|
3978
4307
|
}
|
|
@@ -3991,7 +4320,12 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3991
4320
|
// and mousedown clears a prior pin's leftover selection, so stale
|
|
3992
4321
|
// selection state never blocks the next plain re-pin click. One-shot:
|
|
3993
4322
|
// only the drag's own trailing click is suppressed.
|
|
3994
|
-
if (dragEndedClick) {
|
|
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
|
+
}
|
|
3995
4329
|
// Shift-click while an ARMED pinpoint draft is open: toggle the element
|
|
3996
4330
|
// in/out of the SAME draft comment instead of replacing the selection.
|
|
3997
4331
|
// Unarmed drafts (modes the parent does not mirror, e.g. quickLabel)
|
|
@@ -4005,6 +4339,9 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
4005
4339
|
if (multiEl) toggleMultiTarget(multiEl, { x: e.clientX, y: e.clientY });
|
|
4006
4340
|
return;
|
|
4007
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;
|
|
4008
4345
|
// Click what the hover box shows: the currently hovered element is the
|
|
4009
4346
|
// target the user aimed at. Fall back to a fresh hit-test at the click
|
|
4010
4347
|
// point (e.g. a click with no preceding mousemove).
|
|
@@ -4032,12 +4369,36 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
4032
4369
|
window.getSelection().removeAllRanges();
|
|
4033
4370
|
renderAnnotationOverlay();
|
|
4034
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
|
+
}
|
|
4035
4395
|
document.addEventListener('keydown', function(e) {
|
|
4036
4396
|
if (e.key !== 'Escape' || vimEnabled) return;
|
|
4037
4397
|
if (!annotateModeActive) {
|
|
4038
4398
|
// Interact mode: Esc belongs to the page — except an open drag-comment
|
|
4039
4399
|
// draft (drag-selection stays live in Interact) still closes first.
|
|
4040
4400
|
if (pendingSelection) closePendingDraft();
|
|
4401
|
+
else offerUnusedEscape(e);
|
|
4041
4402
|
return;
|
|
4042
4403
|
}
|
|
4043
4404
|
if (pendingSelection) {
|
|
@@ -115,7 +115,7 @@ export function useMentionAutocomplete(options: {
|
|
|
115
115
|
}, [mentionsKey, onMentionsChange]);
|
|
116
116
|
|
|
117
117
|
const items = useMemo(
|
|
118
|
-
() => (source && trigger ? mentionMatches(source.people, trigger, taggedIds) : []),
|
|
118
|
+
() => (source && trigger ? mentionMatches(source.people, trigger, taggedIds, source.kinds, source.matchDetail ?? true) : []),
|
|
119
119
|
[source, trigger, taggedIds],
|
|
120
120
|
);
|
|
121
121
|
|
|
@@ -59,14 +59,18 @@ export interface UseSkillReferenceAutocompleteResult {
|
|
|
59
59
|
* active row and dismisses the menu when the user has engaged with it (a row
|
|
60
60
|
* is active or a query was typed); on a bare-trigger menu with no engagement
|
|
61
61
|
* it passes through, so Escape still closes the composer in one press.
|
|
62
|
+
* `bareEscapeDismisses` (default off) makes Escape close any open menu first,
|
|
63
|
+
* for a composer where Escape costs more than a press (the Inbox's compose
|
|
64
|
+
* dialog asks whether to keep a draft).
|
|
62
65
|
*/
|
|
63
66
|
export function useSkillReferenceAutocomplete(options: {
|
|
64
67
|
text: string;
|
|
65
68
|
setText: (text: string) => void;
|
|
66
69
|
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
|
|
67
70
|
enabled: boolean;
|
|
71
|
+
bareEscapeDismisses?: boolean;
|
|
68
72
|
}): UseSkillReferenceAutocompleteResult {
|
|
69
|
-
const { text, setText, textareaRef, enabled } = options;
|
|
73
|
+
const { text, setText, textareaRef, enabled, bareEscapeDismisses = false } = options;
|
|
70
74
|
// Seed from the memory cache only when the surface opted in — a disabled
|
|
71
75
|
// composer must stay inert even after another surface warmed the catalog.
|
|
72
76
|
const [catalog, setCatalog] = useState<SkillCatalogEntry[]>(() =>
|
|
@@ -205,7 +209,7 @@ export function useSkillReferenceAutocomplete(options: {
|
|
|
205
209
|
// Only consume Escape when the user engaged with the menu (typed a
|
|
206
210
|
// query or activated a row). A bare-trigger menu the user ignored
|
|
207
211
|
// must not cost an extra Escape on the way to closing the composer.
|
|
208
|
-
if (boundedActive === null && trigger.query.length === 0) return false;
|
|
212
|
+
if (!bareEscapeDismisses && boundedActive === null && trigger.query.length === 0) return false;
|
|
209
213
|
e.preventDefault();
|
|
210
214
|
e.stopPropagation();
|
|
211
215
|
setActiveIndex(null);
|
|
@@ -215,7 +219,7 @@ export function useSkillReferenceAutocomplete(options: {
|
|
|
215
219
|
return false;
|
|
216
220
|
}
|
|
217
221
|
},
|
|
218
|
-
[boundedActive, items.length, open, select, trigger],
|
|
222
|
+
[bareEscapeDismisses, boundedActive, items.length, open, select, trigger],
|
|
219
223
|
);
|
|
220
224
|
|
|
221
225
|
const humanOnlyReferences = useMemo(
|
package/hooks/useVimSelection.ts
CHANGED
|
@@ -317,8 +317,16 @@ export function useVimSelection({
|
|
|
317
317
|
return next;
|
|
318
318
|
}, [containerRef, setState]);
|
|
319
319
|
|
|
320
|
+
// Whether vim was on since the last reset. The disabled branch below resets
|
|
321
|
+
// only when vim actually goes from on to off: on a mount where vim was never
|
|
322
|
+
// on there is nothing to reset, and `window.getSelection()` in
|
|
323
|
+
// `clearSelectionWithin` forces a layout on every document open.
|
|
324
|
+
const wasEnabledRef = useRef(enabled);
|
|
325
|
+
|
|
320
326
|
useEffect(() => {
|
|
321
327
|
if (!enabled) {
|
|
328
|
+
if (!wasEnabledRef.current) return;
|
|
329
|
+
wasEnabledRef.current = false;
|
|
322
330
|
setState(createInitialVimSelectionState());
|
|
323
331
|
setFocused(false);
|
|
324
332
|
setHelpOpen(false);
|
|
@@ -326,6 +334,7 @@ export function useVimSelection({
|
|
|
326
334
|
clearSelectionWithin(containerRef.current);
|
|
327
335
|
return;
|
|
328
336
|
}
|
|
337
|
+
wasEnabledRef.current = true;
|
|
329
338
|
if (!focused) return;
|
|
330
339
|
if (restoringFocusRef.current) {
|
|
331
340
|
restoringFocusRef.current = false;
|
package/package.json
CHANGED
package/shortcuts/runtime.ts
CHANGED
|
@@ -27,6 +27,12 @@ export interface UseShortcutScopeOptions<TScope extends ShortcutScopeDefinition<
|
|
|
27
27
|
handlers: ShortcutHandlers<TScope>;
|
|
28
28
|
target?: ShortcutEventTarget;
|
|
29
29
|
stopOnMatch?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Listen in the capture phase: the scope sees a key before the focused
|
|
32
|
+
* element does (a guard that must run before a component's own handler).
|
|
33
|
+
* Default false (bubble), so a component that used the key first can say so.
|
|
34
|
+
*/
|
|
35
|
+
capture?: boolean;
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
interface ShortcutSequenceCandidate {
|
|
@@ -123,6 +129,7 @@ export function useShortcutScope<TScope extends ShortcutScopeDefinition<any>>({
|
|
|
123
129
|
handlers,
|
|
124
130
|
target = 'window',
|
|
125
131
|
stopOnMatch = true,
|
|
132
|
+
capture = false,
|
|
126
133
|
}: UseShortcutScopeOptions<TScope>) {
|
|
127
134
|
const handlersRef = useRef(handlers);
|
|
128
135
|
const sequenceRef = useRef<ShortcutSequenceState | null>(null);
|
|
@@ -206,11 +213,11 @@ export function useShortcutScope<TScope extends ShortcutScopeDefinition<any>>({
|
|
|
206
213
|
if (started.length > 0) keyboardEvent.preventDefault();
|
|
207
214
|
};
|
|
208
215
|
|
|
209
|
-
eventTarget.addEventListener('keydown', handleKeyDown);
|
|
216
|
+
eventTarget.addEventListener('keydown', handleKeyDown, capture);
|
|
210
217
|
return () => {
|
|
211
|
-
eventTarget.removeEventListener('keydown', handleKeyDown);
|
|
218
|
+
eventTarget.removeEventListener('keydown', handleKeyDown, capture);
|
|
212
219
|
};
|
|
213
|
-
}, [scope, stopOnMatch, target]);
|
|
220
|
+
}, [scope, stopOnMatch, target, capture]);
|
|
214
221
|
}
|
|
215
222
|
|
|
216
223
|
export function createShortcutScopeHook<TScope extends ShortcutScopeDefinition<any>>(scope: TScope) {
|
package/utils/mentions.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* Nothing here touches the DOM, fetches, or persists. A composer with no
|
|
12
12
|
* `mentionSource` never calls any of it.
|
|
13
13
|
*/
|
|
14
|
+
import type { ReactNode } from 'react';
|
|
14
15
|
|
|
15
16
|
/** One pickable identity, supplied by the host. */
|
|
16
17
|
export interface MentionPerson {
|
|
@@ -32,6 +33,13 @@ export interface MentionPerson {
|
|
|
32
33
|
* supplied `onPickBlocked` (see `MentionSource`).
|
|
33
34
|
*/
|
|
34
35
|
readonly canOpen: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Optional group heading: consecutive people with the same
|
|
38
|
+
* `group` are drawn under one heading, as a `role="group"` labelled by it
|
|
39
|
+
* (the Inbox groups agents by kind). Absent on every person → the flat
|
|
40
|
+
* list, exactly as before.
|
|
41
|
+
*/
|
|
42
|
+
readonly group?: string;
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
/** The opt-in `@` mention source for `CommentPopover`'s composer. */
|
|
@@ -61,6 +69,24 @@ export interface MentionSource {
|
|
|
61
69
|
readonly tokenClassName?: string;
|
|
62
70
|
/** Fires on every text change with the ids whose token still survives in the body. */
|
|
63
71
|
readonly onMentionsChange?: (ids: readonly string[]) => void;
|
|
72
|
+
/**
|
|
73
|
+
* Which kinds of people this composer offers. Absent → users only,
|
|
74
|
+
* as before (an agent is not taggable in a host's comments by default). A
|
|
75
|
+
* host whose agents can be tagged (the Inbox) passes `['agent']` or both.
|
|
76
|
+
*/
|
|
77
|
+
readonly kinds?: readonly MentionPerson['kind'][];
|
|
78
|
+
/**
|
|
79
|
+
* Whether a query also matches the `detail` line. Absent → true, as
|
|
80
|
+
* before. A host whose detail is incidental (the Inbox's "billing-api ·
|
|
81
|
+
* idle") passes false, so `@p` finds Pi, not every row with a "p" in it.
|
|
82
|
+
*/
|
|
83
|
+
readonly matchDetail?: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Optional line drawn under the composer field, from the people
|
|
86
|
+
* the draft still tags: a host's "Goes to …" and heads-up. Absent → nothing
|
|
87
|
+
* is drawn, as before.
|
|
88
|
+
*/
|
|
89
|
+
readonly renderNotice?: (tagged: readonly MentionPerson[]) => ReactNode;
|
|
64
90
|
/**
|
|
65
91
|
* A `canOpen: false` person was picked. When supplied, NOTHING is inserted
|
|
66
92
|
* and the host shows its own no-access affordance. When absent, such a
|
|
@@ -115,24 +141,27 @@ export function mentionTrigger(text: string, caret: number): MentionTrigger | nu
|
|
|
115
141
|
}
|
|
116
142
|
|
|
117
143
|
/**
|
|
118
|
-
* The people this trigger offers: users only (an agent is not
|
|
119
|
-
*
|
|
120
|
-
*
|
|
144
|
+
* The people this trigger offers: users only by default (an agent is not
|
|
145
|
+
* taggable in a comment unless the host says so with `kinds`), never one
|
|
146
|
+
* already tagged in this draft, de-duplicated by id, and filtered on label or
|
|
147
|
+
* detail once a query character exists.
|
|
121
148
|
*/
|
|
122
149
|
export function mentionMatches(
|
|
123
150
|
people: readonly MentionPerson[],
|
|
124
151
|
trigger: MentionTrigger,
|
|
125
152
|
alreadyTagged: ReadonlySet<string>,
|
|
153
|
+
kinds: readonly MentionPerson['kind'][] = ['user'],
|
|
154
|
+
matchDetail = true,
|
|
126
155
|
): readonly MentionPerson[] {
|
|
127
156
|
const seen = new Set<string>();
|
|
128
157
|
return people.filter((person) => {
|
|
129
|
-
if (person.kind
|
|
158
|
+
if (!kinds.includes(person.kind)) return false;
|
|
130
159
|
if (alreadyTagged.has(person.id) || seen.has(person.id)) return false;
|
|
131
160
|
seen.add(person.id);
|
|
132
161
|
if (trigger.query === '') return true;
|
|
133
162
|
return (
|
|
134
163
|
person.label.toLowerCase().includes(trigger.query) ||
|
|
135
|
-
(person.detail ?? '').toLowerCase().includes(trigger.query)
|
|
164
|
+
(matchDetail && (person.detail ?? '').toLowerCase().includes(trigger.query))
|
|
136
165
|
);
|
|
137
166
|
});
|
|
138
167
|
}
|