const LABEL_HEIGHT = 27; /** * Positions an element-tag label relative to its highlighted overlay. * * Strategy (in priority order): * 1. If the element is near the viewport top AND tall enough (>= 2x label height), * place the label **inside** the element at the top-left. * 2. If the element is near the viewport top but too short, place the label * **below** the element. * 3. Otherwise, place the label **above** the element (default). * * For full-width elements (spanning nearly the entire viewport), the left offset * is nudged inward (6px) to prevent clipping against the viewport edge. * * @param label - The label div to position (style.top and style.left are set). * @param rect - The bounding client rect of the highlighted element. */ export function positionLabel(label, rect) { const nearTop = rect.top < LABEL_HEIGHT; const tallEnough = rect.height >= LABEL_HEIGHT * 2; const isFullWidth = rect.width >= window.innerWidth - 4; const edgeLeft = isFullWidth ? "8px" : "-2px"; const insideLeft = isFullWidth ? "8px" : "4px"; if (nearTop && tallEnough) { label.style.top = "2px"; label.style.left = insideLeft; } else if (nearTop) { label.style.top = `${rect.height + 2}px`; label.style.left = edgeLeft; } else { label.style.top = `-${LABEL_HEIGHT}px`; label.style.left = edgeLeft; } } /** Check if an element has instrumentation attributes */ export function isInstrumentedElement(element) { const htmlEl = element; return !!(htmlEl.dataset?.sourceLocation || htmlEl.dataset?.visualSelectorId); } /** Get the selector ID from an element's data attributes (prefers source-location) */ export function getElementSelectorId(element) { const htmlEl = element; return (htmlEl.dataset?.sourceLocation || htmlEl.dataset?.visualSelectorId || null); } export const ALLOWED_ATTRIBUTES = ["src"]; export const PLUGIN_ELEMENT_ATTR = "data-vite-plugin-element"; /** Find elements by ID - first try data-source-location, fallback to data-visual-selector-id */ export function findElementsById(id) { if (!id) return []; const sourceElements = Array.from(document.querySelectorAll(`[data-source-location="${id}"]`)); if (sourceElements.length > 0) { return sourceElements; } return Array.from(document.querySelectorAll(`[data-visual-selector-id="${id}"]`)); } /** * Update element classes by visual selector ID. * Uses setAttribute instead of className to support both HTML and SVG elements. */ export function updateElementClasses(elements, classes) { elements.forEach((element) => { element.setAttribute("class", classes); }); } /** Set a single attribute on all provided elements. */ export function updateElementAttribute(elements, attribute, value, arrIndex) { if (!ALLOWED_ATTRIBUTES.includes(attribute)) { return; } const targetArrIndex = arrIndex != null ? String(arrIndex) : null; elements.forEach((element) => { if (targetArrIndex === null || element.dataset.arrIndex === targetArrIndex) { element.setAttribute(attribute, value); } }); } /** Collect attribute values from an element for a given allowlist. */ export function collectAllowedAttributes(element, allowedAttributes) { const attributes = {}; for (const attr of allowedAttributes) { const val = element.getAttribute(attr); if (val !== null) { attributes[attr] = val; } } return attributes; } /** * Freeze all CSS animations and transitions on the page by injecting * scoped styles under `[data-visual-edit-active]` and programmatically * finishing (or pausing) every running animation. * * Plugin-owned elements (`[data-vite-plugin-element]`) are excluded so * the plugin UI stays animated. */ export function stopAnimations() { if (document.getElementById('freeze-animations')) return; document.documentElement.setAttribute('data-visual-edit-active', ''); const animStyle = document.createElement('style'); animStyle.id = 'freeze-animations'; animStyle.textContent = ` [data-visual-edit-active] *:not([${PLUGIN_ELEMENT_ATTR}]):not([${PLUGIN_ELEMENT_ATTR}] *), [data-visual-edit-active] *:not([${PLUGIN_ELEMENT_ATTR}]):not([${PLUGIN_ELEMENT_ATTR}] *)::before, [data-visual-edit-active] *:not([${PLUGIN_ELEMENT_ATTR}]):not([${PLUGIN_ELEMENT_ATTR}] *)::after { animation-play-state: paused !important; transition: none !important; } `; const pointerStyle = document.createElement('style'); pointerStyle.id = 'freeze-pointer-events'; pointerStyle.textContent = ` [data-visual-edit-active] * { pointer-events: none !important; } [${PLUGIN_ELEMENT_ATTR}], [${PLUGIN_ELEMENT_ATTR}] * { pointer-events: auto !important; } `; const target = document.head || document.documentElement; target.appendChild(animStyle); target.appendChild(pointerStyle); document.getAnimations().forEach((a) => { // Skip animations on plugin UI elements const animTarget = a.effect?.target; if (animTarget instanceof Element && animTarget.closest(`[${PLUGIN_ELEMENT_ATTR}]`)) return; try { a.finish(); // fast-forward to end state } catch { a.pause(); // finish() throws on infinite animations — pause instead } }); } /** * Resume all previously frozen animations and remove the injected * freeze styles. Cleans up the `data-visual-edit-active` attribute * from `` so scoped selectors no longer match. */ export function resumeAnimations() { const animStyle = document.getElementById('freeze-animations'); if (!animStyle) return; animStyle.remove(); document.getElementById('freeze-pointer-events')?.remove(); document.documentElement.removeAttribute('data-visual-edit-active'); document.getAnimations().forEach((a) => { if (a.playState === 'paused') { try { a.play(); } catch { /* animation target may have been removed */ } } }); } /** * Hit-test the page at (`x`, `y`) and walk up the DOM to find the * nearest ancestor that carries instrumentation attributes * (`data-source-location` or `data-visual-selector-id`). * * Temporarily disables the pointer-events freeze sheet so the * browser's native `elementFromPoint` can reach the real target. */ export function findInstrumentedElement(x, y) { const pointerStyle = document.getElementById('freeze-pointer-events'); if (pointerStyle) pointerStyle.disabled = true; const el = document.elementFromPoint(x, y); if (pointerStyle) pointerStyle.disabled = false; return el?.closest('[data-source-location], [data-visual-selector-id]') ?? null; } /** * Resolve which element should be hovered at (`x`, `y`), skipping the * currently selected element. Returns the selector ID of the hovered * element, or `null` if the point is empty or hits the selected element. */ export function resolveHoverTarget(x, y, selectedElementId) { const element = findInstrumentedElement(x, y); if (!element) return null; const selectorId = getElementSelectorId(element); if (selectorId === selectedElementId) return null; return selectorId; } const VISUAL_EDIT_FONT_FACE_ID_PREFIX = "visual-edit-font-face-"; function isValidFontFaceCss(css) { const trimmed = css.trim(); if (!trimmed.startsWith("@font-face")) return false; if (trimmed.includes("\\")) return false; // Prevent CSS escapes const openBraces = (trimmed.match(/{/g) || []).length; const closeBraces = (trimmed.match(/}/g) || []).length; if (openBraces !== 1 || closeBraces !== 1 || !trimmed.endsWith("}")) return false; const chunks = trimmed.split(/url\(/i); for (let i = 1; i < chunks.length; i++) { const end = chunks[i].indexOf(")"); if (end === -1) return false; const url = chunks[i].slice(0, end).trim().replace(/^['"]|['"]$/g, "").trim(); if (!/^https:\/\//i.test(url)) return false; } return true; } export function injectFontFaceCss(id, css) { if (typeof id !== "string" || !id.trim()) return; if (typeof css !== "string" || !isValidFontFaceCss(css)) return; const styleId = `${VISUAL_EDIT_FONT_FACE_ID_PREFIX}${id}`; if (document.getElementById(styleId)) return; const style = document.createElement("style"); style.id = styleId; style.dataset.visualEditFontFace = "true"; style.textContent = css; document.head.appendChild(style); } //# sourceMappingURL=utils.js.map