@coframe-gtm/annotations 1.0.4 → 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 +4 -8
- package/src/README.md +4 -3
- package/src/api.ts +12 -0
- package/src/bundle.ts +11 -5
- package/src/inject/build.ts +0 -2
- package/src/inject/bundle-source.generated.ts +1 -1
- package/src/output.ts +1 -1
- package/src/picker.ts +23 -5
- package/src/signal.ts +56 -0
- package/src/store.ts +36 -2
- package/src/ui/Avatar.ts +62 -0
- package/src/ui/Composer.ts +206 -0
- package/src/ui/Cursors.ts +48 -0
- package/src/ui/Pins.ts +138 -0
- package/src/ui/ThreadPanel.ts +132 -0
- package/src/ui/Toolbar.ts +270 -0
- package/src/ui/brand.ts +16 -0
- package/src/ui/dom.ts +95 -0
- package/src/ui/helpers.ts +89 -0
- package/src/ui/overlay.ts +163 -0
- package/src/ui/styles.ts +212 -34
- package/src/ui/App.tsx +0 -69
- package/src/ui/Composer.tsx +0 -138
- package/src/ui/Cursors.tsx +0 -50
- package/src/ui/Pins.tsx +0 -58
- package/src/ui/ThreadPanel.tsx +0 -124
- package/src/ui/Toolbar.tsx +0 -116
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toolbar — minimal, logo-first control surface.
|
|
3
|
+
*
|
|
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.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
activeThreadId,
|
|
14
|
+
annotations,
|
|
15
|
+
collapsed,
|
|
16
|
+
feedbackTool,
|
|
17
|
+
hoverBox,
|
|
18
|
+
mode,
|
|
19
|
+
multiSelection,
|
|
20
|
+
primaryAction,
|
|
21
|
+
toolbarPos,
|
|
22
|
+
} from "../store.js";
|
|
23
|
+
import { commitMultiSelection } from "../picker.js";
|
|
24
|
+
import { api } from "../api.js";
|
|
25
|
+
import { brandGlyph } from "./brand.js";
|
|
26
|
+
import { el } from "./dom.js";
|
|
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
|
+
|
|
91
|
+
export function hintFor(tool: string): string {
|
|
92
|
+
if (tool === "text") return "Select text on the page to annotate it.";
|
|
93
|
+
if (tool === "multi") return "Click elements to group them, then commit.";
|
|
94
|
+
return "Click any element to comment. Esc to exit.";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function SegButton(label: string, on: boolean, onClick: () => void): HTMLElement {
|
|
98
|
+
return el("button", { class: `cf-seg-btn${on ? " on" : ""}`, onClick }, label);
|
|
99
|
+
}
|
|
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
|
+
|
|
126
|
+
export function renderToolbar(): HTMLElement {
|
|
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";
|
|
156
|
+
const multi = multiSelection.value.length;
|
|
157
|
+
|
|
158
|
+
const head = el(
|
|
159
|
+
"div",
|
|
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.
|
|
164
|
+
el(
|
|
165
|
+
"button",
|
|
166
|
+
{ class: "cf-collapse", title: "Collapse to icon", onClick: () => (collapsed.value = true) },
|
|
167
|
+
brandGlyph(),
|
|
168
|
+
),
|
|
169
|
+
el(
|
|
170
|
+
"div",
|
|
171
|
+
{ class: "cf-seg" },
|
|
172
|
+
SegButton("View", mode.value === "view", () => {
|
|
173
|
+
mode.value = "view";
|
|
174
|
+
hoverBox.value = null;
|
|
175
|
+
multiSelection.value = [];
|
|
176
|
+
}),
|
|
177
|
+
SegButton("Comment", active, () => {
|
|
178
|
+
mode.value = "feedback";
|
|
179
|
+
}),
|
|
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,
|
|
232
|
+
active
|
|
233
|
+
? el(
|
|
234
|
+
"div",
|
|
235
|
+
{ class: "cf-seg cf-tools" },
|
|
236
|
+
SegButton("Element", feedbackTool.value === "element", () => {
|
|
237
|
+
feedbackTool.value = "element";
|
|
238
|
+
multiSelection.value = [];
|
|
239
|
+
}),
|
|
240
|
+
SegButton("Text", feedbackTool.value === "text", () => {
|
|
241
|
+
feedbackTool.value = "text";
|
|
242
|
+
multiSelection.value = [];
|
|
243
|
+
}),
|
|
244
|
+
SegButton("Multi", feedbackTool.value === "multi", () => {
|
|
245
|
+
feedbackTool.value = "multi";
|
|
246
|
+
}),
|
|
247
|
+
)
|
|
248
|
+
: null,
|
|
249
|
+
active && feedbackTool.value === "multi" && multi
|
|
250
|
+
? el(
|
|
251
|
+
"button",
|
|
252
|
+
{ class: "cf-btn cf-btn-primary cf-commit", onClick: commitMultiSelection },
|
|
253
|
+
`Comment ${multi} element${multi === 1 ? "" : "s"}`,
|
|
254
|
+
)
|
|
255
|
+
: null,
|
|
256
|
+
active ? el("div", { class: "cf-hint" }, hintFor(feedbackTool.value)) : null,
|
|
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;
|
|
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/dom.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny DRY DOM-building helpers — the vanilla replacement for JSX.
|
|
3
|
+
*
|
|
4
|
+
* `el(tag, props?, ...children)` builds an HTMLElement; `svg(tag, …)`
|
|
5
|
+
* does the same in the SVG namespace (for the cursor arrow). Props map
|
|
6
|
+
* onto attributes/handlers the same way the old JSX did:
|
|
7
|
+
*
|
|
8
|
+
* - `class` → className
|
|
9
|
+
* - `style` → inline style string
|
|
10
|
+
* - `on*` (onClick…) → addEventListener (onClick→click, onInput→input,
|
|
11
|
+
* onKeyDown→keydown — lowercased remainder)
|
|
12
|
+
* - `dataset` → element.dataset entries
|
|
13
|
+
* - boolean attrs → present when true, omitted when false
|
|
14
|
+
* - everything else → setAttribute(key, String(value))
|
|
15
|
+
*
|
|
16
|
+
* Children may be strings (text nodes), Nodes, arrays (flattened), or
|
|
17
|
+
* null/false/undefined (skipped).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
21
|
+
|
|
22
|
+
export type Child = Node | string | number | null | false | undefined | Child[];
|
|
23
|
+
|
|
24
|
+
export interface Props {
|
|
25
|
+
[key: string]: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function applyProps(node: Element, props: Props): void {
|
|
29
|
+
for (const [key, value] of Object.entries(props)) {
|
|
30
|
+
if (value === undefined || value === null) continue;
|
|
31
|
+
|
|
32
|
+
if (key === "class") {
|
|
33
|
+
node.setAttribute("class", String(value));
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (key === "style") {
|
|
37
|
+
node.setAttribute("style", String(value));
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (key === "dataset") {
|
|
41
|
+
const data = value as Record<string, string>;
|
|
42
|
+
if (node instanceof HTMLElement) {
|
|
43
|
+
for (const [dk, dv] of Object.entries(data)) node.dataset[dk] = dv;
|
|
44
|
+
}
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (key.startsWith("on") && typeof value === "function") {
|
|
48
|
+
const event = key.slice(2).toLowerCase();
|
|
49
|
+
node.addEventListener(event, value as EventListener);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (typeof value === "boolean") {
|
|
53
|
+
if (value) node.setAttribute(key, "");
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
node.setAttribute(key, String(value));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function appendChildren(node: Element, children: Child[]): void {
|
|
61
|
+
for (const child of children) {
|
|
62
|
+
if (child === null || child === false || child === undefined) continue;
|
|
63
|
+
if (Array.isArray(child)) {
|
|
64
|
+
appendChildren(node, child);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (child instanceof Node) {
|
|
68
|
+
node.appendChild(child);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
node.appendChild(document.createTextNode(String(child)));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function el(
|
|
76
|
+
tag: string,
|
|
77
|
+
props?: Props | null,
|
|
78
|
+
...children: Child[]
|
|
79
|
+
): HTMLElement {
|
|
80
|
+
const node = document.createElement(tag);
|
|
81
|
+
if (props) applyProps(node, props);
|
|
82
|
+
appendChildren(node, children);
|
|
83
|
+
return node;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function svg(
|
|
87
|
+
tag: string,
|
|
88
|
+
props?: Props | null,
|
|
89
|
+
...children: Child[]
|
|
90
|
+
): SVGElement {
|
|
91
|
+
const node = document.createElementNS(SVG_NS, tag);
|
|
92
|
+
if (props) applyProps(node, props);
|
|
93
|
+
appendChildren(node, children);
|
|
94
|
+
return node;
|
|
95
|
+
}
|
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
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vanilla per-layer overlay mount — the replacement for the single
|
|
3
|
+
* Preact <App/> tree.
|
|
4
|
+
*
|
|
5
|
+
* Each visual layer gets its OWN container and subscribes ONLY to the
|
|
6
|
+
* signals it depends on, re-rendering just that layer when one changes.
|
|
7
|
+
* This is deliberate: `hoverBox` updates on every mousemove, and a
|
|
8
|
+
* single global re-render would destroy an open composer (blowing away
|
|
9
|
+
* the textarea's focus + value) on each move. Layer isolation keeps the
|
|
10
|
+
* composer alive while the hover box repaints independently.
|
|
11
|
+
*
|
|
12
|
+
* Layers (all inside the closed Shadow DOM, fixed-positioned):
|
|
13
|
+
* 1. styles — static <style> with STYLES (once)
|
|
14
|
+
* 2. hover <- hoverBox, draft (hidden while composing)
|
|
15
|
+
* 3. multi-box <- multiSelection, viewportTick
|
|
16
|
+
* 4. pins <- annotations, viewportTick
|
|
17
|
+
* 5. cursors <- cursors (multi-cursor)
|
|
18
|
+
* 6. toolbar <- mode, feedbackTool, annotations, multiSelection
|
|
19
|
+
* (skipped entirely when init headless = true)
|
|
20
|
+
* 7. composer <- draft (rebuilt only when the draft opens/closes)
|
|
21
|
+
* 8. thread <- activeThreadId, annotations
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
activeThreadId,
|
|
26
|
+
annotations,
|
|
27
|
+
cursors,
|
|
28
|
+
draft,
|
|
29
|
+
hoverBox,
|
|
30
|
+
mode,
|
|
31
|
+
feedbackTool,
|
|
32
|
+
multiSelection,
|
|
33
|
+
primaryAction,
|
|
34
|
+
brandLogo,
|
|
35
|
+
collapsed,
|
|
36
|
+
theme,
|
|
37
|
+
toolbarPos,
|
|
38
|
+
viewportTick,
|
|
39
|
+
} from "../store.js";
|
|
40
|
+
import type { Signal } from "../signal.js";
|
|
41
|
+
import { boxStyle } from "./helpers.js";
|
|
42
|
+
import { renderToolbar } from "./Toolbar.js";
|
|
43
|
+
import { MultiBox, renderPin } from "./Pins.js";
|
|
44
|
+
import { renderCursor } from "./Cursors.js";
|
|
45
|
+
import { renderComposer } from "./Composer.js";
|
|
46
|
+
import { renderThreadPanel } from "./ThreadPanel.js";
|
|
47
|
+
import { STYLES } from "./styles.js";
|
|
48
|
+
import { el } from "./dom.js";
|
|
49
|
+
|
|
50
|
+
interface MountOptions {
|
|
51
|
+
/** When true the toolbar layer is not mounted (parent frame drives mode). */
|
|
52
|
+
headless?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Wire a layer container to a set of signals; re-render on any change. */
|
|
56
|
+
function bindLayer(
|
|
57
|
+
container: HTMLElement,
|
|
58
|
+
deps: Signal<unknown>[],
|
|
59
|
+
render: () => Node | Node[] | null,
|
|
60
|
+
): void {
|
|
61
|
+
const paint = (): void => {
|
|
62
|
+
container.replaceChildren();
|
|
63
|
+
const out = render();
|
|
64
|
+
if (out === null) return;
|
|
65
|
+
if (Array.isArray(out)) container.append(...out);
|
|
66
|
+
else container.append(out);
|
|
67
|
+
};
|
|
68
|
+
paint();
|
|
69
|
+
for (const dep of deps) dep.subscribe(paint);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function mountOverlay(
|
|
73
|
+
appRoot: HTMLElement,
|
|
74
|
+
options: MountOptions = {},
|
|
75
|
+
): void {
|
|
76
|
+
const root = el("div", { class: "cf-overlay" });
|
|
77
|
+
|
|
78
|
+
const setTheme = (): void => {
|
|
79
|
+
root.setAttribute("data-theme", theme.value === "auto" ? "dark" : theme.value);
|
|
80
|
+
};
|
|
81
|
+
setTheme();
|
|
82
|
+
theme.subscribe(setTheme);
|
|
83
|
+
|
|
84
|
+
// 1. Styles — static, once.
|
|
85
|
+
const style = document.createElement("style");
|
|
86
|
+
style.textContent = STYLES;
|
|
87
|
+
root.appendChild(style);
|
|
88
|
+
|
|
89
|
+
// 2. Hover highlight (hidden while composing).
|
|
90
|
+
const hoverLayer = el("div", { class: "cf-layer-hover" });
|
|
91
|
+
bindLayer(hoverLayer, [hoverBox as Signal<unknown>, draft as Signal<unknown>], () => {
|
|
92
|
+
const box = hoverBox.value;
|
|
93
|
+
if (!box || draft.value) return null;
|
|
94
|
+
return el("div", { class: "cf-hover", style: boxStyle(box) });
|
|
95
|
+
});
|
|
96
|
+
root.appendChild(hoverLayer);
|
|
97
|
+
|
|
98
|
+
// 3. Multi-select boxes.
|
|
99
|
+
const multiLayer = el("div", { class: "cf-layer-multi" });
|
|
100
|
+
bindLayer(
|
|
101
|
+
multiLayer,
|
|
102
|
+
[multiSelection as Signal<unknown>, viewportTick as Signal<unknown>],
|
|
103
|
+
() => multiSelection.value.map((element) => MultiBox(element)),
|
|
104
|
+
);
|
|
105
|
+
root.appendChild(multiLayer);
|
|
106
|
+
|
|
107
|
+
// 4. Pins.
|
|
108
|
+
const pinsLayer = el("div", { class: "cf-layer-pins" });
|
|
109
|
+
bindLayer(
|
|
110
|
+
pinsLayer,
|
|
111
|
+
[annotations as Signal<unknown>, viewportTick as Signal<unknown>],
|
|
112
|
+
() =>
|
|
113
|
+
annotations.value
|
|
114
|
+
.map((a, i) => renderPin(a, i + 1))
|
|
115
|
+
.filter((n): n is HTMLElement => n !== null),
|
|
116
|
+
);
|
|
117
|
+
root.appendChild(pinsLayer);
|
|
118
|
+
|
|
119
|
+
// 5. Cursors (multi-cursor — one labeled cursor per entry).
|
|
120
|
+
const cursorsLayer = el("div", { class: "cf-layer-cursors" });
|
|
121
|
+
bindLayer(cursorsLayer, [cursors as Signal<unknown>], () =>
|
|
122
|
+
cursors.value.map((c) => renderCursor(c)),
|
|
123
|
+
);
|
|
124
|
+
root.appendChild(cursorsLayer);
|
|
125
|
+
|
|
126
|
+
// 6. Toolbar — skipped entirely in headless mode.
|
|
127
|
+
if (!options.headless) {
|
|
128
|
+
const toolbarLayer = el("div", { class: "cf-layer-toolbar" });
|
|
129
|
+
bindLayer(
|
|
130
|
+
toolbarLayer,
|
|
131
|
+
[
|
|
132
|
+
mode as Signal<unknown>,
|
|
133
|
+
feedbackTool as Signal<unknown>,
|
|
134
|
+
annotations as Signal<unknown>,
|
|
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>,
|
|
140
|
+
],
|
|
141
|
+
() => renderToolbar(),
|
|
142
|
+
);
|
|
143
|
+
root.appendChild(toolbarLayer);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 7. Composer — rebuilt only when the draft opens/closes.
|
|
147
|
+
const composerLayer = el("div", { class: "cf-layer-composer" });
|
|
148
|
+
bindLayer(composerLayer, [draft as Signal<unknown>], () =>
|
|
149
|
+
draft.value ? renderComposer(draft.value) : null,
|
|
150
|
+
);
|
|
151
|
+
root.appendChild(composerLayer);
|
|
152
|
+
|
|
153
|
+
// 8. Thread panel.
|
|
154
|
+
const threadLayer = el("div", { class: "cf-layer-thread" });
|
|
155
|
+
bindLayer(
|
|
156
|
+
threadLayer,
|
|
157
|
+
[activeThreadId as Signal<unknown>, annotations as Signal<unknown>],
|
|
158
|
+
() => (activeThreadId.value ? renderThreadPanel(activeThreadId.value) : null),
|
|
159
|
+
);
|
|
160
|
+
root.appendChild(threadLayer);
|
|
161
|
+
|
|
162
|
+
appRoot.appendChild(root);
|
|
163
|
+
}
|