@plannotator/pi-extension 0.27.5 → 0.27.6

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