@plannotator/ui 0.27.0 → 0.29.0

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