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