1// @effect-diagnostics globalDate:off globalTimers:off - This isolated Electron preload does not run inside an Effect runtime.
2import { ipcRenderer } from "electron";
3import { getElementContext } from "react-grab/primitives";
4import type {
5 DesktopPreviewAnnotationTheme,
6 PickedElementPayload,
7 PickedElementStackFrame,
8 PreviewAnnotationPayload,
9 PreviewAnnotationPoint,
10 PreviewAnnotationRect,
11 PreviewAnnotationRegionTarget,
12 PreviewAnnotationStrokeTarget,
13 PreviewAnnotationStyleChange,
14 PreviewAnnotationSubmission,
15} from "@t3tools/contracts";
16
17import { resolveAnnotationSubmission } from "./AnnotationKeyboard.ts";
18import { previewAnnotationStyles } from "./AnnotationStyles.generated.ts";
19import { installRecordingCursor } from "./RecordingCursor.ts";
20import { DEFAULT_RECORDING_INPUT_OPTIONS } from "./RecordingInput.ts";
21import {
22 ANNOTATION_CAPTURED_CHANNEL,
23 ANNOTATION_SEND_ENABLED_CHANNEL,
24 ANNOTATION_THEME_CHANNEL,
25 CANCEL_PICK_CHANNEL,
26 ELEMENT_PICKED_CHANNEL,
27 HUMAN_INPUT_CHANNEL,
28 MOUSE_NAVIGATE_CHANNEL,
29 RECORDING_CURSOR_CHANNEL,
30 RECORDING_POINTER_CHANNEL,
31 RECORDING_KEY_CHANNEL,
32 RECORDING_INPUT_CHANNEL,
33 RECORDING_CONTROLLER_CHANNEL,
34 START_PICK_CHANNEL,
35} from "./GuestProtocol.ts";
36const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui";
37const Z_INDEX_OVERLAY = 2147483646;
38const PRIMARY = "var(--t3-primary)";
39const PRIMARY_FILL = "color-mix(in srgb, var(--t3-primary) 10%, transparent)";
40const MAX_MARQUEE_ELEMENTS = 20;
41/** Upper bound on one element's React context lookup during submit. */
42const ELEMENT_CONTEXT_TIMEOUT_MS = 5_000;
43const CONTENT_LAYER_Z_INDEX = 1;
44const CHROME_LAYER_Z_INDEX = 10;
45
46let recordingCursor: ReturnType<typeof installRecordingCursor> | null = null;
47ipcRenderer.on(
48 RECORDING_CURSOR_CHANNEL,
49 (_event, active: unknown, inputOptions: unknown, controller: unknown) => {
50 if (active === true) {
51 const options =
52 typeof inputOptions === "object" && inputOptions !== null
53 ? {
54 showKeyPresses:
55 "showKeyPresses" in inputOptions && inputOptions.showKeyPresses === true,
56 showMousePresses:
57 "showMousePresses" in inputOptions && inputOptions.showMousePresses === true,
58 }
59 : DEFAULT_RECORDING_INPUT_OPTIONS;
60 recordingCursor ??= installRecordingCursor(document, window, options, (input) =>
61 ipcRenderer.send(RECORDING_INPUT_CHANNEL, input),
62 );
63 recordingCursor.setTheme(annotationTheme);
64 if (controller === "agent" || controller === "human" || controller === "none")
65 recordingCursor.setController(controller);
66 } else {
67 recordingCursor?.dispose();
68 recordingCursor = null;
69 }
70 },
71);
72ipcRenderer.on(RECORDING_CONTROLLER_CHANNEL, (_event, controller: unknown, point: unknown) => {
73 const humanPoint =
74 typeof point === "object" &&
75 point !== null &&
76 "x" in point &&
77 typeof point.x === "number" &&
78 Number.isFinite(point.x) &&
79 "y" in point &&
80 typeof point.y === "number" &&
81 Number.isFinite(point.y)
82 ? { x: point.x, y: point.y }
83 : undefined;
84 if (controller === "agent" || controller === "human" || controller === "none")
85 recordingCursor?.setController(controller, humanPoint);
86});
87ipcRenderer.on(RECORDING_KEY_CHANNEL, (_event, input: unknown) => {
88 if (
89 typeof input !== "object" ||
90 input === null ||
91 !("key" in input) ||
92 typeof input.key !== "string"
93 )
94 return;
95 recordingCursor?.keyPress({
96 key: input.key,
97 metaKey: "metaKey" in input && input.metaKey === true,
98 ctrlKey: "ctrlKey" in input && input.ctrlKey === true,
99 altKey: "altKey" in input && input.altKey === true,
100 shiftKey: "shiftKey" in input && input.shiftKey === true,
101 });
102});
103ipcRenderer.on(RECORDING_POINTER_CHANNEL, (_event, point: unknown) => {
104 if (
105 typeof point === "object" &&
106 point !== null &&
107 "x" in point &&
108 typeof point.x === "number" &&
109 Number.isFinite(point.x) &&
110 "y" in point &&
111 typeof point.y === "number" &&
112 Number.isFinite(point.y)
113 )
114 recordingCursor?.move(
115 { x: point.x, y: point.y },
116 "phase" in point && point.phase === "click" ? "click" : "move",
117 );
118});
119
120type AnnotationTool = "select" | "marquee" | "draw" | "erase";
121
122interface SelectedElement {
123 id: string;
124 element: Element;
125 outline: HTMLDivElement;
126 label: HTMLDivElement;
127 baselineStyles: Map<string, string>;
128}
129
130interface AnnotationSession {
131 teardown: (notifyMain: boolean) => void;
132 applyTheme: (theme: DesktopPreviewAnnotationTheme) => void;
133 setSendEnabled: (enabled: boolean) => void;
134}
135
136let activeSession: AnnotationSession | null = null;
137let idSequence = 0;
138let annotationTheme: DesktopPreviewAnnotationTheme | null = null;
139
140const applyAnnotationTheme = (
141 host: HTMLElement,
142 theme: DesktopPreviewAnnotationTheme | null,
143): void => {
144 if (!theme) return;
145 host.style.colorScheme = theme.colorScheme;
146 const variables = {
147 "--t3-radius": theme.radius,
148 "--t3-background": theme.background,
149 "--t3-foreground": theme.foreground,
150 "--t3-popover": theme.popover,
151 "--t3-popover-foreground": theme.popoverForeground,
152 "--t3-primary": theme.primary,
153 "--t3-primary-foreground": theme.primaryForeground,
154 "--t3-muted": theme.muted,
155 "--t3-muted-foreground": theme.mutedForeground,
156 "--t3-accent": theme.accent,
157 "--t3-accent-foreground": theme.accentForeground,
158 "--t3-border": theme.border,
159 "--t3-input": theme.input,
160 "--t3-ring": theme.ring,
161 "--t3-font-sans": theme.fontSans,
162 "--t3-font-mono": theme.fontMono,
163 };
164 for (const [name, value] of Object.entries(variables)) {
165 host.style.setProperty(name, value);
166 }
167};
168
169const reportHumanPointerInput = (event: PointerEvent): void => {
170 if (!event.isTrusted) return;
171 ipcRenderer.send(HUMAN_INPUT_CHANNEL, {
172 kind: "pointer",
173 x: event.clientX,
174 y: event.clientY,
175 button: event.button,
176 });
177};
178
179const reportHumanKeyInput = (event: KeyboardEvent): void => {
180 if (!event.isTrusted) return;
181 ipcRenderer.send(HUMAN_INPUT_CHANNEL, {
182 kind: "key",
183 key: event.key,
184 code: event.code,
185 });
186};
187
188window.addEventListener("pointerdown", reportHumanPointerInput, true);
189window.addEventListener("keydown", reportHumanKeyInput, true);
190
191// Mouse thumb buttons: `button === 3` is Back, `button === 4` is Forward.
192const MOUSE_BUTTON_BACK = 3;
193const MOUSE_BUTTON_FORWARD = 4;
194
195const navigationDirectionForButton = (button: number): "back" | "forward" | null => {
196 if (button === MOUSE_BUTTON_BACK) return "back";
197 if (button === MOUSE_BUTTON_FORWARD) return "forward";
198 return null;
199};
200
201// Chromium routes thumb-button history navigation to the *focused* WebContents,
202// so hovering this guest without focusing it sends the host app's router back
203// instead of the preview. Suppress Chromium's default here and drive this tab's
204// history explicitly so the buttons always navigate the browser the pointer is
205// over — never the host app.
206const suppressNavigationButton = (event: MouseEvent): void => {
207 if (!event.isTrusted || navigationDirectionForButton(event.button) === null) return;
208 event.preventDefault();
209 event.stopImmediatePropagation();
210};
211
212const requestNavigationForButton = (event: MouseEvent): void => {
213 if (!event.isTrusted) return;
214 const direction = navigationDirectionForButton(event.button);
215 if (direction === null) return;
216 event.preventDefault();
217 event.stopImmediatePropagation();
218 ipcRenderer.send(MOUSE_NAVIGATE_CHANNEL, { direction });
219};
220
221window.addEventListener("mousedown", suppressNavigationButton, true);
222window.addEventListener("mouseup", requestNavigationForButton, true);
223window.addEventListener("auxclick", suppressNavigationButton, true);
224
225const nextId = (prefix: string): string => {
226 idSequence += 1;
227 return `${prefix}_${idSequence.toString(36)}`;
228};
229
230const rectFromDomRect = (rect: DOMRect): PreviewAnnotationRect => ({
231 x: rect.left,
232 y: rect.top,
233 width: rect.width,
234 height: rect.height,
235});
236
237const normalizeRect = (
238 startX: number,
239 startY: number,
240 endX: number,
241 endY: number,
242): PreviewAnnotationRect => ({
243 x: Math.min(startX, endX),
244 y: Math.min(startY, endY),
245 width: Math.abs(endX - startX),
246 height: Math.abs(endY - startY),
247});
248
249const isUsableRect = (rect: PreviewAnnotationRect): boolean => rect.width >= 3 && rect.height >= 3;
250
251function unionRects(
252 rects: ReadonlyArray<PreviewAnnotationRect>,
253 padding = 20,
254): PreviewAnnotationRect | null {
255 if (rects.length === 0) return null;
256 const left = Math.min(...rects.map((rect) => rect.x));
257 const top = Math.min(...rects.map((rect) => rect.y));
258 const right = Math.max(...rects.map((rect) => rect.x + rect.width));
259 const bottom = Math.max(...rects.map((rect) => rect.y + rect.height));
260 const x = Math.max(0, left - padding);
261 const y = Math.max(0, top - padding);
262 const maxWidth = Math.max(1, window.innerWidth - x);
263 const maxHeight = Math.max(1, window.innerHeight - y);
264 return {
265 x,
266 y,
267 width: Math.min(maxWidth, right - left + padding * 2),
268 height: Math.min(maxHeight, bottom - top + padding * 2),
269 };
270}
271
272function isAnnotationNode(element: Element): boolean {
273 return element instanceof Element && element.closest(`[${OVERLAY_ATTRIBUTE}]`) !== null;
274}
275
276function pickFromPoint(clientX: number, clientY: number): Element | null {
277 for (const candidate of document.elementsFromPoint(clientX, clientY)) {
278 if (!(candidate instanceof Element)) continue;
279 if (isAnnotationNode(candidate)) continue;
280 if (candidate === document.documentElement || candidate === document.body) continue;
281 return candidate;
282 }
283 return null;
284}
285
286function describeRawElement(element: Element): string {
287 const tag = element.tagName.toLowerCase();
288 const id = element.id ? `#${element.id}` : "";
289 const classes =
290 element instanceof HTMLElement && typeof element.className === "string"
291 ? element.className
292 .trim()
293 .split(/\s+/)
294 .filter(Boolean)
295 .slice(0, 2)
296 .map((name) => `.${name}`)
297 .join("")
298 : "";
299 return `${tag}${id}${classes}`;
300}
301
302function createBox(color: string, fill: string): HTMLDivElement {
303 const node = document.createElement("div");
304 node.setAttribute(OVERLAY_ATTRIBUTE, "");
305 node.style.cssText = [
306 "position:fixed",
307 "pointer-events:none",
308 `border:2px solid ${color}`,
309 `background:${fill}`,
310 "border-radius:3px",
311 "box-sizing:border-box",
312 "display:none",
313 `z-index:${CONTENT_LAYER_Z_INDEX}`,
314 ].join(";");
315 return node;
316}
317
318function positionBox(node: HTMLElement, rect: PreviewAnnotationRect): void {
319 if (rect.width <= 0 || rect.height <= 0) {
320 node.style.display = "none";
321 return;
322 }
323 node.style.display = "block";
324 node.style.transform = `translate(${rect.x}px, ${rect.y}px)`;
325 node.style.width = `${rect.width}px`;
326 node.style.height = `${rect.height}px`;
327}
328
329/** Clamps a box to the viewport and clipping ancestors, so its border is never painted off-screen. */
330function visibleElementRect(element: Element): PreviewAnnotationRect {
331 const rect = element.getBoundingClientRect();
332 let left = Math.max(0, rect.left);
333 let top = Math.max(0, rect.top);
334 let right = Math.min(document.documentElement.clientWidth, rect.right);
335 let bottom = Math.min(document.documentElement.clientHeight, rect.bottom);
336 if (getComputedStyle(element).position !== "fixed") {
337 for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) {
338 if (ancestor === document.body || ancestor === document.documentElement) break;
339 const style = getComputedStyle(ancestor);
340 const bounds = ancestor.getBoundingClientRect();
341 const clipLeft = bounds.left + ancestor.clientLeft;
342 const clipTop = bounds.top + ancestor.clientTop;
343 if (style.overflowX !== "visible") {
344 left = Math.max(left, clipLeft);
345 right = Math.min(right, clipLeft + ancestor.clientWidth);
346 }
347 if (style.overflowY !== "visible") {
348 top = Math.max(top, clipTop);
349 bottom = Math.min(bottom, clipTop + ancestor.clientHeight);
350 }
351 }
352 }
353 return { x: left, y: top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) };
354}
355
356function createLabel(): HTMLDivElement {
357 const label = document.createElement("div");
358 label.setAttribute(OVERLAY_ATTRIBUTE, "");
359 label.className =
360 "fixed z-1 max-w-70 overflow-hidden rounded-md bg-primary px-2 py-1 font-sans text-xs font-semibold text-primary-foreground shadow-md";
361 label.style.cssText = [
362 "position:fixed",
363 "pointer-events:none",
364 "white-space:nowrap",
365 "text-overflow:ellipsis",
366 `z-index:${CONTENT_LAYER_Z_INDEX}`,
367 ].join(";");
368 return label;
369}
370
371function updateSelectedVisual(target: SelectedElement): void {
372 if (!target.element.isConnected) {
373 target.outline.style.display = "none";
374 target.label.style.display = "none";
375 return;
376 }
377 const rect = visibleElementRect(target.element);
378 positionBox(target.outline, rect);
379 if (rect.width === 0 || rect.height === 0) {
380 target.label.style.display = "none";
381 return;
382 }
383 target.label.textContent = describeRawElement(target.element);
384 target.label.style.display = "block";
385 target.label.style.transform = `translate(${Math.max(4, rect.x)}px, ${Math.max(4, rect.y - 22)}px)`;
386}
387
388function toStackFrame(frame: {
389 functionName?: string;
390 fileName?: string;
391 lineNumber?: number;
392 columnNumber?: number;
393}): PickedElementStackFrame {
394 return {
395 functionName: frame.functionName ?? null,
396 fileName: frame.fileName ?? null,
397 lineNumber: frame.lineNumber ?? null,
398 columnNumber: frame.columnNumber ?? null,
399 };
400}
401
402/**
403 * Resolves to `null` instead of hanging when `promise` outlives `millis`.
404 * `getElementContext` walks the inspected page's React internals, and some
405 * pages leave it pending forever. Without a bound, the whole submit chain
406 * stalls and the overlay sits on "Capturing…".
407 */
408function withCaptureTimeout<A>(promise: Promise<A>, millis: number): Promise<A | null> {
409 let timer: ReturnType<typeof setTimeout> | undefined;
410 return Promise.race([
411 promise,
412 new Promise<null>((resolve) => {
413 timer = setTimeout(() => resolve(null), millis);
414 }),
415 ]).finally(() => clearTimeout(timer));
416}
417
418/** Truncation for the DOM-only preview used when React context is unavailable. */
419const HTML_PREVIEW_MAX_CHARS = 500;
420
421/**
422 * Describes a picked element. The React context lookup can stall or throw on
423 * some pages, so the element is never dropped: without context it still
424 * carries its tag, a short HTML preview, and its rect so the crop stays on the
425 * pick instead of falling back to the whole viewport.
426 */
427async function captureElement(element: Element): Promise<PickedElementPayload> {
428 const base = {
429 pageUrl: location.href,
430 pageTitle: document.title?.trim() || null,
431 tagName: element.tagName.toLowerCase(),
432 pickedAt: new Date().toISOString(),
433 };
434 try {
435 const context = await withCaptureTimeout(
436 Promise.resolve(getElementContext(element)),
437 ELEMENT_CONTEXT_TIMEOUT_MS,
438 );
439 if (context) {
440 const stack = (context.stack ?? []).map(toStackFrame);
441 return {
442 ...base,
443 selector: context.selector,
444 htmlPreview: context.htmlPreview ?? "",
445 componentName: context.componentName,
446 source: stack[0] ?? null,
447 stack,
448 styles: context.styles ?? "",
449 };
450 }
451 } catch {
452 // Fall through to the DOM-only payload.
453 }
454 return {
455 ...base,
456 selector: null,
457 htmlPreview: element.outerHTML.slice(0, HTML_PREVIEW_MAX_CHARS),
458 componentName: null,
459 source: null,
460 stack: [],
461 styles: "",
462 };
463}
464
465function createButton(label: string, title: string): HTMLButtonElement {
466 const button = document.createElement("button");
467 button.type = "button";
468 button.textContent = label;
469 button.title = title;
470 button.className =
471 "inline-flex h-7 cursor-pointer items-center justify-center rounded-md border border-transparent px-2 font-sans text-xs font-medium text-foreground outline-none hover:bg-accent disabled:pointer-events-none disabled:opacity-60";
472 return button;
473}
474
475function styleControl(input: HTMLInputElement | HTMLSelectElement): void {
476 input.setAttribute("aria-label", input.getAttribute("aria-label") ?? "Style value");
477 input.className =
478 "h-7 min-w-0 w-full appearance-none rounded-md border border-input bg-background px-2 font-mono text-xs text-foreground shadow-xs outline-none";
479}
480
481function createUnitControl(input: HTMLInputElement): HTMLElement {
482 const wrapper = document.createElement("div");
483 wrapper.style.cssText = "position:relative;min-width:0";
484 const unit = document.createElement("span");
485 unit.textContent = input.dataset.unit ?? "";
486 unit.className =
487 "pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 font-mono text-xs text-muted-foreground";
488 wrapper.append(input, unit);
489 return wrapper;
490}
491
492function createField(
493 labelText: string,
494 input: HTMLInputElement | HTMLSelectElement,
495): HTMLLabelElement {
496 const label = document.createElement("label");
497 label.className =
498 "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground";
499 const text = document.createElement("span");
500 text.textContent = labelText;
501 styleControl(input);
502 label.append(
503 text,
504 input instanceof HTMLInputElement && input.dataset.unit ? createUnitControl(input) : input,
505 );
506 return label;
507}
508
509function createStyleSection(): HTMLElement {
510 const section = document.createElement("section");
511 section.className = "grid gap-1 border-t border-border py-2";
512 return section;
513}
514
515function createUnitInput(unit: string, placeholder = "0"): HTMLInputElement {
516 const input = document.createElement("input");
517 input.type = "number";
518 input.placeholder = placeholder;
519 input.style.paddingRight = "30px";
520 input.dataset.unit = unit;
521 return input;
522}
523
524function pathFromPoints(points: ReadonlyArray<PreviewAnnotationPoint>): string {
525 if (points.length === 0) return "";
526 if (points.length === 1) return `M ${points[0]!.x} ${points[0]!.y} l 0.01 0.01`;
527 let path = `M ${points[0]!.x} ${points[0]!.y}`;
528 for (let index = 1; index < points.length - 1; index += 1) {
529 const current = points[index]!;
530 const next = points[index + 1]!;
531 path += ` Q ${current.x} ${current.y} ${(current.x + next.x) / 2} ${(current.y + next.y) / 2}`;
532 }
533 const last = points[points.length - 1]!;
534 path += ` L ${last.x} ${last.y}`;
535 return path;
536}
537
538function strokeBounds(
539 points: ReadonlyArray<PreviewAnnotationPoint>,
540 width: number,
541): PreviewAnnotationRect {
542 const xs = points.map((point) => point.x);
543 const ys = points.map((point) => point.y);
544 const padding = width + 3;
545 const left = Math.min(...xs) - padding;
546 const top = Math.min(...ys) - padding;
547 const right = Math.max(...xs) + padding;
548 const bottom = Math.max(...ys) + padding;
549 return { x: left, y: top, width: right - left, height: bottom - top };
550}
551
552function startAnnotation(sendEnabled: boolean): void {
553 activeSession?.teardown(false);
554 let finished = false;
555 const host = document.createElement("div");
556 host.setAttribute(OVERLAY_ATTRIBUTE, "");
557 host.setAttribute("dir", "ltr");
558 host.style.cssText = `position:fixed;inset:0;z-index:${Z_INDEX_OVERLAY};pointer-events:none`;
559 applyAnnotationTheme(host, annotationTheme);
560 const shadowRoot = host.attachShadow({ mode: "closed" });
561 const themeStyle = document.createElement("style");
562 themeStyle.textContent = previewAnnotationStyles;
563 shadowRoot.appendChild(themeStyle);
564
565 const root = document.createElement("div");
566 root.setAttribute(OVERLAY_ATTRIBUTE, "");
567 root.setAttribute("dir", "ltr");
568 root.className = "fixed inset-0 font-sans text-foreground";
569 root.style.cssText = "pointer-events:none";
570 const cursorStyle = document.createElement("style");
571 cursorStyle.setAttribute(OVERLAY_ATTRIBUTE, "");
572 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; }`;
573 document.documentElement.appendChild(cursorStyle);
574 shadowRoot.appendChild(root);
575
576 const hoverOutline = createBox(PRIMARY, PRIMARY_FILL);
577 const marqueeBox = createBox(PRIMARY, PRIMARY_FILL);
578 root.append(hoverOutline, marqueeBox);
579
580 const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
581 svg.setAttribute(OVERLAY_ATTRIBUTE, "");
582 svg.setAttribute("width", "100%");
583 svg.setAttribute("height", "100%");
584 svg.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`);
585 svg.style.cssText = "position:fixed;inset:0;overflow:visible;pointer-events:none";
586 svg.style.zIndex = String(CONTENT_LAYER_Z_INDEX);
587 root.appendChild(svg);
588
589 const toolbar = document.createElement("div");
590 toolbar.setAttribute(OVERLAY_ATTRIBUTE, "");
591 toolbar.className =
592 "pointer-events-auto fixed top-2.5 left-1/2 flex -translate-x-1/2 gap-0.5 rounded-lg border border-border bg-popover/95 p-1 text-popover-foreground shadow-lg backdrop-blur-xl";
593 toolbar.style.zIndex = String(CHROME_LAYER_Z_INDEX);
594 root.appendChild(toolbar);
595
596 const editor = document.createElement("div");
597 editor.setAttribute(OVERLAY_ATTRIBUTE, "");
598 editor.className =
599 "pointer-events-auto fixed hidden max-h-[calc(100vh-16px)] w-[min(360px,calc(100vw-16px))] flex-col overflow-hidden rounded-xl border border-border bg-popover/96 text-popover-foreground shadow-2xl backdrop-blur-xl";
600 editor.style.zIndex = String(CHROME_LAYER_Z_INDEX);
601 root.appendChild(editor);
602
603 const composerRow = document.createElement("div");
604 composerRow.className = "flex items-start gap-2 p-2";
605
606 const adjust = createButton("", "Expand annotation editor");
607 adjust.setAttribute("aria-label", "Expand annotation editor");
608 adjust.setAttribute("aria-expanded", "false");
609 adjust.className +=
610 " h-8 w-8 shrink-0 bg-muted p-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground";
611 adjust.innerHTML =
612 '<svg viewBox="0 0 20 20" width="15" height="15" aria-hidden="true"><path d="M4 5h12M4 10h12M4 15h12M7 3v4M13 8v4M9 13v4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
613 composerRow.appendChild(adjust);
614
615 const comment = document.createElement("textarea");
616 comment.placeholder = "Describe the change…";
617 comment.setAttribute("dir", "auto");
618 comment.rows = 1;
619 comment.className =
620 "min-h-8 max-h-24 min-w-0 flex-1 resize-none overflow-y-hidden border-0 border-b border-b-transparent bg-transparent px-0 py-1.5 font-sans text-sm leading-5 text-foreground outline-none ring-0 placeholder:text-muted-foreground focus:border-b-primary focus:outline-none focus:ring-0";
621 composerRow.appendChild(comment);
622
623 const dragHandle = document.createElement("button");
624 dragHandle.type = "button";
625 dragHandle.textContent = "â ¿";
626 dragHandle.title = "Drag annotation editor";
627 dragHandle.className =
628 "hidden h-8 w-6 shrink-0 cursor-grab select-none border-0 bg-transparent p-0 font-sans text-lg font-bold leading-5 text-muted-foreground";
629 composerRow.appendChild(dragHandle);
630
631 const submit = createButton("Attach", "Attach annotation and screenshot (Enter)");
632 const updateSendHint = () => {
633 submit.title = sendEnabled
634 ? "Attach annotation and screenshot (Enter). Send with Cmd/Ctrl+Enter."
635 : "Attach annotation and screenshot (Enter)";
636 };
637 updateSendHint();
638 submit.className +=
639 " h-8 shrink-0 border-primary bg-primary px-3 text-primary-foreground shadow-sm hover:bg-primary/90";
640 composerRow.appendChild(submit);
641 editor.appendChild(composerRow);
642
643 const stylePanel = document.createElement("div");
644 stylePanel.className =
645 "hidden max-h-[min(176px,calc(100vh-180px))] overflow-auto border-t border-border bg-muted/40 px-3";
646 editor.appendChild(stylePanel);
647
648 const selected = new Map<Element, SelectedElement>();
649 const regions: PreviewAnnotationRegionTarget[] = [];
650 const strokes: PreviewAnnotationStrokeTarget[] = [];
651 const styleChanges = new Map<string, PreviewAnnotationStyleChange>();
652 const toolButtons = new Map<AnnotationTool, HTMLButtonElement>();
653 let tool: AnnotationTool = "select";
654 let dragStart: PreviewAnnotationPoint | null = null;
655 let activeStroke: { target: PreviewAnnotationStrokeTarget; path: SVGPathElement } | null = null;
656 let pendingCapture = false;
657 let editorExpanded = false;
658 let editorWasShown = false;
659 let editorPosition: { left: number; top: number } | null = null;
660 let editorDrag: { pointerId: number; offsetX: number; offsetY: number } | null = null;
661 let editorLayoutFrame: number | null = null;
662
663 const resizeComment = (): void => {
664 const maxHeight = 96;
665 comment.style.height = "auto";
666 const nextHeight = Math.min(comment.scrollHeight, maxHeight);
667 comment.style.height = `${nextHeight}px`;
668 comment.style.overflowY = comment.scrollHeight > maxHeight ? "auto" : "hidden";
669 queueEditorLayout();
670 };
671 comment.addEventListener("input", resizeComment);
672
673 const updateStatus = (): void => {
674 const hasTargets = selected.size > 0 || regions.length > 0 || strokes.length > 0;
675 editor.style.display = hasTargets ? "flex" : "none";
676 submit.disabled = !hasTargets;
677 submit.style.opacity = hasTargets ? "1" : "0.45";
678 adjust.disabled = !hasTargets;
679 stylePanel.style.display = editorExpanded && selected.size > 0 ? "grid" : "none";
680 queueEditorLayout();
681 if (hasTargets && !editorWasShown) {
682 editorWasShown = true;
683 window.setTimeout(() => comment.focus({ preventScroll: true }), 0);
684 }
685 };
686
687 const refreshToolButtons = (): void => {
688 for (const [candidate, button] of toolButtons) {
689 const active = candidate === tool;
690 button.classList.toggle("bg-primary/10", active);
691 button.classList.toggle("text-primary", active);
692 button.classList.toggle("text-foreground", !active);
693 }
694 if (tool !== "select") hoverOutline.style.display = "none";
695 if (tool !== "marquee") marqueeBox.style.display = "none";
696 document.documentElement.setAttribute("data-t3code-annotation-tool", tool);
697 };
698
699 const removeSelected = (target: SelectedElement): void => {
700 if (target.element instanceof HTMLElement || target.element instanceof SVGElement) {
701 for (const [property, baseline] of target.baselineStyles) {
702 if (baseline) target.element.style.setProperty(property, baseline);
703 else target.element.style.removeProperty(property);
704 }
705 }
706 selected.delete(target.element);
707 target.outline.remove();
708 target.label.remove();
709 for (const [key, change] of styleChanges) {
710 if (change.targetId === target.id) styleChanges.delete(key);
711 }
712 updateStatus();
713 };
714
715 const addSelected = (element: Element): void => {
716 if (selected.has(element)) return;
717 const target: SelectedElement = {
718 id: nextId("element"),
719 element,
720 outline: createBox(PRIMARY, PRIMARY_FILL),
721 label: createLabel(),
722 baselineStyles: new Map(),
723 };
724 selected.set(element, target);
725 root.append(target.outline, target.label);
726 updateSelectedVisual(target);
727 updateStatus();
728 if (editorExpanded) {
729 stylePanel.style.display = "grid";
730 syncStyleControls();
731 }
732 };
733
734 const toggleSelected = (element: Element, additive: boolean): void => {
735 const existing = selected.get(element);
736 if (existing) {
737 removeSelected(existing);
738 return;
739 }
740 if (!additive) {
741 for (const target of Array.from(selected.values())) removeSelected(target);
742 }
743 addSelected(element);
744 };
745
746 const setStyleForSelected = (property: string, value: string): void => {
747 for (const target of selected.values()) {
748 if (!(target.element instanceof HTMLElement || target.element instanceof SVGElement))
749 continue;
750 if (!target.baselineStyles.has(property)) {
751 target.baselineStyles.set(property, target.element.style.getPropertyValue(property));
752 }
753 const key = `${target.id}:${property}`;
754 const previousValue =
755 styleChanges.get(key)?.previousValue ??
756 getComputedStyle(target.element).getPropertyValue(property).trim();
757 target.element.style.setProperty(property, value, "important");
758 styleChanges.set(key, {
759 targetId: target.id,
760 selector: null,
761 property,
762 previousValue,
763 value,
764 });
765 updateSelectedVisual(target);
766 }
767 };
768
769 const textSection = createStyleSection();
770 const colorsSection = createStyleSection();
771 const bordersSection = createStyleSection();
772 const sizingSection = createStyleSection();
773 stylePanel.append(textSection, colorsSection, bordersSection, sizingSection);
774
775 const fontFamily = document.createElement("select");
776 for (const value of ["inherit", "system-ui", "sans-serif", "serif", "monospace"]) {
777 const option = document.createElement("option");
778 option.value = value;
779 option.textContent = value;
780 fontFamily.appendChild(option);
781 }
782 fontFamily.addEventListener("change", () => setStyleForSelected("font-family", fontFamily.value));
783 textSection.appendChild(createField("Font", fontFamily));
784
785 const fontSize = createUnitInput("px", "16");
786 fontSize.min = "1";
787 fontSize.max = "300";
788 fontSize.addEventListener("input", () => {
789 if (fontSize.value) setStyleForSelected("font-size", `${fontSize.value}px`);
790 });
791 textSection.appendChild(createField("Font size", fontSize));
792
793 const fontWeight = document.createElement("select");
794 for (const value of ["300", "400", "500", "600", "700", "800", "900"]) {
795 const option = document.createElement("option");
796 option.value = value;
797 option.textContent = value;
798 fontWeight.appendChild(option);
799 }
800 fontWeight.addEventListener("change", () => setStyleForSelected("font-weight", fontWeight.value));
801 textSection.appendChild(createField("Font weight", fontWeight));
802
803 const lineHeight = document.createElement("input");
804 lineHeight.type = "text";
805 lineHeight.placeholder = "normal / 1.4";
806 lineHeight.addEventListener("change", () => {
807 if (lineHeight.value.trim()) setStyleForSelected("line-height", lineHeight.value.trim());
808 });
809 textSection.appendChild(createField("Line height", lineHeight));
810
811 const createColorRow = (
812 labelText: string,
813 property: string,
814 section: HTMLElement,
815 ): { row: HTMLLabelElement; color: HTMLInputElement; text: HTMLInputElement } => {
816 const row = document.createElement("label");
817 row.className =
818 "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground";
819 const label = document.createElement("span");
820 label.textContent = labelText;
821 const control = document.createElement("div");
822 control.className =
823 "grid h-7 grid-cols-[22px_minmax(0,1fr)] items-center gap-1 rounded-md border border-input bg-background px-1 shadow-xs";
824 const color = document.createElement("input");
825 color.type = "color";
826 color.setAttribute("aria-label", labelText);
827 color.style.cssText =
828 "width:20px;height:20px;padding:0;border:0;border-radius:5px;overflow:hidden;background:transparent;cursor:pointer";
829 const text = document.createElement("input");
830 text.type = "text";
831 text.setAttribute("aria-label", `${labelText} value`);
832 text.className =
833 "min-w-0 w-full border-0 bg-transparent font-mono text-xs text-foreground outline-none";
834 color.addEventListener("input", () => {
835 text.value = color.value;
836 setStyleForSelected(property, color.value);
837 });
838 text.addEventListener("change", () => {
839 const value = text.value.trim();
840 if (!value) return;
841 setStyleForSelected(property, value);
842 if (/^#[0-9a-f]{6}$/i.test(value)) color.value = value;
843 });
844 control.append(color, text);
845 row.append(label, control);
846 section.appendChild(row);
847 return { row, color, text };
848 };
849
850 const textColor = createColorRow("Text color", "color", colorsSection);
851 const backgroundColor = createColorRow("Background", "background-color", colorsSection);
852
853 const opacity = document.createElement("input");
854 opacity.type = "range";
855 opacity.min = "0";
856 opacity.max = "1";
857 opacity.step = "0.05";
858 opacity.value = "1";
859 opacity.style.accentColor = PRIMARY;
860 opacity.addEventListener("input", () => setStyleForSelected("opacity", opacity.value));
861 colorsSection.appendChild(createField("Opacity", opacity));
862
863 const radius = createUnitInput("px", "0");
864 radius.min = "0";
865 radius.max = "300";
866 radius.addEventListener("input", () => {
867 if (radius.value) setStyleForSelected("border-radius", `${radius.value}px`);
868 });
869 bordersSection.appendChild(createField("Radius", radius));
870
871 const borderColor = createColorRow("Border color", "border-color", bordersSection);
872
873 const borderWidth = createUnitInput("px", "0");
874 borderWidth.min = "0";
875 borderWidth.max = "100";
876 borderWidth.addEventListener("input", () => {
877 if (borderWidth.value) {
878 setStyleForSelected("border-style", "solid");
879 setStyleForSelected("border-width", `${borderWidth.value}px`);
880 }
881 });
882 bordersSection.appendChild(createField("Border width", borderWidth));
883
884 const dimensions = document.createElement("div");
885 dimensions.style.cssText =
886 "display:grid;grid-template-columns:82px minmax(0,1fr);gap:8px;align-items:center";
887 const dimensionLabel = document.createElement("div");
888 dimensionLabel.className = "grid gap-2 font-sans text-xs font-medium text-muted-foreground";
889 dimensionLabel.innerHTML = "<span>Width</span><span>Height</span>";
890 const dimensionControls = document.createElement("div");
891 dimensionControls.style.cssText = "position:relative;display:grid;gap:3px;padding-left:22px";
892 const widthInput = createUnitInput("px", "auto");
893 const heightInput = createUnitInput("px", "auto");
894 styleControl(widthInput);
895 styleControl(heightInput);
896 const aspectLock = createButton("", "Lock aspect ratio");
897 aspectLock.setAttribute("aria-pressed", "true");
898 aspectLock.style.cssText +=
899 ";position:absolute;left:0;top:50%;transform:translateY(-50%);width:18px;height:38px;padding:0";
900 aspectLock.className += " bg-primary/10 text-primary";
901 dimensionControls.append(
902 createUnitControl(widthInput),
903 createUnitControl(heightInput),
904 aspectLock,
905 );
906 dimensions.append(dimensionLabel, dimensionControls);
907 sizingSection.appendChild(dimensions);
908
909 let aspectLocked = true;
910 let aspectRatio = 1;
911 const refreshAspectButton = (): void => {
912 aspectLock.innerHTML = aspectLocked
913 ? '<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>'
914 : '<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>';
915 aspectLock.setAttribute("aria-pressed", String(aspectLocked));
916 aspectLock.classList.toggle("bg-primary/10", aspectLocked);
917 aspectLock.classList.toggle("text-primary", aspectLocked);
918 aspectLock.classList.toggle("bg-muted", !aspectLocked);
919 aspectLock.classList.toggle("text-muted-foreground", !aspectLocked);
920 };
921 aspectLock.addEventListener("click", () => {
922 aspectLocked = !aspectLocked;
923 refreshAspectButton();
924 });
925 widthInput.addEventListener("input", () => {
926 const width = Number(widthInput.value);
927 if (!Number.isFinite(width) || width <= 0) return;
928 setStyleForSelected("width", `${width}px`);
929 if (aspectLocked && aspectRatio > 0) {
930 const height = Math.max(1, Math.round(width / aspectRatio));
931 heightInput.value = String(height);
932 setStyleForSelected("height", `${height}px`);
933 }
934 });
935 heightInput.addEventListener("input", () => {
936 const height = Number(heightInput.value);
937 if (!Number.isFinite(height) || height <= 0) return;
938 setStyleForSelected("height", `${height}px`);
939 if (aspectLocked && aspectRatio > 0) {
940 const width = Math.max(1, Math.round(height * aspectRatio));
941 widthInput.value = String(width);
942 setStyleForSelected("width", `${width}px`);
943 }
944 });
945 refreshAspectButton();
946
947 const addSpacingField = (
948 label: string,
949 property: string,
950 placeholder: string,
951 ): HTMLInputElement => {
952 const input = document.createElement("input");
953 input.type = "text";
954 input.placeholder = placeholder;
955 input.addEventListener("change", () => {
956 if (input.value.trim()) setStyleForSelected(property, input.value.trim());
957 });
958 sizingSection.appendChild(createField(label, input));
959 return input;
960 };
961 const padding = addSpacingField("Padding", "padding", "0 0 0 0");
962 const margin = addSpacingField("Margin", "margin", "0 0 0 0");
963 const gap = addSpacingField("Gap", "gap", "0px");
964
965 const syncStyleControls = (): void => {
966 const first = selected.values().next().value as SelectedElement | undefined;
967 if (!first) return;
968 const computed = getComputedStyle(first.element);
969 const rect = first.element.getBoundingClientRect();
970 aspectRatio = rect.height > 0 ? rect.width / rect.height : 1;
971 widthInput.value = String(Math.round(rect.width));
972 heightInput.value = String(Math.round(rect.height));
973 fontSize.value = String(Math.round(Number.parseFloat(computed.fontSize) || 16));
974 fontWeight.value = computed.fontWeight.match(/^[0-9]+$/) ? computed.fontWeight : "400";
975 lineHeight.value = computed.lineHeight;
976 fontFamily.value = Array.from(fontFamily.options).some(
977 (option) => option.value === computed.fontFamily,
978 )
979 ? computed.fontFamily
980 : "inherit";
981 textColor.text.value = computed.color;
982 backgroundColor.text.value = computed.backgroundColor;
983 borderColor.text.value = computed.borderColor;
984 opacity.value = computed.opacity;
985 radius.value = String(Math.round(Number.parseFloat(computed.borderRadius) || 0));
986 borderWidth.value = String(Math.round(Number.parseFloat(computed.borderWidth) || 0));
987 padding.value = computed.padding;
988 margin.value = computed.margin;
989 gap.value = computed.gap === "normal" ? "0px" : computed.gap;
990 };
991
992 const tools: ReadonlyArray<[AnnotationTool, string, string]> = [
993 ["select", "Select", "Select elements (V)"],
994 ["marquee", "Region", "Draw a region or marquee-select elements (R)"],
995 ["draw", "Draw", "Draw freehand (D)"],
996 ["erase", "Erase", "Remove an annotation target (E)"],
997 ];
998 for (const [candidate, label, title] of tools) {
999 const button = createButton(label, title);
1000 button.className += " h-8 px-2.5 text-sm";
1001 button.addEventListener("click", () => {
1002 tool = candidate;
1003 refreshToolButtons();
1004 });
1005 toolButtons.set(candidate, button);
1006 toolbar.appendChild(button);
1007 }
1008
1009 const clampEditorPosition = (left: number, top: number): { left: number; top: number } => {
1010 const margin = 8;
1011 const rect = editor.getBoundingClientRect();
1012 return {
1013 left: Math.min(
1014 Math.max(margin, left),
1015 Math.max(margin, window.innerWidth - rect.width - margin),
1016 ),
1017 top: Math.min(
1018 Math.max(margin, top),
1019 Math.max(margin, window.innerHeight - rect.height - margin),
1020 ),
1021 };
1022 };
1023
1024 const applyEditorPosition = (position: { left: number; top: number }): void => {
1025 const clamped = clampEditorPosition(position.left, position.top);
1026 editor.style.left = `${clamped.left}px`;
1027 editor.style.top = `${clamped.top}px`;
1028 editor.style.right = "auto";
1029 editor.style.bottom = "auto";
1030 if (editorExpanded) editorPosition = clamped;
1031 };
1032
1033 const getAnnotationBounds = (): PreviewAnnotationRect | null =>
1034 unionRects(
1035 [
1036 // Follow the visible outline, so a clipped selection does not anchor the editor off-screen.
1037 ...Array.from(selected.values(), (target) => visibleElementRect(target.element)).filter(
1038 (rect) => rect.width > 0 && rect.height > 0,
1039 ),
1040 ...regions.map((region) => region.rect),
1041 ...strokes.map((stroke) => stroke.bounds),
1042 ],
1043 0,
1044 );
1045
1046 const positionCompactEditor = (): void => {
1047 const bounds = getAnnotationBounds();
1048 if (!bounds) return;
1049 const editorRect = editor.getBoundingClientRect();
1050 const gap = 8;
1051 const candidates = [
1052 { left: bounds.x + bounds.width + gap, top: bounds.y },
1053 { left: bounds.x - editorRect.width - gap, top: bounds.y },
1054 {
1055 left: bounds.x + bounds.width - editorRect.width,
1056 top: bounds.y + bounds.height + gap,
1057 },
1058 {
1059 left: bounds.x + bounds.width - editorRect.width,
1060 top: bounds.y - editorRect.height - gap,
1061 },
1062 ];
1063 const overflow = (position: { left: number; top: number }): number =>
1064 Math.max(0, -position.left) +
1065 Math.max(0, -position.top) +
1066 Math.max(0, position.left + editorRect.width - window.innerWidth) +
1067 Math.max(0, position.top + editorRect.height - window.innerHeight);
1068 const best = candidates.reduce((current, candidate) =>
1069 overflow(candidate) < overflow(current) ? candidate : current,
1070 );
1071 applyEditorPosition(best);
1072 };
1073
1074 function queueEditorLayout(): void {
1075 if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame);
1076 editorLayoutFrame = window.requestAnimationFrame(() => {
1077 editorLayoutFrame = null;
1078 if (editor.style.display === "none") return;
1079 if (editorExpanded && editorPosition) applyEditorPosition(editorPosition);
1080 else positionCompactEditor();
1081 });
1082 }
1083
1084 adjust.addEventListener("click", () => {
1085 if (selected.size === 0) return;
1086 if (!editorExpanded) {
1087 const rect = editor.getBoundingClientRect();
1088 editorExpanded = true;
1089 editorPosition = { left: rect.left, top: rect.top };
1090 stylePanel.style.display = selected.size > 0 ? "grid" : "none";
1091 dragHandle.style.display = "block";
1092 adjust.setAttribute("aria-expanded", "true");
1093 adjust.title = "Collapse annotation editor";
1094 adjust.setAttribute("aria-label", "Collapse annotation editor");
1095 if (selected.size > 0) syncStyleControls();
1096 } else {
1097 editorExpanded = false;
1098 editorPosition = null;
1099 stylePanel.style.display = "none";
1100 dragHandle.style.display = "none";
1101 adjust.setAttribute("aria-expanded", "false");
1102 adjust.title = "Expand annotation editor";
1103 adjust.setAttribute("aria-label", "Expand annotation editor");
1104 }
1105 queueEditorLayout();
1106 });
1107
1108 const onEditorPointerDown = (event: PointerEvent): void => {
1109 if (event.button !== 0 || !editorExpanded) return;
1110 const rect = editor.getBoundingClientRect();
1111 editorDrag = {
1112 pointerId: event.pointerId,
1113 offsetX: event.clientX - rect.left,
1114 offsetY: event.clientY - rect.top,
1115 };
1116 dragHandle.setPointerCapture(event.pointerId);
1117 dragHandle.style.cursor = "grabbing";
1118 event.preventDefault();
1119 event.stopPropagation();
1120 };
1121
1122 const onEditorPointerMove = (event: PointerEvent): void => {
1123 if (!editorDrag || editorDrag.pointerId !== event.pointerId) return;
1124 applyEditorPosition({
1125 left: event.clientX - editorDrag.offsetX,
1126 top: event.clientY - editorDrag.offsetY,
1127 });
1128 event.preventDefault();
1129 event.stopPropagation();
1130 };
1131
1132 const onEditorPointerUp = (event: PointerEvent): void => {
1133 if (!editorDrag || editorDrag.pointerId !== event.pointerId) return;
1134 editorDrag = null;
1135 dragHandle.style.cursor = "grab";
1136 if (dragHandle.hasPointerCapture(event.pointerId))
1137 dragHandle.releasePointerCapture(event.pointerId);
1138 event.preventDefault();
1139 event.stopPropagation();
1140 };
1141 dragHandle.addEventListener("pointerdown", onEditorPointerDown);
1142 dragHandle.addEventListener("pointermove", onEditorPointerMove);
1143 dragHandle.addEventListener("pointerup", onEditorPointerUp);
1144 dragHandle.addEventListener("pointercancel", onEditorPointerUp);
1145
1146 const repaint = (): void => {
1147 for (const target of selected.values()) updateSelectedVisual(target);
1148 queueEditorLayout();
1149 };
1150 // A scrollbar appearing narrows the viewport without a window resize.
1151 const rootResizeObserver = new ResizeObserver(repaint);
1152
1153 const removeTargetAtPoint = (x: number, y: number): boolean => {
1154 for (const target of Array.from(selected.values()).toReversed()) {
1155 const rect = target.element.getBoundingClientRect();
1156 if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
1157 removeSelected(target);
1158 return true;
1159 }
1160 }
1161 const regionIndex = regions.findIndex(
1162 (region) =>
1163 x >= region.rect.x &&
1164 x <= region.rect.x + region.rect.width &&
1165 y >= region.rect.y &&
1166 y <= region.rect.y + region.rect.height,
1167 );
1168 if (regionIndex >= 0) {
1169 const [removed] = regions.splice(regionIndex, 1);
1170 root.querySelector(`[data-region-id="${removed?.id}"]`)?.remove();
1171 updateStatus();
1172 return true;
1173 }
1174 const strokeIndex = strokes.findIndex(
1175 (stroke) =>
1176 x >= stroke.bounds.x &&
1177 x <= stroke.bounds.x + stroke.bounds.width &&
1178 y >= stroke.bounds.y &&
1179 y <= stroke.bounds.y + stroke.bounds.height,
1180 );
1181 if (strokeIndex >= 0) {
1182 const [removed] = strokes.splice(strokeIndex, 1);
1183 svg.querySelector(`[data-stroke-id="${removed?.id}"]`)?.remove();
1184 updateStatus();
1185 return true;
1186 }
1187 return false;
1188 };
1189
1190 const selectElementsInRect = (rect: PreviewAnnotationRect): number => {
1191 const candidates = Array.from(document.querySelectorAll("body *"))
1192 .filter((element) => !isAnnotationNode(element))
1193 .map((element) => ({ element, rect: element.getBoundingClientRect() }))
1194 .filter(({ rect: candidate }) => {
1195 if (candidate.width < 2 || candidate.height < 2) return false;
1196 return !(
1197 candidate.right < rect.x ||
1198 candidate.left > rect.x + rect.width ||
1199 candidate.bottom < rect.y ||
1200 candidate.top > rect.y + rect.height
1201 );
1202 })
1203 .filter(({ element, rect: candidate }) => {
1204 const centerX = candidate.left + candidate.width / 2;
1205 const centerY = candidate.top + candidate.height / 2;
1206 return (
1207 centerX >= rect.x &&
1208 centerX <= rect.x + rect.width &&
1209 centerY >= rect.y &&
1210 centerY <= rect.y + rect.height &&
1211 (element.children.length === 0 ||
1212 element instanceof HTMLButtonElement ||
1213 element instanceof HTMLAnchorElement ||
1214 element.getAttribute("role") === "button")
1215 );
1216 })
1217 .sort(
1218 (left, right) => left.rect.width * left.rect.height - right.rect.width * right.rect.height,
1219 )
1220 .slice(0, MAX_MARQUEE_ELEMENTS);
1221 for (const candidate of candidates) addSelected(candidate.element);
1222 return candidates.length;
1223 };
1224
1225 const clearHoverOutline = (): void => {
1226 hoverOutline.style.display = "none";
1227 };
1228
1229 const onPointerMove = (event: PointerEvent): void => {
1230 if (isAnnotationNode(event.target as Element)) {
1231 clearHoverOutline();
1232 return;
1233 }
1234 if (tool === "select" && dragStart === null) {
1235 const target = pickFromPoint(event.clientX, event.clientY);
1236 if (target) positionBox(hoverOutline, visibleElementRect(target));
1237 else clearHoverOutline();
1238 return;
1239 }
1240 clearHoverOutline();
1241 if (tool === "marquee" && dragStart) {
1242 positionBox(
1243 marqueeBox,
1244 normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY),
1245 );
1246 return;
1247 }
1248 if (tool === "draw" && activeStroke) {
1249 activeStroke.target.points = [
1250 ...activeStroke.target.points,
1251 { x: event.clientX, y: event.clientY },
1252 ];
1253 activeStroke.target.bounds = strokeBounds(
1254 activeStroke.target.points,
1255 activeStroke.target.width,
1256 );
1257 activeStroke.path.setAttribute("d", pathFromPoints(activeStroke.target.points));
1258 }
1259 };
1260
1261 const onPointerDown = (event: PointerEvent): void => {
1262 if (event.button !== 0 || isAnnotationNode(event.target as Element)) return;
1263 event.preventDefault();
1264 event.stopPropagation();
1265 if (tool === "select") {
1266 const target = pickFromPoint(event.clientX, event.clientY);
1267 if (target) toggleSelected(target, event.shiftKey);
1268 return;
1269 }
1270 if (tool === "erase") {
1271 removeTargetAtPoint(event.clientX, event.clientY);
1272 return;
1273 }
1274 dragStart = { x: event.clientX, y: event.clientY };
1275 if (tool === "draw") {
1276 const stroke: PreviewAnnotationStrokeTarget = {
1277 id: nextId("stroke"),
1278 color: annotationTheme?.primary ?? "#2563eb",
1279 width: 4,
1280 points: [dragStart],
1281 bounds: { x: dragStart.x, y: dragStart.y, width: 1, height: 1 },
1282 };
1283 const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
1284 path.setAttribute(OVERLAY_ATTRIBUTE, "");
1285 path.setAttribute("data-stroke-id", stroke.id);
1286 path.setAttribute("fill", "none");
1287 path.setAttribute("stroke", stroke.color);
1288 path.setAttribute("stroke-width", String(stroke.width));
1289 path.setAttribute("stroke-linecap", "round");
1290 path.setAttribute("stroke-linejoin", "round");
1291 svg.appendChild(path);
1292 activeStroke = { target: stroke, path };
1293 }
1294 };
1295
1296 const onPointerUp = (event: PointerEvent): void => {
1297 if (!dragStart) return;
1298 event.preventDefault();
1299 event.stopPropagation();
1300 if (tool === "marquee") {
1301 const rect = normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY);
1302 marqueeBox.style.display = "none";
1303 if (isUsableRect(rect)) {
1304 const found = selectElementsInRect(rect);
1305 if (found === 0) {
1306 const region: PreviewAnnotationRegionTarget = { id: nextId("region"), rect };
1307 regions.push(region);
1308 const regionBox = createBox(
1309 PRIMARY,
1310 "color-mix(in srgb, var(--t3-primary) 6%, transparent)",
1311 );
1312 regionBox.setAttribute("data-region-id", region.id);
1313 positionBox(regionBox, rect);
1314 root.appendChild(regionBox);
1315 }
1316 }
1317 } else if (tool === "draw" && activeStroke) {
1318 if (activeStroke.target.points.length > 1) strokes.push(activeStroke.target);
1319 else activeStroke.path.remove();
1320 activeStroke = null;
1321 }
1322 dragStart = null;
1323 updateStatus();
1324 };
1325
1326 const onClick = (event: MouseEvent): void => {
1327 if (isAnnotationNode(event.target as Element)) return;
1328 event.preventDefault();
1329 event.stopPropagation();
1330 };
1331
1332 const onPointerOut = (event: PointerEvent): void => {
1333 if (event.relatedTarget === null) clearHoverOutline();
1334 };
1335
1336 const onWindowBlur = (): void => {
1337 clearHoverOutline();
1338 };
1339
1340 const restoreStyles = (): void => {
1341 for (const target of selected.values()) {
1342 if (!(target.element instanceof HTMLElement || target.element instanceof SVGElement))
1343 continue;
1344 for (const [property, baseline] of target.baselineStyles) {
1345 if (baseline) target.element.style.setProperty(property, baseline);
1346 else target.element.style.removeProperty(property);
1347 }
1348 }
1349 };
1350
1351 const teardown = (notifyMain: boolean): void => {
1352 if (finished) return;
1353 finished = true;
1354 rootResizeObserver.disconnect();
1355 restoreStyles();
1356 window.removeEventListener("pointermove", onPointerMove, true);
1357 window.removeEventListener("pointerdown", onPointerDown, true);
1358 window.removeEventListener("pointerup", onPointerUp, true);
1359 window.removeEventListener("pointerout", onPointerOut, true);
1360 window.removeEventListener("click", onClick, true);
1361 window.removeEventListener("blur", onWindowBlur);
1362 window.removeEventListener("keydown", onKeyDown, true);
1363 window.removeEventListener("scroll", repaint, true);
1364 window.removeEventListener("resize", repaint);
1365 dragHandle.removeEventListener("pointerdown", onEditorPointerDown);
1366 dragHandle.removeEventListener("pointermove", onEditorPointerMove);
1367 dragHandle.removeEventListener("pointerup", onEditorPointerUp);
1368 dragHandle.removeEventListener("pointercancel", onEditorPointerUp);
1369 if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame);
1370 ipcRenderer.off(CANCEL_PICK_CHANNEL, onCancel);
1371 ipcRenderer.off(ANNOTATION_CAPTURED_CHANNEL, onCaptured);
1372 document.documentElement.removeAttribute("data-t3code-annotation-tool");
1373 cursorStyle.remove();
1374 host.remove();
1375 activeSession = null;
1376 if (notifyMain) ipcRenderer.send(ELEMENT_PICKED_CHANNEL, null);
1377 };
1378
1379 const onCancel = (): void => teardown(false);
1380 const onCaptured = (): void => teardown(false);
1381 const onKeyDown = (event: KeyboardEvent): void => {
1382 if (isAnnotationNode(event.target as Element) && event.key !== "Escape") return;
1383 if (event.key === "Escape") {
1384 event.preventDefault();
1385 event.stopPropagation();
1386 teardown(true);
1387 return;
1388 }
1389 if (event.key === "v") tool = "select";
1390 else if (event.key === "r") tool = "marquee";
1391 else if (event.key === "d") tool = "draw";
1392 else if (event.key === "e") tool = "erase";
1393 else return;
1394 refreshToolButtons();
1395 };
1396
1397 const submitAnnotation = (submission: PreviewAnnotationSubmission): void => {
1398 if (submission === "send" && !sendEnabled) return;
1399 if (pendingCapture || (selected.size === 0 && regions.length === 0 && strokes.length === 0))
1400 return;
1401 pendingCapture = true;
1402 submit.disabled = true;
1403 submit.textContent = "Capturing…";
1404 // Snapshot everything the annotation will carry before the capture runs.
1405 // The element context lookup can take up to its timeout, and the user can
1406 // keep editing meanwhile; the annotation must describe what they submitted.
1407 const submittedComment = comment.value.trim();
1408 const submittedRegions = [...regions];
1409 const submittedStrokes = [...strokes];
1410 const submittedStyleChanges = Array.from(styleChanges.values(), (change) => ({ ...change }));
1411 void Promise.all(
1412 Array.from(selected.values()).map(async (target) => {
1413 const element = await captureElement(target.element);
1414 for (const change of submittedStyleChanges) {
1415 if (change.targetId === target.id && element.selector !== null) {
1416 change.selector = element.selector;
1417 }
1418 }
1419 return {
1420 id: target.id,
1421 element,
1422 rect: rectFromDomRect(target.element.getBoundingClientRect()),
1423 };
1424 }),
1425 )
1426 .then((elements) => {
1427 // The overlay may have been cancelled or replaced while the capture
1428 // ran. A late submit must not deliver into the next pick's listener.
1429 if (finished) return;
1430 const annotation: PreviewAnnotationPayload = {
1431 id: nextId("annotation"),
1432 pageUrl: location.href,
1433 pageTitle: document.title?.trim() || null,
1434 comment: submittedComment,
1435 elements,
1436 regions: submittedRegions,
1437 strokes: submittedStrokes,
1438 styleChanges: submittedStyleChanges,
1439 screenshot: null,
1440 createdAt: new Date().toISOString(),
1441 };
1442 editor.style.display = "none";
1443 toolbar.style.display = "none";
1444 hoverOutline.style.display = "none";
1445 const screenshotRect = unionRects([
1446 ...elements.map((target) => target.rect),
1447 ...submittedRegions.map((region) => region.rect),
1448 ...submittedStrokes.map((stroke) => stroke.bounds),
1449 ]);
1450 ipcRenderer.send(
1451 ELEMENT_PICKED_CHANNEL,
1452 annotation,
1453 screenshotRect,
1454 submission === "send" && !sendEnabled ? "attach" : submission,
1455 // Main crops a full-page capture, whose pixels are CSS px × this.
1456 window.devicePixelRatio,
1457 );
1458 })
1459 .catch(() => {
1460 // Last resort. Main is waiting on this message, so hand it an empty
1461 // pick rather than leaving the button stuck on "Capturing…" and the
1462 // renderer's pick promise pending. teardown is a no-op once finished.
1463 teardown(true);
1464 });
1465 };
1466 submit.addEventListener("click", () => submitAnnotation("attach"));
1467 root.addEventListener("keydown", (event) => {
1468 const submission =
1469 event.target === comment ? resolveAnnotationSubmission(event, sendEnabled) : null;
1470 // Keep this in the bubble phase so editor inputs receive the event before
1471 // it is isolated from listeners installed by the inspected page.
1472 event.stopImmediatePropagation();
1473 if (!submission) return;
1474 event.preventDefault();
1475 submitAnnotation(submission);
1476 });
1477
1478 window.addEventListener("pointermove", onPointerMove, { capture: true, passive: false });
1479 window.addEventListener("pointerdown", onPointerDown, { capture: true, passive: false });
1480 window.addEventListener("pointerup", onPointerUp, { capture: true, passive: false });
1481 window.addEventListener("pointerout", onPointerOut, { capture: true, passive: true });
1482 window.addEventListener("click", onClick, { capture: true, passive: false });
1483 window.addEventListener("blur", onWindowBlur);
1484 window.addEventListener("keydown", onKeyDown, { capture: true });
1485 window.addEventListener("scroll", repaint, { capture: true, passive: true });
1486 window.addEventListener("resize", repaint, { passive: true });
1487 ipcRenderer.on(CANCEL_PICK_CHANNEL, onCancel);
1488 ipcRenderer.on(ANNOTATION_CAPTURED_CHANNEL, onCaptured);
1489 document.documentElement.appendChild(host);
1490 rootResizeObserver.observe(document.documentElement);
1491 refreshToolButtons();
1492 updateStatus();
1493 activeSession = {
1494 teardown,
1495 applyTheme: (theme) => applyAnnotationTheme(host, theme),
1496 setSendEnabled: (enabled) => {
1497 sendEnabled = enabled;
1498 updateSendHint();
1499 },
1500 };
1501}
1502
1503ipcRenderer.on(
1504 START_PICK_CHANNEL,
1505 (_event, theme: DesktopPreviewAnnotationTheme | undefined, sendEnabled?: boolean) => {
1506 if (theme) annotationTheme = theme;
1507 startAnnotation(sendEnabled === true);
1508 },
1509);
1510ipcRenderer.on(ANNOTATION_SEND_ENABLED_CHANNEL, (_event, enabled: boolean) => {
1511 activeSession?.setSendEnabled(enabled === true);
1512});
1513ipcRenderer.on(ANNOTATION_THEME_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme) => {
1514 annotationTheme = theme;
1515 recordingCursor?.setTheme(theme);
1516 activeSession?.applyTheme(theme);
1517});
1518ipcRenderer.on(CANCEL_PICK_CHANNEL, () => activeSession?.teardown(false));