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