@plannotator/ui 0.32.0 → 0.33.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.
@@ -0,0 +1,4392 @@
1
+ (function() {
2
+ var PREFIX = 'plannotator-bridge-';
3
+
4
+ // --- Live mode (proxied local app) ---
5
+ // Srcdoc sessions carry no config: LIVE stays null and every branch below is
6
+ // inert, keeping srcdoc behavior byte-for-byte identical. The proxy injects
7
+ // this script into EVERY HTML response, so a frame gate deactivates the
8
+ // bridge when the proxied page is opened directly (not framed) and inside
9
+ // nested same-origin subframes: only the frame whose parent IS the editor
10
+ // may run.
11
+ var LIVE = window.__plannotatorLiveConfig || null;
12
+ if (LIVE && (window === window.parent || window.parent !== window.top)) return;
13
+ // The server cannot know which origin form (localhost or 127.0.0.1) the
14
+ // editor tab was opened on, so live outbound messages are posted once per
15
+ // listed editor origin: the browser delivers only the post whose
16
+ // targetOrigin matches the parent document and silently drops the rest,
17
+ // so exactly one copy arrives. Inbound accepts any listed origin. Srcdoc
18
+ // keeps targetOrigin '*' and no token.
19
+ function isEditorOrigin(origin) {
20
+ if (!LIVE) return true;
21
+ var list = LIVE.editorOrigins || [];
22
+ for (var i = 0; i < list.length; i++) { if (list[i] === origin) return true; }
23
+ return false;
24
+ }
25
+ function postToParent(msg) {
26
+ if (LIVE) {
27
+ msg.token = LIVE.token;
28
+ var origins = LIVE.editorOrigins || [];
29
+ for (var o = 0; o < origins.length; o++) parent.postMessage(msg, origins[o]);
30
+ return;
31
+ }
32
+ parent.postMessage(msg, '*');
33
+ }
34
+ // Page identity for multi-page live sessions: annotations are stamped with
35
+ // the page they were made on, and restore filters to the current page.
36
+ function currentPageUrl() {
37
+ return (location.pathname + location.search).slice(0, 2048);
38
+ }
39
+ if (LIVE) {
40
+ // SPA navigation: report history changes so the parent can re-filter the
41
+ // restored set. Coalesced with a microtask flag so a pushState burst posts
42
+ // once. Full reloads need nothing: the proxy re-injects and the fresh
43
+ // document posts ready again.
44
+ var pageChangeQueued = false;
45
+ var postPageChange = function() {
46
+ if (pageChangeQueued) return;
47
+ pageChangeQueued = true;
48
+ Promise.resolve().then(function() {
49
+ pageChangeQueued = false;
50
+ postToParent({ type: PREFIX + 'page-change', pageUrl: currentPageUrl() });
51
+ });
52
+ };
53
+ var wrapHistory = function(name) {
54
+ var original = history[name];
55
+ if (typeof original !== 'function') return;
56
+ history[name] = function() {
57
+ var result = original.apply(this, arguments);
58
+ postPageChange();
59
+ return result;
60
+ };
61
+ };
62
+ wrapHistory('pushState');
63
+ wrapHistory('replaceState');
64
+ window.addEventListener('popstate', postPageChange);
65
+ }
66
+
67
+ // --- Theme ---
68
+ // The author owns this document. Unless it opted in to host theming
69
+ // (hostTheme), only viewer-namespaced --pn-* properties may be written to its
70
+ // root, and its class list is never touched.
71
+ window.addEventListener('message', function(e) {
72
+ if (e.source !== parent) return;
73
+ if (LIVE && (!isEditorOrigin(e.origin) || !e.data || e.data.token !== LIVE.token)) return;
74
+ if (!e.data) return;
75
+ if (e.data.type === PREFIX + 'set-vim-help') {
76
+ vimHelpOpen = !!e.data.open;
77
+ postToParent({
78
+ type: PREFIX + 'vim-help',
79
+ open: vimHelpOpen
80
+ });
81
+ return;
82
+ }
83
+ if (e.data.type !== PREFIX + 'theme') return;
84
+ var root = document.documentElement;
85
+ var tokens = e.data.tokens || {};
86
+ var hostTheme = !!e.data.hostTheme;
87
+ for (var key in tokens) {
88
+ if (!tokens.hasOwnProperty(key)) continue;
89
+ if (!hostTheme && key.indexOf('--pn-') !== 0) continue;
90
+ root.style.setProperty(key, tokens[key]);
91
+ }
92
+ if (hostTheme) {
93
+ root.classList.remove('light');
94
+ if (e.data.isLight) root.classList.add('light');
95
+ }
96
+ });
97
+
98
+ // --- Resize ---
99
+ var lastHeight = 0;
100
+ function postResize() {
101
+ if (LIVE) return; // live surfaces render full-viewport; the parent ignores height
102
+ if (!document.body) return;
103
+ var h = document.body.scrollHeight;
104
+ if (h !== lastHeight) {
105
+ lastHeight = h;
106
+ postToParent({ type: PREFIX + 'resize', height: h });
107
+ }
108
+ }
109
+ window.addEventListener('load', postResize);
110
+
111
+ // --- Selection ---
112
+ var pendingSelection = null;
113
+ var pendingRange = null; // live range for the pending selection (scroll tracking)
114
+ var pendingPinEl = null; // element pinned by a pinpoint click (outline + scroll tracking)
115
+ var pendingPinAnchor = null; // serialized anchor for the pending pin
116
+ var pendingPinKey = null; // target key for the primary pinpoint target (multi-select)
117
+ var pendingPinLabel = null; // semantic label captured for the primary target
118
+ var pendingPinPoint = null; // normalized {x,y} click point inside the pinned element's rect
119
+ var pendingPinViaPinpoint = false; // pinpoint drafts survive scroll-out (see postSelectionRect)
120
+ // Multi-select is ARMED EXPLICITLY by the parent (arm-multi-select), and only
121
+ // when the comment composer owns the draft. The bridge must never accept a
122
+ // shift-toggle the parent would not mirror (e.g. quickLabel-mode drafts):
123
+ // what the user sees pinned and what the annotation saves must never diverge.
124
+ var multiSelectArmed = false;
125
+ // Shift-click multi-select: additional elements joined to the SAME draft
126
+ // comment while the composer is open. Each entry owns a pinned outline box.
127
+ var pendingMultiTargets = []; // { key, el, anchor, label, text, box }
128
+ var multiTargetSeq = 0;
129
+ var MAX_MULTI_TARGETS = 16;
130
+ // Per-draft cap on additional targets: the parent may lower it on
131
+ // arm-multi-select ({ max }) to its product cap so the toggle stops where
132
+ // the saved annotation would. Never above MAX_MULTI_TARGETS; reset with
133
+ // the arm on every draft.
134
+ var multiSelectMax = MAX_MULTI_TARGETS;
135
+ function clampMultiSelectMax(value) {
136
+ if (typeof value !== 'number' || !isFinite(value)) return MAX_MULTI_TARGETS;
137
+ var whole = Math.floor(value);
138
+ if (whole < 0) return 0;
139
+ return whole > MAX_MULTI_TARGETS ? MAX_MULTI_TARGETS : whole;
140
+ }
141
+ // Live mode clamps the INPUT METHOD to pinpoint (click = element). Text
142
+ // drag-selection is a separate, always-on channel — see the mouseup handler
143
+ // — so the clamp only decides what a plain click does, never whether text
144
+ // can be selected and commented.
145
+ var currentInputMethod = LIVE ? 'pinpoint' : 'drag'; // 'drag' = text selection, 'pinpoint' = click an element
146
+ // Interact/Annotate mode. While INACTIVE the bridge keeps clicks native: no
147
+ // pinpoint capture, no hover outline, no [data-annotate] click, no
148
+ // committed-highlight click interception — clicks, forms, and SPA
149
+ // navigation reach the page untouched. Text drag-selection commenting stays
150
+ // LIVE in both modes (a real drag opens the comment toolbar even in
151
+ // Interact), committed overlay artifacts stay visible in both modes, and
152
+ // marker buttons keep their own clicks. BOTH surface kinds start ARMED —
153
+ // Esc (or the header pen) drops to Interact.
154
+ var annotateModeActive = true;
155
+ function updatePinpointCursor() {
156
+ if (!document.body) return;
157
+ if (annotateModeActive && currentInputMethod === 'pinpoint') {
158
+ document.body.setAttribute('data-plannotator-pinpoint-cursor', '');
159
+ } else {
160
+ document.body.removeAttribute('data-plannotator-pinpoint-cursor');
161
+ }
162
+ }
163
+ var pinpointHover = null;
164
+ var vimEnabled = false;
165
+ var vimHudEnabled = false;
166
+ var vimPhase = 'inactive';
167
+ var vimActiveMode = 'selection';
168
+ var vimPinpointEl = null;
169
+ var vimVisualBlockAnchorEl = null;
170
+ var vimPendingG = false;
171
+ var vimPendingGTimer = 0;
172
+ var vimHelpOpen = false;
173
+ var vimAddedBodyTabIndex = false;
174
+ var vimActionReturn = null;
175
+ var vimLastActionId = null;
176
+ var vimLastActionContext = 'inactive';
177
+ var vimLastPostedPhase = null;
178
+ // A plain click on an element-annotation target opens the toolbar, but the same
179
+ // click's mouseup schedules a handleSelection() that would see an empty selection
180
+ // and immediately clear it. This flag suppresses that one trailing clear.
181
+ var skipNextClear = false;
182
+
183
+ // Drag-selection commenting is handled by the merged capture-phase mouseup
184
+ // listener below (after the drag-yield state it reads) — it is ALWAYS live:
185
+ // both surfaces, armed or Interact, drag or pinpoint input method.
186
+
187
+ // The page fully controls element text, so everything posted as a selection
188
+ // is bounded here before it crosses the bridge (the parent enforces the same
189
+ // cap on its side of the trust boundary). One pinpoint click on a huge
190
+ // <pre>/<table> must not ship megabytes into drafts, feedback, or share URLs.
191
+ var MAX_SELECTION_TEXT = 10000;
192
+
193
+ function capSelectionText(text) {
194
+ if (text.length <= MAX_SELECTION_TEXT) return text;
195
+ var cut = MAX_SELECTION_TEXT;
196
+ var last = text.charCodeAt(cut - 1);
197
+ // Never split a surrogate pair: a lone high surrogate at the cut point
198
+ // becomes U+FFFD the moment the string is UTF-8 encoded downstream.
199
+ if (last >= 0xd800 && last <= 0xdbff) cut -= 1;
200
+ return text.slice(0, cut);
201
+ }
202
+
203
+ function handleSelection(modeOverride, extras) {
204
+ var sel = window.getSelection();
205
+ if (!sel || sel.isCollapsed || !sel.rangeCount) {
206
+ // Trailing clear from a plain-click element annotation — consume it once.
207
+ if (skipNextClear) { skipNextClear = false; return; }
208
+ if (pendingSelection) {
209
+ postToParent({ type: PREFIX + 'selection-clear' });
210
+ pendingSelection = null;
211
+ pendingRange = null;
212
+ clearMultiTargets();
213
+ clearPendingPin();
214
+ renderAnnotationOverlay();
215
+ }
216
+ return false;
217
+ }
218
+ skipNextClear = false; // a real text selection happened
219
+ var range = sel.getRangeAt(0);
220
+ var text = capSelectionText(sel.toString().trim());
221
+ if (!text) return false;
222
+
223
+ // A draft now owns the surface: kill the click-to-select hover
224
+ // affordance, including any hit test already scheduled.
225
+ clearHoverHighlight();
226
+
227
+ var rect = range.getBoundingClientRect();
228
+ pendingRange = range;
229
+ pendingSelection = {
230
+ text: text,
231
+ startContainerPath: getNodePath(range.startContainer),
232
+ startOffset: range.startOffset,
233
+ endContainerPath: getNodePath(range.endContainer),
234
+ endOffset: range.endOffset
235
+ };
236
+
237
+ postToParent({
238
+ type: PREFIX + 'selection',
239
+ text: text,
240
+ modeOverride: modeOverride || undefined,
241
+ anchor: (extras && extras.anchor) || undefined,
242
+ pinpoint: (extras && extras.pinpoint) || undefined,
243
+ targetKey: (extras && extras.targetKey) || undefined,
244
+ targetLabel: (extras && extras.targetLabel) || undefined,
245
+ rect: { top: rect.top, left: rect.left, width: rect.width, height: rect.height }
246
+ });
247
+ renderAnnotationOverlay(); // draft selection highlight (overlay-projected)
248
+ return true;
249
+ }
250
+
251
+ // Keep the toolbar/popover attached while the iframe content scrolls: re-post the
252
+ // pending selection's live rect (parent has no way to see an in-iframe scroll).
253
+ // Capture phase so inner scroll containers count too.
254
+ var scrollRaf = 0;
255
+ function postSelectionRect() {
256
+ scrollRaf = 0;
257
+ if (!pendingSelection) return;
258
+ // Element pins carry no live range — track the pinned element's box instead.
259
+ var tracked = pendingRange || (pendingPinEl && pendingPinEl.isConnected ? pendingPinEl : null);
260
+ if (!tracked) return;
261
+ var r = tracked.getBoundingClientRect();
262
+ if (r.bottom < 0 || r.top > window.innerHeight) {
263
+ // Pinpoint drafts survive scrolling: the user is expected to roam the
264
+ // page (especially to shift-click distant elements into the draft), so
265
+ // the pinned primary leaving the viewport must not tear the draft down.
266
+ // Drag selections keep the existing close-on-scroll-out behavior.
267
+ if (pendingPinViaPinpoint || pendingMultiTargets.length > 0) return;
268
+ // Selection scrolled out of view — close the toolbar (matches markdown).
269
+ postToParent({ type: PREFIX + 'selection-clear' });
270
+ pendingSelection = null;
271
+ pendingRange = null;
272
+ clearPendingPin();
273
+ renderAnnotationOverlay();
274
+ return;
275
+ }
276
+ postToParent({
277
+ type: PREFIX + 'selection-rect',
278
+ rect: { top: r.top, left: r.left, width: r.width, height: r.height }
279
+ });
280
+ }
281
+ window.addEventListener('scroll', function() {
282
+ if (!pendingSelection) return;
283
+ if (!scrollRaf) scrollRaf = requestAnimationFrame(postSelectionRect);
284
+ }, true);
285
+
286
+ // --- Mark Creation ---
287
+ window.addEventListener('message', function(e) {
288
+ if (e.source !== parent) return;
289
+ if (LIVE && (!isEditorOrigin(e.origin) || !e.data || e.data.token !== LIVE.token)) return;
290
+ if (!e.data || !e.data.type) return;
291
+ var type = e.data.type;
292
+
293
+ if (type === PREFIX + 'create-mark') {
294
+ var id = e.data.id;
295
+ var annType = e.data.annotationType || 'comment';
296
+ if (pendingSelection) {
297
+ var record = null;
298
+ // Text selections register a live range (overlay highlight); element
299
+ // pinpoints (e.g. SVG nodes) carry no range and register the pinned
300
+ // element with the user's selected point. The page DOM is untouched.
301
+ if (pendingSelection.startContainerPath) {
302
+ var committedRange = buildPendingRange(pendingSelection);
303
+ if (pendingPinEl) {
304
+ record = ensureAnnRecord(id, annType, {
305
+ originalText: pendingSelection.text || '',
306
+ anchor: pendingPinAnchor,
307
+ additionalAnchors: null
308
+ });
309
+ addElementTarget(record, pendingPinEl, pendingPinAnchor, pendingPinPoint);
310
+ if (committedRange) addRangeTarget(record, committedRange, pendingSelection.text || '', true);
311
+ } else if (committedRange) {
312
+ record = ensureAnnRecord(id, annType, {
313
+ originalText: pendingSelection.text || '',
314
+ anchor: null,
315
+ additionalAnchors: null
316
+ });
317
+ addRangeTarget(record, committedRange, pendingSelection.text || '', false);
318
+ }
319
+ if (
320
+ vimActionReturn
321
+ && (vimActionReturn.phase === 'visual' || vimActionReturn.phase === 'visual-block')
322
+ ) {
323
+ vimActionReturn.range = committedRangeClone(id) || vimActionReturn.range;
324
+ }
325
+ } else if (pendingPinEl) {
326
+ record = ensureAnnRecord(id, annType, {
327
+ originalText: '',
328
+ anchor: pendingPinAnchor,
329
+ additionalAnchors: null
330
+ });
331
+ addElementTarget(record, pendingPinEl, pendingPinAnchor, pendingPinPoint);
332
+ }
333
+ // Every additional multi-select target registers under the SAME
334
+ // annotation id — all of its markers share one comment number.
335
+ if (pendingMultiTargets.length) {
336
+ if (!record) {
337
+ record = ensureAnnRecord(id, annType, {
338
+ originalText: '',
339
+ anchor: pendingPinAnchor,
340
+ additionalAnchors: null
341
+ });
342
+ }
343
+ var extraAnchorList = [];
344
+ for (var mtIndex = 0; mtIndex < pendingMultiTargets.length; mtIndex++) {
345
+ var mt = pendingMultiTargets[mtIndex];
346
+ addElementTarget(record, mt.el, mt.anchor, mt.point);
347
+ if (mt.anchor) extraAnchorList.push(mt.anchor);
348
+ }
349
+ if (record.params) {
350
+ record.params.additionalAnchors = extraAnchorList.length ? extraAnchorList : null;
351
+ }
352
+ }
353
+ pendingSelection = null;
354
+ pendingRange = null;
355
+ window.getSelection().removeAllRanges();
356
+ }
357
+ clearMultiTargets();
358
+ clearPendingPin();
359
+ restoreVimSemanticTarget();
360
+ renderAnnotationOverlay();
361
+ }
362
+
363
+ else if (type === PREFIX + 'find-and-mark') {
364
+ var found = restoreAnnotation(
365
+ e.data.id,
366
+ e.data.annotationType || 'comment',
367
+ typeof e.data.originalText === 'string' ? e.data.originalText : '',
368
+ e.data.anchor,
369
+ e.data.additionalAnchors
370
+ );
371
+ postToParent({
372
+ type: PREFIX + 'mark-applied',
373
+ id: e.data.id,
374
+ success: found
375
+ });
376
+ }
377
+
378
+ else if (type === PREFIX + 'remove-mark') {
379
+ removeAnnRecord(e.data.id);
380
+ renderAnnotationOverlay();
381
+ }
382
+
383
+ else if (type === PREFIX + 'clear-marks') {
384
+ annRecords = [];
385
+ annNumbers = null; // stale synced numbers must not leak onto future records
386
+ focusedAnnotationId = null;
387
+ restoreFailedIds.clear();
388
+ renderAnnotationOverlay();
389
+ }
390
+
391
+ else if (type === PREFIX + 'sync-annotations') {
392
+ // Parent-authoritative numbering: the ordered saved-annotation list
393
+ // (index + 1 in the panel's collection). Bounded and shape-checked —
394
+ // malformed entries are skipped, a malformed list is ignored outright.
395
+ var syncList = e.data.annotations;
396
+ if (syncList && typeof syncList.length === 'number') {
397
+ var nextNumbers = new Map();
398
+ var syncCount = Math.min(syncList.length, MAX_SYNC_ANNOTATIONS);
399
+ for (var syncIndex = 0; syncIndex < syncCount; syncIndex++) {
400
+ var syncEntry = syncList[syncIndex];
401
+ if (!syncEntry || typeof syncEntry.id !== 'string' || !syncEntry.id || syncEntry.id.length > 256) continue;
402
+ var syncNumber = syncEntry.number;
403
+ if (
404
+ typeof syncNumber !== 'number'
405
+ || !isFinite(syncNumber)
406
+ || syncNumber < 1
407
+ || syncNumber > 100000
408
+ || Math.floor(syncNumber) !== syncNumber
409
+ ) continue;
410
+ nextNumbers.set(syncEntry.id, syncNumber);
411
+ }
412
+ annNumbers = nextNumbers;
413
+ renderAnnotationOverlay();
414
+ }
415
+ }
416
+
417
+ else if (type === PREFIX + 'cancel-selection') {
418
+ pendingSelection = null;
419
+ pendingRange = null;
420
+ skipNextClear = false;
421
+ clearMultiTargets();
422
+ clearPendingPin();
423
+ window.getSelection().removeAllRanges();
424
+ restoreVimSemanticTarget();
425
+ renderAnnotationOverlay();
426
+ }
427
+
428
+ else if (type === PREFIX + 'arm-multi-select') {
429
+ // Parent arms shift-multi-select for the draft it is mirroring in the
430
+ // comment composer. The key must name the CURRENT primary so a stale
431
+ // arm from a previous draft can never arm a new one.
432
+ if (
433
+ pendingPinEl
434
+ && pendingPinViaPinpoint
435
+ && typeof e.data.key === 'string'
436
+ && e.data.key === pendingPinKey
437
+ ) {
438
+ multiSelectArmed = true;
439
+ // Optional product cap for THIS draft; absent keeps the bridge's own.
440
+ multiSelectMax = e.data.max === undefined
441
+ ? MAX_MULTI_TARGETS
442
+ : clampMultiSelectMax(e.data.max);
443
+ }
444
+ }
445
+
446
+ else if (type === PREFIX + 'remove-target') {
447
+ // Parent-initiated removal (chip X button), or the parent's echo of a
448
+ // bridge-side removal. Idempotent: an already-removed key is a no-op,
449
+ // which also resyncs the two sides after a forged removal message.
450
+ removeMultiTargetByKey(typeof e.data.key === 'string' ? e.data.key : '', false);
451
+ }
452
+
453
+ else if (type === PREFIX + 'flash-target') {
454
+ flashMultiTarget(typeof e.data.key === 'string' ? e.data.key : '');
455
+ }
456
+
457
+ else if (type === PREFIX + 'scroll-to') {
458
+ // Selecting an annotation scrolls its first resolved target into view
459
+ // and flashes the overlay focus highlight over EVERY rect of EVERY
460
+ // target — never a class write on page elements, and never only the
461
+ // first fragment of a multi-paragraph selection. The optional
462
+ // behavior lets the parent pass its reduced-motion preference across
463
+ // the boundary; absent means smooth, as before.
464
+ scrollToAnnotation(e.data.id, e.data.behavior === 'auto' ? 'auto' : 'smooth');
465
+ }
466
+
467
+ else if (type === PREFIX + 'focus-mark') {
468
+ focusAnnotationRecord(typeof e.data.id === 'string' ? e.data.id : null, false);
469
+ }
470
+
471
+ else if (type === PREFIX + 'report-unanchored') {
472
+ // The parent posted its restore batch and wants the complete set once
473
+ // the next complete overlay pass has run, even if the set is unchanged
474
+ // (empty included). Messages are processed in order, so the pass this
475
+ // schedules sees every find-and-mark posted before this request.
476
+ unanchoredReportRequested = true;
477
+ schedulePinpointReconcile();
478
+ }
479
+
480
+ else if (type === PREFIX + 'set-input-method') {
481
+ // Live mode clamps the input method to pinpoint (what a plain click
482
+ // does); text drag-selection commenting stays live regardless.
483
+ currentInputMethod = (LIVE || e.data.method === 'pinpoint') ? 'pinpoint' : 'drag';
484
+ if (currentInputMethod === 'pinpoint') {
485
+ clearHoverHighlight(); // pinpoint owns clicks; drop the select affordance (and any pending hit test)
486
+ } else {
487
+ clearPinpointHover();
488
+ }
489
+ updatePinpointCursor(); // cursor affordance is mode-gated: never in Interact
490
+ if (vimEnabled) updateVimUi();
491
+ }
492
+
493
+ else if (type === PREFIX + 'set-annotate-mode') {
494
+ var nextAnnotateActive = e.data.active === true;
495
+ if (nextAnnotateActive !== annotateModeActive) {
496
+ annotateModeActive = nextAnnotateActive;
497
+ if (!annotateModeActive) {
498
+ // Disarm tears down every PENDING affordance; committed markers and
499
+ // highlights stay visible, and marker buttons keep their clicks.
500
+ if (pendingSelection) postToParent({ type: PREFIX + 'selection-clear' });
501
+ pendingSelection = null;
502
+ pendingRange = null;
503
+ skipNextClear = false;
504
+ clearMultiTargets();
505
+ clearPendingPin();
506
+ try { window.getSelection().removeAllRanges(); } catch (ex) {}
507
+ clearPinpointHover();
508
+ clearHoverHighlight();
509
+ renderAnnotationOverlay();
510
+ }
511
+ updatePinpointCursor();
512
+ }
513
+ }
514
+
515
+ else if (type === PREFIX + 'set-vim-mode') {
516
+ // Vim is off in live mode: it writes classes onto author elements and
517
+ // captures keys the app needs.
518
+ if (LIVE) return;
519
+ var wasVimEnabled = vimEnabled;
520
+ var wasVimHudEnabled = vimHudEnabled;
521
+ vimEnabled = e.data.enabled === true;
522
+ vimHudEnabled = e.data.hudEnabled === true;
523
+ if (wasVimHudEnabled !== vimHudEnabled) vimLastPostedPhase = null;
524
+ vimActiveMode = e.data.mode === 'comment'
525
+ || e.data.mode === 'redline'
526
+ || e.data.mode === 'quickLabel'
527
+ ? e.data.mode
528
+ : 'selection';
529
+ if (!vimEnabled) {
530
+ clearVimUi();
531
+ vimPinpointEl = null;
532
+ vimPhase = 'inactive';
533
+ } else if (!wasVimEnabled) {
534
+ prepareVimFocusOwner();
535
+ vimPhase = 'inactive';
536
+ updateVimUi();
537
+ } else {
538
+ updateVimUi();
539
+ }
540
+ }
541
+
542
+ else if (type === PREFIX + 'focus-vim') {
543
+ if (vimEnabled) {
544
+ ensureVimFocus();
545
+ if (vimPhase === 'inactive') resetVimSemanticNavigation();
546
+ updateVimUi();
547
+ }
548
+ }
549
+ });
550
+
551
+ // --- Pinpoint: hover to outline the element under the cursor, click to pin ---
552
+ // Hover is pure per-event hit-testing: deep elementFromPoint (piercing open
553
+ // shadow roots) picks the REAL element under the pointer — no tag whitelist,
554
+ // no has-text requirement — so styled div/span prototypes (chips, icon
555
+ // buttons, cards) are individually targetable. Scope control is mouse-only
556
+ // and geometric, matching the markdown pinpoint feel: the deepest element
557
+ // directly under the pointer wins, and pointing at a container's padding or
558
+ // any area not covered by a child selects the container. Elements under
559
+ // 16px on both axes promote to the nearest ancestor at least 16px on one
560
+ // axis (agentation's MIN_CAPTURE_SIZE pattern). The click reuses the normal
561
+ // selection pipeline — select the element's text and run handleSelection()
562
+ // like a drag — the hover visual is a dedicated fixed-position outline box
563
+ // (never a class write on the page's own elements), the click serializes a
564
+ // CSS anchor for later restoration, and element-only pins (SVG, icon
565
+ // buttons) get a numbered badge. The semantic target graph below survives
566
+ // as the vim-navigation vocabulary only; the pointer path never builds it.
567
+ var PINPOINT_SKIP_SELECTOR = 'script,style,noscript,[data-plannotator-vim-ui]';
568
+
569
+ // Identity set of every overlay node the viewer creates inside the page
570
+ // (pin badges, pinpoint box/label, vim UI). Hit-testing excludes overlay
571
+ // nodes by IDENTITY, never by selector match, so page markup carrying our
572
+ // attribute names cannot spoof its way out of (or into) targeting.
573
+ var overlayNodes = new Set();
574
+
575
+ // Floor for hover targets (not a whitelist): a decorative dot or hairline
576
+ // under 16px on BOTH axes promotes to the nearest ancestor that is at least
577
+ // 16px on one axis, so tiny leaves stay clickable without precision-mousing.
578
+ var MIN_CAPTURE_SIZE = 16;
579
+
580
+ // Headless DOM test environments lay nothing out (every rect is 0x0); size
581
+ // rules only apply when the body actually has a laid-out box.
582
+ function layoutActive() {
583
+ return !!document.body && document.body.getBoundingClientRect().width > 0;
584
+ }
585
+
586
+ function deepElementFromPoint(x, y) {
587
+ var el = typeof document.elementFromPoint === 'function'
588
+ ? document.elementFromPoint(x, y)
589
+ : null;
590
+ var guard = 0;
591
+ while (
592
+ el
593
+ && el.shadowRoot
594
+ && typeof el.shadowRoot.elementFromPoint === 'function'
595
+ && guard++ < 24
596
+ ) {
597
+ var inner = el.shadowRoot.elementFromPoint(x, y);
598
+ if (!inner || inner === el) break;
599
+ el = inner;
600
+ }
601
+ return el;
602
+ }
603
+
604
+ function isViewerOverlayNode(node) {
605
+ var current = node;
606
+ var guard = 0;
607
+ while (current && guard++ < 200) {
608
+ if (overlayNodes.has(current)) return true;
609
+ current = current.parentElement
610
+ || (current.getRootNode && current.getRootNode().host)
611
+ || null;
612
+ }
613
+ return false;
614
+ }
615
+
616
+ function promoteTinyTarget(el) {
617
+ if (!layoutActive()) return el;
618
+ var r = el.getBoundingClientRect();
619
+ if (r.width >= MIN_CAPTURE_SIZE || r.height >= MIN_CAPTURE_SIZE) return el;
620
+ var current = el.parentElement;
621
+ while (current && current !== document.body && current !== document.documentElement) {
622
+ var cr = current.getBoundingClientRect();
623
+ if (cr.width >= MIN_CAPTURE_SIZE || cr.height >= MIN_CAPTURE_SIZE) return current;
624
+ current = current.parentElement;
625
+ }
626
+ return el;
627
+ }
628
+
629
+ // The placed-marker button the most recent raw (pre-yield) probe landed
630
+ // on, if any. Hover uses it to advertise the MARKER's identity instead of
631
+ // the page element beneath it — the marker owns the click, so the hover
632
+ // label must never promise an annotate action the click won't perform.
633
+ var lastRawHitMarker = null;
634
+
635
+ function committedMarkerButtonFrom(node) {
636
+ var current = node;
637
+ var guard = 0;
638
+ while (current && guard++ < 24) {
639
+ if (
640
+ current.nodeType === 1
641
+ && overlayNodes.has(current)
642
+ && current.hasAttribute
643
+ && current.hasAttribute('data-plannotator-marker')
644
+ ) return current;
645
+ current = current.parentElement
646
+ || (current.getRootNode && current.getRootNode().host)
647
+ || null;
648
+ }
649
+ return null;
650
+ }
651
+
652
+ // Resolve the pinpoint target at a viewport point. The deepest rendered
653
+ // element under the pointer wins; containers are selected by pointing at
654
+ // their uncovered area (padding, gaps), exactly like the markdown surface.
655
+ // fallbackNode covers engines without a real elementFromPoint (headless DOM
656
+ // tests) and the scroll reconcile, which re-resolves under a still cursor.
657
+ function resolvePinpointTargetAt(x, y, fallbackNode) {
658
+ lastRawHitMarker = null;
659
+ var node = deepElementFromPoint(x, y);
660
+ if (node && isViewerOverlayNode(node)) {
661
+ lastRawHitMarker = committedMarkerButtonFrom(node);
662
+ // A placed marker owns its clicks, but hit-testing for a NEW selection
663
+ // must reach the page beneath it: temporarily yield marker hit targets
664
+ // and probe again (identity-gated, never selector-gated).
665
+ node = withMarkersYielded(function() { return deepElementFromPoint(x, y); });
666
+ }
667
+ if (!node || node === document.documentElement || node === document.body) {
668
+ node = fallbackNode || null;
669
+ }
670
+ while (node && node.nodeType === 3) node = node.parentNode;
671
+ if (!node || node.nodeType !== 1) return null;
672
+ if (node === document.documentElement || node === document.body) return null;
673
+ if (isViewerOverlayNode(node)) return null;
674
+ if (node.closest && node.closest('script,style,noscript')) return null;
675
+ // SVG shape primitives promote to their nearest group: a <g> is the
676
+ // authored unit of an SVG diagram, and its <path> fragments are not
677
+ // individually meaningful annotation targets.
678
+ if (node.ownerSVGElement && node.closest) {
679
+ var svgGroup = node.closest('g');
680
+ if (svgGroup) node = svgGroup;
681
+ }
682
+ node = promoteTinyTarget(node);
683
+ if (node === document.body || node === document.documentElement) return null;
684
+ return node;
685
+ }
686
+
687
+ // Last-position reuse: a pointer that moved under 2px within 16ms resolves
688
+ // to the cached element instead of re-hit-testing. The scroll reconcile
689
+ // invalidates this cache — same point, different element after a scroll.
690
+ var lastPointerHit = null;
691
+ function pinpointLeafAt(x, y, fallbackNode) {
692
+ var now = typeof performance !== 'undefined' && performance.now
693
+ ? performance.now()
694
+ : Date.now();
695
+ if (
696
+ lastPointerHit
697
+ && Math.abs(x - lastPointerHit.x) <= 2
698
+ && Math.abs(y - lastPointerHit.y) <= 2
699
+ && now - lastPointerHit.t <= 16
700
+ && (!lastPointerHit.el || lastPointerHit.el.isConnected)
701
+ ) {
702
+ lastRawHitMarker = lastPointerHit.marker || null;
703
+ return lastPointerHit.el;
704
+ }
705
+ var el = resolvePinpointTargetAt(x, y, fallbackNode);
706
+ lastPointerHit = { x: x, y: y, t: now, el: el, marker: lastRawHitMarker };
707
+ return el;
708
+ }
709
+ function invalidatePointerHitCache() {
710
+ lastPointerHit = null;
711
+ }
712
+ var SEMANTIC_BLOCK_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,pre,blockquote,figcaption,table,button,[data-annotate],svg g';
713
+ var SEMANTIC_GROUP_SELECTOR = 'section,article,aside,nav,header,footer,ul,ol,figure,main';
714
+ var SEMANTIC_INLINE_SELECTOR = 'a,em,strong,b,i,code,small,label,mark,sup,sub,u,abbr,time';
715
+ var semanticTargetKeys = new WeakMap();
716
+ var semanticTargetKeyCounter = 0;
717
+
718
+ function semanticTargetKey(el) {
719
+ var existing = semanticTargetKeys.get(el);
720
+ if (existing) return existing;
721
+ semanticTargetKeyCounter += 1;
722
+ var key = 'html-target-' + semanticTargetKeyCounter;
723
+ semanticTargetKeys.set(el, key);
724
+ return key;
725
+ }
726
+
727
+ function semanticLabel(el) {
728
+ return PINPOINT_LABELS[el.tagName] || el.tagName.toLowerCase();
729
+ }
730
+
731
+ function buildSemanticTargetGraph() {
732
+ var targets = [];
733
+ var blocks = [];
734
+ var byElement = new Map();
735
+
736
+ function add(el, kind, parent) {
737
+ if (!el || byElement.has(el) || el.closest(PINPOINT_SKIP_SELECTOR)) return null;
738
+ if (!el.textContent || !el.textContent.trim()) return null;
739
+ var target = {
740
+ key: semanticTargetKey(el),
741
+ element: el,
742
+ kind: kind,
743
+ parentKey: parent ? parent.key : null,
744
+ label: semanticLabel(el)
745
+ };
746
+ targets.push(target);
747
+ byElement.set(el, target);
748
+ return target;
749
+ }
750
+
751
+ function addInlineDescendants(root, parent) {
752
+ var inlineElements = Array.prototype.slice.call(root.querySelectorAll(SEMANTIC_INLINE_SELECTOR));
753
+ for (var inlineIndex = 0; inlineIndex < inlineElements.length; inlineIndex++) {
754
+ var inlineEl = inlineElements[inlineIndex];
755
+ var ancestorEl = inlineEl.parentElement && inlineEl.parentElement.closest(SEMANTIC_INLINE_SELECTOR);
756
+ var inlineParent = ancestorEl && root.contains(ancestorEl)
757
+ ? byElement.get(ancestorEl) || parent
758
+ : parent;
759
+ add(inlineEl, 'inline', inlineParent);
760
+ }
761
+ }
762
+
763
+ var groupElements = Array.prototype.slice.call(document.querySelectorAll(SEMANTIC_GROUP_SELECTOR));
764
+ for (var groupIndex = 0; groupIndex < groupElements.length; groupIndex++) {
765
+ var groupEl = groupElements[groupIndex];
766
+ var containingGroupEl = groupEl.parentElement && groupEl.parentElement.closest(SEMANTIC_GROUP_SELECTOR);
767
+ add(groupEl, 'group', containingGroupEl ? byElement.get(containingGroupEl) || null : null);
768
+ }
769
+
770
+ var blockElements = Array.prototype.slice.call(document.querySelectorAll(SEMANTIC_BLOCK_SELECTOR));
771
+ for (var blockIndex = 0; blockIndex < blockElements.length; blockIndex++) {
772
+ var blockEl = blockElements[blockIndex];
773
+ if (blockEl.closest(PINPOINT_SKIP_SELECTOR)) continue;
774
+ var containingBlock = blockEl.parentElement && blockEl.parentElement.closest(SEMANTIC_BLOCK_SELECTOR);
775
+ if (containingBlock) continue;
776
+ var parentGroupEl = blockEl.parentElement && blockEl.parentElement.closest(SEMANTIC_GROUP_SELECTOR);
777
+ var parentGroup = parentGroupEl ? byElement.get(parentGroupEl) : null;
778
+ var kind = blockEl.tagName === 'TABLE'
779
+ ? 'table'
780
+ : blockEl.tagName === 'PRE'
781
+ ? 'code'
782
+ : 'block';
783
+ var blockTarget = add(blockEl, kind, parentGroup || null);
784
+ if (!blockTarget) continue;
785
+ blocks.push(blockTarget);
786
+
787
+ if (blockEl.tagName === 'TABLE') {
788
+ var rows = Array.prototype.slice.call(blockEl.rows || []);
789
+ for (var rowIndex = 0; rowIndex < rows.length; rowIndex++) {
790
+ var rowTarget = add(rows[rowIndex], 'row', blockTarget);
791
+ if (!rowTarget) continue;
792
+ var cells = Array.prototype.slice.call(rows[rowIndex].cells || []);
793
+ for (var cellIndex = 0; cellIndex < cells.length; cellIndex++) {
794
+ var cellTarget = add(cells[cellIndex], 'cell', rowTarget);
795
+ if (cellTarget) {
796
+ cellTarget.rowIndex = rowIndex;
797
+ cellTarget.columnIndex = cellIndex;
798
+ addInlineDescendants(cells[cellIndex], cellTarget);
799
+ }
800
+ }
801
+ }
802
+ } else {
803
+ addInlineDescendants(blockEl, blockTarget);
804
+ }
805
+ }
806
+
807
+ return {
808
+ targets: targets,
809
+ blocks: blocks,
810
+ byElement: byElement
811
+ };
812
+ }
813
+
814
+ function semanticChildren(graph, target) {
815
+ return graph.targets.filter(function(candidate) {
816
+ return candidate.parentKey === target.key;
817
+ });
818
+ }
819
+
820
+ function semanticParent(graph, target) {
821
+ if (!target || !target.parentKey) return null;
822
+ for (var i = 0; i < graph.targets.length; i++) {
823
+ if (graph.targets[i].key === target.parentKey) return graph.targets[i];
824
+ }
825
+ return null;
826
+ }
827
+
828
+ function semanticSibling(graph, target, delta) {
829
+ var parent = semanticParent(graph, target);
830
+ if (!parent) return target;
831
+ var siblings = semanticChildren(graph, parent);
832
+ var index = siblings.indexOf(target);
833
+ if (index < 0) return target;
834
+ var nextIndex = Math.max(0, Math.min(siblings.length - 1, index + delta));
835
+ return siblings[nextIndex] || target;
836
+ }
837
+
838
+ function semanticOwningBlock(graph, target) {
839
+ var current = target;
840
+ while (current && graph.blocks.indexOf(current) < 0) {
841
+ current = semanticParent(graph, current);
842
+ }
843
+ return current || target;
844
+ }
845
+
846
+ function resolveSemanticTarget(graph, node) {
847
+ var el = node;
848
+ while (el && el.nodeType === 3) el = el.parentNode;
849
+ if (
850
+ !el
851
+ || el.nodeType !== 1
852
+ || el === document.documentElement
853
+ || el === document.body
854
+ || el.closest(PINPOINT_SKIP_SELECTOR)
855
+ ) return null;
856
+
857
+ if (el.ownerSVGElement && el.closest) {
858
+ var svgGroup = el.closest('g');
859
+ if (svgGroup && graph.byElement.has(svgGroup)) return graph.byElement.get(svgGroup);
860
+ }
861
+
862
+ var inline = el.closest && el.closest(SEMANTIC_INLINE_SELECTOR);
863
+ if (inline && graph.byElement.has(inline)) return graph.byElement.get(inline);
864
+ var cell = el.closest && el.closest('td,th');
865
+ if (cell && graph.byElement.has(cell)) return graph.byElement.get(cell);
866
+
867
+ var current = el;
868
+ while (current && current !== document.body) {
869
+ if (graph.byElement.has(current)) return graph.byElement.get(current);
870
+ current = current.parentElement;
871
+ }
872
+ return null;
873
+ }
874
+
875
+ // Floating label naming the element under the cursor (like the markdown overlay).
876
+ var PINPOINT_LABELS = { H1:'Heading', H2:'Heading', H3:'Heading', H4:'Heading', H5:'Heading', H6:'Heading', P:'Paragraph', UL:'List', OL:'List', LI:'List item', A:'Link', BUTTON:'Button', IMG:'Image', TABLE:'Table', THEAD:'Table', TBODY:'Table', TR:'Row', TD:'Cell', TH:'Header cell', SECTION:'Section', NAV:'Navigation', HEADER:'Header', FOOTER:'Footer', ARTICLE:'Article', ASIDE:'Sidebar', BLOCKQUOTE:'Quote', PRE:'Code', CODE:'Code', FIGURE:'Figure', FIGCAPTION:'Caption', MAIN:'Main', FORM:'Form', INPUT:'Input', LABEL:'Label' };
877
+
878
+ var MAX_HOVER_LABEL = 40;
879
+ function truncateLabel(text) {
880
+ return text.length > MAX_HOVER_LABEL ? text.slice(0, MAX_HOVER_LABEL) : text;
881
+ }
882
+
883
+ // First 1-2 meaningful class tokens: split on whitespace/underscore/hyphen,
884
+ // drop tokens of 2 chars or fewer and CSS-module-hash-looking tokens, so a
885
+ // div.rowchip labels "rowchip" and styles_Card_ab12f labels "Card".
886
+ function meaningfulClassTokens(el) {
887
+ var out = [];
888
+ if (!el.classList || !el.classList.length) return out;
889
+ for (var i = 0; i < el.classList.length && out.length < 2; i++) {
890
+ var cls = el.classList[i];
891
+ if (isLikelyGeneratedClass(cls)) continue;
892
+ var parts = String(cls).split(/[\s_-]+/);
893
+ for (var j = 0; j < parts.length && out.length < 2; j++) {
894
+ var token = parts[j];
895
+ if (token.length <= 2) continue;
896
+ if (/^[a-fA-F0-9]+$/.test(token) && token.length >= 5) continue;
897
+ if (/[0-9]{4,}/.test(token)) continue;
898
+ out.push(token);
899
+ }
900
+ }
901
+ return out;
902
+ }
903
+
904
+ // Label cascade for generic containers (div/span/custom elements):
905
+ // aria-label -> role -> meaningful class tokens -> own short text ->
906
+ // "container". Known semantic tags keep their human names above.
907
+ function pinpointHoverLabel(el) {
908
+ var known = PINPOINT_LABELS[el.tagName];
909
+ if (known) return known;
910
+ var aria = el.getAttribute && el.getAttribute('aria-label');
911
+ if (aria && aria.trim()) return truncateLabel(aria.trim());
912
+ var role = el.getAttribute && el.getAttribute('role');
913
+ if (role && role.trim()) return truncateLabel(role.trim());
914
+ var tokens = meaningfulClassTokens(el);
915
+ if (tokens.length) return truncateLabel(tokens.join(' '));
916
+ var text = (el.textContent || '').replace(/\s+/g, ' ').trim();
917
+ if (text && text.length <= MAX_HOVER_LABEL) return text;
918
+ return 'container';
919
+ }
920
+
921
+ var pinpointLabelEl = null;
922
+ function getPinpointLabelEl() {
923
+ if (!pinpointLabelEl) {
924
+ pinpointLabelEl = document.createElement('div');
925
+ pinpointLabelEl.setAttribute('data-plannotator-pinpoint-label', '');
926
+ pinpointLabelEl.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;display:none;font:600 11px/1.3 system-ui,-apple-system,sans-serif;padding:2px 7px;border-radius:5px;background:var(--pn-focus-highlight,#4493f8);color:#fff;white-space:nowrap;box-shadow:0 1px 5px rgba(0,0,0,.35);';
927
+ overlayNodes.add(pinpointLabelEl);
928
+ }
929
+ // Paint order at equal z-index follows DOM order: the label must sit
930
+ // AFTER the marker overlay host on the root element or marker bubbles
931
+ // occlude it. Re-append whenever it is not the last child (appendChild
932
+ // moves an already-connected node).
933
+ var labelRoot = document.documentElement || document.body;
934
+ if (labelRoot && (!pinpointLabelEl.isConnected || pinpointLabelEl.nextSibling)) {
935
+ labelRoot.appendChild(pinpointLabelEl);
936
+ }
937
+ return pinpointLabelEl;
938
+ }
939
+ function hidePinpointLabel() { if (pinpointLabelEl) pinpointLabelEl.style.display = 'none'; }
940
+
941
+ // Hover outline box: fixed-position, pointer-events none, sized to the
942
+ // hovered element's live rect. The page DOM is never touched for hover.
943
+ var pinpointBoxEl = null;
944
+ function getPinpointBoxEl() {
945
+ if (!pinpointBoxEl) {
946
+ pinpointBoxEl = document.createElement('div');
947
+ pinpointBoxEl.setAttribute('data-plannotator-pinpoint-box', '');
948
+ pinpointBoxEl.setAttribute('data-plannotator-vim-ui', '');
949
+ overlayNodes.add(pinpointBoxEl);
950
+ }
951
+ if (!pinpointBoxEl.isConnected) document.body.appendChild(pinpointBoxEl);
952
+ return pinpointBoxEl;
953
+ }
954
+ function positionPinpointBox(el) {
955
+ var r = el.getBoundingClientRect();
956
+ var box = getPinpointBoxEl();
957
+ box.style.display = 'block';
958
+ box.style.left = r.left + 'px';
959
+ box.style.top = r.top + 'px';
960
+ box.style.width = r.width + 'px';
961
+ box.style.height = r.height + 'px';
962
+ }
963
+ function hidePinpointBox() {
964
+ if (pinpointBoxEl) {
965
+ pinpointBoxEl.style.display = 'none';
966
+ pinpointBoxEl.removeAttribute('data-pinned');
967
+ pinpointBoxEl.classList.remove('pn-pin-enter');
968
+ }
969
+ }
970
+ function clearPinpointHover() {
971
+ pinpointHover = null;
972
+ if (!pendingPinEl) hidePinpointBox();
973
+ hidePinpointLabel();
974
+ }
975
+ function positionPinpointLabel(el, labelText) {
976
+ var r = el.getBoundingClientRect();
977
+ var lbl = getPinpointLabelEl();
978
+ lbl.textContent = labelText;
979
+ lbl.style.display = 'block';
980
+ // Flip below the element when the pill would leave the viewport top.
981
+ var top = r.top - 22;
982
+ if (top < 2) top = Math.min(r.bottom + 4, window.innerHeight - 24);
983
+ lbl.style.top = top + 'px';
984
+ lbl.style.left = Math.max(2, Math.min(r.left, window.innerWidth - 120)) + 'px';
985
+ }
986
+
987
+ // Identity-gated hover update (only restyle when the resolved element
988
+ // changes); shared by mousemove and the scroll/resize reconcile pass.
989
+ // Per-event hit-testing only — never builds the semantic graph.
990
+ function updatePinpointHover(x, y, fallbackNode) {
991
+ var el = pinpointLeafAt(x, y, fallbackNode);
992
+ // The 25px marker bubble owns clicks over it (they select its comment),
993
+ // so hover must advertise the MARKER's own identity — no annotate box,
994
+ // label "Comment N" — never the element beneath. Moving off the bubble
995
+ // restores normal element hover/annotate.
996
+ if (lastRawHitMarker && !pendingPinEl) {
997
+ var markerLabel = lastRawHitMarker.getAttribute('aria-label') || 'Comment';
998
+ pinpointHover = null;
999
+ hidePinpointBox();
1000
+ positionPinpointLabel(lastRawHitMarker, markerLabel);
1001
+ return;
1002
+ }
1003
+ if (el !== pinpointHover) {
1004
+ pinpointHover = el;
1005
+ if (el && !pendingPinEl) {
1006
+ var box = getPinpointBoxEl();
1007
+ box.classList.remove('pn-pin-enter');
1008
+ void box.offsetWidth; // restart the enter animation
1009
+ box.classList.add('pn-pin-enter');
1010
+ positionPinpointBox(el);
1011
+ } else if (!pendingPinEl) {
1012
+ hidePinpointBox();
1013
+ }
1014
+ } else if (el && !pendingPinEl) {
1015
+ positionPinpointBox(el); // same element — keep the box glued while scrolling
1016
+ }
1017
+ if (!el || pendingPinEl) { hidePinpointLabel(); return; }
1018
+ positionPinpointLabel(el, pinpointHoverLabel(el));
1019
+ }
1020
+
1021
+ var lastPointer = null;
1022
+ document.addEventListener('mousemove', function(e) {
1023
+ lastPointer = { x: e.clientX, y: e.clientY };
1024
+ updateDragYield(e);
1025
+ if (!annotateModeActive) return; // Interact mode: no hover affordances
1026
+ if (currentInputMethod !== 'pinpoint') {
1027
+ // Click-to-select hover affordance (drag mode owns highlight clicks):
1028
+ // cheap cached-rect hit test, rAF-throttled, cleared while a draft or
1029
+ // pending selection owns the surface.
1030
+ if (!pendingPinEl && !pendingSelection && renderedCommittedRects.length) {
1031
+ scheduleHoverHitTest(e.clientX, e.clientY);
1032
+ } else {
1033
+ clearHoverHighlight();
1034
+ }
1035
+ return;
1036
+ }
1037
+ if (vimEnabled && vimPhase !== 'inactive') return;
1038
+ // Mid-drag (text selection in progress) the element hover box is noise:
1039
+ // the drag owns the surface until mouseup resolves it.
1040
+ if (dragYieldActive) { clearPinpointHover(); return; }
1041
+ // Hit-test at the pointer (e.target only backstops engines without
1042
+ // elementFromPoint) so the same code path serves the scroll re-hit-test.
1043
+ updatePinpointHover(e.clientX, e.clientY, e.target);
1044
+ if (pendingPinEl && multiSelectArmed) {
1045
+ // Composer yield needs the pointer even while it is inside this iframe;
1046
+ // the shift state rides along because the parent cannot observe
1047
+ // modifiers held while focus/pointer live in the sandbox.
1048
+ schedulePointerRelay(e.clientX, e.clientY, e.shiftKey);
1049
+ // Shift-hover preview: outline what the next shift-click would toggle.
1050
+ if (e.shiftKey) updateMultiHover(e.clientX, e.clientY, e.target);
1051
+ else hideMultiHoverBox();
1052
+ }
1053
+ });
1054
+
1055
+ // rAF-coalesced reconcile: keeps the hover box under a stationary cursor
1056
+ // while the page scrolls, tracks the pinned element, and repositions pin
1057
+ // badges. Capture-phase scroll so inner scroll containers count too.
1058
+ var pinpointReconcileRaf = 0;
1059
+ function schedulePinpointReconcile() {
1060
+ if (pinpointReconcileRaf) return;
1061
+ pinpointReconcileRaf = requestAnimationFrame(function() {
1062
+ pinpointReconcileRaf = 0;
1063
+ // Same point, possibly a different element after a scroll, resize, or
1064
+ // layout-changing mutation (the body ResizeObserver also lands here) —
1065
+ // the last-position cache must never answer the next probe.
1066
+ invalidatePointerHitCache();
1067
+ renderAnnotationOverlay();
1068
+ // Scroll under a stationary pointer moves the committed rects: re-run
1069
+ // the cached-rect hover test so the affordance tracks reality.
1070
+ if (annotateModeActive && currentInputMethod !== 'pinpoint' && lastPointer && !pendingPinEl && !pendingSelection) {
1071
+ setHoverHighlight(committedHighlightIdAtCached(lastPointer.x, lastPointer.y));
1072
+ }
1073
+ positionMultiTargetBoxes();
1074
+ if (pendingPinEl && pendingPinEl.isConnected) {
1075
+ positionPinpointBox(pendingPinEl);
1076
+ return;
1077
+ }
1078
+ if (!annotateModeActive || currentInputMethod !== 'pinpoint') return;
1079
+ if (vimEnabled && vimPhase !== 'inactive') return;
1080
+ if (lastPointer) {
1081
+ updatePinpointHover(lastPointer.x, lastPointer.y, pinpointHover);
1082
+ }
1083
+ });
1084
+ }
1085
+ window.addEventListener('scroll', schedulePinpointReconcile, { passive: true, capture: true });
1086
+ window.addEventListener('resize', schedulePinpointReconcile, { passive: true });
1087
+
1088
+ // --- Annotation overlay: anchors are data, markers are projections ---
1089
+ // Committed annotations never write into the visited page's DOM. Durable
1090
+ // data (anchor selectors, original text, normalized selected point) is
1091
+ // re-resolved to live targets on demand and projected as disposable
1092
+ // artifacts — highlight rectangles and numbered placed-marker buttons —
1093
+ // into a fixed, pointer-transparent, shadow-rooted overlay host appended
1094
+ // to the root element (outside <body>, outside page layout). Only marker
1095
+ // buttons accept pointer input; everything else is hit-transparent.
1096
+ var MARKER_EDGE_INSET = 29; // full marker stays reachable at viewport edges
1097
+ var MARKER_SPREAD_STEP = 12.5; // horizontal step for coincident markers
1098
+ var MARKER_SPREAD_EDGE = 28.5; // effective edge clamp while spreading
1099
+ var MARKER_ASSOC_TOLERANCE = 16; // clamped point must stay this close to the visible target
1100
+ var MAX_HIGHLIGHT_RECTS = 48; // highlight rects drawn per range target
1101
+ var MAX_SYNC_ANNOTATIONS = 512; // parent-synced numbering entries
1102
+
1103
+ // Style isolation only (not a security boundary): the shadow root keeps the
1104
+ // page's CSS off the overlay and the overlay's CSS off the page.
1105
+ var OVERLAY_CSS = [
1106
+ '.pn-layer { position: fixed; inset: 0; pointer-events: none; }',
1107
+ '.pn-hl { position: fixed; pointer-events: none; border-radius: 2px; box-sizing: border-box; }',
1108
+ '.pn-hl-comment { background: oklch(0.70 0.18 60 / 0.28); border-bottom: 2px solid var(--pn-accent, #d97757); }',
1109
+ '.pn-hl-deletion { background: oklch(from var(--pn-destructive, #c0392b) l c h / 0.28); background-image: linear-gradient(to bottom, transparent calc(50% - 1px), var(--pn-destructive, #c0392b) calc(50% - 1px), var(--pn-destructive, #c0392b) calc(50% + 1px), transparent calc(50% + 1px)); }',
1110
+ '.pn-hl-focus { background: oklch(from var(--pn-focus-highlight, #4493f8) l c h / 0.35); box-shadow: 0 0 8px oklch(from var(--pn-focus-highlight, #4493f8) l c h / 0.4); }',
1111
+ '.pn-hl-draft { background: oklch(from var(--pn-focus-highlight, #4493f8) l c h / 0.22); }',
1112
+ '.pn-hl-hover { filter: brightness(1.25) saturate(1.1); }',
1113
+ '.pn-marker { position: fixed; width: 25px; height: 25px; border: 0; padding: 0; margin: 0; background: transparent; transform: translate(-50%, -50%); pointer-events: auto; cursor: pointer; display: flex; align-items: center; justify-content: center; animation: pn-marker-in 0.2s ease-out both; }',
1114
+ '.pn-marker[data-selected="true"] { transform: translate(-50%, -50%) scale(1.08); }',
1115
+ '.pn-marker-icon { pointer-events: none; display: block; position: absolute; top: 0; left: 0; width: 100%; height: 100%; filter: drop-shadow(0 1px 3px rgba(0,0,0,.3)); }',
1116
+ '.pn-marker-icon path { fill: var(--pn-accent, #d97757); stroke: #fff; stroke-width: 1.65; }',
1117
+ '.pn-marker-num { pointer-events: none; position: relative; transform: translate(-0.5px, -1.5px); color: #fff; font: 700 10px/1 system-ui, -apple-system, sans-serif; user-select: none; }',
1118
+ '@keyframes pn-marker-in { from { opacity: 0; } to { opacity: 1; } }',
1119
+ '@media (prefers-reduced-motion: reduce) { .pn-marker { animation: none; } }',
1120
+ ':host([data-pn-hittest]) .pn-marker, [data-plannotator-overlay-host][data-pn-hittest] .pn-marker { pointer-events: none !important; }',
1121
+ '@media print { .pn-layer { display: none !important; } }'
1122
+ ].join('\n');
1123
+
1124
+ // Product-owned speech-bubble marker (26x25 box, accent fill, white stroke).
1125
+ var MARKER_SVG = '<svg class="pn-marker-icon" viewBox="0 0 26 25" aria-hidden="true" focusable="false"><path d="M13 1.1C6.55 1.1 1.4 5.83 1.4 11.62c0 3.62 2.02 6.8 5.08 8.68l-0.85 3.5 4.28-2.02c1.01 0.25 2.05 0.38 3.09 0.38 6.45 0 11.6-4.73 11.6-10.54C24.6 5.83 19.45 1.1 13 1.1Z"/></svg>';
1126
+
1127
+ var overlayHostEl = null;
1128
+ var overlayRootEl = null; // shadow root, or the host itself when unavailable
1129
+ var highlightsLayerEl = null;
1130
+ var markersLayerEl = null;
1131
+
1132
+ function ensureOverlayHost() {
1133
+ if (!overlayHostEl) {
1134
+ overlayHostEl = document.createElement('div');
1135
+ overlayHostEl.setAttribute('data-plannotator-overlay-host', '');
1136
+ var hostStyle = overlayHostEl.style;
1137
+ hostStyle.setProperty('position', 'fixed', 'important');
1138
+ hostStyle.setProperty('top', '0', 'important');
1139
+ hostStyle.setProperty('left', '0', 'important');
1140
+ hostStyle.setProperty('right', '0', 'important');
1141
+ hostStyle.setProperty('bottom', '0', 'important');
1142
+ hostStyle.setProperty('z-index', '2147483647', 'important');
1143
+ hostStyle.setProperty('pointer-events', 'none', 'important');
1144
+ overlayNodes.add(overlayHostEl);
1145
+ var root = overlayHostEl;
1146
+ if (overlayHostEl.attachShadow) {
1147
+ try { root = overlayHostEl.attachShadow({ mode: 'open' }); } catch (ex) {}
1148
+ }
1149
+ overlayRootEl = root;
1150
+ var style = document.createElement('style');
1151
+ style.textContent = OVERLAY_CSS;
1152
+ root.appendChild(style);
1153
+ highlightsLayerEl = document.createElement('div');
1154
+ highlightsLayerEl.className = 'pn-layer';
1155
+ highlightsLayerEl.setAttribute('data-pn-highlights', '');
1156
+ root.appendChild(highlightsLayerEl);
1157
+ markersLayerEl = document.createElement('div');
1158
+ markersLayerEl.className = 'pn-layer';
1159
+ markersLayerEl.setAttribute('data-pn-markers', '');
1160
+ root.appendChild(markersLayerEl);
1161
+ }
1162
+ // Host lives on the root element, not in <body>: the page's own layout
1163
+ // never contains or reflows around it, and the body MutationObserver
1164
+ // never sees overlay writes.
1165
+ if (!overlayHostEl.isConnected) {
1166
+ (document.documentElement || document.body).appendChild(overlayHostEl);
1167
+ // Keep the pinpoint hover label painting ABOVE markers: it must stay
1168
+ // after the host in DOM order (equal z-index resolves by paint order).
1169
+ if (pinpointLabelEl && pinpointLabelEl.isConnected) {
1170
+ (document.documentElement || document.body).appendChild(pinpointLabelEl);
1171
+ }
1172
+ }
1173
+ return overlayHostEl;
1174
+ }
1175
+
1176
+ // Hit-test yielding: marker buttons accept pointer input, so a bare
1177
+ // elementFromPoint over one would resolve overlay chrome instead of the
1178
+ // page. Temporarily disable marker hit targets so probes reach beneath.
1179
+ // Restores (never clears) the attribute: the drag-selection yield below
1180
+ // holds the same attribute across events and must survive a probe.
1181
+ function withMarkersYielded(fn) {
1182
+ if (!overlayHostEl || !overlayHostEl.isConnected) return fn();
1183
+ var alreadyYielded = overlayHostEl.hasAttribute('data-pn-hittest');
1184
+ overlayHostEl.setAttribute('data-pn-hittest', '');
1185
+ try {
1186
+ return fn();
1187
+ } finally {
1188
+ if (!alreadyYielded) overlayHostEl.removeAttribute('data-pn-hittest');
1189
+ }
1190
+ }
1191
+
1192
+ // Drag-selection marker yield: while a text drag is in progress, placed
1193
+ // markers drop pointer input (the same data-pn-hittest CSS the hit-test
1194
+ // yield uses) so a 25px bubble sitting over the text cannot capture the
1195
+ // selection mid-drag. Armed only by a >4px move with the primary button
1196
+ // held from a non-overlay mousedown, so marker clicks (mousedown ON the
1197
+ // marker) and plain click-to-select (no drag) are untouched; disarmed on
1198
+ // mouseup or when the button is seen released. The same >4px arming is
1199
+ // what tells the mouseup handler below that a REAL drag happened, which
1200
+ // is how drag-selection commenting stays live in pinpoint/armed mode
1201
+ // without a plain pinpoint click re-posting its own selection.
1202
+ var dragYieldStart = null;
1203
+ var dragYieldActive = false;
1204
+ // True between a drag's terminating mouseup and the next mousedown: the
1205
+ // click event that follows a completed drag must not pinpoint-annotate.
1206
+ var dragEndedClick = false;
1207
+ function endDragYield() {
1208
+ dragYieldStart = null;
1209
+ if (dragYieldActive) {
1210
+ dragYieldActive = false;
1211
+ if (overlayHostEl) overlayHostEl.removeAttribute('data-pn-hittest');
1212
+ }
1213
+ }
1214
+ function updateDragYield(e) {
1215
+ if (!dragYieldStart) return;
1216
+ if (typeof e.buttons === 'number' && !(e.buttons & 1)) {
1217
+ endDragYield(); // missed mouseup (released outside the iframe)
1218
+ return;
1219
+ }
1220
+ if (
1221
+ !dragYieldActive
1222
+ && (Math.abs(e.clientX - dragYieldStart.x) > 4 || Math.abs(e.clientY - dragYieldStart.y) > 4)
1223
+ ) {
1224
+ dragYieldActive = true;
1225
+ if (overlayHostEl && overlayHostEl.isConnected) {
1226
+ overlayHostEl.setAttribute('data-pn-hittest', '');
1227
+ }
1228
+ }
1229
+ }
1230
+ document.addEventListener('mousedown', function(e) {
1231
+ dragEndedClick = false;
1232
+ if (e.button !== 0) return;
1233
+ if (isViewerOverlayNode(e.target)) return;
1234
+ dragYieldStart = { x: e.clientX, y: e.clientY };
1235
+ }, true);
1236
+ document.addEventListener('mouseup', function() {
1237
+ // Text drag-selection commenting is ALWAYS live: both surfaces, armed or
1238
+ // Interact. In armed pinpoint a plain click belongs to the pinpoint
1239
+ // handler — only a drag that actually PRODUCED a text selection owns its
1240
+ // trailing click, so annotateElement's own selection is never re-posted
1241
+ // by its trailing mouseup. The >4px drift alone is NOT enough to arm the
1242
+ // click suppression: a drifted click (common on trackpads) that selected
1243
+ // nothing must stay a click, or armed pinpoint would silently swallow it
1244
+ // while the unprevented click leaks through to the page. Browsers clear
1245
+ // a prior selection on the mousedown that starts the drag, so the
1246
+ // selection observed here is the one THIS drag produced. Everywhere else
1247
+ // the classic always-schedule behavior stays: handleSelection only acts
1248
+ // on a real selection, posts the clear that dismisses a stale draft, and
1249
+ // never preventDefaults — a plain click is never swallowed.
1250
+ var dragged = dragYieldActive;
1251
+ endDragYield();
1252
+ var draggedSelection = false;
1253
+ if (dragged) {
1254
+ try {
1255
+ var s = window.getSelection();
1256
+ draggedSelection = !!(s && !s.isCollapsed && s.rangeCount && (s.toString() || '').trim());
1257
+ } catch (ex) {}
1258
+ }
1259
+ dragEndedClick = draggedSelection;
1260
+ if (annotateModeActive && currentInputMethod === 'pinpoint' && !draggedSelection) return;
1261
+ setTimeout(handleSelection, 10);
1262
+ }, true);
1263
+
1264
+ // One record per committed annotation. Its targets are live projections;
1265
+ // its params are the durable data they re-resolve from after mutations.
1266
+ var annRecords = []; // { id, annType, params: { originalText, anchor, additionalAnchors }, targets }
1267
+ var annNumbers = null; // Map(id -> display number) synced from the parent's ordered collection
1268
+ var focusedAnnotationId = null;
1269
+ var focusFlashTimer = 0;
1270
+ var markerButtons = new Map(); // "id::targetIndex" -> <button>
1271
+
1272
+ function findAnnRecord(id) {
1273
+ for (var i = 0; i < annRecords.length; i++) {
1274
+ if (annRecords[i].id === id) return annRecords[i];
1275
+ }
1276
+ return null;
1277
+ }
1278
+
1279
+ function ensureAnnRecord(id, annType, params) {
1280
+ var existing = findAnnRecord(id);
1281
+ if (existing) return existing;
1282
+ var record = { id: id, annType: annType || 'comment', params: params || null, targets: [] };
1283
+ annRecords.push(record);
1284
+ return record;
1285
+ }
1286
+
1287
+ function removeAnnRecord(id) {
1288
+ for (var i = annRecords.length - 1; i >= 0; i--) {
1289
+ if (annRecords[i].id === id) annRecords.splice(i, 1);
1290
+ }
1291
+ restoreFailedIds['delete'](id);
1292
+ if (focusedAnnotationId === id) focusedAnnotationId = null;
1293
+ }
1294
+
1295
+ // Fail-closed transparency (host ask): markers for dead targets are
1296
+ // omitted, never guessed — this names WHICH annotations currently have no
1297
+ // live representation on the page (every target dead, or the restore never
1298
+ // resolved anything) so the host can tell the user instead of letting them
1299
+ // silently vanish. Emitted only when the set changes, and only from
1300
+ // complete overlay passes — budget-starved passes reschedule themselves
1301
+ // and would flap the set. restoreFailedIds carries total restore failures,
1302
+ // whose records are removed and therefore invisible to the per-pass scan.
1303
+ var lastUnanchoredKey = '[]';
1304
+ var restoreFailedIds = new Set();
1305
+ // Set by report-unanchored: the parent asks for the complete set after
1306
+ // its restore batch, so the next COMPLETE pass emits even when the set
1307
+ // did not change (an all-restored document reports its empty set once).
1308
+ var unanchoredReportRequested = false;
1309
+ function emitUnanchored(deadRecordIds) {
1310
+ var seen = new Set();
1311
+ var combined = [];
1312
+ for (var deadIndex = 0; deadIndex < deadRecordIds.length; deadIndex++) {
1313
+ if (!seen.has(deadRecordIds[deadIndex])) {
1314
+ seen.add(deadRecordIds[deadIndex]);
1315
+ combined.push(deadRecordIds[deadIndex]);
1316
+ }
1317
+ }
1318
+ restoreFailedIds.forEach(function(failedId) {
1319
+ if (!seen.has(failedId)) {
1320
+ seen.add(failedId);
1321
+ combined.push(failedId);
1322
+ }
1323
+ });
1324
+ combined.sort();
1325
+ if (combined.length > 512) combined = combined.slice(0, 512);
1326
+ var key = JSON.stringify(combined);
1327
+ if (key === lastUnanchoredKey && !unanchoredReportRequested) return;
1328
+ unanchoredReportRequested = false;
1329
+ lastUnanchoredKey = key;
1330
+ // postToParent, not a raw '*' post: live sessions stamp the session
1331
+ // token and post only to the listed editor origins, and the parent
1332
+ // drops untokened live messages — a raw post would silently disable
1333
+ // unanchored reporting exactly where restores fail most (live pages).
1334
+ postToParent({ type: PREFIX + 'unanchored', ids: combined });
1335
+ }
1336
+
1337
+ function validNormalizedPoint(p) {
1338
+ if (!p || typeof p.x !== 'number' || typeof p.y !== 'number') return null;
1339
+ if (!isFinite(p.x) || !isFinite(p.y)) return null;
1340
+ return { x: Math.max(0, Math.min(1, p.x)), y: Math.max(0, Math.min(1, p.y)) };
1341
+ }
1342
+
1343
+ function normalizedPointOf(anchor, point) {
1344
+ return validNormalizedPoint(point) || (anchor ? validNormalizedPoint(anchor.point) : null);
1345
+ }
1346
+
1347
+ function addElementTarget(record, element, anchor, point) {
1348
+ if (!element) return false;
1349
+ // One annotation may cover several elements (multi-select): dedup by
1350
+ // (id, element) and (id, anchor) so a re-resolved anchor can't
1351
+ // double-mark the same logical target — never by id alone.
1352
+ for (var i = 0; i < record.targets.length; i++) {
1353
+ var t = record.targets[i];
1354
+ if (t.kind !== 'element') continue;
1355
+ if (t.element === element) return false;
1356
+ if (anchor && anchorsEqual(t.anchor, anchor)) return false;
1357
+ }
1358
+ record.targets.push({
1359
+ kind: 'element',
1360
+ element: element,
1361
+ anchor: anchor || null,
1362
+ point: normalizedPointOf(anchor, point)
1363
+ });
1364
+ return true;
1365
+ }
1366
+
1367
+ function addRangeTarget(record, range, text, markerless) {
1368
+ if (!range) return false;
1369
+ record.targets.push({ kind: 'range', range: range, text: text || '', markerless: !!markerless });
1370
+ return true;
1371
+ }
1372
+
1373
+ function rangeAlive(range) {
1374
+ if (!range) return false;
1375
+ try {
1376
+ var s = range.startContainer;
1377
+ var e = range.endContainer;
1378
+ if (!s || !e) return false;
1379
+ if (typeof s.isConnected === 'boolean' && !s.isConnected) return false;
1380
+ if (typeof e.isConnected === 'boolean' && !e.isConnected) return false;
1381
+ return true;
1382
+ } catch (ex) {
1383
+ return false;
1384
+ }
1385
+ }
1386
+
1387
+ // Resolve one contiguous document Range for the first occurrence of text.
1388
+ // Overlay-owned text (labels, vim chrome) is skipped by IDENTITY so viewer
1389
+ // chrome can never satisfy a page-text search.
1390
+ function findTextRange(text, root) {
1391
+ var scope = root || document.body;
1392
+ if (!text || !scope) return null;
1393
+ var walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT, null);
1394
+ var buffer = '';
1395
+ var nodes = [];
1396
+ while (walker.nextNode()) {
1397
+ var current = walker.currentNode;
1398
+ if (current.parentElement && isViewerOverlayNode(current.parentElement)) continue;
1399
+ nodes.push({ node: current, start: buffer.length });
1400
+ buffer += current.textContent;
1401
+ }
1402
+ var idx = buffer.indexOf(text);
1403
+ if (idx === -1) return null;
1404
+ var endIdx = idx + text.length;
1405
+ var startEntry = null;
1406
+ var endEntry = null;
1407
+ for (var i = 0; i < nodes.length; i++) {
1408
+ var entry = nodes[i];
1409
+ var nodeEnd = entry.start + entry.node.length;
1410
+ if (!startEntry && idx < nodeEnd) startEntry = entry;
1411
+ if (endIdx <= nodeEnd) { endEntry = entry; break; }
1412
+ }
1413
+ if (!startEntry || !endEntry) return null;
1414
+ try {
1415
+ var range = document.createRange();
1416
+ range.setStart(startEntry.node, idx - startEntry.start);
1417
+ range.setEnd(endEntry.node, endIdx - endEntry.start);
1418
+ return range;
1419
+ } catch (ex) {
1420
+ return null;
1421
+ }
1422
+ }
1423
+
1424
+ /**
1425
+ * Restore ladder (fail-closed, matches the old find-and-mark contract):
1426
+ * resolved SVG anchors are element targets; a resolved anchor scopes the
1427
+ * text search (element marker + markerless highlight range); the
1428
+ * document-wide text search runs BEFORE the element fallback so text that
1429
+ * moved elsewhere is followed rather than pinning a stale container; a
1430
+ * resolved element whose text is gone everywhere is the last resort.
1431
+ * Additional anchors restore anchor-only — a stale anchor simply doesn't
1432
+ * restore.
1433
+ */
1434
+ function restoreAnnotation(id, annType, originalText, anchor, additionalAnchors) {
1435
+ removeAnnRecord(id); // a fresh restore is authoritative for its id
1436
+ var record = ensureAnnRecord(id, annType, {
1437
+ originalText: originalText || '',
1438
+ anchor: anchor || null,
1439
+ additionalAnchors: additionalAnchors || null
1440
+ });
1441
+ var found = false;
1442
+ var anchorEl = resolveAnchorElement(anchor);
1443
+ var isSvgAnchor = anchorEl && (anchorEl.ownerSVGElement || anchorEl.tagName.toLowerCase() === 'svg');
1444
+ if (isSvgAnchor) {
1445
+ found = addElementTarget(record, anchorEl, anchor, null);
1446
+ } else if (anchorEl) {
1447
+ var scopedRange = findTextRange(originalText, anchorEl);
1448
+ if (scopedRange) {
1449
+ // Pinpoint-with-text: the element owns the placed marker (at the
1450
+ // stored selected point); the scoped range paints the highlight.
1451
+ found = addElementTarget(record, anchorEl, anchor, null);
1452
+ addRangeTarget(record, scopedRange, originalText, true);
1453
+ }
1454
+ }
1455
+ if (!found) {
1456
+ var docRange = findTextRange(originalText, null);
1457
+ if (docRange) {
1458
+ addRangeTarget(record, docRange, originalText, false);
1459
+ found = true;
1460
+ }
1461
+ }
1462
+ if (!found && anchorEl) {
1463
+ found = addElementTarget(record, anchorEl, anchor, null);
1464
+ }
1465
+ if (additionalAnchors && additionalAnchors.length) {
1466
+ var extraCount = Math.min(additionalAnchors.length, MAX_MULTI_TARGETS);
1467
+ for (var extraIndex = 0; extraIndex < extraCount; extraIndex++) {
1468
+ var extraEl = resolveAnchorElement(additionalAnchors[extraIndex]);
1469
+ if (extraEl && addElementTarget(record, extraEl, additionalAnchors[extraIndex], null)) {
1470
+ found = true;
1471
+ }
1472
+ }
1473
+ }
1474
+ if (!record.targets.length && LIVE) {
1475
+ // Live pages render late (lazy routes, data-dependent trees): a
1476
+ // restore that resolves nothing keeps its record, seeded with
1477
+ // unresolved placeholder targets built from the durable params, so
1478
+ // the mutation-driven reconcile re-acquires them when the elements
1479
+ // appear. Srcdoc documents are static (nothing would ever
1480
+ // re-resolve), so the record is dropped below exactly as before.
1481
+ if (anchor) {
1482
+ record.targets.push({
1483
+ kind: 'element',
1484
+ element: null,
1485
+ anchor: anchor,
1486
+ point: normalizedPointOf(anchor, null)
1487
+ });
1488
+ }
1489
+ if (originalText) {
1490
+ record.targets.push({
1491
+ kind: 'range',
1492
+ range: null,
1493
+ text: originalText,
1494
+ markerless: !!anchor
1495
+ });
1496
+ }
1497
+ if (additionalAnchors && additionalAnchors.length) {
1498
+ var lateCount = Math.min(additionalAnchors.length, MAX_MULTI_TARGETS);
1499
+ for (var lateIndex = 0; lateIndex < lateCount; lateIndex++) {
1500
+ if (!additionalAnchors[lateIndex]) continue;
1501
+ record.targets.push({
1502
+ kind: 'element',
1503
+ element: null,
1504
+ anchor: additionalAnchors[lateIndex],
1505
+ point: normalizedPointOf(additionalAnchors[lateIndex], null)
1506
+ });
1507
+ }
1508
+ }
1509
+ }
1510
+ if (!record.targets.length) {
1511
+ // The record is removed (nothing to retry), so the per-pass dead scan
1512
+ // cannot see this id: track it separately for the unanchored report.
1513
+ // Live sessions only reach here when the durable params seeded no
1514
+ // placeholder targets at all (nothing will ever re-resolve).
1515
+ removeAnnRecord(id);
1516
+ restoreFailedIds.add(id);
1517
+ }
1518
+ // rAF-coalesced render (B3): restoring N annotations posts N
1519
+ // find-and-mark messages, and a synchronous render here made a batch
1520
+ // restore O(N^2) full overlay passes. The searches above stay
1521
+ // synchronous (the mark-applied reply depends on them); only the
1522
+ // projection is deferred, and one frame renders the whole batch.
1523
+ schedulePinpointReconcile();
1524
+ return found;
1525
+ }
1526
+
1527
+ // Unresolvable-target re-search is GENERATION-gated: findTextRange is a
1528
+ // whole-document TreeWalker sweep (and anchor re-resolution a document
1529
+ // query + text snapshot), while refreshRecordTargets runs on every
1530
+ // scroll/resize rAF and every body mutation. A page with one stale
1531
+ // annotation plus scrolling must not do O(document-text) work per frame
1532
+ // forever. The counter advances only on signals that can change text
1533
+ // (page mutations, settle events, frame loads); scroll and resize alone
1534
+ // never unlock a re-search — geometry changes, text doesn't.
1535
+ var domGeneration = 1;
1536
+
1537
+ function monotonicNow() {
1538
+ return typeof performance !== 'undefined' && performance.now
1539
+ ? performance.now()
1540
+ : Date.now();
1541
+ }
1542
+
1543
+ // Wall-clock backoff ON TOP of the generation gate: a page that mutates
1544
+ // styles/text once per frame advances domGeneration every frame, so the
1545
+ // generation gate alone would re-run the whole-document text search (or
1546
+ // anchor re-resolution) per frame forever for a permanently unresolvable
1547
+ // target. After each FAILED search a target waits for BOTH a new
1548
+ // generation AND its backoff (300ms, doubling to a 5s cap, reset on any
1549
+ // success) before it may search again.
1550
+ var DEAD_SEARCH_BACKOFF_MS = 300;
1551
+ var DEAD_SEARCH_BACKOFF_MAX_MS = 5000;
1552
+ // Per-reconcile-pass budget: at most this many dead-target searches run in
1553
+ // one pass, so many stale targets can never stack whole-document scans
1554
+ // into a single frame. When eligible targets are skipped for budget, a
1555
+ // follow-up pass is scheduled; each failure's >=300ms backoff then hands
1556
+ // the next pass's budget to different targets (round-robin by backoff).
1557
+ var MAX_DEAD_SEARCHES_PER_PASS = 2;
1558
+ var deadSearchBudget = MAX_DEAD_SEARCHES_PER_PASS;
1559
+ var deadSearchSkipped = false;
1560
+
1561
+ // budget: dead searches this pass may run. Per-frame reconcile passes use
1562
+ // the default (they repeat, so skipped targets get follow-up frames);
1563
+ // user-initiated one-shot passes (print, scroll-to) pass Infinity — they
1564
+ // have no follow-up, and the backoff + generation gates still bound them.
1565
+ function beginDeadSearchPass(budget) {
1566
+ deadSearchBudget = typeof budget === 'number' ? budget : MAX_DEAD_SEARCHES_PER_PASS;
1567
+ deadSearchSkipped = false;
1568
+ }
1569
+
1570
+ function deadSearchAllowed(target) {
1571
+ if (target.failedGeneration === domGeneration) return false;
1572
+ if (target.searchBackoffUntil && monotonicNow() < target.searchBackoffUntil) return false;
1573
+ if (deadSearchBudget <= 0) {
1574
+ deadSearchSkipped = true;
1575
+ return false;
1576
+ }
1577
+ return true;
1578
+ }
1579
+
1580
+ function noteDeadSearchResult(target, found) {
1581
+ deadSearchBudget -= 1;
1582
+ if (found) {
1583
+ target.failedGeneration = 0;
1584
+ target.searchBackoffMs = 0;
1585
+ target.searchBackoffUntil = 0;
1586
+ } else {
1587
+ target.failedGeneration = domGeneration;
1588
+ target.searchBackoffMs = target.searchBackoffMs
1589
+ ? Math.min(target.searchBackoffMs * 2, DEAD_SEARCH_BACKOFF_MAX_MS)
1590
+ : DEAD_SEARCH_BACKOFF_MS;
1591
+ target.searchBackoffUntil = monotonicNow() + target.searchBackoffMs;
1592
+ }
1593
+ }
1594
+
1595
+ // Re-resolve stale live targets from durable data. Element targets
1596
+ // re-acquire through their anchor; range targets re-run the text search
1597
+ // (anchor-scoped first). Unresolvable targets stay hidden — never guessed
1598
+ // — and each failed search is gated by the generation of its last attempt
1599
+ // PLUS the wall-clock backoff and per-pass budget above, so they retry
1600
+ // only after the document could actually have changed, and never per
1601
+ // frame.
1602
+ function refreshRecordTargets(record) {
1603
+ for (var i = 0; i < record.targets.length; i++) {
1604
+ var target = record.targets[i];
1605
+ if (target.kind === 'element') {
1606
+ if ((!target.element || !target.element.isConnected) && target.anchor) {
1607
+ if (!deadSearchAllowed(target)) continue;
1608
+ target.element = resolveAnchorElement(target.anchor);
1609
+ noteDeadSearchResult(target, !!target.element);
1610
+ }
1611
+ } else if (!rangeAlive(target.range)) {
1612
+ target.range = null;
1613
+ if (target.text) {
1614
+ if (!deadSearchAllowed(target)) continue;
1615
+ var scopeEl = record.params && record.params.anchor
1616
+ ? resolveAnchorElement(record.params.anchor)
1617
+ : null;
1618
+ target.range = (scopeEl && findTextRange(target.text, scopeEl))
1619
+ || findTextRange(target.text, null);
1620
+ noteDeadSearchResult(target, !!target.range);
1621
+ }
1622
+ }
1623
+ }
1624
+ }
1625
+
1626
+ function committedRangeClone(id) {
1627
+ var record = findAnnRecord(id);
1628
+ if (!record) return null;
1629
+ for (var i = 0; i < record.targets.length; i++) {
1630
+ var target = record.targets[i];
1631
+ if (target.kind === 'range' && rangeAlive(target.range)) {
1632
+ try { return target.range.cloneRange(); } catch (ex) {}
1633
+ }
1634
+ }
1635
+ return null;
1636
+ }
1637
+
1638
+ function buildPendingRange(selData) {
1639
+ try {
1640
+ var startNode = resolveNodePath(selData.startContainerPath);
1641
+ var endNode = resolveNodePath(selData.endContainerPath);
1642
+ if (!startNode || !endNode) return null;
1643
+ var range = document.createRange();
1644
+ range.setStart(startNode, selData.startOffset);
1645
+ range.setEnd(endNode, selData.endOffset);
1646
+ return range;
1647
+ } catch (ex) {
1648
+ return null;
1649
+ }
1650
+ }
1651
+
1652
+ function rectRight(r) { return typeof r.right === 'number' ? r.right : r.left + (r.width || 0); }
1653
+ function rectBottom(r) { return typeof r.bottom === 'number' ? r.bottom : r.top + (r.height || 0); }
1654
+
1655
+ // Ancestors that establish the containing block for FIXED-position
1656
+ // descendants (transform / perspective / filter / backdrop-filter /
1657
+ // will-change of those). Overflow clipping only applies to a fixed target
1658
+ // when the clipper is in its containing-block chain, so plain static
1659
+ // clippers must be skipped for fixed targets — a fully visible
1660
+ // position:fixed element inside an overflow:hidden ancestor would
1661
+ // otherwise be intersected away and omitted forever.
1662
+ function establishesFixedContainingBlock(style) {
1663
+ if (!style) return false;
1664
+ if (style.transform && style.transform !== 'none') return true;
1665
+ if (style.perspective && style.perspective !== 'none') return true;
1666
+ if (style.filter && style.filter !== 'none') return true;
1667
+ var backdrop = style.backdropFilter || style.webkitBackdropFilter || '';
1668
+ if (backdrop && backdrop !== 'none') return true;
1669
+ var willChange = style.willChange || '';
1670
+ if (
1671
+ willChange.indexOf('transform') >= 0
1672
+ || willChange.indexOf('perspective') >= 0
1673
+ || willChange.indexOf('filter') >= 0
1674
+ ) return true;
1675
+ var containValue = ' ' + (style.contain || '') + ' ';
1676
+ if (
1677
+ containValue.indexOf(' layout ') >= 0
1678
+ || containValue.indexOf(' paint ') >= 0
1679
+ || containValue.indexOf(' strict ') >= 0
1680
+ || containValue.indexOf(' content ') >= 0
1681
+ ) return true;
1682
+ var containerType = style.containerType || '';
1683
+ if (containerType === 'size' || containerType === 'inline-size') return true;
1684
+ return false;
1685
+ }
1686
+
1687
+ // Viewport-space clip window from the element's clipping/scroll ancestor
1688
+ // chain, or null when nothing clips. Shared by marker projection AND every
1689
+ // painted highlight rect: content an inner scroll container has scrolled
1690
+ // away must never paint stripes over unrelated content outside its box.
1691
+ function clipBoundsFor(el) {
1692
+ if (!layoutActive() || !el) return null;
1693
+ var left = -Infinity;
1694
+ var top = -Infinity;
1695
+ var right = Infinity;
1696
+ var bottom = Infinity;
1697
+ var clipped = false;
1698
+ var fixedSkip = false;
1699
+ try {
1700
+ var ownStyle = window.getComputedStyle(el);
1701
+ fixedSkip = !!ownStyle && ownStyle.position === 'fixed';
1702
+ } catch (exOwn) {}
1703
+ var current = el.parentElement;
1704
+ var guard = 0;
1705
+ while (
1706
+ current
1707
+ && current !== document.body
1708
+ && current !== document.documentElement
1709
+ && guard++ < 200
1710
+ ) {
1711
+ var style = null;
1712
+ try { style = window.getComputedStyle(current); } catch (ex) { break; }
1713
+ // Once the fixed target's containing block is reached, that ancestor
1714
+ // and everything above it clip normally (the containing block itself
1715
+ // participates in ordinary flow).
1716
+ if (fixedSkip && establishesFixedContainingBlock(style)) fixedSkip = false;
1717
+ var overflowX = (style && (style.overflowX || style.overflow)) || '';
1718
+ var overflowY = (style && (style.overflowY || style.overflow)) || '';
1719
+ var clipsX = overflowX && overflowX !== 'visible';
1720
+ var clipsY = overflowY && overflowY !== 'visible';
1721
+ if ((clipsX || clipsY) && !fixedSkip) {
1722
+ var cr = current.getBoundingClientRect();
1723
+ if (clipsX) { left = Math.max(left, cr.left); right = Math.min(right, rectRight(cr)); clipped = true; }
1724
+ if (clipsY) { top = Math.max(top, cr.top); bottom = Math.min(bottom, rectBottom(cr)); clipped = true; }
1725
+ }
1726
+ current = current.parentElement;
1727
+ }
1728
+ return clipped ? { left: left, top: top, right: right, bottom: bottom } : null;
1729
+ }
1730
+
1731
+ // Intersect one paintable rect with a clip window; null when nothing
1732
+ // visible survives (the rect is DROPPED, never painted degenerate).
1733
+ function clipRect(rect, bounds) {
1734
+ if (!bounds) return rect;
1735
+ var left = Math.max(rect.left, bounds.left);
1736
+ var top = Math.max(rect.top, bounds.top);
1737
+ var right = Math.min(rectRight(rect), bounds.right);
1738
+ var bottom = Math.min(rectBottom(rect), bounds.bottom);
1739
+ if (right <= left || bottom <= top) return null;
1740
+ return { left: left, top: top, right: right, bottom: bottom, width: right - left, height: bottom - top };
1741
+ }
1742
+
1743
+ // Early viewport cull (small margin): a target wholly outside the viewport
1744
+ // paints nothing and places no marker, so the per-frame style/clip reads it
1745
+ // would otherwise cost (getComputedStyle, the clip-chain ancestor walk,
1746
+ // client-rect collection) are skipped outright. Never applies without
1747
+ // layout (headless DOM tests report every rect as 0x0 at the origin).
1748
+ var VIEWPORT_CULL_MARGIN = 64;
1749
+ function rectOutsideViewport(rect) {
1750
+ if (!rect || !layoutActive()) return false;
1751
+ var vw = window.innerWidth;
1752
+ var vh = window.innerHeight;
1753
+ return rectBottom(rect) < -VIEWPORT_CULL_MARGIN
1754
+ || rect.top > vh + VIEWPORT_CULL_MARGIN
1755
+ || rectRight(rect) < -VIEWPORT_CULL_MARGIN
1756
+ || rect.left > vw + VIEWPORT_CULL_MARGIN;
1757
+ }
1758
+
1759
+ // Intersect a target rect with its clip chain for MARKER projection. May
1760
+ // return a degenerate rect — markerViewportPoint reads that as "no visible
1761
+ // intersection" and omits.
1762
+ function clippedTargetRect(el, rect) {
1763
+ var bounds = clipBoundsFor(el);
1764
+ if (!bounds) return rect;
1765
+ var left = Math.max(rect.left, bounds.left);
1766
+ var top = Math.max(rect.top, bounds.top);
1767
+ var right = Math.min(rectRight(rect), bounds.right);
1768
+ var bottom = Math.min(rectBottom(rect), bounds.bottom);
1769
+ return { left: left, top: top, right: right, bottom: bottom, width: right - left, height: bottom - top };
1770
+ }
1771
+
1772
+ // Unresolved-for-display: a target whose box exists but is invisible
1773
+ // (visibility:hidden/collapse, display:none) keeps a full-size rect, so
1774
+ // without this gate markers, focus rects, and highlights would render over
1775
+ // whatever visible content stacks in the same box (e.g. carousel slides
1776
+ // toggled via visibility). display:none targets already report empty
1777
+ // client rects, but visibility does not. Checked per reconcile frame, only
1778
+ // for targets that passed the cheaper gates.
1779
+ //
1780
+ // Deliberately NO opacity:0 leg: computed opacity does not inherit, so a
1781
+ // container faded to opacity:0 leaves descendants at computed 1 and would
1782
+ // not be caught anyway — while the legitimate invisible-hit-target pattern
1783
+ // (<input type=file style="opacity:0;position:absolute;inset:0"> over a
1784
+ // styled control) SHOULD keep its marker exactly over the visible control.
1785
+ function targetStyleHidden(el) {
1786
+ if (!el || !layoutActive()) return false;
1787
+ var style = null;
1788
+ try { style = window.getComputedStyle(el); } catch (ex) { return false; }
1789
+ if (!style) return false;
1790
+ if (style.visibility === 'hidden' || style.visibility === 'collapse') return true;
1791
+ if (style.display === 'none') return true;
1792
+ return false;
1793
+ }
1794
+
1795
+ /**
1796
+ * Project a raw marker point against the target's visible geometry:
1797
+ * omit when the target has no visible intersection with the viewport,
1798
+ * clamp so the full marker stays reachable at viewport edges, and omit
1799
+ * when the point is no longer visibly associated with the target
1800
+ * (clipped/scrolled away). Never guesses a position.
1801
+ */
1802
+ function markerViewportPoint(visRect, rawX, rawY) {
1803
+ if (!layoutActive()) return { x: rawX, y: rawY };
1804
+ var vw = window.innerWidth;
1805
+ var vh = window.innerHeight;
1806
+ var visLeft = Math.max(visRect.left, 0);
1807
+ var visTop = Math.max(visRect.top, 0);
1808
+ var visRight = Math.min(rectRight(visRect), vw);
1809
+ var visBottom = Math.min(rectBottom(visRect), vh);
1810
+ if (visRight <= visLeft || visBottom <= visTop) return null;
1811
+ // Association is tested against the UNCLAMPED point: viewport edges CLAMP
1812
+ // (never omit — a fully visible element flush against the edge keeps its
1813
+ // marker even though the 29px inset moves it inward), while clip/scroll
1814
+ // detachment — a raw point no longer near the visible target geometry —
1815
+ // omits. The clamp below is rendering-only.
1816
+ var dx = rawX < visLeft ? visLeft - rawX : rawX > visRight ? rawX - visRight : 0;
1817
+ var dy = rawY < visTop ? visTop - rawY : rawY > visBottom ? rawY - visBottom : 0;
1818
+ if (dx > MARKER_ASSOC_TOLERANCE || dy > MARKER_ASSOC_TOLERANCE) return null;
1819
+ var x = Math.max(MARKER_EDGE_INSET, Math.min(rawX, vw - MARKER_EDGE_INSET));
1820
+ var y = Math.max(MARKER_EDGE_INSET, Math.min(rawY, vh - MARKER_EDGE_INSET));
1821
+ return { x: x, y: y };
1822
+ }
1823
+
1824
+ // Containment filter: Range.getClientRects() returns BOTH a fully-contained
1825
+ // element's border box AND its line rects, so multi-paragraph selections
1826
+ // would double-paint stacked translucent rects (and redlines duplicate
1827
+ // strike lines). Drop any rect that strictly contains a smaller rect in
1828
+ // the same list.
1829
+ function dropContainerRects(rects) {
1830
+ if (rects.length < 2) return rects;
1831
+ return rects.filter(function(a) {
1832
+ var aArea = (a.width || 0) * (a.height || 0);
1833
+ for (var i = 0; i < rects.length; i++) {
1834
+ var b = rects[i];
1835
+ if (b === a) continue;
1836
+ var bArea = (b.width || 0) * (b.height || 0);
1837
+ if (bArea >= aArea) continue; // only a STRICTLY larger box is a container
1838
+ if (
1839
+ a.left <= b.left
1840
+ && a.top <= b.top
1841
+ && rectRight(a) >= rectRight(b)
1842
+ && rectBottom(a) >= rectBottom(b)
1843
+ ) return false;
1844
+ }
1845
+ return true;
1846
+ });
1847
+ }
1848
+
1849
+ // Client rects for a range, BOUNDED at the source: a huge drag-selection or
1850
+ // redline can yield thousands of client rects (only the selection TEXT is
1851
+ // capped, never the Range extent), and this runs per range target per rAF
1852
+ // reconcile and per click hit-test. Only the paintable prefix (at most
1853
+ // MAX_HIGHLIGHT_RECTS) is ever materialized — so the zero-size and
1854
+ // containment filters below operate on at most 48 entries — and the
1855
+ // marker's TRUE last rect (m12) is read directly by index off the live
1856
+ // DOMRectList, never by collecting the whole list.
1857
+ function rangeClientRects(range) {
1858
+ var out = [];
1859
+ var lastRaw = null;
1860
+ if (!range) return { rects: out, last: null };
1861
+ if (typeof range.getClientRects === 'function') {
1862
+ try {
1863
+ var list = range.getClientRects();
1864
+ for (var i = 0; i < list.length && out.length < MAX_HIGHLIGHT_RECTS; i++) out.push(list[i]);
1865
+ if (list.length) lastRaw = list[list.length - 1];
1866
+ } catch (ex) {}
1867
+ }
1868
+ if (!out.length && typeof range.getBoundingClientRect === 'function') {
1869
+ try {
1870
+ var bounds = range.getBoundingClientRect();
1871
+ if (bounds) {
1872
+ out.push(bounds);
1873
+ if (!lastRaw) lastRaw = bounds;
1874
+ }
1875
+ } catch (ex2) {}
1876
+ }
1877
+ if (layoutActive()) {
1878
+ out = out.filter(function(rect) {
1879
+ return (rect.width || 0) > 0 || (rect.height || 0) > 0;
1880
+ });
1881
+ out = dropContainerRects(out);
1882
+ }
1883
+ return { rects: out, last: lastRaw };
1884
+ }
1885
+
1886
+ // Range paint/projection geometry, shared by the overlay render, the print
1887
+ // layer, and highlight click hit-testing: clip-tested painted rects
1888
+ // (capped), the TRUE last client rect (marker anchor — never the capped
1889
+ // list's 48th rect), and the clipped union (marker association; extended
1890
+ // to the last rect so the tail of a cap-truncated selection still
1891
+ // associates its marker).
1892
+ function rangeVisualGeometry(range) {
1893
+ var rangeEl = range && range.startContainer
1894
+ ? (range.startContainer.nodeType === 1
1895
+ ? range.startContainer
1896
+ : range.startContainer.parentElement)
1897
+ : null;
1898
+ if (rangeEl && targetStyleHidden(rangeEl)) {
1899
+ return { paint: [], last: null, vis: null, hidden: true };
1900
+ }
1901
+ var collected = rangeClientRects(range);
1902
+ var all = collected.rects;
1903
+ if (!all.length) return { paint: [], last: null, vis: null, hidden: false };
1904
+ var bounds = clipBoundsFor(rangeEl);
1905
+ var paint = [];
1906
+ for (var i = 0; i < all.length && paint.length < MAX_HIGHLIGHT_RECTS; i++) {
1907
+ var clipped = clipRect(all[i], bounds);
1908
+ if (clipped) paint.push(clipped);
1909
+ }
1910
+ var last = collected.last;
1911
+ if (layoutActive() && last && !(last.width || 0) && !(last.height || 0)) {
1912
+ // A zero-size trailing rect (collapsed tail) has no visual anchor —
1913
+ // fall back to the last collected paintable rect.
1914
+ last = all[all.length - 1];
1915
+ }
1916
+ var union = unionOfRects(last ? all.concat([last]) : all);
1917
+ var vis = bounds
1918
+ ? {
1919
+ left: Math.max(union.left, bounds.left),
1920
+ top: Math.max(union.top, bounds.top),
1921
+ right: Math.min(union.right, bounds.right),
1922
+ bottom: Math.min(union.bottom, bounds.bottom)
1923
+ }
1924
+ : union;
1925
+ return { paint: paint, last: last, vis: vis, hidden: false };
1926
+ }
1927
+
1928
+ function unionOfRects(rects) {
1929
+ var left = Infinity;
1930
+ var top = Infinity;
1931
+ var right = -Infinity;
1932
+ var bottom = -Infinity;
1933
+ for (var i = 0; i < rects.length; i++) {
1934
+ left = Math.min(left, rects[i].left);
1935
+ top = Math.min(top, rects[i].top);
1936
+ right = Math.max(right, rectRight(rects[i]));
1937
+ bottom = Math.max(bottom, rectBottom(rects[i]));
1938
+ }
1939
+ return { left: left, top: top, right: right, bottom: bottom, width: right - left, height: bottom - top };
1940
+ }
1941
+
1942
+ var highlightPool = [];
1943
+ var highlightUsed = 0;
1944
+ // Read/write batching: the render pass QUEUES highlight rects while it
1945
+ // reads geometry, then flushes every pool write at once. Interleaving a
1946
+ // style write between one record's writes and the next record's
1947
+ // getBoundingClientRect would force a synchronous layout per record.
1948
+ var queuedHighlights = [];
1949
+ // Cached viewport rects of the committed highlights currently painted,
1950
+ // rebuilt on every flush. The hover affordance hit-tests the pointer
1951
+ // against THIS cache — never against live range geometry per mousemove.
1952
+ var renderedCommittedRects = [];
1953
+ function takeHighlightRect(className, rect, annId) {
1954
+ queuedHighlights.push({ className: className, rect: rect, annId: annId || '' });
1955
+ }
1956
+ function flushQueuedHighlights() {
1957
+ highlightUsed = 0;
1958
+ renderedCommittedRects.length = 0;
1959
+ for (var q = 0; q < queuedHighlights.length; q++) {
1960
+ var item = queuedHighlights[q];
1961
+ var div = highlightPool[highlightUsed];
1962
+ if (!div) {
1963
+ div = document.createElement('div');
1964
+ overlayNodes.add(div);
1965
+ highlightPool.push(div);
1966
+ highlightsLayerEl.appendChild(div);
1967
+ }
1968
+ highlightUsed += 1;
1969
+ var hoverClass = item.annId && item.annId === hoverHighlightId ? ' pn-hl-hover' : '';
1970
+ div.className = 'pn-hl ' + item.className + hoverClass;
1971
+ if (item.annId) {
1972
+ div.setAttribute('data-annotation-id', item.annId);
1973
+ renderedCommittedRects.push({
1974
+ id: item.annId,
1975
+ left: item.rect.left,
1976
+ top: item.rect.top,
1977
+ right: rectRight(item.rect),
1978
+ bottom: rectBottom(item.rect)
1979
+ });
1980
+ } else {
1981
+ div.removeAttribute('data-annotation-id');
1982
+ }
1983
+ div.style.display = 'block';
1984
+ div.style.left = item.rect.left + 'px';
1985
+ div.style.top = item.rect.top + 'px';
1986
+ div.style.width = (item.rect.width || 0) + 'px';
1987
+ div.style.height = (item.rect.height || 0) + 'px';
1988
+ }
1989
+ queuedHighlights.length = 0;
1990
+ hideUnusedHighlights();
1991
+ }
1992
+ function hideUnusedHighlights() {
1993
+ for (var i = highlightUsed; i < highlightPool.length; i++) {
1994
+ highlightPool[i].style.display = 'none';
1995
+ }
1996
+ }
1997
+
1998
+ // --- Hover affordance for click-to-select ---
1999
+ // Pre-overlay, inline marks carried cursor:pointer + hover styling; overlay
2000
+ // rects are pointer-transparent, so hovering is detected by hit-testing the
2001
+ // rAF-throttled pointer against the CACHED rendered rects (bounding-box
2002
+ // checks only — no geometry reads) and toggling a class on that
2003
+ // annotation's rect divs inside the shadow root. Shadow-root writes are
2004
+ // unobserved by the page mutation observer, so no reconcile feedback loop,
2005
+ // and the rects stay pointer-events: none throughout.
2006
+ var hoverHighlightId = null;
2007
+ var hoverHitRaf = 0;
2008
+ var hoverHitPos = null;
2009
+
2010
+ function committedHighlightIdAtCached(x, y) {
2011
+ var best = null;
2012
+ for (var i = 0; i < renderedCommittedRects.length; i++) {
2013
+ var r = renderedCommittedRects[i];
2014
+ if (x < r.left || x > r.right || y < r.top || y > r.bottom) continue;
2015
+ var area = (r.right - r.left) * (r.bottom - r.top);
2016
+ if (area <= 0) continue;
2017
+ // Smallest rect wins on overlap, matching the click hit-test.
2018
+ if (!best || area <= best.area) best = { id: r.id, area: area };
2019
+ }
2020
+ return best ? best.id : null;
2021
+ }
2022
+
2023
+ function setHoverHighlight(id) {
2024
+ if (id === hoverHighlightId) return;
2025
+ hoverHighlightId = id;
2026
+ for (var i = 0; i < highlightUsed; i++) {
2027
+ var div = highlightPool[i];
2028
+ if (hoverHighlightId && div.getAttribute('data-annotation-id') === hoverHighlightId) {
2029
+ div.classList.add('pn-hl-hover');
2030
+ } else {
2031
+ div.classList.remove('pn-hl-hover');
2032
+ }
2033
+ }
2034
+ }
2035
+
2036
+ function scheduleHoverHitTest(x, y) {
2037
+ hoverHitPos = { x: x, y: y };
2038
+ if (hoverHitRaf) return;
2039
+ hoverHitRaf = requestAnimationFrame(function() {
2040
+ hoverHitRaf = 0;
2041
+ if (!hoverHitPos) return;
2042
+ // Only drag mode without an open draft may paint the affordance: a
2043
+ // mode switch or draft opening between schedule and frame must not
2044
+ // resurrect a stale hover (flushQueuedHighlights re-applies whatever
2045
+ // hoverHighlightId holds on every render).
2046
+ if (currentInputMethod === 'pinpoint' || pendingPinEl || pendingSelection) return;
2047
+ setHoverHighlight(committedHighlightIdAtCached(hoverHitPos.x, hoverHitPos.y));
2048
+ });
2049
+ }
2050
+
2051
+ // Full teardown for every "hover must die" transition (pinpoint switch,
2052
+ // draft open, pointer leave): cancels the pending rAF and clears the
2053
+ // tracked position so a scheduled hit test cannot re-apply the class.
2054
+ function clearHoverHighlight() {
2055
+ if (hoverHitRaf) {
2056
+ cancelAnimationFrame(hoverHitRaf);
2057
+ hoverHitRaf = 0;
2058
+ }
2059
+ hoverHitPos = null;
2060
+ setHoverHighlight(null);
2061
+ }
2062
+
2063
+ document.addEventListener('mouseleave', function() {
2064
+ clearHoverHighlight();
2065
+ });
2066
+
2067
+ function makeMarkerButton(annId) {
2068
+ var btn = document.createElement('button');
2069
+ btn.type = 'button';
2070
+ btn.className = 'pn-marker';
2071
+ btn.setAttribute('data-plannotator-marker', '');
2072
+ btn.setAttribute('data-annotation-id', annId);
2073
+ btn.innerHTML = MARKER_SVG + '<span class="pn-marker-num"></span>';
2074
+ btn.addEventListener('click', function(clickEvent) {
2075
+ clickEvent.preventDefault();
2076
+ clickEvent.stopPropagation();
2077
+ postToParent({ type: PREFIX + 'mark-click', id: annId });
2078
+ });
2079
+ overlayNodes.add(btn);
2080
+ return btn;
2081
+ }
2082
+
2083
+ function placeMarkers(markers) {
2084
+ // Coincident markers (same rounded x:y) spread horizontally around the
2085
+ // shared point, ordered deterministically by (number, id). This is a
2086
+ // collision rule for coincident points only, not general label layout.
2087
+ var groups = new Map();
2088
+ var i;
2089
+ for (i = 0; i < markers.length; i++) {
2090
+ var m = markers[i];
2091
+ if (m.hidden) continue;
2092
+ var key = Math.round(m.x) + ':' + Math.round(m.y);
2093
+ var group = groups.get(key);
2094
+ if (!group) { group = []; groups.set(key, group); }
2095
+ group.push(m);
2096
+ }
2097
+ groups.forEach(function(group) {
2098
+ if (group.length < 2) return;
2099
+ group.sort(function(a, b) {
2100
+ if (a.number !== b.number) return a.number - b.number;
2101
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
2102
+ });
2103
+ var vw = layoutActive() ? window.innerWidth : 0;
2104
+ for (var gi = 0; gi < group.length; gi++) {
2105
+ var spreadX = group[gi].x + (gi - (group.length - 1) / 2) * MARKER_SPREAD_STEP;
2106
+ if (vw) spreadX = Math.max(MARKER_SPREAD_EDGE, Math.min(spreadX, vw - MARKER_SPREAD_EDGE));
2107
+ group[gi].x = spreadX;
2108
+ }
2109
+ });
2110
+ var usedKeys = {};
2111
+ for (i = 0; i < markers.length; i++) {
2112
+ var marker = markers[i];
2113
+ usedKeys[marker.key] = true;
2114
+ var btn = markerButtons.get(marker.key);
2115
+ if (marker.hidden) {
2116
+ // Unresolved or visually detached target: omit the marker rather
2117
+ // than leave a bubble floating over unrelated content.
2118
+ if (btn) btn.style.display = 'none';
2119
+ continue;
2120
+ }
2121
+ if (!btn) {
2122
+ btn = makeMarkerButton(marker.id);
2123
+ markerButtons.set(marker.key, btn);
2124
+ markersLayerEl.appendChild(btn);
2125
+ }
2126
+ btn.style.display = 'flex';
2127
+ btn.style.left = marker.x + 'px';
2128
+ btn.style.top = marker.y + 'px';
2129
+ btn.setAttribute('data-selected', focusedAnnotationId === marker.id ? 'true' : 'false');
2130
+ btn.setAttribute('aria-label', 'Comment ' + marker.number);
2131
+ var num = btn.querySelector('.pn-marker-num');
2132
+ if (num) num.textContent = String(marker.number);
2133
+ }
2134
+ markerButtons.forEach(function(btn, key) {
2135
+ if (usedKeys[key]) return;
2136
+ if (btn.parentNode) btn.parentNode.removeChild(btn);
2137
+ overlayNodes.delete(btn);
2138
+ markerButtons.delete(key);
2139
+ });
2140
+ }
2141
+
2142
+ function renderAnnotationOverlay() {
2143
+ var hasDraftRange = !!(pendingSelection && pendingRange);
2144
+ if (!annRecords.length && !hasDraftRange && !overlayHostEl) {
2145
+ // Nothing to project, but total restore failures must still report:
2146
+ // a session whose only annotations failed to restore never builds the
2147
+ // overlay host, and silence here would hide exactly that case.
2148
+ emitUnanchored([]);
2149
+ return;
2150
+ }
2151
+ ensureOverlayHost();
2152
+ beginDeadSearchPass();
2153
+ queuedHighlights.length = 0;
2154
+ var unanchoredThisPass = [];
2155
+ var markers = [];
2156
+ // Fallback numbering by first-seen registration order — used only until
2157
+ // the parent's ordered sync arrives. Numbering NEVER derives from target
2158
+ // count: every target of one annotation shares one number.
2159
+ var fallbackNumbers = new Map();
2160
+ for (var f = 0; f < annRecords.length; f++) {
2161
+ if (!fallbackNumbers.has(annRecords[f].id)) {
2162
+ fallbackNumbers.set(annRecords[f].id, fallbackNumbers.size + 1);
2163
+ }
2164
+ }
2165
+ for (var recordIndex = 0; recordIndex < annRecords.length; recordIndex++) {
2166
+ var record = annRecords[recordIndex];
2167
+ refreshRecordTargets(record);
2168
+ // Live means connected/findable — clipped, offscreen, or style-hidden
2169
+ // targets are still anchored (their content exists; it just isn't
2170
+ // currently visible) and must not report as unanchored.
2171
+ var recordAnchored = false;
2172
+ for (var liveIndex = 0; liveIndex < record.targets.length; liveIndex++) {
2173
+ var liveTarget = record.targets[liveIndex];
2174
+ if (liveTarget.kind === 'element'
2175
+ ? (liveTarget.element && liveTarget.element.isConnected)
2176
+ : rangeAlive(liveTarget.range)) {
2177
+ recordAnchored = true;
2178
+ break;
2179
+ }
2180
+ }
2181
+ if (!recordAnchored) unanchoredThisPass.push(record.id);
2182
+ var number = annNumbers && annNumbers.has(record.id)
2183
+ ? annNumbers.get(record.id)
2184
+ : fallbackNumbers.get(record.id);
2185
+ var focused = focusedAnnotationId === record.id;
2186
+ // One PLACED marker per resolved element per record: two anchors of
2187
+ // one annotation re-resolving to the same element must not render two
2188
+ // coincident same-number markers that then spread apart. Dedup runs
2189
+ // among placed (visible) markers only — a target whose own point is
2190
+ // clipped away must not consume the element's slot and suppress a
2191
+ // sibling target whose point IS visible.
2192
+ var seenRecordElements = [];
2193
+ for (var targetIndex = 0; targetIndex < record.targets.length; targetIndex++) {
2194
+ var target = record.targets[targetIndex];
2195
+ var markerKey = record.id + '::' + targetIndex;
2196
+ if (target.kind === 'element') {
2197
+ var el = target.element;
2198
+ if (!el || !el.isConnected) {
2199
+ markers.push({ key: markerKey, id: record.id, number: number, hidden: true });
2200
+ continue;
2201
+ }
2202
+ var rect = el.getBoundingClientRect();
2203
+ if (rectOutsideViewport(rect)) {
2204
+ // Entirely off-viewport: the marker would be omitted and any
2205
+ // focus rect invisible — skip the style/clip reads outright.
2206
+ markers.push({ key: markerKey, id: record.id, number: number, hidden: true });
2207
+ continue;
2208
+ }
2209
+ var zeroSize = layoutActive() && !(rect.width || 0) && !(rect.height || 0);
2210
+ var styleHidden = !zeroSize && targetStyleHidden(el);
2211
+ var point = target.point || { x: 0.5, y: 0.5 };
2212
+ var placed = zeroSize || styleHidden ? null : markerViewportPoint(
2213
+ clippedTargetRect(el, rect),
2214
+ rect.left + point.x * (rect.width || 0),
2215
+ rect.top + point.y * (rect.height || 0)
2216
+ );
2217
+ if (placed && seenRecordElements.indexOf(el) >= 0) placed = null;
2218
+ if (placed) {
2219
+ seenRecordElements.push(el);
2220
+ markers.push({ key: markerKey, id: record.id, number: number, x: placed.x, y: placed.y });
2221
+ if (focused) {
2222
+ // The focus flash is a painted rect too: clip-test it like
2223
+ // every other highlight so it never flashes over content
2224
+ // outside the target's scroll container.
2225
+ var focusRect = clipRect(rect, clipBoundsFor(el));
2226
+ if (focusRect) takeHighlightRect('pn-hl-focus', focusRect, record.id);
2227
+ }
2228
+ } else {
2229
+ markers.push({ key: markerKey, id: record.id, number: number, hidden: true });
2230
+ }
2231
+ } else {
2232
+ if (rangeAlive(target.range)) {
2233
+ // Cull on the range's bounding rect BEFORE any per-rect work
2234
+ // (client-rect collection, clip-chain walks, computed styles).
2235
+ var rangeCullRect = null;
2236
+ try { rangeCullRect = target.range.getBoundingClientRect(); } catch (exCull) {}
2237
+ if (rangeCullRect && rectOutsideViewport(rangeCullRect)) {
2238
+ if (!target.markerless) {
2239
+ markers.push({ key: markerKey, id: record.id, number: number, hidden: true });
2240
+ }
2241
+ continue;
2242
+ }
2243
+ }
2244
+ var geometry = rangeVisualGeometry(target.range);
2245
+ for (var rectIndex = 0; rectIndex < geometry.paint.length; rectIndex++) {
2246
+ takeHighlightRect(
2247
+ record.annType === 'deletion' ? 'pn-hl-deletion' : 'pn-hl-comment',
2248
+ geometry.paint[rectIndex],
2249
+ record.id
2250
+ );
2251
+ if (focused) takeHighlightRect('pn-hl-focus', geometry.paint[rectIndex], record.id);
2252
+ }
2253
+ if (!target.markerless) {
2254
+ var rangePlaced = null;
2255
+ if (geometry.last && geometry.vis) {
2256
+ rangePlaced = markerViewportPoint(
2257
+ geometry.vis,
2258
+ rectRight(geometry.last),
2259
+ geometry.last.top + (geometry.last.height || 0) / 2
2260
+ );
2261
+ }
2262
+ if (rangePlaced) {
2263
+ markers.push({ key: markerKey, id: record.id, number: number, x: rangePlaced.x, y: rangePlaced.y });
2264
+ } else {
2265
+ markers.push({ key: markerKey, id: record.id, number: number, hidden: true });
2266
+ }
2267
+ }
2268
+ }
2269
+ }
2270
+ }
2271
+ // Draft selection highlight: overlay-projected rectangles from the live
2272
+ // pending range — the page's own DOM is never mutated for draft state.
2273
+ // Clip-tested like committed rects (M1): a draft inside a scrolled inner
2274
+ // container must not stripe content outside the container's box.
2275
+ if (hasDraftRange) {
2276
+ var draftCullRect = null;
2277
+ try { draftCullRect = pendingRange.getBoundingClientRect(); } catch (exDraftCull) {}
2278
+ if (!draftCullRect || !rectOutsideViewport(draftCullRect)) {
2279
+ var draftGeometry = rangeVisualGeometry(pendingRange);
2280
+ for (var draftIndex = 0; draftIndex < draftGeometry.paint.length; draftIndex++) {
2281
+ takeHighlightRect('pn-hl-draft', draftGeometry.paint[draftIndex], '');
2282
+ }
2283
+ }
2284
+ }
2285
+ // Write phase: every geometry read above is done before the first pool
2286
+ // style write lands (B2), so the pass costs one layout, not one per
2287
+ // record.
2288
+ flushQueuedHighlights();
2289
+ placeMarkers(markers);
2290
+ // Budget-starved passes reschedule below and may still revive targets,
2291
+ // so only a complete pass may update the unanchored report.
2292
+ if (!deadSearchSkipped) emitUnanchored(unanchoredThisPass);
2293
+ // Eligible dead-target searches skipped for budget get a follow-up pass;
2294
+ // the loop terminates once every eligible target has been attempted at
2295
+ // the current generation (its failedGeneration then blocks it).
2296
+ if (deadSearchSkipped) schedulePinpointReconcile();
2297
+ }
2298
+
2299
+ function focusAnnotationRecord(id, flash) {
2300
+ if (focusFlashTimer) {
2301
+ clearTimeout(focusFlashTimer);
2302
+ focusFlashTimer = 0;
2303
+ }
2304
+ focusedAnnotationId = id || null;
2305
+ renderAnnotationOverlay();
2306
+ if (id && flash) {
2307
+ focusFlashTimer = setTimeout(function() {
2308
+ focusFlashTimer = 0;
2309
+ if (focusedAnnotationId === id) {
2310
+ focusedAnnotationId = null;
2311
+ renderAnnotationOverlay();
2312
+ }
2313
+ }, 2000);
2314
+ }
2315
+ }
2316
+
2317
+ function scrollToAnnotation(id, behavior) {
2318
+ var record = findAnnRecord(id);
2319
+ if (!record) return;
2320
+ beginDeadSearchPass(Infinity); // user-initiated one-shot: never budget-starved
2321
+ refreshRecordTargets(record);
2322
+ var scrollEl = null;
2323
+ for (var i = 0; i < record.targets.length && !scrollEl; i++) {
2324
+ var target = record.targets[i];
2325
+ if (target.kind === 'element' && target.element && target.element.isConnected) {
2326
+ scrollEl = target.element;
2327
+ } else if (target.kind === 'range' && rangeAlive(target.range)) {
2328
+ var node = target.range.startContainer;
2329
+ scrollEl = node.nodeType === 1 ? node : node.parentElement;
2330
+ }
2331
+ }
2332
+ if (scrollEl) {
2333
+ try { scrollEl.scrollIntoView({ behavior: behavior || 'smooth', block: 'center' }); } catch (ex) {}
2334
+ }
2335
+ focusAnnotationRecord(id, true);
2336
+ }
2337
+
2338
+ // --- Element anchors: verified-unique CSS selectors for restoration ---
2339
+ // Semantic ladder (id → identity attribute → meaningful classes), each rung
2340
+ // proved unique with a real query before acceptance; positional
2341
+ // tag > tag:nth-of-type() path as the last resort. Restoration fails closed:
2342
+ // a weak (positional/class) selector must also match the captured text
2343
+ // snapshot, so a shifted list never mis-anchors an annotation.
2344
+ var ANCHOR_IDENTITY_ATTRS = ['data-annotate', 'data-testid', 'data-test', 'data-test-id', 'data-cy', 'data-qa', 'aria-label', 'name', 'role', 'href', 'alt'];
2345
+
2346
+ function anchorTextSnapshot(el) {
2347
+ var text = (el.textContent || '').replace(/\s+/g, ' ').trim();
2348
+ return text.length > 180 ? text.slice(0, 180) : text;
2349
+ }
2350
+
2351
+ function uniquelySelects(selector, el) {
2352
+ try {
2353
+ var matches = document.querySelectorAll(selector);
2354
+ return matches.length === 1 && matches[0] === el;
2355
+ } catch (ex) {
2356
+ return false;
2357
+ }
2358
+ }
2359
+
2360
+ function escapeAttrValue(value) {
2361
+ return '"' + value.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
2362
+ }
2363
+
2364
+ function isLikelyGeneratedClass(name) {
2365
+ if (name.length > 36) return true;
2366
+ if (/^[a-f0-9]{8,}$/i.test(name)) return true;
2367
+ if (/[A-Za-z]+[_-][A-Za-z]*[0-9]{4,}/.test(name)) return true;
2368
+ return false;
2369
+ }
2370
+
2371
+ function semanticSelectorFor(el) {
2372
+ var tag = el.tagName.toLowerCase();
2373
+ var canEscape = typeof CSS !== 'undefined' && CSS.escape;
2374
+ if (el.id && canEscape) {
2375
+ var idSel = '#' + CSS.escape(el.id);
2376
+ if (uniquelySelects(idSel, el)) return idSel;
2377
+ }
2378
+ for (var i = 0; i < ANCHOR_IDENTITY_ATTRS.length; i++) {
2379
+ var name = ANCHOR_IDENTITY_ATTRS[i];
2380
+ var value = el.getAttribute && el.getAttribute(name);
2381
+ if (!value) continue;
2382
+ value = value.trim();
2383
+ if (!value || value.length > 240 || value.indexOf('\n') >= 0) continue;
2384
+ var attrSel = tag + '[' + name + '=' + escapeAttrValue(value) + ']';
2385
+ if (uniquelySelects(attrSel, el)) return attrSel;
2386
+ }
2387
+ if (canEscape && el.classList && el.classList.length) {
2388
+ var meaningful = [];
2389
+ for (var c = 0; c < el.classList.length && meaningful.length < 2; c++) {
2390
+ var cls = el.classList[c];
2391
+ if (isLikelyGeneratedClass(cls)) continue;
2392
+ meaningful.push('.' + CSS.escape(cls));
2393
+ }
2394
+ if (meaningful.length) {
2395
+ var classSel = tag + meaningful.join('');
2396
+ if (uniquelySelects(classSel, el)) return classSel;
2397
+ }
2398
+ }
2399
+ return null;
2400
+ }
2401
+
2402
+ // Each ancestor step runs a document-wide uniqueness query against a
2403
+ // selector that grows with the path, so cost is quadratic in depth. Real
2404
+ // documents anchor within a few levels; a degenerate deeply-wrapped chain
2405
+ // (templated exports, generated markup) must not freeze the tab on a
2406
+ // single pinpoint click. Past the cap the anchor is abandoned (fail
2407
+ // closed): text-search restoration still works, and no anchor beats one
2408
+ // that costs seconds of synchronous main-thread time.
2409
+ var MAX_ANCHOR_PATH_DEPTH = 40;
2410
+
2411
+ function buildAnchorSelector(el) {
2412
+ var path = [];
2413
+ var current = el;
2414
+ var depth = 0;
2415
+ while (current && current.nodeType === 1 && current !== document.body && current !== document.documentElement) {
2416
+ if (++depth > MAX_ANCHOR_PATH_DEPTH) return null;
2417
+ var semantic = semanticSelectorFor(current);
2418
+ if (semantic) {
2419
+ path.unshift(semantic);
2420
+ return path.join(' > ');
2421
+ }
2422
+ var segment = current.tagName.toLowerCase();
2423
+ var parentEl = current.parentElement;
2424
+ if (parentEl) {
2425
+ var sameTag = [];
2426
+ for (var i = 0; i < parentEl.children.length; i++) {
2427
+ if (parentEl.children[i].tagName === current.tagName) sameTag.push(parentEl.children[i]);
2428
+ }
2429
+ if (sameTag.length > 1) segment += ':nth-of-type(' + (sameTag.indexOf(current) + 1) + ')';
2430
+ }
2431
+ path.unshift(segment);
2432
+ var candidate = path.join(' > ');
2433
+ if (uniquelySelects(candidate, el)) return candidate;
2434
+ current = parentEl;
2435
+ }
2436
+ // Reaching here means every candidate — including the full body-rooted
2437
+ // path — failed the uniqueness query. A known-ambiguous selector must not
2438
+ // ship as an anchor: no anchor (text-search restoration) beats one that
2439
+ // can bind to the wrong element after a re-render.
2440
+ return null;
2441
+ }
2442
+
2443
+ function buildElementAnchor(el) {
2444
+ if (!el || el.nodeType !== 1) return null;
2445
+ var selector = buildAnchorSelector(el);
2446
+ if (!selector) return null;
2447
+ var snapshot = anchorTextSnapshot(el);
2448
+ // Text-less elements anchor ONLY through a stable-identity rung (#id or
2449
+ // an author-controlled data-* identity attribute). A weak (positional /
2450
+ // class) selector has nothing to verify against — identical siblings
2451
+ // share every structural trait, so any derived signature would validate
2452
+ // the WRONG element after a sibling is inserted or removed. A
2453
+ // wrong-binding anchor is worse than no anchor: ship none and fail
2454
+ // closed (the pin simply doesn't restore).
2455
+ if (!snapshot && !anchorHasStableIdentity(selector, el)) return null;
2456
+ return {
2457
+ selector: selector,
2458
+ tagName: el.tagName.toLowerCase(),
2459
+ text: snapshot
2460
+ };
2461
+ }
2462
+
2463
+ // Stable identity: the selector IS the resolved element's own #id or data-*
2464
+ // rung, re-derived from the element itself and compared whole — never parsed
2465
+ // out of the selector string (an attribute value containing ' > ' or '#'
2466
+ // would fool a string parse). Behavioral attributes (role, href, aria-label,
2467
+ // name, alt) identify what an element does, not what it says, so they never
2468
+ // exempt an anchor from the text check: a regenerated page keeps its
2469
+ // role="button" while the button's meaning changes completely.
2470
+ var STABLE_IDENTITY_ATTRS = ['data-annotate', 'data-testid', 'data-test', 'data-test-id', 'data-cy', 'data-qa'];
2471
+
2472
+ function anchorHasStableIdentity(selector, el) {
2473
+ var canEscape = typeof CSS !== 'undefined' && CSS.escape;
2474
+ if (el.id && canEscape && selector === '#' + CSS.escape(el.id)) return true;
2475
+ var tag = el.tagName.toLowerCase();
2476
+ for (var i = 0; i < STABLE_IDENTITY_ATTRS.length; i++) {
2477
+ var name = STABLE_IDENTITY_ATTRS[i];
2478
+ var value = el.getAttribute && el.getAttribute(name);
2479
+ if (!value) continue;
2480
+ if (selector === tag + '[' + name + '=' + escapeAttrValue(value.trim()) + ']') return true;
2481
+ }
2482
+ return false;
2483
+ }
2484
+
2485
+ function resolveAnchorElement(anchor) {
2486
+ if (!anchor || typeof anchor.selector !== 'string' || !anchor.selector || typeof anchor.tagName !== 'string') return null;
2487
+ var matches;
2488
+ try {
2489
+ matches = document.querySelectorAll(anchor.selector);
2490
+ } catch (ex) {
2491
+ return null;
2492
+ }
2493
+ if (matches.length !== 1) return null;
2494
+ var el = matches[0];
2495
+ if (el.tagName.toLowerCase() !== anchor.tagName.toLowerCase()) return null;
2496
+ if (!anchorHasStableIdentity(anchor.selector, el)) {
2497
+ // Weak (positional / class / behavioral-attribute) anchor: the captured
2498
+ // text snapshot must exist and still match, or the anchor is rejected and
2499
+ // restoration falls back to text search. A missing or empty snapshot is a
2500
+ // rejection, not an exemption.
2501
+ if (typeof anchor.text !== 'string' || !anchor.text) return null;
2502
+ if (anchorTextSnapshot(el) !== anchor.text) return null;
2503
+ }
2504
+ return el;
2505
+ }
2506
+
2507
+ function clearPendingPin() {
2508
+ pendingPinEl = null;
2509
+ pendingPinAnchor = null;
2510
+ pendingPinKey = null;
2511
+ pendingPinLabel = null;
2512
+ pendingPinPoint = null;
2513
+ pendingPinViaPinpoint = false;
2514
+ multiSelectArmed = false;
2515
+ multiSelectMax = MAX_MULTI_TARGETS;
2516
+ hidePinpointBox();
2517
+ }
2518
+
2519
+ /** Normalize a client-coordinate click into the element's rect (0..1 on
2520
+ * each axis; zero-size axes read 0.5). This is the durable "selected
2521
+ * point" a placed marker reprojects from after layout changes. */
2522
+ function normalizePointInElement(el, clientPoint) {
2523
+ if (!el || !clientPoint) return null;
2524
+ if (typeof clientPoint.x !== 'number' || typeof clientPoint.y !== 'number') return null;
2525
+ if (!isFinite(clientPoint.x) || !isFinite(clientPoint.y)) return null;
2526
+ if (!layoutActive()) return null; // no geometry to normalize against
2527
+ var r = el.getBoundingClientRect();
2528
+ var x = (r.width || 0) === 0 ? 0.5 : (clientPoint.x - r.left) / r.width;
2529
+ var y = (r.height || 0) === 0 ? 0.5 : (clientPoint.y - r.top) / r.height;
2530
+ return { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) };
2531
+ }
2532
+
2533
+ // --- Multi-select (shift-click): several elements, ONE draft comment ---
2534
+
2535
+ function makeTargetKey() {
2536
+ multiTargetSeq += 1;
2537
+ return 'ht-' + multiTargetSeq;
2538
+ }
2539
+
2540
+ function anchorsEqual(a, b) {
2541
+ return !!a && !!b
2542
+ && a.selector === b.selector
2543
+ && a.tagName === b.tagName
2544
+ && (a.text || '') === (b.text || '');
2545
+ }
2546
+
2547
+ // Per-target pinned outline boxes. The primary keeps the main pinpoint box;
2548
+ // each additional target gets its own (same attribute so the CSS applies,
2549
+ // identity-tracked in overlayNodes like every viewer overlay).
2550
+ function createMultiTargetBox(el) {
2551
+ var box = document.createElement('div');
2552
+ box.setAttribute('data-plannotator-pinpoint-box', '');
2553
+ box.setAttribute('data-plannotator-vim-ui', '');
2554
+ box.setAttribute('data-pinned', '');
2555
+ overlayNodes.add(box);
2556
+ document.body.appendChild(box);
2557
+ positionBoxToEl(box, el);
2558
+ return box;
2559
+ }
2560
+
2561
+ function positionBoxToEl(box, el) {
2562
+ var r = el.getBoundingClientRect();
2563
+ box.style.display = 'block';
2564
+ box.style.left = r.left + 'px';
2565
+ box.style.top = r.top + 'px';
2566
+ box.style.width = r.width + 'px';
2567
+ box.style.height = r.height + 'px';
2568
+ }
2569
+
2570
+ function destroyMultiTargetBox(box) {
2571
+ if (!box) return;
2572
+ if (box.parentNode) box.parentNode.removeChild(box);
2573
+ overlayNodes.delete(box);
2574
+ }
2575
+
2576
+ function clearMultiTargets() {
2577
+ for (var i = 0; i < pendingMultiTargets.length; i++) {
2578
+ destroyMultiTargetBox(pendingMultiTargets[i].box);
2579
+ }
2580
+ pendingMultiTargets = [];
2581
+ hideMultiHoverBox();
2582
+ }
2583
+
2584
+ function positionMultiTargetBoxes() {
2585
+ for (var i = 0; i < pendingMultiTargets.length; i++) {
2586
+ var entry = pendingMultiTargets[i];
2587
+ if (entry.el && entry.el.isConnected) positionBoxToEl(entry.box, entry.el);
2588
+ else entry.box.style.display = 'none';
2589
+ }
2590
+ }
2591
+
2592
+ // Element text for an additional target: same capping and text-less
2593
+ // description used by the primary element path in annotateElement.
2594
+ function elementTargetText(el, label) {
2595
+ var text = capSelectionText((el.textContent || '').trim());
2596
+ if (!text) text = capSelectionText('[element: ' + label + ']');
2597
+ return text;
2598
+ }
2599
+
2600
+ /**
2601
+ * Remove one draft target by key. Removing the primary promotes the first
2602
+ * remaining additional target to primary (it commits as an element pin);
2603
+ * removing the final target cancels the whole draft. When echo is true
2604
+ * (bridge-initiated toggle-off) the removal is posted to the parent, which
2605
+ * performs the identical deterministic update on its chip list.
2606
+ */
2607
+ function removeMultiTargetByKey(key, echo) {
2608
+ if (!key) return;
2609
+ if (key === pendingPinKey) {
2610
+ if (!pendingMultiTargets.length) {
2611
+ // Last target gone — the draft is cancelled.
2612
+ pendingSelection = null;
2613
+ pendingRange = null;
2614
+ skipNextClear = false;
2615
+ clearMultiTargets();
2616
+ clearPendingPin();
2617
+ try { window.getSelection().removeAllRanges(); } catch (ex) {}
2618
+ renderAnnotationOverlay();
2619
+ if (echo) postToParent({ type: PREFIX + 'multi-target-removed', key: key });
2620
+ return;
2621
+ }
2622
+ var next = pendingMultiTargets.shift();
2623
+ destroyMultiTargetBox(next.box);
2624
+ pendingPinEl = next.el;
2625
+ pendingPinAnchor = next.anchor;
2626
+ pendingPinKey = next.key;
2627
+ pendingPinLabel = next.label;
2628
+ pendingPinPoint = next.point || null;
2629
+ // A promoted primary commits as an element pin: the original text
2630
+ // selection belonged to the removed element and no longer applies.
2631
+ pendingSelection = { element: true };
2632
+ pendingRange = null;
2633
+ try { window.getSelection().removeAllRanges(); } catch (ex2) {}
2634
+ var mainBox = getPinpointBoxEl();
2635
+ mainBox.setAttribute('data-pinned', '');
2636
+ mainBox.classList.remove('pn-pin-enter');
2637
+ if (pendingPinEl && pendingPinEl.isConnected) positionPinpointBox(pendingPinEl);
2638
+ renderAnnotationOverlay();
2639
+ if (echo) postToParent({ type: PREFIX + 'multi-target-removed', key: key });
2640
+ return;
2641
+ }
2642
+ for (var i = 0; i < pendingMultiTargets.length; i++) {
2643
+ if (pendingMultiTargets[i].key === key) {
2644
+ destroyMultiTargetBox(pendingMultiTargets[i].box);
2645
+ pendingMultiTargets.splice(i, 1);
2646
+ if (echo) postToParent({ type: PREFIX + 'multi-target-removed', key: key });
2647
+ return;
2648
+ }
2649
+ }
2650
+ }
2651
+
2652
+ /** Shift-click toggle: add the element to the draft, or remove it if it is
2653
+ * already selected (dedup by DOM identity first, then by anchor equality
2654
+ * so a re-rendered page cannot double-select the same logical element). */
2655
+ function toggleMultiTarget(el, clickPoint) {
2656
+ if (!el) return;
2657
+ if (el === pendingPinEl) {
2658
+ removeMultiTargetByKey(pendingPinKey, true);
2659
+ return;
2660
+ }
2661
+ for (var i = 0; i < pendingMultiTargets.length; i++) {
2662
+ if (pendingMultiTargets[i].el === el) {
2663
+ removeMultiTargetByKey(pendingMultiTargets[i].key, true);
2664
+ return;
2665
+ }
2666
+ }
2667
+ var anchor = buildElementAnchor(el);
2668
+ if (anchor) {
2669
+ if (anchorsEqual(anchor, pendingPinAnchor)) {
2670
+ removeMultiTargetByKey(pendingPinKey, true);
2671
+ return;
2672
+ }
2673
+ for (var j = 0; j < pendingMultiTargets.length; j++) {
2674
+ if (anchorsEqual(anchor, pendingMultiTargets[j].anchor)) {
2675
+ removeMultiTargetByKey(pendingMultiTargets[j].key, true);
2676
+ return;
2677
+ }
2678
+ }
2679
+ }
2680
+ // Cap at the source: never grow the draft past the parent-side DTO cap
2681
+ // (or the lower product cap the parent armed this draft with).
2682
+ if (pendingMultiTargets.length >= multiSelectMax) return;
2683
+ var point = normalizePointInElement(el, clickPoint);
2684
+ if (anchor && point) anchor.point = point;
2685
+ var label = pinpointHoverLabel(el);
2686
+ var text = elementTargetText(el, label);
2687
+ var key = makeTargetKey();
2688
+ var box = createMultiTargetBox(el);
2689
+ pendingMultiTargets.push({ key: key, el: el, anchor: anchor, label: label, text: text, point: point, box: box });
2690
+ postToParent({
2691
+ type: PREFIX + 'multi-target-added',
2692
+ key: key,
2693
+ label: label,
2694
+ text: text,
2695
+ anchor: anchor || undefined
2696
+ });
2697
+ }
2698
+
2699
+ /** Chip hover in the composer: flash the corresponding pinned outline. */
2700
+ function flashMultiTarget(key) {
2701
+ var el = null;
2702
+ var box = null;
2703
+ if (key && key === pendingPinKey) {
2704
+ el = pendingPinEl;
2705
+ box = getPinpointBoxEl();
2706
+ } else {
2707
+ for (var i = 0; i < pendingMultiTargets.length; i++) {
2708
+ if (pendingMultiTargets[i].key === key) {
2709
+ el = pendingMultiTargets[i].el;
2710
+ box = pendingMultiTargets[i].box;
2711
+ break;
2712
+ }
2713
+ }
2714
+ }
2715
+ if (!el || !el.isConnected || !box) return;
2716
+ positionBoxToEl(box, el);
2717
+ box.classList.remove('pn-pin-enter');
2718
+ void box.offsetWidth; // restart the enter animation as the flash
2719
+ box.classList.add('pn-pin-enter');
2720
+ var r = el.getBoundingClientRect();
2721
+ if (r.bottom < 0 || r.top > window.innerHeight) {
2722
+ try { el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } catch (ex) {}
2723
+ }
2724
+ }
2725
+
2726
+ // Shift-hover preview while a draft is pinned: shows what the next
2727
+ // shift-click would add. A separate box — the main one tracks the primary.
2728
+ var multiHoverBoxEl = null;
2729
+ function getMultiHoverBoxEl() {
2730
+ if (!multiHoverBoxEl) {
2731
+ multiHoverBoxEl = document.createElement('div');
2732
+ multiHoverBoxEl.setAttribute('data-plannotator-pinpoint-box', '');
2733
+ multiHoverBoxEl.setAttribute('data-plannotator-vim-ui', '');
2734
+ overlayNodes.add(multiHoverBoxEl);
2735
+ }
2736
+ if (!multiHoverBoxEl.isConnected) document.body.appendChild(multiHoverBoxEl);
2737
+ return multiHoverBoxEl;
2738
+ }
2739
+ function hideMultiHoverBox() {
2740
+ if (multiHoverBoxEl) multiHoverBoxEl.style.display = 'none';
2741
+ }
2742
+ function updateMultiHover(x, y, fallbackNode) {
2743
+ var el = pinpointLeafAt(x, y, fallbackNode);
2744
+ if (!el || el === pendingPinEl) { hideMultiHoverBox(); hidePinpointLabel(); return; }
2745
+ positionBoxToEl(getMultiHoverBoxEl(), el);
2746
+ positionPinpointLabel(el, pinpointHoverLabel(el));
2747
+ }
2748
+
2749
+ // Composer-yield support: while a pinned draft is open the parent needs the
2750
+ // pointer position even though the pointer is inside this iframe (the
2751
+ // composer fades / becomes click-through as the pointer approaches it).
2752
+ var pointerRelayRaf = 0;
2753
+ var pointerRelayPos = null;
2754
+ function schedulePointerRelay(x, y, shift) {
2755
+ pointerRelayPos = { x: x, y: y, shift: !!shift };
2756
+ if (pointerRelayRaf) return;
2757
+ pointerRelayRaf = requestAnimationFrame(function() {
2758
+ pointerRelayRaf = 0;
2759
+ if (!pendingPinEl || !pointerRelayPos) return;
2760
+ postToParent({
2761
+ type: PREFIX + 'pointer',
2762
+ x: pointerRelayPos.x,
2763
+ y: pointerRelayPos.y,
2764
+ shift: pointerRelayPos.shift
2765
+ });
2766
+ });
2767
+ }
2768
+
2769
+ // Pin an element: select its text if possible (so a <mark> can wrap it), else
2770
+ // post its text + box directly so the toolbar still anchors (e.g. an SVG node,
2771
+ // whose <text> doesn't select like HTML text). Either way the element stays
2772
+ // outlined ("pinned") while the composer is open, and a serialized CSS anchor
2773
+ // rides along so the annotation can restore to this exact element later.
2774
+ function annotateElement(el, modeOverride, viaPinpoint, clickPoint) {
2775
+ if (!el) return false;
2776
+ pinpointHover = null;
2777
+ hidePinpointLabel();
2778
+ clearMultiTargets(); // a new primary starts a fresh draft
2779
+ // Arming is per-draft, never sticky: the parent re-arms via
2780
+ // arm-multi-select keyed to THIS draft's target key if (and only if) the
2781
+ // comment composer owns it. Without this reset, a mode switch between
2782
+ // drafts (comment -> quick label) leaves a stale arm and the bridge
2783
+ // accumulates pins the saved annotation will not carry.
2784
+ multiSelectArmed = false;
2785
+ multiSelectMax = MAX_MULTI_TARGETS;
2786
+ pendingPinEl = el;
2787
+ pendingPinAnchor = buildElementAnchor(el);
2788
+ pendingPinKey = makeTargetKey();
2789
+ pendingPinLabel = pinpointHoverLabel(el);
2790
+ // The user's selected point, normalized inside the element's rect. It
2791
+ // rides the durable anchor so restoration reprojects the marker at the
2792
+ // same relative spot; keyboard entry (vim) has no pointer and defaults.
2793
+ pendingPinPoint = normalizePointInElement(el, clickPoint);
2794
+ if (pendingPinAnchor && pendingPinPoint) pendingPinAnchor.point = pendingPinPoint;
2795
+ pendingPinViaPinpoint = !!viaPinpoint;
2796
+ var extras = {
2797
+ anchor: pendingPinAnchor,
2798
+ pinpoint: !!viaPinpoint,
2799
+ targetKey: pendingPinKey,
2800
+ targetLabel: pendingPinLabel
2801
+ };
2802
+ // Pinned outline: stronger accent box that tracks the element until the
2803
+ // composer resolves (create-mark or cancel-selection).
2804
+ var box = getPinpointBoxEl();
2805
+ box.setAttribute('data-pinned', '');
2806
+ box.classList.remove('pn-pin-enter');
2807
+ positionPinpointBox(el);
2808
+ // SVG content can't hold an HTML <mark> wrapper — wrapping an SVG <text> in a
2809
+ // <mark> un-renders it (the text disappears). So never text-wrap SVG: treat it
2810
+ // as a whole-element annotation (post its text + box, no mark). HTML elements
2811
+ // still try a real text selection first so a <mark> can highlight the words.
2812
+ var txt = '';
2813
+ if (!el.ownerSVGElement) {
2814
+ try {
2815
+ var sel = window.getSelection();
2816
+ sel.removeAllRanges();
2817
+ var range = document.createRange();
2818
+ range.selectNodeContents(el);
2819
+ sel.addRange(range);
2820
+ txt = (sel.toString() || '').trim();
2821
+ } catch (ex) {}
2822
+ }
2823
+ if (txt) {
2824
+ var posted = handleSelection(modeOverride, extras);
2825
+ if (!posted) clearPendingPin();
2826
+ return posted;
2827
+ }
2828
+ var elText = capSelectionText((el.textContent || '').trim());
2829
+ // Text-less elements (icon buttons, decorative chips, empty containers)
2830
+ // are still annotatable: describe the element instead of quoting it.
2831
+ if (!elText) elText = capSelectionText('[element: ' + pinpointHoverLabel(el) + ']');
2832
+ var r = el.getBoundingClientRect();
2833
+ pendingSelection = { element: true };
2834
+ pendingRange = null;
2835
+ skipNextClear = true; // don't let this click's mouseup clear the toolbar we just opened
2836
+ postToParent({ type: PREFIX + 'selection', text: elText,
2837
+ modeOverride: modeOverride || undefined,
2838
+ anchor: pendingPinAnchor || undefined,
2839
+ pinpoint: !!viaPinpoint || undefined,
2840
+ targetKey: pendingPinKey || undefined,
2841
+ targetLabel: pendingPinLabel || undefined,
2842
+ rect: { top: r.top, left: r.left, width: r.width, height: r.height } });
2843
+ return true;
2844
+ }
2845
+
2846
+ document.addEventListener('click', function(e) {
2847
+ if (!annotateModeActive || currentInputMethod !== 'pinpoint') return;
2848
+ // Real placed markers (and any other viewer overlay) own their clicks —
2849
+ // checked by IDENTITY, not selector, so a page element spoofing
2850
+ // [data-plannotator-marker] stays an ordinary annotatable target.
2851
+ if (isViewerOverlayNode(e.target)) return;
2852
+ // A drag that ended in this click owns the surface: the drag-selection
2853
+ // pass is about to post the selected text, and pinpoint-annotating the
2854
+ // element under the pointer would clobber it (annotateElement rewrites
2855
+ // the selection). Armed at mouseup only when the drag actually produced
2856
+ // a selection — a drifted click arms nothing and pins normally below —
2857
+ // and mousedown clears a prior pin's leftover selection, so stale
2858
+ // selection state never blocks the next plain re-pin click. One-shot:
2859
+ // only the drag's own trailing click is suppressed.
2860
+ if (dragEndedClick) { dragEndedClick = false; return; }
2861
+ // Shift-click while an ARMED pinpoint draft is open: toggle the element
2862
+ // in/out of the SAME draft comment instead of replacing the selection.
2863
+ // Unarmed drafts (modes the parent does not mirror, e.g. quickLabel)
2864
+ // treat shift-click exactly like a plain click.
2865
+ if (e.shiftKey && pendingPinEl && multiSelectArmed && pendingSelection) {
2866
+ e.preventDefault();
2867
+ e.stopPropagation();
2868
+ hideMultiHoverBox();
2869
+ hidePinpointLabel();
2870
+ var multiEl = resolvePinpointTargetAt(e.clientX, e.clientY, e.target);
2871
+ if (multiEl) toggleMultiTarget(multiEl, { x: e.clientX, y: e.clientY });
2872
+ return;
2873
+ }
2874
+ // Click what the hover box shows: the currently hovered element is the
2875
+ // target the user aimed at. Fall back to a fresh hit-test at the click
2876
+ // point (e.g. a click with no preceding mousemove).
2877
+ var el = pinpointHover && pinpointHover.isConnected
2878
+ ? pinpointHover
2879
+ : resolvePinpointTargetAt(e.clientX, e.clientY, e.target);
2880
+ if (!el) return;
2881
+ // Suppress the page's own behavior (links, buttons) — we're annotating.
2882
+ e.preventDefault();
2883
+ e.stopPropagation();
2884
+ annotateElement(el, undefined, true, { x: e.clientX, y: e.clientY });
2885
+ }, true);
2886
+
2887
+ // Escape ladder (outside vim, which has its own): a pending draft closes
2888
+ // first, then the hover outline clears, then Esc EXITS Annotate back to
2889
+ // Interact — the parent owns the mode, so the final rung only posts
2890
+ // annotate-exit and waits for set-annotate-mode to come back down.
2891
+ function closePendingDraft() {
2892
+ postToParent({ type: PREFIX + 'selection-clear' });
2893
+ pendingSelection = null;
2894
+ pendingRange = null;
2895
+ skipNextClear = false;
2896
+ clearMultiTargets();
2897
+ clearPendingPin();
2898
+ window.getSelection().removeAllRanges();
2899
+ renderAnnotationOverlay();
2900
+ }
2901
+ document.addEventListener('keydown', function(e) {
2902
+ if (e.key !== 'Escape' || vimEnabled) return;
2903
+ if (!annotateModeActive) {
2904
+ // Interact mode: Esc belongs to the page — except an open drag-comment
2905
+ // draft (drag-selection stays live in Interact) still closes first.
2906
+ if (pendingSelection) closePendingDraft();
2907
+ return;
2908
+ }
2909
+ if (pendingSelection) {
2910
+ closePendingDraft();
2911
+ } else {
2912
+ // Hover-clear is NOT a rung: the hover outline is a pointer
2913
+ // affordance, not a state the user perceives as a step, so clearing
2914
+ // it and exiting to Interact happen on the SAME press. Only an open
2915
+ // draft earns its own press.
2916
+ if (currentInputMethod === 'pinpoint' && pinpointHover) clearPinpointHover();
2917
+ postToParent({ type: PREFIX + 'annotate-exit' });
2918
+ }
2919
+ });
2920
+
2921
+ // Mod+Shift+A toggles Interact/Annotate from inside the iframe (the parent
2922
+ // registers the same chord, but focus usually lives in here on live apps).
2923
+ // Capture phase so the page cannot swallow the reserved chord; the parent
2924
+ // answers with set-annotate-mode.
2925
+ document.addEventListener('keydown', function(e) {
2926
+ if (!(e.metaKey || e.ctrlKey) || !e.shiftKey || e.altKey) return;
2927
+ if (e.key !== 'a' && e.key !== 'A') return;
2928
+ e.preventDefault();
2929
+ postToParent({ type: PREFIX + 'annotate-toggle' });
2930
+ }, true);
2931
+
2932
+ // Author opt-in: a plain click on any element tagged [data-annotate] pops the
2933
+ // toolbar — no pinpoint mode. Lets an HTML doc (e.g. a flow graph) wire its own
2934
+ // nodes to Plannotator's toolbar. Bubble phase so the page's own click handlers
2935
+ // run first; an active text selection is respected, not clobbered. A click on
2936
+ // a committed highlight selects the annotation instead (the pre-overlay
2937
+ // handler deferred to '.annotation-highlight' the same way).
2938
+ document.addEventListener('click', function(e) {
2939
+ if (!annotateModeActive) return; // Interact mode: [data-annotate] stays a page element
2940
+ if (currentInputMethod === 'pinpoint') return; // pinpoint handler covers this
2941
+ if (isViewerOverlayNode(e.target)) return; // placed markers own their clicks
2942
+ var t = e.target && e.target.closest && e.target.closest('[data-annotate]');
2943
+ if (!t) return;
2944
+ if (committedHighlightAt(e.clientX, e.clientY)) return; // mark-click handler owns it
2945
+ var s = window.getSelection();
2946
+ if (s && !s.isCollapsed && (s.toString() || '').trim()) return; // respect a drag-selection
2947
+ annotateElement(t, undefined, undefined, { x: e.clientX, y: e.clientY });
2948
+ });
2949
+
2950
+ // --- Mark Click ---
2951
+ // Pre-overlay, committed highlights were inline marks with cursor:pointer
2952
+ // and their own click handler: clicking anywhere on a highlighted passage
2953
+ // posted mark-click and selected the annotation in the panel. Overlay
2954
+ // highlight rects are pointer-transparent (page pass-through must remain),
2955
+ // so the affordance is restored by hit-testing the click point against the
2956
+ // painted committed range rects. Bubble phase, exactly like the old
2957
+ // handler: the capture-phase pinpoint annotate click stopPropagation()s
2958
+ // first (pinpoint flows keep owning their clicks, as pre-overlay), and
2959
+ // marker buttons stop propagation before this can run.
2960
+ function committedHighlightAt(x, y) {
2961
+ var best = null;
2962
+ for (var recordIndex = 0; recordIndex < annRecords.length; recordIndex++) {
2963
+ var record = annRecords[recordIndex];
2964
+ for (var targetIndex = 0; targetIndex < record.targets.length; targetIndex++) {
2965
+ var target = record.targets[targetIndex];
2966
+ if (target.kind !== 'range' || !rangeAlive(target.range)) continue;
2967
+ var geometry = rangeVisualGeometry(target.range);
2968
+ for (var i = 0; i < geometry.paint.length; i++) {
2969
+ var rect = geometry.paint[i];
2970
+ if ((rect.width || 0) <= 0 || (rect.height || 0) <= 0) continue;
2971
+ if (x < rect.left || x > rectRight(rect) || y < rect.top || y > rectBottom(rect)) continue;
2972
+ var area = (rect.width || 0) * (rect.height || 0);
2973
+ // Smallest rect wins on overlap; ties go to the later-painted
2974
+ // (topmost) annotation.
2975
+ if (!best || area <= best.area) best = { id: record.id, area: area };
2976
+ }
2977
+ }
2978
+ }
2979
+ return best ? best.id : null;
2980
+ }
2981
+
2982
+ document.addEventListener('click', function(e) {
2983
+ // Interact mode: highlight rects are pointer-transparent projections, so a
2984
+ // page click landing on one goes to the page. Markers (real buttons) stay
2985
+ // the affordance for opening a committed comment in Interact.
2986
+ if (!annotateModeActive) return;
2987
+ if (e.shiftKey) return; // shift belongs to multi-select
2988
+ if (isViewerOverlayNode(e.target)) return; // markers own their clicks
2989
+ if (pendingPinEl) return; // an open pinpoint draft owns the surface
2990
+ var s = window.getSelection();
2991
+ if (s && !s.isCollapsed && (s.toString() || '').trim()) return; // end of a drag-selection
2992
+ var hitId = committedHighlightAt(e.clientX, e.clientY);
2993
+ if (!hitId) return;
2994
+ e.stopPropagation();
2995
+ postToParent({ type: PREFIX + 'mark-click', id: hitId });
2996
+ });
2997
+
2998
+ // --- Optional Vim navigation ---
2999
+ // The bridge owns iframe-local ranges and focus. The parent only enables the
3000
+ // feature and receives the same selection messages used by pointer input.
3001
+ function isVimEditableTarget(node) {
3002
+ var el = node && node.nodeType === 1 ? node : node && node.parentElement;
3003
+ if (!el || !el.closest) return false;
3004
+ return !!el.closest('button,input,textarea,select,a[href],summary,[contenteditable]:not([contenteditable="false"]),[role="button"],[role="link"],[role="textbox"],[role="dialog"],[data-plannotator-vim-ui]');
3005
+ }
3006
+
3007
+ function prepareVimFocusOwner() {
3008
+ if (!document.body) return;
3009
+ document.body.setAttribute('data-plannotator-vim-focus-owner', '');
3010
+ if (!document.body.hasAttribute('tabindex')) {
3011
+ document.body.setAttribute('tabindex', '-1');
3012
+ vimAddedBodyTabIndex = true;
3013
+ }
3014
+ }
3015
+
3016
+ function ensureVimFocus() {
3017
+ prepareVimFocusOwner();
3018
+ if (!document.body) return;
3019
+ try { document.body.focus({ preventScroll: true }); } catch (ex) {
3020
+ try { document.body.focus(); } catch (ignore) {}
3021
+ }
3022
+ }
3023
+
3024
+ function getVimTextNodes(root) {
3025
+ var scope = root || document.body;
3026
+ if (!scope) return [];
3027
+ var nodes = [];
3028
+ var walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT, {
3029
+ acceptNode: function(node) {
3030
+ var parentEl = node.parentElement;
3031
+ if (!parentEl || !node.data || !node.data.length) return NodeFilter.FILTER_REJECT;
3032
+ if (parentEl.closest('script,style,noscript,input,textarea,select,button,[contenteditable]:not([contenteditable="false"]),[data-plannotator-vim-ui]')) {
3033
+ return NodeFilter.FILTER_REJECT;
3034
+ }
3035
+ return NodeFilter.FILTER_ACCEPT;
3036
+ }
3037
+ });
3038
+ while (walker.nextNode()) nodes.push(walker.currentNode);
3039
+ return nodes;
3040
+ }
3041
+
3042
+ function nearestVisibleTextNode() {
3043
+ var nodes = getVimTextNodes();
3044
+ if (!nodes.length) return null;
3045
+ var centerY = window.innerHeight / 2;
3046
+ var best = nodes[0];
3047
+ var bestDistance = Infinity;
3048
+ for (var i = 0; i < nodes.length; i++) {
3049
+ var range = document.createRange();
3050
+ range.selectNodeContents(nodes[i]);
3051
+ var rect = range.getBoundingClientRect();
3052
+ if (!rect.height && !rect.width) continue;
3053
+ var distance = Math.abs((rect.top + rect.bottom) / 2 - centerY);
3054
+ if (distance < bestDistance) {
3055
+ best = nodes[i];
3056
+ bestDistance = distance;
3057
+ }
3058
+ }
3059
+ return best;
3060
+ }
3061
+
3062
+ function setCollapsedSelection(node, offset) {
3063
+ if (!node) return false;
3064
+ var selection = window.getSelection();
3065
+ if (!selection) return false;
3066
+ var range = document.createRange();
3067
+ range.setStart(node, Math.max(0, Math.min(offset || 0, node.length || 0)));
3068
+ range.collapse(true);
3069
+ selection.removeAllRanges();
3070
+ selection.addRange(range);
3071
+ return true;
3072
+ }
3073
+
3074
+ function ensureVimTextCursor() {
3075
+ var selection = window.getSelection();
3076
+ if (selection && selection.rangeCount && selection.focusNode && document.body.contains(selection.focusNode)) {
3077
+ return true;
3078
+ }
3079
+ return setCollapsedSelection(nearestVisibleTextNode(), 0);
3080
+ }
3081
+
3082
+ function currentVimPreciseTarget() {
3083
+ var graph = buildSemanticTargetGraph();
3084
+ var target = currentVimSemanticTarget(graph);
3085
+ return target && (target.kind === 'inline' || target.kind === 'row' || target.kind === 'cell')
3086
+ ? target.element
3087
+ : null;
3088
+ }
3089
+
3090
+ function setVimSelectionFocus(selection, node, offset) {
3091
+ if (vimPhase === 'visual' && selection.anchorNode) {
3092
+ selection.setBaseAndExtent(
3093
+ selection.anchorNode,
3094
+ selection.anchorOffset,
3095
+ node,
3096
+ offset
3097
+ );
3098
+ return true;
3099
+ }
3100
+ return setCollapsedSelection(node, offset);
3101
+ }
3102
+
3103
+ function clampVimSelectionToTarget(selection, target, direction) {
3104
+ if (!target || !selection) return false;
3105
+ if (selection.focusNode && target.contains(selection.focusNode)) return true;
3106
+ var nodes = getVimTextNodes(target);
3107
+ if (!nodes.length) return false;
3108
+ var boundaryNode = direction === 'backward' ? nodes[0] : nodes[nodes.length - 1];
3109
+ var boundaryOffset = direction === 'backward' ? 0 : boundaryNode.length;
3110
+ return setVimSelectionFocus(selection, boundaryNode, boundaryOffset);
3111
+ }
3112
+
3113
+ function syncVimTargetToSelection(selection) {
3114
+ if (!selection || !selection.focusNode) return;
3115
+ var graph = buildSemanticTargetGraph();
3116
+ var resolved = resolveSemanticTarget(graph, selection.focusNode);
3117
+ if (resolved) vimPinpointEl = semanticOwningBlock(graph, resolved).element;
3118
+ }
3119
+
3120
+ function modifyVimSelection(direction, granularity) {
3121
+ if (!ensureVimTextCursor()) return false;
3122
+ var selection = window.getSelection();
3123
+ if (!selection || typeof selection.modify !== 'function') return false;
3124
+ var preciseTarget = currentVimPreciseTarget();
3125
+ selection.modify(vimPhase === 'visual' ? 'extend' : 'move', direction, granularity);
3126
+ if (preciseTarget) clampVimSelectionToTarget(selection, preciseTarget, direction);
3127
+ else syncVimTargetToSelection(selection);
3128
+ updateVimUi();
3129
+ return true;
3130
+ }
3131
+
3132
+ function vimTextPointForOffset(nodes, offset) {
3133
+ var remaining = Math.max(0, offset);
3134
+ for (var i = 0; i < nodes.length; i++) {
3135
+ if (remaining <= nodes[i].length) return { node: nodes[i], offset: remaining };
3136
+ remaining -= nodes[i].length;
3137
+ }
3138
+ var last = nodes[nodes.length - 1];
3139
+ return last ? { node: last, offset: last.length } : null;
3140
+ }
3141
+
3142
+ function vimTextOffsetForPoint(nodes, node, offset) {
3143
+ var total = 0;
3144
+ for (var i = 0; i < nodes.length; i++) {
3145
+ if (nodes[i] === node) return total + Math.max(0, Math.min(offset, nodes[i].length));
3146
+ total += nodes[i].length;
3147
+ }
3148
+ return null;
3149
+ }
3150
+
3151
+ function moveVimWord(motion) {
3152
+ if (!ensureVimTextCursor()) return false;
3153
+ var selection = window.getSelection();
3154
+ var preciseTarget = currentVimPreciseTarget();
3155
+ var textTarget = preciseTarget || currentVimBlock();
3156
+ if (!selection || !selection.focusNode || !textTarget || !Intl.Segmenter) {
3157
+ return modifyVimSelection(motion === 'backward' ? 'backward' : 'forward', 'word');
3158
+ }
3159
+ var nodes = getVimTextNodes(textTarget);
3160
+ var focusOffset = vimTextOffsetForPoint(nodes, selection.focusNode, selection.focusOffset);
3161
+ if (focusOffset === null) return false;
3162
+ var text = nodes.map(function(node) { return node.data; }).join('');
3163
+ var words = Array.from(new Intl.Segmenter(undefined, { granularity: 'word' }).segment(text))
3164
+ .filter(function(segment) { return segment.isWordLike; });
3165
+ var targetOffset = null;
3166
+ if (motion === 'backward') {
3167
+ for (var i = words.length - 1; i >= 0; i--) {
3168
+ if (words[i].index < focusOffset) { targetOffset = words[i].index; break; }
3169
+ }
3170
+ } else if (motion === 'end') {
3171
+ for (var j = 0; j < words.length; j++) {
3172
+ var end = words[j].index + words[j].segment.length;
3173
+ if (end > focusOffset) { targetOffset = end; break; }
3174
+ }
3175
+ } else {
3176
+ for (var k = 0; k < words.length; k++) {
3177
+ if (words[k].index > focusOffset) { targetOffset = words[k].index; break; }
3178
+ }
3179
+ }
3180
+ if (targetOffset === null) {
3181
+ if (preciseTarget) {
3182
+ var boundaryNode = motion === 'backward' ? nodes[0] : nodes[nodes.length - 1];
3183
+ var boundaryOffset = motion === 'backward' ? 0 : boundaryNode.length;
3184
+ var movedToBoundary = setVimSelectionFocus(
3185
+ selection,
3186
+ boundaryNode,
3187
+ boundaryOffset
3188
+ );
3189
+ updateVimUi();
3190
+ return movedToBoundary;
3191
+ }
3192
+ return modifyVimSelection(motion === 'backward' ? 'backward' : 'forward', 'word');
3193
+ }
3194
+ var point = vimTextPointForOffset(nodes, targetOffset);
3195
+ if (!point) return false;
3196
+ setVimSelectionFocus(selection, point.node, point.offset);
3197
+ updateVimUi();
3198
+ return true;
3199
+ }
3200
+
3201
+ function currentVimBlock() {
3202
+ var graph = buildSemanticTargetGraph();
3203
+ var selection = window.getSelection();
3204
+ var node = selection && selection.focusNode;
3205
+ var resolved = resolveSemanticTarget(graph, node);
3206
+ if (resolved && (vimPhase === 'text' || vimPhase === 'visual')) {
3207
+ return semanticOwningBlock(graph, resolved).element;
3208
+ }
3209
+ var semantic = currentVimSemanticTarget(graph);
3210
+ return semantic ? semanticOwningBlock(graph, semantic).element : null;
3211
+ }
3212
+
3213
+ function selectVimBlock(block, resetAnchor) {
3214
+ if (!block) return false;
3215
+ if (resetAnchor !== false) vimVisualBlockAnchorEl = block;
3216
+ var range = document.createRange();
3217
+ range.selectNodeContents(block);
3218
+ var selection = window.getSelection();
3219
+ selection.removeAllRanges();
3220
+ selection.addRange(range);
3221
+ return true;
3222
+ }
3223
+
3224
+ function vimBlocks() {
3225
+ return buildSemanticTargetGraph().blocks.map(function(target) {
3226
+ return target.element;
3227
+ });
3228
+ }
3229
+
3230
+ function moveVimVisualBlock(delta) {
3231
+ var blocks = vimBlocks();
3232
+ if (!blocks.length) return false;
3233
+ var current = currentVimBlock();
3234
+ var index = blocks.indexOf(current);
3235
+ if (index < 0) index = 0;
3236
+ var nextIndex = Math.max(0, Math.min(blocks.length - 1, index + delta));
3237
+ var next = blocks[nextIndex];
3238
+ var anchor = vimVisualBlockAnchorEl || current || next;
3239
+ var anchorIndex = blocks.indexOf(anchor);
3240
+ if (anchorIndex < 0) {
3241
+ anchor = next;
3242
+ anchorIndex = nextIndex;
3243
+ vimVisualBlockAnchorEl = anchor;
3244
+ }
3245
+ var selection = window.getSelection();
3246
+ if (!selection) return false;
3247
+ var anchorTexts = getVimTextNodes(anchor);
3248
+ var nextTexts = getVimTextNodes(next);
3249
+ if (!anchorTexts.length || !nextTexts.length) return false;
3250
+ if (nextIndex >= anchorIndex) {
3251
+ selection.setBaseAndExtent(
3252
+ anchorTexts[0],
3253
+ 0,
3254
+ nextTexts[nextTexts.length - 1],
3255
+ nextTexts[nextTexts.length - 1].length
3256
+ );
3257
+ } else {
3258
+ selection.setBaseAndExtent(
3259
+ anchorTexts[anchorTexts.length - 1],
3260
+ anchorTexts[anchorTexts.length - 1].length,
3261
+ nextTexts[0],
3262
+ 0
3263
+ );
3264
+ }
3265
+ vimPinpointEl = next;
3266
+ next.scrollIntoView({ block: 'nearest' });
3267
+ updateVimUi();
3268
+ return true;
3269
+ }
3270
+
3271
+ function moveVimDocumentBoundary(end) {
3272
+ var nodes = getVimTextNodes();
3273
+ if (!nodes.length) return false;
3274
+ var node = end ? nodes[nodes.length - 1] : nodes[0];
3275
+ var offset = end ? node.length : 0;
3276
+ var selection = window.getSelection();
3277
+ if (!selection) return false;
3278
+ if (vimPhase !== 'visual' || !selection.anchorNode) {
3279
+ setCollapsedSelection(node, offset);
3280
+ } else {
3281
+ selection.setBaseAndExtent(selection.anchorNode, selection.anchorOffset, node, offset);
3282
+ }
3283
+ node.parentElement && node.parentElement.scrollIntoView({ block: 'nearest' });
3284
+ updateVimUi();
3285
+ return true;
3286
+ }
3287
+
3288
+ function currentVimSemanticTarget(graph) {
3289
+ return vimPinpointEl ? graph.byElement.get(vimPinpointEl) || null : null;
3290
+ }
3291
+
3292
+ function semanticVimPhase(target) {
3293
+ return target && (target.kind === 'inline' || target.kind === 'row' || target.kind === 'cell')
3294
+ ? 'inline'
3295
+ : 'block';
3296
+ }
3297
+
3298
+ function setVimPinpointTarget(target) {
3299
+ clearPinpointHover();
3300
+ if (vimPinpointEl) vimPinpointEl.classList.remove('plannotator-pinpoint-hover');
3301
+ vimVisualBlockAnchorEl = null;
3302
+ vimPinpointEl = target ? target.element : null;
3303
+ if (vimPinpointEl) {
3304
+ vimPhase = semanticVimPhase(target);
3305
+ window.getSelection().removeAllRanges();
3306
+ vimPinpointEl.scrollIntoView({ block: 'nearest' });
3307
+ if (vimHudEnabled) {
3308
+ hidePinpointLabel();
3309
+ } else {
3310
+ vimPinpointEl.classList.add('plannotator-pinpoint-hover');
3311
+ var r = vimPinpointEl.getBoundingClientRect();
3312
+ var lbl = getPinpointLabelEl();
3313
+ lbl.textContent = target.label;
3314
+ lbl.style.display = 'block';
3315
+ lbl.style.top = Math.max(2, r.top - 22) + 'px';
3316
+ lbl.style.left = Math.max(2, r.left) + 'px';
3317
+ }
3318
+ } else {
3319
+ hidePinpointLabel();
3320
+ }
3321
+ updateVimUi();
3322
+ }
3323
+
3324
+ function initialVimPinpointTarget() {
3325
+ var graph = buildSemanticTargetGraph();
3326
+ if (!graph.blocks.length) return null;
3327
+ var centerY = window.innerHeight / 2;
3328
+ var blocks = graph.blocks.slice();
3329
+ blocks.sort(function(a, b) {
3330
+ var ar = a.element.getBoundingClientRect();
3331
+ var br = b.element.getBoundingClientRect();
3332
+ return Math.abs((ar.top + ar.bottom) / 2 - centerY) - Math.abs((br.top + br.bottom) / 2 - centerY);
3333
+ });
3334
+ return blocks[0];
3335
+ }
3336
+
3337
+ function moveVimPinpoint(delta, blocksOnly) {
3338
+ var graph = buildSemanticTargetGraph();
3339
+ if (!graph.blocks.length) return false;
3340
+ var current = currentVimSemanticTarget(graph) || initialVimPinpointTarget();
3341
+ if (!current) return false;
3342
+
3343
+ if (vimPhase === 'inline' && !blocksOnly) {
3344
+ setVimPinpointTarget(semanticSibling(graph, current, delta));
3345
+ return true;
3346
+ }
3347
+
3348
+ var block = semanticOwningBlock(graph, current);
3349
+ var index = graph.blocks.indexOf(block);
3350
+ if (index < 0) index = 0;
3351
+ var nextIndex = Math.max(0, Math.min(graph.blocks.length - 1, index + delta));
3352
+ setVimPinpointTarget(graph.blocks[nextIndex]);
3353
+ return true;
3354
+ }
3355
+
3356
+ function refineVimPinpoint(inward) {
3357
+ var graph = buildSemanticTargetGraph();
3358
+ var current = currentVimSemanticTarget(graph);
3359
+ if (!current) {
3360
+ current = initialVimPinpointTarget();
3361
+ if (current) setVimPinpointTarget(current);
3362
+ }
3363
+ if (!current) return false;
3364
+
3365
+ if (!inward) {
3366
+ var parent = semanticParent(graph, current);
3367
+ if (parent) {
3368
+ setVimPinpointTarget(parent);
3369
+ return true;
3370
+ }
3371
+ return true;
3372
+ }
3373
+
3374
+ var child = semanticChildren(graph, current)[0];
3375
+ if (child) {
3376
+ setVimPinpointTarget(child);
3377
+ return true;
3378
+ }
3379
+ enterVimTextTarget(current);
3380
+ return true;
3381
+ }
3382
+
3383
+ function enterVimTextTarget(target) {
3384
+ if (!target || !target.element) return false;
3385
+ var nodes = getVimTextNodes(target.element);
3386
+ if (!nodes.length) return false;
3387
+ if (vimPinpointEl) vimPinpointEl.classList.remove('plannotator-pinpoint-hover');
3388
+ hidePinpointLabel();
3389
+ vimVisualBlockAnchorEl = null;
3390
+ vimPinpointEl = target.element;
3391
+ vimPhase = 'text';
3392
+ setCollapsedSelection(nodes[0], 0);
3393
+ updateVimUi();
3394
+ return true;
3395
+ }
3396
+
3397
+ function resetVimSemanticNavigation() {
3398
+ if (!vimEnabled) return;
3399
+ window.getSelection().removeAllRanges();
3400
+ setVimPinpointTarget(initialVimPinpointTarget());
3401
+ }
3402
+
3403
+ function restoreVimSemanticTarget() {
3404
+ if (!vimEnabled) return;
3405
+ if (vimPhase === 'action' && restoreVimActionState()) return;
3406
+ var graph = buildSemanticTargetGraph();
3407
+ var target = currentVimSemanticTarget(graph) || initialVimPinpointTarget();
3408
+ if (target) setVimPinpointTarget(target);
3409
+ else {
3410
+ vimPinpointEl = null;
3411
+ vimPhase = 'inactive';
3412
+ hidePinpointLabel();
3413
+ updateVimUi();
3414
+ }
3415
+ }
3416
+
3417
+ function rememberVimActionState() {
3418
+ var selection = window.getSelection();
3419
+ var range = selection && selection.rangeCount
3420
+ ? selection.getRangeAt(0).cloneRange()
3421
+ : null;
3422
+ vimActionReturn = {
3423
+ phase: vimPhase,
3424
+ pinpointEl: vimPinpointEl,
3425
+ visualBlockAnchorEl: vimVisualBlockAnchorEl,
3426
+ range: range
3427
+ };
3428
+ }
3429
+
3430
+ function beginVimAction(mode) {
3431
+ if (mode === 'redline' && vimActionReturn) {
3432
+ if (vimActionReturn.phase === 'visual') {
3433
+ vimActionReturn.phase = 'text';
3434
+ if (vimActionReturn.range) vimActionReturn.range.collapse(false);
3435
+ } else if (vimActionReturn.phase === 'visual-block') {
3436
+ var graph = buildSemanticTargetGraph();
3437
+ var target = vimActionReturn.pinpointEl && graph.byElement.get(vimActionReturn.pinpointEl);
3438
+ vimActionReturn.phase = semanticVimPhase(target);
3439
+ vimActionReturn.visualBlockAnchorEl = null;
3440
+ vimActionReturn.range = null;
3441
+ }
3442
+ }
3443
+ vimPhase = 'action';
3444
+ updateVimUi();
3445
+ }
3446
+
3447
+ function restoreVimActionState() {
3448
+ if (!vimActionReturn) return false;
3449
+ var saved = vimActionReturn;
3450
+ vimActionReturn = null;
3451
+ vimPinpointEl = saved.pinpointEl;
3452
+ vimVisualBlockAnchorEl = saved.visualBlockAnchorEl;
3453
+
3454
+ if (saved.phase === 'block' || saved.phase === 'inline') {
3455
+ var graph = buildSemanticTargetGraph();
3456
+ var target = vimPinpointEl && graph.byElement.get(vimPinpointEl);
3457
+ if (target) {
3458
+ setVimPinpointTarget(target);
3459
+ return true;
3460
+ }
3461
+ }
3462
+
3463
+ if (vimPinpointEl) vimPinpointEl.classList.remove('plannotator-pinpoint-hover');
3464
+ hidePinpointLabel();
3465
+ vimPhase = saved.phase;
3466
+ var selection = window.getSelection();
3467
+ if (selection) {
3468
+ selection.removeAllRanges();
3469
+ if (saved.range) {
3470
+ try { selection.addRange(saved.range); } catch (ex) {}
3471
+ }
3472
+ }
3473
+ updateVimUi();
3474
+ return true;
3475
+ }
3476
+
3477
+ function getVimCursorEl() {
3478
+ var cursor = document.querySelector('[data-plannotator-vim-cursor]');
3479
+ if (!cursor) {
3480
+ cursor = document.createElement('div');
3481
+ cursor.setAttribute('data-plannotator-vim-cursor', '');
3482
+ cursor.setAttribute('data-plannotator-vim-ui', '');
3483
+ overlayNodes.add(cursor);
3484
+ document.body.appendChild(cursor);
3485
+ }
3486
+ return cursor;
3487
+ }
3488
+
3489
+ function getVimBadgeEl() {
3490
+ var badge = document.querySelector('[data-plannotator-vim-badge]');
3491
+ if (!badge) {
3492
+ badge = document.createElement('div');
3493
+ badge.setAttribute('data-plannotator-vim-badge', '');
3494
+ badge.setAttribute('data-plannotator-vim-ui', '');
3495
+ overlayNodes.add(badge);
3496
+ document.body.appendChild(badge);
3497
+ }
3498
+ return badge;
3499
+ }
3500
+
3501
+ function getVimReticleEl() {
3502
+ var reticle = document.querySelector('[data-plannotator-vim-reticle]');
3503
+ if (reticle) return reticle;
3504
+ reticle = document.createElement('div');
3505
+ reticle.setAttribute('data-plannotator-vim-reticle', '');
3506
+ reticle.setAttribute('data-plannotator-vim-ui', '');
3507
+ reticle.innerHTML = [
3508
+ '<div data-vim-reticle-fill></div>',
3509
+ '<div data-vim-reticle-corner="top-left"></div>',
3510
+ '<div data-vim-reticle-corner="top-right"></div>',
3511
+ '<div data-vim-reticle-corner="bottom-left"></div>',
3512
+ '<div data-vim-reticle-corner="bottom-right"></div>',
3513
+ '<div data-vim-reticle-label></div>'
3514
+ ].join('');
3515
+ overlayNodes.add(reticle);
3516
+ document.body.appendChild(reticle);
3517
+ return reticle;
3518
+ }
3519
+
3520
+ function hideVimReticle() {
3521
+ var reticle = document.querySelector('[data-plannotator-vim-reticle]');
3522
+ if (reticle) reticle.style.display = 'none';
3523
+ }
3524
+
3525
+ function vimRangeRect(range) {
3526
+ if (!range) return null;
3527
+ var rects = [];
3528
+ if (typeof range.getClientRects === 'function') {
3529
+ try {
3530
+ var rangeRects = range.getClientRects();
3531
+ for (var rangeRectIndex = 0; rangeRectIndex < rangeRects.length; rangeRectIndex++) {
3532
+ rects.push(rangeRects[rangeRectIndex]);
3533
+ }
3534
+ } catch (ex) {}
3535
+ }
3536
+ var visible = rects.filter(function(rect) {
3537
+ return rect.width > 0 || rect.height > 0;
3538
+ });
3539
+ if (!visible.length) {
3540
+ if (typeof range.getBoundingClientRect !== 'function') return null;
3541
+ try {
3542
+ return range.getBoundingClientRect();
3543
+ } catch (ex) {
3544
+ return null;
3545
+ }
3546
+ }
3547
+ var left = Math.min.apply(null, visible.map(function(rect) { return rect.left; }));
3548
+ var top = Math.min.apply(null, visible.map(function(rect) { return rect.top; }));
3549
+ var right = Math.max.apply(null, visible.map(function(rect) { return rect.right; }));
3550
+ var bottom = Math.max.apply(null, visible.map(function(rect) { return rect.bottom; }));
3551
+ return { left: left, top: top, width: right - left, height: bottom - top };
3552
+ }
3553
+
3554
+ function vimReticleSemanticDescriptor(target) {
3555
+ if (!target) return 'TARGET';
3556
+ if (target.kind === 'code') return 'CODE';
3557
+ if (target.kind === 'math') return 'FORMULA';
3558
+ if (target.kind === 'table') return 'TABLE';
3559
+ if (target.kind === 'row') return 'ROW';
3560
+ if (target.kind === 'cell') return 'CELL';
3561
+ return String(target.label || target.kind).split(':')[0].toUpperCase();
3562
+ }
3563
+
3564
+ function vimReticleCursorDescriptor() {
3565
+ if (vimLastActionId === 'moveDown') return 'NEXT LINE';
3566
+ if (vimLastActionId === 'moveUp') return 'PREVIOUS LINE';
3567
+ if (vimLastActionId === 'previousTextBlock') return 'PREVIOUS BLOCK';
3568
+ if (vimLastActionId === 'nextTextBlock') return 'NEXT BLOCK';
3569
+ if (vimLastActionId === 'swapSelectionEnds') return 'SWAPPED ENDS';
3570
+ if (vimLastActionId === 'lineStart') return 'LINE START';
3571
+ if (vimLastActionId === 'lineEnd') return 'LINE END';
3572
+ if (vimLastActionId === 'wordForward') return 'NEXT WORD';
3573
+ if (vimLastActionId === 'wordBackward') return 'PREVIOUS WORD';
3574
+ if (vimLastActionId === 'wordEnd') return 'WORD END';
3575
+ if (vimLastActionId === 'previousTextBlock') return 'PREVIOUS TEXT';
3576
+ if (vimLastActionId === 'nextTextBlock') return 'NEXT TEXT';
3577
+ if (vimLastActionId === 'documentStart') return 'DOCUMENT START';
3578
+ if (vimLastActionId === 'documentEnd') return 'DOCUMENT END';
3579
+ if (vimLastActionId === 'moveOut') {
3580
+ return vimLastActionContext === 'text' || vimLastActionContext === 'visual'
3581
+ ? 'PREVIOUS CHARACTER'
3582
+ : 'TEXT';
3583
+ }
3584
+ if (vimLastActionId === 'refine') {
3585
+ return vimLastActionContext === 'text' || vimLastActionContext === 'visual'
3586
+ ? 'NEXT CHARACTER'
3587
+ : 'INLINE TEXT';
3588
+ }
3589
+ return 'TEXT';
3590
+ }
3591
+
3592
+ function vimReticleVisualDescriptor(blockSelection) {
3593
+ if (blockSelection) return 'BLOCK RANGE';
3594
+ if (vimLastActionId === 'visual') return 'RANGE START';
3595
+ if (vimLastActionId === 'wordForward') return 'NEXT WORD';
3596
+ if (vimLastActionId === 'wordBackward') return 'PREVIOUS WORD';
3597
+ if (vimLastActionId === 'wordEnd') return 'EXACT TOKEN';
3598
+ if (vimLastActionId === 'lineStart') return 'TO LINE START';
3599
+ if (vimLastActionId === 'lineEnd') return 'TO LINE END';
3600
+ if (vimLastActionId === 'moveDown') return 'NEXT LINE';
3601
+ if (vimLastActionId === 'moveUp') return 'PREVIOUS LINE';
3602
+ return 'RANGE';
3603
+ }
3604
+
3605
+ function vimReticleTarget() {
3606
+ var phase = vimPhase;
3607
+ var pinpointEl = vimPinpointEl;
3608
+ var savedRange = null;
3609
+ if (phase === 'action' && vimActionReturn) {
3610
+ phase = vimActionReturn.phase;
3611
+ pinpointEl = vimActionReturn.pinpointEl;
3612
+ savedRange = vimActionReturn.range;
3613
+ }
3614
+
3615
+ if (phase === 'block' || phase === 'inline') {
3616
+ if (!pinpointEl) return null;
3617
+ var graph = buildSemanticTargetGraph();
3618
+ var target = graph.byElement.get(pinpointEl) || null;
3619
+ return {
3620
+ phase: phase,
3621
+ compact: false,
3622
+ rect: pinpointEl.getBoundingClientRect(),
3623
+ label: (phase === 'inline' ? 'INLINE' : 'BLOCK')
3624
+ + ' · ' + vimReticleSemanticDescriptor(target)
3625
+ };
3626
+ }
3627
+
3628
+ var selection = window.getSelection();
3629
+ var range = savedRange;
3630
+ if (!range && selection && selection.rangeCount) {
3631
+ range = selection.getRangeAt(0);
3632
+ }
3633
+ if (!range) return null;
3634
+
3635
+ if (phase === 'text') {
3636
+ var caretRange = range.cloneRange();
3637
+ caretRange.collapse(false);
3638
+ var caretRect = vimRangeRect(caretRange);
3639
+ if (!caretRect) return null;
3640
+ if (!caretRect.height) caretRect.height = 16;
3641
+ if (!caretRect.width) caretRect.width = 1;
3642
+ return {
3643
+ phase: phase,
3644
+ compact: true,
3645
+ rect: caretRect,
3646
+ label: 'CURSOR · ' + vimReticleCursorDescriptor()
3647
+ };
3648
+ }
3649
+
3650
+ if (phase === 'visual' || phase === 'visual-block') {
3651
+ return {
3652
+ phase: phase,
3653
+ compact: false,
3654
+ rect: vimRangeRect(range),
3655
+ label: 'VISUAL · ' + vimReticleVisualDescriptor(phase === 'visual-block')
3656
+ };
3657
+ }
3658
+ return null;
3659
+ }
3660
+
3661
+ function updateVimReticle() {
3662
+ if (!vimHudEnabled || vimPhase === 'inactive') {
3663
+ hideVimReticle();
3664
+ return;
3665
+ }
3666
+ var target = vimReticleTarget();
3667
+ if (!target || !target.rect) {
3668
+ hideVimReticle();
3669
+ return;
3670
+ }
3671
+
3672
+ var rect = target.rect;
3673
+ var paddingX = target.compact ? 10 : 5;
3674
+ var paddingY = target.compact ? 6 : 4;
3675
+ var width = Math.max(44, rect.width + paddingX * 2);
3676
+ var height = Math.max(32, rect.height + paddingY * 2);
3677
+ var left = Math.max(0, rect.left + rect.width / 2 - width / 2);
3678
+ var top = Math.max(0, rect.top + rect.height / 2 - height / 2);
3679
+ var cornerSize = 28;
3680
+ var cornerRight = Math.max(0, width - cornerSize);
3681
+ var cornerBottom = Math.max(0, height - cornerSize);
3682
+ var labelTop = top - 36 >= 4 ? top - 36 : top + height + 6;
3683
+ var reticle = getVimReticleEl();
3684
+ var fill = reticle.querySelector('[data-vim-reticle-fill]');
3685
+ var label = reticle.querySelector('[data-vim-reticle-label]');
3686
+ var topLeft = reticle.querySelector('[data-vim-reticle-corner="top-left"]');
3687
+ var topRight = reticle.querySelector('[data-vim-reticle-corner="top-right"]');
3688
+ var bottomLeft = reticle.querySelector('[data-vim-reticle-corner="bottom-left"]');
3689
+ var bottomRight = reticle.querySelector('[data-vim-reticle-corner="bottom-right"]');
3690
+
3691
+ reticle.style.display = 'block';
3692
+ reticle.setAttribute('data-vim-target-phase', target.phase);
3693
+ reticle.setAttribute('data-vim-target-label', target.label);
3694
+ fill.style.transform = 'translate3d(' + left + 'px,' + top + 'px,0) scale('
3695
+ + (width / 100) + ',' + (height / 100) + ')';
3696
+ topLeft.style.transform = 'translate3d(' + left + 'px,' + top + 'px,0)';
3697
+ topRight.style.transform = 'translate3d(' + (left + cornerRight) + 'px,' + top + 'px,0)';
3698
+ bottomLeft.style.transform = 'translate3d(' + left + 'px,' + (top + cornerBottom) + 'px,0)';
3699
+ bottomRight.style.transform = 'translate3d(' + (left + cornerRight) + 'px,'
3700
+ + (top + cornerBottom) + 'px,0)';
3701
+ label.textContent = target.label;
3702
+ label.style.transform = 'translate3d(' + left + 'px,' + labelTop + 'px,0)';
3703
+ }
3704
+
3705
+ function updateVimUi() {
3706
+ if (!vimEnabled) return;
3707
+ if (vimHudEnabled && vimLastPostedPhase !== vimPhase) {
3708
+ vimLastPostedPhase = vimPhase;
3709
+ postToParent({
3710
+ type: PREFIX + 'vim-state',
3711
+ phase: vimPhase
3712
+ });
3713
+ }
3714
+ var badge = document.querySelector('[data-plannotator-vim-badge]');
3715
+ if (!vimHudEnabled && !badge) badge = getVimBadgeEl();
3716
+ var cursor = getVimCursorEl();
3717
+ if (vimPhase === 'inactive') {
3718
+ if (badge) badge.style.display = 'none';
3719
+ cursor.style.display = 'none';
3720
+ hideVimReticle();
3721
+ return;
3722
+ }
3723
+ if (vimHudEnabled) {
3724
+ if (vimPinpointEl) vimPinpointEl.classList.remove('plannotator-pinpoint-hover');
3725
+ hidePinpointLabel();
3726
+ updateVimReticle();
3727
+ } else {
3728
+ hideVimReticle();
3729
+ if (vimPinpointEl && (vimPhase === 'block' || vimPhase === 'inline')) {
3730
+ vimPinpointEl.classList.add('plannotator-pinpoint-hover');
3731
+ }
3732
+ }
3733
+ if (badge) badge.style.display = vimHudEnabled ? 'none' : 'block';
3734
+ var phaseLabel = vimPhase === 'text' ? 'NORMAL' : vimPhase.toUpperCase().replace('-', ' ');
3735
+ if (badge) {
3736
+ badge.textContent = phaseLabel + ' · ' + (currentInputMethod === 'pinpoint' ? 'PINPOINT' : 'SELECT');
3737
+ }
3738
+ if (vimPhase !== 'text') {
3739
+ cursor.style.display = 'none';
3740
+ return;
3741
+ }
3742
+ var selection = window.getSelection();
3743
+ if (!selection || !selection.focusNode) {
3744
+ cursor.style.display = 'none';
3745
+ return;
3746
+ }
3747
+ var range = document.createRange();
3748
+ try {
3749
+ range.setStart(selection.focusNode, selection.focusOffset);
3750
+ range.collapse(true);
3751
+ var rect = range.getClientRects()[0] || range.getBoundingClientRect();
3752
+ cursor.style.display = 'block';
3753
+ cursor.style.left = rect.left + 'px';
3754
+ cursor.style.top = rect.top + 'px';
3755
+ cursor.style.height = (rect.height || 16) + 'px';
3756
+ } catch (ex) {
3757
+ cursor.style.display = 'none';
3758
+ }
3759
+ }
3760
+
3761
+ var vimUiRaf = 0;
3762
+ function scheduleVimUiUpdate() {
3763
+ if (!vimEnabled || vimPhase === 'inactive' || vimUiRaf) return;
3764
+ vimUiRaf = requestAnimationFrame(function() {
3765
+ vimUiRaf = 0;
3766
+ updateVimUi();
3767
+ });
3768
+ }
3769
+ window.addEventListener('resize', scheduleVimUiUpdate, { passive: true });
3770
+ window.addEventListener('scroll', scheduleVimUiUpdate, { passive: true, capture: true });
3771
+
3772
+ function toggleVimHelp() {
3773
+ vimHelpOpen = !vimHelpOpen;
3774
+ postToParent({
3775
+ type: PREFIX + 'vim-help',
3776
+ open: vimHelpOpen
3777
+ });
3778
+ }
3779
+
3780
+ function clearVimUi() {
3781
+ var nodes = document.querySelectorAll('[data-plannotator-vim-cursor],[data-plannotator-vim-badge],[data-plannotator-vim-reticle]');
3782
+ for (var i = 0; i < nodes.length; i++) nodes[i].remove();
3783
+ if (vimPinpointEl) vimPinpointEl.classList.remove('plannotator-pinpoint-hover');
3784
+ vimVisualBlockAnchorEl = null;
3785
+ vimActionReturn = null;
3786
+ hidePinpointLabel();
3787
+ vimHelpOpen = false;
3788
+ if (vimAddedBodyTabIndex && document.body) {
3789
+ document.body.removeAttribute('tabindex');
3790
+ vimAddedBodyTabIndex = false;
3791
+ }
3792
+ if (document.body) document.body.removeAttribute('data-plannotator-vim-focus-owner');
3793
+ }
3794
+
3795
+ function copyVimText(text) {
3796
+ if (!text) return;
3797
+ postToParent({
3798
+ type: PREFIX + 'vim-copy',
3799
+ text: text
3800
+ });
3801
+ }
3802
+
3803
+ function vimActionMode(key) {
3804
+ // The iframe is an intentionally dependency-free sandbox. Keep these
3805
+ // command keys aligned with plan-review/vimSelection.shortcuts.ts, whose
3806
+ // scope owns the user-facing registry and parent-document dispatch.
3807
+ if (key === 'c') return 'comment';
3808
+ if (key === 'd') return 'redline';
3809
+ if (key === 't') return 'quickLabel';
3810
+ if (key === 'm' || key === ' ' || key === 'Space' || key === 'Spacebar') return 'selection';
3811
+ return null;
3812
+ }
3813
+
3814
+ function vimActionIdForKey(key) {
3815
+ if (key === 'j') return 'moveDown';
3816
+ if (key === 'k') return 'moveUp';
3817
+ if (key === 'G') return 'documentEnd';
3818
+ if (key === 'h' || key === 'H') return 'moveOut';
3819
+ if (key === 'l') return 'refine';
3820
+ if (key === 'v') return 'visual';
3821
+ if (key === 'V') return 'visualBlock';
3822
+ if (key === 'w') return 'wordForward';
3823
+ if (key === 'b') return 'wordBackward';
3824
+ if (key === 'e') return 'wordEnd';
3825
+ if (key === '0') return 'lineStart';
3826
+ if (key === '$') return 'lineEnd';
3827
+ if (key === '{') return 'previousTextBlock';
3828
+ if (key === '}') return 'nextTextBlock';
3829
+ if (key === 'o') return 'swapSelectionEnds';
3830
+ if (key === 'Enter') return 'activeAnnotation';
3831
+ if (key === ' ' || key === 'Space' || key === 'Spacebar') return 'annotationMenu';
3832
+ if (key === 'c') return 'comment';
3833
+ if (key === 'd') return 'redline';
3834
+ if (key === 'm') return 'markup';
3835
+ if (key === 't') return 'label';
3836
+ if (key === 'y') return 'copy';
3837
+ if (key === 'Escape') return 'cancel';
3838
+ if (key === '?') return 'help';
3839
+ return null;
3840
+ }
3841
+
3842
+ function handleVimKeydown(e) {
3843
+ if (!vimEnabled || isVimEditableTarget(e.target) || e.isComposing) return false;
3844
+ if (e.metaKey || e.ctrlKey || e.altKey) return false;
3845
+
3846
+ var key = e.key;
3847
+ var handled = false;
3848
+ var hudKey = key;
3849
+ var vimCommandContext = vimPhase;
3850
+ var vimActionId = key === 'g' ? null : vimActionIdForKey(key);
3851
+
3852
+ if (vimHelpOpen) {
3853
+ if (key === '?' || key === 'Escape') {
3854
+ toggleVimHelp();
3855
+ handled = true;
3856
+ }
3857
+ } else if (key === '?') {
3858
+ toggleVimHelp();
3859
+ handled = true;
3860
+ } else if (key === 'Escape') {
3861
+ if (pendingSelection) {
3862
+ postToParent({ type: PREFIX + 'selection-clear' });
3863
+ pendingSelection = null;
3864
+ pendingRange = null;
3865
+ restoreVimSemanticTarget();
3866
+ renderAnnotationOverlay();
3867
+ handled = true;
3868
+ } else if (vimPhase === 'visual') {
3869
+ var visualSelection = window.getSelection();
3870
+ if (visualSelection && visualSelection.focusNode) {
3871
+ setCollapsedSelection(visualSelection.focusNode, visualSelection.focusOffset);
3872
+ }
3873
+ vimPhase = 'text';
3874
+ updateVimUi();
3875
+ handled = true;
3876
+ } else if (vimPhase === 'text' || vimPhase === 'visual-block' || vimPhase === 'action') {
3877
+ restoreVimSemanticTarget();
3878
+ handled = true;
3879
+ } else if (vimPhase === 'inline') {
3880
+ var escapeGraph = buildSemanticTargetGraph();
3881
+ var escapeTarget = currentVimSemanticTarget(escapeGraph);
3882
+ var escapeParent = escapeTarget && semanticParent(escapeGraph, escapeTarget);
3883
+ if (escapeParent) setVimPinpointTarget(escapeParent);
3884
+ else {
3885
+ if (vimPinpointEl) vimPinpointEl.classList.remove('plannotator-pinpoint-hover');
3886
+ vimPinpointEl = null;
3887
+ vimPhase = 'inactive';
3888
+ hidePinpointLabel();
3889
+ updateVimUi();
3890
+ }
3891
+ handled = true;
3892
+ } else if (vimPhase === 'block') {
3893
+ if (vimPinpointEl) vimPinpointEl.classList.remove('plannotator-pinpoint-hover');
3894
+ vimPinpointEl = null;
3895
+ vimPhase = 'inactive';
3896
+ hidePinpointLabel();
3897
+ window.getSelection().removeAllRanges();
3898
+ updateVimUi();
3899
+ handled = true;
3900
+ }
3901
+ } else if (pendingSelection) {
3902
+ // The parent toolbar/comment/label UI owns keys until it resolves.
3903
+ return false;
3904
+ } else if (key === 'g') {
3905
+ if (vimPendingG) {
3906
+ clearTimeout(vimPendingGTimer);
3907
+ vimPendingG = false;
3908
+ vimActionId = 'documentStart';
3909
+ hudKey = 'gg';
3910
+ if (vimPhase === 'text' || vimPhase === 'visual') moveVimDocumentBoundary(false);
3911
+ else {
3912
+ var firstGraph = buildSemanticTargetGraph();
3913
+ if (firstGraph.blocks.length) setVimPinpointTarget(firstGraph.blocks[0]);
3914
+ }
3915
+ } else {
3916
+ vimPendingG = true;
3917
+ vimPendingGTimer = setTimeout(function() { vimPendingG = false; }, 500);
3918
+ }
3919
+ handled = true;
3920
+ } else {
3921
+ if (vimPendingG) {
3922
+ clearTimeout(vimPendingGTimer);
3923
+ vimPendingG = false;
3924
+ }
3925
+
3926
+ if (vimPhase === 'inactive') {
3927
+ setVimPinpointTarget(initialVimPinpointTarget());
3928
+ } else if (vimPhase === 'block' || vimPhase === 'inline') {
3929
+ var currentGraph = buildSemanticTargetGraph();
3930
+ if (!currentVimSemanticTarget(currentGraph)) {
3931
+ setVimPinpointTarget(initialVimPinpointTarget());
3932
+ }
3933
+ }
3934
+
3935
+ if (vimPhase === 'block' || vimPhase === 'inline') {
3936
+ if (key === 'j') handled = moveVimPinpoint(1, false);
3937
+ else if (key === 'k') handled = moveVimPinpoint(-1, false);
3938
+ else if (key === '{') handled = moveVimPinpoint(-1, true);
3939
+ else if (key === '}') handled = moveVimPinpoint(1, true);
3940
+ else if (key === 'h' || key === 'H') handled = refineVimPinpoint(false);
3941
+ else if (key === 'l') handled = refineVimPinpoint(true);
3942
+ else if (key === 'G') {
3943
+ var lastGraph = buildSemanticTargetGraph();
3944
+ if (lastGraph.blocks.length) {
3945
+ setVimPinpointTarget(lastGraph.blocks[lastGraph.blocks.length - 1]);
3946
+ handled = true;
3947
+ }
3948
+ } else if (key === 'v') {
3949
+ var visualGraph = buildSemanticTargetGraph();
3950
+ var visualTarget = currentVimSemanticTarget(visualGraph);
3951
+ if (visualTarget && enterVimTextTarget(visualTarget)) {
3952
+ vimPhase = 'visual';
3953
+ updateVimUi();
3954
+ handled = true;
3955
+ }
3956
+ } else if (key === 'V') {
3957
+ var blockGraph = buildSemanticTargetGraph();
3958
+ var blockTarget = currentVimSemanticTarget(blockGraph);
3959
+ if (blockTarget) {
3960
+ var owningBlock = semanticOwningBlock(blockGraph, blockTarget);
3961
+ if (vimPinpointEl) vimPinpointEl.classList.remove('plannotator-pinpoint-hover');
3962
+ hidePinpointLabel();
3963
+ vimPinpointEl = owningBlock.element;
3964
+ vimPhase = 'visual-block';
3965
+ handled = selectVimBlock(owningBlock.element, true);
3966
+ updateVimUi();
3967
+ }
3968
+ } else if (key === 'y') {
3969
+ copyVimText(vimPinpointEl && vimPinpointEl.textContent || '');
3970
+ handled = !!vimPinpointEl;
3971
+ } else if (key === 'Enter' || vimActionMode(key)) {
3972
+ var semanticActionMode = key === 'Enter' ? vimActiveMode : vimActionMode(key);
3973
+ rememberVimActionState();
3974
+ var semanticActionStarted = vimPinpointEl
3975
+ ? annotateElement(vimPinpointEl, key === 'Enter' ? undefined : semanticActionMode)
3976
+ : false;
3977
+ if (semanticActionStarted) {
3978
+ beginVimAction(semanticActionMode);
3979
+ } else {
3980
+ vimActionReturn = null;
3981
+ }
3982
+ handled = semanticActionStarted;
3983
+ }
3984
+ } else if (vimPhase === 'text' || vimPhase === 'visual') {
3985
+ ensureVimTextCursor();
3986
+ var selection = window.getSelection();
3987
+ if (key === 'v') {
3988
+ vimPhase = vimPhase === 'visual' ? 'text' : 'visual';
3989
+ if (vimPhase === 'text' && selection && selection.focusNode) {
3990
+ setCollapsedSelection(selection.focusNode, selection.focusOffset);
3991
+ }
3992
+ updateVimUi();
3993
+ handled = true;
3994
+ } else if (key === 'V') {
3995
+ var currentBlock = currentVimBlock();
3996
+ if (currentBlock) {
3997
+ if (vimPinpointEl) vimPinpointEl.classList.remove('plannotator-pinpoint-hover');
3998
+ vimPinpointEl = currentBlock;
3999
+ vimPhase = 'visual-block';
4000
+ selectVimBlock(currentBlock, true);
4001
+ handled = true;
4002
+ }
4003
+ updateVimUi();
4004
+ } else if (key === 'o' && selection && !selection.isCollapsed) {
4005
+ selection.setBaseAndExtent(selection.focusNode, selection.focusOffset, selection.anchorNode, selection.anchorOffset);
4006
+ updateVimUi();
4007
+ handled = true;
4008
+ } else if (key === 'G') {
4009
+ handled = moveVimDocumentBoundary(true);
4010
+ } else if (key === 'h') handled = modifyVimSelection('backward', 'character');
4011
+ else if (key === 'l') handled = modifyVimSelection('forward', 'character');
4012
+ else if (key === 'j') handled = modifyVimSelection('forward', 'line');
4013
+ else if (key === 'k') handled = modifyVimSelection('backward', 'line');
4014
+ else if (key === 'w') handled = moveVimWord('forward');
4015
+ else if (key === 'b') handled = moveVimWord('backward');
4016
+ else if (key === 'e') handled = moveVimWord('end');
4017
+ else if (key === '0') handled = modifyVimSelection('backward', 'lineboundary');
4018
+ else if (key === '$') handled = modifyVimSelection('forward', 'lineboundary');
4019
+ else if (key === '{') handled = modifyVimSelection('backward', 'paragraph');
4020
+ else if (key === '}') handled = modifyVimSelection('forward', 'paragraph');
4021
+ else if (key === 'y' && selection && !selection.isCollapsed) {
4022
+ copyVimText(selection.toString());
4023
+ setCollapsedSelection(selection.focusNode, selection.focusOffset);
4024
+ vimPhase = 'text';
4025
+ updateVimUi();
4026
+ handled = true;
4027
+ } else if ((key === 'Enter' || vimActionMode(key)) && selection && !selection.isCollapsed) {
4028
+ var textActionMode = key === 'Enter' ? vimActiveMode : vimActionMode(key);
4029
+ rememberVimActionState();
4030
+ var textActionStarted = handleSelection(key === 'Enter' ? undefined : textActionMode);
4031
+ if (textActionStarted) {
4032
+ beginVimAction(textActionMode);
4033
+ } else {
4034
+ vimActionReturn = null;
4035
+ }
4036
+ handled = textActionStarted;
4037
+ }
4038
+ } else if (vimPhase === 'visual-block') {
4039
+ var blockSelection = window.getSelection();
4040
+ if (key === 'j' || key === 'k') {
4041
+ handled = moveVimVisualBlock(key === 'j' ? 1 : -1);
4042
+ } else if (key === 'V') {
4043
+ restoreVimSemanticTarget();
4044
+ handled = true;
4045
+ } else if (key === 'o' && blockSelection && !blockSelection.isCollapsed) {
4046
+ blockSelection.setBaseAndExtent(
4047
+ blockSelection.focusNode,
4048
+ blockSelection.focusOffset,
4049
+ blockSelection.anchorNode,
4050
+ blockSelection.anchorOffset
4051
+ );
4052
+ var previousBlockAnchor = vimVisualBlockAnchorEl;
4053
+ vimVisualBlockAnchorEl = vimPinpointEl;
4054
+ if (previousBlockAnchor) vimPinpointEl = previousBlockAnchor;
4055
+ handled = true;
4056
+ } else if (key === 'y' && blockSelection && !blockSelection.isCollapsed) {
4057
+ copyVimText(blockSelection.toString());
4058
+ restoreVimSemanticTarget();
4059
+ handled = true;
4060
+ } else if ((key === 'Enter' || vimActionMode(key)) && blockSelection && !blockSelection.isCollapsed) {
4061
+ var blockActionMode = key === 'Enter' ? vimActiveMode : vimActionMode(key);
4062
+ rememberVimActionState();
4063
+ var blockActionStarted = handleSelection(key === 'Enter' ? undefined : blockActionMode);
4064
+ if (blockActionStarted) {
4065
+ beginVimAction(blockActionMode);
4066
+ } else {
4067
+ vimActionReturn = null;
4068
+ }
4069
+ handled = blockActionStarted;
4070
+ }
4071
+ }
4072
+ }
4073
+
4074
+ if (handled) {
4075
+ if (vimHudEnabled && vimActionId) {
4076
+ vimLastActionId = vimActionId;
4077
+ vimLastActionContext = vimCommandContext;
4078
+ updateVimReticle();
4079
+ postToParent({
4080
+ type: PREFIX + 'vim-command',
4081
+ actionId: vimActionId,
4082
+ key: hudKey,
4083
+ context: vimCommandContext
4084
+ });
4085
+ }
4086
+ e.preventDefault();
4087
+ e.stopImmediatePropagation();
4088
+ }
4089
+ return handled;
4090
+ }
4091
+
4092
+ document.addEventListener('keydown', handleVimKeydown, true);
4093
+
4094
+ // Pointer input exits the keyboard-owned semantic state without synthesizing
4095
+ // focus or consuming the pointer event. Drag selection and Pinpoint clicking
4096
+ // then continue through their existing handlers.
4097
+ document.addEventListener('mousedown', function(e) {
4098
+ if (!vimEnabled || isVimEditableTarget(e.target)) return;
4099
+ clearPinpointHover();
4100
+ if (vimPinpointEl) vimPinpointEl.classList.remove('plannotator-pinpoint-hover');
4101
+ vimPinpointEl = null;
4102
+ vimVisualBlockAnchorEl = null;
4103
+ vimPhase = 'inactive';
4104
+ hidePinpointLabel();
4105
+ updateVimUi();
4106
+ }, true);
4107
+
4108
+ // --- Type-to-comment ---
4109
+ // While a selection is pending, focus is inside this iframe, so the parent's
4110
+ // toolbar keydown listener never sees the keystroke. Forward a single printable
4111
+ // char to the parent so it can open a comment pre-filled with it.
4112
+ document.addEventListener('keydown', function(e) {
4113
+ if (!pendingSelection) return;
4114
+ if (isVimEditableTarget(e.target)) return;
4115
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
4116
+ if (!e.key || e.key.length !== 1) return; // single printable char only
4117
+ e.preventDefault();
4118
+ postToParent({ type: PREFIX + 'keytype', key: e.key });
4119
+ // Hand keyboard focus back to the parent window so the comment textarea can
4120
+ // take it. Blurring the <iframe> from the parent isn't enough — the inner
4121
+ // document keeps focus — so the iframe must relinquish it. parent.focus() is
4122
+ // allowed cross-origin (like postMessage); also drop the active element.
4123
+ try { if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); } catch (ex) {}
4124
+ try { parent.focus(); } catch (ex) {}
4125
+ });
4126
+
4127
+ // --- Helpers ---
4128
+
4129
+ function getNodePath(node) {
4130
+ var path = [];
4131
+ while (node && node !== document.body) {
4132
+ if (node.parentNode) {
4133
+ var siblings = node.parentNode.childNodes;
4134
+ var idx = 0;
4135
+ for (var i = 0; i < siblings.length; i++) {
4136
+ if (siblings[i] === node) { idx = i; break; }
4137
+ }
4138
+ path.unshift(idx);
4139
+ }
4140
+ node = node.parentNode;
4141
+ }
4142
+ return path;
4143
+ }
4144
+
4145
+ function resolveNodePath(path) {
4146
+ var node = document.body;
4147
+ for (var i = 0; i < path.length; i++) {
4148
+ if (!node.childNodes[path[i]]) return null;
4149
+ node = node.childNodes[path[i]];
4150
+ }
4151
+ return node;
4152
+ }
4153
+
4154
+ // Framework rerenders and inline edits invalidate resolved geometry.
4155
+ // Coalesced through the same rAF reconcile as scroll/resize — never polled.
4156
+ // Mutations whose targets are all viewer overlay nodes are ignored by
4157
+ // IDENTITY: our own light-DOM box/label style writes must never schedule
4158
+ // the frame that caused them (that would degenerate into a rAF loop).
4159
+ var pageMutationObserver = null;
4160
+
4161
+ // A mutation is viewer-owned when its target is an overlay node OR it is a
4162
+ // childList change whose added/removed nodes are ALL overlay nodes (the
4163
+ // overlay host / hover label / print layer being appended to the root
4164
+ // element, pinned boxes entering <body>). Those writes must neither bump
4165
+ // the re-search generation nor schedule the reconcile frame that caused
4166
+ // them.
4167
+ function isOverlayOnlyMutation(mutation) {
4168
+ if (isViewerOverlayNode(mutation.target)) return true;
4169
+ if (mutation.type !== 'childList') return false;
4170
+ var added = mutation.addedNodes || [];
4171
+ var removed = mutation.removedNodes || [];
4172
+ if (!added.length && !removed.length) return false;
4173
+ for (var i = 0; i < added.length; i++) {
4174
+ if (!isViewerOverlayNode(added[i])) return false;
4175
+ }
4176
+ for (var j = 0; j < removed.length; j++) {
4177
+ if (!isViewerOverlayNode(removed[j])) return false;
4178
+ }
4179
+ return true;
4180
+ }
4181
+
4182
+ // Zero-work observer gate (B4): with no committed records, no pending
4183
+ // draft, and pinpoint inactive there is nothing for the reconcile pass to
4184
+ // do, so a page mutation should not schedule one. The generation bump
4185
+ // still happens — a record registered later must see the mutations that
4186
+ // occurred while the overlay was idle.
4187
+ function reconcileHasWork() {
4188
+ return annRecords.length > 0
4189
+ || !!pendingSelection
4190
+ || !!pendingPinEl
4191
+ || pendingMultiTargets.length > 0
4192
+ || currentInputMethod === 'pinpoint';
4193
+ }
4194
+
4195
+ function watchPageMutations() {
4196
+ if (pageMutationObserver || typeof MutationObserver === 'undefined' || !document.documentElement) return;
4197
+ pageMutationObserver = new MutationObserver(function(mutations) {
4198
+ for (var i = 0; i < mutations.length; i++) {
4199
+ if (!isOverlayOnlyMutation(mutations[i])) {
4200
+ domGeneration += 1; // page text may have changed: unlock dead-target re-search
4201
+ if (reconcileHasWork()) schedulePinpointReconcile();
4202
+ return;
4203
+ }
4204
+ }
4205
+ });
4206
+ // Observe the ROOT element, not <body>: a page that swaps the <body>
4207
+ // element itself (documentElement.replaceChild) produces no record on a
4208
+ // body-scoped observer, permanently locking dead-target re-search behind
4209
+ // a generation that never advances.
4210
+ pageMutationObserver.observe(document.documentElement, {
4211
+ childList: true,
4212
+ subtree: true,
4213
+ attributes: true,
4214
+ characterData: true
4215
+ });
4216
+ }
4217
+
4218
+ // Animations/transitions move geometry without mutations or scroll events:
4219
+ // reconcile when they settle (end or cancel), matching the reference
4220
+ // invalidation set. Capture phase so non-bubbling targets still count.
4221
+ // Viewer-owned light-DOM chrome (pin-enter, vim reticle) settling is
4222
+ // identity-filtered out: our own overlay animations must never schedule
4223
+ // redundant reconciles of the frames they themselves caused.
4224
+ var settleEvents = ['animationend', 'animationcancel', 'transitionend', 'transitioncancel'];
4225
+ for (var settleIndex = 0; settleIndex < settleEvents.length; settleIndex++) {
4226
+ document.addEventListener(settleEvents[settleIndex], function(e) {
4227
+ if (e && e.target && isViewerOverlayNode(e.target)) return;
4228
+ domGeneration += 1; // a settled page animation can land text-affecting state
4229
+ schedulePinpointReconcile();
4230
+ }, { capture: true, passive: true });
4231
+ }
4232
+
4233
+ // Reflow WITHOUT mutation/scroll/animation still moves geometry: a web-font
4234
+ // swap or an image loading into a fixed-height container reflows silently.
4235
+ // Both change GEOMETRY, not text — they schedule a reconcile but never bump
4236
+ // the re-search generation (a dead target stays dead through them).
4237
+ if (
4238
+ document.fonts
4239
+ && document.fonts.ready
4240
+ && typeof document.fonts.ready.then === 'function'
4241
+ ) {
4242
+ document.fonts.ready.then(function() { schedulePinpointReconcile(); }).catch(function() {});
4243
+ }
4244
+ document.addEventListener('load', function(e) {
4245
+ if (!e || !e.target || e.target === document || isViewerOverlayNode(e.target)) return;
4246
+ // A (same-origin) frame load is a text-capable invalidation; image and
4247
+ // media loads are geometry-only.
4248
+ var tag = e.target.tagName || '';
4249
+ if (tag === 'IFRAME' || tag === 'FRAME') domGeneration += 1;
4250
+ schedulePinpointReconcile();
4251
+ }, { capture: true, passive: true });
4252
+
4253
+ // --- Print parity ---
4254
+ // Pre-overlay, inline annotation marks stayed visible in print ON PURPOSE
4255
+ // (matching markdown documents); only pin badges were print-hidden. The
4256
+ // fixed overlay cannot paginate (fixed-position layers repeat or clip per
4257
+ // printed page), so print parity is restored by re-projecting the
4258
+ // committed highlight rects into a temporary ABSOLUTE-positioned light-DOM
4259
+ // layer in document coordinates (viewport rect + scroll offset), appended
4260
+ // to <body> so it paginates with the content. Markers stay print-hidden —
4261
+ // badges were print-hidden pre-overlay too; parity is highlights-print,
4262
+ // markers-don't. Fail-safe: any error tears the layer down and printing
4263
+ // proceeds without annotation visuals.
4264
+ var PRINT_HL_COMMENT = 'background:oklch(0.70 0.18 60 / 0.28);border-bottom:2px solid var(--pn-accent, #d97757);';
4265
+ var PRINT_HL_DELETION = 'background:oklch(from var(--pn-destructive, #c0392b) l c h / 0.28);background-image:linear-gradient(to bottom, transparent calc(50% - 1px), var(--pn-destructive, #c0392b) calc(50% - 1px), var(--pn-destructive, #c0392b) calc(50% + 1px), transparent calc(50% + 1px));';
4266
+ var printLayerEl = null;
4267
+ var retiredPrintLayerEl = null;
4268
+
4269
+ function teardownPrintLayer() {
4270
+ // Deregister the PREVIOUSLY retired layer now: its removal's mutation
4271
+ // record has long been delivered. The layer removed below must STAY
4272
+ // overlay-registered until then — the observer's overlay-only filter
4273
+ // sees removal records asynchronously and must still recognize the node.
4274
+ if (retiredPrintLayerEl) {
4275
+ overlayNodes.delete(retiredPrintLayerEl);
4276
+ retiredPrintLayerEl = null;
4277
+ }
4278
+ if (!printLayerEl) return;
4279
+ try {
4280
+ if (printLayerEl.parentNode) printLayerEl.parentNode.removeChild(printLayerEl);
4281
+ } catch (ex) {}
4282
+ retiredPrintLayerEl = printLayerEl;
4283
+ printLayerEl = null;
4284
+ }
4285
+
4286
+ function buildPrintLayer() {
4287
+ teardownPrintLayer();
4288
+ try {
4289
+ if (!annRecords.length || !document.body) return;
4290
+ var layer = document.createElement('div');
4291
+ layer.setAttribute('data-plannotator-print-layer', '');
4292
+ layer.style.cssText = 'position:absolute;left:0;top:0;width:0;height:0;overflow:visible;pointer-events:none;';
4293
+ var sx = window.scrollX || 0;
4294
+ var sy = window.scrollY || 0;
4295
+ // User-initiated one-shot: printing has no follow-up pass, so a budget
4296
+ // here would silently print fewer highlights with 3+ dead targets.
4297
+ beginDeadSearchPass(Infinity);
4298
+ for (var recordIndex = 0; recordIndex < annRecords.length; recordIndex++) {
4299
+ var record = annRecords[recordIndex];
4300
+ refreshRecordTargets(record);
4301
+ for (var targetIndex = 0; targetIndex < record.targets.length; targetIndex++) {
4302
+ var target = record.targets[targetIndex];
4303
+ if (target.kind !== 'range' || !rangeAlive(target.range)) continue;
4304
+ var geometry = rangeVisualGeometry(target.range);
4305
+ for (var i = 0; i < geometry.paint.length; i++) {
4306
+ var rect = geometry.paint[i];
4307
+ var div = document.createElement('div');
4308
+ div.style.cssText = 'position:absolute;pointer-events:none;border-radius:2px;box-sizing:border-box;'
4309
+ + (record.annType === 'deletion' ? PRINT_HL_DELETION : PRINT_HL_COMMENT);
4310
+ div.style.left = (rect.left + sx) + 'px';
4311
+ div.style.top = (rect.top + sy) + 'px';
4312
+ div.style.width = (rect.width || 0) + 'px';
4313
+ div.style.height = (rect.height || 0) + 'px';
4314
+ layer.appendChild(div);
4315
+ }
4316
+ }
4317
+ }
4318
+ if (!layer.childNodes.length) return;
4319
+ overlayNodes.add(layer);
4320
+ printLayerEl = layer;
4321
+ // Root element, NOT <body>: a page styling body { position: relative }
4322
+ // would make body's padding box the containing block and shift every
4323
+ // stripe by body's document offset. On <html> the containing block is
4324
+ // the initial containing block (matching the viewport+scroll
4325
+ // coordinates computed above) in the overwhelmingly common case where
4326
+ // html itself is not positioned; a positioned documentElement is
4327
+ // accepted as out of scope.
4328
+ (document.documentElement || document.body).appendChild(layer);
4329
+ } catch (exBuild) {
4330
+ try { teardownPrintLayer(); } catch (exDown) {}
4331
+ }
4332
+ }
4333
+
4334
+ window.addEventListener('beforeprint', buildPrintLayer);
4335
+ window.addEventListener('afterprint', teardownPrintLayer);
4336
+ if (typeof window.matchMedia === 'function') {
4337
+ // Safari fires the print media-query transition without beforeprint in
4338
+ // some paths; mirror the layer lifecycle on it.
4339
+ try {
4340
+ var printMedia = window.matchMedia('print');
4341
+ var onPrintMediaChange = function(mediaEvent) {
4342
+ if (mediaEvent && mediaEvent.matches) buildPrintLayer();
4343
+ else teardownPrintLayer();
4344
+ };
4345
+ if (printMedia.addEventListener) printMedia.addEventListener('change', onPrintMediaChange);
4346
+ else if (printMedia.addListener) printMedia.addListener(onPrintMediaChange);
4347
+ } catch (exMedia) {}
4348
+ }
4349
+
4350
+ function onReady() {
4351
+ if (typeof ResizeObserver !== 'undefined' && document.body) {
4352
+ new ResizeObserver(function() {
4353
+ postResize();
4354
+ scheduleVimUiUpdate();
4355
+ schedulePinpointReconcile();
4356
+ }).observe(document.body);
4357
+ }
4358
+ watchPageMutations();
4359
+ // Armed is the default on both surfaces, and live sessions default to
4360
+ // pinpoint: show the cursor affordance immediately instead of waiting for
4361
+ // the parent's first set-input-method/set-annotate-mode round trip.
4362
+ updatePinpointCursor();
4363
+ var readyMsg = { type: PREFIX + 'ready', protocolVersion: 1 };
4364
+ if (LIVE) readyMsg.pageUrl = currentPageUrl();
4365
+ postToParent(readyMsg);
4366
+ }
4367
+ if (document.readyState === 'loading') {
4368
+ document.addEventListener('DOMContentLoaded', onReady);
4369
+ } else {
4370
+ onReady();
4371
+ }
4372
+
4373
+ // Test-only introspection: the DOM suites assert committed-range EXTENT
4374
+ // (which exact text a committed annotation's range binds), which has no
4375
+ // other observable in the overlay model. Exposes nothing the same-realm
4376
+ // page could not already derive — the overlay root is open, highlight
4377
+ // geometry mirrors the ranges, and the text is the page's own content.
4378
+ window.__plannotatorBridgeInternals = {
4379
+ committedRanges: function(id) {
4380
+ var record = findAnnRecord(id);
4381
+ if (!record) return [];
4382
+ var out = [];
4383
+ for (var i = 0; i < record.targets.length; i++) {
4384
+ var target = record.targets[i];
4385
+ if (target.kind === 'range' && target.range) {
4386
+ try { out.push(target.range.cloneRange()); } catch (ex) {}
4387
+ }
4388
+ }
4389
+ return out;
4390
+ }
4391
+ };
4392
+ })();