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