authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-11 19:56:27-07:00
committergravatar for me@theo.liTheo Browne <me@theo.li> 2026-06-13 22:18:37-07:00
log07c6d70b91d2d2a81e9ccee0a9b1742b60d59eb2
treec19cc4857a61706b695eab38d97c379fdeb28056
parent50bc18b208db39ce1a6fb7200451f999fe457ce2

Add preview annotation capture tooling

- Add structured annotation payload validation and tests - Update preview preload to capture selected elements, regions, and strokes - Wire new preview annotation UI into the web app Co-authored-by: codex <codex@users.noreply.github.com>

13 files changed, 1476 insertions(+), 346 deletions(-)

apps/desktop/src/picked-element-payload.test.ts+67-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import { isPickedElementPayload } from "./picked-element-payload.ts";3import { isPickedElementPayload, isPreviewAnnotationPayload } from "./picked-element-payload.ts";
44
5function validPayload(overrides?: Record<string, unknown>): Record<string, unknown> {5function validPayload(overrides?: Record<string, unknown>): Record<string, unknown> {
6 return {6 return {
...@@ -133,3 +133,69 @@ describe("isPickedElementPayload", () => {...@@ -133,3 +133,69 @@ describe("isPickedElementPayload", () => {
133 expect(isPickedElementPayload(validPayload({ stack: [{ bogus: true }] }))).toBe(false);133 expect(isPickedElementPayload(validPayload({ stack: [{ bogus: true }] }))).toBe(false);
134 });134 });
135});135});
136
137function validAnnotation(overrides?: Record<string, unknown>): Record<string, unknown> {
138 return {
139 id: "annotation_1",
140 pageUrl: "https://example.com/",
141 pageTitle: "Example",
142 comment: "Make this clearer",
143 elements: [
144 {
145 id: "element_1",
146 element: validPayload(),
147 rect: { x: 10, y: 20, width: 100, height: 40 },
148 },
149 ],
150 regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }],
151 strokes: [
152 {
153 id: "stroke_1",
154 color: "#7c3aed",
155 width: 4,
156 points: [
157 { x: 10, y: 10 },
158 { x: 20, y: 20 },
159 ],
160 bounds: { x: 6, y: 6, width: 18, height: 18 },
161 },
162 ],
163 styleChanges: [
164 {
165 targetId: "element_1",
166 selector: "button.submit",
167 property: "opacity",
168 previousValue: "1",
169 value: "0.5",
170 },
171 ],
172 screenshot: null,
173 createdAt: "2026-06-11T00:00:00.000Z",
174 ...overrides,
175 };
176}
177
178describe("isPreviewAnnotationPayload", () => {
179 it("accepts a structured annotation draft before screenshot capture", () => {
180 expect(isPreviewAnnotationPayload(validAnnotation())).toBe(true);
181 });
182
183 it("rejects screenshots supplied by the guest preload", () => {
184 expect(isPreviewAnnotationPayload(validAnnotation({ screenshot: { dataUrl: "bad" } }))).toBe(
185 false,
186 );
187 });
188
189 it("rejects malformed geometry and nested element payloads", () => {
190 expect(
191 isPreviewAnnotationPayload(
192 validAnnotation({ regions: [{ id: "region_1", rect: { x: 0, y: 0, width: "wide" } }] }),
193 ),
194 ).toBe(false);
195 expect(
196 isPreviewAnnotationPayload(
197 validAnnotation({ elements: [{ id: "element_1", element: {}, rect: {} }] }),
198 ),
199 ).toBe(false);
200 });
201});
apps/desktop/src/picked-element-payload.ts+98-1
...@@ -10,7 +10,7 @@...@@ -10,7 +10,7 @@
10 * channel via prototype pollution) would otherwise throw deep in the10 * channel via prototype pollution) would otherwise throw deep in the
11 * renderer and the chip silently never appears.11 * renderer and the chip silently never appears.
12 */12 */
13import type { PickedElementPayload } from "@t3tools/contracts";13import type { PickedElementPayload, PreviewAnnotationPayload } from "@t3tools/contracts";
1414
15function isStringOrNull(value: unknown): value is string | null {15function isStringOrNull(value: unknown): value is string | null {
16 return value === null || typeof value === "string";16 return value === null || typeof value === "string";
...@@ -47,3 +47,100 @@ export function isPickedElementPayload(value: unknown): value is PickedElementPa...@@ -47,3 +47,100 @@ export function isPickedElementPayload(value: unknown): value is PickedElementPa
47 if (!c["stack"].every(isPickedStackFrame)) return false;47 if (!c["stack"].every(isPickedStackFrame)) return false;
48 return true;48 return true;
49}49}
50
51function isRect(value: unknown): boolean {
52 if (typeof value !== "object" || value === null) return false;
53 const rect = value as Record<string, unknown>;
54 return ["x", "y", "width", "height"].every(
55 (key) => typeof rect[key] === "number" && Number.isFinite(rect[key]),
56 );
57}
58
59function isPoint(value: unknown): boolean {
60 if (typeof value !== "object" || value === null) return false;
61 const point = value as Record<string, unknown>;
62 return (
63 typeof point["x"] === "number" &&
64 Number.isFinite(point["x"]) &&
65 typeof point["y"] === "number" &&
66 Number.isFinite(point["y"])
67 );
68}
69
70export function isPreviewAnnotationPayload(value: unknown): value is PreviewAnnotationPayload {
71 if (typeof value !== "object" || value === null) return false;
72 const annotation = value as Record<string, unknown>;
73 if (typeof annotation["id"] !== "string") return false;
74 if (typeof annotation["pageUrl"] !== "string") return false;
75 if (!isStringOrNull(annotation["pageTitle"])) return false;
76 if (typeof annotation["comment"] !== "string") return false;
77 if (typeof annotation["createdAt"] !== "string") return false;
78 if (annotation["screenshot"] !== null) return false;
79
80 const elements = annotation["elements"];
81 if (!Array.isArray(elements)) return false;
82 if (
83 !elements.every((entry) => {
84 if (typeof entry !== "object" || entry === null) return false;
85 const target = entry as Record<string, unknown>;
86 return (
87 typeof target["id"] === "string" &&
88 isPickedElementPayload(target["element"]) &&
89 isRect(target["rect"])
90 );
91 })
92 ) {
93 return false;
94 }
95
96 const regions = annotation["regions"];
97 if (!Array.isArray(regions)) return false;
98 if (
99 !regions.every((entry) => {
100 if (typeof entry !== "object" || entry === null) return false;
101 const target = entry as Record<string, unknown>;
102 return typeof target["id"] === "string" && isRect(target["rect"]);
103 })
104 ) {
105 return false;
106 }
107
108 const strokes = annotation["strokes"];
109 if (!Array.isArray(strokes)) return false;
110 if (
111 !strokes.every((entry) => {
112 if (typeof entry !== "object" || entry === null) return false;
113 const target = entry as Record<string, unknown>;
114 return (
115 typeof target["id"] === "string" &&
116 typeof target["color"] === "string" &&
117 typeof target["width"] === "number" &&
118 Number.isFinite(target["width"]) &&
119 Array.isArray(target["points"]) &&
120 target["points"].every(isPoint) &&
121 isRect(target["bounds"])
122 );
123 })
124 ) {
125 return false;
126 }
127
128 const styleChanges = annotation["styleChanges"];
129 if (!Array.isArray(styleChanges)) return false;
130 if (
131 !styleChanges.every((entry) => {
132 if (typeof entry !== "object" || entry === null) return false;
133 const change = entry as Record<string, unknown>;
134 return (
135 typeof change["targetId"] === "string" &&
136 isStringOrNull(change["selector"]) &&
137 typeof change["property"] === "string" &&
138 typeof change["previousValue"] === "string" &&
139 typeof change["value"] === "string"
140 );
141 })
142 ) {
143 return false;
144 }
145 return true;
146}
apps/desktop/src/preview-pick-preload.ts+1008-311
...@@ -1,175 +1,101 @@...@@ -1,175 +1,101 @@
1// @effect-diagnostics globalDate:off1// @effect-diagnostics globalDate:off
2/**
3 * Preview pick preload — runs in the isolated world of the Chromium
4 * `<webview>` that hosts the in-app browser. Loaded via the
5 * `<webview preload="...">` attribute set by the renderer.
6 *
7 * Responsibilities:
8 *
9 * 1. Listen for `preview:start-pick` from main (sent through
10 * `WebContents.send`, received here via `ipcRenderer.on`).
11 * 2. Mount a minimal blue-highlight element picker on the page.
12 * 3. On click → call `react-grab/primitives.getElementContext` and bubble
13 * a `PickedElementPayload` to main via `ipcRenderer.send`. Main resolves
14 * the in-flight `pickElement(tabId)` promise via its per-WebContents
15 * `wc.ipc.on(...)` listener.
16 * 4. Tear down the picker on Escape, blur, navigation, or explicit cancel.
17 *
18 * Design notes:
19 *
20 * - We never modify the page's DOM tree — the highlight + crosshair cursor
21 * live on a single fixed-position overlay layer that we own. This keeps
22 * us safe against pages that reset DOM or do MutationObserver tracking.
23 * - The overlay uses `pointer-events: none` so clicks fall through to the
24 * real element behind it; we do hit-testing with `elementFromPoint`.
25 * - Only a single pick session is ever active per webview; re-activating
26 * silently replaces the previous session.
27 * - Cancellations triggered BY MAIN (CANCEL_PICK_CHANNEL, or a follow-up
28 * START_PICK_CHANNEL that supersedes the current session) tear down
29 * silently — they do NOT echo a `null` ELEMENT_PICKED back, because main
30 * already knows it cancelled and would otherwise resolve the freshly-
31 * registered next-pick listener with that stale `null`. Cancellations
32 * triggered by the USER (Escape, beforeunload, click on empty) DO send
33 * `null` back so main can resolve the in-flight pick promise.
34 */
35import { ipcRenderer } from "electron";2import { ipcRenderer } from "electron";
36import { getElementContext } from "react-grab/primitives";3import { getElementContext } from "react-grab/primitives";
37import type { PickedElementPayload, PickedElementStackFrame } from "@t3tools/contracts";4import type {
385 PickedElementPayload,
39import { computeLabelPosition } from "./preview-pick-label-position.ts";6 PickedElementStackFrame,
7 PreviewAnnotationPayload,
8 PreviewAnnotationPoint,
9 PreviewAnnotationRect,
10 PreviewAnnotationRegionTarget,
11 PreviewAnnotationStrokeTarget,
12 PreviewAnnotationStyleChange,
13} from "@t3tools/contracts";
4014
41const START_PICK_CHANNEL = "preview:start-pick";15const START_PICK_CHANNEL = "preview:start-pick";
42const CANCEL_PICK_CHANNEL = "preview:cancel-pick";16const CANCEL_PICK_CHANNEL = "preview:cancel-pick";
43const ELEMENT_PICKED_CHANNEL = "preview:element-picked";17const ELEMENT_PICKED_CHANNEL = "preview:element-picked";
4418const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured";
45const HIGHLIGHT_COLOR = "rgba(37, 99, 235, 0.9)"; // blue-60019const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui";
46const HIGHLIGHT_FILL = "rgba(37, 99, 235, 0.16)";
47const LABEL_BG = "rgba(37, 99, 235, 0.96)";
48const Z_INDEX_OVERLAY = 2147483646;20const Z_INDEX_OVERLAY = 2147483646;
21const ACCENT = "#7c3aed";
22const ACCENT_FILL = "rgba(124,58,237,0.12)";
23const BLUE = "#2563eb";
24const MAX_MARQUEE_ELEMENTS = 20;
25const CONTENT_LAYER_Z_INDEX = 1;
26const CHROME_LAYER_Z_INDEX = 10;
4927
50interface PickSession {28type AnnotationTool = "select" | "marquee" | "draw" | "erase";
51 readonly overlay: HTMLDivElement;
52 readonly outline: HTMLDivElement;
53 readonly label: HTMLDivElement;
54 /**
55 * Tear down listeners + DOM WITHOUT notifying main. Used when main itself
56 * initiated the cancel (CANCEL_PICK_CHANNEL) or when a follow-up startPick
57 * supersedes us — main is already waiting on a fresh listener and would
58 * otherwise resolve it with the stale `null` we'd send.
59 */
60 readonly teardownSilent: () => void;
61}
62
63let activeSession: PickSession | null = null;
6429
65function endActiveSession(): void {30interface SelectedElement {
66 if (!activeSession) return;31 id: string;
67 const session = activeSession;32 element: Element;
68 activeSession = null;33 outline: HTMLDivElement;
69 // Supersession from a new startPick is a main-initiated transition: tear34 label: HTMLDivElement;
70 // down silently so the new pick's main-side listener doesn't receive a35 baselineStyles: Map<string, string>;
71 // ghost `null` from the old session.
72 session.teardownSilent();
73}36}
7437
75interface OverlayHandles {38interface AnnotationSession {
76 readonly overlay: HTMLDivElement;39 teardown: (notifyMain: boolean) => void;
77 readonly outline: HTMLDivElement;
78 readonly label: HTMLDivElement;
79 readonly destroyDom: () => void;
80}40}
8141
82function createOverlay(): OverlayHandles {42let activeSession: AnnotationSession | null = null;
83 const overlay = document.createElement("div");43let idSequence = 0;
84 overlay.setAttribute("data-t3code-pick-overlay", "");
85 overlay.style.cssText = [
86 "position:fixed",
87 "inset:0",
88 "z-index:" + String(Z_INDEX_OVERLAY),
89 "pointer-events:none",
90 "cursor:crosshair",
91 // Some apps register `pointer-events: auto !important` on body — using a
92 // dedicated overlay element ensures we don't fight with that.
93 ].join(";");
9444
95 const outline = document.createElement("div");45const nextId = (prefix: string): string => {
96 outline.setAttribute("data-t3code-pick-outline", "");46 idSequence += 1;
97 outline.style.cssText = [47 return `${prefix}_${idSequence.toString(36)}`;
98 "position:absolute",48};
99 "left:0",
100 "top:0",
101 "width:0",
102 "height:0",
103 "border:2px solid " + HIGHLIGHT_COLOR,
104 "background:" + HIGHLIGHT_FILL,
105 "border-radius:2px",
106 "box-shadow:0 0 0 1px rgba(255,255,255,0.6) inset",
107 "transition:none",
108 "display:none",
109 "pointer-events:none",
110 ].join(";");
11149
112 const label = document.createElement("div");50const rectFromDomRect = (rect: DOMRect): PreviewAnnotationRect => ({
113 label.setAttribute("data-t3code-pick-label", "");51 x: rect.left,
114 // `top:0; left:0` so transform translate() positions are absolute viewport52 y: rect.top,
115 // coordinates (we re-anchor the label every paint via a single transform).53 width: rect.width,
116 // `max-width: calc(100vw - 8px)` + ellipsis prevents an overly long54 height: rect.height,
117 // tag#id.class string from overflowing the viewport horizontally before we55});
118 // even get to the clamp logic.56
119 label.style.cssText = [57const normalizeRect = (
120 "position:absolute",58 startX: number,
121 "left:0",59 startY: number,
122 "top:0",60 endX: number,
123 "padding:2px 6px",61 endY: number,
124 "background:" + LABEL_BG,62): PreviewAnnotationRect => ({
125 "color:white",63 x: Math.min(startX, endX),
126 "font:600 11px/1.2 ui-sans-serif,system-ui,-apple-system,sans-serif",64 y: Math.min(startY, endY),
127 "border-radius:3px",65 width: Math.abs(endX - startX),
128 "pointer-events:none",66 height: Math.abs(endY - startY),
129 "white-space:nowrap",67});
130 "max-width:calc(100vw - 8px)",68
131 "overflow:hidden",69const isUsableRect = (rect: PreviewAnnotationRect): boolean => rect.width >= 3 && rect.height >= 3;
132 "text-overflow:ellipsis",
133 "box-shadow:0 1px 2px rgba(0,0,0,0.25)",
134 "display:none",
135 ].join(";");
13670
137 overlay.appendChild(outline);71function unionRects(rects: ReadonlyArray<PreviewAnnotationRect>): PreviewAnnotationRect | null {
138 overlay.appendChild(label);72 if (rects.length === 0) return null;
139 document.documentElement.appendChild(overlay);73 const left = Math.min(...rects.map((rect) => rect.x));
14074 const top = Math.min(...rects.map((rect) => rect.y));
141 // Force the crosshair cursor across the whole page even though we set it75 const right = Math.max(...rects.map((rect) => rect.x + rect.width));
142 // on the overlay (because pointer-events: none means it's never the76 const bottom = Math.max(...rects.map((rect) => rect.y + rect.height));
143 // cursor target). We add a stylesheet rule and revert on cleanup.77 const padding = 20;
144 const styleEl = document.createElement("style");78 const x = Math.max(0, left - padding);
145 styleEl.textContent = `html[data-t3code-picking="1"], html[data-t3code-picking="1"] *, html[data-t3code-picking="1"] *::before, html[data-t3code-picking="1"] *::after { cursor: crosshair !important; }`;79 const y = Math.max(0, top - padding);
146 document.documentElement.appendChild(styleEl);80 const maxWidth = Math.max(1, window.innerWidth - x);
147 document.documentElement.setAttribute("data-t3code-picking", "1");81 const maxHeight = Math.max(1, window.innerHeight - y);
14882 return {
149 const destroyDom = (): void => {83 x,
150 overlay.remove();84 y,
151 styleEl.remove();85 width: Math.min(maxWidth, right - left + padding * 2),
152 document.documentElement.removeAttribute("data-t3code-picking");86 height: Math.min(maxHeight, bottom - top + padding * 2),
153 };87 };
88}
15489
155 return { overlay, outline, label, destroyDom };90function isAnnotationNode(element: Element): boolean {
91 return element instanceof Element && element.closest(`[${OVERLAY_ATTRIBUTE}]`) !== null;
156}92}
15793
158/**
159 * Resolve the element under the cursor while ignoring our own overlay.
160 * We render at z-index 2147483646 with `pointer-events: none`, which means
161 * `elementFromPoint` already skips the overlay — but we double-guard against
162 * pages that mutate `pointer-events` via MutationObservers.
163 */
164function pickFromPoint(clientX: number, clientY: number): Element | null {94function pickFromPoint(clientX: number, clientY: number): Element | null {
165 const candidates = document.elementsFromPoint(clientX, clientY);95 for (const candidate of document.elementsFromPoint(clientX, clientY)) {
166 for (const candidate of candidates) {
167 if (!(candidate instanceof Element)) continue;96 if (!(candidate instanceof Element)) continue;
168 if (candidate.hasAttribute("data-t3code-pick-overlay")) continue;97 if (isAnnotationNode(candidate)) continue;
169 if (candidate.hasAttribute("data-t3code-pick-outline")) continue;98 if (candidate === document.documentElement || candidate === document.body) continue;
170 if (candidate.hasAttribute("data-t3code-pick-label")) continue;
171 if (candidate === document.documentElement) continue;
172 if (candidate === document.body) continue;
173 return candidate;99 return candidate;
174 }100 }
175 return null;101 return null;
...@@ -178,94 +104,74 @@ function pickFromPoint(clientX: number, clientY: number): Element | null {...@@ -178,94 +104,74 @@ function pickFromPoint(clientX: number, clientY: number): Element | null {
178function describeRawElement(element: Element): string {104function describeRawElement(element: Element): string {
179 const tag = element.tagName.toLowerCase();105 const tag = element.tagName.toLowerCase();
180 const id = element.id ? `#${element.id}` : "";106 const id = element.id ? `#${element.id}` : "";
181 const className =107 const classes =
182 element instanceof HTMLElement && typeof element.className === "string"108 element instanceof HTMLElement && typeof element.className === "string"
183 ? element.className109 ? element.className
184 .trim()110 .trim()
185 .split(/\s+/)111 .split(/\s+/)
186 .filter((token) => token.length > 0)112 .filter(Boolean)
187 .slice(0, 2)113 .slice(0, 2)
188 .map((token) => `.${token}`)114 .map((name) => `.${name}`)
189 .join("")115 .join("")
190 : "";116 : "";
191 return `${tag}${id}${className}`;117 return `${tag}${id}${classes}`;
192}118}
193119
194/**120function createBox(color: string, fill: string): HTMLDivElement {
195 * Per-session cache of the resolved React component name for elements we've121 const node = document.createElement("div");
196 * already inspected. Stores `null` when react-grab couldn't find a component122 node.setAttribute(OVERLAY_ATTRIBUTE, "");
197 * (raw HTML / unmounted) so repeat hovers don't re-pay the async lookup.123 node.style.cssText = [
198 */124 "position:fixed",
199const componentNameCache = new WeakMap<Element, string | null>();125 "pointer-events:none",
200const componentNameInFlight = new WeakSet<Element>();126 `border:2px solid ${color}`,
201127 `background:${fill}`,
202function describeElement(element: Element): string {128 "border-radius:3px",
203 const cached = componentNameCache.get(element);129 "box-sizing:border-box",
204 if (cached) return `<${cached}> ${describeRawElement(element)}`;130 "display:none",
205 return describeRawElement(element);131 `z-index:${CONTENT_LAYER_Z_INDEX}`,
132 ].join(";");
133 return node;
206}134}
207135
208function paintOutline(handles: OverlayHandles, element: Element): void {136function positionBox(node: HTMLElement, rect: PreviewAnnotationRect): void {
209 const rect = element.getBoundingClientRect();137 node.style.display = "block";
210 if (rect.width === 0 && rect.height === 0) {138 node.style.transform = `translate(${rect.x}px, ${rect.y}px)`;
211 handles.outline.style.display = "none";139 node.style.width = `${rect.width}px`;
212 handles.label.style.display = "none";140 node.style.height = `${rect.height}px`;
213 return;
214 }
215 handles.outline.style.display = "block";
216 handles.outline.style.transform = `translate(${rect.left}px, ${rect.top}px)`;
217 handles.outline.style.width = `${rect.width}px`;
218 handles.outline.style.height = `${rect.height}px`;
219
220 // Two-pass label paint: first apply the new text and force-block display
221 // so we can measure the rendered size, then clamp to the viewport and
222 // flip below the element when there isn't room above.
223 const text = describeElement(element);
224 if (handles.label.textContent !== text) {
225 handles.label.textContent = text;
226 }
227 handles.label.style.display = "block";
228
229 const labelRect = handles.label.getBoundingClientRect();
230 const { x, y } = computeLabelPosition({
231 targetLeft: rect.left,
232 targetTop: rect.top,
233 targetBottom: rect.bottom,
234 labelWidth: labelRect.width,
235 labelHeight: labelRect.height,
236 viewportWidth: document.documentElement.clientWidth || window.innerWidth || labelRect.width,
237 viewportHeight: document.documentElement.clientHeight || window.innerHeight || labelRect.height,
238 });
239 handles.label.style.transform = `translate(${x}px, ${y}px)`;
240}141}
241142
242/**143function createLabel(): HTMLDivElement {
243 * Kick off (at most once per element) an async react-grab lookup for the144 const label = document.createElement("div");
244 * component name. When the answer arrives, repaint the label iff the element145 label.setAttribute(OVERLAY_ATTRIBUTE, "");
245 * is still under the cursor — otherwise the user has moved on and a stale146 label.style.cssText = [
246 * paint would clobber the next element's label.147 "position:fixed",
247 */148 "pointer-events:none",
248function ensureComponentName(element: Element, onResolve: (resolvedFor: Element) => void): void {149 `background:${ACCENT}`,
249 if (componentNameCache.has(element)) return;150 "color:white",
250 if (componentNameInFlight.has(element)) return;151 "font:600 11px/1.2 ui-sans-serif,system-ui,-apple-system,sans-serif",
251 componentNameInFlight.add(element);152 "padding:2px 6px",
252 void getElementContext(element)153 "border-radius:4px",
253 .then((context) => {154 "white-space:nowrap",
254 const trimmed = context.componentName?.trim();155 "max-width:280px",
255 componentNameCache.set(element, trimmed && trimmed.length > 0 ? trimmed : null);156 "overflow:hidden",
256 })157 "text-overflow:ellipsis",
257 .catch(() => {158 "box-shadow:0 1px 3px rgba(0,0,0,.3)",
258 componentNameCache.set(element, null);159 `z-index:${CONTENT_LAYER_Z_INDEX}`,
259 })160 ].join(";");
260 .finally(() => {161 return label;
261 componentNameInFlight.delete(element);
262 onResolve(element);
263 });
264}162}
265163
266function clearOutline(handles: OverlayHandles): void {164function updateSelectedVisual(target: SelectedElement): void {
267 handles.outline.style.display = "none";165 if (!target.element.isConnected) {
268 handles.label.style.display = "none";166 target.outline.style.display = "none";
167 target.label.style.display = "none";
168 return;
169 }
170 const rect = target.element.getBoundingClientRect();
171 positionBox(target.outline, rectFromDomRect(rect));
172 target.label.textContent = describeRawElement(target.element);
173 target.label.style.display = "block";
174 target.label.style.transform = `translate(${Math.max(4, rect.left)}px, ${Math.max(4, rect.top - 22)}px)`;
269}175}
270176
271function toStackFrame(frame: {177function toStackFrame(frame: {
...@@ -285,10 +191,10 @@ function toStackFrame(frame: {...@@ -285,10 +191,10 @@ function toStackFrame(frame: {
285async function captureElement(element: Element): Promise<PickedElementPayload | null> {191async function captureElement(element: Element): Promise<PickedElementPayload | null> {
286 try {192 try {
287 const context = await getElementContext(element);193 const context = await getElementContext(element);
288 const stack = (context.stack ?? []).map((frame) => toStackFrame(frame));194 const stack = (context.stack ?? []).map(toStackFrame);
289 return {195 return {
290 pageUrl: location.href,196 pageUrl: location.href,
291 pageTitle: document.title?.trim() ? document.title.trim() : null,197 pageTitle: document.title?.trim() || null,
292 tagName: element.tagName.toLowerCase(),198 tagName: element.tagName.toLowerCase(),
293 selector: context.selector,199 selector: context.selector,
294 htmlPreview: context.htmlPreview ?? "",200 htmlPreview: context.htmlPreview ?? "",
...@@ -303,118 +209,909 @@ async function captureElement(element: Element): Promise<PickedElementPayload |...@@ -303,118 +209,909 @@ async function captureElement(element: Element): Promise<PickedElementPayload |
303 }209 }
304}210}
305211
306function startPick(): void {212function createButton(label: string, title: string): HTMLButtonElement {
307 endActiveSession();213 const button = document.createElement("button");
308 if (typeof document === "undefined" || !document.body) {214 button.type = "button";
309 ipcRenderer.send(ELEMENT_PICKED_CHANNEL, null);215 button.textContent = label;
310 return;216 button.title = title;
217 button.style.cssText = [
218 "border:0",
219 "border-radius:7px",
220 "padding:6px 9px",
221 "font:600 12px/1 ui-sans-serif,system-ui,-apple-system,sans-serif",
222 "background:transparent",
223 "color:#27272a",
224 "cursor:pointer",
225 ].join(";");
226 return button;
227}
228
229function styleControl(input: HTMLInputElement | HTMLSelectElement): void {
230 input.setAttribute("aria-label", input.getAttribute("aria-label") ?? "Style value");
231 input.style.cssText +=
232 ";min-width:0;width:100%;height:26px;border:0;border-radius:7px;background:rgba(255,255,255,.78);color:#27272a;padding:3px 8px;box-sizing:border-box;font:500 11px/1 ui-monospace,SFMono-Regular,Menlo,monospace;outline:none;box-shadow:inset 0 0 0 1px rgba(0,0,0,.09);appearance:none";
233}
234
235function createUnitControl(input: HTMLInputElement): HTMLElement {
236 const wrapper = document.createElement("div");
237 wrapper.style.cssText = "position:relative;min-width:0";
238 const unit = document.createElement("span");
239 unit.textContent = input.dataset.unit ?? "";
240 unit.style.cssText =
241 "position:absolute;right:8px;top:50%;transform:translateY(-50%);pointer-events:none;color:#a1a1aa;font:500 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace";
242 wrapper.append(input, unit);
243 return wrapper;
244}
245
246function createField(
247 labelText: string,
248 input: HTMLInputElement | HTMLSelectElement,
249): HTMLLabelElement {
250 const label = document.createElement("label");
251 label.style.cssText =
252 "display:grid;grid-template-columns:82px minmax(0,1fr);align-items:center;gap:8px;min-height:28px;font:500 11px/1.2 ui-sans-serif,system-ui;color:#52525b";
253 const text = document.createElement("span");
254 text.textContent = labelText;
255 styleControl(input);
256 label.append(
257 text,
258 input instanceof HTMLInputElement && input.dataset.unit ? createUnitControl(input) : input,
259 );
260 return label;
261}
262
263function createStyleSection(): HTMLElement {
264 const section = document.createElement("section");
265 section.style.cssText = "display:grid;gap:3px;padding:7px 0;border-top:1px solid rgba(0,0,0,.07)";
266 return section;
267}
268
269function createUnitInput(unit: string, placeholder = "0"): HTMLInputElement {
270 const input = document.createElement("input");
271 input.type = "number";
272 input.placeholder = placeholder;
273 input.style.paddingRight = "30px";
274 input.dataset.unit = unit;
275 return input;
276}
277
278function pathFromPoints(points: ReadonlyArray<PreviewAnnotationPoint>): string {
279 if (points.length === 0) return "";
280 if (points.length === 1) return `M ${points[0]!.x} ${points[0]!.y} l 0.01 0.01`;
281 let path = `M ${points[0]!.x} ${points[0]!.y}`;
282 for (let index = 1; index < points.length - 1; index += 1) {
283 const current = points[index]!;
284 const next = points[index + 1]!;
285 path += ` Q ${current.x} ${current.y} ${(current.x + next.x) / 2} ${(current.y + next.y) / 2}`;
311 }286 }
287 const last = points[points.length - 1]!;
288 path += ` L ${last.x} ${last.y}`;
289 return path;
290}
291
292function strokeBounds(
293 points: ReadonlyArray<PreviewAnnotationPoint>,
294 width: number,
295): PreviewAnnotationRect {
296 const xs = points.map((point) => point.x);
297 const ys = points.map((point) => point.y);
298 const padding = width + 3;
299 const left = Math.min(...xs) - padding;
300 const top = Math.min(...ys) - padding;
301 const right = Math.max(...xs) + padding;
302 const bottom = Math.max(...ys) + padding;
303 return { x: left, y: top, width: right - left, height: bottom - top };
304}
305
306function startAnnotation(): void {
307 activeSession?.teardown(false);
308 let finished = false;
309 const root = document.createElement("div");
310 root.setAttribute(OVERLAY_ATTRIBUTE, "");
311 root.style.cssText = `position:fixed;inset:0;z-index:${Z_INDEX_OVERLAY};pointer-events:none;font-family:ui-sans-serif,system-ui,-apple-system,sans-serif`;
312 const cursorStyle = document.createElement("style");
313 cursorStyle.setAttribute(OVERLAY_ATTRIBUTE, "");
314 cursorStyle.textContent = `html[data-t3code-annotation-tool] body, html[data-t3code-annotation-tool] body * { cursor: crosshair !important; } [${OVERLAY_ATTRIBUTE}], [${OVERLAY_ATTRIBUTE}] * { cursor: default !important; } [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-inner-spin-button, [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-outer-spin-button { appearance:none; margin:0; }`;
315 root.appendChild(cursorStyle);
316
317 const hoverOutline = createBox(BLUE, "rgba(37,99,235,.10)");
318 const marqueeBox = createBox(ACCENT, ACCENT_FILL);
319 root.append(hoverOutline, marqueeBox);
320
321 const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
322 svg.setAttribute(OVERLAY_ATTRIBUTE, "");
323 svg.setAttribute("width", "100%");
324 svg.setAttribute("height", "100%");
325 svg.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`);
326 svg.style.cssText = "position:fixed;inset:0;overflow:visible;pointer-events:none";
327 svg.style.zIndex = String(CONTENT_LAYER_Z_INDEX);
328 root.appendChild(svg);
329
330 const toolbar = document.createElement("div");
331 toolbar.setAttribute(OVERLAY_ATTRIBUTE, "");
332 toolbar.style.cssText = `position:fixed;top:10px;left:50%;transform:translateX(-50%);z-index:${CHROME_LAYER_Z_INDEX};pointer-events:auto;display:flex;gap:2px;padding:3px;border:1px solid rgba(0,0,0,.10);border-radius:9px;background:rgba(255,255,255,.94);box-shadow:0 5px 16px rgba(0,0,0,.12);backdrop-filter:blur(12px)`;
333 root.appendChild(toolbar);
334
335 const editor = document.createElement("div");
336 editor.setAttribute(OVERLAY_ATTRIBUTE, "");
337 editor.style.cssText = `position:fixed;right:12px;bottom:12px;z-index:${CHROME_LAYER_Z_INDEX};width:min(306px,calc(100vw - 24px));max-height:calc(100vh - 32px);overflow:hidden;pointer-events:auto;display:none;flex-direction:column;border:1px solid rgba(0,0,0,.09);border-radius:14px;background:rgba(255,255,255,.96);box-shadow:0 14px 36px rgba(0,0,0,.18);color:#18181b;box-sizing:border-box;backdrop-filter:blur(18px)`;
338 root.appendChild(editor);
339
340 const editorHeader = document.createElement("div");
341 editorHeader.style.cssText =
342 "display:flex;align-items:center;gap:7px;padding:7px 8px;border-bottom:1px solid rgba(0,0,0,.06);cursor:grab;user-select:none";
343 const adjustIcon = document.createElement("div");
344 adjustIcon.innerHTML =
345 '<svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true"><path d="M3 6h8M15 6h2M3 14h2M9 14h8M11 3v6M7 11v6" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><circle cx="13" cy="6" r="2" fill="white" stroke="currentColor" stroke-width="1.5"/><circle cx="7" cy="14" r="2" fill="white" stroke="currentColor" stroke-width="1.5"/></svg>';
346 adjustIcon.style.cssText =
347 "display:grid;place-items:center;width:24px;height:24px;border-radius:999px;background:#f4f4f5;color:#52525b;font:700 14px/1 ui-sans-serif";
348 editorHeader.appendChild(adjustIcon);
349
350 const status = document.createElement("div");
351 status.style.cssText =
352 "min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:600 11px/1.2 ui-sans-serif,system-ui;color:#71717a";
353 editorHeader.appendChild(status);
354 const dragHandle = document.createElement("div");
355 dragHandle.textContent = "â ¿";
356 dragHandle.title = "Drag annotation editor";
357 dragHandle.style.cssText = "color:#a1a1aa;font:700 18px/1 ui-sans-serif";
358 editorHeader.appendChild(dragHandle);
359 editor.appendChild(editorHeader);
360
361 const comment = document.createElement("textarea");
362 comment.placeholder = "Describe the change…";
363 comment.rows = 2;
364 comment.style.cssText =
365 "width:100%;resize:none;min-height:48px;border:0;padding:10px 12px;font:500 12px/1.35 ui-sans-serif,system-ui;color:#18181b;box-sizing:border-box;outline:none;background:transparent";
366 editor.appendChild(comment);
312367
313 const handles = createOverlay();368 const stylePanel = document.createElement("div");
314 let lastTarget: Element | null = null;369 stylePanel.style.cssText =
315 let resolved = false;370 "display:none;max-height:min(380px,calc(100vh - 180px));overflow:auto;padding:0 10px;background:rgba(250,250,250,.62);border-top:1px solid rgba(0,0,0,.06)";
316371 editor.appendChild(stylePanel);
317 // Tear down listeners + DOM. Does NOT notify main. `notifyMain` controls372
318 // whether we additionally bubble a `null` ELEMENT_PICKED back so main can373 const footer = document.createElement("div");
319 // resolve the in-flight pick promise (only true for USER-initiated cancels;374 footer.style.cssText =
320 // main already knows about its own cancellations).375 "display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 8px;border-top:1px solid rgba(0,0,0,.06);background:rgba(255,255,255,.92)";
321 const teardown = (notifyMain: boolean, payload: PickedElementPayload | null): void => {376 const adjust = createButton("Adjust", "Show style controls for selected elements");
322 if (resolved) return;377 adjust.style.cssText +=
323 resolved = true;378 ";display:none;padding:5px 7px;color:#52525b;background:#f4f4f5;font-size:11px";
324 window.removeEventListener("mousemove", onMove, { capture: true } as EventListenerOptions);379 const submit = createButton("Attach", "Attach annotation and screenshot");
325 window.removeEventListener("click", onClick, { capture: true } as EventListenerOptions);380 submit.style.cssText += `;background:${ACCENT};color:white;padding:6px 9px;font-size:11px`;
326 window.removeEventListener("keydown", onKey, { capture: true } as EventListenerOptions);381 footer.append(adjust, submit);
327 window.removeEventListener("scroll", onScrollOrResize, {382 editor.appendChild(footer);
328 capture: true,383
329 } as EventListenerOptions);384 const selected = new Map<Element, SelectedElement>();
330 window.removeEventListener("resize", onScrollOrResize);385 const regions: PreviewAnnotationRegionTarget[] = [];
331 window.removeEventListener("beforeunload", onBeforeUnload);386 const strokes: PreviewAnnotationStrokeTarget[] = [];
332 ipcRenderer.off(CANCEL_PICK_CHANNEL, onMainCancel);387 const styleChanges = new Map<string, PreviewAnnotationStyleChange>();
333 handles.destroyDom();388 const toolButtons = new Map<AnnotationTool, HTMLButtonElement>();
334 if (notifyMain) {389 let tool: AnnotationTool = "select";
335 // `ipcRenderer.send` (NOT `sendToHost`) reaches main, where the390 let dragStart: PreviewAnnotationPoint | null = null;
336 // PreviewViewManager's per-WebContents `wc.ipc.on(...)` listener391 let activeStroke: { target: PreviewAnnotationStrokeTarget; path: SVGPathElement } | null = null;
337 // resolves the in-flight pick promise.392 let pendingCapture = false;
338 ipcRenderer.send(ELEMENT_PICKED_CHANNEL, payload);393 let stylesExpanded = false;
394 let editorWasShown = false;
395 let editorPosition: { left: number; top: number } | null = null;
396 let editorDrag: { pointerId: number; offsetX: number; offsetY: number } | null = null;
397
398 const updateStatus = (): void => {
399 const parts = [
400 selected.size > 0 ? `${selected.size} element${selected.size === 1 ? "" : "s"}` : "",
401 regions.length > 0 ? `${regions.length} region${regions.length === 1 ? "" : "s"}` : "",
402 strokes.length > 0 ? `${strokes.length} drawing${strokes.length === 1 ? "" : "s"}` : "",
403 ].filter(Boolean);
404 const hasTargets = parts.length > 0;
405 status.textContent = parts.join(" · ");
406 editor.style.display = hasTargets ? "flex" : "none";
407 submit.disabled = !hasTargets;
408 submit.style.opacity = hasTargets ? "1" : "0.45";
409 adjust.style.display = selected.size > 0 ? "block" : "none";
410 if (selected.size === 0 && stylesExpanded) {
411 stylesExpanded = false;
412 stylePanel.style.display = "none";
413 adjust.textContent = "Adjust";
414 }
415 if (hasTargets && !editorWasShown) {
416 editorWasShown = true;
417 window.setTimeout(() => comment.focus({ preventScroll: true }), 0);
339 }418 }
340 };419 };
341420
342 // Re-paint when an in-flight component-name lookup resolves, but only if421 const refreshToolButtons = (): void => {
343 // the same element is still under the cursor — otherwise the user moved on422 for (const [candidate, button] of toolButtons) {
344 // and we'd clobber the next element's label.423 const active = candidate === tool;
345 const onComponentNameResolved = (resolvedFor: Element): void => {424 button.style.background = active ? ACCENT_FILL : "transparent";
346 if (lastTarget !== resolvedFor) return;425 button.style.color = active ? ACCENT : "#27272a";
347 paintOutline(handles, resolvedFor);426 }
427 if (tool !== "select") hoverOutline.style.display = "none";
428 if (tool !== "marquee") marqueeBox.style.display = "none";
429 document.documentElement.setAttribute("data-t3code-annotation-tool", tool);
348 };430 };
349431
350 const onMove = (event: MouseEvent): void => {432 const removeSelected = (target: SelectedElement): void => {
351 const target = pickFromPoint(event.clientX, event.clientY);433 if (target.element instanceof HTMLElement || target.element instanceof SVGElement) {
352 if (target === lastTarget) return;434 for (const [property, baseline] of target.baselineStyles) {
353 lastTarget = target;435 if (baseline) target.element.style.setProperty(property, baseline);
354 if (target) {436 else target.element.style.removeProperty(property);
355 paintOutline(handles, target);437 }
356 ensureComponentName(target, onComponentNameResolved);438 }
357 } else {439 selected.delete(target.element);
358 clearOutline(handles);440 target.outline.remove();
441 target.label.remove();
442 for (const [key, change] of styleChanges) {
443 if (change.targetId === target.id) styleChanges.delete(key);
359 }444 }
445 updateStatus();
360 };446 };
361447
362 const onScrollOrResize = (): void => {448 const addSelected = (element: Element): void => {
363 if (lastTarget) paintOutline(handles, lastTarget);449 if (selected.has(element)) return;
450 const target: SelectedElement = {
451 id: nextId("element"),
452 element,
453 outline: createBox(ACCENT, ACCENT_FILL),
454 label: createLabel(),
455 baselineStyles: new Map(),
456 };
457 selected.set(element, target);
458 root.append(target.outline, target.label);
459 updateSelectedVisual(target);
460 updateStatus();
461 if (stylesExpanded) syncStyleControls();
364 };462 };
365463
366 const onClick = (event: MouseEvent): void => {464 const toggleSelected = (element: Element, additive: boolean): void => {
465 const existing = selected.get(element);
466 if (existing) {
467 removeSelected(existing);
468 return;
469 }
470 if (!additive) {
471 for (const target of Array.from(selected.values())) removeSelected(target);
472 }
473 addSelected(element);
474 };
475
476 const setStyleForSelected = (property: string, value: string): void => {
477 for (const target of selected.values()) {
478 if (!(target.element instanceof HTMLElement || target.element instanceof SVGElement))
479 continue;
480 if (!target.baselineStyles.has(property)) {
481 target.baselineStyles.set(property, target.element.style.getPropertyValue(property));
482 }
483 const key = `${target.id}:${property}`;
484 const previousValue =
485 styleChanges.get(key)?.previousValue ??
486 getComputedStyle(target.element).getPropertyValue(property).trim();
487 target.element.style.setProperty(property, value, "important");
488 styleChanges.set(key, {
489 targetId: target.id,
490 selector: null,
491 property,
492 previousValue,
493 value,
494 });
495 updateSelectedVisual(target);
496 }
497 };
498
499 const textSection = createStyleSection();
500 const colorsSection = createStyleSection();
501 const bordersSection = createStyleSection();
502 const sizingSection = createStyleSection();
503 stylePanel.append(textSection, colorsSection, bordersSection, sizingSection);
504
505 const fontFamily = document.createElement("select");
506 for (const value of ["inherit", "system-ui", "sans-serif", "serif", "monospace"]) {
507 const option = document.createElement("option");
508 option.value = value;
509 option.textContent = value;
510 fontFamily.appendChild(option);
511 }
512 fontFamily.addEventListener("change", () => setStyleForSelected("font-family", fontFamily.value));
513 textSection.appendChild(createField("Font", fontFamily));
514
515 const fontSize = createUnitInput("px", "16");
516 fontSize.min = "1";
517 fontSize.max = "300";
518 fontSize.addEventListener("input", () => {
519 if (fontSize.value) setStyleForSelected("font-size", `${fontSize.value}px`);
520 });
521 textSection.appendChild(createField("Font size", fontSize));
522
523 const fontWeight = document.createElement("select");
524 for (const value of ["300", "400", "500", "600", "700", "800", "900"]) {
525 const option = document.createElement("option");
526 option.value = value;
527 option.textContent = value;
528 fontWeight.appendChild(option);
529 }
530 fontWeight.addEventListener("change", () => setStyleForSelected("font-weight", fontWeight.value));
531 textSection.appendChild(createField("Font weight", fontWeight));
532
533 const lineHeight = document.createElement("input");
534 lineHeight.type = "text";
535 lineHeight.placeholder = "normal / 1.4";
536 lineHeight.addEventListener("change", () => {
537 if (lineHeight.value.trim()) setStyleForSelected("line-height", lineHeight.value.trim());
538 });
539 textSection.appendChild(createField("Line height", lineHeight));
540
541 const createColorRow = (
542 labelText: string,
543 property: string,
544 section: HTMLElement,
545 ): { row: HTMLLabelElement; color: HTMLInputElement; text: HTMLInputElement } => {
546 const row = document.createElement("label");
547 row.style.cssText =
548 "display:grid;grid-template-columns:82px minmax(0,1fr);align-items:center;gap:8px;min-height:28px;font:500 11px/1.2 ui-sans-serif,system-ui;color:#52525b";
549 const label = document.createElement("span");
550 label.textContent = labelText;
551 const control = document.createElement("div");
552 control.style.cssText =
553 "display:grid;grid-template-columns:22px minmax(0,1fr);align-items:center;gap:5px;height:26px;padding:2px 5px;border-radius:7px;background:rgba(255,255,255,.78);box-shadow:inset 0 0 0 1px rgba(0,0,0,.09)";
554 const color = document.createElement("input");
555 color.type = "color";
556 color.setAttribute("aria-label", labelText);
557 color.style.cssText =
558 "width:20px;height:20px;padding:0;border:0;border-radius:5px;overflow:hidden;background:transparent;cursor:pointer";
559 const text = document.createElement("input");
560 text.type = "text";
561 text.setAttribute("aria-label", `${labelText} value`);
562 text.style.cssText =
563 "min-width:0;width:100%;border:0;background:transparent;color:#52525b;font:500 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;outline:none";
564 color.addEventListener("input", () => {
565 text.value = color.value;
566 setStyleForSelected(property, color.value);
567 });
568 text.addEventListener("change", () => {
569 const value = text.value.trim();
570 if (!value) return;
571 setStyleForSelected(property, value);
572 if (/^#[0-9a-f]{6}$/i.test(value)) color.value = value;
573 });
574 control.append(color, text);
575 row.append(label, control);
576 section.appendChild(row);
577 return { row, color, text };
578 };
579
580 const textColor = createColorRow("Text color", "color", colorsSection);
581 const backgroundColor = createColorRow("Background", "background-color", colorsSection);
582
583 const opacity = document.createElement("input");
584 opacity.type = "range";
585 opacity.min = "0";
586 opacity.max = "1";
587 opacity.step = "0.05";
588 opacity.value = "1";
589 opacity.style.accentColor = ACCENT;
590 opacity.addEventListener("input", () => setStyleForSelected("opacity", opacity.value));
591 colorsSection.appendChild(createField("Opacity", opacity));
592
593 const radius = createUnitInput("px", "0");
594 radius.min = "0";
595 radius.max = "300";
596 radius.addEventListener("input", () => {
597 if (radius.value) setStyleForSelected("border-radius", `${radius.value}px`);
598 });
599 bordersSection.appendChild(createField("Radius", radius));
600
601 const borderColor = createColorRow("Border color", "border-color", bordersSection);
602
603 const borderWidth = createUnitInput("px", "0");
604 borderWidth.min = "0";
605 borderWidth.max = "100";
606 borderWidth.addEventListener("input", () => {
607 if (borderWidth.value) {
608 setStyleForSelected("border-style", "solid");
609 setStyleForSelected("border-width", `${borderWidth.value}px`);
610 }
611 });
612 bordersSection.appendChild(createField("Border width", borderWidth));
613
614 const dimensions = document.createElement("div");
615 dimensions.style.cssText =
616 "display:grid;grid-template-columns:82px minmax(0,1fr);gap:8px;align-items:center";
617 const dimensionLabel = document.createElement("div");
618 dimensionLabel.style.cssText =
619 "display:grid;gap:9px;font:500 11px/1.2 ui-sans-serif,system-ui;color:#52525b";
620 dimensionLabel.innerHTML = "<span>Width</span><span>Height</span>";
621 const dimensionControls = document.createElement("div");
622 dimensionControls.style.cssText = "position:relative;display:grid;gap:3px;padding-left:22px";
623 const widthInput = createUnitInput("px", "auto");
624 const heightInput = createUnitInput("px", "auto");
625 styleControl(widthInput);
626 styleControl(heightInput);
627 const aspectLock = createButton("", "Lock aspect ratio");
628 aspectLock.setAttribute("aria-pressed", "true");
629 aspectLock.style.cssText +=
630 ";position:absolute;left:0;top:50%;transform:translateY(-50%);width:18px;height:38px;padding:0;border-radius:6px;background:#ede9fe;color:#6d28d9";
631 dimensionControls.append(
632 createUnitControl(widthInput),
633 createUnitControl(heightInput),
634 aspectLock,
635 );
636 dimensions.append(dimensionLabel, dimensionControls);
637 sizingSection.appendChild(dimensions);
638
639 let aspectLocked = true;
640 let aspectRatio = 1;
641 const refreshAspectButton = (): void => {
642 aspectLock.innerHTML = aspectLocked
643 ? '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="M8 6.5 9.5 5A3.5 3.5 0 0 1 14.5 10l-1.5 1.5M12 13.5 10.5 15A3.5 3.5 0 0 1 5.5 10L7 8.5M7.5 12.5l5-5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>'
644 : '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="m6 6 8 8M8 6.5 9.5 5A3.5 3.5 0 0 1 14 9M12 13.5 10.5 15A3.5 3.5 0 0 1 6 11" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>';
645 aspectLock.setAttribute("aria-pressed", String(aspectLocked));
646 aspectLock.style.background = aspectLocked ? "#ede9fe" : "#f4f4f5";
647 aspectLock.style.color = aspectLocked ? "#6d28d9" : "#71717a";
648 };
649 aspectLock.addEventListener("click", () => {
650 aspectLocked = !aspectLocked;
651 refreshAspectButton();
652 });
653 widthInput.addEventListener("input", () => {
654 const width = Number(widthInput.value);
655 if (!Number.isFinite(width) || width <= 0) return;
656 setStyleForSelected("width", `${width}px`);
657 if (aspectLocked && aspectRatio > 0) {
658 const height = Math.max(1, Math.round(width / aspectRatio));
659 heightInput.value = String(height);
660 setStyleForSelected("height", `${height}px`);
661 }
662 });
663 heightInput.addEventListener("input", () => {
664 const height = Number(heightInput.value);
665 if (!Number.isFinite(height) || height <= 0) return;
666 setStyleForSelected("height", `${height}px`);
667 if (aspectLocked && aspectRatio > 0) {
668 const width = Math.max(1, Math.round(height * aspectRatio));
669 widthInput.value = String(width);
670 setStyleForSelected("width", `${width}px`);
671 }
672 });
673 refreshAspectButton();
674
675 const addSpacingField = (
676 label: string,
677 property: string,
678 placeholder: string,
679 ): HTMLInputElement => {
680 const input = document.createElement("input");
681 input.type = "text";
682 input.placeholder = placeholder;
683 input.addEventListener("change", () => {
684 if (input.value.trim()) setStyleForSelected(property, input.value.trim());
685 });
686 sizingSection.appendChild(createField(label, input));
687 return input;
688 };
689 const padding = addSpacingField("Padding", "padding", "0 0 0 0");
690 const margin = addSpacingField("Margin", "margin", "0 0 0 0");
691 const gap = addSpacingField("Gap", "gap", "0px");
692
693 const syncStyleControls = (): void => {
694 const first = selected.values().next().value as SelectedElement | undefined;
695 if (!first) return;
696 const computed = getComputedStyle(first.element);
697 const rect = first.element.getBoundingClientRect();
698 aspectRatio = rect.height > 0 ? rect.width / rect.height : 1;
699 widthInput.value = String(Math.round(rect.width));
700 heightInput.value = String(Math.round(rect.height));
701 fontSize.value = String(Math.round(Number.parseFloat(computed.fontSize) || 16));
702 fontWeight.value = computed.fontWeight.match(/^[0-9]+$/) ? computed.fontWeight : "400";
703 lineHeight.value = computed.lineHeight;
704 fontFamily.value = Array.from(fontFamily.options).some(
705 (option) => option.value === computed.fontFamily,
706 )
707 ? computed.fontFamily
708 : "inherit";
709 textColor.text.value = computed.color;
710 backgroundColor.text.value = computed.backgroundColor;
711 borderColor.text.value = computed.borderColor;
712 opacity.value = computed.opacity;
713 radius.value = String(Math.round(Number.parseFloat(computed.borderRadius) || 0));
714 borderWidth.value = String(Math.round(Number.parseFloat(computed.borderWidth) || 0));
715 padding.value = computed.padding;
716 margin.value = computed.margin;
717 gap.value = computed.gap === "normal" ? "0px" : computed.gap;
718 };
719
720 const tools: ReadonlyArray<[AnnotationTool, string, string]> = [
721 ["select", "Select", "Select elements (V)"],
722 ["marquee", "Region", "Draw a region or marquee-select elements (R)"],
723 ["draw", "Draw", "Draw freehand (D)"],
724 ["erase", "Erase", "Remove an annotation target (E)"],
725 ];
726 for (const [candidate, label, title] of tools) {
727 const button = createButton(label, title);
728 button.style.cssText += ";padding:5px 7px;font-size:11px;border-radius:6px";
729 button.addEventListener("click", () => {
730 tool = candidate;
731 refreshToolButtons();
732 });
733 toolButtons.set(candidate, button);
734 toolbar.appendChild(button);
735 }
736
737 adjust.addEventListener("click", () => {
738 if (selected.size === 0) return;
739 stylesExpanded = !stylesExpanded;
740 stylePanel.style.display = stylesExpanded ? "grid" : "none";
741 adjust.textContent = stylesExpanded ? "Hide styles" : "Adjust";
742 if (stylesExpanded) syncStyleControls();
743 });
744
745 const clampEditorPosition = (left: number, top: number): { left: number; top: number } => {
746 const margin = 8;
747 const rect = editor.getBoundingClientRect();
748 return {
749 left: Math.min(
750 Math.max(margin, left),
751 Math.max(margin, window.innerWidth - rect.width - margin),
752 ),
753 top: Math.min(
754 Math.max(margin, top),
755 Math.max(margin, window.innerHeight - rect.height - margin),
756 ),
757 };
758 };
759
760 const applyEditorPosition = (position: { left: number; top: number }): void => {
761 editorPosition = clampEditorPosition(position.left, position.top);
762 editor.style.left = `${editorPosition.left}px`;
763 editor.style.top = `${editorPosition.top}px`;
764 editor.style.right = "auto";
765 editor.style.bottom = "auto";
766 };
767
768 const onEditorPointerDown = (event: PointerEvent): void => {
769 if (event.button !== 0) return;
770 const rect = editor.getBoundingClientRect();
771 editorDrag = {
772 pointerId: event.pointerId,
773 offsetX: event.clientX - rect.left,
774 offsetY: event.clientY - rect.top,
775 };
776 editorHeader.setPointerCapture(event.pointerId);
777 editorHeader.style.cursor = "grabbing";
367 event.preventDefault();778 event.preventDefault();
368 event.stopPropagation();779 event.stopPropagation();
369 const target = pickFromPoint(event.clientX, event.clientY);780 };
370 if (!target) {781
371 teardown(true, null);782 const onEditorPointerMove = (event: PointerEvent): void => {
783 if (!editorDrag || editorDrag.pointerId !== event.pointerId) return;
784 applyEditorPosition({
785 left: event.clientX - editorDrag.offsetX,
786 top: event.clientY - editorDrag.offsetY,
787 });
788 event.preventDefault();
789 event.stopPropagation();
790 };
791
792 const onEditorPointerUp = (event: PointerEvent): void => {
793 if (!editorDrag || editorDrag.pointerId !== event.pointerId) return;
794 editorDrag = null;
795 editorHeader.style.cursor = "grab";
796 if (editorHeader.hasPointerCapture(event.pointerId))
797 editorHeader.releasePointerCapture(event.pointerId);
798 event.preventDefault();
799 event.stopPropagation();
800 };
801 editorHeader.addEventListener("pointerdown", onEditorPointerDown);
802 editorHeader.addEventListener("pointermove", onEditorPointerMove);
803 editorHeader.addEventListener("pointerup", onEditorPointerUp);
804 editorHeader.addEventListener("pointercancel", onEditorPointerUp);
805
806 const repaint = (): void => {
807 for (const target of selected.values()) updateSelectedVisual(target);
808 if (editorPosition) applyEditorPosition(editorPosition);
809 };
810
811 const removeTargetAtPoint = (x: number, y: number): boolean => {
812 for (const target of Array.from(selected.values()).toReversed()) {
813 const rect = target.element.getBoundingClientRect();
814 if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
815 removeSelected(target);
816 return true;
817 }
818 }
819 const regionIndex = regions.findIndex(
820 (region) =>
821 x >= region.rect.x &&
822 x <= region.rect.x + region.rect.width &&
823 y >= region.rect.y &&
824 y <= region.rect.y + region.rect.height,
825 );
826 if (regionIndex >= 0) {
827 const [removed] = regions.splice(regionIndex, 1);
828 root.querySelector(`[data-region-id="${removed?.id}"]`)?.remove();
829 updateStatus();
830 return true;
831 }
832 const strokeIndex = strokes.findIndex(
833 (stroke) =>
834 x >= stroke.bounds.x &&
835 x <= stroke.bounds.x + stroke.bounds.width &&
836 y >= stroke.bounds.y &&
837 y <= stroke.bounds.y + stroke.bounds.height,
838 );
839 if (strokeIndex >= 0) {
840 const [removed] = strokes.splice(strokeIndex, 1);
841 svg.querySelector(`[data-stroke-id="${removed?.id}"]`)?.remove();
842 updateStatus();
843 return true;
844 }
845 return false;
846 };
847
848 const selectElementsInRect = (rect: PreviewAnnotationRect): number => {
849 const candidates = Array.from(document.querySelectorAll("body *"))
850 .filter((element) => !isAnnotationNode(element))
851 .map((element) => ({ element, rect: element.getBoundingClientRect() }))
852 .filter(({ rect: candidate }) => {
853 if (candidate.width < 2 || candidate.height < 2) return false;
854 return !(
855 candidate.right < rect.x ||
856 candidate.left > rect.x + rect.width ||
857 candidate.bottom < rect.y ||
858 candidate.top > rect.y + rect.height
859 );
860 })
861 .filter(({ element, rect: candidate }) => {
862 const centerX = candidate.left + candidate.width / 2;
863 const centerY = candidate.top + candidate.height / 2;
864 return (
865 centerX >= rect.x &&
866 centerX <= rect.x + rect.width &&
867 centerY >= rect.y &&
868 centerY <= rect.y + rect.height &&
869 (element.children.length === 0 ||
870 element instanceof HTMLButtonElement ||
871 element instanceof HTMLAnchorElement ||
872 element.getAttribute("role") === "button")
873 );
874 })
875 .sort(
876 (left, right) => left.rect.width * left.rect.height - right.rect.width * right.rect.height,
877 )
878 .slice(0, MAX_MARQUEE_ELEMENTS);
879 for (const candidate of candidates) addSelected(candidate.element);
880 return candidates.length;
881 };
882
883 const clearHoverOutline = (): void => {
884 hoverOutline.style.display = "none";
885 };
886
887 const onPointerMove = (event: PointerEvent): void => {
888 if (isAnnotationNode(event.target as Element)) {
889 clearHoverOutline();
890 return;
891 }
892 if (tool === "select" && dragStart === null) {
893 const target = pickFromPoint(event.clientX, event.clientY);
894 if (target) positionBox(hoverOutline, rectFromDomRect(target.getBoundingClientRect()));
895 else clearHoverOutline();
372 return;896 return;
373 }897 }
374 void captureElement(target).then((payload) => teardown(true, payload));898 clearHoverOutline();
899 if (tool === "marquee" && dragStart) {
900 positionBox(
901 marqueeBox,
902 normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY),
903 );
904 return;
905 }
906 if (tool === "draw" && activeStroke) {
907 activeStroke.target.points = [
908 ...activeStroke.target.points,
909 { x: event.clientX, y: event.clientY },
910 ];
911 activeStroke.target.bounds = strokeBounds(
912 activeStroke.target.points,
913 activeStroke.target.width,
914 );
915 activeStroke.path.setAttribute("d", pathFromPoints(activeStroke.target.points));
916 }
375 };917 };
376918
377 const onKey = (event: KeyboardEvent): void => {919 const onPointerDown = (event: PointerEvent): void => {
378 if (event.key === "Escape") {920 if (event.button !== 0 || isAnnotationNode(event.target as Element)) return;
379 event.preventDefault();921 event.preventDefault();
380 event.stopPropagation();922 event.stopPropagation();
381 teardown(true, null);923 if (tool === "select") {
924 const target = pickFromPoint(event.clientX, event.clientY);
925 if (target) toggleSelected(target, event.shiftKey);
926 return;
927 }
928 if (tool === "erase") {
929 removeTargetAtPoint(event.clientX, event.clientY);
930 return;
931 }
932 dragStart = { x: event.clientX, y: event.clientY };
933 if (tool === "draw") {
934 const stroke: PreviewAnnotationStrokeTarget = {
935 id: nextId("stroke"),
936 color: ACCENT,
937 width: 4,
938 points: [dragStart],
939 bounds: { x: dragStart.x, y: dragStart.y, width: 1, height: 1 },
940 };
941 const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
942 path.setAttribute(OVERLAY_ATTRIBUTE, "");
943 path.setAttribute("data-stroke-id", stroke.id);
944 path.setAttribute("fill", "none");
945 path.setAttribute("stroke", stroke.color);
946 path.setAttribute("stroke-width", String(stroke.width));
947 path.setAttribute("stroke-linecap", "round");
948 path.setAttribute("stroke-linejoin", "round");
949 svg.appendChild(path);
950 activeStroke = { target: stroke, path };
951 }
952 };
953
954 const onPointerUp = (event: PointerEvent): void => {
955 if (!dragStart) return;
956 event.preventDefault();
957 event.stopPropagation();
958 if (tool === "marquee") {
959 const rect = normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY);
960 marqueeBox.style.display = "none";
961 if (isUsableRect(rect)) {
962 const found = selectElementsInRect(rect);
963 if (found === 0) {
964 const region: PreviewAnnotationRegionTarget = { id: nextId("region"), rect };
965 regions.push(region);
966 const regionBox = createBox(ACCENT, "rgba(124,58,237,.06)");
967 regionBox.setAttribute("data-region-id", region.id);
968 positionBox(regionBox, rect);
969 root.appendChild(regionBox);
970 }
971 }
972 } else if (tool === "draw" && activeStroke) {
973 if (activeStroke.target.points.length > 1) strokes.push(activeStroke.target);
974 else activeStroke.path.remove();
975 activeStroke = null;
382 }976 }
977 dragStart = null;
978 updateStatus();
383 };979 };
384980
385 const onBeforeUnload = (): void => {981 const onClick = (event: MouseEvent): void => {
386 teardown(true, null);982 if (isAnnotationNode(event.target as Element)) return;
983 event.preventDefault();
984 event.stopPropagation();
387 };985 };
388986
389 // Cancellation initiated by main (CANCEL_PICK_CHANNEL). Tear down silently987 const onPointerOut = (event: PointerEvent): void => {
390 // — main already knows it cancelled and is either done waiting or about to988 if (event.relatedTarget === null) clearHoverOutline();
391 // register a fresh listener for a new pick. If we sent `null` here, that
392 // fresh listener would receive it and resolve the new pick instantly (the
393 // C1 race we previously hit).
394 const onMainCancel = (): void => {
395 teardown(false, null);
396 };989 };
397990
398 // Capture-phase listeners on `window` to outrun page handlers that991 const onWindowBlur = (): void => {
399 // `stopPropagation()` early. `passive: false` because we need preventDefault.992 clearHoverOutline();
400 window.addEventListener("mousemove", onMove, { capture: true, passive: true });
401 window.addEventListener("click", onClick, { capture: true });
402 window.addEventListener("keydown", onKey, { capture: true });
403 window.addEventListener("scroll", onScrollOrResize, { capture: true, passive: true });
404 window.addEventListener("resize", onScrollOrResize, { passive: true });
405 window.addEventListener("beforeunload", onBeforeUnload);
406 ipcRenderer.on(CANCEL_PICK_CHANNEL, onMainCancel);
407
408 // Hand a "silent teardown" to `activeSession` so that a follow-up
409 // `startPick()` can supersede us without echoing `null` back to main.
410 activeSession = {
411 overlay: handles.overlay,
412 outline: handles.outline,
413 label: handles.label,
414 teardownSilent: () => teardown(false, null),
415 };993 };
994
995 const restoreStyles = (): void => {
996 for (const target of selected.values()) {
997 if (!(target.element instanceof HTMLElement || target.element instanceof SVGElement))
998 continue;
999 for (const [property, baseline] of target.baselineStyles) {
1000 if (baseline) target.element.style.setProperty(property, baseline);
1001 else target.element.style.removeProperty(property);
1002 }
1003 }
1004 };
1005
1006 const teardown = (notifyMain: boolean): void => {
1007 if (finished) return;
1008 finished = true;
1009 restoreStyles();
1010 window.removeEventListener("pointermove", onPointerMove, true);
1011 window.removeEventListener("pointerdown", onPointerDown, true);
1012 window.removeEventListener("pointerup", onPointerUp, true);
1013 window.removeEventListener("pointerout", onPointerOut, true);
1014 window.removeEventListener("click", onClick, true);
1015 window.removeEventListener("blur", onWindowBlur);
1016 window.removeEventListener("keydown", onKeyDown, true);
1017 window.removeEventListener("scroll", repaint, true);
1018 window.removeEventListener("resize", repaint);
1019 editorHeader.removeEventListener("pointerdown", onEditorPointerDown);
1020 editorHeader.removeEventListener("pointermove", onEditorPointerMove);
1021 editorHeader.removeEventListener("pointerup", onEditorPointerUp);
1022 editorHeader.removeEventListener("pointercancel", onEditorPointerUp);
1023 ipcRenderer.off(CANCEL_PICK_CHANNEL, onCancel);
1024 ipcRenderer.off(ANNOTATION_CAPTURED_CHANNEL, onCaptured);
1025 document.documentElement.removeAttribute("data-t3code-annotation-tool");
1026 root.remove();
1027 activeSession = null;
1028 if (notifyMain) ipcRenderer.send(ELEMENT_PICKED_CHANNEL, null);
1029 };
1030
1031 const onCancel = (): void => teardown(false);
1032 const onCaptured = (): void => teardown(false);
1033 const onKeyDown = (event: KeyboardEvent): void => {
1034 if (isAnnotationNode(event.target as Element) && event.key !== "Escape") return;
1035 if (event.key === "Escape") {
1036 event.preventDefault();
1037 event.stopPropagation();
1038 teardown(true);
1039 return;
1040 }
1041 if (event.key === "v") tool = "select";
1042 else if (event.key === "r") tool = "marquee";
1043 else if (event.key === "d") tool = "draw";
1044 else if (event.key === "e") tool = "erase";
1045 else return;
1046 refreshToolButtons();
1047 };
1048
1049 submit.addEventListener("click", () => {
1050 if (pendingCapture || (selected.size === 0 && regions.length === 0 && strokes.length === 0))
1051 return;
1052 pendingCapture = true;
1053 submit.disabled = true;
1054 submit.textContent = "Capturing…";
1055 void Promise.all(
1056 Array.from(selected.values()).map(async (target) => {
1057 const element = await captureElement(target.element);
1058 if (!element) return null;
1059 for (const change of styleChanges.values()) {
1060 if (change.targetId === target.id) change.selector = element.selector;
1061 }
1062 return {
1063 id: target.id,
1064 element,
1065 rect: rectFromDomRect(target.element.getBoundingClientRect()),
1066 };
1067 }),
1068 ).then((captured) => {
1069 const elements = captured.filter((target) => target !== null);
1070 const annotation: PreviewAnnotationPayload = {
1071 id: nextId("annotation"),
1072 pageUrl: location.href,
1073 pageTitle: document.title?.trim() || null,
1074 comment: comment.value.trim(),
1075 elements,
1076 regions: [...regions],
1077 strokes: [...strokes],
1078 styleChanges: Array.from(styleChanges.values()),
1079 screenshot: null,
1080 createdAt: new Date().toISOString(),
1081 };
1082 editor.style.display = "none";
1083 toolbar.style.display = "none";
1084 hoverOutline.style.display = "none";
1085 const screenshotRect = unionRects([
1086 ...elements.map((target) => target.rect),
1087 ...regions.map((region) => region.rect),
1088 ...strokes.map((stroke) => stroke.bounds),
1089 ]);
1090 ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect);
1091 });
1092 });
1093 comment.addEventListener("keydown", (event) => {
1094 if (event.key !== "Enter" || !(event.metaKey || event.ctrlKey)) return;
1095 event.preventDefault();
1096 submit.click();
1097 });
1098
1099 window.addEventListener("pointermove", onPointerMove, { capture: true, passive: false });
1100 window.addEventListener("pointerdown", onPointerDown, { capture: true, passive: false });
1101 window.addEventListener("pointerup", onPointerUp, { capture: true, passive: false });
1102 window.addEventListener("pointerout", onPointerOut, { capture: true, passive: true });
1103 window.addEventListener("click", onClick, { capture: true, passive: false });
1104 window.addEventListener("blur", onWindowBlur);
1105 window.addEventListener("keydown", onKeyDown, { capture: true });
1106 window.addEventListener("scroll", repaint, { capture: true, passive: true });
1107 window.addEventListener("resize", repaint, { passive: true });
1108 ipcRenderer.on(CANCEL_PICK_CHANNEL, onCancel);
1109 ipcRenderer.on(ANNOTATION_CAPTURED_CHANNEL, onCaptured);
1110 document.documentElement.appendChild(root);
1111 refreshToolButtons();
1112 updateStatus();
1113 activeSession = { teardown };
416}1114}
4171115
418ipcRenderer.on(START_PICK_CHANNEL, () => {1116ipcRenderer.on(START_PICK_CHANNEL, () => startAnnotation());
419 startPick();1117ipcRenderer.on(CANCEL_PICK_CHANNEL, () => activeSession?.teardown(false));
420});
apps/desktop/src/preview-view-manager.ts+76-12
...@@ -6,16 +6,17 @@...@@ -6,16 +6,17 @@
6 * elements live in the renderer; we only attach listeners and forward state6 * elements live in the renderer; we only attach listeners and forward state
7 * here). Single layer-scoped browser session partition.7 * here). Single layer-scoped browser session partition.
8 */8 */
9import type { PickedElementPayload } from "@t3tools/contracts";9import type { PreviewAnnotationPayload, PreviewAnnotationRect } from "@t3tools/contracts";
10import { normalizePreviewUrl } from "@t3tools/shared/preview";10import { normalizePreviewUrl } from "@t3tools/shared/preview";
11import { type BrowserWindow, type Session, session, webContents } from "electron";11import { type BrowserWindow, type Session, session, webContents } from "electron";
1212
13import { isPickedElementPayload } from "./picked-element-payload.ts";13import { isPreviewAnnotationPayload } from "./picked-element-payload.ts";
1414
15const PREVIEW_PARTITION = "persist:t3code-preview";15const PREVIEW_PARTITION = "persist:t3code-preview";
16const START_PICK_CHANNEL = "preview:start-pick";16const START_PICK_CHANNEL = "preview:start-pick";
17const CANCEL_PICK_CHANNEL = "preview:cancel-pick";17const CANCEL_PICK_CHANNEL = "preview:cancel-pick";
18const ELEMENT_PICKED_CHANNEL = "preview:element-picked";18const ELEMENT_PICKED_CHANNEL = "preview:element-picked";
19const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured";
1920
20// Re-export the guest webview security posture from its dedicated module so21// Re-export the guest webview security posture from its dedicated module so
21// the constant is unit-testable in isolation. See22// the constant is unit-testable in isolation. See
...@@ -53,6 +54,58 @@ const ZOOM_LEVELS: ReadonlyArray<number> = [...@@ -53,6 +54,58 @@ const ZOOM_LEVELS: ReadonlyArray<number> = [
53const DEFAULT_ZOOM_FACTOR = 1.0;54const DEFAULT_ZOOM_FACTOR = 1.0;
54const ZOOM_EPSILON = 0.001;55const ZOOM_EPSILON = 0.001;
5556
57const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => {
58 if (typeof value !== "object" || value === null) return null;
59 const rect = value as Record<string, unknown>;
60 const x = rect["x"];
61 const y = rect["y"];
62 const width = rect["width"];
63 const height = rect["height"];
64 if (
65 typeof x !== "number" ||
66 !Number.isFinite(x) ||
67 typeof y !== "number" ||
68 !Number.isFinite(y) ||
69 typeof width !== "number" ||
70 !Number.isFinite(width) ||
71 typeof height !== "number" ||
72 !Number.isFinite(height) ||
73 width <= 0 ||
74 height <= 0
75 ) {
76 return null;
77 }
78 return {
79 x: Math.max(0, Math.floor(x)),
80 y: Math.max(0, Math.floor(y)),
81 width: Math.max(1, Math.ceil(width)),
82 height: Math.max(1, Math.ceil(height)),
83 };
84};
85
86const captureAnnotationScreenshot = async (
87 wc: Electron.WebContents,
88 cropRect: PreviewAnnotationRect | null,
89) => {
90 const image = await wc.capturePage(
91 cropRect
92 ? {
93 x: cropRect.x,
94 y: cropRect.y,
95 width: cropRect.width,
96 height: cropRect.height,
97 }
98 : undefined,
99 );
100 const size = image.getSize();
101 return {
102 dataUrl: image.toDataURL(),
103 width: size.width,
104 height: size.height,
105 cropRect: cropRect ?? { x: 0, y: 0, width: size.width, height: size.height },
106 };
107};
108
56const findZoomStep = (current: number): number => {109const findZoomStep = (current: number): number => {
57 for (let index = 0; index < ZOOM_LEVELS.length; index += 1) {110 for (let index = 0; index < ZOOM_LEVELS.length; index += 1) {
58 if (Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON) return index;111 if (Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON) return index;
...@@ -77,7 +130,7 @@ interface ManagedListeners {...@@ -77,7 +130,7 @@ interface ManagedListeners {
77}130}
78131
79interface PickSession {132interface PickSession {
80 readonly resolve: (payload: PickedElementPayload | null) => void;133 readonly resolve: (payload: PreviewAnnotationPayload | null) => void;
81 readonly cleanup: () => void;134 readonly cleanup: () => void;
82}135}
83136
...@@ -103,7 +156,7 @@ export class PreviewViewManager {...@@ -103,7 +156,7 @@ export class PreviewViewManager {
103 private readonly attached = new Map<number, ManagedListeners>();156 private readonly attached = new Map<number, ManagedListeners>();
104 private browserSession: Session | null = null;157 private browserSession: Session | null = null;
105 private readonly listeners = new Set<Listener>();158 private readonly listeners = new Set<Listener>();
106 /** In-flight element-pick sessions, keyed by tabId (one pick per tab). */159 /** In-flight preview annotation sessions, keyed by tabId. */
107 private readonly pickSessions = new Map<string, PickSession>();160 private readonly pickSessions = new Map<string, PickSession>();
108161
109 setMainWindow(window: BrowserWindow): void {162 setMainWindow(window: BrowserWindow): void {
...@@ -290,17 +343,17 @@ export class PreviewViewManager {...@@ -290,17 +343,17 @@ export class PreviewViewManager {
290 }343 }
291344
292 /**345 /**
293 * Activate the in-page element picker for `tabId`. Resolves with the346 * Activate annotation mode for `tabId`. Resolves after the guest submits a
294 * `PickedElementPayload` the preload script bubbles back via `ipc-message`,347 * multi-target annotation and the desktop process captures its screenshot,
295 * or `null` when the user cancels (Escape, navigation, manual cancel).348 * or with `null` when the user cancels.
296 *349 *
297 * Exactly one pick session may be active per tab — re-invoking while a350 * Exactly one pick session may be active per tab — re-invoking while a
298 * pick is in flight cleanly resolves the old session with `null` first.351 * pick is in flight cleanly resolves the old session with `null` first.
299 */352 */
300 async pickElement(tabId: string): Promise<PickedElementPayload | null> {353 async pickElement(tabId: string): Promise<PreviewAnnotationPayload | null> {
301 const wc = this.requireWebContents(tabId);354 const wc = this.requireWebContents(tabId);
302 this.cancelPickElement(tabId);355 this.cancelPickElement(tabId);
303 return new Promise<PickedElementPayload | null>((resolve) => {356 return new Promise<PreviewAnnotationPayload | null>((resolve) => {
304 // `wc.ipc` is the per-WebContents IpcMain that receives messages the357 // `wc.ipc` is the per-WebContents IpcMain that receives messages the
305 // webview's preload sends with `ipcRenderer.send(...)`. We use that358 // webview's preload sends with `ipcRenderer.send(...)`. We use that
306 // (not the global `wc.on("ipc-message", ...)`, which is for359 // (not the global `wc.on("ipc-message", ...)`, which is for
...@@ -313,7 +366,7 @@ export class PreviewViewManager {...@@ -313,7 +366,7 @@ export class PreviewViewManager {
313 this.pickSessions.delete(tabId);366 this.pickSessions.delete(tabId);
314 };367 };
315 const session: PickSession = { resolve, cleanup };368 const session: PickSession = { resolve, cleanup };
316 const settle = (payload: PickedElementPayload | null) => {369 const settle = (payload: PreviewAnnotationPayload | null) => {
317 if (this.pickSessions.get(tabId) !== session) return;370 if (this.pickSessions.get(tabId) !== session) return;
318 cleanup();371 cleanup();
319 resolve(payload);372 resolve(payload);
...@@ -324,11 +377,22 @@ export class PreviewViewManager {...@@ -324,11 +377,22 @@ export class PreviewViewManager {
324 settle(null);377 settle(null);
325 return;378 return;
326 }379 }
327 if (!isPickedElementPayload(payload)) {380 if (!isPreviewAnnotationPayload(payload)) {
328 settle(null);381 settle(null);
329 return;382 return;
330 }383 }
331 settle(payload);384 const cropRect = normalizeCaptureRect(args[1]);
385 void captureAnnotationScreenshot(wc, cropRect)
386 .then((screenshot) => settle({ ...payload, screenshot }))
387 .catch(() => settle(payload))
388 .finally(() => {
389 if (wc.isDestroyed()) return;
390 try {
391 wc.send(ANNOTATION_CAPTURED_CHANNEL);
392 } catch {
393 // The guest may have navigated while capture was in flight.
394 }
395 });
332 };396 };
333 const onDestroyed = () => settle(null);397 const onDestroyed = () => settle(null);
334 const onNavigated = () => settle(null);398 const onNavigated = () => settle(null);
apps/web/package.json-1
...@@ -64,7 +64,6 @@...@@ -64,7 +64,6 @@
64 "babel-plugin-react-compiler": "1.0.0",64 "babel-plugin-react-compiler": "1.0.0",
65 "msw": "2.12.11",65 "msw": "2.12.11",
66 "playwright": "^1.58.2",66 "playwright": "^1.58.2",
67 "react-grab": "^0.1.32",
68 "tailwindcss": "^4.0.0",67 "tailwindcss": "^4.0.0",
69 "vite": "catalog:",68 "vite": "catalog:",
70 "vite-plus": "catalog:",69 "vite-plus": "catalog:",
apps/web/src/components/preview/PreviewChromeRow.tsx+5-5
...@@ -37,8 +37,8 @@ interface Props {...@@ -37,8 +37,8 @@ interface Props {
37 /** When provided, renders an "Open in browser" affordance to the right. */37 /** When provided, renders an "Open in browser" affordance to the right. */
38 onOpenInBrowser?: (() => void) | undefined;38 onOpenInBrowser?: (() => void) | undefined;
39 /**39 /**
40 * When provided, renders a "Select element" toggle button to the right of40 * When provided, renders an annotation-mode toggle button to the right of
41 * the URL input. Pressed while a pick is active (button shows in `pressed`41 * the URL input. Pressed while annotation mode is active (button shows in `pressed`
42 * state). Disabled in `pickDisabled` mode.42 * state). Disabled in `pickDisabled` mode.
43 */43 */
44 onPickElement?: (() => void) | undefined;44 onPickElement?: (() => void) | undefined;
...@@ -192,7 +192,7 @@ export function PreviewChromeRow({...@@ -192,7 +192,7 @@ export function PreviewChromeRow({
192 size="icon-xs"192 size="icon-xs"
193 onClick={onPickElement}193 onClick={onPickElement}
194 disabled={pickDisabled}194 disabled={pickDisabled}
195 aria-label={pickActive ? "Cancel element pick" : "Select element from page"}195 aria-label={pickActive ? "Cancel annotation" : "Annotate preview"}
196 aria-pressed={pickActive ? "true" : "false"}196 aria-pressed={pickActive ? "true" : "false"}
197 type="button"197 type="button"
198 />198 />
...@@ -204,8 +204,8 @@ export function PreviewChromeRow({...@@ -204,8 +204,8 @@ export function PreviewChromeRow({
204 {pickDisabled && pickDisabledReason204 {pickDisabled && pickDisabledReason
205 ? pickDisabledReason205 ? pickDisabledReason
206 : pickActive206 : pickActive
207 ? "Cancel pick (Esc)"207 ? "Cancel annotation (Esc)"
208 : "Select element to attach"}208 : "Annotate elements, regions, and drawings"}
209 </TooltipPopup>209 </TooltipPopup>
210 </Tooltip>210 </Tooltip>
211 ) : null}211 ) : null}
apps/web/src/components/preview/PreviewView.tsx+28-6
...@@ -7,6 +7,10 @@ import { useCallback, useEffect, useRef, useState } from "react";...@@ -7,6 +7,10 @@ import { useCallback, useEffect, useRef, useState } from "react";
7import { useComposerDraftStore } from "~/composerDraftStore";7import { useComposerDraftStore } from "~/composerDraftStore";
8import { ensureEnvironmentApi } from "~/environmentApi";8import { ensureEnvironmentApi } from "~/environmentApi";
9import { normalizeElementContextSelection } from "~/lib/elementContext";9import { normalizeElementContextSelection } from "~/lib/elementContext";
10import {
11 appendPreviewAnnotationPrompt,
12 previewAnnotationScreenshotFile,
13} from "~/lib/previewAnnotation";
10import { ensureLocalApi } from "~/localApi";14import { ensureLocalApi } from "~/localApi";
11import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore";15import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore";
1216
...@@ -45,6 +49,9 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) {...@@ -45,6 +49,9 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) {
45 const applyServerSnapshot = usePreviewStateStore((state) => state.applyServerSnapshot);49 const applyServerSnapshot = usePreviewStateStore((state) => state.applyServerSnapshot);
46 const rememberUrl = usePreviewStateStore((state) => state.rememberUrl);50 const rememberUrl = usePreviewStateStore((state) => state.rememberUrl);
47 const addElementContext = useComposerDraftStore((store) => store.addElementContext);51 const addElementContext = useComposerDraftStore((store) => store.addElementContext);
52 const addImage = useComposerDraftStore((store) => store.addImage);
53 const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft);
54 const setPrompt = useComposerDraftStore((store) => store.setPrompt);
4855
49 usePreviewSession(threadRef);56 usePreviewSession(threadRef);
5057
...@@ -137,11 +144,26 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) {...@@ -137,11 +144,26 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) {
137 setPickActive(true);144 setPickActive(true);
138 void (async () => {145 void (async () => {
139 try {146 try {
140 const payload = await previewBridge.pickElement(tabId);147 const annotation = await previewBridge.pickElement(tabId);
141 if (!payload) return;148 if (!annotation) return;
142 const selection = normalizeElementContextSelection(payload);149 for (const target of annotation.elements) {
143 if (!selection) return;150 const selection = normalizeElementContextSelection(target.element);
144 addElementContext(threadRef, selection);151 if (selection) addElementContext(threadRef, selection);
152 }
153 const currentPrompt = getComposerDraft(threadRef)?.prompt ?? "";
154 setPrompt(threadRef, appendPreviewAnnotationPrompt(currentPrompt, annotation));
155 const screenshotFile = await previewAnnotationScreenshotFile(annotation);
156 if (screenshotFile && annotation.screenshot) {
157 addImage(threadRef, {
158 type: "image",
159 id: annotation.id,
160 name: screenshotFile.name,
161 mimeType: screenshotFile.type,
162 sizeBytes: screenshotFile.size,
163 previewUrl: annotation.screenshot.dataUrl,
164 file: screenshotFile,
165 });
166 }
145 } catch {167 } catch {
146 // Picker failed (e.g. webview navigated). Treat as silent cancel.168 // Picker failed (e.g. webview navigated). Treat as silent cancel.
147 } finally {169 } finally {
...@@ -165,7 +187,7 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) {...@@ -165,7 +187,7 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) {
165 }187 }
166 }188 }
167 })();189 })();
168 }, [addElementContext, tabId, threadRef]);190 }, [addElementContext, addImage, getComposerDraft, setPrompt, tabId, threadRef]);
169191
170 // If the active tab changes mid-pick (close, thread switch, hot restart),192 // If the active tab changes mid-pick (close, thread switch, hot restart),
171 // tell main to tear down the in-flight session AND reset our local toggle193 // tell main to tear down the in-flight session AND reset our local toggle
apps/web/src/lib/previewAnnotation.test.ts created+60
...@@ -0,0 +1,60 @@
1import type { PreviewAnnotationPayload } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { appendPreviewAnnotationPrompt, buildPreviewAnnotationPrompt } from "./previewAnnotation";
5
6const annotation: PreviewAnnotationPayload = {
7 id: "annotation_1",
8 pageUrl: "http://localhost:3000",
9 pageTitle: "Example",
10 comment: "Make these cards feel related.",
11 elements: [],
12 regions: [{ id: "region_1", rect: { x: 10, y: 20, width: 100, height: 80 } }],
13 strokes: [
14 {
15 id: "stroke_1",
16 color: "#7c3aed",
17 width: 4,
18 points: [
19 { x: 10, y: 10 },
20 { x: 20, y: 20 },
21 ],
22 bounds: { x: 6, y: 6, width: 18, height: 18 },
23 },
24 ],
25 styleChanges: [
26 {
27 targetId: "element_1",
28 selector: ".card",
29 property: "border-radius",
30 previousValue: "4px",
31 value: "16px",
32 },
33 ],
34 screenshot: {
35 dataUrl: "data:image/png;base64,AA==",
36 width: 100,
37 height: 80,
38 cropRect: { x: 10, y: 20, width: 100, height: 80 },
39 },
40 createdAt: "2026-06-11T00:00:00.000Z",
41};
42
43describe("preview annotations", () => {
44 it("describes regions, drawings, styles, and screenshot context", () => {
45 const result = buildPreviewAnnotationPrompt(annotation);
46 expect(result).toContain("Make these cards feel related.");
47 expect(result).toContain("1 marked region");
48 expect(result).toContain("1 drawing");
49 expect(result).toContain("border-radius: 4px → 16px");
50 expect(result).toContain("attached screenshot");
51 });
52
53 it("appends to an existing composer prompt", () => {
54 expect(
55 appendPreviewAnnotationPrompt("Fix this", annotation).startsWith(
56 "Fix this\n\nPreview annotation:",
57 ),
58 ).toBe(true);
59 });
60});
apps/web/src/lib/previewAnnotation.ts created+53
...@@ -0,0 +1,53 @@
1import type { PreviewAnnotationPayload } from "@t3tools/contracts";
2
3export function buildPreviewAnnotationPrompt(annotation: PreviewAnnotationPayload): string {
4 const lines = ["Preview annotation:"];
5 if (annotation.comment.trim()) lines.push(annotation.comment.trim());
6 const targets: string[] = [];
7 if (annotation.elements.length > 0) {
8 targets.push(
9 `${annotation.elements.length} selected element${annotation.elements.length === 1 ? "" : "s"}`,
10 );
11 }
12 if (annotation.regions.length > 0) {
13 targets.push(
14 `${annotation.regions.length} marked region${annotation.regions.length === 1 ? "" : "s"}`,
15 );
16 }
17 if (annotation.strokes.length > 0) {
18 targets.push(
19 `${annotation.strokes.length} drawing${annotation.strokes.length === 1 ? "" : "s"}`,
20 );
21 }
22 if (targets.length > 0) lines.push(`Targets: ${targets.join(", ")}.`);
23 if (annotation.styleChanges.length > 0) {
24 lines.push("Requested visual changes:");
25 for (const change of annotation.styleChanges) {
26 lines.push(`- ${change.property}: ${change.previousValue || "(unset)"} → ${change.value}`);
27 }
28 }
29 if (annotation.screenshot) {
30 lines.push("The attached screenshot is the annotated preview crop.");
31 }
32 return lines.join("\n");
33}
34
35export function appendPreviewAnnotationPrompt(
36 prompt: string,
37 annotation: PreviewAnnotationPayload,
38): string {
39 const annotationText = buildPreviewAnnotationPrompt(annotation);
40 const trimmed = prompt.trim();
41 return trimmed ? `${trimmed}\n\n${annotationText}` : annotationText;
42}
43
44export async function previewAnnotationScreenshotFile(
45 annotation: PreviewAnnotationPayload,
46): Promise<File | null> {
47 if (!annotation.screenshot) return null;
48 const response = await fetch(annotation.screenshot.dataUrl);
49 const blob = await response.blob();
50 return new File([blob], `preview-annotation-${annotation.id}.png`, {
51 type: blob.type || "image/png",
52 });
53}
apps/web/src/main.tsx-4
...@@ -18,10 +18,6 @@ import { getRouter } from "./router";...@@ -18,10 +18,6 @@ import { getRouter } from "./router";
18import { APP_DISPLAY_NAME } from "./branding";18import { APP_DISPLAY_NAME } from "./branding";
19import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay";19import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay";
2020
21if (import.meta.env.DEV) {
22 void import("react-grab");
23}
24
25// Electron loads the app from a file-backed shell, so hash history avoids path resolution issues.21// Electron loads the app from a file-backed shell, so hash history avoids path resolution issues.
26const history = isElectron ? createHashHistory() : createBrowserHistory();22const history = isElectron ? createHashHistory() : createBrowserHistory();
2723
apps/web/src/reactGrabBoundary.test.ts created+15
...@@ -0,0 +1,15 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import { readFileSync } from "node:fs";
3import { describe, expect, it } from "vite-plus/test";
4
5import packageJson from "../package.json" with { type: "json" };
6
7describe("React Grab runtime boundary", () => {
8 it("keeps the host renderer free of the React Grab overlay", () => {
9 const mainSource = readFileSync(new URL("./main.tsx", import.meta.url), "utf8");
10
11 expect(mainSource).not.toMatch(/import\(["']react-grab["']\)/);
12 expect(packageJson.dependencies).not.toHaveProperty("react-grab");
13 expect(packageJson.devDependencies).not.toHaveProperty("react-grab");
14 });
15});
packages/contracts/src/ipc.ts+66-2
...@@ -505,6 +505,70 @@ export interface PickedElementPayload {...@@ -505,6 +505,70 @@ export interface PickedElementPayload {
505 pickedAt: string;505 pickedAt: string;
506}506}
507507
508export interface PreviewAnnotationRect {
509 x: number;
510 y: number;
511 width: number;
512 height: number;
513}
514
515export interface PreviewAnnotationPoint {
516 x: number;
517 y: number;
518}
519
520export interface PreviewAnnotationElementTarget {
521 id: string;
522 element: PickedElementPayload;
523 rect: PreviewAnnotationRect;
524}
525
526export interface PreviewAnnotationRegionTarget {
527 id: string;
528 rect: PreviewAnnotationRect;
529}
530
531export interface PreviewAnnotationStrokeTarget {
532 id: string;
533 color: string;
534 width: number;
535 points: ReadonlyArray<PreviewAnnotationPoint>;
536 bounds: PreviewAnnotationRect;
537}
538
539export interface PreviewAnnotationStyleChange {
540 targetId: string;
541 selector: string | null;
542 property: string;
543 previousValue: string;
544 value: string;
545}
546
547export interface PreviewAnnotationScreenshot {
548 dataUrl: string;
549 width: number;
550 height: number;
551 cropRect: PreviewAnnotationRect;
552}
553
554/**
555 * A submitted preview annotation. One annotation may reference multiple DOM
556 * elements, freeform regions, and ink strokes. The desktop main process adds
557 * the screenshot after the guest preload submits the structured draft.
558 */
559export interface PreviewAnnotationPayload {
560 id: string;
561 pageUrl: string;
562 pageTitle: string | null;
563 comment: string;
564 elements: ReadonlyArray<PreviewAnnotationElementTarget>;
565 regions: ReadonlyArray<PreviewAnnotationRegionTarget>;
566 strokes: ReadonlyArray<PreviewAnnotationStrokeTarget>;
567 styleChanges: ReadonlyArray<PreviewAnnotationStyleChange>;
568 screenshot: PreviewAnnotationScreenshot | null;
569 createdAt: string;
570}
571
508export interface DesktopBridge {572export interface DesktopBridge {
509 getAppBranding: () => DesktopAppBranding | null;573 getAppBranding: () => DesktopAppBranding | null;
510 getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null;574 getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null;
...@@ -601,8 +665,8 @@ export interface DesktopPreviewBridge {...@@ -601,8 +665,8 @@ export interface DesktopPreviewBridge {
601 * the picked payload, or `null` when the user cancels (Escape / nav). The665 * the picked payload, or `null` when the user cancels (Escape / nav). The
602 * promise rejects if the picker can't be activated (no webview, etc.).666 * promise rejects if the picker can't be activated (no webview, etc.).
603 */667 */
604 pickElement: (tabId: string) => Promise<PickedElementPayload | null>;668 pickElement: (tabId: string) => Promise<PreviewAnnotationPayload | null>;
605 /** Cancel an in-flight `pickElement(tabId)` (renderer-side teardown). */669 /** Cancel an in-flight preview annotation session. */
606 cancelPickElement: (tabId: string) => Promise<void>;670 cancelPickElement: (tabId: string) => Promise<void>;
607 onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void;671 onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void;
608}672}
pnpm-lock.yaml-3
...@@ -583,9 +583,6 @@ importers:...@@ -583,9 +583,6 @@ importers:
583 playwright:583 playwright:
584 specifier: ^1.58.2584 specifier: ^1.58.2
585 version: 1.60.0585 version: 1.60.0
586 react-grab:
587 specifier: ^0.1.32
588 version: 0.1.44(react@19.2.6)
589 tailwindcss:586 tailwindcss:
590 specifier: ^4.0.0587 specifier: ^4.0.0
591 version: 4.3.0588 version: 4.3.0