@reticlehq/browser 2.8.0 → 2.9.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/NOTICE +7 -0
- package/dist/actions/actions-dom.js +23 -15
- package/dist/actions/actions.d.ts +17 -0
- package/dist/actions/actions.js +132 -29
- package/dist/actions/synthetic-input.d.ts +9 -0
- package/dist/actions/synthetic-input.js +40 -0
- package/dist/commands/commands.js +11 -1
- package/dist/connect-options.d.ts +9 -9
- package/dist/dom/a11y.d.ts +9 -0
- package/dist/dom/a11y.js +19 -0
- package/dist/dom/dom-ignore.d.ts +19 -6
- package/dist/dom/dom-ignore.js +20 -13
- package/dist/dom/query.d.ts +2 -2
- package/dist/dom/query.js +85 -45
- package/dist/dom/snapshot.d.ts +5 -0
- package/dist/dom/snapshot.js +34 -0
- package/dist/dom/source.d.ts +14 -0
- package/dist/dom/source.js +16 -0
- package/dist/observers/install-all.js +4 -1
- package/dist/observers/ipc.d.ts +16 -0
- package/dist/observers/ipc.js +19 -1
- package/dist/observers/network.d.ts +8 -0
- package/dist/observers/network.js +24 -3
- package/dist/observers/state.js +21 -2
- package/dist/presenter/presenter-brand.d.ts +24 -7
- package/dist/presenter/presenter-brand.js +23 -6
- package/dist/presenter/presenter-config.d.ts +102 -9
- package/dist/presenter/presenter-config.js +94 -8
- package/dist/presenter/presenter-controls.d.ts +10 -10
- package/dist/presenter/presenter-controls.js +175 -85
- package/dist/presenter/presenter-dock-layout.d.ts +6 -0
- package/dist/presenter/presenter-dock-layout.js +170 -0
- package/dist/presenter/presenter-drag.d.ts +48 -0
- package/dist/presenter/presenter-drag.js +198 -0
- package/dist/presenter/presenter-effects.d.ts +1 -1
- package/dist/presenter/presenter-effects.js +1 -1
- package/dist/presenter/presenter-glow.d.ts +5 -5
- package/dist/presenter/presenter-glow.js +7 -7
- package/dist/presenter/presenter-heroicons-data.d.ts +41 -0
- package/dist/presenter/presenter-heroicons-data.js +38 -0
- package/dist/presenter/presenter-hud-chrome.d.ts +17 -0
- package/dist/presenter/presenter-hud-chrome.js +41 -0
- package/dist/presenter/presenter-icons.d.ts +44 -0
- package/dist/presenter/presenter-icons.js +100 -0
- package/dist/presenter/presenter-log.d.ts +27 -5
- package/dist/presenter/presenter-log.js +114 -28
- package/dist/presenter/presenter-report-copy.d.ts +83 -0
- package/dist/presenter/presenter-report-copy.js +127 -0
- package/dist/presenter/presenter-report-styles.d.ts +7 -0
- package/dist/presenter/presenter-report-styles.js +102 -0
- package/dist/presenter/presenter-report.d.ts +23 -0
- package/dist/presenter/presenter-report.js +217 -0
- package/dist/presenter/presenter-run-state.d.ts +1 -1
- package/dist/presenter/presenter-settings-styles.d.ts +2 -0
- package/dist/presenter/presenter-settings-styles.js +153 -0
- package/dist/presenter/presenter-settings.d.ts +82 -0
- package/dist/presenter/presenter-settings.js +494 -0
- package/dist/presenter/presenter-shell-styles.d.ts +1 -0
- package/dist/presenter/presenter-shell-styles.js +375 -0
- package/dist/presenter/presenter-shell.d.ts +48 -0
- package/dist/presenter/presenter-shell.js +408 -0
- package/dist/presenter/presenter-styles.d.ts +9 -4
- package/dist/presenter/presenter-styles.js +61 -125
- package/dist/presenter/presenter-tally.d.ts +8 -11
- package/dist/presenter/presenter-tally.js +48 -23
- package/dist/presenter/presenter-test-helpers.d.ts +1 -1
- package/dist/presenter/presenter-test-helpers.js +1 -1
- package/dist/presenter/presenter-verbs.js +2 -2
- package/dist/presenter/presenter-workspace.d.ts +15 -0
- package/dist/presenter/presenter-workspace.js +180 -0
- package/dist/presenter/presenter.d.ts +15 -10
- package/dist/presenter/presenter.js +194 -46
- package/dist/reticle.d.ts +2 -2
- package/dist/reticle.js +23 -19
- package/dist/review/annotator-styles.d.ts +6 -0
- package/dist/review/annotator-styles.js +81 -0
- package/dist/review/annotator.d.ts +29 -17
- package/dist/review/annotator.js +477 -146
- package/package.json +7 -4
package/dist/review/annotator.js
CHANGED
|
@@ -1,121 +1,112 @@
|
|
|
1
|
+
import { isSyntheticInput } from '../actions/synthetic-input.js';
|
|
1
2
|
import { EventType } from '@reticlehq/core';
|
|
3
|
+
import { isReticleUi, isReticleOverlay } from '../dom/dom-ignore.js';
|
|
2
4
|
import { resolveMarkAnchor } from './mark-anchor.js';
|
|
3
5
|
import { nativeSetTimeout, nativeClearTimeout } from '../timers/native-timers.js';
|
|
4
|
-
|
|
6
|
+
import { ANNOTATOR_CSS, ANNOTATOR_ROOT_HTML, MARK_CANCEL, MARK_PENDING_GLYPH, MARK_PLACEHOLDER, MARK_SUBMIT, } from './annotator-styles.js';
|
|
7
|
+
/** The cursor must rest this long before the outline boxes the element under it - calm, not jumpy. */
|
|
5
8
|
const HIGHLIGHT_REST_MS = 130;
|
|
6
|
-
|
|
7
|
-
|
|
9
|
+
const SHAKE_MS = 250;
|
|
10
|
+
const POP_W = 280;
|
|
11
|
+
const POP_H = 170;
|
|
12
|
+
const EDGE = 8;
|
|
8
13
|
const MARK_ATTR = 'data-reticle-mark';
|
|
9
14
|
const ACTIVE_ATTR = 'data-reticle-mark-active';
|
|
10
|
-
const Z = 2147483640;
|
|
11
15
|
const sel = (role) => `[${MARK_ATTR}="${role}"]`;
|
|
12
|
-
const CSS = `
|
|
13
|
-
${sel('fab')}{position:fixed;left:18px;bottom:18px;z-index:${String(Z + 2)};
|
|
14
|
-
font:500 13px/1 "Inter",system-ui,sans-serif;display:inline-flex;align-items:center;gap:7px;
|
|
15
|
-
padding:9px 13px;border-radius:11px;cursor:pointer;color:#e9ebf2;
|
|
16
|
-
background:linear-gradient(180deg,rgba(19,22,32,.92),rgba(13,15,22,.92));
|
|
17
|
-
border:1px solid rgba(255,255,255,.12);box-shadow:0 10px 30px -10px rgba(0,0,0,.6);
|
|
18
|
-
-webkit-backdrop-filter:blur(20px);backdrop-filter:blur(20px);transition:transform .12s,border-color .15s;}
|
|
19
|
-
${sel('fab')}:hover{transform:translateY(-1px);border-color:rgba(124,131,255,.55);}
|
|
20
|
-
${sel('fab')}[data-on="1"]{color:#fff;border-color:#7c83ff;background:linear-gradient(180deg,#6366f1,#4f46e5);}
|
|
21
|
-
${sel('dot')}{width:8px;height:8px;border-radius:50%;background:#ff7a7a;flex:none;}
|
|
22
|
-
${sel('fab')}[data-on="1"] ${sel('dot')}{background:#fff;}
|
|
23
|
-
html[${ACTIVE_ATTR}] *{cursor:crosshair !important;}
|
|
24
|
-
/* The Flag button + its popover are interactive — keep the pointer cursor over them, not crosshair. */
|
|
25
|
-
html[${ACTIVE_ATTR}] ${sel('fab')},html[${ACTIVE_ATTR}] ${sel('fab')} *,html[${ACTIVE_ATTR}] ${sel('pop')},html[${ACTIVE_ATTR}] ${sel('pop')} *{cursor:pointer !important;}
|
|
26
|
-
/* The outline glides to the rested element with an ease (soothing), and fades rather than snapping. */
|
|
27
|
-
${sel('hi')}{position:fixed;z-index:${String(Z + 1)};pointer-events:none;opacity:0;box-sizing:border-box;
|
|
28
|
-
border:2px solid #7c83ff;border-radius:6px;background:rgba(124,131,255,.12);box-shadow:0 0 0 2px rgba(124,131,255,.22);
|
|
29
|
-
transition:left .22s cubic-bezier(.22,1,.36,1),top .22s cubic-bezier(.22,1,.36,1),width .22s cubic-bezier(.22,1,.36,1),height .22s cubic-bezier(.22,1,.36,1),opacity .18s ease;}
|
|
30
|
-
${sel('hi')}[data-on="1"]{opacity:1;}
|
|
31
|
-
${sel('hilabel')}{position:absolute;top:-21px;left:-2px;background:#6366f1;color:#fff;
|
|
32
|
-
font:600 10.5px/1 "Inter",system-ui,sans-serif;padding:3px 6px;border-radius:5px;white-space:nowrap;
|
|
33
|
-
max-width:300px;overflow:hidden;text-overflow:ellipsis;}
|
|
34
|
-
${sel('pin')}{position:fixed;z-index:${String(Z + 1)};width:22px;height:22px;margin:-11px 0 0 -11px;
|
|
35
|
-
border-radius:50% 50% 50% 2px;background:#ff5d5d;border:2px solid #fff;box-shadow:0 4px 12px -2px rgba(0,0,0,.5);
|
|
36
|
-
color:#fff;font:700 11px/18px "Inter",system-ui,sans-serif;text-align:center;pointer-events:none;}
|
|
37
|
-
${sel('pop')}{position:fixed;z-index:${String(Z + 3)};width:280px;box-sizing:border-box;
|
|
38
|
-
background:linear-gradient(180deg,rgba(19,22,32,.96),rgba(13,15,22,.96));border:1px solid rgba(255,255,255,.14);
|
|
39
|
-
border-radius:14px;padding:12px;box-shadow:0 24px 60px -16px rgba(0,0,0,.7);
|
|
40
|
-
-webkit-backdrop-filter:blur(24px);backdrop-filter:blur(24px);font:13px/1.5 "Inter",system-ui,sans-serif;color:#e9ebf2;}
|
|
41
|
-
${sel('pop')} .reticle-mark-where{color:#9aa0b2;font-size:11.5px;margin-bottom:7px;
|
|
42
|
-
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
|
43
|
-
${sel('pop')} textarea{width:100%;box-sizing:border-box;min-height:62px;resize:vertical;
|
|
44
|
-
background:rgba(0,0,0,.3);border:1px solid rgba(255,255,255,.12);border-radius:9px;color:#e9ebf2;
|
|
45
|
-
font:13px/1.45 "Inter",system-ui,sans-serif;padding:8px;outline:none;}
|
|
46
|
-
${sel('pop')} textarea:focus{border-color:#7c83ff;}
|
|
47
|
-
${sel('pop')} .reticle-mark-row{display:flex;gap:8px;align-items:center;margin-top:9px;}
|
|
48
|
-
${sel('pop')} .reticle-mark-hint{margin-right:auto;color:#6a7186;font-size:10.5px;letter-spacing:.02em;}
|
|
49
|
-
${sel('pop')} button{font:600 12px/1 "Inter",system-ui,sans-serif;padding:8px 12px;border-radius:9px;cursor:pointer;border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.05);color:#cdd2e2;}
|
|
50
|
-
${sel('pop')} button[data-send]{background:#6366f1;border-color:#7c83ff;color:#fff;}
|
|
51
|
-
${sel('pop')} button[data-send]:disabled{opacity:.5;cursor:default;}`;
|
|
52
16
|
export class Annotator {
|
|
53
17
|
#emit;
|
|
54
18
|
#now;
|
|
55
19
|
#onMark;
|
|
20
|
+
#shouldBlock;
|
|
21
|
+
#onCountChange;
|
|
56
22
|
#root;
|
|
57
|
-
#fab;
|
|
58
|
-
#pop;
|
|
59
|
-
/** Hover outline box that shows WHICH element a click would flag (agentation-style). */
|
|
60
23
|
#hi;
|
|
61
24
|
#hiLabel;
|
|
62
|
-
|
|
25
|
+
#selBox;
|
|
26
|
+
#pop;
|
|
27
|
+
#pending;
|
|
63
28
|
#hiTimer;
|
|
29
|
+
#shakeTimer;
|
|
64
30
|
#active = false;
|
|
65
|
-
#
|
|
31
|
+
#marks = [];
|
|
32
|
+
#editing;
|
|
33
|
+
#pendingKey;
|
|
34
|
+
#pendingTarget;
|
|
35
|
+
#markersBtn;
|
|
36
|
+
#clearBtn;
|
|
37
|
+
#countEl;
|
|
38
|
+
#accent;
|
|
66
39
|
#onClick;
|
|
67
40
|
#onKeydown;
|
|
68
41
|
#onMove;
|
|
42
|
+
#onScroll;
|
|
43
|
+
#onResize;
|
|
44
|
+
#mo;
|
|
69
45
|
constructor(deps) {
|
|
70
46
|
this.#emit = deps.emit;
|
|
71
47
|
this.#now = deps.now;
|
|
72
48
|
this.#onMark = deps.onMark;
|
|
49
|
+
this.#shouldBlock = deps.shouldBlock ?? (() => true);
|
|
50
|
+
this.#onCountChange = deps.onCountChange;
|
|
73
51
|
}
|
|
74
|
-
/** Whether annotate mode is currently capturing clicks. */
|
|
75
52
|
get active() {
|
|
76
53
|
return this.#active;
|
|
77
54
|
}
|
|
78
|
-
/** Number of marks sent this session (drives the pin numbering). */
|
|
79
55
|
get markCount() {
|
|
80
|
-
return this.#
|
|
56
|
+
return this.#marks.length;
|
|
81
57
|
}
|
|
82
58
|
mount() {
|
|
83
59
|
if (this.#root !== undefined || 'undefined' === typeof document)
|
|
84
60
|
return;
|
|
85
61
|
const style = document.createElement('style');
|
|
86
62
|
style.setAttribute(MARK_ATTR, 'style');
|
|
87
|
-
style.textContent =
|
|
63
|
+
style.textContent = ANNOTATOR_CSS;
|
|
88
64
|
document.head.appendChild(style);
|
|
89
65
|
const root = document.createElement('div');
|
|
90
66
|
root.setAttribute(MARK_ATTR, 'root');
|
|
91
|
-
root.innerHTML =
|
|
92
|
-
<span ${MARK_ATTR}="dot"></span><span>Flag a bug</span></button>
|
|
93
|
-
<div ${MARK_ATTR}="hi"><span ${MARK_ATTR}="hilabel"></span></div>`;
|
|
67
|
+
root.innerHTML = ANNOTATOR_ROOT_HTML;
|
|
94
68
|
document.body.appendChild(root);
|
|
95
69
|
this.#root = root;
|
|
96
|
-
this.#fab = root.querySelector(sel('fab')) ?? undefined;
|
|
97
70
|
this.#hi = root.querySelector(sel('hi')) ?? undefined;
|
|
98
71
|
this.#hiLabel = root.querySelector(sel('hilabel')) ?? undefined;
|
|
99
|
-
this.#
|
|
100
|
-
e.stopPropagation();
|
|
101
|
-
this.toggle();
|
|
102
|
-
});
|
|
103
|
-
// Capture-phase click: intercept the FIRST click on a real page element while active.
|
|
72
|
+
this.#selBox = root.querySelector(sel('sel')) ?? undefined;
|
|
104
73
|
this.#onClick = (ev) => this.#handleClick(ev);
|
|
105
74
|
document.addEventListener('click', this.#onClick, { capture: true });
|
|
106
|
-
// Hover outline: while active, box the element a click would flag — so you see what you're pointing at.
|
|
107
75
|
this.#onMove = (ev) => this.#scheduleMove(ev);
|
|
108
76
|
document.addEventListener('mousemove', this.#onMove, { passive: true, capture: true });
|
|
109
|
-
|
|
110
|
-
this.#onKeydown = (ev) => {
|
|
111
|
-
if (ev.key !== 'Escape' || !this.#active)
|
|
112
|
-
return;
|
|
113
|
-
if (this.#pop !== undefined)
|
|
114
|
-
this.#closePopover();
|
|
115
|
-
else
|
|
116
|
-
this.toggle(false);
|
|
117
|
-
};
|
|
77
|
+
this.#onKeydown = (ev) => this.#handleKey(ev);
|
|
118
78
|
document.addEventListener('keydown', this.#onKeydown);
|
|
79
|
+
this.#onScroll = () => this.#reposition();
|
|
80
|
+
this.#onResize = () => this.#reposition();
|
|
81
|
+
window.addEventListener('scroll', this.#onScroll, true);
|
|
82
|
+
window.addEventListener('resize', this.#onResize);
|
|
83
|
+
this.#mo = new MutationObserver(() => this.syncAnchors());
|
|
84
|
+
this.#mo.observe(document.documentElement, { childList: true, subtree: true });
|
|
85
|
+
if (undefined !== this.#accent)
|
|
86
|
+
this.setAccent(this.#accent);
|
|
87
|
+
}
|
|
88
|
+
/** Drive marker/highlight chrome from the HUD accent swatch. */
|
|
89
|
+
setAccent(hex) {
|
|
90
|
+
this.#accent = hex;
|
|
91
|
+
this.#root?.style.setProperty('--reticle-mark-accent', hex);
|
|
92
|
+
}
|
|
93
|
+
/** @deprecated HUD expand/collapse owns annotate mode; chrome buttons are hide/clear. */
|
|
94
|
+
attachFlagButton(btn) {
|
|
95
|
+
this.attachChrome({ markersBtn: btn });
|
|
96
|
+
}
|
|
97
|
+
attachChrome(chrome) {
|
|
98
|
+
this.#markersBtn = chrome.markersBtn;
|
|
99
|
+
this.#clearBtn = chrome.clearBtn;
|
|
100
|
+
this.#countEl = chrome.countEl;
|
|
101
|
+
this.#markersBtn?.addEventListener('click', (e) => {
|
|
102
|
+
e.stopPropagation();
|
|
103
|
+
this.#toggleMarkers();
|
|
104
|
+
});
|
|
105
|
+
this.#clearBtn?.addEventListener('click', (e) => {
|
|
106
|
+
e.stopPropagation();
|
|
107
|
+
this.clearAll();
|
|
108
|
+
});
|
|
109
|
+
this.#syncChrome();
|
|
119
110
|
}
|
|
120
111
|
destroy() {
|
|
121
112
|
if (this.#onClick !== undefined) {
|
|
@@ -130,22 +121,30 @@ export class Annotator {
|
|
|
130
121
|
document.removeEventListener('mousemove', this.#onMove, { capture: true });
|
|
131
122
|
this.#onMove = undefined;
|
|
132
123
|
}
|
|
133
|
-
if (this.#
|
|
134
|
-
|
|
135
|
-
this.#
|
|
124
|
+
if (this.#onScroll !== undefined) {
|
|
125
|
+
window.removeEventListener('scroll', this.#onScroll, true);
|
|
126
|
+
this.#onScroll = undefined;
|
|
136
127
|
}
|
|
128
|
+
if (this.#onResize !== undefined) {
|
|
129
|
+
window.removeEventListener('resize', this.#onResize);
|
|
130
|
+
this.#onResize = undefined;
|
|
131
|
+
}
|
|
132
|
+
this.#mo?.disconnect();
|
|
133
|
+
this.#mo = undefined;
|
|
134
|
+
if (this.#hiTimer !== undefined)
|
|
135
|
+
nativeClearTimeout(this.#hiTimer);
|
|
136
|
+
if (this.#shakeTimer !== undefined)
|
|
137
|
+
nativeClearTimeout(this.#shakeTimer);
|
|
137
138
|
this.#closePopover();
|
|
138
139
|
document.documentElement.removeAttribute(ACTIVE_ATTR);
|
|
139
140
|
this.#root?.remove();
|
|
140
141
|
document.querySelectorAll(`style[${MARK_ATTR}="style"]`).forEach((s) => s.remove());
|
|
141
142
|
this.#root = undefined;
|
|
142
|
-
this.#fab = undefined;
|
|
143
143
|
this.#active = false;
|
|
144
|
+
this.#marks = [];
|
|
144
145
|
}
|
|
145
|
-
/** Turn annotate mode on/off. With no argument, flips the current state. */
|
|
146
146
|
toggle(on) {
|
|
147
147
|
this.#active = on ?? !this.#active;
|
|
148
|
-
this.#fab?.setAttribute('data-on', this.#active ? '1' : '0');
|
|
149
148
|
if (this.#active)
|
|
150
149
|
document.documentElement.setAttribute(ACTIVE_ATTR, '1');
|
|
151
150
|
else {
|
|
@@ -153,24 +152,116 @@ export class Annotator {
|
|
|
153
152
|
this.#hideHighlight();
|
|
154
153
|
this.#closePopover();
|
|
155
154
|
}
|
|
155
|
+
this.#syncChrome();
|
|
156
|
+
}
|
|
157
|
+
clearAll() {
|
|
158
|
+
this.#closePopover();
|
|
159
|
+
for (const mark of this.#marks)
|
|
160
|
+
mark.pin.remove();
|
|
161
|
+
this.#marks = [];
|
|
162
|
+
this.#syncChrome();
|
|
163
|
+
}
|
|
164
|
+
syncAnchors() {
|
|
165
|
+
const route = currentRoute();
|
|
166
|
+
for (const mark of this.#marks) {
|
|
167
|
+
const live = mark.target !== undefined && document.contains(mark.target);
|
|
168
|
+
if (!live) {
|
|
169
|
+
mark.target = undefined;
|
|
170
|
+
mark.pin.setAttribute('data-stale', '1');
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
mark.pin.removeAttribute('data-stale');
|
|
174
|
+
}
|
|
175
|
+
mark.pin.hidden = mark.route !== route;
|
|
176
|
+
}
|
|
177
|
+
this.#paintSelection();
|
|
178
|
+
}
|
|
179
|
+
#toggleMarkers() {
|
|
180
|
+
const root = this.#root;
|
|
181
|
+
if (root === undefined)
|
|
182
|
+
return;
|
|
183
|
+
const hide = '1' === root.getAttribute('data-hide') ? '0' : '1';
|
|
184
|
+
if ('1' === hide)
|
|
185
|
+
root.setAttribute('data-hide', '1');
|
|
186
|
+
else
|
|
187
|
+
root.removeAttribute('data-hide');
|
|
188
|
+
this.#markersBtn?.setAttribute('data-active', '0' === hide ? '0' : '1');
|
|
189
|
+
this.#markersBtn?.setAttribute('aria-pressed', '1' === hide ? 'true' : 'false');
|
|
190
|
+
}
|
|
191
|
+
#syncChrome() {
|
|
192
|
+
const n = this.#marks.length;
|
|
193
|
+
this.#onCountChange?.(n);
|
|
194
|
+
if (this.#countEl !== undefined) {
|
|
195
|
+
this.#countEl.textContent = String(n);
|
|
196
|
+
this.#countEl.hidden = 0 === n;
|
|
197
|
+
}
|
|
198
|
+
this.#clearBtn?.toggleAttribute('disabled', 0 === n);
|
|
199
|
+
this.#markersBtn?.toggleAttribute('disabled', 0 === n);
|
|
200
|
+
}
|
|
201
|
+
#handleKey(ev) {
|
|
202
|
+
if (ev.key !== 'Escape' || !this.#active)
|
|
203
|
+
return;
|
|
204
|
+
if (this.#pop !== undefined) {
|
|
205
|
+
ev.preventDefault();
|
|
206
|
+
this.#closePopover();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
this.toggle(false);
|
|
156
210
|
}
|
|
157
211
|
#handleClick(ev) {
|
|
158
212
|
if (!this.#active)
|
|
159
213
|
return;
|
|
160
|
-
|
|
161
|
-
|
|
214
|
+
// A click Reticle dispatched is the agent driving the app, not a person placing a mark. Without
|
|
215
|
+
// this, expanding the HUD silently disabled every `reticle_act` click while the action still
|
|
216
|
+
// reported success — a false green in our own UI. See synthetic-input.ts.
|
|
217
|
+
if (isSyntheticInput())
|
|
218
|
+
return;
|
|
219
|
+
const raw = ev.target;
|
|
220
|
+
if (!(raw instanceof Element))
|
|
221
|
+
return;
|
|
222
|
+
// The page blocker is a full-viewport shield Reticle puts up WHILE annotating, so with it on
|
|
223
|
+
// every click lands on Reticle's own UI and was discarded as such — annotate mode looked
|
|
224
|
+
// completely dead, because the one thing standing between the user and the page is the thing
|
|
225
|
+
// annotate mode turns on. Resolve what is underneath instead of giving up on the click.
|
|
226
|
+
const target = raw.hasAttribute(BLOCKER_ATTR_NAME)
|
|
227
|
+
? pageElementAt(ev.clientX, ev.clientY)
|
|
228
|
+
: raw;
|
|
229
|
+
if (target === undefined)
|
|
230
|
+
return;
|
|
231
|
+
if (isReticleOverlay(target))
|
|
232
|
+
return;
|
|
233
|
+
if (true === this.#markersBtn?.contains(target))
|
|
234
|
+
return;
|
|
235
|
+
if (true === this.#clearBtn?.contains(target))
|
|
236
|
+
return;
|
|
237
|
+
if (target.closest(sel('pin')) !== null)
|
|
238
|
+
return;
|
|
239
|
+
if (target.closest(sel('pop')) !== null)
|
|
240
|
+
return;
|
|
241
|
+
if (this.#pop !== undefined) {
|
|
242
|
+
ev.preventDefault();
|
|
243
|
+
ev.stopPropagation();
|
|
244
|
+
this.#shakePopover();
|
|
162
245
|
return;
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
this.#
|
|
246
|
+
}
|
|
247
|
+
const el = target instanceof HTMLElement ? target : target.parentElement;
|
|
248
|
+
if (null === el)
|
|
249
|
+
return;
|
|
250
|
+
if (this.#shouldBlock()) {
|
|
251
|
+
ev.preventDefault();
|
|
252
|
+
ev.stopPropagation();
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
ev.preventDefault();
|
|
256
|
+
}
|
|
257
|
+
const key = markKey(el);
|
|
258
|
+
const existing = this.#marks.find((m) => m.anchor === key && m.route === currentRoute());
|
|
259
|
+
if (existing !== undefined) {
|
|
260
|
+
this.#openEdit(existing);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
this.#openPopover(el, ev.clientX, ev.clientY);
|
|
168
264
|
}
|
|
169
|
-
/**
|
|
170
|
-
* Debounce the outline: while the cursor is moving the human is still travelling, so we wait for a
|
|
171
|
-
* brief rest before boxing the element under it (the box then eases into place via CSS). A pending
|
|
172
|
-
* timer is replaced on every move, so only the resting position ever paints.
|
|
173
|
-
*/
|
|
174
265
|
#scheduleMove(ev) {
|
|
175
266
|
if (this.#hiTimer !== undefined)
|
|
176
267
|
nativeClearTimeout(this.#hiTimer);
|
|
@@ -179,15 +270,21 @@ export class Annotator {
|
|
|
179
270
|
this.#handleMove(ev);
|
|
180
271
|
}, HIGHLIGHT_REST_MS);
|
|
181
272
|
}
|
|
182
|
-
/** Box the element under the cursor (when active, no popover open) so you see what a click flags. */
|
|
183
273
|
#handleMove(ev) {
|
|
184
274
|
if (this.#hi === undefined)
|
|
185
275
|
return;
|
|
186
|
-
const
|
|
276
|
+
const raw = ev.target;
|
|
277
|
+
// Same shield as in #handleClick: while annotating, a full-viewport blocker sits over the page,
|
|
278
|
+
// so EVERY mousemove reports it as the target. Resolving only there left the outline suppressed
|
|
279
|
+
// on every element - a crosshair with no indication of what it was on. See #handleClick.
|
|
280
|
+
const target = raw instanceof Element && raw.hasAttribute(BLOCKER_ATTR_NAME)
|
|
281
|
+
? pageElementAt(ev.clientX, ev.clientY)
|
|
282
|
+
: raw;
|
|
187
283
|
const skip = !this.#active ||
|
|
188
284
|
this.#pop !== undefined ||
|
|
189
285
|
!(target instanceof Element) ||
|
|
190
|
-
target
|
|
286
|
+
isReticleOverlay(target) ||
|
|
287
|
+
target.closest(sel('pin')) !== null;
|
|
191
288
|
if (skip) {
|
|
192
289
|
this.#hi.setAttribute('data-on', '0');
|
|
193
290
|
return;
|
|
@@ -205,7 +302,6 @@ export class Annotator {
|
|
|
205
302
|
if (this.#hiLabel !== undefined)
|
|
206
303
|
this.#hiLabel.textContent = describeEl(target);
|
|
207
304
|
}
|
|
208
|
-
/** Hide the hover outline (annotate mode off, or a popover took over). Cancels any pending box. */
|
|
209
305
|
#hideHighlight() {
|
|
210
306
|
if (this.#hiTimer !== undefined) {
|
|
211
307
|
nativeClearTimeout(this.#hiTimer);
|
|
@@ -213,55 +309,152 @@ export class Annotator {
|
|
|
213
309
|
}
|
|
214
310
|
this.#hi?.setAttribute('data-on', '0');
|
|
215
311
|
}
|
|
216
|
-
#
|
|
312
|
+
#openEdit(mark) {
|
|
217
313
|
this.#closePopover();
|
|
218
|
-
this.#
|
|
314
|
+
this.#editing = mark;
|
|
315
|
+
this.#pendingKey = mark.anchor;
|
|
316
|
+
this.#pendingTarget = mark.target;
|
|
317
|
+
this.#hideHighlight();
|
|
318
|
+
// Where the element is NOW, not where it was when the mark was made. The popover is
|
|
319
|
+
// position:fixed, so it is placed in viewport coordinates, and the stored clientX/clientY are
|
|
320
|
+
// viewport coordinates from an earlier scroll position. Reopening a mark after scrolling put the
|
|
321
|
+
// note somewhere unrelated to the thing it annotates — further away the further you had scrolled.
|
|
322
|
+
const at = this.#currentPoint(mark);
|
|
323
|
+
this.#mountPopover(mark.label, at.x, at.y, mark.note);
|
|
324
|
+
this.#dropPending(at.x, at.y);
|
|
325
|
+
this.#paintSelection();
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* The viewport point a mark's popover should open at.
|
|
329
|
+
*
|
|
330
|
+
* Prefers the element's live box, because that is what the note is ABOUT. Falls back to the stored
|
|
331
|
+
* coordinates only when the element is gone, where there is nothing better and the mark is already
|
|
332
|
+
* shown as stale.
|
|
333
|
+
*/
|
|
334
|
+
#currentPoint(mark) {
|
|
335
|
+
const el = mark.target;
|
|
336
|
+
if (el !== undefined && el.isConnected) {
|
|
337
|
+
const r = el.getBoundingClientRect();
|
|
338
|
+
if (r.width > 0 || r.height > 0)
|
|
339
|
+
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
|
340
|
+
}
|
|
341
|
+
return { x: mark.clientX, y: mark.isFixed ? mark.clientY : mark.yDoc - window.scrollY };
|
|
342
|
+
}
|
|
343
|
+
#openPopover(el, x, y) {
|
|
219
344
|
const resolved = resolveMarkAnchor(el);
|
|
220
|
-
const
|
|
221
|
-
|
|
345
|
+
const key = resolved.anchor;
|
|
346
|
+
if (this.#pendingKey === key && this.#pop !== undefined) {
|
|
347
|
+
this.#shakePopover();
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
this.#closePopover();
|
|
351
|
+
this.#hideHighlight();
|
|
352
|
+
this.#editing = undefined;
|
|
353
|
+
this.#pendingKey = key;
|
|
354
|
+
this.#pendingTarget = el;
|
|
222
355
|
const where = resolved.source !== undefined
|
|
223
356
|
? `${resolved.label} · ${resolved.source.file}:${String(resolved.source.line)}`
|
|
224
357
|
: resolved.label;
|
|
358
|
+
this.#mountPopover(where, x, y, '');
|
|
359
|
+
this.#dropPending(x, y);
|
|
360
|
+
this.#paintSelection();
|
|
361
|
+
this.#bindSubmit(resolved, x, y);
|
|
362
|
+
}
|
|
363
|
+
#mountPopover(where, x, y, initial) {
|
|
364
|
+
const pop = document.createElement('div');
|
|
365
|
+
pop.setAttribute(MARK_ATTR, 'pop');
|
|
366
|
+
pop.setAttribute('role', 'dialog');
|
|
367
|
+
pop.setAttribute('aria-label', MARK_PLACEHOLDER);
|
|
225
368
|
pop.innerHTML = `<div class="reticle-mark-where"></div>
|
|
226
|
-
<textarea
|
|
227
|
-
<div class="reticle-mark-row"
|
|
228
|
-
|
|
369
|
+
<textarea rows="2" placeholder="${MARK_PLACEHOLDER}"></textarea>
|
|
370
|
+
<div class="reticle-mark-row">
|
|
371
|
+
<button type="button" data-cancel>${MARK_CANCEL}</button>
|
|
372
|
+
<button type="button" data-send disabled>${MARK_SUBMIT}</button>
|
|
373
|
+
</div>`;
|
|
229
374
|
const whereEl = pop.querySelector('.reticle-mark-where');
|
|
230
375
|
if (whereEl !== null)
|
|
231
376
|
whereEl.textContent = where;
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
pop.style.
|
|
235
|
-
pop
|
|
236
|
-
document.body.appendChild(pop);
|
|
377
|
+
const pos = clampPop(x, y);
|
|
378
|
+
pop.style.left = `${String(pos.left)}px`;
|
|
379
|
+
pop.style.top = `${String(pos.top)}px`;
|
|
380
|
+
this.#root?.appendChild(pop);
|
|
237
381
|
this.#pop = pop;
|
|
382
|
+
requestAnimationFrame(() => pop.setAttribute('data-in', '1'));
|
|
238
383
|
const textarea = pop.querySelector('textarea');
|
|
239
384
|
const send = pop.querySelector('button[data-send]');
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
if (0 === note.length)
|
|
243
|
-
return; // never send an empty mark
|
|
244
|
-
this.#sendMark(note, resolved, x, y);
|
|
245
|
-
this.#closePopover();
|
|
246
|
-
};
|
|
247
|
-
textarea?.addEventListener('input', () => {
|
|
385
|
+
if (textarea !== null) {
|
|
386
|
+
textarea.value = initial;
|
|
248
387
|
if (send !== null)
|
|
249
388
|
send.disabled = 0 === textarea.value.trim().length;
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
e.
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
389
|
+
textarea.addEventListener('input', () => {
|
|
390
|
+
if (send !== null)
|
|
391
|
+
send.disabled = 0 === textarea.value.trim().length;
|
|
392
|
+
});
|
|
393
|
+
textarea.addEventListener('keydown', (e) => {
|
|
394
|
+
e.stopPropagation();
|
|
395
|
+
if (e.isComposing)
|
|
396
|
+
return;
|
|
397
|
+
if ('Escape' === e.key) {
|
|
398
|
+
e.preventDefault();
|
|
399
|
+
this.#closePopover();
|
|
400
|
+
}
|
|
401
|
+
else if ('Enter' === e.key && !e.shiftKey) {
|
|
402
|
+
e.preventDefault();
|
|
403
|
+
this.#submitOpen();
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
nativeSetTimeout(() => textarea.focus(), 50);
|
|
407
|
+
}
|
|
263
408
|
pop.querySelector('button[data-cancel]')?.addEventListener('click', () => this.#closePopover());
|
|
264
|
-
send?.addEventListener('click',
|
|
409
|
+
send?.addEventListener('click', () => this.#submitOpen());
|
|
410
|
+
this.#pendingResolved = undefined;
|
|
411
|
+
this.#pendingXY = { x, y };
|
|
412
|
+
}
|
|
413
|
+
#pendingResolved;
|
|
414
|
+
#pendingXY = { x: 0, y: 0 };
|
|
415
|
+
#bindSubmit(resolved, x, y) {
|
|
416
|
+
this.#pendingResolved = resolved;
|
|
417
|
+
this.#pendingXY = { x, y };
|
|
418
|
+
}
|
|
419
|
+
#submitOpen() {
|
|
420
|
+
const note = this.#pop?.querySelector('textarea')?.value.trim() ?? '';
|
|
421
|
+
if (0 === note.length)
|
|
422
|
+
return;
|
|
423
|
+
const editing = this.#editing;
|
|
424
|
+
if (editing !== undefined) {
|
|
425
|
+
editing.note = note;
|
|
426
|
+
const tipn = editing.pin.querySelector(sel('tipn'));
|
|
427
|
+
if (tipn !== null)
|
|
428
|
+
tipn.textContent = note;
|
|
429
|
+
this.#emit(EventType.HUMAN_MARK, {
|
|
430
|
+
note,
|
|
431
|
+
anchor: editing.anchor,
|
|
432
|
+
label: editing.label,
|
|
433
|
+
route: editing.route,
|
|
434
|
+
});
|
|
435
|
+
this.#report(note, editing.anchor, editing.label, this.#marks.indexOf(editing) + 1, editing.source);
|
|
436
|
+
this.#closePopover();
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const resolved = this.#pendingResolved;
|
|
440
|
+
if (resolved === undefined)
|
|
441
|
+
return;
|
|
442
|
+
this.#sendMark(note, resolved, this.#pendingXY.x, this.#pendingXY.y);
|
|
443
|
+
this.#closePopover();
|
|
444
|
+
}
|
|
445
|
+
#shakePopover() {
|
|
446
|
+
const pop = this.#pop;
|
|
447
|
+
if (pop === undefined)
|
|
448
|
+
return;
|
|
449
|
+
pop.classList.remove('reticle-mark-shake');
|
|
450
|
+
void pop.offsetWidth;
|
|
451
|
+
pop.classList.add('reticle-mark-shake');
|
|
452
|
+
if (this.#shakeTimer !== undefined)
|
|
453
|
+
nativeClearTimeout(this.#shakeTimer);
|
|
454
|
+
this.#shakeTimer = nativeSetTimeout(() => {
|
|
455
|
+
pop.classList.remove('reticle-mark-shake');
|
|
456
|
+
this.#shakeTimer = undefined;
|
|
457
|
+
}, SHAKE_MS);
|
|
265
458
|
}
|
|
266
459
|
#sendMark(note, resolved, x, y) {
|
|
267
460
|
const data = {
|
|
@@ -269,40 +462,179 @@ export class Annotator {
|
|
|
269
462
|
anchor: resolved.anchor,
|
|
270
463
|
strategy: resolved.strategy,
|
|
271
464
|
label: resolved.label,
|
|
272
|
-
route:
|
|
465
|
+
route: currentRoute(),
|
|
273
466
|
};
|
|
274
467
|
if (resolved.source !== undefined)
|
|
275
468
|
data['source'] = resolved.source;
|
|
276
469
|
this.#emit(EventType.HUMAN_MARK, data);
|
|
277
|
-
this.#
|
|
278
|
-
this.#
|
|
279
|
-
this.#
|
|
470
|
+
this.#now();
|
|
471
|
+
this.#dropPin(resolved, note, x, y);
|
|
472
|
+
this.#report(note, resolved.anchor, resolved.label, this.#marks.length, sourceLabel(resolved));
|
|
280
473
|
}
|
|
281
|
-
|
|
474
|
+
/** Hand the HUD everything it needs to name this mark in one row. */
|
|
475
|
+
#report(note, anchor, label, index, source) {
|
|
476
|
+
const mark = { note, anchor, label, index };
|
|
477
|
+
if (source !== undefined)
|
|
478
|
+
mark.source = source;
|
|
479
|
+
this.#onMark?.(mark);
|
|
480
|
+
}
|
|
481
|
+
#dropPending(x, y) {
|
|
482
|
+
this.#pending?.remove();
|
|
483
|
+
const pending = document.createElement('div');
|
|
484
|
+
pending.setAttribute(MARK_ATTR, 'pending');
|
|
485
|
+
pending.textContent = MARK_PENDING_GLYPH;
|
|
486
|
+
pending.style.left = `${String(x)}px`;
|
|
487
|
+
pending.style.top = `${String(y)}px`;
|
|
488
|
+
this.#root?.appendChild(pending);
|
|
489
|
+
this.#pending = pending;
|
|
490
|
+
}
|
|
491
|
+
#dropPin(resolved, note, x, y) {
|
|
282
492
|
if (this.#root === undefined)
|
|
283
493
|
return;
|
|
284
|
-
const pin = document.createElement('
|
|
494
|
+
const pin = document.createElement('button');
|
|
495
|
+
pin.type = 'button';
|
|
285
496
|
pin.setAttribute(MARK_ATTR, 'pin');
|
|
286
|
-
pin.
|
|
287
|
-
|
|
288
|
-
pin.
|
|
497
|
+
pin.setAttribute('aria-label', resolved.label);
|
|
498
|
+
const n = this.#marks.length + 1;
|
|
499
|
+
pin.innerHTML = `<span>${String(n)}</span><span ${MARK_ATTR}="tip"><span ${MARK_ATTR}="tipq"></span><span ${MARK_ATTR}="tipn"></span></span>`;
|
|
500
|
+
const q = pin.querySelector(sel('tipq'));
|
|
501
|
+
const tn = pin.querySelector(sel('tipn'));
|
|
502
|
+
if (q !== null)
|
|
503
|
+
q.textContent = resolved.label;
|
|
504
|
+
if (tn !== null)
|
|
505
|
+
tn.textContent = note;
|
|
506
|
+
const isFixed = isElementFixed(this.#pendingTarget);
|
|
507
|
+
const yDoc = isFixed ? y : y + window.scrollY;
|
|
508
|
+
pin.style.left = `${String((x / window.innerWidth) * 100)}%`;
|
|
509
|
+
pin.style.top = `${String(isFixed ? y : yDoc - window.scrollY)}px`;
|
|
289
510
|
this.#root.appendChild(pin);
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
511
|
+
const mark = {
|
|
512
|
+
id: `${resolved.anchor}:${String(this.#now())}:${String(n)}`,
|
|
513
|
+
note,
|
|
514
|
+
label: resolved.label,
|
|
515
|
+
anchor: resolved.anchor,
|
|
516
|
+
route: currentRoute(),
|
|
517
|
+
xPct: (x / window.innerWidth) * 100,
|
|
518
|
+
yDoc,
|
|
519
|
+
clientX: x,
|
|
520
|
+
clientY: y,
|
|
521
|
+
isFixed,
|
|
522
|
+
pin,
|
|
523
|
+
target: this.#pendingTarget,
|
|
524
|
+
source: sourceLabel(resolved),
|
|
525
|
+
};
|
|
526
|
+
pin.addEventListener('click', (e) => {
|
|
527
|
+
e.preventDefault();
|
|
528
|
+
e.stopPropagation();
|
|
529
|
+
if (!this.#active)
|
|
530
|
+
return;
|
|
531
|
+
this.#openEdit(mark);
|
|
532
|
+
});
|
|
533
|
+
this.#marks.push(mark);
|
|
534
|
+
this.#syncChrome();
|
|
535
|
+
}
|
|
536
|
+
#paintSelection() {
|
|
537
|
+
const box = this.#selBox;
|
|
538
|
+
if (box === undefined)
|
|
539
|
+
return;
|
|
540
|
+
const el = this.#pendingTarget;
|
|
541
|
+
if (el === undefined || !document.contains(el) || this.#pop === undefined) {
|
|
542
|
+
box.hidden = true;
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
const rect = el.getBoundingClientRect();
|
|
546
|
+
box.hidden = false;
|
|
547
|
+
box.style.left = `${String(rect.left)}px`;
|
|
548
|
+
box.style.top = `${String(rect.top)}px`;
|
|
549
|
+
box.style.width = `${String(rect.width)}px`;
|
|
550
|
+
box.style.height = `${String(rect.height)}px`;
|
|
551
|
+
}
|
|
552
|
+
#reposition() {
|
|
553
|
+
for (const mark of this.#marks) {
|
|
554
|
+
if (mark.isFixed) {
|
|
555
|
+
mark.pin.style.top = `${String(mark.clientY)}px`;
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
mark.pin.style.top = `${String(mark.yDoc - window.scrollY)}px`;
|
|
559
|
+
}
|
|
560
|
+
this.#repositionPopover();
|
|
561
|
+
this.#paintSelection();
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Keep an OPEN popover attached to its element while the page scrolls.
|
|
565
|
+
*
|
|
566
|
+
* The pins were repositioned here and the popover was not, so scrolling with a note open slid the
|
|
567
|
+
* note off its target and left it floating over unrelated content.
|
|
568
|
+
*/
|
|
569
|
+
#repositionPopover() {
|
|
570
|
+
const pop = this.#pop;
|
|
571
|
+
const mark = this.#editing;
|
|
572
|
+
if (pop === undefined || mark === undefined)
|
|
573
|
+
return;
|
|
574
|
+
const at = this.#currentPoint(mark);
|
|
575
|
+
const pos = clampPop(at.x, at.y);
|
|
576
|
+
pop.style.left = `${String(pos.left)}px`;
|
|
577
|
+
pop.style.top = `${String(pos.top)}px`;
|
|
296
578
|
}
|
|
297
579
|
#closePopover() {
|
|
298
580
|
this.#pop?.remove();
|
|
299
581
|
this.#pop = undefined;
|
|
582
|
+
this.#pending?.remove();
|
|
583
|
+
this.#pending = undefined;
|
|
584
|
+
this.#pendingKey = undefined;
|
|
585
|
+
this.#pendingTarget = undefined;
|
|
586
|
+
this.#pendingResolved = undefined;
|
|
587
|
+
this.#editing = undefined;
|
|
588
|
+
if (this.#selBox !== undefined)
|
|
589
|
+
this.#selBox.hidden = true;
|
|
300
590
|
}
|
|
301
591
|
}
|
|
592
|
+
/** "file:line" for a resolved anchor, when the app carries source stamps. */
|
|
593
|
+
function sourceLabel(resolved) {
|
|
594
|
+
return resolved.source === undefined
|
|
595
|
+
? undefined
|
|
596
|
+
: `${resolved.source.file}:${String(resolved.source.line)}`;
|
|
597
|
+
}
|
|
598
|
+
function currentRoute() {
|
|
599
|
+
return 'undefined' === typeof location ? '' : location.pathname + location.search;
|
|
600
|
+
}
|
|
601
|
+
function markKey(el) {
|
|
602
|
+
return resolveMarkAnchor(el).anchor;
|
|
603
|
+
}
|
|
604
|
+
function isElementFixed(el) {
|
|
605
|
+
if (el === undefined || 'undefined' === typeof getComputedStyle)
|
|
606
|
+
return false;
|
|
607
|
+
const pos = getComputedStyle(el).position;
|
|
608
|
+
return 'fixed' === pos || 'sticky' === pos;
|
|
609
|
+
}
|
|
610
|
+
/** The blocker Reticle raises over the page while annotating; see the click handler. */
|
|
611
|
+
const BLOCKER_ATTR_NAME = 'data-reticle-blocker';
|
|
302
612
|
/**
|
|
303
|
-
*
|
|
304
|
-
*
|
|
613
|
+
* The topmost element of the HOST PAGE at a point, seeing past Reticle's own overlays.
|
|
614
|
+
*
|
|
615
|
+
* `elementsFromPoint` returns the whole stack topmost-first, so the page element is simply the first
|
|
616
|
+
* entry that is not ours. Returns undefined where the API is unavailable (jsdom) or nothing but
|
|
617
|
+
* Reticle UI is there, and the caller drops the click rather than guessing at a target.
|
|
305
618
|
*/
|
|
619
|
+
function pageElementAt(x, y) {
|
|
620
|
+
const from = document.elementsFromPoint.bind(document);
|
|
621
|
+
if (from === undefined)
|
|
622
|
+
return undefined;
|
|
623
|
+
for (const el of from(x, y)) {
|
|
624
|
+
if (!isReticleUi(el))
|
|
625
|
+
return el;
|
|
626
|
+
}
|
|
627
|
+
return undefined;
|
|
628
|
+
}
|
|
629
|
+
function clampPop(x, y) {
|
|
630
|
+
const vw = window.innerWidth;
|
|
631
|
+
const vh = window.innerHeight;
|
|
632
|
+
const left = Math.min(Math.max(x, EDGE + POP_W / 2), vw - EDGE - POP_W / 2);
|
|
633
|
+
let top = y + 12;
|
|
634
|
+
if (top + POP_H > vh - EDGE)
|
|
635
|
+
top = Math.max(EDGE, y - POP_H - 12);
|
|
636
|
+
return { left, top };
|
|
637
|
+
}
|
|
306
638
|
function describeEl(el) {
|
|
307
639
|
const testid = el.getAttribute('data-testid');
|
|
308
640
|
if (testid !== null && testid.length > 0)
|
|
@@ -314,7 +646,6 @@ function describeEl(el) {
|
|
|
314
646
|
const text = (el.textContent ?? '').trim().replace(/\s+/g, ' ').slice(0, 40);
|
|
315
647
|
return text.length > 0 ? `${tag} "${text}"` : tag;
|
|
316
648
|
}
|
|
317
|
-
/** Construct + mount the annotator (mirrors installRecorder's ergonomics). */
|
|
318
649
|
export function installAnnotator(deps) {
|
|
319
650
|
const annotator = new Annotator(deps);
|
|
320
651
|
annotator.mount();
|