diff --git a/apps/desktop/src/picked-element-payload.test.ts b/apps/desktop/src/picked-element-payload.test.ts index 8e5c3ad54bac9676467a1fb5cf0fdb395039a385..39537fa7c967b634ab863cb365d49dd49ef25683 100644 --- a/apps/desktop/src/picked-element-payload.test.ts +++ b/apps/desktop/src/picked-element-payload.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { isPickedElementPayload } from "./picked-element-payload.ts"; +import { isPickedElementPayload, isPreviewAnnotationPayload } from "./picked-element-payload.ts"; function validPayload(overrides?: Record): Record { return { @@ -133,3 +133,69 @@ describe("isPickedElementPayload", () => { expect(isPickedElementPayload(validPayload({ stack: [{ bogus: true }] }))).toBe(false); }); }); + +function validAnnotation(overrides?: Record): Record { + return { + id: "annotation_1", + pageUrl: "https://example.com/", + pageTitle: "Example", + comment: "Make this clearer", + elements: [ + { + id: "element_1", + element: validPayload(), + rect: { x: 10, y: 20, width: 100, height: 40 }, + }, + ], + regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }], + strokes: [ + { + id: "stroke_1", + color: "#7c3aed", + width: 4, + points: [ + { x: 10, y: 10 }, + { x: 20, y: 20 }, + ], + bounds: { x: 6, y: 6, width: 18, height: 18 }, + }, + ], + styleChanges: [ + { + targetId: "element_1", + selector: "button.submit", + property: "opacity", + previousValue: "1", + value: "0.5", + }, + ], + screenshot: null, + createdAt: "2026-06-11T00:00:00.000Z", + ...overrides, + }; +} + +describe("isPreviewAnnotationPayload", () => { + it("accepts a structured annotation draft before screenshot capture", () => { + expect(isPreviewAnnotationPayload(validAnnotation())).toBe(true); + }); + + it("rejects screenshots supplied by the guest preload", () => { + expect(isPreviewAnnotationPayload(validAnnotation({ screenshot: { dataUrl: "bad" } }))).toBe( + false, + ); + }); + + it("rejects malformed geometry and nested element payloads", () => { + expect( + isPreviewAnnotationPayload( + validAnnotation({ regions: [{ id: "region_1", rect: { x: 0, y: 0, width: "wide" } }] }), + ), + ).toBe(false); + expect( + isPreviewAnnotationPayload( + validAnnotation({ elements: [{ id: "element_1", element: {}, rect: {} }] }), + ), + ).toBe(false); + }); +}); diff --git a/apps/desktop/src/picked-element-payload.ts b/apps/desktop/src/picked-element-payload.ts index 1b5d4df9f5be835aaa075dc2475851cc06efdfe1..a9fadf63c8ff444fbc3c6b89edda07e83bd6dabb 100644 --- a/apps/desktop/src/picked-element-payload.ts +++ b/apps/desktop/src/picked-element-payload.ts @@ -10,7 +10,7 @@ * channel via prototype pollution) would otherwise throw deep in the * renderer and the chip silently never appears. */ -import type { PickedElementPayload } from "@t3tools/contracts"; +import type { PickedElementPayload, PreviewAnnotationPayload } from "@t3tools/contracts"; function isStringOrNull(value: unknown): value is string | null { return value === null || typeof value === "string"; @@ -47,3 +47,100 @@ export function isPickedElementPayload(value: unknown): value is PickedElementPa if (!c["stack"].every(isPickedStackFrame)) return false; return true; } + +function isRect(value: unknown): boolean { + if (typeof value !== "object" || value === null) return false; + const rect = value as Record; + return ["x", "y", "width", "height"].every( + (key) => typeof rect[key] === "number" && Number.isFinite(rect[key]), + ); +} + +function isPoint(value: unknown): boolean { + if (typeof value !== "object" || value === null) return false; + const point = value as Record; + return ( + typeof point["x"] === "number" && + Number.isFinite(point["x"]) && + typeof point["y"] === "number" && + Number.isFinite(point["y"]) + ); +} + +export function isPreviewAnnotationPayload(value: unknown): value is PreviewAnnotationPayload { + if (typeof value !== "object" || value === null) return false; + const annotation = value as Record; + if (typeof annotation["id"] !== "string") return false; + if (typeof annotation["pageUrl"] !== "string") return false; + if (!isStringOrNull(annotation["pageTitle"])) return false; + if (typeof annotation["comment"] !== "string") return false; + if (typeof annotation["createdAt"] !== "string") return false; + if (annotation["screenshot"] !== null) return false; + + const elements = annotation["elements"]; + if (!Array.isArray(elements)) return false; + if ( + !elements.every((entry) => { + if (typeof entry !== "object" || entry === null) return false; + const target = entry as Record; + return ( + typeof target["id"] === "string" && + isPickedElementPayload(target["element"]) && + isRect(target["rect"]) + ); + }) + ) { + return false; + } + + const regions = annotation["regions"]; + if (!Array.isArray(regions)) return false; + if ( + !regions.every((entry) => { + if (typeof entry !== "object" || entry === null) return false; + const target = entry as Record; + return typeof target["id"] === "string" && isRect(target["rect"]); + }) + ) { + return false; + } + + const strokes = annotation["strokes"]; + if (!Array.isArray(strokes)) return false; + if ( + !strokes.every((entry) => { + if (typeof entry !== "object" || entry === null) return false; + const target = entry as Record; + return ( + typeof target["id"] === "string" && + typeof target["color"] === "string" && + typeof target["width"] === "number" && + Number.isFinite(target["width"]) && + Array.isArray(target["points"]) && + target["points"].every(isPoint) && + isRect(target["bounds"]) + ); + }) + ) { + return false; + } + + const styleChanges = annotation["styleChanges"]; + if (!Array.isArray(styleChanges)) return false; + if ( + !styleChanges.every((entry) => { + if (typeof entry !== "object" || entry === null) return false; + const change = entry as Record; + return ( + typeof change["targetId"] === "string" && + isStringOrNull(change["selector"]) && + typeof change["property"] === "string" && + typeof change["previousValue"] === "string" && + typeof change["value"] === "string" + ); + }) + ) { + return false; + } + return true; +} diff --git a/apps/desktop/src/preview-pick-preload.ts b/apps/desktop/src/preview-pick-preload.ts index f5dacf1edb9807187c65c71bf2cf6d72105a5697..be4df208ed891fb8233584a11d555617a63ae856 100644 --- a/apps/desktop/src/preview-pick-preload.ts +++ b/apps/desktop/src/preview-pick-preload.ts @@ -1,175 +1,101 @@ // @effect-diagnostics globalDate:off -/** - * Preview pick preload — runs in the isolated world of the Chromium - * `` that hosts the in-app browser. Loaded via the - * `` attribute set by the renderer. - * - * Responsibilities: - * - * 1. Listen for `preview:start-pick` from main (sent through - * `WebContents.send`, received here via `ipcRenderer.on`). - * 2. Mount a minimal blue-highlight element picker on the page. - * 3. On click → call `react-grab/primitives.getElementContext` and bubble - * a `PickedElementPayload` to main via `ipcRenderer.send`. Main resolves - * the in-flight `pickElement(tabId)` promise via its per-WebContents - * `wc.ipc.on(...)` listener. - * 4. Tear down the picker on Escape, blur, navigation, or explicit cancel. - * - * Design notes: - * - * - We never modify the page's DOM tree — the highlight + crosshair cursor - * live on a single fixed-position overlay layer that we own. This keeps - * us safe against pages that reset DOM or do MutationObserver tracking. - * - The overlay uses `pointer-events: none` so clicks fall through to the - * real element behind it; we do hit-testing with `elementFromPoint`. - * - Only a single pick session is ever active per webview; re-activating - * silently replaces the previous session. - * - Cancellations triggered BY MAIN (CANCEL_PICK_CHANNEL, or a follow-up - * START_PICK_CHANNEL that supersedes the current session) tear down - * silently — they do NOT echo a `null` ELEMENT_PICKED back, because main - * already knows it cancelled and would otherwise resolve the freshly- - * registered next-pick listener with that stale `null`. Cancellations - * triggered by the USER (Escape, beforeunload, click on empty) DO send - * `null` back so main can resolve the in-flight pick promise. - */ import { ipcRenderer } from "electron"; import { getElementContext } from "react-grab/primitives"; -import type { PickedElementPayload, PickedElementStackFrame } from "@t3tools/contracts"; - -import { computeLabelPosition } from "./preview-pick-label-position.ts"; +import type { + PickedElementPayload, + PickedElementStackFrame, + PreviewAnnotationPayload, + PreviewAnnotationPoint, + PreviewAnnotationRect, + PreviewAnnotationRegionTarget, + PreviewAnnotationStrokeTarget, + PreviewAnnotationStyleChange, +} from "@t3tools/contracts"; const START_PICK_CHANNEL = "preview:start-pick"; const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; - -const HIGHLIGHT_COLOR = "rgba(37, 99, 235, 0.9)"; // blue-600 -const HIGHLIGHT_FILL = "rgba(37, 99, 235, 0.16)"; -const LABEL_BG = "rgba(37, 99, 235, 0.96)"; +const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; +const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; const Z_INDEX_OVERLAY = 2147483646; +const ACCENT = "#7c3aed"; +const ACCENT_FILL = "rgba(124,58,237,0.12)"; +const BLUE = "#2563eb"; +const MAX_MARQUEE_ELEMENTS = 20; +const CONTENT_LAYER_Z_INDEX = 1; +const CHROME_LAYER_Z_INDEX = 10; -interface PickSession { - readonly overlay: HTMLDivElement; - readonly outline: HTMLDivElement; - readonly label: HTMLDivElement; - /** - * Tear down listeners + DOM WITHOUT notifying main. Used when main itself - * initiated the cancel (CANCEL_PICK_CHANNEL) or when a follow-up startPick - * supersedes us — main is already waiting on a fresh listener and would - * otherwise resolve it with the stale `null` we'd send. - */ - readonly teardownSilent: () => void; -} - -let activeSession: PickSession | null = null; +type AnnotationTool = "select" | "marquee" | "draw" | "erase"; -function endActiveSession(): void { - if (!activeSession) return; - const session = activeSession; - activeSession = null; - // Supersession from a new startPick is a main-initiated transition: tear - // down silently so the new pick's main-side listener doesn't receive a - // ghost `null` from the old session. - session.teardownSilent(); +interface SelectedElement { + id: string; + element: Element; + outline: HTMLDivElement; + label: HTMLDivElement; + baselineStyles: Map; } -interface OverlayHandles { - readonly overlay: HTMLDivElement; - readonly outline: HTMLDivElement; - readonly label: HTMLDivElement; - readonly destroyDom: () => void; +interface AnnotationSession { + teardown: (notifyMain: boolean) => void; } -function createOverlay(): OverlayHandles { - const overlay = document.createElement("div"); - overlay.setAttribute("data-t3code-pick-overlay", ""); - overlay.style.cssText = [ - "position:fixed", - "inset:0", - "z-index:" + String(Z_INDEX_OVERLAY), - "pointer-events:none", - "cursor:crosshair", - // Some apps register `pointer-events: auto !important` on body — using a - // dedicated overlay element ensures we don't fight with that. - ].join(";"); +let activeSession: AnnotationSession | null = null; +let idSequence = 0; - const outline = document.createElement("div"); - outline.setAttribute("data-t3code-pick-outline", ""); - outline.style.cssText = [ - "position:absolute", - "left:0", - "top:0", - "width:0", - "height:0", - "border:2px solid " + HIGHLIGHT_COLOR, - "background:" + HIGHLIGHT_FILL, - "border-radius:2px", - "box-shadow:0 0 0 1px rgba(255,255,255,0.6) inset", - "transition:none", - "display:none", - "pointer-events:none", - ].join(";"); +const nextId = (prefix: string): string => { + idSequence += 1; + return `${prefix}_${idSequence.toString(36)}`; +}; - const label = document.createElement("div"); - label.setAttribute("data-t3code-pick-label", ""); - // `top:0; left:0` so transform translate() positions are absolute viewport - // coordinates (we re-anchor the label every paint via a single transform). - // `max-width: calc(100vw - 8px)` + ellipsis prevents an overly long - // tag#id.class string from overflowing the viewport horizontally before we - // even get to the clamp logic. - label.style.cssText = [ - "position:absolute", - "left:0", - "top:0", - "padding:2px 6px", - "background:" + LABEL_BG, - "color:white", - "font:600 11px/1.2 ui-sans-serif,system-ui,-apple-system,sans-serif", - "border-radius:3px", - "pointer-events:none", - "white-space:nowrap", - "max-width:calc(100vw - 8px)", - "overflow:hidden", - "text-overflow:ellipsis", - "box-shadow:0 1px 2px rgba(0,0,0,0.25)", - "display:none", - ].join(";"); +const rectFromDomRect = (rect: DOMRect): PreviewAnnotationRect => ({ + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, +}); - overlay.appendChild(outline); - overlay.appendChild(label); - document.documentElement.appendChild(overlay); +const normalizeRect = ( + startX: number, + startY: number, + endX: number, + endY: number, +): PreviewAnnotationRect => ({ + x: Math.min(startX, endX), + y: Math.min(startY, endY), + width: Math.abs(endX - startX), + height: Math.abs(endY - startY), +}); - // Force the crosshair cursor across the whole page even though we set it - // on the overlay (because pointer-events: none means it's never the - // cursor target). We add a stylesheet rule and revert on cleanup. - const styleEl = document.createElement("style"); - 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; }`; - document.documentElement.appendChild(styleEl); - document.documentElement.setAttribute("data-t3code-picking", "1"); +const isUsableRect = (rect: PreviewAnnotationRect): boolean => rect.width >= 3 && rect.height >= 3; - const destroyDom = (): void => { - overlay.remove(); - styleEl.remove(); - document.documentElement.removeAttribute("data-t3code-picking"); +function unionRects(rects: ReadonlyArray): PreviewAnnotationRect | null { + if (rects.length === 0) return null; + const left = Math.min(...rects.map((rect) => rect.x)); + const top = Math.min(...rects.map((rect) => rect.y)); + const right = Math.max(...rects.map((rect) => rect.x + rect.width)); + const bottom = Math.max(...rects.map((rect) => rect.y + rect.height)); + const padding = 20; + const x = Math.max(0, left - padding); + const y = Math.max(0, top - padding); + const maxWidth = Math.max(1, window.innerWidth - x); + const maxHeight = Math.max(1, window.innerHeight - y); + return { + x, + y, + width: Math.min(maxWidth, right - left + padding * 2), + height: Math.min(maxHeight, bottom - top + padding * 2), }; +} - return { overlay, outline, label, destroyDom }; +function isAnnotationNode(element: Element): boolean { + return element instanceof Element && element.closest(`[${OVERLAY_ATTRIBUTE}]`) !== null; } -/** - * Resolve the element under the cursor while ignoring our own overlay. - * We render at z-index 2147483646 with `pointer-events: none`, which means - * `elementFromPoint` already skips the overlay — but we double-guard against - * pages that mutate `pointer-events` via MutationObservers. - */ function pickFromPoint(clientX: number, clientY: number): Element | null { - const candidates = document.elementsFromPoint(clientX, clientY); - for (const candidate of candidates) { + for (const candidate of document.elementsFromPoint(clientX, clientY)) { if (!(candidate instanceof Element)) continue; - if (candidate.hasAttribute("data-t3code-pick-overlay")) continue; - if (candidate.hasAttribute("data-t3code-pick-outline")) continue; - if (candidate.hasAttribute("data-t3code-pick-label")) continue; - if (candidate === document.documentElement) continue; - if (candidate === document.body) continue; + if (isAnnotationNode(candidate)) continue; + if (candidate === document.documentElement || candidate === document.body) continue; return candidate; } return null; @@ -178,94 +104,74 @@ function pickFromPoint(clientX: number, clientY: number): Element | null { function describeRawElement(element: Element): string { const tag = element.tagName.toLowerCase(); const id = element.id ? `#${element.id}` : ""; - const className = + const classes = element instanceof HTMLElement && typeof element.className === "string" ? element.className .trim() .split(/\s+/) - .filter((token) => token.length > 0) + .filter(Boolean) .slice(0, 2) - .map((token) => `.${token}`) + .map((name) => `.${name}`) .join("") : ""; - return `${tag}${id}${className}`; + return `${tag}${id}${classes}`; } -/** - * Per-session cache of the resolved React component name for elements we've - * already inspected. Stores `null` when react-grab couldn't find a component - * (raw HTML / unmounted) so repeat hovers don't re-pay the async lookup. - */ -const componentNameCache = new WeakMap(); -const componentNameInFlight = new WeakSet(); +function createBox(color: string, fill: string): HTMLDivElement { + const node = document.createElement("div"); + node.setAttribute(OVERLAY_ATTRIBUTE, ""); + node.style.cssText = [ + "position:fixed", + "pointer-events:none", + `border:2px solid ${color}`, + `background:${fill}`, + "border-radius:3px", + "box-sizing:border-box", + "display:none", + `z-index:${CONTENT_LAYER_Z_INDEX}`, + ].join(";"); + return node; +} + +function positionBox(node: HTMLElement, rect: PreviewAnnotationRect): void { + node.style.display = "block"; + node.style.transform = `translate(${rect.x}px, ${rect.y}px)`; + node.style.width = `${rect.width}px`; + node.style.height = `${rect.height}px`; +} -function describeElement(element: Element): string { - const cached = componentNameCache.get(element); - if (cached) return `<${cached}> ${describeRawElement(element)}`; - return describeRawElement(element); +function createLabel(): HTMLDivElement { + const label = document.createElement("div"); + label.setAttribute(OVERLAY_ATTRIBUTE, ""); + label.style.cssText = [ + "position:fixed", + "pointer-events:none", + `background:${ACCENT}`, + "color:white", + "font:600 11px/1.2 ui-sans-serif,system-ui,-apple-system,sans-serif", + "padding:2px 6px", + "border-radius:4px", + "white-space:nowrap", + "max-width:280px", + "overflow:hidden", + "text-overflow:ellipsis", + "box-shadow:0 1px 3px rgba(0,0,0,.3)", + `z-index:${CONTENT_LAYER_Z_INDEX}`, + ].join(";"); + return label; } -function paintOutline(handles: OverlayHandles, element: Element): void { - const rect = element.getBoundingClientRect(); - if (rect.width === 0 && rect.height === 0) { - handles.outline.style.display = "none"; - handles.label.style.display = "none"; +function updateSelectedVisual(target: SelectedElement): void { + if (!target.element.isConnected) { + target.outline.style.display = "none"; + target.label.style.display = "none"; return; } - handles.outline.style.display = "block"; - handles.outline.style.transform = `translate(${rect.left}px, ${rect.top}px)`; - handles.outline.style.width = `${rect.width}px`; - handles.outline.style.height = `${rect.height}px`; - - // Two-pass label paint: first apply the new text and force-block display - // so we can measure the rendered size, then clamp to the viewport and - // flip below the element when there isn't room above. - const text = describeElement(element); - if (handles.label.textContent !== text) { - handles.label.textContent = text; - } - handles.label.style.display = "block"; - - const labelRect = handles.label.getBoundingClientRect(); - const { x, y } = computeLabelPosition({ - targetLeft: rect.left, - targetTop: rect.top, - targetBottom: rect.bottom, - labelWidth: labelRect.width, - labelHeight: labelRect.height, - viewportWidth: document.documentElement.clientWidth || window.innerWidth || labelRect.width, - viewportHeight: document.documentElement.clientHeight || window.innerHeight || labelRect.height, - }); - handles.label.style.transform = `translate(${x}px, ${y}px)`; -} - -/** - * Kick off (at most once per element) an async react-grab lookup for the - * component name. When the answer arrives, repaint the label iff the element - * is still under the cursor — otherwise the user has moved on and a stale - * paint would clobber the next element's label. - */ -function ensureComponentName(element: Element, onResolve: (resolvedFor: Element) => void): void { - if (componentNameCache.has(element)) return; - if (componentNameInFlight.has(element)) return; - componentNameInFlight.add(element); - void getElementContext(element) - .then((context) => { - const trimmed = context.componentName?.trim(); - componentNameCache.set(element, trimmed && trimmed.length > 0 ? trimmed : null); - }) - .catch(() => { - componentNameCache.set(element, null); - }) - .finally(() => { - componentNameInFlight.delete(element); - onResolve(element); - }); -} - -function clearOutline(handles: OverlayHandles): void { - handles.outline.style.display = "none"; - handles.label.style.display = "none"; + const rect = target.element.getBoundingClientRect(); + positionBox(target.outline, rectFromDomRect(rect)); + target.label.textContent = describeRawElement(target.element); + target.label.style.display = "block"; + target.label.style.transform = `translate(${Math.max(4, rect.left)}px, ${Math.max(4, rect.top - 22)}px)`; } function toStackFrame(frame: { @@ -285,10 +191,10 @@ function toStackFrame(frame: { async function captureElement(element: Element): Promise { try { const context = await getElementContext(element); - const stack = (context.stack ?? []).map((frame) => toStackFrame(frame)); + const stack = (context.stack ?? []).map(toStackFrame); return { pageUrl: location.href, - pageTitle: document.title?.trim() ? document.title.trim() : null, + pageTitle: document.title?.trim() || null, tagName: element.tagName.toLowerCase(), selector: context.selector, htmlPreview: context.htmlPreview ?? "", @@ -303,118 +209,909 @@ async function captureElement(element: Element): Promise): string { + if (points.length === 0) return ""; + if (points.length === 1) return `M ${points[0]!.x} ${points[0]!.y} l 0.01 0.01`; + let path = `M ${points[0]!.x} ${points[0]!.y}`; + for (let index = 1; index < points.length - 1; index += 1) { + const current = points[index]!; + const next = points[index + 1]!; + path += ` Q ${current.x} ${current.y} ${(current.x + next.x) / 2} ${(current.y + next.y) / 2}`; + } + const last = points[points.length - 1]!; + path += ` L ${last.x} ${last.y}`; + return path; +} + +function strokeBounds( + points: ReadonlyArray, + width: number, +): PreviewAnnotationRect { + const xs = points.map((point) => point.x); + const ys = points.map((point) => point.y); + const padding = width + 3; + const left = Math.min(...xs) - padding; + const top = Math.min(...ys) - padding; + const right = Math.max(...xs) + padding; + const bottom = Math.max(...ys) + padding; + return { x: left, y: top, width: right - left, height: bottom - top }; +} + +function startAnnotation(): void { + activeSession?.teardown(false); + let finished = false; + const root = document.createElement("div"); + root.setAttribute(OVERLAY_ATTRIBUTE, ""); + 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`; + const cursorStyle = document.createElement("style"); + cursorStyle.setAttribute(OVERLAY_ATTRIBUTE, ""); + 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; }`; + root.appendChild(cursorStyle); + + const hoverOutline = createBox(BLUE, "rgba(37,99,235,.10)"); + const marqueeBox = createBox(ACCENT, ACCENT_FILL); + root.append(hoverOutline, marqueeBox); + + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute(OVERLAY_ATTRIBUTE, ""); + svg.setAttribute("width", "100%"); + svg.setAttribute("height", "100%"); + svg.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`); + svg.style.cssText = "position:fixed;inset:0;overflow:visible;pointer-events:none"; + svg.style.zIndex = String(CONTENT_LAYER_Z_INDEX); + root.appendChild(svg); + + const toolbar = document.createElement("div"); + toolbar.setAttribute(OVERLAY_ATTRIBUTE, ""); + 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)`; + root.appendChild(toolbar); + + const editor = document.createElement("div"); + editor.setAttribute(OVERLAY_ATTRIBUTE, ""); + 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)`; + root.appendChild(editor); + + const editorHeader = document.createElement("div"); + editorHeader.style.cssText = + "display:flex;align-items:center;gap:7px;padding:7px 8px;border-bottom:1px solid rgba(0,0,0,.06);cursor:grab;user-select:none"; + const adjustIcon = document.createElement("div"); + adjustIcon.innerHTML = + ''; + adjustIcon.style.cssText = + "display:grid;place-items:center;width:24px;height:24px;border-radius:999px;background:#f4f4f5;color:#52525b;font:700 14px/1 ui-sans-serif"; + editorHeader.appendChild(adjustIcon); + + const status = document.createElement("div"); + status.style.cssText = + "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"; + editorHeader.appendChild(status); + const dragHandle = document.createElement("div"); + dragHandle.textContent = "⠿"; + dragHandle.title = "Drag annotation editor"; + dragHandle.style.cssText = "color:#a1a1aa;font:700 18px/1 ui-sans-serif"; + editorHeader.appendChild(dragHandle); + editor.appendChild(editorHeader); + + const comment = document.createElement("textarea"); + comment.placeholder = "Describe the change…"; + comment.rows = 2; + comment.style.cssText = + "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"; + editor.appendChild(comment); + + const stylePanel = document.createElement("div"); + stylePanel.style.cssText = + "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)"; + editor.appendChild(stylePanel); + + const footer = document.createElement("div"); + footer.style.cssText = + "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)"; + const adjust = createButton("Adjust", "Show style controls for selected elements"); + adjust.style.cssText += + ";display:none;padding:5px 7px;color:#52525b;background:#f4f4f5;font-size:11px"; + const submit = createButton("Attach", "Attach annotation and screenshot"); + submit.style.cssText += `;background:${ACCENT};color:white;padding:6px 9px;font-size:11px`; + footer.append(adjust, submit); + editor.appendChild(footer); + + const selected = new Map(); + const regions: PreviewAnnotationRegionTarget[] = []; + const strokes: PreviewAnnotationStrokeTarget[] = []; + const styleChanges = new Map(); + const toolButtons = new Map(); + let tool: AnnotationTool = "select"; + let dragStart: PreviewAnnotationPoint | null = null; + let activeStroke: { target: PreviewAnnotationStrokeTarget; path: SVGPathElement } | null = null; + let pendingCapture = false; + let stylesExpanded = false; + let editorWasShown = false; + let editorPosition: { left: number; top: number } | null = null; + let editorDrag: { pointerId: number; offsetX: number; offsetY: number } | null = null; + + const updateStatus = (): void => { + const parts = [ + selected.size > 0 ? `${selected.size} element${selected.size === 1 ? "" : "s"}` : "", + regions.length > 0 ? `${regions.length} region${regions.length === 1 ? "" : "s"}` : "", + strokes.length > 0 ? `${strokes.length} drawing${strokes.length === 1 ? "" : "s"}` : "", + ].filter(Boolean); + const hasTargets = parts.length > 0; + status.textContent = parts.join(" · "); + editor.style.display = hasTargets ? "flex" : "none"; + submit.disabled = !hasTargets; + submit.style.opacity = hasTargets ? "1" : "0.45"; + adjust.style.display = selected.size > 0 ? "block" : "none"; + if (selected.size === 0 && stylesExpanded) { + stylesExpanded = false; + stylePanel.style.display = "none"; + adjust.textContent = "Adjust"; + } + if (hasTargets && !editorWasShown) { + editorWasShown = true; + window.setTimeout(() => comment.focus({ preventScroll: true }), 0); + } + }; + + const refreshToolButtons = (): void => { + for (const [candidate, button] of toolButtons) { + const active = candidate === tool; + button.style.background = active ? ACCENT_FILL : "transparent"; + button.style.color = active ? ACCENT : "#27272a"; + } + if (tool !== "select") hoverOutline.style.display = "none"; + if (tool !== "marquee") marqueeBox.style.display = "none"; + document.documentElement.setAttribute("data-t3code-annotation-tool", tool); + }; + + const removeSelected = (target: SelectedElement): void => { + if (target.element instanceof HTMLElement || target.element instanceof SVGElement) { + for (const [property, baseline] of target.baselineStyles) { + if (baseline) target.element.style.setProperty(property, baseline); + else target.element.style.removeProperty(property); + } + } + selected.delete(target.element); + target.outline.remove(); + target.label.remove(); + for (const [key, change] of styleChanges) { + if (change.targetId === target.id) styleChanges.delete(key); + } + updateStatus(); + }; + + const addSelected = (element: Element): void => { + if (selected.has(element)) return; + const target: SelectedElement = { + id: nextId("element"), + element, + outline: createBox(ACCENT, ACCENT_FILL), + label: createLabel(), + baselineStyles: new Map(), + }; + selected.set(element, target); + root.append(target.outline, target.label); + updateSelectedVisual(target); + updateStatus(); + if (stylesExpanded) syncStyleControls(); + }; + + const toggleSelected = (element: Element, additive: boolean): void => { + const existing = selected.get(element); + if (existing) { + removeSelected(existing); + return; + } + if (!additive) { + for (const target of Array.from(selected.values())) removeSelected(target); + } + addSelected(element); + }; + + const setStyleForSelected = (property: string, value: string): void => { + for (const target of selected.values()) { + if (!(target.element instanceof HTMLElement || target.element instanceof SVGElement)) + continue; + if (!target.baselineStyles.has(property)) { + target.baselineStyles.set(property, target.element.style.getPropertyValue(property)); + } + const key = `${target.id}:${property}`; + const previousValue = + styleChanges.get(key)?.previousValue ?? + getComputedStyle(target.element).getPropertyValue(property).trim(); + target.element.style.setProperty(property, value, "important"); + styleChanges.set(key, { + targetId: target.id, + selector: null, + property, + previousValue, + value, + }); + updateSelectedVisual(target); + } + }; + + const textSection = createStyleSection(); + const colorsSection = createStyleSection(); + const bordersSection = createStyleSection(); + const sizingSection = createStyleSection(); + stylePanel.append(textSection, colorsSection, bordersSection, sizingSection); + + const fontFamily = document.createElement("select"); + for (const value of ["inherit", "system-ui", "sans-serif", "serif", "monospace"]) { + const option = document.createElement("option"); + option.value = value; + option.textContent = value; + fontFamily.appendChild(option); + } + fontFamily.addEventListener("change", () => setStyleForSelected("font-family", fontFamily.value)); + textSection.appendChild(createField("Font", fontFamily)); + + const fontSize = createUnitInput("px", "16"); + fontSize.min = "1"; + fontSize.max = "300"; + fontSize.addEventListener("input", () => { + if (fontSize.value) setStyleForSelected("font-size", `${fontSize.value}px`); + }); + textSection.appendChild(createField("Font size", fontSize)); + + const fontWeight = document.createElement("select"); + for (const value of ["300", "400", "500", "600", "700", "800", "900"]) { + const option = document.createElement("option"); + option.value = value; + option.textContent = value; + fontWeight.appendChild(option); + } + fontWeight.addEventListener("change", () => setStyleForSelected("font-weight", fontWeight.value)); + textSection.appendChild(createField("Font weight", fontWeight)); + + const lineHeight = document.createElement("input"); + lineHeight.type = "text"; + lineHeight.placeholder = "normal / 1.4"; + lineHeight.addEventListener("change", () => { + if (lineHeight.value.trim()) setStyleForSelected("line-height", lineHeight.value.trim()); + }); + textSection.appendChild(createField("Line height", lineHeight)); + + const createColorRow = ( + labelText: string, + property: string, + section: HTMLElement, + ): { row: HTMLLabelElement; color: HTMLInputElement; text: HTMLInputElement } => { + const row = document.createElement("label"); + row.style.cssText = + "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"; + const label = document.createElement("span"); + label.textContent = labelText; + const control = document.createElement("div"); + control.style.cssText = + "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)"; + const color = document.createElement("input"); + color.type = "color"; + color.setAttribute("aria-label", labelText); + color.style.cssText = + "width:20px;height:20px;padding:0;border:0;border-radius:5px;overflow:hidden;background:transparent;cursor:pointer"; + const text = document.createElement("input"); + text.type = "text"; + text.setAttribute("aria-label", `${labelText} value`); + text.style.cssText = + "min-width:0;width:100%;border:0;background:transparent;color:#52525b;font:500 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;outline:none"; + color.addEventListener("input", () => { + text.value = color.value; + setStyleForSelected(property, color.value); + }); + text.addEventListener("change", () => { + const value = text.value.trim(); + if (!value) return; + setStyleForSelected(property, value); + if (/^#[0-9a-f]{6}$/i.test(value)) color.value = value; + }); + control.append(color, text); + row.append(label, control); + section.appendChild(row); + return { row, color, text }; + }; + + const textColor = createColorRow("Text color", "color", colorsSection); + const backgroundColor = createColorRow("Background", "background-color", colorsSection); + + const opacity = document.createElement("input"); + opacity.type = "range"; + opacity.min = "0"; + opacity.max = "1"; + opacity.step = "0.05"; + opacity.value = "1"; + opacity.style.accentColor = ACCENT; + opacity.addEventListener("input", () => setStyleForSelected("opacity", opacity.value)); + colorsSection.appendChild(createField("Opacity", opacity)); + + const radius = createUnitInput("px", "0"); + radius.min = "0"; + radius.max = "300"; + radius.addEventListener("input", () => { + if (radius.value) setStyleForSelected("border-radius", `${radius.value}px`); + }); + bordersSection.appendChild(createField("Radius", radius)); + + const borderColor = createColorRow("Border color", "border-color", bordersSection); + + const borderWidth = createUnitInput("px", "0"); + borderWidth.min = "0"; + borderWidth.max = "100"; + borderWidth.addEventListener("input", () => { + if (borderWidth.value) { + setStyleForSelected("border-style", "solid"); + setStyleForSelected("border-width", `${borderWidth.value}px`); + } + }); + bordersSection.appendChild(createField("Border width", borderWidth)); + + const dimensions = document.createElement("div"); + dimensions.style.cssText = + "display:grid;grid-template-columns:82px minmax(0,1fr);gap:8px;align-items:center"; + const dimensionLabel = document.createElement("div"); + dimensionLabel.style.cssText = + "display:grid;gap:9px;font:500 11px/1.2 ui-sans-serif,system-ui;color:#52525b"; + dimensionLabel.innerHTML = "WidthHeight"; + const dimensionControls = document.createElement("div"); + dimensionControls.style.cssText = "position:relative;display:grid;gap:3px;padding-left:22px"; + const widthInput = createUnitInput("px", "auto"); + const heightInput = createUnitInput("px", "auto"); + styleControl(widthInput); + styleControl(heightInput); + const aspectLock = createButton("", "Lock aspect ratio"); + aspectLock.setAttribute("aria-pressed", "true"); + aspectLock.style.cssText += + ";position:absolute;left:0;top:50%;transform:translateY(-50%);width:18px;height:38px;padding:0;border-radius:6px;background:#ede9fe;color:#6d28d9"; + dimensionControls.append( + createUnitControl(widthInput), + createUnitControl(heightInput), + aspectLock, + ); + dimensions.append(dimensionLabel, dimensionControls); + sizingSection.appendChild(dimensions); + + let aspectLocked = true; + let aspectRatio = 1; + const refreshAspectButton = (): void => { + aspectLock.innerHTML = aspectLocked + ? '' + : ''; + aspectLock.setAttribute("aria-pressed", String(aspectLocked)); + aspectLock.style.background = aspectLocked ? "#ede9fe" : "#f4f4f5"; + aspectLock.style.color = aspectLocked ? "#6d28d9" : "#71717a"; + }; + aspectLock.addEventListener("click", () => { + aspectLocked = !aspectLocked; + refreshAspectButton(); + }); + widthInput.addEventListener("input", () => { + const width = Number(widthInput.value); + if (!Number.isFinite(width) || width <= 0) return; + setStyleForSelected("width", `${width}px`); + if (aspectLocked && aspectRatio > 0) { + const height = Math.max(1, Math.round(width / aspectRatio)); + heightInput.value = String(height); + setStyleForSelected("height", `${height}px`); + } + }); + heightInput.addEventListener("input", () => { + const height = Number(heightInput.value); + if (!Number.isFinite(height) || height <= 0) return; + setStyleForSelected("height", `${height}px`); + if (aspectLocked && aspectRatio > 0) { + const width = Math.max(1, Math.round(height * aspectRatio)); + widthInput.value = String(width); + setStyleForSelected("width", `${width}px`); + } + }); + refreshAspectButton(); + + const addSpacingField = ( + label: string, + property: string, + placeholder: string, + ): HTMLInputElement => { + const input = document.createElement("input"); + input.type = "text"; + input.placeholder = placeholder; + input.addEventListener("change", () => { + if (input.value.trim()) setStyleForSelected(property, input.value.trim()); + }); + sizingSection.appendChild(createField(label, input)); + return input; + }; + const padding = addSpacingField("Padding", "padding", "0 0 0 0"); + const margin = addSpacingField("Margin", "margin", "0 0 0 0"); + const gap = addSpacingField("Gap", "gap", "0px"); + + const syncStyleControls = (): void => { + const first = selected.values().next().value as SelectedElement | undefined; + if (!first) return; + const computed = getComputedStyle(first.element); + const rect = first.element.getBoundingClientRect(); + aspectRatio = rect.height > 0 ? rect.width / rect.height : 1; + widthInput.value = String(Math.round(rect.width)); + heightInput.value = String(Math.round(rect.height)); + fontSize.value = String(Math.round(Number.parseFloat(computed.fontSize) || 16)); + fontWeight.value = computed.fontWeight.match(/^[0-9]+$/) ? computed.fontWeight : "400"; + lineHeight.value = computed.lineHeight; + fontFamily.value = Array.from(fontFamily.options).some( + (option) => option.value === computed.fontFamily, + ) + ? computed.fontFamily + : "inherit"; + textColor.text.value = computed.color; + backgroundColor.text.value = computed.backgroundColor; + borderColor.text.value = computed.borderColor; + opacity.value = computed.opacity; + radius.value = String(Math.round(Number.parseFloat(computed.borderRadius) || 0)); + borderWidth.value = String(Math.round(Number.parseFloat(computed.borderWidth) || 0)); + padding.value = computed.padding; + margin.value = computed.margin; + gap.value = computed.gap === "normal" ? "0px" : computed.gap; + }; + + const tools: ReadonlyArray<[AnnotationTool, string, string]> = [ + ["select", "Select", "Select elements (V)"], + ["marquee", "Region", "Draw a region or marquee-select elements (R)"], + ["draw", "Draw", "Draw freehand (D)"], + ["erase", "Erase", "Remove an annotation target (E)"], + ]; + for (const [candidate, label, title] of tools) { + const button = createButton(label, title); + button.style.cssText += ";padding:5px 7px;font-size:11px;border-radius:6px"; + button.addEventListener("click", () => { + tool = candidate; + refreshToolButtons(); + }); + toolButtons.set(candidate, button); + toolbar.appendChild(button); } - const handles = createOverlay(); - let lastTarget: Element | null = null; - let resolved = false; - - // Tear down listeners + DOM. Does NOT notify main. `notifyMain` controls - // whether we additionally bubble a `null` ELEMENT_PICKED back so main can - // resolve the in-flight pick promise (only true for USER-initiated cancels; - // main already knows about its own cancellations). - const teardown = (notifyMain: boolean, payload: PickedElementPayload | null): void => { - if (resolved) return; - resolved = true; - window.removeEventListener("mousemove", onMove, { capture: true } as EventListenerOptions); - window.removeEventListener("click", onClick, { capture: true } as EventListenerOptions); - window.removeEventListener("keydown", onKey, { capture: true } as EventListenerOptions); - window.removeEventListener("scroll", onScrollOrResize, { - capture: true, - } as EventListenerOptions); - window.removeEventListener("resize", onScrollOrResize); - window.removeEventListener("beforeunload", onBeforeUnload); - ipcRenderer.off(CANCEL_PICK_CHANNEL, onMainCancel); - handles.destroyDom(); - if (notifyMain) { - // `ipcRenderer.send` (NOT `sendToHost`) reaches main, where the - // PreviewViewManager's per-WebContents `wc.ipc.on(...)` listener - // resolves the in-flight pick promise. - ipcRenderer.send(ELEMENT_PICKED_CHANNEL, payload); - } - }; - - // Re-paint when an in-flight component-name lookup resolves, but only if - // the same element is still under the cursor — otherwise the user moved on - // and we'd clobber the next element's label. - const onComponentNameResolved = (resolvedFor: Element): void => { - if (lastTarget !== resolvedFor) return; - paintOutline(handles, resolvedFor); - }; - - const onMove = (event: MouseEvent): void => { - const target = pickFromPoint(event.clientX, event.clientY); - if (target === lastTarget) return; - lastTarget = target; - if (target) { - paintOutline(handles, target); - ensureComponentName(target, onComponentNameResolved); - } else { - clearOutline(handles); - } - }; - - const onScrollOrResize = (): void => { - if (lastTarget) paintOutline(handles, lastTarget); + adjust.addEventListener("click", () => { + if (selected.size === 0) return; + stylesExpanded = !stylesExpanded; + stylePanel.style.display = stylesExpanded ? "grid" : "none"; + adjust.textContent = stylesExpanded ? "Hide styles" : "Adjust"; + if (stylesExpanded) syncStyleControls(); + }); + + const clampEditorPosition = (left: number, top: number): { left: number; top: number } => { + const margin = 8; + const rect = editor.getBoundingClientRect(); + return { + left: Math.min( + Math.max(margin, left), + Math.max(margin, window.innerWidth - rect.width - margin), + ), + top: Math.min( + Math.max(margin, top), + Math.max(margin, window.innerHeight - rect.height - margin), + ), + }; + }; + + const applyEditorPosition = (position: { left: number; top: number }): void => { + editorPosition = clampEditorPosition(position.left, position.top); + editor.style.left = `${editorPosition.left}px`; + editor.style.top = `${editorPosition.top}px`; + editor.style.right = "auto"; + editor.style.bottom = "auto"; + }; + + const onEditorPointerDown = (event: PointerEvent): void => { + if (event.button !== 0) return; + const rect = editor.getBoundingClientRect(); + editorDrag = { + pointerId: event.pointerId, + offsetX: event.clientX - rect.left, + offsetY: event.clientY - rect.top, + }; + editorHeader.setPointerCapture(event.pointerId); + editorHeader.style.cursor = "grabbing"; + event.preventDefault(); + event.stopPropagation(); + }; + + const onEditorPointerMove = (event: PointerEvent): void => { + if (!editorDrag || editorDrag.pointerId !== event.pointerId) return; + applyEditorPosition({ + left: event.clientX - editorDrag.offsetX, + top: event.clientY - editorDrag.offsetY, + }); + event.preventDefault(); + event.stopPropagation(); + }; + + const onEditorPointerUp = (event: PointerEvent): void => { + if (!editorDrag || editorDrag.pointerId !== event.pointerId) return; + editorDrag = null; + editorHeader.style.cursor = "grab"; + if (editorHeader.hasPointerCapture(event.pointerId)) + editorHeader.releasePointerCapture(event.pointerId); + event.preventDefault(); + event.stopPropagation(); + }; + editorHeader.addEventListener("pointerdown", onEditorPointerDown); + editorHeader.addEventListener("pointermove", onEditorPointerMove); + editorHeader.addEventListener("pointerup", onEditorPointerUp); + editorHeader.addEventListener("pointercancel", onEditorPointerUp); + + const repaint = (): void => { + for (const target of selected.values()) updateSelectedVisual(target); + if (editorPosition) applyEditorPosition(editorPosition); + }; + + const removeTargetAtPoint = (x: number, y: number): boolean => { + for (const target of Array.from(selected.values()).toReversed()) { + const rect = target.element.getBoundingClientRect(); + if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { + removeSelected(target); + return true; + } + } + const regionIndex = regions.findIndex( + (region) => + x >= region.rect.x && + x <= region.rect.x + region.rect.width && + y >= region.rect.y && + y <= region.rect.y + region.rect.height, + ); + if (regionIndex >= 0) { + const [removed] = regions.splice(regionIndex, 1); + root.querySelector(`[data-region-id="${removed?.id}"]`)?.remove(); + updateStatus(); + return true; + } + const strokeIndex = strokes.findIndex( + (stroke) => + x >= stroke.bounds.x && + x <= stroke.bounds.x + stroke.bounds.width && + y >= stroke.bounds.y && + y <= stroke.bounds.y + stroke.bounds.height, + ); + if (strokeIndex >= 0) { + const [removed] = strokes.splice(strokeIndex, 1); + svg.querySelector(`[data-stroke-id="${removed?.id}"]`)?.remove(); + updateStatus(); + return true; + } + return false; + }; + + const selectElementsInRect = (rect: PreviewAnnotationRect): number => { + const candidates = Array.from(document.querySelectorAll("body *")) + .filter((element) => !isAnnotationNode(element)) + .map((element) => ({ element, rect: element.getBoundingClientRect() })) + .filter(({ rect: candidate }) => { + if (candidate.width < 2 || candidate.height < 2) return false; + return !( + candidate.right < rect.x || + candidate.left > rect.x + rect.width || + candidate.bottom < rect.y || + candidate.top > rect.y + rect.height + ); + }) + .filter(({ element, rect: candidate }) => { + const centerX = candidate.left + candidate.width / 2; + const centerY = candidate.top + candidate.height / 2; + return ( + centerX >= rect.x && + centerX <= rect.x + rect.width && + centerY >= rect.y && + centerY <= rect.y + rect.height && + (element.children.length === 0 || + element instanceof HTMLButtonElement || + element instanceof HTMLAnchorElement || + element.getAttribute("role") === "button") + ); + }) + .sort( + (left, right) => left.rect.width * left.rect.height - right.rect.width * right.rect.height, + ) + .slice(0, MAX_MARQUEE_ELEMENTS); + for (const candidate of candidates) addSelected(candidate.element); + return candidates.length; + }; + + const clearHoverOutline = (): void => { + hoverOutline.style.display = "none"; + }; + + const onPointerMove = (event: PointerEvent): void => { + if (isAnnotationNode(event.target as Element)) { + clearHoverOutline(); + return; + } + if (tool === "select" && dragStart === null) { + const target = pickFromPoint(event.clientX, event.clientY); + if (target) positionBox(hoverOutline, rectFromDomRect(target.getBoundingClientRect())); + else clearHoverOutline(); + return; + } + clearHoverOutline(); + if (tool === "marquee" && dragStart) { + positionBox( + marqueeBox, + normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY), + ); + return; + } + if (tool === "draw" && activeStroke) { + activeStroke.target.points = [ + ...activeStroke.target.points, + { x: event.clientX, y: event.clientY }, + ]; + activeStroke.target.bounds = strokeBounds( + activeStroke.target.points, + activeStroke.target.width, + ); + activeStroke.path.setAttribute("d", pathFromPoints(activeStroke.target.points)); + } + }; + + const onPointerDown = (event: PointerEvent): void => { + if (event.button !== 0 || isAnnotationNode(event.target as Element)) return; + event.preventDefault(); + event.stopPropagation(); + if (tool === "select") { + const target = pickFromPoint(event.clientX, event.clientY); + if (target) toggleSelected(target, event.shiftKey); + return; + } + if (tool === "erase") { + removeTargetAtPoint(event.clientX, event.clientY); + return; + } + dragStart = { x: event.clientX, y: event.clientY }; + if (tool === "draw") { + const stroke: PreviewAnnotationStrokeTarget = { + id: nextId("stroke"), + color: ACCENT, + width: 4, + points: [dragStart], + bounds: { x: dragStart.x, y: dragStart.y, width: 1, height: 1 }, + }; + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute(OVERLAY_ATTRIBUTE, ""); + path.setAttribute("data-stroke-id", stroke.id); + path.setAttribute("fill", "none"); + path.setAttribute("stroke", stroke.color); + path.setAttribute("stroke-width", String(stroke.width)); + path.setAttribute("stroke-linecap", "round"); + path.setAttribute("stroke-linejoin", "round"); + svg.appendChild(path); + activeStroke = { target: stroke, path }; + } + }; + + const onPointerUp = (event: PointerEvent): void => { + if (!dragStart) return; + event.preventDefault(); + event.stopPropagation(); + if (tool === "marquee") { + const rect = normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY); + marqueeBox.style.display = "none"; + if (isUsableRect(rect)) { + const found = selectElementsInRect(rect); + if (found === 0) { + const region: PreviewAnnotationRegionTarget = { id: nextId("region"), rect }; + regions.push(region); + const regionBox = createBox(ACCENT, "rgba(124,58,237,.06)"); + regionBox.setAttribute("data-region-id", region.id); + positionBox(regionBox, rect); + root.appendChild(regionBox); + } + } + } else if (tool === "draw" && activeStroke) { + if (activeStroke.target.points.length > 1) strokes.push(activeStroke.target); + else activeStroke.path.remove(); + activeStroke = null; + } + dragStart = null; + updateStatus(); }; const onClick = (event: MouseEvent): void => { + if (isAnnotationNode(event.target as Element)) return; event.preventDefault(); event.stopPropagation(); - const target = pickFromPoint(event.clientX, event.clientY); - if (!target) { - teardown(true, null); - return; + }; + + const onPointerOut = (event: PointerEvent): void => { + if (event.relatedTarget === null) clearHoverOutline(); + }; + + const onWindowBlur = (): void => { + clearHoverOutline(); + }; + + const restoreStyles = (): void => { + for (const target of selected.values()) { + if (!(target.element instanceof HTMLElement || target.element instanceof SVGElement)) + continue; + for (const [property, baseline] of target.baselineStyles) { + if (baseline) target.element.style.setProperty(property, baseline); + else target.element.style.removeProperty(property); + } } - void captureElement(target).then((payload) => teardown(true, payload)); }; - const onKey = (event: KeyboardEvent): void => { + const teardown = (notifyMain: boolean): void => { + if (finished) return; + finished = true; + restoreStyles(); + window.removeEventListener("pointermove", onPointerMove, true); + window.removeEventListener("pointerdown", onPointerDown, true); + window.removeEventListener("pointerup", onPointerUp, true); + window.removeEventListener("pointerout", onPointerOut, true); + window.removeEventListener("click", onClick, true); + window.removeEventListener("blur", onWindowBlur); + window.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("scroll", repaint, true); + window.removeEventListener("resize", repaint); + editorHeader.removeEventListener("pointerdown", onEditorPointerDown); + editorHeader.removeEventListener("pointermove", onEditorPointerMove); + editorHeader.removeEventListener("pointerup", onEditorPointerUp); + editorHeader.removeEventListener("pointercancel", onEditorPointerUp); + ipcRenderer.off(CANCEL_PICK_CHANNEL, onCancel); + ipcRenderer.off(ANNOTATION_CAPTURED_CHANNEL, onCaptured); + document.documentElement.removeAttribute("data-t3code-annotation-tool"); + root.remove(); + activeSession = null; + if (notifyMain) ipcRenderer.send(ELEMENT_PICKED_CHANNEL, null); + }; + + const onCancel = (): void => teardown(false); + const onCaptured = (): void => teardown(false); + const onKeyDown = (event: KeyboardEvent): void => { + if (isAnnotationNode(event.target as Element) && event.key !== "Escape") return; if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); - teardown(true, null); + teardown(true); + return; } + if (event.key === "v") tool = "select"; + else if (event.key === "r") tool = "marquee"; + else if (event.key === "d") tool = "draw"; + else if (event.key === "e") tool = "erase"; + else return; + refreshToolButtons(); }; - const onBeforeUnload = (): void => { - teardown(true, null); - }; - - // Cancellation initiated by main (CANCEL_PICK_CHANNEL). Tear down silently - // — main already knows it cancelled and is either done waiting or about to - // register a fresh listener for a new pick. If we sent `null` here, that - // fresh listener would receive it and resolve the new pick instantly (the - // C1 race we previously hit). - const onMainCancel = (): void => { - teardown(false, null); - }; + submit.addEventListener("click", () => { + if (pendingCapture || (selected.size === 0 && regions.length === 0 && strokes.length === 0)) + return; + pendingCapture = true; + submit.disabled = true; + submit.textContent = "Capturing…"; + void Promise.all( + Array.from(selected.values()).map(async (target) => { + const element = await captureElement(target.element); + if (!element) return null; + for (const change of styleChanges.values()) { + if (change.targetId === target.id) change.selector = element.selector; + } + return { + id: target.id, + element, + rect: rectFromDomRect(target.element.getBoundingClientRect()), + }; + }), + ).then((captured) => { + const elements = captured.filter((target) => target !== null); + const annotation: PreviewAnnotationPayload = { + id: nextId("annotation"), + pageUrl: location.href, + pageTitle: document.title?.trim() || null, + comment: comment.value.trim(), + elements, + regions: [...regions], + strokes: [...strokes], + styleChanges: Array.from(styleChanges.values()), + screenshot: null, + createdAt: new Date().toISOString(), + }; + editor.style.display = "none"; + toolbar.style.display = "none"; + hoverOutline.style.display = "none"; + const screenshotRect = unionRects([ + ...elements.map((target) => target.rect), + ...regions.map((region) => region.rect), + ...strokes.map((stroke) => stroke.bounds), + ]); + ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect); + }); + }); + comment.addEventListener("keydown", (event) => { + if (event.key !== "Enter" || !(event.metaKey || event.ctrlKey)) return; + event.preventDefault(); + submit.click(); + }); - // Capture-phase listeners on `window` to outrun page handlers that - // `stopPropagation()` early. `passive: false` because we need preventDefault. - window.addEventListener("mousemove", onMove, { capture: true, passive: true }); - window.addEventListener("click", onClick, { capture: true }); - window.addEventListener("keydown", onKey, { capture: true }); - window.addEventListener("scroll", onScrollOrResize, { capture: true, passive: true }); - window.addEventListener("resize", onScrollOrResize, { passive: true }); - window.addEventListener("beforeunload", onBeforeUnload); - ipcRenderer.on(CANCEL_PICK_CHANNEL, onMainCancel); - - // Hand a "silent teardown" to `activeSession` so that a follow-up - // `startPick()` can supersede us without echoing `null` back to main. - activeSession = { - overlay: handles.overlay, - outline: handles.outline, - label: handles.label, - teardownSilent: () => teardown(false, null), - }; + window.addEventListener("pointermove", onPointerMove, { capture: true, passive: false }); + window.addEventListener("pointerdown", onPointerDown, { capture: true, passive: false }); + window.addEventListener("pointerup", onPointerUp, { capture: true, passive: false }); + window.addEventListener("pointerout", onPointerOut, { capture: true, passive: true }); + window.addEventListener("click", onClick, { capture: true, passive: false }); + window.addEventListener("blur", onWindowBlur); + window.addEventListener("keydown", onKeyDown, { capture: true }); + window.addEventListener("scroll", repaint, { capture: true, passive: true }); + window.addEventListener("resize", repaint, { passive: true }); + ipcRenderer.on(CANCEL_PICK_CHANNEL, onCancel); + ipcRenderer.on(ANNOTATION_CAPTURED_CHANNEL, onCaptured); + document.documentElement.appendChild(root); + refreshToolButtons(); + updateStatus(); + activeSession = { teardown }; } -ipcRenderer.on(START_PICK_CHANNEL, () => { - startPick(); -}); +ipcRenderer.on(START_PICK_CHANNEL, () => startAnnotation()); +ipcRenderer.on(CANCEL_PICK_CHANNEL, () => activeSession?.teardown(false)); diff --git a/apps/desktop/src/preview-view-manager.ts b/apps/desktop/src/preview-view-manager.ts index a49695430cae33db937140843675c03611bc44a1..1f378e90cea5628bf82f7715ac3c0fb3937fd6ed 100644 --- a/apps/desktop/src/preview-view-manager.ts +++ b/apps/desktop/src/preview-view-manager.ts @@ -6,16 +6,17 @@ * elements live in the renderer; we only attach listeners and forward state * here). Single layer-scoped browser session partition. */ -import type { PickedElementPayload } from "@t3tools/contracts"; +import type { PreviewAnnotationPayload, PreviewAnnotationRect } from "@t3tools/contracts"; import { normalizePreviewUrl } from "@t3tools/shared/preview"; import { type BrowserWindow, type Session, session, webContents } from "electron"; -import { isPickedElementPayload } from "./picked-element-payload.ts"; +import { isPreviewAnnotationPayload } from "./picked-element-payload.ts"; const PREVIEW_PARTITION = "persist:t3code-preview"; const START_PICK_CHANNEL = "preview:start-pick"; const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; +const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; // Re-export the guest webview security posture from its dedicated module so // the constant is unit-testable in isolation. See @@ -53,6 +54,58 @@ const ZOOM_LEVELS: ReadonlyArray = [ const DEFAULT_ZOOM_FACTOR = 1.0; const ZOOM_EPSILON = 0.001; +const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => { + if (typeof value !== "object" || value === null) return null; + const rect = value as Record; + const x = rect["x"]; + const y = rect["y"]; + const width = rect["width"]; + const height = rect["height"]; + if ( + typeof x !== "number" || + !Number.isFinite(x) || + typeof y !== "number" || + !Number.isFinite(y) || + typeof width !== "number" || + !Number.isFinite(width) || + typeof height !== "number" || + !Number.isFinite(height) || + width <= 0 || + height <= 0 + ) { + return null; + } + return { + x: Math.max(0, Math.floor(x)), + y: Math.max(0, Math.floor(y)), + width: Math.max(1, Math.ceil(width)), + height: Math.max(1, Math.ceil(height)), + }; +}; + +const captureAnnotationScreenshot = async ( + wc: Electron.WebContents, + cropRect: PreviewAnnotationRect | null, +) => { + const image = await wc.capturePage( + cropRect + ? { + x: cropRect.x, + y: cropRect.y, + width: cropRect.width, + height: cropRect.height, + } + : undefined, + ); + const size = image.getSize(); + return { + dataUrl: image.toDataURL(), + width: size.width, + height: size.height, + cropRect: cropRect ?? { x: 0, y: 0, width: size.width, height: size.height }, + }; +}; + const findZoomStep = (current: number): number => { for (let index = 0; index < ZOOM_LEVELS.length; index += 1) { if (Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON) return index; @@ -77,7 +130,7 @@ interface ManagedListeners { } interface PickSession { - readonly resolve: (payload: PickedElementPayload | null) => void; + readonly resolve: (payload: PreviewAnnotationPayload | null) => void; readonly cleanup: () => void; } @@ -103,7 +156,7 @@ export class PreviewViewManager { private readonly attached = new Map(); private browserSession: Session | null = null; private readonly listeners = new Set(); - /** In-flight element-pick sessions, keyed by tabId (one pick per tab). */ + /** In-flight preview annotation sessions, keyed by tabId. */ private readonly pickSessions = new Map(); setMainWindow(window: BrowserWindow): void { @@ -290,17 +343,17 @@ export class PreviewViewManager { } /** - * Activate the in-page element picker for `tabId`. Resolves with the - * `PickedElementPayload` the preload script bubbles back via `ipc-message`, - * or `null` when the user cancels (Escape, navigation, manual cancel). + * Activate annotation mode for `tabId`. Resolves after the guest submits a + * multi-target annotation and the desktop process captures its screenshot, + * or with `null` when the user cancels. * * Exactly one pick session may be active per tab — re-invoking while a * pick is in flight cleanly resolves the old session with `null` first. */ - async pickElement(tabId: string): Promise { + async pickElement(tabId: string): Promise { const wc = this.requireWebContents(tabId); this.cancelPickElement(tabId); - return new Promise((resolve) => { + return new Promise((resolve) => { // `wc.ipc` is the per-WebContents IpcMain that receives messages the // webview's preload sends with `ipcRenderer.send(...)`. We use that // (not the global `wc.on("ipc-message", ...)`, which is for @@ -313,7 +366,7 @@ export class PreviewViewManager { this.pickSessions.delete(tabId); }; const session: PickSession = { resolve, cleanup }; - const settle = (payload: PickedElementPayload | null) => { + const settle = (payload: PreviewAnnotationPayload | null) => { if (this.pickSessions.get(tabId) !== session) return; cleanup(); resolve(payload); @@ -324,11 +377,22 @@ export class PreviewViewManager { settle(null); return; } - if (!isPickedElementPayload(payload)) { + if (!isPreviewAnnotationPayload(payload)) { settle(null); return; } - settle(payload); + const cropRect = normalizeCaptureRect(args[1]); + void captureAnnotationScreenshot(wc, cropRect) + .then((screenshot) => settle({ ...payload, screenshot })) + .catch(() => settle(payload)) + .finally(() => { + if (wc.isDestroyed()) return; + try { + wc.send(ANNOTATION_CAPTURED_CHANNEL); + } catch { + // The guest may have navigated while capture was in flight. + } + }); }; const onDestroyed = () => settle(null); const onNavigated = () => settle(null); diff --git a/apps/web/package.json b/apps/web/package.json index b0910fe608f689b8b61973581e83f7740be61164..cbf554a6679544eba01923c5477a5a98a8fd416d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -64,7 +64,6 @@ "babel-plugin-react-compiler": "1.0.0", "msw": "2.12.11", "playwright": "^1.58.2", - "react-grab": "^0.1.32", "tailwindcss": "^4.0.0", "vite": "catalog:", "vite-plus": "catalog:", diff --git a/apps/web/src/components/preview/PreviewChromeRow.tsx b/apps/web/src/components/preview/PreviewChromeRow.tsx index cdb46501de0551268b83042e913758cd70a121df..c84d27345a8d225eca26d9b95d914da212e96a67 100644 --- a/apps/web/src/components/preview/PreviewChromeRow.tsx +++ b/apps/web/src/components/preview/PreviewChromeRow.tsx @@ -37,8 +37,8 @@ interface Props { /** When provided, renders an "Open in browser" affordance to the right. */ onOpenInBrowser?: (() => void) | undefined; /** - * When provided, renders a "Select element" toggle button to the right of - * the URL input. Pressed while a pick is active (button shows in `pressed` + * When provided, renders an annotation-mode toggle button to the right of + * the URL input. Pressed while annotation mode is active (button shows in `pressed` * state). Disabled in `pickDisabled` mode. */ onPickElement?: (() => void) | undefined; @@ -192,7 +192,7 @@ export function PreviewChromeRow({ size="icon-xs" onClick={onPickElement} disabled={pickDisabled} - aria-label={pickActive ? "Cancel element pick" : "Select element from page"} + aria-label={pickActive ? "Cancel annotation" : "Annotate preview"} aria-pressed={pickActive ? "true" : "false"} type="button" /> @@ -204,8 +204,8 @@ export function PreviewChromeRow({ {pickDisabled && pickDisabledReason ? pickDisabledReason : pickActive - ? "Cancel pick (Esc)" - : "Select element to attach"} + ? "Cancel annotation (Esc)" + : "Annotate elements, regions, and drawings"} ) : null} diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index fb61fcb354cba8e68e6225489f35b0451e8e833b..0f4e66514583af26c1f434ef006861aee96643ad 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -7,6 +7,10 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useComposerDraftStore } from "~/composerDraftStore"; import { ensureEnvironmentApi } from "~/environmentApi"; import { normalizeElementContextSelection } from "~/lib/elementContext"; +import { + appendPreviewAnnotationPrompt, + previewAnnotationScreenshotFile, +} from "~/lib/previewAnnotation"; import { ensureLocalApi } from "~/localApi"; import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore"; @@ -45,6 +49,9 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { const applyServerSnapshot = usePreviewStateStore((state) => state.applyServerSnapshot); const rememberUrl = usePreviewStateStore((state) => state.rememberUrl); const addElementContext = useComposerDraftStore((store) => store.addElementContext); + const addImage = useComposerDraftStore((store) => store.addImage); + const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); + const setPrompt = useComposerDraftStore((store) => store.setPrompt); usePreviewSession(threadRef); @@ -137,11 +144,26 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { setPickActive(true); void (async () => { try { - const payload = await previewBridge.pickElement(tabId); - if (!payload) return; - const selection = normalizeElementContextSelection(payload); - if (!selection) return; - addElementContext(threadRef, selection); + const annotation = await previewBridge.pickElement(tabId); + if (!annotation) return; + for (const target of annotation.elements) { + const selection = normalizeElementContextSelection(target.element); + if (selection) addElementContext(threadRef, selection); + } + const currentPrompt = getComposerDraft(threadRef)?.prompt ?? ""; + setPrompt(threadRef, appendPreviewAnnotationPrompt(currentPrompt, annotation)); + const screenshotFile = await previewAnnotationScreenshotFile(annotation); + if (screenshotFile && annotation.screenshot) { + addImage(threadRef, { + type: "image", + id: annotation.id, + name: screenshotFile.name, + mimeType: screenshotFile.type, + sizeBytes: screenshotFile.size, + previewUrl: annotation.screenshot.dataUrl, + file: screenshotFile, + }); + } } catch { // Picker failed (e.g. webview navigated). Treat as silent cancel. } finally { @@ -165,7 +187,7 @@ export function PreviewView({ threadRef, configuredUrls, visible }: Props) { } } })(); - }, [addElementContext, tabId, threadRef]); + }, [addElementContext, addImage, getComposerDraft, setPrompt, tabId, threadRef]); // If the active tab changes mid-pick (close, thread switch, hot restart), // tell main to tear down the in-flight session AND reset our local toggle diff --git a/apps/web/src/lib/previewAnnotation.test.ts b/apps/web/src/lib/previewAnnotation.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5dc5c0b6601ea336e177a48a38415f813e9e3712 --- /dev/null +++ b/apps/web/src/lib/previewAnnotation.test.ts @@ -0,0 +1,60 @@ +import type { PreviewAnnotationPayload } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { appendPreviewAnnotationPrompt, buildPreviewAnnotationPrompt } from "./previewAnnotation"; + +const annotation: PreviewAnnotationPayload = { + id: "annotation_1", + pageUrl: "http://localhost:3000", + pageTitle: "Example", + comment: "Make these cards feel related.", + elements: [], + regions: [{ id: "region_1", rect: { x: 10, y: 20, width: 100, height: 80 } }], + strokes: [ + { + id: "stroke_1", + color: "#7c3aed", + width: 4, + points: [ + { x: 10, y: 10 }, + { x: 20, y: 20 }, + ], + bounds: { x: 6, y: 6, width: 18, height: 18 }, + }, + ], + styleChanges: [ + { + targetId: "element_1", + selector: ".card", + property: "border-radius", + previousValue: "4px", + value: "16px", + }, + ], + screenshot: { + dataUrl: "data:image/png;base64,AA==", + width: 100, + height: 80, + cropRect: { x: 10, y: 20, width: 100, height: 80 }, + }, + createdAt: "2026-06-11T00:00:00.000Z", +}; + +describe("preview annotations", () => { + it("describes regions, drawings, styles, and screenshot context", () => { + const result = buildPreviewAnnotationPrompt(annotation); + expect(result).toContain("Make these cards feel related."); + expect(result).toContain("1 marked region"); + expect(result).toContain("1 drawing"); + expect(result).toContain("border-radius: 4px → 16px"); + expect(result).toContain("attached screenshot"); + }); + + it("appends to an existing composer prompt", () => { + expect( + appendPreviewAnnotationPrompt("Fix this", annotation).startsWith( + "Fix this\n\nPreview annotation:", + ), + ).toBe(true); + }); +}); diff --git a/apps/web/src/lib/previewAnnotation.ts b/apps/web/src/lib/previewAnnotation.ts new file mode 100644 index 0000000000000000000000000000000000000000..c5d3747967d9a0b30671741a1408c64ddd7535b4 --- /dev/null +++ b/apps/web/src/lib/previewAnnotation.ts @@ -0,0 +1,53 @@ +import type { PreviewAnnotationPayload } from "@t3tools/contracts"; + +export function buildPreviewAnnotationPrompt(annotation: PreviewAnnotationPayload): string { + const lines = ["Preview annotation:"]; + if (annotation.comment.trim()) lines.push(annotation.comment.trim()); + const targets: string[] = []; + if (annotation.elements.length > 0) { + targets.push( + `${annotation.elements.length} selected element${annotation.elements.length === 1 ? "" : "s"}`, + ); + } + if (annotation.regions.length > 0) { + targets.push( + `${annotation.regions.length} marked region${annotation.regions.length === 1 ? "" : "s"}`, + ); + } + if (annotation.strokes.length > 0) { + targets.push( + `${annotation.strokes.length} drawing${annotation.strokes.length === 1 ? "" : "s"}`, + ); + } + if (targets.length > 0) lines.push(`Targets: ${targets.join(", ")}.`); + if (annotation.styleChanges.length > 0) { + lines.push("Requested visual changes:"); + for (const change of annotation.styleChanges) { + lines.push(`- ${change.property}: ${change.previousValue || "(unset)"} → ${change.value}`); + } + } + if (annotation.screenshot) { + lines.push("The attached screenshot is the annotated preview crop."); + } + return lines.join("\n"); +} + +export function appendPreviewAnnotationPrompt( + prompt: string, + annotation: PreviewAnnotationPayload, +): string { + const annotationText = buildPreviewAnnotationPrompt(annotation); + const trimmed = prompt.trim(); + return trimmed ? `${trimmed}\n\n${annotationText}` : annotationText; +} + +export async function previewAnnotationScreenshotFile( + annotation: PreviewAnnotationPayload, +): Promise { + if (!annotation.screenshot) return null; + const response = await fetch(annotation.screenshot.dataUrl); + const blob = await response.blob(); + return new File([blob], `preview-annotation-${annotation.id}.png`, { + type: blob.type || "image/png", + }); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index b4aff4f2cacd0794be4e184f479dc688c786dbef..e92cad9aa3dfe065509b0cd418c4ed238fb2f22a 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -18,10 +18,6 @@ import { getRouter } from "./router"; import { APP_DISPLAY_NAME } from "./branding"; import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; -if (import.meta.env.DEV) { - void import("react-grab"); -} - // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. const history = isElectron ? createHashHistory() : createBrowserHistory(); diff --git a/apps/web/src/reactGrabBoundary.test.ts b/apps/web/src/reactGrabBoundary.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..672bd9f3f9b4827fddbf4a86d252289a05e47163 --- /dev/null +++ b/apps/web/src/reactGrabBoundary.test.ts @@ -0,0 +1,15 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vite-plus/test"; + +import packageJson from "../package.json" with { type: "json" }; + +describe("React Grab runtime boundary", () => { + it("keeps the host renderer free of the React Grab overlay", () => { + const mainSource = readFileSync(new URL("./main.tsx", import.meta.url), "utf8"); + + expect(mainSource).not.toMatch(/import\(["']react-grab["']\)/); + expect(packageJson.dependencies).not.toHaveProperty("react-grab"); + expect(packageJson.devDependencies).not.toHaveProperty("react-grab"); + }); +}); diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 5bb68c6575aa5a428ee173cc2fab5a12fe0d175a..161aaf69a6be7fecad48dfca5de38c18f7b47020 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -505,6 +505,70 @@ export interface PickedElementPayload { pickedAt: string; } +export interface PreviewAnnotationRect { + x: number; + y: number; + width: number; + height: number; +} + +export interface PreviewAnnotationPoint { + x: number; + y: number; +} + +export interface PreviewAnnotationElementTarget { + id: string; + element: PickedElementPayload; + rect: PreviewAnnotationRect; +} + +export interface PreviewAnnotationRegionTarget { + id: string; + rect: PreviewAnnotationRect; +} + +export interface PreviewAnnotationStrokeTarget { + id: string; + color: string; + width: number; + points: ReadonlyArray; + bounds: PreviewAnnotationRect; +} + +export interface PreviewAnnotationStyleChange { + targetId: string; + selector: string | null; + property: string; + previousValue: string; + value: string; +} + +export interface PreviewAnnotationScreenshot { + dataUrl: string; + width: number; + height: number; + cropRect: PreviewAnnotationRect; +} + +/** + * A submitted preview annotation. One annotation may reference multiple DOM + * elements, freeform regions, and ink strokes. The desktop main process adds + * the screenshot after the guest preload submits the structured draft. + */ +export interface PreviewAnnotationPayload { + id: string; + pageUrl: string; + pageTitle: string | null; + comment: string; + elements: ReadonlyArray; + regions: ReadonlyArray; + strokes: ReadonlyArray; + styleChanges: ReadonlyArray; + screenshot: PreviewAnnotationScreenshot | null; + createdAt: string; +} + export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; @@ -601,8 +665,8 @@ export interface DesktopPreviewBridge { * the picked payload, or `null` when the user cancels (Escape / nav). The * promise rejects if the picker can't be activated (no webview, etc.). */ - pickElement: (tabId: string) => Promise; - /** Cancel an in-flight `pickElement(tabId)` (renderer-side teardown). */ + pickElement: (tabId: string) => Promise; + /** Cancel an in-flight preview annotation session. */ cancelPickElement: (tabId: string) => Promise; onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7644d97d0da14618032a09518366436cc7745df..9b012818b61b31814ac68413eb84a38e3ccbf544 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -583,9 +583,6 @@ importers: playwright: specifier: ^1.58.2 version: 1.60.0 - react-grab: - specifier: ^0.1.32 - version: 0.1.44(react@19.2.6) tailwindcss: specifier: ^4.0.0 version: 4.3.0