{"version":3,"sources":["../../src/injections/utils.ts","../../src/injections/layer-dropdown/consts.ts","../../src/injections/layer-dropdown/utils.ts","../../src/injections/layer-dropdown/dropdown-ui.ts","../../src/injections/layer-dropdown/controller.ts","../../src/capabilities/inline-edit/dom-utils.ts","../../src/capabilities/inline-edit/controller.ts","../../src/consts.ts","../../src/injections/canvas-wheel-zoom-bridge.ts","../../src/injections/page-height-bridge.ts","../../src/injections/auto-popup-suppressor.ts","../../src/injections/visual-edit-agent.ts"],"sourcesContent":["const LABEL_HEIGHT = 27;\n\n/**\n * Positions an element-tag label relative to its highlighted overlay.\n *\n * Strategy (in priority order):\n * 1. If the element is near the viewport top AND tall enough (>= 2x label height),\n * place the label **inside** the element at the top-left.\n * 2. If the element is near the viewport top but too short, place the label\n * **below** the element.\n * 3. Otherwise, place the label **above** the element (default).\n *\n * For full-width elements (spanning nearly the entire viewport), the left offset\n * is nudged inward (6px) to prevent clipping against the viewport edge.\n *\n * @param label - The label div to position (style.top and style.left are set).\n * @param rect - The bounding client rect of the highlighted element.\n */\nexport function positionLabel(label: HTMLDivElement, rect: DOMRect): void {\n const nearTop = rect.top < LABEL_HEIGHT;\n const tallEnough = rect.height >= LABEL_HEIGHT * 2;\n const isFullWidth = rect.width >= window.innerWidth - 4;\n const edgeLeft = isFullWidth ? \"8px\" : \"-2px\";\n const insideLeft = isFullWidth ? \"8px\" : \"4px\";\n\n if (nearTop && tallEnough) {\n label.style.top = \"2px\";\n label.style.left = insideLeft;\n } else if (nearTop) {\n label.style.top = `${rect.height + 2}px`;\n label.style.left = edgeLeft;\n } else {\n label.style.top = `-${LABEL_HEIGHT}px`;\n label.style.left = edgeLeft;\n }\n}\n\n/** Check if an element has instrumentation attributes */\nexport function isInstrumentedElement(element: Element): boolean {\n const htmlEl = element as HTMLElement;\n return !!(\n htmlEl.dataset?.sourceLocation || htmlEl.dataset?.visualSelectorId\n );\n}\n\n/** Get the selector ID from an element's data attributes (prefers source-location) */\nexport function getElementSelectorId(element: Element): string | null {\n const htmlEl = element as HTMLElement;\n return (\n htmlEl.dataset?.sourceLocation ||\n htmlEl.dataset?.visualSelectorId ||\n null\n );\n}\n\nexport const ALLOWED_ATTRIBUTES: string[] = [\"src\"];\n\nexport const PLUGIN_ELEMENT_ATTR = \"data-vite-plugin-element\";\n\n/** Find elements by ID - first try data-source-location, fallback to data-visual-selector-id */\nexport function findElementsById(id: string | null): Element[] {\n if (!id) return [];\n const sourceElements = Array.from(\n document.querySelectorAll(`[data-source-location=\"${id}\"]`)\n );\n if (sourceElements.length > 0) {\n return sourceElements;\n }\n return Array.from(\n document.querySelectorAll(`[data-visual-selector-id=\"${id}\"]`)\n );\n}\n\n/**\n * Update element classes by visual selector ID.\n * Uses setAttribute instead of className to support both HTML and SVG elements.\n */\nexport function updateElementClasses(elements: Element[], classes: string): void {\n elements.forEach((element) => {\n element.setAttribute(\"class\", classes);\n });\n}\n\n/** Set a single attribute on all provided elements. */\nexport function updateElementAttribute(elements: Element[], attribute: string, value: string, arrIndex?: number | string): void {\n if (!ALLOWED_ATTRIBUTES.includes(attribute)) {\n return;\n }\n\n const targetArrIndex = arrIndex != null ? String(arrIndex) : null;\n elements.forEach((element) => {\n if (\n targetArrIndex === null ||\n (element as HTMLElement).dataset.arrIndex === targetArrIndex\n ) {\n element.setAttribute(attribute, value);\n }\n });\n}\n\n/** Collect attribute values from an element for a given allowlist. */\nexport function collectAllowedAttributes(element: Element, allowedAttributes: string[]): Record {\n const attributes: Record = {};\n for (const attr of allowedAttributes) {\n const val = element.getAttribute(attr);\n if (val !== null) {\n attributes[attr] = val;\n }\n }\n return attributes;\n}\n\n/**\n * Freeze all CSS animations and transitions on the page by injecting\n * scoped styles under `[data-visual-edit-active]` and programmatically\n * finishing (or pausing) every running animation.\n *\n * Plugin-owned elements (`[data-vite-plugin-element]`) are excluded so\n * the plugin UI stays animated.\n */\nexport function stopAnimations(): void {\n if (document.getElementById('freeze-animations')) return;\n\n document.documentElement.setAttribute('data-visual-edit-active', '');\n\n const animStyle = document.createElement('style');\n animStyle.id = 'freeze-animations';\n animStyle.textContent = `\n [data-visual-edit-active] *:not([${PLUGIN_ELEMENT_ATTR}]):not([${PLUGIN_ELEMENT_ATTR}] *),\n [data-visual-edit-active] *:not([${PLUGIN_ELEMENT_ATTR}]):not([${PLUGIN_ELEMENT_ATTR}] *)::before,\n [data-visual-edit-active] *:not([${PLUGIN_ELEMENT_ATTR}]):not([${PLUGIN_ELEMENT_ATTR}] *)::after {\n animation-play-state: paused !important;\n transition: none !important;\n }\n `;\n\n const pointerStyle = document.createElement('style');\n pointerStyle.id = 'freeze-pointer-events';\n pointerStyle.textContent = `\n [data-visual-edit-active] * { pointer-events: none !important; }\n [${PLUGIN_ELEMENT_ATTR}], [${PLUGIN_ELEMENT_ATTR}] * { pointer-events: auto !important; }\n `;\n\n const target = document.head || document.documentElement;\n target.appendChild(animStyle);\n target.appendChild(pointerStyle);\n\n document.getAnimations().forEach((a) => {\n // Skip animations on plugin UI elements\n const animTarget = (a.effect as KeyframeEffect)?.target;\n if (animTarget instanceof Element && animTarget.closest(`[${PLUGIN_ELEMENT_ATTR}]`)) return;\n\n try {\n a.finish(); // fast-forward to end state\n } catch {\n a.pause(); // finish() throws on infinite animations — pause instead\n }\n });\n}\n\n/**\n * Resume all previously frozen animations and remove the injected\n * freeze styles. Cleans up the `data-visual-edit-active` attribute\n * from `` so scoped selectors no longer match.\n */\nexport function resumeAnimations(): void {\n const animStyle = document.getElementById('freeze-animations');\n if (!animStyle) return;\n\n animStyle.remove();\n document.getElementById('freeze-pointer-events')?.remove();\n document.documentElement.removeAttribute('data-visual-edit-active');\n\n document.getAnimations().forEach((a) => {\n if (a.playState === 'paused') {\n try { a.play(); } catch { /* animation target may have been removed */ }\n }\n });\n}\n\n/**\n * Hit-test the page at (`x`, `y`) and walk up the DOM to find the\n * nearest ancestor that carries instrumentation attributes\n * (`data-source-location` or `data-visual-selector-id`).\n *\n * Temporarily disables the pointer-events freeze sheet so the\n * browser's native `elementFromPoint` can reach the real target.\n */\nexport function findInstrumentedElement(x: number, y: number): Element | null {\n const pointerStyle = document.getElementById('freeze-pointer-events') as HTMLStyleElement | null;\n if (pointerStyle) pointerStyle.disabled = true;\n\n const el = document.elementFromPoint(x, y);\n\n if (pointerStyle) pointerStyle.disabled = false;\n\n return el?.closest('[data-source-location], [data-visual-selector-id]') ?? null;\n}\n\n/**\n * Resolve which element should be hovered at (`x`, `y`), skipping the\n * currently selected element. Returns the selector ID of the hovered\n * element, or `null` if the point is empty or hits the selected element.\n */\nexport function resolveHoverTarget(x: number, y: number, selectedElementId: string | null): string | null {\n const element = findInstrumentedElement(x, y);\n if (!element) return null;\n\n const selectorId = getElementSelectorId(element);\n\n if (selectorId === selectedElementId) return null;\n\n return selectorId;\n}\n\nconst VISUAL_EDIT_FONT_FACE_ID_PREFIX = \"visual-edit-font-face-\";\n\nfunction isValidFontFaceCss(css: string): boolean {\n const trimmed = css.trim();\n if (!trimmed.startsWith(\"@font-face\")) return false;\n\n if (trimmed.includes(\"\\\\\")) return false; // Prevent CSS escapes\n\n const openBraces = (trimmed.match(/{/g) || []).length;\n const closeBraces = (trimmed.match(/}/g) || []).length;\n if (openBraces !== 1 || closeBraces !== 1 || !trimmed.endsWith(\"}\")) return false;\n\n const chunks = trimmed.split(/url\\(/i);\n for (let i = 1; i < chunks.length; i++) {\n const end = chunks[i]!.indexOf(\")\");\n if (end === -1) return false;\n const url = chunks[i]!.slice(0, end).trim().replace(/^['\"]|['\"]$/g, \"\").trim();\n if (!/^https:\\/\\//i.test(url)) return false;\n }\n\n return true;\n}\n\nexport function injectFontFaceCss(id: string, css: string): void {\n if (typeof id !== \"string\" || !id.trim()) return;\n if (typeof css !== \"string\" || !isValidFontFaceCss(css)) return;\n\n const styleId = `${VISUAL_EDIT_FONT_FACE_ID_PREFIX}${id}`;\n if (document.getElementById(styleId)) return;\n\n const style = document.createElement(\"style\");\n style.id = styleId;\n style.dataset.visualEditFontFace = \"true\";\n style.textContent = css;\n document.head.appendChild(style);\n}\n","/** Style constants for the layer dropdown UI */\n\nexport const DROPDOWN_CONTAINER_STYLES: Record = {\n position: \"absolute\",\n backgroundColor: \"#ffffff\",\n border: \"1px solid #e2e8f0\",\n borderRadius: \"6px\",\n boxShadow: \"0 4px 12px rgba(0, 0, 0, 0.15)\",\n fontSize: \"12px\",\n minWidth: \"120px\",\n maxHeight: \"200px\",\n overflowY: \"auto\",\n zIndex: \"10001\",\n padding: \"4px 0\",\n pointerEvents: \"auto\",\n};\n\nexport const DROPDOWN_ITEM_BASE_STYLES: Record = {\n padding: \"4px 12px\",\n cursor: \"pointer\",\n color: \"#334155\",\n backgroundColor: \"transparent\",\n whiteSpace: \"nowrap\",\n lineHeight: \"1.5\",\n fontWeight: \"400\",\n};\n\nexport const DROPDOWN_ITEM_ACTIVE_COLOR = \"#526cff\";\nexport const DROPDOWN_ITEM_ACTIVE_BG = \"#DBEAFE\";\nexport const DROPDOWN_ITEM_ACTIVE_FONT_WEIGHT = \"600\";\n\nexport const DROPDOWN_ITEM_HOVER_BG = \"#f1f5f9\";\n\nexport const DEPTH_INDENT_PX = 10;\n\n/** SVG chevron shown when dropdown is collapsed (click to expand) */\nexport const CHEVRON_COLLAPSED = ``;\n/** SVG chevron shown when dropdown is expanded (click to collapse) */\nexport const CHEVRON_EXPANDED = ``;\n\nexport const CHEVRON_ATTR = \"data-chevron\";\n\nexport const BASE_PADDING_PX = 12;\n\nexport const LAYER_DROPDOWN_ATTR = \"data-layer-dropdown\";\n\n/** Max instrumented ancestors to show above the selected element */\nexport const MAX_PARENT_DEPTH = 2;\n\n/** Max instrumented depth levels to show below the selected element */\nexport const MAX_CHILD_DEPTH = 2;\n","/** DOM utilities for the layer-dropdown module */\n\nimport { isInstrumentedElement, getElementSelectorId } from \"../utils.js\";\nimport { MAX_PARENT_DEPTH, MAX_CHILD_DEPTH } from \"./consts.js\";\n\nimport type { LayerInfo } from \"./types.js\";\n\n/** Apply a style map to an element */\nexport function applyStyles(element: HTMLElement, styles: Record): void {\n for (const key of Object.keys(styles)) {\n element.style.setProperty(\n key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`),\n styles[key]!\n );\n }\n}\n\n/** Display name for a layer — just the real tag name */\nexport function getLayerDisplayName(layer: LayerInfo): string {\n return layer.tagName;\n}\n\nfunction toLayerInfo(element: Element, depth?: number): LayerInfo {\n const info: LayerInfo = {\n element,\n tagName: element.tagName.toLowerCase(),\n selectorId: getElementSelectorId(element),\n };\n if (depth !== undefined) info.depth = depth;\n return info;\n}\n\n/**\n * Collect instrumented descendants up to `maxDepth` instrumented nesting levels.\n * Non-instrumented wrappers are walked through without counting toward depth.\n * Results are in DOM order.\n * When `startDepth` is provided, assigns `depth` to each item during collection.\n */\nexport function getInstrumentedDescendants(\n parent: Element,\n maxDepth: number,\n startDepth?: number\n): LayerInfo[] {\n const result: LayerInfo[] = [];\n\n function walk(el: Element, instrDepth: number): void {\n if (instrDepth > maxDepth) return;\n for (let i = 0; i < el.children.length; i++) {\n const child = el.children[i]!;\n if (isInstrumentedElement(child)) {\n const info: LayerInfo = {\n element: child,\n tagName: child.tagName.toLowerCase(),\n selectorId: getElementSelectorId(child),\n };\n if (startDepth !== undefined) {\n info.depth = startDepth + instrDepth - 1;\n }\n result.push(info);\n walk(child, instrDepth + 1);\n } else {\n walk(child, instrDepth);\n }\n }\n }\n\n walk(parent, 1);\n return result;\n}\n\n/** Collect instrumented ancestors from selected element up to MAX_PARENT_DEPTH (outermost first). */\nfunction collectInstrumentedParents(selectedElement: Element): LayerInfo[] {\n const parents: LayerInfo[] = [];\n let current = selectedElement.parentElement;\n while (\n current &&\n current !== document.documentElement &&\n current !== document.body &&\n parents.length < MAX_PARENT_DEPTH\n ) {\n if (isInstrumentedElement(current)) {\n parents.push(toLayerInfo(current));\n }\n current = current.parentElement;\n }\n parents.reverse();\n return parents;\n}\n\n/** Add parents to chain with depth 0, 1, …; returns depth of selected (parents.length). */\nfunction addParentsToChain(chain: LayerInfo[], parents: LayerInfo[]): number {\n parents.forEach((p, i) => {\n chain.push({ ...p, depth: i });\n });\n return parents.length;\n}\n\n/** Add selected element and its descendants at the given depth. */\nfunction addSelfAndDescendantsToChain(\n chain: LayerInfo[],\n selectedElement: Element,\n selfDepth: number\n): void {\n chain.push(toLayerInfo(selectedElement, selfDepth));\n const descendants = getInstrumentedDescendants(\n selectedElement,\n MAX_CHILD_DEPTH,\n selfDepth + 1\n );\n chain.push(...descendants);\n}\n\n/** Get the innermost instrumented parent's DOM element, or null if none. */\nfunction getImmediateInstrParent(parents: LayerInfo[]): Element | null {\n return parents.at(-1)?.element ?? null;\n}\n\n/** Collect instrumented siblings of the selected element from its parent (DOM order). */\nfunction collectSiblings(parent: Element, selectedElement: Element): LayerInfo[] {\n const siblings = getInstrumentedDescendants(parent, 1);\n if (!siblings.some((s) => s.element === selectedElement)) {\n siblings.push(toLayerInfo(selectedElement));\n }\n return siblings;\n}\n\n/** Add siblings at selfDepth, expanding children only for the selected element. */\nfunction appendSiblingsWithSelected(\n chain: LayerInfo[],\n siblings: LayerInfo[],\n selectedElement: Element,\n selfDepth: number\n): void {\n const selectedSelectorId = getElementSelectorId(selectedElement);\n const seen = new Set();\n for (const sibling of siblings) {\n if (sibling.element === selectedElement) {\n addSelfAndDescendantsToChain(chain, selectedElement, selfDepth);\n if (selectedSelectorId) seen.add(selectedSelectorId);\n } else {\n const id = sibling.selectorId;\n if (id != null) {\n if (id === selectedSelectorId || seen.has(id)) continue;\n seen.add(id);\n }\n chain.push({ ...sibling, depth: selfDepth });\n }\n }\n}\n\n/**\n * Build the layer chain for the dropdown:\n *\n * Parents – up to MAX_PARENT_DEPTH instrumented ancestors, outer → inner.\n * Siblings – instrumented children of the immediate parent, at the same depth.\n * Current – the selected element (highlighted), with children expanded.\n * Children – instrumented descendants within MAX_CHILD_DEPTH levels, DOM order.\n *\n * Each item carries a `depth` for visual indentation.\n */\nexport function buildLayerChain(selectedElement: Element): LayerInfo[] {\n const parents = collectInstrumentedParents(selectedElement);\n const chain: LayerInfo[] = [];\n const selfDepth = addParentsToChain(chain, parents);\n\n const instrParent = getImmediateInstrParent(parents);\n if (instrParent) {\n const siblings = collectSiblings(instrParent, selectedElement);\n appendSiblingsWithSelected(chain, siblings, selectedElement, selfDepth);\n } else {\n addSelfAndDescendantsToChain(chain, selectedElement, selfDepth);\n }\n\n return chain;\n}\n","/** Dropdown UI component for layer navigation */\n\nimport {\n DROPDOWN_CONTAINER_STYLES,\n DROPDOWN_ITEM_BASE_STYLES,\n DROPDOWN_ITEM_ACTIVE_COLOR,\n DROPDOWN_ITEM_ACTIVE_BG,\n DROPDOWN_ITEM_ACTIVE_FONT_WEIGHT,\n DROPDOWN_ITEM_HOVER_BG,\n DEPTH_INDENT_PX,\n BASE_PADDING_PX,\n CHEVRON_COLLAPSED,\n CHEVRON_EXPANDED,\n CHEVRON_ATTR,\n LAYER_DROPDOWN_ATTR,\n} from \"./consts.js\";\nimport { applyStyles, getLayerDisplayName } from \"./utils.js\";\nimport type { LayerInfo, DropdownCallbacks } from \"./types.js\";\nimport { PLUGIN_ELEMENT_ATTR } from \"../utils.js\";\n\nlet activeDropdown: HTMLDivElement | null = null;\nlet activeLabel: HTMLDivElement | null = null;\nlet outsideMousedownHandler: ((e: MouseEvent) => void) | null = null;\nlet activeOnHoverEnd: (() => void) | null = null;\nlet activeKeydownHandler: ((e: KeyboardEvent) => void) | null = null;\n\nfunction createDropdownItem(\n layer: LayerInfo,\n isActive: boolean,\n { onSelect, onHover, onHoverEnd }: DropdownCallbacks\n): HTMLDivElement {\n const item = document.createElement(\"div\");\n item.textContent = getLayerDisplayName(layer);\n applyStyles(item, DROPDOWN_ITEM_BASE_STYLES);\n\n const depth = layer.depth ?? 0;\n if (depth > 0) {\n item.style.paddingLeft = `${BASE_PADDING_PX + depth * DEPTH_INDENT_PX}px`;\n }\n\n if (isActive) {\n item.style.color = DROPDOWN_ITEM_ACTIVE_COLOR;\n item.style.backgroundColor = DROPDOWN_ITEM_ACTIVE_BG;\n item.style.fontWeight = DROPDOWN_ITEM_ACTIVE_FONT_WEIGHT;\n }\n\n item.addEventListener(\"mouseenter\", () => {\n if (!isActive) item.style.backgroundColor = DROPDOWN_ITEM_HOVER_BG;\n if (onHover) onHover(layer);\n });\n\n item.addEventListener(\"mouseleave\", () => {\n if (!isActive) item.style.backgroundColor = \"transparent\";\n if (onHoverEnd) onHoverEnd();\n });\n\n item.addEventListener(\"click\", (e: MouseEvent) => {\n e.stopPropagation();\n e.preventDefault();\n onSelect(layer);\n });\n\n return item;\n}\n\n/** Create the dropdown DOM element with layer items */\nexport function createDropdownElement(\n layers: LayerInfo[],\n currentElement: Element | null,\n callbacks: DropdownCallbacks\n): HTMLDivElement {\n const container = document.createElement(\"div\");\n container.setAttribute(LAYER_DROPDOWN_ATTR, \"true\");\n container.setAttribute(PLUGIN_ELEMENT_ATTR, \"true\");\n applyStyles(container, DROPDOWN_CONTAINER_STYLES);\n\n layers.forEach((layer) => {\n const isActive = layer.element === currentElement;\n container.appendChild(createDropdownItem(layer, isActive, callbacks));\n });\n\n return container;\n}\n\n/** Add chevron indicator and pointer-events to the label */\nexport function enhanceLabelWithChevron(label: HTMLDivElement): void {\n if (label.querySelector(`[${CHEVRON_ATTR}]`)) return;\n\n const chevron = document.createElement(\"span\");\n chevron.setAttribute(CHEVRON_ATTR, \"true\");\n chevron.style.display = \"inline-flex\";\n chevron.innerHTML = CHEVRON_COLLAPSED;\n label.appendChild(chevron);\n\n label.style.display = \"inline-flex\";\n label.style.alignItems = \"center\";\n label.style.cursor = \"pointer\";\n label.style.userSelect = \"none\";\n label.style.whiteSpace = \"nowrap\";\n label.style.pointerEvents = \"auto\";\n label.setAttribute(LAYER_DROPDOWN_ATTR, \"true\");\n label.setAttribute(PLUGIN_ELEMENT_ATTR, \"true\");\n}\n\nfunction setupKeyboardNavigation(\n dropdown: HTMLDivElement,\n layers: LayerInfo[],\n currentElement: Element | null,\n { onSelect, onHover, onHoverEnd }: DropdownCallbacks\n): void {\n const items = Array.from(dropdown.children) as HTMLDivElement[];\n let focusedIndex = layers.findIndex((l) => l.element === currentElement);\n\n const setFocusedItem = (index: number) => {\n if (focusedIndex >= 0 && focusedIndex < items.length) {\n const prev = items[focusedIndex]!;\n if (prev.style.color !== DROPDOWN_ITEM_ACTIVE_COLOR) {\n prev.style.backgroundColor = \"transparent\";\n }\n }\n focusedIndex = index;\n if (focusedIndex >= 0 && focusedIndex < items.length) {\n const cur = items[focusedIndex]!;\n if (cur.style.color !== DROPDOWN_ITEM_ACTIVE_COLOR) {\n cur.style.backgroundColor = DROPDOWN_ITEM_HOVER_BG;\n }\n cur.scrollIntoView({ block: \"nearest\" });\n if (onHover && focusedIndex >= 0 && focusedIndex < layers.length) {\n onHover(layers[focusedIndex]!);\n }\n }\n };\n\n activeKeydownHandler = (e: KeyboardEvent) => {\n if (e.key === \"ArrowDown\") {\n e.preventDefault();\n e.stopPropagation();\n setFocusedItem(focusedIndex < items.length - 1 ? focusedIndex + 1 : 0);\n } else if (e.key === \"ArrowUp\") {\n e.preventDefault();\n e.stopPropagation();\n setFocusedItem(focusedIndex > 0 ? focusedIndex - 1 : items.length - 1);\n } else if (e.key === \"Enter\" && focusedIndex >= 0 && focusedIndex < layers.length) {\n e.preventDefault();\n e.stopPropagation();\n if (onHoverEnd) onHoverEnd();\n onSelect(layers[focusedIndex]!);\n closeDropdown();\n }\n };\n document.addEventListener(\"keydown\", activeKeydownHandler, true);\n}\n\nfunction setupOutsideClickHandler(\n dropdown: HTMLDivElement,\n label: HTMLDivElement\n): void {\n let skipFirst = true;\n outsideMousedownHandler = (e: MouseEvent) => {\n if (skipFirst) { skipFirst = false; return; }\n const target = e.target as Node;\n if (!dropdown.contains(target) && target !== label) {\n closeDropdown();\n }\n };\n document.addEventListener(\"mousedown\", outsideMousedownHandler, true);\n}\n\n/** Show the dropdown below the label element */\nexport function showDropdown(\n label: HTMLDivElement,\n layers: LayerInfo[],\n currentElement: Element | null,\n callbacks: DropdownCallbacks\n): void {\n closeDropdown();\n\n const dropdown = createDropdownElement(\n layers,\n currentElement,\n {\n ...callbacks,\n onSelect: (layer) => {\n if (callbacks.onHoverEnd) callbacks.onHoverEnd();\n callbacks.onSelect(layer);\n closeDropdown();\n },\n }\n );\n\n const overlay = label.parentElement;\n if (!overlay) return;\n\n dropdown.style.top = `${label.offsetTop + label.offsetHeight + 2}px`;\n dropdown.style.left = `${label.offsetLeft}px`;\n\n overlay.appendChild(dropdown);\n activeDropdown = dropdown;\n activeLabel = label;\n const chevronEl = label.querySelector(`[${CHEVRON_ATTR}]`);\n if (chevronEl) {\n chevronEl.innerHTML = CHEVRON_EXPANDED;\n }\n activeOnHoverEnd = callbacks.onHoverEnd ?? null;\n\n setupKeyboardNavigation(dropdown, layers, currentElement, callbacks);\n setupOutsideClickHandler(dropdown, label);\n}\n\n/** Close the active dropdown and clean up listeners */\nexport function closeDropdown(): void {\n const chevronEl = activeLabel?.querySelector(`[${CHEVRON_ATTR}]`);\n if (chevronEl) {\n chevronEl.innerHTML = CHEVRON_COLLAPSED;\n }\n activeLabel = null;\n\n if (activeOnHoverEnd) {\n activeOnHoverEnd();\n activeOnHoverEnd = null;\n }\n\n if (activeDropdown && activeDropdown.parentNode) {\n activeDropdown.remove();\n }\n activeDropdown = null;\n\n if (outsideMousedownHandler) {\n document.removeEventListener(\"mousedown\", outsideMousedownHandler, true);\n outsideMousedownHandler = null;\n }\n\n if (activeKeydownHandler) {\n document.removeEventListener(\"keydown\", activeKeydownHandler, true);\n activeKeydownHandler = null;\n }\n}\n\n/** Check if a dropdown is currently visible */\nexport function isDropdownOpen(): boolean {\n return activeDropdown !== null;\n}\n","/** Controller that encapsulates layer-dropdown integration logic */\n\nimport { getElementSelectorId } from \"../utils.js\";\nimport { buildLayerChain } from \"./utils.js\";\nimport {\n enhanceLabelWithChevron,\n showDropdown,\n closeDropdown,\n isDropdownOpen,\n} from \"./dropdown-ui.js\";\nimport type { LayerInfo, LayerControllerConfig, LayerController } from \"./types.js\";\n\nexport function createLayerController(config: LayerControllerConfig): LayerController {\n let layerPreviewOverlay: HTMLDivElement | null = null;\n let escapeHandler: ((e: KeyboardEvent) => void) | null = null;\n let dropdownSourceLayer: LayerInfo | null = null;\n\n const clearLayerPreview = () => {\n if (layerPreviewOverlay && layerPreviewOverlay.parentNode) {\n layerPreviewOverlay.remove();\n }\n layerPreviewOverlay = null;\n };\n\n const showLayerPreview = (layer: LayerInfo) => {\n clearLayerPreview();\n if (getElementSelectorId(layer.element) === config.getSelectedElementId()) return;\n\n layerPreviewOverlay = config.createPreviewOverlay(layer.element);\n };\n\n const selectLayer = (layer: LayerInfo) => {\n clearLayerPreview();\n closeDropdown();\n if (escapeHandler) {\n document.removeEventListener(\"keydown\", escapeHandler, true);\n escapeHandler = null;\n }\n dropdownSourceLayer = null;\n\n const firstOverlay = config.selectElement(layer.element);\n attachToOverlay(firstOverlay, layer.element);\n };\n\n const restoreSelection = () => {\n if (escapeHandler) {\n document.removeEventListener(\"keydown\", escapeHandler, true);\n escapeHandler = null;\n }\n if (dropdownSourceLayer) {\n selectLayer(dropdownSourceLayer);\n dropdownSourceLayer = null;\n }\n };\n\n const handleLabelClick = (e: MouseEvent, label: HTMLDivElement, element: Element, layers: LayerInfo[], currentId: string | null) => {\n e.stopPropagation();\n e.preventDefault();\n if (isDropdownOpen()) {\n closeDropdown();\n restoreSelection();\n } else {\n dropdownSourceLayer = {\n element,\n tagName: element.tagName.toLowerCase(),\n selectorId: currentId,\n };\n config.onDeselect();\n\n escapeHandler = (ev: KeyboardEvent) => {\n if (ev.key === \"Escape\") {\n ev.stopPropagation();\n closeDropdown();\n restoreSelection();\n }\n };\n document.addEventListener(\"keydown\", escapeHandler, true);\n\n showDropdown(label, layers, element, { onSelect: selectLayer, onHover: showLayerPreview, onHoverEnd: clearLayerPreview });\n }\n };\n\n const attachToOverlay = (\n overlay: HTMLDivElement | undefined,\n element: Element\n ) => {\n if (!overlay) return;\n\n const label = overlay.querySelector(\"div\") as HTMLDivElement | null;\n if (!label) return;\n\n const layers = buildLayerChain(element);\n if (layers.length <= 1) return;\n\n const currentId = getElementSelectorId(element);\n enhanceLabelWithChevron(label);\n\n label.addEventListener(\"click\", (e: MouseEvent) => {\n handleLabelClick(e, label, element, layers, currentId);\n });\n };\n\n const cleanup = () => {\n clearLayerPreview();\n closeDropdown();\n };\n\n return { attachToOverlay, cleanup };\n}\n","const FOCUS_STYLE_ID = \"visual-edit-focus-styles\";\n\nconst EDITABLE_TAGS = [\n \"div\", \"p\", \"h1\", \"h2\", \"h3\", \"h4\", \"h5\", \"h6\",\n \"span\", \"li\", \"td\", \"a\", \"button\", \"label\",\n];\n\nexport const isStaticArrayTextElement = (element: HTMLElement): boolean => {\n return !!element.dataset.arrField;\n};\n\nconst passesStructuralChecks = (element: HTMLElement): boolean => {\n if (!EDITABLE_TAGS.includes(element.tagName.toLowerCase())) return false;\n if (!element.textContent?.trim()) return false;\n if (element.querySelector(\"img, video, canvas, svg\")) return false;\n if (element.children?.length > 0) return false;\n return true;\n};\n\nexport const injectFocusOutlineCSS = () => {\n if (document.getElementById(FOCUS_STYLE_ID)) return;\n\n const style = document.createElement(\"style\");\n style.id = FOCUS_STYLE_ID;\n style.textContent = `\n [data-selected=\"true\"][contenteditable=\"true\"]:focus {\n outline: none !important;\n }\n `;\n document.head.appendChild(style);\n};\n\nexport const removeFocusOutlineCSS = () => {\n document.getElementById(FOCUS_STYLE_ID)?.remove();\n};\n\nexport const selectText = (element: HTMLElement) => {\n const range = document.createRange();\n range.selectNodeContents(element);\n const selection = window.getSelection();\n selection?.removeAllRanges();\n selection?.addRange(range);\n};\n\nexport const isEditableTextElement = (element: Element): boolean => {\n if (!(element instanceof HTMLElement)) return false;\n if (!passesStructuralChecks(element)) return false;\n if (isStaticArrayTextElement(element)) return true;\n if (element.dataset.dynamicContent === \"true\") return false;\n return true;\n};\n\nexport const shouldEnterInlineEditingMode = (element: Element): boolean => {\n if (!(element instanceof HTMLElement) || element.dataset.selected !== \"true\") {\n return false;\n }\n return isEditableTextElement(element);\n};\n","import type { InlineEditHost, InlineEditController } from \"./types.js\";\nimport {\n injectFocusOutlineCSS,\n removeFocusOutlineCSS,\n selectText,\n shouldEnterInlineEditingMode,\n isStaticArrayTextElement,\n} from \"./dom-utils.js\";\nimport { PLUGIN_ELEMENT_ATTR } from \"../../injections/utils.js\";\n\nconst DEBOUNCE_MS = 500;\n\nexport function createInlineEditController(\n host: InlineEditHost\n): InlineEditController {\n let currentEditingElement: HTMLElement | null = null;\n let debouncedSendTimeout: ReturnType | null = null;\n let enabled = false;\n const listenerAbortControllers = new WeakMap();\n\n // --- Private helpers ---\n\n const repositionOverlays = () => {\n const selectedId = host.getSelectedElementId();\n if (!selectedId) return;\n const elements = host.findElementsById(selectedId);\n const overlays = host.getSelectedOverlays();\n overlays.forEach((overlay, i) => {\n if (i < elements.length && elements[i]) {\n host.positionOverlay(overlay, elements[i]);\n }\n });\n };\n\n const reportEdit = (element: HTMLElement) => {\n const originalContent = element.dataset.originalTextContent;\n const newContent = element.textContent;\n\n const svgElement = element as unknown as SVGElement;\n const rect = element.getBoundingClientRect();\n\n const message: Record = {\n type: \"inline-edit\",\n elementInfo: {\n tagName: element.tagName,\n classes:\n (svgElement.className as unknown as SVGAnimatedString)?.baseVal ||\n element.className ||\n \"\",\n visualSelectorId: host.getSelectedElementId(),\n content: newContent,\n dataSourceLocation: element.dataset.sourceLocation,\n isDynamicContent: element.dataset.dynamicContent === \"true\",\n linenumber: element.dataset.linenumber,\n filename: element.dataset.filename,\n position: {\n top: rect.top,\n left: rect.left,\n right: rect.right,\n bottom: rect.bottom,\n width: rect.width,\n height: rect.height,\n centerX: rect.left + rect.width / 2,\n centerY: rect.top + rect.height / 2,\n },\n },\n originalContent,\n newContent,\n };\n\n if (isStaticArrayTextElement(element)) {\n message.arrIndex = element.dataset.arrIndex;\n message.arrVariableName = element.dataset.arrVariableName;\n message.arrField = element.dataset.arrField;\n }\n\n window.parent.postMessage(message, \"*\");\n\n element.dataset.originalTextContent = newContent || \"\";\n };\n\n const debouncedReport = (element: HTMLElement) => {\n if (debouncedSendTimeout) clearTimeout(debouncedSendTimeout);\n debouncedSendTimeout = setTimeout(() => reportEdit(element), DEBOUNCE_MS);\n };\n\n const onTextInput = (element: HTMLElement) => {\n repositionOverlays();\n debouncedReport(element);\n };\n\n const handleInputEvent = function (this: HTMLElement) {\n onTextInput(this);\n };\n\n const makeEditable = (element: HTMLElement) => {\n injectFocusOutlineCSS();\n\n element.dataset.originalTextContent = element.textContent || \"\";\n element.dataset.originalCursor = element.style.cursor;\n element.contentEditable = \"true\";\n element.setAttribute(PLUGIN_ELEMENT_ATTR, \"true\");\n\n const abortController = new AbortController();\n listenerAbortControllers.set(element, abortController);\n element.addEventListener(\"input\", handleInputEvent, {\n signal: abortController.signal,\n });\n\n element.style.cursor = \"text\";\n selectText(element);\n setTimeout(() => {\n if (element.isConnected) {\n element.focus();\n }\n }, 0);\n };\n\n const makeNonEditable = (element: HTMLElement) => {\n const abortController = listenerAbortControllers.get(element);\n if (abortController) {\n abortController.abort();\n listenerAbortControllers.delete(element);\n }\n\n if (!element.isConnected) return;\n\n removeFocusOutlineCSS();\n element.contentEditable = \"false\";\n element.removeAttribute(PLUGIN_ELEMENT_ATTR);\n delete element.dataset.originalTextContent;\n\n if (element.dataset.originalCursor !== undefined) {\n element.style.cursor = element.dataset.originalCursor;\n delete element.dataset.originalCursor;\n }\n };\n\n // --- Public API ---\n\n return {\n get enabled() {\n return enabled;\n },\n set enabled(value: boolean) {\n enabled = value;\n },\n\n isEditing() {\n return currentEditingElement !== null;\n },\n\n getCurrentElement() {\n return currentEditingElement;\n },\n\n canEdit(element: Element) {\n return shouldEnterInlineEditingMode(element);\n },\n\n startEditing(element: HTMLElement) {\n currentEditingElement = element;\n\n host.getSelectedOverlays().forEach((o) => {\n o.style.display = \"none\";\n });\n\n makeEditable(element);\n\n window.parent.postMessage(\n {\n type: \"content-editing-started\",\n visualSelectorId: host.getSelectedElementId(),\n },\n \"*\"\n );\n },\n\n stopEditing() {\n if (!currentEditingElement) return;\n\n if (debouncedSendTimeout) {\n clearTimeout(debouncedSendTimeout);\n debouncedSendTimeout = null;\n }\n\n const element = currentEditingElement;\n makeNonEditable(element);\n\n host.getSelectedOverlays().forEach((o) => {\n o.style.display = \"\";\n });\n\n repositionOverlays();\n\n window.parent.postMessage(\n {\n type: \"content-editing-ended\",\n visualSelectorId: host.getSelectedElementId(),\n },\n \"*\"\n );\n\n currentEditingElement = null;\n },\n\n markElementsSelected(elements: Element[]) {\n elements.forEach((el) => {\n if (el instanceof HTMLElement) {\n el.dataset.selected = \"true\";\n }\n });\n },\n\n clearSelectedMarks(elementId: string | null) {\n if (!elementId) return;\n host.findElementsById(elementId).forEach((el) => {\n if (el instanceof HTMLElement) {\n delete el.dataset.selected;\n }\n });\n },\n\n handleToggleMessage(data: { dataSourceLocation: string; inlineEditingMode: boolean }) {\n if (!enabled) return;\n\n const elements = host.findElementsById(data.dataSourceLocation);\n if (elements.length === 0 || !(elements[0] instanceof HTMLElement)) return;\n\n const element = elements[0];\n\n if (data.inlineEditingMode) {\n if (!shouldEnterInlineEditingMode(element)) return;\n\n // Select the element first if not already selected\n if (host.getSelectedElementId() !== data.dataSourceLocation) {\n this.stopEditing();\n host.clearSelection();\n this.markElementsSelected(elements);\n host.createSelectionOverlays(elements, data.dataSourceLocation);\n }\n this.startEditing(element);\n } else {\n if (currentEditingElement === element) {\n this.stopEditing();\n }\n }\n },\n\n cleanup() {\n this.stopEditing();\n },\n };\n}\n","export const DATA_COLLECTION_ID = \"data-collection-id\";\nexport const DATA_COLLECTION_ITEM_ID = \"data-collection-item-id\";\nexport const DATA_COLLECTION_ITEM_FIELD = \"data-collection-item-field\";\nexport const DATA_COLLECTION_REFERENCE = \"data-collection-reference\";\nexport const DATA_ARR_INDEX = \"data-arr-index\";\nexport const DATA_ARR_VARIABLE_NAME = \"data-arr-variable-name\";\nexport const DATA_ARR_FIELD = \"data-arr-field\";\n\nexport const ALLOWED_CUSTOM_COMPONENTS = [\"Image\", \"Link\"];\nexport const MAX_JSX_DEPTH = 10;\nexport const EXCLUDED_FIELDS = [\"children\", \"length\"];\nexport const THEME_FONT_PREVIEW_ID = \"__theme-font-preview\";\n","import { PLUGIN_ELEMENT_ATTR } from \"./utils.js\";\n\ntype CanvasWheelPanData = {\n deltaX: number;\n deltaY: number;\n deltaMode: number;\n clientX: number;\n clientY: number;\n shiftKey: boolean;\n ctrlKey: false;\n metaKey: false;\n};\n\nfunction elementFromEventTarget(target: EventTarget | null): Element | null {\n if (target instanceof Element) return target;\n if (target instanceof Node) return target.parentElement;\n return null;\n}\n\nfunction isPluginOwnedTarget(target: EventTarget | null): boolean {\n return elementFromEventTarget(target)?.closest(`[${PLUGIN_ELEMENT_ATTR}]`) != null;\n}\n\nexport function createCanvasWheelZoomBridgeController() {\n let isEnabled = false;\n\n const onWheel = (event: WheelEvent): void => {\n if (isPluginOwnedTarget(event.target)) return;\n\n event.preventDefault();\n if (event.ctrlKey || event.metaKey) {\n window.parent.postMessage({\n type: \"canvas-wheel-zoom\",\n data: {\n deltaY: event.deltaY,\n deltaMode: event.deltaMode,\n clientX: event.clientX,\n clientY: event.clientY,\n ctrlKey: event.ctrlKey,\n metaKey: event.metaKey,\n },\n }, \"*\");\n return;\n }\n\n const panData: CanvasWheelPanData = {\n deltaX: event.deltaX,\n deltaY: event.deltaY,\n deltaMode: event.deltaMode,\n clientX: event.clientX,\n clientY: event.clientY,\n shiftKey: event.shiftKey,\n ctrlKey: false,\n metaKey: false,\n };\n window.parent.postMessage({\n type: \"canvas-wheel-pan\",\n data: panData,\n }, \"*\");\n };\n\n const enable = (): void => {\n if (isEnabled) return;\n isEnabled = true;\n window.addEventListener(\"wheel\", onWheel, { capture: true, passive: false });\n };\n\n const disable = (): void => {\n if (!isEnabled) return;\n isEnabled = false;\n window.removeEventListener(\"wheel\", onWheel, true);\n };\n\n return {\n enable,\n disable,\n };\n}\n","// page-height-bridge — postMessage protocol the iframe exposes to its parent.\n// Inert until the parent posts a message; no observers, rewrites, or timers\n// fire on their own.\n//\n// parent → child { type: \"freeze-vh-units\", referenceVhBase?: number }\n// Rewrites every viewport-height unit (`vh`/`dvh`/`svh`/`lvh`) in\n//