import type { RuntimeJson, RuntimeOutboundMessage, RuntimePickerElementInfo } from "./types"; import { swallow } from "./diagnostics"; type PickerModuleDeps = { postMessage: (payload: RuntimeOutboundMessage) => void; }; const PICKER_IGNORE_SELECTOR = [ "[data-hyperframes-ignore]", "[data-hyperframes-picker-ignore]", "[data-hf-ignore]", "[data-no-inspect]", "[data-no-pick]", "[data-hyper-shader-loading]", ].join(","); const PICKER_BLOCK_SELECTOR = [ "[data-hyperframes-picker-block]", "[data-hyper-shader-loading]", ].join(","); const COLOR_GRADING_SOURCE_HIDDEN_ATTR = "data-hf-color-grading-source-hidden"; export type PickerModule = { enablePickMode: () => void; disablePickMode: () => void; installPickerApi: () => void; }; export function createPickerModule(deps: PickerModuleDeps): PickerModule { let pickModeActive = false; let pickModeHighlightEl: Element | null = null; let pickModeStyleEl: HTMLStyleElement | null = null; let pickLastHoveredInfo: RuntimePickerElementInfo | null = null; let pickLastSelectedInfo: RuntimePickerElementInfo | null = null; function emitPickerRuntimeEvent(eventName: string, detail: RuntimeJson): void { try { window.dispatchEvent(new CustomEvent(eventName, { detail })); } catch (err) { // no-op in unsupported contexts swallow("runtime.picker.site1", err); } } function setLastHoveredInfo(info: RuntimePickerElementInfo | null): void { pickLastHoveredInfo = info; emitPickerRuntimeEvent("hyperframe:picker:hovered", { elementInfo: pickLastHoveredInfo, isPickMode: pickModeActive, timestamp: Date.now(), }); } function setLastSelectedInfo(info: RuntimePickerElementInfo | null): void { pickLastSelectedInfo = info; emitPickerRuntimeEvent("hyperframe:picker:selected", { elementInfo: pickLastSelectedInfo, isPickMode: pickModeActive, timestamp: Date.now(), }); } function isEffectivelyHidden(el: HTMLElement): boolean { const win = el.ownerDocument.defaultView; if (!win) return false; let current: HTMLElement | null = el; while (current && current !== document.body && current !== document.documentElement) { const computed = win.getComputedStyle(current); if (computed.display === "none" || computed.visibility === "hidden") return true; if (computed.pointerEvents === "none") return true; const opacity = Number.parseFloat(computed.opacity); if ( Number.isFinite(opacity) && opacity <= 0.01 && !current.hasAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR) ) return true; current = current.parentElement; } return false; } function isPickableElement(el: Element | null): el is Element { if (!el || el === document.body || el === document.documentElement) return false; const tag = el.tagName.toLowerCase(); if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") return false; if (el.classList.contains("__hf-pick-highlight")) return false; if (el.closest(PICKER_IGNORE_SELECTOR)) return false; if (isEffectivelyHidden(el as HTMLElement)) return false; return true; } function blocksPickerAtPoint(el: Element | null): boolean { return Boolean(el?.closest(PICKER_BLOCK_SELECTOR)); } function buildElementSelector(el: Element): string { const htmlEl = el as HTMLElement; if (htmlEl.id) return `#${htmlEl.id}`; const compositionId = el.getAttribute("data-composition-id"); if (compositionId) return `[data-composition-id="${CSS.escape(compositionId)}"]`; const compositionSrc = el.getAttribute("data-composition-src"); if (compositionSrc) return `[data-composition-src="${CSS.escape(compositionSrc)}"]`; const track = el.getAttribute("data-track-index"); if (track) return `[data-track-index="${CSS.escape(track)}"]`; const tag = el.tagName.toLowerCase(); const parent = el.parentElement; if (!parent) return tag; const siblings = parent.querySelectorAll(`:scope > ${tag}`); if (siblings.length === 1) return tag; for (let i = 0; i < siblings.length; i += 1) { if (siblings[i] === el) return `${tag}:nth-of-type(${i + 1})`; } return tag; } function buildElementLabel(el: Element): string { const tag = el.tagName.toLowerCase(); const text = (el.textContent ?? "").trim().replace(/\s+/g, " "); const trimLabel = (value: string, maxChars: number) => value.length > maxChars ? `${value.slice(0, maxChars - 1)}…` : value; if (tag === "h1" || tag === "h2" || tag === "h3") return "Heading"; if (tag === "p" || tag === "span" || tag === "div") return text.length > 0 ? trimLabel(text, 56) : "Text"; if (tag === "img") return "Image"; if (tag === "video") return "Video"; if (tag === "audio") return "Audio"; if (tag === "svg") return "Shape"; if (el.getAttribute("data-composition-src")) return "Composition"; if (tag === "section") return "Section"; return `${tag.charAt(0).toUpperCase()}${tag.slice(1)}`; } function getPickCandidatesFromPoint(clientX: number, clientY: number, limit?: number): Element[] { const maxCandidates = typeof limit === "number" && limit > 0 ? limit : 8; let raw: Element[] = []; if (document.elementsFromPoint) { raw = document.elementsFromPoint(clientX, clientY); } else if (document.elementFromPoint) { const single = document.elementFromPoint(clientX, clientY); raw = single ? [single] : []; } if (blocksPickerAtPoint(raw[0] ?? null)) return []; const dedupe: Record = {}; const candidates: Element[] = []; for (let i = 0; i < raw.length; i += 1) { const node = raw[i]; if (!isPickableElement(node)) continue; const key = `${node.tagName}::${(node as HTMLElement).id || ""}::${i}`; if (dedupe[key]) continue; dedupe[key] = true; candidates.push(node); if (candidates.length >= maxCandidates) break; } return candidates; } function extractElementInfo(el: Element): RuntimePickerElementInfo { const rect = el.getBoundingClientRect(); const dataAttributes: Record = {}; for (let i = 0; i < el.attributes.length; i += 1) { const attr = el.attributes[i]; if (attr.name.startsWith("data-")) { dataAttributes[attr.name] = attr.value; } } const htmlEl = el as HTMLElement; return { id: htmlEl.id || null, tagName: el.tagName.toLowerCase(), selector: buildElementSelector(el), label: buildElementLabel(el), boundingBox: { x: rect.left, y: rect.top, width: rect.width, height: rect.height }, textContent: el.textContent ? el.textContent.trim().slice(0, 200) : null, src: el.getAttribute("src") || el.getAttribute("data-composition-src") || null, dataAttributes, }; } function getPickInfosFromPoint( clientX: number, clientY: number, limit?: number, ): RuntimePickerElementInfo[] { return getPickCandidatesFromPoint(clientX, clientY, limit).map(extractElementInfo); } function onPickMouseMove(event: MouseEvent): void { if (!pickModeActive) return; const candidates = getPickCandidatesFromPoint(event.clientX, event.clientY, 1); const target = candidates[0] ?? (event.target instanceof Element ? event.target : null); if (!isPickableElement(target)) return; if (pickModeHighlightEl === target) return; if (pickModeHighlightEl) { pickModeHighlightEl.classList.remove("__hf-pick-highlight"); } pickModeHighlightEl = target; target.classList.add("__hf-pick-highlight"); const info = extractElementInfo(target); setLastHoveredInfo(info); deps.postMessage({ source: "hf-preview", type: "element-hovered", elementInfo: info }); } function onPickClick(event: MouseEvent): void { if (!pickModeActive) return; event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); const infos = getPickInfosFromPoint(event.clientX, event.clientY, 8); if (infos.length === 0) return; setLastHoveredInfo(infos[0] ?? null); deps.postMessage({ source: "hf-preview", type: "element-pick-candidates", candidates: infos, selectedIndex: 0, point: { x: event.clientX, y: event.clientY }, }); } function onPickKeyDown(event: KeyboardEvent): void { if (event.key !== "Escape") return; disablePickMode(); deps.postMessage({ source: "hf-preview", type: "pick-mode-cancelled" }); } function enablePickMode(): void { if (pickModeActive) return; pickModeActive = true; pickModeStyleEl = document.createElement("style"); pickModeStyleEl.textContent = [ ".__hf-pick-highlight { outline: 2px solid #4f8cf7 !important; outline-offset: 2px; cursor: crosshair !important; }", ".__hf-pick-active * { cursor: crosshair !important; }", ].join("\n"); document.head.appendChild(pickModeStyleEl); document.body.classList.add("__hf-pick-active"); document.addEventListener("mousemove", onPickMouseMove, true); document.addEventListener("click", onPickClick, true); document.addEventListener("keydown", onPickKeyDown, true); emitPickerRuntimeEvent("hyperframe:picker:mode", { isPickMode: true, timestamp: Date.now() }); } function disablePickMode(): void { if (!pickModeActive) return; pickModeActive = false; if (pickModeHighlightEl) { pickModeHighlightEl.classList.remove("__hf-pick-highlight"); pickModeHighlightEl = null; } if (pickModeStyleEl) { pickModeStyleEl.remove(); pickModeStyleEl = null; } document.body.classList.remove("__hf-pick-active"); document.removeEventListener("mousemove", onPickMouseMove, true); document.removeEventListener("click", onPickClick, true); document.removeEventListener("keydown", onPickKeyDown, true); emitPickerRuntimeEvent("hyperframe:picker:mode", { isPickMode: false, timestamp: Date.now() }); } function installPickerApi(): void { window.__HF_PICKER_API = { enable: enablePickMode, disable: disablePickMode, isActive: () => pickModeActive, getHovered: () => pickLastHoveredInfo, getSelected: () => pickLastSelectedInfo, getCandidatesAtPoint: (clientX, clientY, limit) => Number.isFinite(clientX) && Number.isFinite(clientY) ? getPickInfosFromPoint(clientX, clientY, limit) : [], pickAtPoint: (clientX, clientY, index) => { if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) return null; const infos = getPickInfosFromPoint(clientX, clientY, 8); if (!infos.length) return null; const safeIndex = Math.max(0, Math.min(infos.length - 1, Number(index ?? 0))); const selected = infos[safeIndex] ?? null; if (!selected) return null; setLastSelectedInfo(selected); deps.postMessage({ source: "hf-preview", type: "element-picked", elementInfo: selected }); disablePickMode(); return selected; }, pickManyAtPoint: (clientX, clientY, indexes) => { if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) return []; const infos = getPickInfosFromPoint(clientX, clientY, 8); if (!infos.length) return []; const selected: RuntimePickerElementInfo[] = []; const rawIndexes = Array.isArray(indexes) ? indexes : [0]; for (const rawIndex of rawIndexes) { const idx = Math.max(0, Math.min(infos.length - 1, Math.floor(Number(rawIndex)))); const info = infos[idx]; if (!info) continue; const duplicate = selected.some( (item) => item.selector === info.selector && item.tagName === info.tagName, ); if (!duplicate) selected.push(info); } if (!selected.length) return []; setLastSelectedInfo(selected[0] ?? null); deps.postMessage({ source: "hf-preview", type: "element-picked-many", elementInfos: selected, }); disablePickMode(); return selected; }, }; emitPickerRuntimeEvent("hyperframe:picker:api-ready", { hasApi: true, timestamp: Date.now() }); } return { enablePickMode, disablePickMode, installPickerApi }; }