@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,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composer popup — the comment + intent/severity form shown when a
|
|
3
|
+
* target is captured. Vanilla DOM.
|
|
4
|
+
*
|
|
5
|
+
* Local form state (comment/intent/severity) lives in plain closure
|
|
6
|
+
* variables, NOT in a re-rendering hook. The comment is read from the
|
|
7
|
+
* textarea's `.value` at submit time. Selecting an intent/severity chip
|
|
8
|
+
* toggles the `.on` class on the chips in place — the composer is never
|
|
9
|
+
* rebuilt on a chip click, so the textarea keeps its focus + value.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { api } from "../api.js";
|
|
13
|
+
import type { CapturedTarget } from "../capture.js";
|
|
14
|
+
import { author, draft } from "../store.js";
|
|
15
|
+
import type {
|
|
16
|
+
AnnotationIntent,
|
|
17
|
+
AnnotationKind,
|
|
18
|
+
AnnotationSeverity,
|
|
19
|
+
} from "../types.js";
|
|
20
|
+
import {
|
|
21
|
+
INTENTS,
|
|
22
|
+
SEVERITIES,
|
|
23
|
+
SEVERITY_COLOR,
|
|
24
|
+
SEVERITY_NEUTRAL,
|
|
25
|
+
} from "./constants.js";
|
|
26
|
+
import { placeNear, truncate } from "./helpers.js";
|
|
27
|
+
import { brandGlyph } from "./brand.js";
|
|
28
|
+
import { el } from "./dom.js";
|
|
29
|
+
|
|
30
|
+
export function renderComposer(target: CapturedTarget): HTMLElement {
|
|
31
|
+
// Local form state — closure vars, not hooks. Chip selections update
|
|
32
|
+
// these + toggle classes in place; the comment is read from the DOM.
|
|
33
|
+
let intent: AnnotationIntent = "change";
|
|
34
|
+
let severity: AnnotationSeverity = "important";
|
|
35
|
+
|
|
36
|
+
const textarea = el("textarea", {
|
|
37
|
+
class: "cf-textarea",
|
|
38
|
+
placeholder: "Describe the change… (Markdown supported)",
|
|
39
|
+
autofocus: true,
|
|
40
|
+
onKeyDown: (e: KeyboardEvent) => {
|
|
41
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") submit();
|
|
42
|
+
},
|
|
43
|
+
onInput: () => syncSubmitState(),
|
|
44
|
+
}) as HTMLTextAreaElement;
|
|
45
|
+
|
|
46
|
+
const submit = (): void => {
|
|
47
|
+
const text = textarea.value.trim();
|
|
48
|
+
if (!text) return;
|
|
49
|
+
api.addAnnotation({
|
|
50
|
+
...target,
|
|
51
|
+
comment: text,
|
|
52
|
+
intent,
|
|
53
|
+
severity,
|
|
54
|
+
kind: "feedback" satisfies AnnotationKind,
|
|
55
|
+
author: author.value,
|
|
56
|
+
});
|
|
57
|
+
draft.value = null;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const cancel = (): void => {
|
|
61
|
+
draft.value = null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Build a chip group whose selection toggles `.on` in place. `onSelect`
|
|
65
|
+
// updates the closure state; we never rebuild the composer.
|
|
66
|
+
function chipGroup(
|
|
67
|
+
labels: readonly string[],
|
|
68
|
+
color: (label: string) => string,
|
|
69
|
+
isOn: (label: string) => boolean,
|
|
70
|
+
onSelect: (label: string) => void,
|
|
71
|
+
): { group: HTMLElement; refresh: () => void } {
|
|
72
|
+
const chips = labels.map((label) => {
|
|
73
|
+
const on = isOn(label);
|
|
74
|
+
return el(
|
|
75
|
+
"button",
|
|
76
|
+
{
|
|
77
|
+
class: `cf-chip${on ? " on" : ""}`,
|
|
78
|
+
style: on ? `--cf-chip:${color(label)};` : undefined,
|
|
79
|
+
onClick: () => {
|
|
80
|
+
onSelect(label);
|
|
81
|
+
refresh();
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
label,
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
const group = el("div", { class: "cf-chipset" }, ...chips);
|
|
88
|
+
function refresh(): void {
|
|
89
|
+
labels.forEach((label, i) => {
|
|
90
|
+
const chip = chips[i]!;
|
|
91
|
+
const on = isOn(label);
|
|
92
|
+
chip.className = `cf-chip${on ? " on" : ""}`;
|
|
93
|
+
if (on) chip.setAttribute("style", `--cf-chip:${color(label)};`);
|
|
94
|
+
else chip.removeAttribute("style");
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return { group, refresh };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const intentGroup = chipGroup(
|
|
101
|
+
INTENTS,
|
|
102
|
+
() => SEVERITY_NEUTRAL,
|
|
103
|
+
(label) => intent === label,
|
|
104
|
+
(label) => {
|
|
105
|
+
intent = label as AnnotationIntent;
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const severityGroup = chipGroup(
|
|
110
|
+
SEVERITIES,
|
|
111
|
+
(label) => SEVERITY_COLOR[label as AnnotationSeverity],
|
|
112
|
+
(label) => severity === label,
|
|
113
|
+
(label) => {
|
|
114
|
+
severity = label as AnnotationSeverity;
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const label = target.isMultiSelect
|
|
119
|
+
? `${target.elementBoundingBoxes?.length ?? 0} elements`
|
|
120
|
+
: target.selectedText
|
|
121
|
+
? `"${truncate(target.selectedText, 40)}"`
|
|
122
|
+
: `<${target.element}>`;
|
|
123
|
+
|
|
124
|
+
const submitBtn = el(
|
|
125
|
+
"button",
|
|
126
|
+
{
|
|
127
|
+
class: "cf-btn cf-btn-primary",
|
|
128
|
+
disabled: true,
|
|
129
|
+
onClick: submit,
|
|
130
|
+
},
|
|
131
|
+
"Comment",
|
|
132
|
+
) as HTMLButtonElement;
|
|
133
|
+
|
|
134
|
+
function syncSubmitState(): void {
|
|
135
|
+
submitBtn.disabled = !textarea.value.trim();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Intent + severity are power-user controls — keep them out of the way
|
|
139
|
+
// behind a single disclosure so the default surface is just type + send.
|
|
140
|
+
const details = el(
|
|
141
|
+
"div",
|
|
142
|
+
{ class: "cf-details", hidden: true },
|
|
143
|
+
el(
|
|
144
|
+
"div",
|
|
145
|
+
{ class: "cf-field" },
|
|
146
|
+
el("span", { class: "cf-field-label" }, "Intent"),
|
|
147
|
+
intentGroup.group,
|
|
148
|
+
),
|
|
149
|
+
el(
|
|
150
|
+
"div",
|
|
151
|
+
{ class: "cf-field" },
|
|
152
|
+
el("span", { class: "cf-field-label" }, "Severity"),
|
|
153
|
+
severityGroup.group,
|
|
154
|
+
),
|
|
155
|
+
) as HTMLElement;
|
|
156
|
+
|
|
157
|
+
const detailsBtn = el(
|
|
158
|
+
"button",
|
|
159
|
+
{
|
|
160
|
+
class: "cf-disclose",
|
|
161
|
+
onClick: () => {
|
|
162
|
+
const open = details.hidden;
|
|
163
|
+
details.hidden = !open;
|
|
164
|
+
detailsBtn.classList.toggle("on", open);
|
|
165
|
+
detailsBtn.textContent = open ? "Details ▴" : "Details ▾";
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
"Details ▾",
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const popup = el(
|
|
172
|
+
"div",
|
|
173
|
+
{ class: "cf-popup cf-composer" },
|
|
174
|
+
el(
|
|
175
|
+
"div",
|
|
176
|
+
{ class: "cf-popup-head" },
|
|
177
|
+
brandGlyph("cf-popup-brand"),
|
|
178
|
+
el("span", { class: "cf-popup-target" }, label),
|
|
179
|
+
el("button", { class: "cf-icon-btn", onClick: cancel, title: "Close (Esc)" }, "✕"),
|
|
180
|
+
),
|
|
181
|
+
textarea,
|
|
182
|
+
details,
|
|
183
|
+
el(
|
|
184
|
+
"div",
|
|
185
|
+
{ class: "cf-popup-actions" },
|
|
186
|
+
detailsBtn,
|
|
187
|
+
submitBtn,
|
|
188
|
+
),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
// Anchor the composer next to the element being annotated.
|
|
192
|
+
const bb = target.boundingBox;
|
|
193
|
+
if (bb) {
|
|
194
|
+
placeNear(popup, {
|
|
195
|
+
left: bb.x - window.scrollX,
|
|
196
|
+
top: bb.y - window.scrollY,
|
|
197
|
+
width: bb.width,
|
|
198
|
+
height: bb.height,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
// Focus the comment field right away — the `autofocus` attribute doesn't
|
|
202
|
+
// fire on nodes created + inserted dynamically into the shadow root, so
|
|
203
|
+
// without this the user has to click into the box before typing.
|
|
204
|
+
requestAnimationFrame(() => textarea.focus({ preventScroll: true }));
|
|
205
|
+
return popup;
|
|
206
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live presence cursors — one labeled arrow per remote actor (human
|
|
3
|
+
* reviewers + spun-off agents) collaborating on the same page. Vanilla
|
|
4
|
+
* DOM; the cursors layer renders one of these per entry in
|
|
5
|
+
* `cursors.value` (multi-cursor).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PresenceCursor } from "../store.js";
|
|
9
|
+
import { CURSOR_PALETTE } from "./constants.js";
|
|
10
|
+
import { hashId } from "./helpers.js";
|
|
11
|
+
import { el, svg } from "./dom.js";
|
|
12
|
+
|
|
13
|
+
export function renderCursor(c: PresenceCursor): HTMLElement {
|
|
14
|
+
// x is % of viewport width; y is px from document top.
|
|
15
|
+
const left = (c.x / 100) * window.innerWidth;
|
|
16
|
+
const top = c.y - window.scrollY;
|
|
17
|
+
const color =
|
|
18
|
+
c.color ??
|
|
19
|
+
(c.kind === "agent"
|
|
20
|
+
? CURSOR_PALETTE[Math.abs(hashId(c.id)) % CURSOR_PALETTE.length]!
|
|
21
|
+
: "#ffffff");
|
|
22
|
+
|
|
23
|
+
const arrow = svg(
|
|
24
|
+
"svg",
|
|
25
|
+
{
|
|
26
|
+
width: "18",
|
|
27
|
+
height: "18",
|
|
28
|
+
viewBox: "0 0 18 18",
|
|
29
|
+
class: "cf-cursor-arrow",
|
|
30
|
+
},
|
|
31
|
+
svg("path", {
|
|
32
|
+
d: "M2 2 L2 14 L6 10 L9 16 L11 15 L8 9 L14 9 Z",
|
|
33
|
+
fill: color,
|
|
34
|
+
stroke: "rgba(0,0,0,0.35)",
|
|
35
|
+
"stroke-width": "0.75",
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
return el(
|
|
40
|
+
"div",
|
|
41
|
+
{
|
|
42
|
+
class: `cf-cursor cf-cursor-${c.kind}`,
|
|
43
|
+
style: `top:${top}px;left:${left}px;--cf-cursor:${color};`,
|
|
44
|
+
},
|
|
45
|
+
arrow,
|
|
46
|
+
el("span", { class: "cf-cursor-label" }, c.label),
|
|
47
|
+
);
|
|
48
|
+
}
|
package/src/ui/Pins.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins + multi-select boxes (vanilla DOM).
|
|
3
|
+
*
|
|
4
|
+
* - `renderPin(a, index)` — one numbered marker per annotation,
|
|
5
|
+
* re-anchored to its live element. Click toggles the thread panel;
|
|
6
|
+
* hover shows a small floating preview card with the comment.
|
|
7
|
+
* - `MultiBox(el)` — a pulsing outline around an element in the
|
|
8
|
+
* current multi-selection set.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { activeThreadId, draft } from "../store.js";
|
|
12
|
+
import type { Annotation, BoundingBox } from "../types.js";
|
|
13
|
+
import { DEFAULT_DOT, SEVERITY_COLOR } from "./constants.js";
|
|
14
|
+
import { colorForIdentity } from "./Avatar.js";
|
|
15
|
+
import { annotationBox, boxStyle, firstLine } from "./helpers.js";
|
|
16
|
+
import { el } from "./dom.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Annotations whose entrance animation has already played. The pins layer
|
|
20
|
+
* re-renders on EVERY scroll/resize tick, so without this gate the
|
|
21
|
+
* `cf-rise`/`cf-pin-pop` keyframes would replay on each tick and the
|
|
22
|
+
* markers would flicker instead of tracking their element smoothly. We
|
|
23
|
+
* only tag a marker `.cf-fresh` (→ animated) the first time we see its id.
|
|
24
|
+
*/
|
|
25
|
+
const seenPins = new Set<string>();
|
|
26
|
+
|
|
27
|
+
/** A document-relative box → live screen coords (so it tracks scroll). */
|
|
28
|
+
function docBoxToScreen(
|
|
29
|
+
b: BoundingBox,
|
|
30
|
+
isFixed?: boolean,
|
|
31
|
+
): { top: number; left: number; width: number; height: number } {
|
|
32
|
+
return {
|
|
33
|
+
top: b.y - (isFixed ? 0 : window.scrollY),
|
|
34
|
+
left: b.x - (isFixed ? 0 : window.scrollX),
|
|
35
|
+
width: b.width,
|
|
36
|
+
height: b.height,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Short glyph shown inside the pin — agent initial, or the index. */
|
|
41
|
+
function pinLabel(a: Annotation, index: number): string {
|
|
42
|
+
if (a.author?.kind === "agent") {
|
|
43
|
+
const name = (a.author.displayName ?? "").trim();
|
|
44
|
+
return name ? name.charAt(0).toUpperCase() : "AI";
|
|
45
|
+
}
|
|
46
|
+
return String(index);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function renderPin(a: Annotation, index: number): HTMLElement | null {
|
|
50
|
+
const isAgent = a.author?.kind === "agent";
|
|
51
|
+
// Color-code by agent (matches their avatar + cursor); humans use severity.
|
|
52
|
+
const color = isAgent
|
|
53
|
+
? colorForIdentity(a.author!)
|
|
54
|
+
: a.severity
|
|
55
|
+
? SEVERITY_COLOR[a.severity]
|
|
56
|
+
: DEFAULT_DOT;
|
|
57
|
+
const resolved = a.status === "resolved" || a.status === "dismissed";
|
|
58
|
+
|
|
59
|
+
// Animate the entrance only the first render for this annotation — not on
|
|
60
|
+
// every scroll/resize re-render (which would otherwise flicker).
|
|
61
|
+
const fresh = !seenPins.has(a.id);
|
|
62
|
+
seenPins.add(a.id);
|
|
63
|
+
const flags = `${resolved ? " resolved" : ""}${isAgent ? " agent" : ""}${fresh ? " cf-fresh" : ""}`;
|
|
64
|
+
|
|
65
|
+
const pin = el(
|
|
66
|
+
"button",
|
|
67
|
+
{
|
|
68
|
+
class: `cf-pin${flags}`,
|
|
69
|
+
title: a.author?.displayName
|
|
70
|
+
? `${a.author.displayName}: ${firstLine(a.comment)}`
|
|
71
|
+
: firstLine(a.comment),
|
|
72
|
+
onClick: () => {
|
|
73
|
+
// One popup at a time: opening a thread closes any open composer,
|
|
74
|
+
// so a stray dark composer never stacks behind the thread panel.
|
|
75
|
+
const opening = activeThreadId.value !== a.id;
|
|
76
|
+
if (opening) draft.value = null;
|
|
77
|
+
activeThreadId.value = opening ? a.id : null;
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
pinLabel(a, index),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// Hover-to-preview: a small floating card with the comment.
|
|
84
|
+
let preview: HTMLElement | null = null;
|
|
85
|
+
pin.addEventListener("mouseenter", () => {
|
|
86
|
+
if (preview || activeThreadId.value === a.id) return; // thread already open
|
|
87
|
+
preview = el("div", { class: "cf-pin-preview" }, firstLine(a.comment) || a.comment);
|
|
88
|
+
pin.appendChild(preview);
|
|
89
|
+
});
|
|
90
|
+
pin.addEventListener("mouseleave", () => {
|
|
91
|
+
preview?.remove();
|
|
92
|
+
preview = null;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// The outline box hugs each annotated element so it's obvious what the
|
|
96
|
+
// annotation refers to; the pin sits at the first element's top-left.
|
|
97
|
+
const makeWrap = (box: {
|
|
98
|
+
top: number;
|
|
99
|
+
left: number;
|
|
100
|
+
width: number;
|
|
101
|
+
height: number;
|
|
102
|
+
}): HTMLElement =>
|
|
103
|
+
el("div", {
|
|
104
|
+
class: `cf-anno${flags}`,
|
|
105
|
+
style: `${boxStyle(box)}--cf-pin:${color};`,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Multi-element annotation → outline EVERY selected element, not just the
|
|
109
|
+
// primary; the numbered pin sits on the first box.
|
|
110
|
+
if (a.isMultiSelect && a.elementBoundingBoxes?.length) {
|
|
111
|
+
const group = el("div", { class: "cf-anno-group" });
|
|
112
|
+
a.elementBoundingBoxes.forEach((b, i) => {
|
|
113
|
+
const wrap = makeWrap(docBoxToScreen(b, a.isFixed));
|
|
114
|
+
if (i === 0) wrap.appendChild(pin);
|
|
115
|
+
group.appendChild(wrap);
|
|
116
|
+
});
|
|
117
|
+
return group;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const box = annotationBox(a);
|
|
121
|
+
if (!box) return null;
|
|
122
|
+
const wrap = makeWrap(box);
|
|
123
|
+
wrap.appendChild(pin);
|
|
124
|
+
return wrap;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function MultiBox(target: Element): HTMLElement {
|
|
128
|
+
const rect = target.getBoundingClientRect();
|
|
129
|
+
return el("div", {
|
|
130
|
+
class: "cf-multi-box",
|
|
131
|
+
style: boxStyle({
|
|
132
|
+
top: rect.top,
|
|
133
|
+
left: rect.left,
|
|
134
|
+
width: rect.width,
|
|
135
|
+
height: rect.height,
|
|
136
|
+
}),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thread panel — the per-pin discussion (human ↔ agent). Shows the
|
|
3
|
+
* root annotation comment plus the reply thread, with a compose box
|
|
4
|
+
* and a resolve affordance. Reads the active annotation from the
|
|
5
|
+
* `../store.js` signals by id. Vanilla DOM.
|
|
6
|
+
*
|
|
7
|
+
* The reply text is read from the textarea at send time (no hook state);
|
|
8
|
+
* the overlay's thread layer re-renders this on annotation changes so
|
|
9
|
+
* new thread messages appear, which clears the in-progress reply — same
|
|
10
|
+
* behavior as the previous Preact `useState("")` reset after send.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { api } from "../api.js";
|
|
14
|
+
import { activeThreadId, annotations, author } from "../store.js";
|
|
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";
|
|
19
|
+
import { el } from "./dom.js";
|
|
20
|
+
|
|
21
|
+
export function Message(idt: AvatarIdentity, content: string): HTMLElement {
|
|
22
|
+
const name = idt.displayName ?? (idt.kind === "agent" ? "Agent" : "You");
|
|
23
|
+
return el(
|
|
24
|
+
"div",
|
|
25
|
+
{ class: `cf-msg cf-msg-${idt.kind}` },
|
|
26
|
+
el(
|
|
27
|
+
"div",
|
|
28
|
+
{ class: "cf-msg-meta" },
|
|
29
|
+
renderAvatar(idt),
|
|
30
|
+
el("span", { class: "cf-msg-name" }, name),
|
|
31
|
+
),
|
|
32
|
+
el("div", { class: "cf-msg-body" }, content),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function renderThreadPanel(id: string): HTMLElement | null {
|
|
37
|
+
const a = annotations.value.find((x) => x.id === id);
|
|
38
|
+
if (!a) return null;
|
|
39
|
+
|
|
40
|
+
const textarea = el("textarea", {
|
|
41
|
+
class: "cf-textarea cf-textarea-sm",
|
|
42
|
+
placeholder: "Reply…",
|
|
43
|
+
onKeyDown: (e: KeyboardEvent) => {
|
|
44
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") send();
|
|
45
|
+
},
|
|
46
|
+
onInput: () => syncSendState(),
|
|
47
|
+
}) as HTMLTextAreaElement;
|
|
48
|
+
|
|
49
|
+
const send = (): void => {
|
|
50
|
+
const text = textarea.value.trim();
|
|
51
|
+
if (!text) return;
|
|
52
|
+
api.replyToAnnotation(id, { role: author.value.kind, content: text });
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const sendBtn = el(
|
|
56
|
+
"button",
|
|
57
|
+
{ class: "cf-btn cf-btn-primary", disabled: true, onClick: send },
|
|
58
|
+
"Reply",
|
|
59
|
+
) as HTMLButtonElement;
|
|
60
|
+
|
|
61
|
+
function syncSendState(): void {
|
|
62
|
+
sendBtn.disabled = !textarea.value.trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const popup = el(
|
|
66
|
+
"div",
|
|
67
|
+
{ class: "cf-popup cf-thread" },
|
|
68
|
+
el(
|
|
69
|
+
"div",
|
|
70
|
+
{ class: "cf-popup-head" },
|
|
71
|
+
brandGlyph("cf-popup-brand"),
|
|
72
|
+
el("span", { class: "cf-popup-target" }, `<${a.element}>`),
|
|
73
|
+
a.severity
|
|
74
|
+
? el(
|
|
75
|
+
"span",
|
|
76
|
+
{ class: "cf-tag", style: `--cf-tag:${SEVERITY_COLOR[a.severity]};` },
|
|
77
|
+
a.severity,
|
|
78
|
+
)
|
|
79
|
+
: null,
|
|
80
|
+
el(
|
|
81
|
+
"button",
|
|
82
|
+
{
|
|
83
|
+
class: "cf-icon-btn",
|
|
84
|
+
onClick: () => {
|
|
85
|
+
activeThreadId.value = null;
|
|
86
|
+
},
|
|
87
|
+
title: "Close",
|
|
88
|
+
},
|
|
89
|
+
"✕",
|
|
90
|
+
),
|
|
91
|
+
),
|
|
92
|
+
el(
|
|
93
|
+
"div",
|
|
94
|
+
{ class: "cf-thread-body" },
|
|
95
|
+
Message(a.author ?? { kind: "human", displayName: "You" }, a.comment),
|
|
96
|
+
...(a.thread ?? []).map((m) =>
|
|
97
|
+
Message(
|
|
98
|
+
{ kind: m.role, displayName: m.role === "agent" ? "Agent" : "You" },
|
|
99
|
+
m.content,
|
|
100
|
+
),
|
|
101
|
+
),
|
|
102
|
+
),
|
|
103
|
+
el(
|
|
104
|
+
"div",
|
|
105
|
+
{ class: "cf-thread-compose" },
|
|
106
|
+
textarea,
|
|
107
|
+
el(
|
|
108
|
+
"div",
|
|
109
|
+
{ class: "cf-thread-actions" },
|
|
110
|
+
a.status !== "resolved"
|
|
111
|
+
? el(
|
|
112
|
+
"button",
|
|
113
|
+
{
|
|
114
|
+
class: "cf-btn cf-btn-ghost",
|
|
115
|
+
onClick: () => api.resolveAnnotation(id, author.value.kind),
|
|
116
|
+
},
|
|
117
|
+
"Resolve",
|
|
118
|
+
)
|
|
119
|
+
: el("span", { class: "cf-resolved-tag" }, "✓ resolved"),
|
|
120
|
+
sendBtn,
|
|
121
|
+
),
|
|
122
|
+
),
|
|
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;
|
|
132
|
+
}
|