@coframe-gtm/annotations 1.1.0 → 1.2.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/package.json +1 -1
- package/src/inject/bundle-source.generated.ts +1 -1
- package/src/output.ts +1 -1
- package/src/picker.ts +23 -5
- package/src/store.ts +34 -0
- package/src/ui/Avatar.ts +62 -0
- package/src/ui/Composer.ts +51 -11
- package/src/ui/Cursors.ts +1 -5
- package/src/ui/Pins.ts +91 -21
- package/src/ui/ThreadPanel.ts +22 -10
- package/src/ui/Toolbar.ts +202 -20
- package/src/ui/brand.ts +16 -0
- package/src/ui/helpers.ts +89 -0
- package/src/ui/overlay.ts +8 -0
- package/src/ui/styles.ts +194 -39
package/src/ui/ThreadPanel.ts
CHANGED
|
@@ -13,20 +13,20 @@
|
|
|
13
13
|
import { api } from "../api.js";
|
|
14
14
|
import { activeThreadId, annotations, author } from "../store.js";
|
|
15
15
|
import { SEVERITY_COLOR } from "./constants.js";
|
|
16
|
+
import { renderAvatar, type AvatarIdentity } from "./Avatar.js";
|
|
17
|
+
import { annotationBox, placeNear } from "./helpers.js";
|
|
18
|
+
import { brandGlyph } from "./brand.js";
|
|
16
19
|
import { el } from "./dom.js";
|
|
17
20
|
|
|
18
|
-
export function Message(
|
|
19
|
-
|
|
20
|
-
name: string,
|
|
21
|
-
content: string,
|
|
22
|
-
): HTMLElement {
|
|
21
|
+
export function Message(idt: AvatarIdentity, content: string): HTMLElement {
|
|
22
|
+
const name = idt.displayName ?? (idt.kind === "agent" ? "Agent" : "You");
|
|
23
23
|
return el(
|
|
24
24
|
"div",
|
|
25
|
-
{ class: `cf-msg cf-msg-${
|
|
25
|
+
{ class: `cf-msg cf-msg-${idt.kind}` },
|
|
26
26
|
el(
|
|
27
27
|
"div",
|
|
28
28
|
{ class: "cf-msg-meta" },
|
|
29
|
-
|
|
29
|
+
renderAvatar(idt),
|
|
30
30
|
el("span", { class: "cf-msg-name" }, name),
|
|
31
31
|
),
|
|
32
32
|
el("div", { class: "cf-msg-body" }, content),
|
|
@@ -62,12 +62,13 @@ export function renderThreadPanel(id: string): HTMLElement | null {
|
|
|
62
62
|
sendBtn.disabled = !textarea.value.trim();
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
const popup = el(
|
|
66
66
|
"div",
|
|
67
67
|
{ class: "cf-popup cf-thread" },
|
|
68
68
|
el(
|
|
69
69
|
"div",
|
|
70
70
|
{ class: "cf-popup-head" },
|
|
71
|
+
brandGlyph("cf-popup-brand"),
|
|
71
72
|
el("span", { class: "cf-popup-target" }, `<${a.element}>`),
|
|
72
73
|
a.severity
|
|
73
74
|
? el(
|
|
@@ -91,9 +92,12 @@ export function renderThreadPanel(id: string): HTMLElement | null {
|
|
|
91
92
|
el(
|
|
92
93
|
"div",
|
|
93
94
|
{ class: "cf-thread-body" },
|
|
94
|
-
Message(a.author
|
|
95
|
+
Message(a.author ?? { kind: "human", displayName: "You" }, a.comment),
|
|
95
96
|
...(a.thread ?? []).map((m) =>
|
|
96
|
-
Message(
|
|
97
|
+
Message(
|
|
98
|
+
{ kind: m.role, displayName: m.role === "agent" ? "Agent" : "You" },
|
|
99
|
+
m.content,
|
|
100
|
+
),
|
|
97
101
|
),
|
|
98
102
|
),
|
|
99
103
|
el(
|
|
@@ -117,4 +121,12 @@ export function renderThreadPanel(id: string): HTMLElement | null {
|
|
|
117
121
|
),
|
|
118
122
|
),
|
|
119
123
|
);
|
|
124
|
+
|
|
125
|
+
// Anchor the thread panel next to its annotated element.
|
|
126
|
+
const box = annotationBox(a);
|
|
127
|
+
if (box) placeNear(popup, box);
|
|
128
|
+
// Focus the reply field on open (the `autofocus` attribute doesn't fire
|
|
129
|
+
// on dynamically-inserted shadow-root nodes).
|
|
130
|
+
requestAnimationFrame(() => textarea.focus({ preventScroll: true }));
|
|
131
|
+
return popup;
|
|
120
132
|
}
|
package/src/ui/Toolbar.ts
CHANGED
|
@@ -1,51 +1,170 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Toolbar —
|
|
3
|
-
* hint. Reads cross-cutting state from the `../store.js` signals and
|
|
4
|
-
* commits multi-selections through `../picker.js`.
|
|
2
|
+
* Toolbar — minimal, logo-first control surface.
|
|
5
3
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* - Collapsed (default): a small draggable logo "FAB". Tap to expand.
|
|
5
|
+
* - Expanded: a tight row — [grip] [logo] [View | Comment] [count] —
|
|
6
|
+
* plus the tool row + primary action when commenting. A visible grip
|
|
7
|
+
* signals "draggable"; the logo collapses back to the puck.
|
|
8
|
+
*
|
|
9
|
+
* Vanilla DOM. `renderToolbar()` rebuilds from the current signal values.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import {
|
|
13
|
+
activeThreadId,
|
|
12
14
|
annotations,
|
|
15
|
+
collapsed,
|
|
13
16
|
feedbackTool,
|
|
14
17
|
hoverBox,
|
|
15
18
|
mode,
|
|
16
19
|
multiSelection,
|
|
20
|
+
primaryAction,
|
|
21
|
+
toolbarPos,
|
|
17
22
|
} from "../store.js";
|
|
18
23
|
import { commitMultiSelection } from "../picker.js";
|
|
24
|
+
import { api } from "../api.js";
|
|
25
|
+
import { brandGlyph } from "./brand.js";
|
|
19
26
|
import { el } from "./dom.js";
|
|
20
27
|
|
|
28
|
+
const EDGE = 8; // keep this many px of margin from every viewport edge
|
|
29
|
+
const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.min(v, hi));
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Drag `target` by `handle`. Buttons inside the handle still click
|
|
33
|
+
* (they're skipped). Moves the element directly during the drag, then
|
|
34
|
+
* commits the final position to `toolbarPos`. If the pointer barely
|
|
35
|
+
* moved it's a tap → `onTap` (used to expand the collapsed FAB).
|
|
36
|
+
*/
|
|
37
|
+
function attachDrag(target: HTMLElement, handle: HTMLElement, onTap?: () => void): void {
|
|
38
|
+
handle.addEventListener("pointerdown", (e: PointerEvent) => {
|
|
39
|
+
if ((e.target as HTMLElement)?.closest("button")) return; // let buttons click
|
|
40
|
+
e.preventDefault();
|
|
41
|
+
const rect = target.getBoundingClientRect();
|
|
42
|
+
const offX = e.clientX - rect.left;
|
|
43
|
+
const offY = e.clientY - rect.top;
|
|
44
|
+
const startX = e.clientX;
|
|
45
|
+
const startY = e.clientY;
|
|
46
|
+
let x = rect.left;
|
|
47
|
+
let y = rect.top;
|
|
48
|
+
let moved = false;
|
|
49
|
+
handle.setPointerCapture(e.pointerId);
|
|
50
|
+
target.classList.add("cf-dragging");
|
|
51
|
+
|
|
52
|
+
const move = (ev: PointerEvent): void => {
|
|
53
|
+
if (Math.abs(ev.clientX - startX) + Math.abs(ev.clientY - startY) > 4) moved = true;
|
|
54
|
+
x = clamp(ev.clientX - offX, EDGE, window.innerWidth - target.offsetWidth - EDGE);
|
|
55
|
+
y = clamp(ev.clientY - offY, EDGE, window.innerHeight - target.offsetHeight - EDGE);
|
|
56
|
+
target.style.left = `${x}px`;
|
|
57
|
+
target.style.top = `${y}px`;
|
|
58
|
+
target.style.right = "auto";
|
|
59
|
+
target.style.bottom = "auto";
|
|
60
|
+
};
|
|
61
|
+
const up = (): void => {
|
|
62
|
+
target.classList.remove("cf-dragging");
|
|
63
|
+
handle.removeEventListener("pointermove", move);
|
|
64
|
+
handle.removeEventListener("pointerup", up);
|
|
65
|
+
if (moved) toolbarPos.value = { x, y };
|
|
66
|
+
else onTap?.();
|
|
67
|
+
};
|
|
68
|
+
handle.addEventListener("pointermove", move);
|
|
69
|
+
handle.addEventListener("pointerup", up);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Scroll the annotation's element into view and open its thread. */
|
|
74
|
+
function jumpTo(index: number): void {
|
|
75
|
+
const list = annotations.value;
|
|
76
|
+
if (!list.length) return;
|
|
77
|
+
const i = ((index % list.length) + list.length) % list.length;
|
|
78
|
+
const a = list[i]!;
|
|
79
|
+
let target: Element | null = null;
|
|
80
|
+
try {
|
|
81
|
+
target =
|
|
82
|
+
(a.fullPath && document.querySelector(a.fullPath)) ||
|
|
83
|
+
(a.elementPath ? document.querySelector(a.elementPath) : null);
|
|
84
|
+
} catch {
|
|
85
|
+
/* invalid selector — fall through */
|
|
86
|
+
}
|
|
87
|
+
target?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
88
|
+
activeThreadId.value = a.id;
|
|
89
|
+
}
|
|
90
|
+
|
|
21
91
|
export function hintFor(tool: string): string {
|
|
22
92
|
if (tool === "text") return "Select text on the page to annotate it.";
|
|
23
93
|
if (tool === "multi") return "Click elements to group them, then commit.";
|
|
24
94
|
return "Click any element to comment. Esc to exit.";
|
|
25
95
|
}
|
|
26
96
|
|
|
27
|
-
export function SegButton(
|
|
28
|
-
label: string,
|
|
29
|
-
on: boolean,
|
|
30
|
-
onClick: () => void,
|
|
31
|
-
): HTMLElement {
|
|
97
|
+
export function SegButton(label: string, on: boolean, onClick: () => void): HTMLElement {
|
|
32
98
|
return el("button", { class: `cf-seg-btn${on ? " on" : ""}`, onClick }, label);
|
|
33
99
|
}
|
|
34
100
|
|
|
101
|
+
function posStyle(): string | undefined {
|
|
102
|
+
const pos = toolbarPos.value;
|
|
103
|
+
return pos ? `left:${pos.x}px;top:${pos.y}px;right:auto;bottom:auto;` : undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* After the element is in the DOM, measure its real size and nudge it
|
|
108
|
+
* fully on-screen. The panel anchors top-left, so a wide expansion near
|
|
109
|
+
* the right/bottom edge would otherwise overflow — this pulls it back.
|
|
110
|
+
*/
|
|
111
|
+
function keepOnScreen(elx: HTMLElement): void {
|
|
112
|
+
requestAnimationFrame(() => {
|
|
113
|
+
const r = elx.getBoundingClientRect();
|
|
114
|
+
if (!r.width || !r.height) return;
|
|
115
|
+
const x = clamp(r.left, EDGE, Math.max(EDGE, window.innerWidth - r.width - EDGE));
|
|
116
|
+
const y = clamp(r.top, EDGE, Math.max(EDGE, window.innerHeight - r.height - EDGE));
|
|
117
|
+
if (Math.abs(x - r.left) > 0.5 || Math.abs(y - r.top) > 0.5) {
|
|
118
|
+
elx.style.left = `${x}px`;
|
|
119
|
+
elx.style.top = `${y}px`;
|
|
120
|
+
elx.style.right = "auto";
|
|
121
|
+
elx.style.bottom = "auto";
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
35
126
|
export function renderToolbar(): HTMLElement {
|
|
36
|
-
const active = mode.value === "feedback";
|
|
37
127
|
const count = annotations.value.length;
|
|
128
|
+
|
|
129
|
+
// Collapsed → a minimal draggable logo. Tap (or Enter/Space) to open.
|
|
130
|
+
if (collapsed.value) {
|
|
131
|
+
const fab = el("div", {
|
|
132
|
+
class: "cf-fab",
|
|
133
|
+
role: "button",
|
|
134
|
+
tabindex: "0",
|
|
135
|
+
title: "Open annotations · drag to move",
|
|
136
|
+
style: posStyle(),
|
|
137
|
+
});
|
|
138
|
+
fab.appendChild(brandGlyph());
|
|
139
|
+
if (count > 0) {
|
|
140
|
+
fab.appendChild(el("span", { class: "cf-fab-badge" }, count > 99 ? "99+" : String(count)));
|
|
141
|
+
}
|
|
142
|
+
fab.addEventListener("keydown", (e: KeyboardEvent) => {
|
|
143
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
144
|
+
e.preventDefault();
|
|
145
|
+
collapsed.value = false;
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
attachDrag(fab, fab, () => {
|
|
149
|
+
collapsed.value = false;
|
|
150
|
+
});
|
|
151
|
+
keepOnScreen(fab);
|
|
152
|
+
return fab;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const active = mode.value === "feedback";
|
|
38
156
|
const multi = multiSelection.value.length;
|
|
39
157
|
|
|
40
|
-
|
|
158
|
+
const head = el(
|
|
41
159
|
"div",
|
|
42
|
-
{ class: "cf-toolbar" },
|
|
160
|
+
{ class: "cf-toolbar-head", title: "Drag to move" },
|
|
161
|
+
// Visible grip → "you can drag this".
|
|
162
|
+
el("span", { class: "cf-grip", "aria-hidden": "true" }, "⠿"),
|
|
163
|
+
// Logo → collapses back to the puck.
|
|
43
164
|
el(
|
|
44
|
-
"
|
|
45
|
-
{ class: "cf-
|
|
46
|
-
|
|
47
|
-
el("span", null, "Annotations"),
|
|
48
|
-
el("span", { class: "cf-count" }, String(count)),
|
|
165
|
+
"button",
|
|
166
|
+
{ class: "cf-collapse", title: "Collapse to icon", onClick: () => (collapsed.value = true) },
|
|
167
|
+
brandGlyph(),
|
|
49
168
|
),
|
|
50
169
|
el(
|
|
51
170
|
"div",
|
|
@@ -59,6 +178,57 @@ export function renderToolbar(): HTMLElement {
|
|
|
59
178
|
mode.value = "feedback";
|
|
60
179
|
}),
|
|
61
180
|
),
|
|
181
|
+
el("span", { class: "cf-count" }, String(count)),
|
|
182
|
+
// Erase board — clears every annotation.
|
|
183
|
+
count > 0
|
|
184
|
+
? el(
|
|
185
|
+
"button",
|
|
186
|
+
{
|
|
187
|
+
class: "cf-icon-btn cf-clear",
|
|
188
|
+
title: "Clear all annotations",
|
|
189
|
+
onClick: () => {
|
|
190
|
+
api.clearAnnotations();
|
|
191
|
+
api.clearCursors();
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
"Clear",
|
|
195
|
+
)
|
|
196
|
+
: null,
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
// Suggestions navigator — count + jump to each annotation.
|
|
200
|
+
const list = annotations.value;
|
|
201
|
+
const agentCount = list.filter((a) => a.author?.kind === "agent").length;
|
|
202
|
+
const cur = Math.max(0, list.findIndex((a) => a.id === activeThreadId.value));
|
|
203
|
+
const nav =
|
|
204
|
+
count > 0
|
|
205
|
+
? el(
|
|
206
|
+
"div",
|
|
207
|
+
{ class: "cf-nav" },
|
|
208
|
+
el(
|
|
209
|
+
"button",
|
|
210
|
+
{ class: "cf-nav-btn", title: "Previous", onClick: () => jumpTo(cur - 1) },
|
|
211
|
+
"‹",
|
|
212
|
+
),
|
|
213
|
+
el(
|
|
214
|
+
"span",
|
|
215
|
+
{ class: "cf-nav-label" },
|
|
216
|
+
agentCount > 0
|
|
217
|
+
? `${agentCount} agent${agentCount === 1 ? "" : "s"} · ${cur + 1}/${count}`
|
|
218
|
+
: `${cur + 1} / ${count}`,
|
|
219
|
+
),
|
|
220
|
+
el(
|
|
221
|
+
"button",
|
|
222
|
+
{ class: "cf-nav-btn", title: "Next", onClick: () => jumpTo(cur + 1) },
|
|
223
|
+
"›",
|
|
224
|
+
),
|
|
225
|
+
)
|
|
226
|
+
: null;
|
|
227
|
+
|
|
228
|
+
const toolbar = el("div", { class: "cf-toolbar", style: posStyle() });
|
|
229
|
+
const children: Array<Node | null> = [
|
|
230
|
+
head,
|
|
231
|
+
nav,
|
|
62
232
|
active
|
|
63
233
|
? el(
|
|
64
234
|
"div",
|
|
@@ -84,5 +254,17 @@ export function renderToolbar(): HTMLElement {
|
|
|
84
254
|
)
|
|
85
255
|
: null,
|
|
86
256
|
active ? el("div", { class: "cf-hint" }, hintFor(feedbackTool.value)) : null,
|
|
87
|
-
|
|
257
|
+
primaryAction.value
|
|
258
|
+
? el(
|
|
259
|
+
"button",
|
|
260
|
+
{ class: "cf-btn cf-btn-primary cf-launch", onClick: () => primaryAction.value?.run() },
|
|
261
|
+
primaryAction.value.label,
|
|
262
|
+
)
|
|
263
|
+
: null,
|
|
264
|
+
];
|
|
265
|
+
for (const child of children) if (child) toolbar.appendChild(child);
|
|
266
|
+
|
|
267
|
+
attachDrag(toolbar, head);
|
|
268
|
+
keepOnScreen(toolbar);
|
|
269
|
+
return toolbar;
|
|
88
270
|
}
|
package/src/ui/brand.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brand glyph — the host-supplied logo (the "floater" mark) or a neutral
|
|
3
|
+
* dot fallback. Shared by the toolbar/FAB and the popups, so the composer
|
|
4
|
+
* reads as the floater that grew into a box.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { brandLogo } from "../store.js";
|
|
8
|
+
import { el } from "./dom.js";
|
|
9
|
+
|
|
10
|
+
export function brandGlyph(extraClass = ""): HTMLElement {
|
|
11
|
+
const span = el("span", { class: `cf-brand${extraClass ? ` ${extraClass}` : ""}` });
|
|
12
|
+
const logo = brandLogo.value;
|
|
13
|
+
if (logo) span.innerHTML = logo;
|
|
14
|
+
else span.appendChild(el("span", { class: "cf-logo-dot" }));
|
|
15
|
+
return span;
|
|
16
|
+
}
|
package/src/ui/helpers.ts
CHANGED
|
@@ -27,6 +27,71 @@ export function truncate(text: string, max: number): string {
|
|
|
27
27
|
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Anchor a popup just below its target element (viewport-coords box),
|
|
32
|
+
* then clamp it on-screen — flipping above when it would overflow the
|
|
33
|
+
* bottom. Keeps the composer/thread next to what they refer to.
|
|
34
|
+
*
|
|
35
|
+
* Once placed, the popup *morphs out of the floater*: it animates from
|
|
36
|
+
* the launcher's position + size (the FAB / toolbar puck) to its final
|
|
37
|
+
* rect, so the box reads as the floater that flew over and opened.
|
|
38
|
+
*/
|
|
39
|
+
export function placeNear(
|
|
40
|
+
popup: HTMLElement,
|
|
41
|
+
box: { left: number; top: number; width: number; height: number },
|
|
42
|
+
): void {
|
|
43
|
+
popup.style.position = "fixed";
|
|
44
|
+
popup.style.left = `${box.left}px`;
|
|
45
|
+
popup.style.top = `${box.top + box.height + 10}px`;
|
|
46
|
+
popup.style.right = "auto";
|
|
47
|
+
popup.style.bottom = "auto";
|
|
48
|
+
requestAnimationFrame(() => {
|
|
49
|
+
const r = popup.getBoundingClientRect();
|
|
50
|
+
if (!r.width) return;
|
|
51
|
+
const left = Math.max(8, Math.min(box.left, window.innerWidth - r.width - 8));
|
|
52
|
+
let top = box.top + box.height + 10;
|
|
53
|
+
if (top + r.height > window.innerHeight - 8) top = box.top - r.height - 10;
|
|
54
|
+
top = Math.max(8, Math.min(top, window.innerHeight - r.height - 8));
|
|
55
|
+
popup.style.left = `${left}px`;
|
|
56
|
+
popup.style.top = `${top}px`;
|
|
57
|
+
morphFromLauncher(popup);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Spring the popup open from the floater (FAB / toolbar puck): a FLIP
|
|
63
|
+
* that starts the popup shrunk + translated onto the launcher, then
|
|
64
|
+
* animates to its real position. Falls back silently if the launcher
|
|
65
|
+
* can't be found or the Web Animations API is unavailable.
|
|
66
|
+
*/
|
|
67
|
+
function morphFromLauncher(popup: HTMLElement): void {
|
|
68
|
+
if (typeof popup.animate !== "function") return;
|
|
69
|
+
const root = popup.closest(".cf-overlay");
|
|
70
|
+
const launcher = root?.querySelector<HTMLElement>(".cf-fab, .cf-toolbar");
|
|
71
|
+
if (!launcher) return;
|
|
72
|
+
const from = launcher.getBoundingClientRect();
|
|
73
|
+
const to = popup.getBoundingClientRect();
|
|
74
|
+
if (!from.width || !to.width) return;
|
|
75
|
+
|
|
76
|
+
const dx = from.left + from.width / 2 - (to.left + to.width / 2);
|
|
77
|
+
const dy = from.top + from.height / 2 - (to.top + to.height / 2);
|
|
78
|
+
const sx = Math.max(0.08, from.width / to.width);
|
|
79
|
+
const sy = Math.max(0.08, from.height / to.height);
|
|
80
|
+
|
|
81
|
+
// Suppress the CSS keyframe so it doesn't fight the morph.
|
|
82
|
+
popup.classList.add("cf-morph");
|
|
83
|
+
popup.style.transformOrigin = "center center";
|
|
84
|
+
popup.animate(
|
|
85
|
+
[
|
|
86
|
+
{ transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`, opacity: 0.25 },
|
|
87
|
+
{ transform: "translate(0, 0) scale(1, 1)", opacity: 1 },
|
|
88
|
+
],
|
|
89
|
+
{ duration: 360, easing: "cubic-bezier(.34,1.3,.5,1)", fill: "both" },
|
|
90
|
+
).addEventListener("finish", () => {
|
|
91
|
+
popup.style.transform = "";
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
30
95
|
export function hashId(s: string): number {
|
|
31
96
|
let h = 0;
|
|
32
97
|
for (let i = 0; i < s.length; i++) h = (h << 5) - h + s.charCodeAt(i);
|
|
@@ -48,3 +113,27 @@ export function pinPosition(a: Annotation): { top: number; left: number } | null
|
|
|
48
113
|
}
|
|
49
114
|
return null;
|
|
50
115
|
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Live viewport rect of the element an annotation targets — used to
|
|
119
|
+
* draw the highlight outline around it. Re-anchors to the live element
|
|
120
|
+
* (falls back to the stored bounding box). Mirrors {@link pinPosition}.
|
|
121
|
+
*/
|
|
122
|
+
export function annotationBox(
|
|
123
|
+
a: Annotation,
|
|
124
|
+
): { top: number; left: number; width: number; height: number } | null {
|
|
125
|
+
const el = requery(a.fullPath ?? "") ?? requery(a.elementPath ?? "");
|
|
126
|
+
if (el) {
|
|
127
|
+
const r = el.getBoundingClientRect();
|
|
128
|
+
return { top: r.top, left: r.left, width: r.width, height: r.height };
|
|
129
|
+
}
|
|
130
|
+
if (a.boundingBox) {
|
|
131
|
+
return {
|
|
132
|
+
top: a.boundingBox.y - (a.isFixed ? 0 : window.scrollY),
|
|
133
|
+
left: a.boundingBox.x - (a.isFixed ? 0 : window.scrollX),
|
|
134
|
+
width: a.boundingBox.width,
|
|
135
|
+
height: a.boundingBox.height,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
package/src/ui/overlay.ts
CHANGED
|
@@ -30,7 +30,11 @@ import {
|
|
|
30
30
|
mode,
|
|
31
31
|
feedbackTool,
|
|
32
32
|
multiSelection,
|
|
33
|
+
primaryAction,
|
|
34
|
+
brandLogo,
|
|
35
|
+
collapsed,
|
|
33
36
|
theme,
|
|
37
|
+
toolbarPos,
|
|
34
38
|
viewportTick,
|
|
35
39
|
} from "../store.js";
|
|
36
40
|
import type { Signal } from "../signal.js";
|
|
@@ -129,6 +133,10 @@ export function mountOverlay(
|
|
|
129
133
|
feedbackTool as Signal<unknown>,
|
|
130
134
|
annotations as Signal<unknown>,
|
|
131
135
|
multiSelection as Signal<unknown>,
|
|
136
|
+
toolbarPos as Signal<unknown>,
|
|
137
|
+
primaryAction as Signal<unknown>,
|
|
138
|
+
collapsed as Signal<unknown>,
|
|
139
|
+
brandLogo as Signal<unknown>,
|
|
132
140
|
],
|
|
133
141
|
() => renderToolbar(),
|
|
134
142
|
);
|