| ... | @@ -1,175 +1,101 @@ | ... | @@ -1,175 +1,101 @@ |
| 1 | // @effect-diagnostics globalDate:off | 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 | import { ipcRenderer } from "electron"; | 2 | import { ipcRenderer } from "electron"; |
| 36 | import { getElementContext } from "react-grab/primitives"; | 3 | import { getElementContext } from "react-grab/primitives"; |
| 37 | import type { PickedElementPayload, PickedElementStackFrame } from "@t3tools/contracts"; | 4 | import type { |
| 38 | | 5 | PickedElementPayload, |
| 39 | import { computeLabelPosition } from "./preview-pick-label-position.ts"; | 6 | PickedElementStackFrame, |
| | 7 | PreviewAnnotationPayload, |
| | 8 | PreviewAnnotationPoint, |
| | 9 | PreviewAnnotationRect, |
| | 10 | PreviewAnnotationRegionTarget, |
| | 11 | PreviewAnnotationStrokeTarget, |
| | 12 | PreviewAnnotationStyleChange, |
| | 13 | } from "@t3tools/contracts"; |
| 40 | | 14 | |
| 41 | const START_PICK_CHANNEL = "preview:start-pick"; | 15 | const START_PICK_CHANNEL = "preview:start-pick"; |
| 42 | const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; | 16 | const CANCEL_PICK_CHANNEL = "preview:cancel-pick"; |
| 43 | const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; | 17 | const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; |
| 44 | | 18 | const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; |
| 45 | const HIGHLIGHT_COLOR = "rgba(37, 99, 235, 0.9)"; // blue-600 | 19 | const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; |
| 46 | const HIGHLIGHT_FILL = "rgba(37, 99, 235, 0.16)"; | | |
| 47 | const LABEL_BG = "rgba(37, 99, 235, 0.96)"; | | |
| 48 | const Z_INDEX_OVERLAY = 2147483646; | 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 { | 28 | type AnnotationTool = "select" | "marquee" | "draw" | "erase"; |
| 51 | readonly overlay: HTMLDivElement; | | |
| 52 | readonly outline: HTMLDivElement; | | |
| 53 | readonly label: HTMLDivElement; | | |
| 54 | /** | | |
| 55 | * Tear down listeners + DOM WITHOUT notifying main. Used when main itself | | |
| 56 | * initiated the cancel (CANCEL_PICK_CHANNEL) or when a follow-up startPick | | |
| 57 | * supersedes us — main is already waiting on a fresh listener and would | | |
| 58 | * otherwise resolve it with the stale `null` we'd send. | | |
| 59 | */ | | |
| 60 | readonly teardownSilent: () => void; | | |
| 61 | } | | |
| 62 | | | |
| 63 | let activeSession: PickSession | null = null; | | |
| 64 | | 29 | |
| 65 | function endActiveSession(): void { | 30 | interface SelectedElement { |
| 66 | if (!activeSession) return; | 31 | id: string; |
| 67 | const session = activeSession; | 32 | element: Element; |
| 68 | activeSession = null; | 33 | outline: HTMLDivElement; |
| 69 | // Supersession from a new startPick is a main-initiated transition: tear | 34 | label: HTMLDivElement; |
| 70 | // down silently so the new pick's main-side listener doesn't receive a | 35 | baselineStyles: Map<string, string>; |
| 71 | // ghost `null` from the old session. | | |
| 72 | session.teardownSilent(); | | |
| 73 | } | 36 | } |
| 74 | | 37 | |
| 75 | interface OverlayHandles { | 38 | interface AnnotationSession { |
| 76 | readonly overlay: HTMLDivElement; | 39 | teardown: (notifyMain: boolean) => void; |
| 77 | readonly outline: HTMLDivElement; | | |
| 78 | readonly label: HTMLDivElement; | | |
| 79 | readonly destroyDom: () => void; | | |
| 80 | } | 40 | } |
| 81 | | 41 | |
| 82 | function createOverlay(): OverlayHandles { | 42 | let activeSession: AnnotationSession | null = null; |
| 83 | const overlay = document.createElement("div"); | 43 | let idSequence = 0; |
| 84 | overlay.setAttribute("data-t3code-pick-overlay", ""); | | |
| 85 | overlay.style.cssText = [ | | |
| 86 | "position:fixed", | | |
| 87 | "inset:0", | | |
| 88 | "z-index:" + String(Z_INDEX_OVERLAY), | | |
| 89 | "pointer-events:none", | | |
| 90 | "cursor:crosshair", | | |
| 91 | // Some apps register `pointer-events: auto !important` on body — using a | | |
| 92 | // dedicated overlay element ensures we don't fight with that. | | |
| 93 | ].join(";"); | | |
| 94 | | 44 | |
| 95 | const outline = document.createElement("div"); | 45 | const nextId = (prefix: string): string => { |
| 96 | outline.setAttribute("data-t3code-pick-outline", ""); | 46 | idSequence += 1; |
| 97 | outline.style.cssText = [ | 47 | return `${prefix}_${idSequence.toString(36)}`; |
| 98 | "position:absolute", | 48 | }; |
| 99 | "left:0", | | |
| 100 | "top:0", | | |
| 101 | "width:0", | | |
| 102 | "height:0", | | |
| 103 | "border:2px solid " + HIGHLIGHT_COLOR, | | |
| 104 | "background:" + HIGHLIGHT_FILL, | | |
| 105 | "border-radius:2px", | | |
| 106 | "box-shadow:0 0 0 1px rgba(255,255,255,0.6) inset", | | |
| 107 | "transition:none", | | |
| 108 | "display:none", | | |
| 109 | "pointer-events:none", | | |
| 110 | ].join(";"); | | |
| 111 | | 49 | |
| 112 | const label = document.createElement("div"); | 50 | const rectFromDomRect = (rect: DOMRect): PreviewAnnotationRect => ({ |
| 113 | label.setAttribute("data-t3code-pick-label", ""); | 51 | x: rect.left, |
| 114 | // `top:0; left:0` so transform translate() positions are absolute viewport | 52 | y: rect.top, |
| 115 | // coordinates (we re-anchor the label every paint via a single transform). | 53 | width: rect.width, |
| 116 | // `max-width: calc(100vw - 8px)` + ellipsis prevents an overly long | 54 | height: rect.height, |
| 117 | // tag#id.class string from overflowing the viewport horizontally before we | 55 | }); |
| 118 | // even get to the clamp logic. | 56 | |
| 119 | label.style.cssText = [ | 57 | const normalizeRect = ( |
| 120 | "position:absolute", | 58 | startX: number, |
| 121 | "left:0", | 59 | startY: number, |
| 122 | "top:0", | 60 | endX: number, |
| 123 | "padding:2px 6px", | 61 | endY: number, |
| 124 | "background:" + LABEL_BG, | 62 | ): PreviewAnnotationRect => ({ |
| 125 | "color:white", | 63 | x: Math.min(startX, endX), |
| 126 | "font:600 11px/1.2 ui-sans-serif,system-ui,-apple-system,sans-serif", | 64 | y: Math.min(startY, endY), |
| 127 | "border-radius:3px", | 65 | width: Math.abs(endX - startX), |
| 128 | "pointer-events:none", | 66 | height: Math.abs(endY - startY), |
| 129 | "white-space:nowrap", | 67 | }); |
| 130 | "max-width:calc(100vw - 8px)", | 68 | |
| 131 | "overflow:hidden", | 69 | const isUsableRect = (rect: PreviewAnnotationRect): boolean => rect.width >= 3 && rect.height >= 3; |
| 132 | "text-overflow:ellipsis", | | |
| 133 | "box-shadow:0 1px 2px rgba(0,0,0,0.25)", | | |
| 134 | "display:none", | | |
| 135 | ].join(";"); | | |
| 136 | | 70 | |
| 137 | overlay.appendChild(outline); | 71 | function unionRects(rects: ReadonlyArray<PreviewAnnotationRect>): PreviewAnnotationRect | null { |
| 138 | overlay.appendChild(label); | 72 | if (rects.length === 0) return null; |
| 139 | document.documentElement.appendChild(overlay); | 73 | const left = Math.min(...rects.map((rect) => rect.x)); |
| 140 | | 74 | const top = Math.min(...rects.map((rect) => rect.y)); |
| 141 | // Force the crosshair cursor across the whole page even though we set it | 75 | const right = Math.max(...rects.map((rect) => rect.x + rect.width)); |
| 142 | // on the overlay (because pointer-events: none means it's never the | 76 | const bottom = Math.max(...rects.map((rect) => rect.y + rect.height)); |
| 143 | // cursor target). We add a stylesheet rule and revert on cleanup. | 77 | const padding = 20; |
| 144 | const styleEl = document.createElement("style"); | 78 | const x = Math.max(0, left - padding); |
| 145 | styleEl.textContent = `html[data-t3code-picking="1"], html[data-t3code-picking="1"] *, html[data-t3code-picking="1"] *::before, html[data-t3code-picking="1"] *::after { cursor: crosshair !important; }`; | 79 | const y = Math.max(0, top - padding); |
| 146 | document.documentElement.appendChild(styleEl); | 80 | const maxWidth = Math.max(1, window.innerWidth - x); |
| 147 | document.documentElement.setAttribute("data-t3code-picking", "1"); | 81 | const maxHeight = Math.max(1, window.innerHeight - y); |
| 148 | | 82 | return { |
| 149 | const destroyDom = (): void => { | 83 | x, |
| 150 | overlay.remove(); | 84 | y, |
| 151 | styleEl.remove(); | 85 | width: Math.min(maxWidth, right - left + padding * 2), |
| 152 | document.documentElement.removeAttribute("data-t3code-picking"); | 86 | height: Math.min(maxHeight, bottom - top + padding * 2), |
| 153 | }; | 87 | }; |
| | 88 | } |
| 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 | function pickFromPoint(clientX: number, clientY: number): Element | null { | 94 | function pickFromPoint(clientX: number, clientY: number): Element | null { |
| 165 | const candidates = document.elementsFromPoint(clientX, clientY); | 95 | for (const candidate of document.elementsFromPoint(clientX, clientY)) { |
| 166 | for (const candidate of candidates) { | | |
| 167 | if (!(candidate instanceof Element)) continue; | 96 | if (!(candidate instanceof Element)) continue; |
| 168 | if (candidate.hasAttribute("data-t3code-pick-overlay")) continue; | 97 | if (isAnnotationNode(candidate)) continue; |
| 169 | if (candidate.hasAttribute("data-t3code-pick-outline")) continue; | 98 | if (candidate === document.documentElement || candidate === document.body) continue; |
| 170 | if (candidate.hasAttribute("data-t3code-pick-label")) continue; | | |
| 171 | if (candidate === document.documentElement) continue; | | |
| 172 | if (candidate === document.body) continue; | | |
| 173 | return candidate; | 99 | return candidate; |
| 174 | } | 100 | } |
| 175 | return null; | 101 | return null; |
| ... | @@ -178,94 +104,74 @@ function pickFromPoint(clientX: number, clientY: number): Element | null { | ... | @@ -178,94 +104,74 @@ function pickFromPoint(clientX: number, clientY: number): Element | null { |
| 178 | function describeRawElement(element: Element): string { | 104 | function describeRawElement(element: Element): string { |
| 179 | const tag = element.tagName.toLowerCase(); | 105 | const tag = element.tagName.toLowerCase(); |
| 180 | const id = element.id ? `#${element.id}` : ""; | 106 | const id = element.id ? `#${element.id}` : ""; |
| 181 | const className = | 107 | const classes = |
| 182 | element instanceof HTMLElement && typeof element.className === "string" | 108 | element instanceof HTMLElement && typeof element.className === "string" |
| 183 | ? element.className | 109 | ? element.className |
| 184 | .trim() | 110 | .trim() |
| 185 | .split(/\s+/) | 111 | .split(/\s+/) |
| 186 | .filter((token) => token.length > 0) | 112 | .filter(Boolean) |
| 187 | .slice(0, 2) | 113 | .slice(0, 2) |
| 188 | .map((token) => `.${token}`) | 114 | .map((name) => `.${name}`) |
| 189 | .join("") | 115 | .join("") |
| 190 | : ""; | 116 | : ""; |
| 191 | return `${tag}${id}${className}`; | 117 | return `${tag}${id}${classes}`; |
| 192 | } | 118 | } |
| 193 | | 119 | |
| 194 | /** | 120 | function createBox(color: string, fill: string): HTMLDivElement { |
| 195 | * Per-session cache of the resolved React component name for elements we've | 121 | const node = document.createElement("div"); |
| 196 | * already inspected. Stores `null` when react-grab couldn't find a component | 122 | node.setAttribute(OVERLAY_ATTRIBUTE, ""); |
| 197 | * (raw HTML / unmounted) so repeat hovers don't re-pay the async lookup. | 123 | node.style.cssText = [ |
| 198 | */ | 124 | "position:fixed", |
| 199 | const componentNameCache = new WeakMap<Element, string | null>(); | 125 | "pointer-events:none", |
| 200 | const componentNameInFlight = new WeakSet<Element>(); | 126 | `border:2px solid ${color}`, |
| 201 | | 127 | `background:${fill}`, |
| 202 | function describeElement(element: Element): string { | 128 | "border-radius:3px", |
| 203 | const cached = componentNameCache.get(element); | 129 | "box-sizing:border-box", |
| 204 | if (cached) return `<${cached}> ${describeRawElement(element)}`; | 130 | "display:none", |
| 205 | return describeRawElement(element); | 131 | `z-index:${CONTENT_LAYER_Z_INDEX}`, |
| | 132 | ].join(";"); |
| | 133 | return node; |
| 206 | } | 134 | } |
| 207 | | 135 | |
| 208 | function paintOutline(handles: OverlayHandles, element: Element): void { | 136 | function positionBox(node: HTMLElement, rect: PreviewAnnotationRect): void { |
| 209 | const rect = element.getBoundingClientRect(); | 137 | node.style.display = "block"; |
| 210 | if (rect.width === 0 && rect.height === 0) { | 138 | node.style.transform = `translate(${rect.x}px, ${rect.y}px)`; |
| 211 | handles.outline.style.display = "none"; | 139 | node.style.width = `${rect.width}px`; |
| 212 | handles.label.style.display = "none"; | 140 | node.style.height = `${rect.height}px`; |
| 213 | return; | | |
| 214 | } | | |
| 215 | handles.outline.style.display = "block"; | | |
| 216 | handles.outline.style.transform = `translate(${rect.left}px, ${rect.top}px)`; | | |
| 217 | handles.outline.style.width = `${rect.width}px`; | | |
| 218 | handles.outline.style.height = `${rect.height}px`; | | |
| 219 | | | |
| 220 | // Two-pass label paint: first apply the new text and force-block display | | |
| 221 | // so we can measure the rendered size, then clamp to the viewport and | | |
| 222 | // flip below the element when there isn't room above. | | |
| 223 | const text = describeElement(element); | | |
| 224 | if (handles.label.textContent !== text) { | | |
| 225 | handles.label.textContent = text; | | |
| 226 | } | | |
| 227 | handles.label.style.display = "block"; | | |
| 228 | | | |
| 229 | const labelRect = handles.label.getBoundingClientRect(); | | |
| 230 | const { x, y } = computeLabelPosition({ | | |
| 231 | targetLeft: rect.left, | | |
| 232 | targetTop: rect.top, | | |
| 233 | targetBottom: rect.bottom, | | |
| 234 | labelWidth: labelRect.width, | | |
| 235 | labelHeight: labelRect.height, | | |
| 236 | viewportWidth: document.documentElement.clientWidth || window.innerWidth || labelRect.width, | | |
| 237 | viewportHeight: document.documentElement.clientHeight || window.innerHeight || labelRect.height, | | |
| 238 | }); | | |
| 239 | handles.label.style.transform = `translate(${x}px, ${y}px)`; | | |
| 240 | } | 141 | } |
| 241 | | 142 | |
| 242 | /** | 143 | function createLabel(): HTMLDivElement { |
| 243 | * Kick off (at most once per element) an async react-grab lookup for the | 144 | const label = document.createElement("div"); |
| 244 | * component name. When the answer arrives, repaint the label iff the element | 145 | label.setAttribute(OVERLAY_ATTRIBUTE, ""); |
| 245 | * is still under the cursor — otherwise the user has moved on and a stale | 146 | label.style.cssText = [ |
| 246 | * paint would clobber the next element's label. | 147 | "position:fixed", |
| 247 | */ | 148 | "pointer-events:none", |
| 248 | function ensureComponentName(element: Element, onResolve: (resolvedFor: Element) => void): void { | 149 | `background:${ACCENT}`, |
| 249 | if (componentNameCache.has(element)) return; | 150 | "color:white", |
| 250 | if (componentNameInFlight.has(element)) return; | 151 | "font:600 11px/1.2 ui-sans-serif,system-ui,-apple-system,sans-serif", |
| 251 | componentNameInFlight.add(element); | 152 | "padding:2px 6px", |
| 252 | void getElementContext(element) | 153 | "border-radius:4px", |
| 253 | .then((context) => { | 154 | "white-space:nowrap", |
| 254 | const trimmed = context.componentName?.trim(); | 155 | "max-width:280px", |
| 255 | componentNameCache.set(element, trimmed && trimmed.length > 0 ? trimmed : null); | 156 | "overflow:hidden", |
| 256 | }) | 157 | "text-overflow:ellipsis", |
| 257 | .catch(() => { | 158 | "box-shadow:0 1px 3px rgba(0,0,0,.3)", |
| 258 | componentNameCache.set(element, null); | 159 | `z-index:${CONTENT_LAYER_Z_INDEX}`, |
| 259 | }) | 160 | ].join(";"); |
| 260 | .finally(() => { | 161 | return label; |
| 261 | componentNameInFlight.delete(element); | | |
| 262 | onResolve(element); | | |
| 263 | }); | | |
| 264 | } | 162 | } |
| 265 | | 163 | |
| 266 | function clearOutline(handles: OverlayHandles): void { | 164 | function updateSelectedVisual(target: SelectedElement): void { |
| 267 | handles.outline.style.display = "none"; | 165 | if (!target.element.isConnected) { |
| 268 | handles.label.style.display = "none"; | 166 | target.outline.style.display = "none"; |
| | 167 | target.label.style.display = "none"; |
| | 168 | return; |
| | 169 | } |
| | 170 | const rect = target.element.getBoundingClientRect(); |
| | 171 | positionBox(target.outline, rectFromDomRect(rect)); |
| | 172 | target.label.textContent = describeRawElement(target.element); |
| | 173 | target.label.style.display = "block"; |
| | 174 | target.label.style.transform = `translate(${Math.max(4, rect.left)}px, ${Math.max(4, rect.top - 22)}px)`; |
| 269 | } | 175 | } |
| 270 | | 176 | |
| 271 | function toStackFrame(frame: { | 177 | function toStackFrame(frame: { |
| ... | @@ -285,10 +191,10 @@ function toStackFrame(frame: { | ... | @@ -285,10 +191,10 @@ function toStackFrame(frame: { |
| 285 | async function captureElement(element: Element): Promise<PickedElementPayload | null> { | 191 | async function captureElement(element: Element): Promise<PickedElementPayload | null> { |
| 286 | try { | 192 | try { |
| 287 | const context = await getElementContext(element); | 193 | const context = await getElementContext(element); |
| 288 | const stack = (context.stack ?? []).map((frame) => toStackFrame(frame)); | 194 | const stack = (context.stack ?? []).map(toStackFrame); |
| 289 | return { | 195 | return { |
| 290 | pageUrl: location.href, | 196 | pageUrl: location.href, |
| 291 | pageTitle: document.title?.trim() ? document.title.trim() : null, | 197 | pageTitle: document.title?.trim() || null, |
| 292 | tagName: element.tagName.toLowerCase(), | 198 | tagName: element.tagName.toLowerCase(), |
| 293 | selector: context.selector, | 199 | selector: context.selector, |
| 294 | htmlPreview: context.htmlPreview ?? "", | 200 | htmlPreview: context.htmlPreview ?? "", |
| ... | @@ -303,118 +209,909 @@ async function captureElement(element: Element): Promise<PickedElementPayload | | ... | @@ -303,118 +209,909 @@ async function captureElement(element: Element): Promise<PickedElementPayload | |
| 303 | } | 209 | } |
| 304 | } | 210 | } |
| 305 | | 211 | |
| 306 | function startPick(): void { | 212 | function createButton(label: string, title: string): HTMLButtonElement { |
| 307 | endActiveSession(); | 213 | const button = document.createElement("button"); |
| 308 | if (typeof document === "undefined" || !document.body) { | 214 | button.type = "button"; |
| 309 | ipcRenderer.send(ELEMENT_PICKED_CHANNEL, null); | 215 | button.textContent = label; |
| 310 | return; | 216 | button.title = title; |
| | 217 | button.style.cssText = [ |
| | 218 | "border:0", |
| | 219 | "border-radius:7px", |
| | 220 | "padding:6px 9px", |
| | 221 | "font:600 12px/1 ui-sans-serif,system-ui,-apple-system,sans-serif", |
| | 222 | "background:transparent", |
| | 223 | "color:#27272a", |
| | 224 | "cursor:pointer", |
| | 225 | ].join(";"); |
| | 226 | return button; |
| | 227 | } |
| | 228 | |
| | 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(); | 368 | const stylePanel = document.createElement("div"); |
| 314 | let lastTarget: Element | null = null; | 369 | stylePanel.style.cssText = |
| 315 | let resolved = false; | 370 | "display:none;max-height:min(380px,calc(100vh - 180px));overflow:auto;padding:0 10px;background:rgba(250,250,250,.62);border-top:1px solid rgba(0,0,0,.06)"; |
| 316 | | 371 | editor.appendChild(stylePanel); |
| 317 | // Tear down listeners + DOM. Does NOT notify main. `notifyMain` controls | 372 | |
| 318 | // whether we additionally bubble a `null` ELEMENT_PICKED back so main can | 373 | const footer = document.createElement("div"); |
| 319 | // resolve the in-flight pick promise (only true for USER-initiated cancels; | 374 | footer.style.cssText = |
| 320 | // main already knows about its own cancellations). | 375 | "display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 8px;border-top:1px solid rgba(0,0,0,.06);background:rgba(255,255,255,.92)"; |
| 321 | const teardown = (notifyMain: boolean, payload: PickedElementPayload | null): void => { | 376 | const adjust = createButton("Adjust", "Show style controls for selected elements"); |
| 322 | if (resolved) return; | 377 | adjust.style.cssText += |
| 323 | resolved = true; | 378 | ";display:none;padding:5px 7px;color:#52525b;background:#f4f4f5;font-size:11px"; |
| 324 | window.removeEventListener("mousemove", onMove, { capture: true } as EventListenerOptions); | 379 | const submit = createButton("Attach", "Attach annotation and screenshot"); |
| 325 | window.removeEventListener("click", onClick, { capture: true } as EventListenerOptions); | 380 | submit.style.cssText += `;background:${ACCENT};color:white;padding:6px 9px;font-size:11px`; |
| 326 | window.removeEventListener("keydown", onKey, { capture: true } as EventListenerOptions); | 381 | footer.append(adjust, submit); |
| 327 | window.removeEventListener("scroll", onScrollOrResize, { | 382 | editor.appendChild(footer); |
| 328 | capture: true, | 383 | |
| 329 | } as EventListenerOptions); | 384 | const selected = new Map<Element, SelectedElement>(); |
| 330 | window.removeEventListener("resize", onScrollOrResize); | 385 | const regions: PreviewAnnotationRegionTarget[] = []; |
| 331 | window.removeEventListener("beforeunload", onBeforeUnload); | 386 | const strokes: PreviewAnnotationStrokeTarget[] = []; |
| 332 | ipcRenderer.off(CANCEL_PICK_CHANNEL, onMainCancel); | 387 | const styleChanges = new Map<string, PreviewAnnotationStyleChange>(); |
| 333 | handles.destroyDom(); | 388 | const toolButtons = new Map<AnnotationTool, HTMLButtonElement>(); |
| 334 | if (notifyMain) { | 389 | let tool: AnnotationTool = "select"; |
| 335 | // `ipcRenderer.send` (NOT `sendToHost`) reaches main, where the | 390 | let dragStart: PreviewAnnotationPoint | null = null; |
| 336 | // PreviewViewManager's per-WebContents `wc.ipc.on(...)` listener | 391 | let activeStroke: { target: PreviewAnnotationStrokeTarget; path: SVGPathElement } | null = null; |
| 337 | // resolves the in-flight pick promise. | 392 | let pendingCapture = false; |
| 338 | ipcRenderer.send(ELEMENT_PICKED_CHANNEL, payload); | 393 | let stylesExpanded = false; |
| | 394 | let editorWasShown = false; |
| | 395 | let editorPosition: { left: number; top: number } | null = null; |
| | 396 | let editorDrag: { pointerId: number; offsetX: number; offsetY: number } | null = null; |
| | 397 | |
| | 398 | const updateStatus = (): void => { |
| | 399 | const parts = [ |
| | 400 | selected.size > 0 ? `${selected.size} element${selected.size === 1 ? "" : "s"}` : "", |
| | 401 | regions.length > 0 ? `${regions.length} region${regions.length === 1 ? "" : "s"}` : "", |
| | 402 | strokes.length > 0 ? `${strokes.length} drawing${strokes.length === 1 ? "" : "s"}` : "", |
| | 403 | ].filter(Boolean); |
| | 404 | const hasTargets = parts.length > 0; |
| | 405 | status.textContent = parts.join(" · "); |
| | 406 | editor.style.display = hasTargets ? "flex" : "none"; |
| | 407 | submit.disabled = !hasTargets; |
| | 408 | submit.style.opacity = hasTargets ? "1" : "0.45"; |
| | 409 | adjust.style.display = selected.size > 0 ? "block" : "none"; |
| | 410 | if (selected.size === 0 && stylesExpanded) { |
| | 411 | stylesExpanded = false; |
| | 412 | stylePanel.style.display = "none"; |
| | 413 | adjust.textContent = "Adjust"; |
| | 414 | } |
| | 415 | if (hasTargets && !editorWasShown) { |
| | 416 | editorWasShown = true; |
| | 417 | window.setTimeout(() => comment.focus({ preventScroll: true }), 0); |
| 339 | } | 418 | } |
| 340 | }; | 419 | }; |
| 341 | | 420 | |
| 342 | // Re-paint when an in-flight component-name lookup resolves, but only if | 421 | const refreshToolButtons = (): void => { |
| 343 | // the same element is still under the cursor — otherwise the user moved on | 422 | for (const [candidate, button] of toolButtons) { |
| 344 | // and we'd clobber the next element's label. | 423 | const active = candidate === tool; |
| 345 | const onComponentNameResolved = (resolvedFor: Element): void => { | 424 | button.style.background = active ? ACCENT_FILL : "transparent"; |
| 346 | if (lastTarget !== resolvedFor) return; | 425 | button.style.color = active ? ACCENT : "#27272a"; |
| 347 | paintOutline(handles, resolvedFor); | 426 | } |
| | 427 | if (tool !== "select") hoverOutline.style.display = "none"; |
| | 428 | if (tool !== "marquee") marqueeBox.style.display = "none"; |
| | 429 | document.documentElement.setAttribute("data-t3code-annotation-tool", tool); |
| 348 | }; | 430 | }; |
| 349 | | 431 | |
| 350 | const onMove = (event: MouseEvent): void => { | 432 | const removeSelected = (target: SelectedElement): void => { |
| 351 | const target = pickFromPoint(event.clientX, event.clientY); | 433 | if (target.element instanceof HTMLElement || target.element instanceof SVGElement) { |
| 352 | if (target === lastTarget) return; | 434 | for (const [property, baseline] of target.baselineStyles) { |
| 353 | lastTarget = target; | 435 | if (baseline) target.element.style.setProperty(property, baseline); |
| 354 | if (target) { | 436 | else target.element.style.removeProperty(property); |
| 355 | paintOutline(handles, target); | 437 | } |
| 356 | ensureComponentName(target, onComponentNameResolved); | 438 | } |
| 357 | } else { | 439 | selected.delete(target.element); |
| 358 | clearOutline(handles); | 440 | target.outline.remove(); |
| | 441 | target.label.remove(); |
| | 442 | for (const [key, change] of styleChanges) { |
| | 443 | if (change.targetId === target.id) styleChanges.delete(key); |
| 359 | } | 444 | } |
| | 445 | updateStatus(); |
| 360 | }; | 446 | }; |
| 361 | | 447 | |
| 362 | const onScrollOrResize = (): void => { | 448 | const addSelected = (element: Element): void => { |
| 363 | if (lastTarget) paintOutline(handles, lastTarget); | 449 | if (selected.has(element)) return; |
| | 450 | const target: SelectedElement = { |
| | 451 | id: nextId("element"), |
| | 452 | element, |
| | 453 | outline: createBox(ACCENT, ACCENT_FILL), |
| | 454 | label: createLabel(), |
| | 455 | baselineStyles: new Map(), |
| | 456 | }; |
| | 457 | selected.set(element, target); |
| | 458 | root.append(target.outline, target.label); |
| | 459 | updateSelectedVisual(target); |
| | 460 | updateStatus(); |
| | 461 | if (stylesExpanded) syncStyleControls(); |
| 364 | }; | 462 | }; |
| 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 | event.preventDefault(); | 778 | event.preventDefault(); |
| 368 | event.stopPropagation(); | 779 | event.stopPropagation(); |
| 369 | const target = pickFromPoint(event.clientX, event.clientY); | 780 | }; |
| 370 | if (!target) { | 781 | |
| 371 | teardown(true, null); | 782 | const onEditorPointerMove = (event: PointerEvent): void => { |
| | 783 | if (!editorDrag || editorDrag.pointerId !== event.pointerId) return; |
| | 784 | applyEditorPosition({ |
| | 785 | left: event.clientX - editorDrag.offsetX, |
| | 786 | top: event.clientY - editorDrag.offsetY, |
| | 787 | }); |
| | 788 | event.preventDefault(); |
| | 789 | event.stopPropagation(); |
| | 790 | }; |
| | 791 | |
| | 792 | const onEditorPointerUp = (event: PointerEvent): void => { |
| | 793 | if (!editorDrag || editorDrag.pointerId !== event.pointerId) return; |
| | 794 | editorDrag = null; |
| | 795 | editorHeader.style.cursor = "grab"; |
| | 796 | if (editorHeader.hasPointerCapture(event.pointerId)) |
| | 797 | editorHeader.releasePointerCapture(event.pointerId); |
| | 798 | event.preventDefault(); |
| | 799 | event.stopPropagation(); |
| | 800 | }; |
| | 801 | editorHeader.addEventListener("pointerdown", onEditorPointerDown); |
| | 802 | editorHeader.addEventListener("pointermove", onEditorPointerMove); |
| | 803 | editorHeader.addEventListener("pointerup", onEditorPointerUp); |
| | 804 | editorHeader.addEventListener("pointercancel", onEditorPointerUp); |
| | 805 | |
| | 806 | const repaint = (): void => { |
| | 807 | for (const target of selected.values()) updateSelectedVisual(target); |
| | 808 | if (editorPosition) applyEditorPosition(editorPosition); |
| | 809 | }; |
| | 810 | |
| | 811 | const removeTargetAtPoint = (x: number, y: number): boolean => { |
| | 812 | for (const target of Array.from(selected.values()).toReversed()) { |
| | 813 | const rect = target.element.getBoundingClientRect(); |
| | 814 | if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { |
| | 815 | removeSelected(target); |
| | 816 | return true; |
| | 817 | } |
| | 818 | } |
| | 819 | const regionIndex = regions.findIndex( |
| | 820 | (region) => |
| | 821 | x >= region.rect.x && |
| | 822 | x <= region.rect.x + region.rect.width && |
| | 823 | y >= region.rect.y && |
| | 824 | y <= region.rect.y + region.rect.height, |
| | 825 | ); |
| | 826 | if (regionIndex >= 0) { |
| | 827 | const [removed] = regions.splice(regionIndex, 1); |
| | 828 | root.querySelector(`[data-region-id="${removed?.id}"]`)?.remove(); |
| | 829 | updateStatus(); |
| | 830 | return true; |
| | 831 | } |
| | 832 | const strokeIndex = strokes.findIndex( |
| | 833 | (stroke) => |
| | 834 | x >= stroke.bounds.x && |
| | 835 | x <= stroke.bounds.x + stroke.bounds.width && |
| | 836 | y >= stroke.bounds.y && |
| | 837 | y <= stroke.bounds.y + stroke.bounds.height, |
| | 838 | ); |
| | 839 | if (strokeIndex >= 0) { |
| | 840 | const [removed] = strokes.splice(strokeIndex, 1); |
| | 841 | svg.querySelector(`[data-stroke-id="${removed?.id}"]`)?.remove(); |
| | 842 | updateStatus(); |
| | 843 | return true; |
| | 844 | } |
| | 845 | return false; |
| | 846 | }; |
| | 847 | |
| | 848 | const selectElementsInRect = (rect: PreviewAnnotationRect): number => { |
| | 849 | const candidates = Array.from(document.querySelectorAll("body *")) |
| | 850 | .filter((element) => !isAnnotationNode(element)) |
| | 851 | .map((element) => ({ element, rect: element.getBoundingClientRect() })) |
| | 852 | .filter(({ rect: candidate }) => { |
| | 853 | if (candidate.width < 2 || candidate.height < 2) return false; |
| | 854 | return !( |
| | 855 | candidate.right < rect.x || |
| | 856 | candidate.left > rect.x + rect.width || |
| | 857 | candidate.bottom < rect.y || |
| | 858 | candidate.top > rect.y + rect.height |
| | 859 | ); |
| | 860 | }) |
| | 861 | .filter(({ element, rect: candidate }) => { |
| | 862 | const centerX = candidate.left + candidate.width / 2; |
| | 863 | const centerY = candidate.top + candidate.height / 2; |
| | 864 | return ( |
| | 865 | centerX >= rect.x && |
| | 866 | centerX <= rect.x + rect.width && |
| | 867 | centerY >= rect.y && |
| | 868 | centerY <= rect.y + rect.height && |
| | 869 | (element.children.length === 0 || |
| | 870 | element instanceof HTMLButtonElement || |
| | 871 | element instanceof HTMLAnchorElement || |
| | 872 | element.getAttribute("role") === "button") |
| | 873 | ); |
| | 874 | }) |
| | 875 | .sort( |
| | 876 | (left, right) => left.rect.width * left.rect.height - right.rect.width * right.rect.height, |
| | 877 | ) |
| | 878 | .slice(0, MAX_MARQUEE_ELEMENTS); |
| | 879 | for (const candidate of candidates) addSelected(candidate.element); |
| | 880 | return candidates.length; |
| | 881 | }; |
| | 882 | |
| | 883 | const clearHoverOutline = (): void => { |
| | 884 | hoverOutline.style.display = "none"; |
| | 885 | }; |
| | 886 | |
| | 887 | const onPointerMove = (event: PointerEvent): void => { |
| | 888 | if (isAnnotationNode(event.target as Element)) { |
| | 889 | clearHoverOutline(); |
| | 890 | return; |
| | 891 | } |
| | 892 | if (tool === "select" && dragStart === null) { |
| | 893 | const target = pickFromPoint(event.clientX, event.clientY); |
| | 894 | if (target) positionBox(hoverOutline, rectFromDomRect(target.getBoundingClientRect())); |
| | 895 | else clearHoverOutline(); |
| 372 | return; | 896 | return; |
| 373 | } | 897 | } |
| 374 | void captureElement(target).then((payload) => teardown(true, payload)); | 898 | clearHoverOutline(); |
| | 899 | if (tool === "marquee" && dragStart) { |
| | 900 | positionBox( |
| | 901 | marqueeBox, |
| | 902 | normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY), |
| | 903 | ); |
| | 904 | return; |
| | 905 | } |
| | 906 | if (tool === "draw" && activeStroke) { |
| | 907 | activeStroke.target.points = [ |
| | 908 | ...activeStroke.target.points, |
| | 909 | { x: event.clientX, y: event.clientY }, |
| | 910 | ]; |
| | 911 | activeStroke.target.bounds = strokeBounds( |
| | 912 | activeStroke.target.points, |
| | 913 | activeStroke.target.width, |
| | 914 | ); |
| | 915 | activeStroke.path.setAttribute("d", pathFromPoints(activeStroke.target.points)); |
| | 916 | } |
| 375 | }; | 917 | }; |
| 376 | | 918 | |
| 377 | const onKey = (event: KeyboardEvent): void => { | 919 | const onPointerDown = (event: PointerEvent): void => { |
| 378 | if (event.key === "Escape") { | 920 | if (event.button !== 0 || isAnnotationNode(event.target as Element)) return; |
| 379 | event.preventDefault(); | 921 | event.preventDefault(); |
| 380 | event.stopPropagation(); | 922 | event.stopPropagation(); |
| 381 | teardown(true, null); | 923 | if (tool === "select") { |
| | 924 | const target = pickFromPoint(event.clientX, event.clientY); |
| | 925 | if (target) toggleSelected(target, event.shiftKey); |
| | 926 | return; |
| | 927 | } |
| | 928 | if (tool === "erase") { |
| | 929 | removeTargetAtPoint(event.clientX, event.clientY); |
| | 930 | return; |
| | 931 | } |
| | 932 | dragStart = { x: event.clientX, y: event.clientY }; |
| | 933 | if (tool === "draw") { |
| | 934 | const stroke: PreviewAnnotationStrokeTarget = { |
| | 935 | id: nextId("stroke"), |
| | 936 | color: ACCENT, |
| | 937 | width: 4, |
| | 938 | points: [dragStart], |
| | 939 | bounds: { x: dragStart.x, y: dragStart.y, width: 1, height: 1 }, |
| | 940 | }; |
| | 941 | const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); |
| | 942 | path.setAttribute(OVERLAY_ATTRIBUTE, ""); |
| | 943 | path.setAttribute("data-stroke-id", stroke.id); |
| | 944 | path.setAttribute("fill", "none"); |
| | 945 | path.setAttribute("stroke", stroke.color); |
| | 946 | path.setAttribute("stroke-width", String(stroke.width)); |
| | 947 | path.setAttribute("stroke-linecap", "round"); |
| | 948 | path.setAttribute("stroke-linejoin", "round"); |
| | 949 | svg.appendChild(path); |
| | 950 | activeStroke = { target: stroke, path }; |
| | 951 | } |
| | 952 | }; |
| | 953 | |
| | 954 | const onPointerUp = (event: PointerEvent): void => { |
| | 955 | if (!dragStart) return; |
| | 956 | event.preventDefault(); |
| | 957 | event.stopPropagation(); |
| | 958 | if (tool === "marquee") { |
| | 959 | const rect = normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY); |
| | 960 | marqueeBox.style.display = "none"; |
| | 961 | if (isUsableRect(rect)) { |
| | 962 | const found = selectElementsInRect(rect); |
| | 963 | if (found === 0) { |
| | 964 | const region: PreviewAnnotationRegionTarget = { id: nextId("region"), rect }; |
| | 965 | regions.push(region); |
| | 966 | const regionBox = createBox(ACCENT, "rgba(124,58,237,.06)"); |
| | 967 | regionBox.setAttribute("data-region-id", region.id); |
| | 968 | positionBox(regionBox, rect); |
| | 969 | root.appendChild(regionBox); |
| | 970 | } |
| | 971 | } |
| | 972 | } else if (tool === "draw" && activeStroke) { |
| | 973 | if (activeStroke.target.points.length > 1) strokes.push(activeStroke.target); |
| | 974 | else activeStroke.path.remove(); |
| | 975 | activeStroke = null; |
| 382 | } | 976 | } |
| | 977 | dragStart = null; |
| | 978 | updateStatus(); |
| 383 | }; | 979 | }; |
| 384 | | 980 | |
| 385 | const onBeforeUnload = (): void => { | 981 | const onClick = (event: MouseEvent): void => { |
| 386 | teardown(true, null); | 982 | if (isAnnotationNode(event.target as Element)) return; |
| | 983 | event.preventDefault(); |
| | 984 | event.stopPropagation(); |
| 387 | }; | 985 | }; |
| 388 | | 986 | |
| 389 | // Cancellation initiated by main (CANCEL_PICK_CHANNEL). Tear down silently | 987 | const onPointerOut = (event: PointerEvent): void => { |
| 390 | // — main already knows it cancelled and is either done waiting or about to | 988 | if (event.relatedTarget === null) clearHoverOutline(); |
| 391 | // register a fresh listener for a new pick. If we sent `null` here, that | | |
| 392 | // fresh listener would receive it and resolve the new pick instantly (the | | |
| 393 | // C1 race we previously hit). | | |
| 394 | const onMainCancel = (): void => { | | |
| 395 | teardown(false, null); | | |
| 396 | }; | 989 | }; |
| 397 | | 990 | |
| 398 | // Capture-phase listeners on `window` to outrun page handlers that | 991 | const onWindowBlur = (): void => { |
| 399 | // `stopPropagation()` early. `passive: false` because we need preventDefault. | 992 | clearHoverOutline(); |
| 400 | window.addEventListener("mousemove", onMove, { capture: true, passive: true }); | | |
| 401 | window.addEventListener("click", onClick, { capture: true }); | | |
| 402 | window.addEventListener("keydown", onKey, { capture: true }); | | |
| 403 | window.addEventListener("scroll", onScrollOrResize, { capture: true, passive: true }); | | |
| 404 | window.addEventListener("resize", onScrollOrResize, { passive: true }); | | |
| 405 | window.addEventListener("beforeunload", onBeforeUnload); | | |
| 406 | ipcRenderer.on(CANCEL_PICK_CHANNEL, onMainCancel); | | |
| 407 | | | |
| 408 | // Hand a "silent teardown" to `activeSession` so that a follow-up | | |
| 409 | // `startPick()` can supersede us without echoing `null` back to main. | | |
| 410 | activeSession = { | | |
| 411 | overlay: handles.overlay, | | |
| 412 | outline: handles.outline, | | |
| 413 | label: handles.label, | | |
| 414 | teardownSilent: () => teardown(false, null), | | |
| 415 | }; | 993 | }; |
| | 994 | |
| | 995 | const restoreStyles = (): void => { |
| | 996 | for (const target of selected.values()) { |
| | 997 | if (!(target.element instanceof HTMLElement || target.element instanceof SVGElement)) |
| | 998 | continue; |
| | 999 | for (const [property, baseline] of target.baselineStyles) { |
| | 1000 | if (baseline) target.element.style.setProperty(property, baseline); |
| | 1001 | else target.element.style.removeProperty(property); |
| | 1002 | } |
| | 1003 | } |
| | 1004 | }; |
| | 1005 | |
| | 1006 | const teardown = (notifyMain: boolean): void => { |
| | 1007 | if (finished) return; |
| | 1008 | finished = true; |
| | 1009 | restoreStyles(); |
| | 1010 | window.removeEventListener("pointermove", onPointerMove, true); |
| | 1011 | window.removeEventListener("pointerdown", onPointerDown, true); |
| | 1012 | window.removeEventListener("pointerup", onPointerUp, true); |
| | 1013 | window.removeEventListener("pointerout", onPointerOut, true); |
| | 1014 | window.removeEventListener("click", onClick, true); |
| | 1015 | window.removeEventListener("blur", onWindowBlur); |
| | 1016 | window.removeEventListener("keydown", onKeyDown, true); |
| | 1017 | window.removeEventListener("scroll", repaint, true); |
| | 1018 | window.removeEventListener("resize", repaint); |
| | 1019 | editorHeader.removeEventListener("pointerdown", onEditorPointerDown); |
| | 1020 | editorHeader.removeEventListener("pointermove", onEditorPointerMove); |
| | 1021 | editorHeader.removeEventListener("pointerup", onEditorPointerUp); |
| | 1022 | editorHeader.removeEventListener("pointercancel", onEditorPointerUp); |
| | 1023 | ipcRenderer.off(CANCEL_PICK_CHANNEL, onCancel); |
| | 1024 | ipcRenderer.off(ANNOTATION_CAPTURED_CHANNEL, onCaptured); |
| | 1025 | document.documentElement.removeAttribute("data-t3code-annotation-tool"); |
| | 1026 | root.remove(); |
| | 1027 | activeSession = null; |
| | 1028 | if (notifyMain) ipcRenderer.send(ELEMENT_PICKED_CHANNEL, null); |
| | 1029 | }; |
| | 1030 | |
| | 1031 | const onCancel = (): void => teardown(false); |
| | 1032 | const onCaptured = (): void => teardown(false); |
| | 1033 | const onKeyDown = (event: KeyboardEvent): void => { |
| | 1034 | if (isAnnotationNode(event.target as Element) && event.key !== "Escape") return; |
| | 1035 | if (event.key === "Escape") { |
| | 1036 | event.preventDefault(); |
| | 1037 | event.stopPropagation(); |
| | 1038 | teardown(true); |
| | 1039 | return; |
| | 1040 | } |
| | 1041 | if (event.key === "v") tool = "select"; |
| | 1042 | else if (event.key === "r") tool = "marquee"; |
| | 1043 | else if (event.key === "d") tool = "draw"; |
| | 1044 | else if (event.key === "e") tool = "erase"; |
| | 1045 | else return; |
| | 1046 | refreshToolButtons(); |
| | 1047 | }; |
| | 1048 | |
| | 1049 | submit.addEventListener("click", () => { |
| | 1050 | if (pendingCapture || (selected.size === 0 && regions.length === 0 && strokes.length === 0)) |
| | 1051 | return; |
| | 1052 | pendingCapture = true; |
| | 1053 | submit.disabled = true; |
| | 1054 | submit.textContent = "Capturing…"; |
| | 1055 | void Promise.all( |
| | 1056 | Array.from(selected.values()).map(async (target) => { |
| | 1057 | const element = await captureElement(target.element); |
| | 1058 | if (!element) return null; |
| | 1059 | for (const change of styleChanges.values()) { |
| | 1060 | if (change.targetId === target.id) change.selector = element.selector; |
| | 1061 | } |
| | 1062 | return { |
| | 1063 | id: target.id, |
| | 1064 | element, |
| | 1065 | rect: rectFromDomRect(target.element.getBoundingClientRect()), |
| | 1066 | }; |
| | 1067 | }), |
| | 1068 | ).then((captured) => { |
| | 1069 | const elements = captured.filter((target) => target !== null); |
| | 1070 | const annotation: PreviewAnnotationPayload = { |
| | 1071 | id: nextId("annotation"), |
| | 1072 | pageUrl: location.href, |
| | 1073 | pageTitle: document.title?.trim() || null, |
| | 1074 | comment: comment.value.trim(), |
| | 1075 | elements, |
| | 1076 | regions: [...regions], |
| | 1077 | strokes: [...strokes], |
| | 1078 | styleChanges: Array.from(styleChanges.values()), |
| | 1079 | screenshot: null, |
| | 1080 | createdAt: new Date().toISOString(), |
| | 1081 | }; |
| | 1082 | editor.style.display = "none"; |
| | 1083 | toolbar.style.display = "none"; |
| | 1084 | hoverOutline.style.display = "none"; |
| | 1085 | const screenshotRect = unionRects([ |
| | 1086 | ...elements.map((target) => target.rect), |
| | 1087 | ...regions.map((region) => region.rect), |
| | 1088 | ...strokes.map((stroke) => stroke.bounds), |
| | 1089 | ]); |
| | 1090 | ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect); |
| | 1091 | }); |
| | 1092 | }); |
| | 1093 | comment.addEventListener("keydown", (event) => { |
| | 1094 | if (event.key !== "Enter" || !(event.metaKey || event.ctrlKey)) return; |
| | 1095 | event.preventDefault(); |
| | 1096 | submit.click(); |
| | 1097 | }); |
| | 1098 | |
| | 1099 | window.addEventListener("pointermove", onPointerMove, { capture: true, passive: false }); |
| | 1100 | window.addEventListener("pointerdown", onPointerDown, { capture: true, passive: false }); |
| | 1101 | window.addEventListener("pointerup", onPointerUp, { capture: true, passive: false }); |
| | 1102 | window.addEventListener("pointerout", onPointerOut, { capture: true, passive: true }); |
| | 1103 | window.addEventListener("click", onClick, { capture: true, passive: false }); |
| | 1104 | window.addEventListener("blur", onWindowBlur); |
| | 1105 | window.addEventListener("keydown", onKeyDown, { capture: true }); |
| | 1106 | window.addEventListener("scroll", repaint, { capture: true, passive: true }); |
| | 1107 | window.addEventListener("resize", repaint, { passive: true }); |
| | 1108 | ipcRenderer.on(CANCEL_PICK_CHANNEL, onCancel); |
| | 1109 | ipcRenderer.on(ANNOTATION_CAPTURED_CHANNEL, onCaptured); |
| | 1110 | document.documentElement.appendChild(root); |
| | 1111 | refreshToolButtons(); |
| | 1112 | updateStatus(); |
| | 1113 | activeSession = { teardown }; |
| 416 | } | 1114 | } |
| 417 | | 1115 | |
| 418 | ipcRenderer.on(START_PICK_CHANNEL, () => { | 1116 | ipcRenderer.on(START_PICK_CHANNEL, () => startAnnotation()); |
| 419 | startPick(); | 1117 | ipcRenderer.on(CANCEL_PICK_CHANNEL, () => activeSession?.teardown(false)); |
| 420 | }); | | |