| 1 | // @effect-diagnostics globalDate:off globalTimers:off - This isolated Electron preload does not run inside an Effect runtime. |
| 2 | import { ipcRenderer } from "electron"; |
| 3 | import { getElementContext } from "react-grab/primitives"; |
| 4 | import 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 | |
| 17 | import { resolveAnnotationSubmission } from "./AnnotationKeyboard.ts"; |
| 18 | import { previewAnnotationStyles } from "./AnnotationStyles.generated.ts"; |
| 19 | import { installRecordingCursor } from "./RecordingCursor.ts"; |
| 20 | import { DEFAULT_RECORDING_INPUT_OPTIONS } from "./RecordingInput.ts"; |
| 21 | import { |
| 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"; |
| 36 | const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; |
| 37 | const Z_INDEX_OVERLAY = 2147483646; |
| 38 | const PRIMARY = "var(--t3-primary)"; |
| 39 | const PRIMARY_FILL = "color-mix(in srgb, var(--t3-primary) 10%, transparent)"; |
| 40 | const MAX_MARQUEE_ELEMENTS = 20; |
| 41 | /** Upper bound on one element's React context lookup during submit. */ |
| 42 | const ELEMENT_CONTEXT_TIMEOUT_MS = 5_000; |
| 43 | const CONTENT_LAYER_Z_INDEX = 1; |
| 44 | const CHROME_LAYER_Z_INDEX = 10; |
| 45 | |
| 46 | let recordingCursor: ReturnType<typeof installRecordingCursor> | null = null; |
| 47 | ipcRenderer.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 | ); |
| 72 | ipcRenderer.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 | }); |
| 87 | ipcRenderer.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 | }); |
| 103 | ipcRenderer.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 | |
| 120 | type AnnotationTool = "select" | "marquee" | "draw" | "erase"; |
| 121 | |
| 122 | interface SelectedElement { |
| 123 | id: string; |
| 124 | element: Element; |
| 125 | outline: HTMLDivElement; |
| 126 | label: HTMLDivElement; |
| 127 | baselineStyles: Map<string, string>; |
| 128 | } |
| 129 | |
| 130 | interface AnnotationSession { |
| 131 | teardown: (notifyMain: boolean) => void; |
| 132 | applyTheme: (theme: DesktopPreviewAnnotationTheme) => void; |
| 133 | setSendEnabled: (enabled: boolean) => void; |
| 134 | } |
| 135 | |
| 136 | let activeSession: AnnotationSession | null = null; |
| 137 | let idSequence = 0; |
| 138 | let annotationTheme: DesktopPreviewAnnotationTheme | null = null; |
| 139 | |
| 140 | const 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 | |
| 169 | const 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 | |
| 179 | const 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 | |
| 188 | window.addEventListener("pointerdown", reportHumanPointerInput, true); |
| 189 | window.addEventListener("keydown", reportHumanKeyInput, true); |
| 190 | |
| 191 | // Mouse thumb buttons: `button === 3` is Back, `button === 4` is Forward. |
| 192 | const MOUSE_BUTTON_BACK = 3; |
| 193 | const MOUSE_BUTTON_FORWARD = 4; |
| 194 | |
| 195 | const 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. |
| 206 | const suppressNavigationButton = (event: MouseEvent): void => { |
| 207 | if (!event.isTrusted || navigationDirectionForButton(event.button) === null) return; |
| 208 | event.preventDefault(); |
| 209 | event.stopImmediatePropagation(); |
| 210 | }; |
| 211 | |
| 212 | const 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 | |
| 221 | window.addEventListener("mousedown", suppressNavigationButton, true); |
| 222 | window.addEventListener("mouseup", requestNavigationForButton, true); |
| 223 | window.addEventListener("auxclick", suppressNavigationButton, true); |
| 224 | |
| 225 | const nextId = (prefix: string): string => { |
| 226 | idSequence += 1; |
| 227 | return `${prefix}_${idSequence.toString(36)}`; |
| 228 | }; |
| 229 | |
| 230 | const rectFromDomRect = (rect: DOMRect): PreviewAnnotationRect => ({ |
| 231 | x: rect.left, |
| 232 | y: rect.top, |
| 233 | width: rect.width, |
| 234 | height: rect.height, |
| 235 | }); |
| 236 | |
| 237 | const 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 | |
| 249 | const isUsableRect = (rect: PreviewAnnotationRect): boolean => rect.width >= 3 && rect.height >= 3; |
| 250 | |
| 251 | function 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 | |
| 272 | function isAnnotationNode(element: Element): boolean { |
| 273 | return element instanceof Element && element.closest(`[${OVERLAY_ATTRIBUTE}]`) !== null; |
| 274 | } |
| 275 | |
| 276 | function 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 | |
| 286 | function 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 | |
| 302 | function 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 | |
| 318 | function 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. */ |
| 330 | function 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 | |
| 356 | function 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 | |
| 371 | function 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 | |
| 388 | function 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 | */ |
| 408 | function 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. */ |
| 419 | const 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 | */ |
| 427 | async 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 | |
| 465 | function 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 | |
| 475 | function 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 | |
| 481 | function 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 | |
| 492 | function 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 | |
| 509 | function 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 | |
| 515 | function 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 | |
| 524 | function 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 | |
| 538 | function 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 | |
| 552 | function 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 | |
| 1503 | ipcRenderer.on( |
| 1504 | START_PICK_CHANNEL, |
| 1505 | (_event, theme: DesktopPreviewAnnotationTheme | undefined, sendEnabled?: boolean) => { |
| 1506 | if (theme) annotationTheme = theme; |
| 1507 | startAnnotation(sendEnabled === true); |
| 1508 | }, |
| 1509 | ); |
| 1510 | ipcRenderer.on(ANNOTATION_SEND_ENABLED_CHANNEL, (_event, enabled: boolean) => { |
| 1511 | activeSession?.setSendEnabled(enabled === true); |
| 1512 | }); |
| 1513 | ipcRenderer.on(ANNOTATION_THEME_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme) => { |
| 1514 | annotationTheme = theme; |
| 1515 | recordingCursor?.setTheme(theme); |
| 1516 | activeSession?.applyTheme(theme); |
| 1517 | }); |
| 1518 | ipcRenderer.on(CANCEL_PICK_CHANNEL, () => activeSession?.teardown(false)); |