document.addEventListener("DOMContentLoaded", () => { let htmlString = ""; let idCounter = 0; let currentDomain = ""; const transformProperties = ["translateX", "translateY", "rotateZ", "rotateX", "rotateY", "scale", "skewX", "skewY"] const domToLayer = new WeakMap(); const layerToDom = new Map(); const layerNavMap = new Map(); let lastActiveEl = null; let lastHoverEl = null; let animationCounter = 0; let replayCounter = 0; // Global counter to force animation replay async function fetchHTML(domain) { const res = await fetch( `https://css-animator.moden.workers.dev/?domain=${encodeURIComponent( domain )}` ); if (!res.ok) throw new Error(`Fetch failed: ${res.status}`); return await res.text(); } const iframe = document.querySelector(".animator_iframe"); const selectorInput = document.querySelector( "[data-animator='selector-input']" ); const animationNameInput = document.querySelector( "[data-animator='animation-name-input']" ); const durationInput = document.querySelector('[data-animator="duration"]'); const delayInput = document.querySelector('[data-animator="delay"]'); const easeInput = document.querySelector('select[data-animator="ease"]'); const staggerInput = document.querySelector( 'input[type="number"][data-animator="stagger"]' ); const staggerCountInput = document.querySelector( 'input[type="number"][data-animator="stagger-count"]' ); const staggerListInput = document.querySelector( 'input[data-animator="stagger-list"]' ); const timelineNameInput = document.querySelector( 'input[data-animator="timeline-name"]' ); const timelineSelectInput = document.querySelector( 'select[data-animator="timeline-select"]' ); const newTimelineButton = document.querySelector( 'button[data-animator="new-timeline"]' ); const input = document.querySelector("[data-animator='website-input']"); const layerList = document.querySelector(".animator_layer_list"); const templateItem = layerList?.querySelector(".animator_layer_item"); const buttonWrap = document.querySelector(".animator_layer_button_wrap"); if (!iframe || !input || !layerList || !templateItem) return; const timelineList = document.querySelector(".animator_timeline_list"); // Track multiple timelines (each timeline has its own set of animations) const timelines = new Map(); // key: timeline name, value: { name, animations: Map(), wraps: [], selectedWraps: [], primaryWrap: null, styleElement } let currentTimelineName = "timeline-1"; let wrapTemplate = null; // Store the first wrap as a template const propertyMap = { "Move X": "translateX", "Move Y": "translateY", "Rotate": "rotateZ", "Rotate X": "rotateX", "Rotate Y": "rotateY", "ClipPath": "clip-path", "Width": "width", "Min W": "min-width", "Max W": "max-width", "Height": "height", "Min H": "min-height", "Max H": "max-height", Scale: "scale", "Skew X": "skewX", "Skew Y": "skewY", Opacity: "opacity", }; // Helper functions for timeline management function getCurrentTimeline() { return timelines.get(currentTimelineName); } function getCurrentAnimations() { const timeline = getCurrentTimeline(); return timeline ? timeline.animations : new Map(); } function getCurrentSelectedWraps() { const timeline = getCurrentTimeline(); return timeline ? timeline.selectedWraps : []; } function getCurrentPrimaryWrap() { const timeline = getCurrentTimeline(); return timeline ? timeline.primaryWrap : null; } function setCurrentSelectedWraps(wraps) { const timeline = getCurrentTimeline(); if (timeline) timeline.selectedWraps = wraps; } function setCurrentPrimaryWrap(wrap) { const timeline = getCurrentTimeline(); if (timeline) timeline.primaryWrap = wrap; } function generateUniqueTimelineName(baseName = "timeline") { let counter = 1; let name = baseName; // If base name ends with a number, extract it const match = baseName.match(/^(.+?)-(\d+)$/); if (match) { name = match[1]; counter = parseInt(match[2]); } let uniqueName = timelines.size === 0 ? `${name}-1` : `${name}-${counter}`; while (timelines.has(uniqueName)) { counter++; uniqueName = `${name}-${counter}`; } return uniqueName; } function updateTimelineSelect() { if (!timelineSelectInput) return; timelineSelectInput.innerHTML = ""; timelines.forEach((timeline, name) => { const option = document.createElement("option"); option.value = name; option.textContent = name; if (name === currentTimelineName) { option.selected = true; } timelineSelectInput.appendChild(option); }); } function createNewTimeline(name = null) { const timelineName = name || generateUniqueTimelineName("animation"); if (timelines.has(timelineName)) { console.warn(`Timeline ${timelineName} already exists`); return null; } const timeline = { name: timelineName, animations: new Map(), wraps: [], // Each timeline has its own wraps selectedWraps: [], primaryWrap: null, styleElement: null, }; timelines.set(timelineName, timeline); updateTimelineSelect(); return timeline; } function switchToTimeline(timelineName) { if (!timelines.has(timelineName)) { console.warn(`Timeline ${timelineName} does not exist`); return; } currentTimelineName = timelineName; // Update timeline name input if (timelineNameInput) { timelineNameInput.value = timelineName; } // Update select if (timelineSelectInput) { timelineSelectInput.value = timelineName; } // Hide all wraps from all timelines timelines.forEach((timeline) => { timeline.wraps.forEach((wrap) => { wrap.style.display = "none"; }); }); // Show wraps from current timeline const timeline = getCurrentTimeline(); if (timeline) { timeline.wraps.forEach((wrap) => { wrap.style.display = ""; }); } // Load the timeline's UI state if (timeline && timeline.primaryWrap) { const animData = timeline.animations.get(timeline.primaryWrap); if (animData) { loadAnimationToUI(animData); } } else { // Clear UI if no primary wrap clearUIInputs(); } // Update visual states for all timeline wraps updateAllTimelineWrapsVisuals(); } function createTimelineWrap() { if (!wrapTemplate || !timelineList) return null; const newWrap = wrapTemplate.cloneNode(true); // Reset styles const item = newWrap.querySelector(".animator_timeline_line_item"); if (item) { item.style.width = "5em"; item.style.left = "0em"; } const textElement = newWrap.querySelector(".animator_timeline_line_text"); if (textElement) { textElement.textContent = "fade-in"; } newWrap.classList.remove("is-active"); return newWrap; } function updateAllTimelineWrapsVisuals() { const animations = getCurrentAnimations(); const timeline = getCurrentTimeline(); if (!timeline) return; // Update only the current timeline's wraps timeline.wraps.forEach((wrap) => { const animData = animations.get(wrap); if (animData) { // Update visuals for this wrap updateTimelineItemVisuals(wrap); // Update active state const selectedWraps = getCurrentSelectedWraps(); wrap.classList.toggle("is-active", selectedWraps.includes(wrap)); } else { // This wrap doesn't have data in current timeline, remove active state wrap.classList.remove("is-active"); } }); } function clearUIInputs() { if (selectorInput) selectorInput.value = ".my-class"; if (animationNameInput) animationNameInput.value = "fade-in"; if (durationInput) durationInput.value = "0.5"; if (delayInput) delayInput.value = "0"; if (easeInput) easeInput.value = "ease-out"; if (staggerInput) staggerInput.value = ""; if (staggerCountInput) staggerCountInput.value = "10"; if (staggerListInput) staggerListInput.value = ""; document.querySelectorAll(".animator_panel_item_wrap").forEach((wrap) => { const startInput = wrap.querySelector('[data-animator="start-value"]'); const endInput = wrap.querySelector('[data-animator="end-value"]'); if (startInput) startInput.value = ""; if (endInput) endInput.value = ""; }); updatePanelActiveStates(); } function updateAllTimelineWrapsVisuals() { const animations = getCurrentAnimations(); // Update all timeline wraps to reflect current timeline's data document .querySelectorAll(".animator_timeline_line_wrap") .forEach((wrap) => { const animData = animations.get(wrap); if (animData) { // Update visuals for this wrap updateTimelineItemVisuals(wrap); // Update active state const selectedWraps = getCurrentSelectedWraps(); wrap.classList.toggle("is-active", selectedWraps.includes(wrap)); } else { // This wrap doesn't have data in current timeline, remove active state wrap.classList.remove("is-active"); } }); } // Set initial timeline name value if (timelineNameInput) { timelineNameInput.value = "timeline-1"; } function formatSeconds(value) { return Number(value.toFixed(2)).toString(); } function getCurrentProperties() { const properties = []; document.querySelectorAll(".animator_panel_item_wrap").forEach((item) => { const titleEl = item.querySelector(".animator_panel_item_title"); const startEl = item.querySelector('[data-animator="start-value"]'); const endEl = item.querySelector('[data-animator="end-value"]'); if (!titleEl || !startEl || !endEl) return; const prop = titleEl.value.trim(); const startVal = startEl.value.trim(); const endVal = endEl.value.trim(); if (!prop || (startVal === "" && endVal === "")) return; properties.push({ name: prop, startValue: startVal, endValue: endVal, isReadonly: titleEl.hasAttribute("readonly"), }); }); return properties; } function buildAnimatorStyleForAnimation(animData) { const { selector, animationName, duration, delay, ease, properties, stagger, staggerCount, staggerList, } = animData; // Add replay counter to force animation restart const fullAnimationName = `${animationName}-r${replayCounter}`; let fromStyle = ""; let toStyle = ""; const fromTransforms = []; const toTransforms = []; properties.forEach((prop) => { const cssProp = prop.isReadonly ? propertyMap[prop.name] || prop.name : prop.name; if (transformProperties.includes(cssProp)) { if (prop.startValue !== "") fromTransforms.push(`${cssProp}(${prop.startValue})`); if (prop.endValue !== "") toTransforms.push(`${cssProp}(${prop.endValue})`); } else { if (prop.startValue !== "") fromStyle += `${cssProp}: ${prop.startValue}; `; if (prop.endValue !== "") toStyle += `${cssProp}: ${prop.endValue}; `; } }); if (fromTransforms.length) fromStyle += `transform: ${fromTransforms.join(" ")};`; if (toTransforms.length) toStyle += `transform: ${toTransforms.join(" ")};`; if (fromStyle === "" && toStyle === "") return ""; // Use calc delay if stagger has a value (regardless of stagger-list) const hasStagger = stagger !== "" && stagger !== null && stagger !== undefined && !isNaN(parseFloat(stagger)); const animationDelay = hasStagger ? `calc((${stagger}s * var(--i)) + ${formatSeconds(delay)}s)` : `${formatSeconds(delay)}s`; // Generate stagger list CSS only if stagger-list has a value (regardless of stagger input) let staggerListCSS = ""; if (staggerList && staggerList.trim() !== "") { const count = parseInt(staggerCount) || 10; const nthParts = []; for (let i = 1; i <= count; i++) { nthParts.push(`> :nth-child(${i}){--i:${i - 1}}`); } staggerListCSS = `\n${staggerList.trim()} { ${nthParts.join("")} }`; } return ` @keyframes ${fullAnimationName} { ${fromStyle ? `from { ${fromStyle} }` : ""} ${ toStyle ? `to { ${toStyle} }` : "" } } ${selector} { animation: ${fullAnimationName} ${formatSeconds( duration )}s ${ease} ${animationDelay} both; }${staggerListCSS}`.trim(); } function rebuildAllAnimationStyles() { const doc = iframe.contentDocument; if (!doc) return; // Remove old style tags and rebuild all timelines doc .querySelectorAll("style[data-css-animator]") .forEach((style) => style.remove()); // Increment replay counter to force all animations to restart replayCounter++; timelines.forEach((timeline, timelineName) => { const newStyle = doc.createElement("style"); newStyle.setAttribute("data-css-animator", timelineName); let allStyles = ""; timeline.animations.forEach((animData) => { const styleText = buildAnimatorStyleForAnimation(animData); if (styleText) { allStyles += styleText + "\n\n"; } }); newStyle.textContent = allStyles; const mountPoint = doc.head || doc.body || doc.documentElement; mountPoint?.appendChild(newStyle); // Store reference to style element timeline.styleElement = newStyle; }); } function rebuildCurrentTimelineStyles() { const doc = iframe.contentDocument; if (!doc) return; const timeline = getCurrentTimeline(); if (!timeline) return; // Remove only current timeline's style tag const oldStyle = doc.querySelector( `style[data-css-animator="${currentTimelineName}"]` ); if (oldStyle) oldStyle.remove(); // Increment replay counter to force animations to restart replayCounter++; const newStyle = doc.createElement("style"); newStyle.setAttribute("data-css-animator", currentTimelineName); let allStyles = ""; // Iterate over wraps array (which has the correct order) instead of animations Map timeline.wraps.forEach((wrap) => { const animData = timeline.animations.get(wrap); if (animData) { const styleText = buildAnimatorStyleForAnimation(animData); if (styleText) { allStyles += styleText + "\n\n"; } } }); newStyle.textContent = allStyles; const mountPoint = doc.head || doc.body || doc.documentElement; mountPoint?.appendChild(newStyle); // Store reference to style element timeline.styleElement = newStyle; } function isAnimationNameTaken(name, excludeWrap = null) { let isTaken = false; // Check across ALL timelines, not just current one timelines.forEach((timeline) => { timeline.animations.forEach((animData, wrap) => { if (wrap === excludeWrap) return; const baseName = animData.animationName.replace(/-\d+$/, ""); if (baseName === name) { isTaken = true; } }); }); return isTaken; } function updateActiveAnimation() { const primaryTimelineWrap = getCurrentPrimaryWrap(); if (!primaryTimelineWrap) return; const animations = getCurrentAnimations(); const animData = animations.get(primaryTimelineWrap); if (!animData) return; animData.selector = selectorInput?.value?.trim() || ".my-class"; animData.animationName = (animationNameInput?.value?.trim() || "fade-in") + `-${animData.counter}`; animData.duration = parseFloat(durationInput?.value) || 0.5; animData.delay = parseFloat(delayInput?.value) || 0; animData.ease = easeInput?.value || "ease-out"; animData.stagger = staggerInput?.value || ""; animData.staggerCount = parseInt(staggerCountInput?.value) || 10; animData.staggerList = staggerListInput?.value?.trim() || ""; animData.properties = getCurrentProperties(); rebuildCurrentTimelineStyles(); updateTimelineItemVisuals(primaryTimelineWrap); } function loadAnimationToUI(animData) { if (selectorInput) selectorInput.value = animData.selector; if (animationNameInput) animationNameInput.value = animData.animationName.replace(/-\d+$/, ""); if (durationInput) durationInput.value = formatSeconds(animData.duration); if (delayInput) delayInput.value = formatSeconds(animData.delay); if (easeInput) easeInput.value = animData.ease; if (staggerInput) staggerInput.value = animData.stagger || ""; if (staggerCountInput) staggerCountInput.value = animData.staggerCount || 10; if (staggerListInput) staggerListInput.value = animData.staggerList || ""; // Clear all property inputs first document.querySelectorAll(".animator_panel_item_wrap").forEach((wrap) => { const titleEl = wrap.querySelector(".animator_panel_item_title"); const startInput = wrap.querySelector('[data-animator="start-value"]'); const endInput = wrap.querySelector('[data-animator="end-value"]'); // Clear all fields if (startInput) startInput.value = ""; if (endInput) endInput.value = ""; // Clear custom property titles (editable fields) if (titleEl && !titleEl.hasAttribute("readonly")) { titleEl.value = ""; } }); // Separate properties into readonly and custom const readonlyProps = animData.properties.filter((p) => p.isReadonly); const customProps = animData.properties.filter((p) => !p.isReadonly); // Load readonly properties (Opacity, Move X, etc.) readonlyProps.forEach((prop) => { document.querySelectorAll(".animator_panel_item_wrap").forEach((wrap) => { const titleEl = wrap.querySelector(".animator_panel_item_title"); const startInput = wrap.querySelector('[data-animator="start-value"]'); const endInput = wrap.querySelector('[data-animator="end-value"]'); if (titleEl && titleEl.value.trim() === prop.name) { if (startInput) startInput.value = prop.startValue; if (endInput) endInput.value = prop.endValue; } }); }); // Load custom properties into editable fields let customPropIndex = 0; document.querySelectorAll(".animator_panel_item_wrap").forEach((wrap) => { const titleEl = wrap.querySelector(".animator_panel_item_title"); const startInput = wrap.querySelector('[data-animator="start-value"]'); const endInput = wrap.querySelector('[data-animator="end-value"]'); // Find editable title fields that are empty if ( titleEl && !titleEl.hasAttribute("readonly") && !titleEl.value.trim() && customPropIndex < customProps.length ) { const customProp = customProps[customPropIndex]; titleEl.value = customProp.name; if (startInput) startInput.value = customProp.startValue; if (endInput) endInput.value = customProp.endValue; customPropIndex++; } }); updatePanelActiveStates(); updateTimelineText(); } function setActiveTimeline(timelineWrap, isShiftClick = false) { if (!timelineWrap) return; const animations = getCurrentAnimations(); let selectedTimelineWraps = getCurrentSelectedWraps(); let primaryTimelineWrap = getCurrentPrimaryWrap(); if (isShiftClick && selectedTimelineWraps.length > 0) { // Shift-click: add to selection if (selectedTimelineWraps.includes(timelineWrap)) { // Already selected, deselect it selectedTimelineWraps = selectedTimelineWraps.filter( (w) => w !== timelineWrap ); timelineWrap.classList.remove("is-active"); // Update primary if we deselected it if ( primaryTimelineWrap === timelineWrap && selectedTimelineWraps.length > 0 ) { primaryTimelineWrap = selectedTimelineWraps[0]; setCurrentPrimaryWrap(primaryTimelineWrap); const animData = animations.get(primaryTimelineWrap); if (animData) loadAnimationToUI(animData); } else if (selectedTimelineWraps.length === 0) { primaryTimelineWrap = null; setCurrentPrimaryWrap(null); } } else { // Add to selection selectedTimelineWraps.push(timelineWrap); timelineWrap.classList.add("is-active"); // Keep the first selected as primary (don't change UI) } setCurrentSelectedWraps(selectedTimelineWraps); } else { // Regular click: single selection // Remove is-active from all document .querySelectorAll(".animator_timeline_line_wrap") .forEach((wrap) => { wrap.classList.remove("is-active"); }); // Add is-active to clicked timelineWrap.classList.add("is-active"); selectedTimelineWraps = [timelineWrap]; primaryTimelineWrap = timelineWrap; setCurrentSelectedWraps(selectedTimelineWraps); setCurrentPrimaryWrap(primaryTimelineWrap); // Load this animation's data into UI const animData = animations.get(timelineWrap); if (animData) { loadAnimationToUI(animData); } } } function updateTimelineItemVisuals(timelineWrap) { const animations = getCurrentAnimations(); const animData = animations.get(timelineWrap); if (!animData) { // No data for this timeline, reset to defaults const item = timelineWrap.querySelector(".animator_timeline_line_item"); const textElement = timelineWrap.querySelector( ".animator_timeline_line_text" ); if (item) { item.style.width = "5em"; item.style.left = "0em"; } if (textElement) { textElement.textContent = "fade-in"; } return; } const item = timelineWrap.querySelector(".animator_timeline_line_item"); const textElement = timelineWrap.querySelector( ".animator_timeline_line_text" ); if (item) { item.style.width = `${secondsToEm(animData.duration)}em`; item.style.left = `${secondsToEm(animData.delay)}em`; } if (textElement) { textElement.textContent = animData.animationName.replace(/-\d+$/, ""); } } function createNewAnimation(baseData = null) { const counter = animationCounter++; if (baseData) { // Get base name without counter suffix let baseName = baseData.animationName.replace(/-\d+$/, ""); // Ensure the duplicated animation has a unique name if (isAnimationNameTaken(baseName)) { let nameCounter = 1; let uniqueName = `${baseName}-${nameCounter}`; while (isAnimationNameTaken(uniqueName)) { nameCounter++; uniqueName = `${baseName}-${nameCounter}`; } baseName = uniqueName; } return { counter, selector: baseData.selector, animationName: `${baseName}-${counter}`, duration: baseData.duration, delay: baseData.delay, ease: baseData.ease, properties: JSON.parse(JSON.stringify(baseData.properties)), stagger: baseData.stagger || "", staggerCount: baseData.staggerCount || 10, staggerList: baseData.staggerList || "", }; } return { counter, selector: ".my-class", animationName: `fade-in-${counter}`, duration: 0.5, delay: 0, ease: "ease-out", properties: [ { name: "Opacity", startValue: "0", endValue: "1", isReadonly: true }, ], stagger: "", staggerCount: 10, staggerList: "", }; } function duplicateActiveTimeline() { const primaryTimelineWrap = getCurrentPrimaryWrap(); if (!primaryTimelineWrap) return; const animations = getCurrentAnimations(); const timeline = getCurrentTimeline(); if (!timeline) return; const activeAnimData = animations.get(primaryTimelineWrap); if (!activeAnimData) return; // Create new animation data const newAnimData = createNewAnimation(activeAnimData); // Update selector based on currently selected element in iframe if (lastActiveEl) { if (lastActiveEl.classList.length > 0) { // Use first class name newAnimData.selector = `.${lastActiveEl.classList[0]}`; } else { // Use tag name newAnimData.selector = lastActiveEl.tagName.toLowerCase(); } } // Clear stagger-list, stagger value, and reset stagger-count when duplicating newAnimData.stagger = ""; newAnimData.staggerList = ""; newAnimData.staggerCount = 10; // Clone the timeline wrap element const newTimelineWrap = primaryTimelineWrap.cloneNode(true); // Insert after active in DOM primaryTimelineWrap.parentElement.insertBefore( newTimelineWrap, primaryTimelineWrap.nextSibling ); // Add to current timeline's wraps array const activeIndex = timeline.wraps.indexOf(primaryTimelineWrap); timeline.wraps.splice(activeIndex + 1, 0, newTimelineWrap); // Store the new animation animations.set(newTimelineWrap, newAnimData); // Update visuals and rebuild styles updateTimelineItemVisuals(newTimelineWrap); rebuildCurrentTimelineStyles(); // Set as active setActiveTimeline(newTimelineWrap); } function deleteActiveTimeline() { const selectedTimelineWraps = getCurrentSelectedWraps(); if (selectedTimelineWraps.length === 0) return; const animations = getCurrentAnimations(); const timeline = getCurrentTimeline(); if (!timeline) return; const allWraps = timeline.wraps; // Don't delete if it would leave us with no timelines const remainingCount = allWraps.length - selectedTimelineWraps.length; if (remainingCount < 1) { // Keep the first selected one if deleting all would leave none const toDelete = selectedTimelineWraps.slice(1); if (toDelete.length === 0) return; // Can't delete the only one toDelete.forEach((wrap) => { animations.delete(wrap); const index = timeline.wraps.indexOf(wrap); if (index > -1) timeline.wraps.splice(index, 1); wrap.remove(); }); // Keep the first one selected setCurrentSelectedWraps([selectedTimelineWraps[0]]); setCurrentPrimaryWrap(selectedTimelineWraps[0]); } else { // Delete all selected const firstSelectedIndex = allWraps.indexOf(selectedTimelineWraps[0]); selectedTimelineWraps.forEach((wrap) => { animations.delete(wrap); const index = timeline.wraps.indexOf(wrap); if (index > -1) timeline.wraps.splice(index, 1); wrap.remove(); }); // Find new active (prefer next, then previous) const newActive = timeline.wraps[firstSelectedIndex] || timeline.wraps[firstSelectedIndex - 1] || timeline.wraps[0]; if (newActive) { setActiveTimeline(newActive); } } rebuildCurrentTimelineStyles(); } function updateTimelineText() { const primaryTimelineWrap = getCurrentPrimaryWrap(); if (!primaryTimelineWrap) return; const animations = getCurrentAnimations(); const animData = animations.get(primaryTimelineWrap); if (!animData) return; const animationName = animationNameInput?.value?.trim() || "fade-in"; const textElement = primaryTimelineWrap.querySelector( ".animator_timeline_line_text" ); if (textElement) { textElement.textContent = animationName; } } function updateDurationDelayInputs() { const primaryTimelineWrap = getCurrentPrimaryWrap(); if (!primaryTimelineWrap) return; const animations = getCurrentAnimations(); const animData = animations.get(primaryTimelineWrap); if (!animData) return; if (durationInput) { durationInput.value = formatSeconds(animData.duration); } if (delayInput) { delayInput.value = formatSeconds(animData.delay); } } function emToSeconds(em) { return Math.max(0, em) * 0.1; } function secondsToEm(sec) { return Math.max(0, sec) * 10; } function parseEmValue(value, fallbackPx, fontSize) { if (!value) { if (fallbackPx != null && fallbackPx !== "auto") { const px = parseFloat(fallbackPx); if (!Number.isNaN(px) && fontSize) return px / fontSize; } return 0; } if (value.endsWith("em")) { const em = parseFloat(value); return Number.isNaN(em) ? 0 : em; } if (value.endsWith("px")) { const px = parseFloat(value); return Number.isNaN(px) || !fontSize ? 0 : px / fontSize; } const num = parseFloat(value); return Number.isNaN(num) ? 0 : num; } function parseAnimationFromCSS(cssText) { // Parse animation properties from a CSS rule // Handle: animation: name duration ease delay both; // where delay can be a simple value like "0s" or calc like "calc((0.2s * var(--i)) + 0.6s)" const animationMatch = cssText.match( /animation:\s*([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+(.+?)\s+both/ ); if (!animationMatch) return null; const [, animName, duration, ease, delay] = animationMatch; // Parse duration (remove 's') const durationValue = parseFloat(duration); // Parse delay - could be calc() or plain value let delayValue = 0; let staggerValue = ""; if (delay.startsWith("calc(")) { // Extract stagger and base delay from calc((0.4s * var(--i)) + 0.5s) const calcMatch = delay.match( /calc\(\(([^s]+)s\s*\*\s*var\(--i\)\)\s*\+\s*([^s]+)s\)/ ); if (calcMatch) { staggerValue = calcMatch[1]; delayValue = parseFloat(calcMatch[2]); } else { // If calc doesn't match the stagger pattern, just try to get a number delayValue = 0; } } else { delayValue = parseFloat(delay); } return { animationName: animName.replace(/-r\d+$/, ""), // Remove replay counter duration: durationValue, delay: delayValue, ease: ease, stagger: staggerValue, }; } function parseKeyframeProperties(keyframeText) { // Parse from { ... } to { ... } properties const fromMatch = keyframeText.match(/from\s*\{\s*([^}]+)\s*\}/); const toMatch = keyframeText.match(/to\s*\{\s*([^}]+)\s*\}/); const properties = []; const fromProps = {}; const toProps = {}; const customProps = {}; // Track custom properties if (fromMatch) { const fromContent = fromMatch[1]; // Parse opacity const opacityMatch = fromContent.match(/opacity:\s*([^;]+)/); if (opacityMatch) fromProps.opacity = opacityMatch[1].trim(); // Parse transform const transformMatch = fromContent.match(/transform:\s*([^;]+)/); if (transformMatch) { const transform = transformMatch[1].trim(); const translateXMatch = transform.match(/translateX\(([^)]+)\)/); const translateYMatch = transform.match(/translateY\(([^)]+)\)/); const rotateZMatch = transform.match(/rotateZ\(([^)]+)\)/); const rotateXMatch = transform.match(/rotateX\(([^)]+)\)/); const rotateYMatch = transform.match(/rotateY\(([^)]+)\)/); const skewXMatch = transform.match(/skewX\(([^)]+)\)/); const skewYMatch = transform.match(/skewY\(([^)]+)\)/); const scaleMatch = transform.match(/scale\(([^)]+)\)/); if (translateXMatch) fromProps.translateX = translateXMatch[1]; if (translateYMatch) fromProps.translateY = translateYMatch[1]; if (rotateZMatch) fromProps.rotateZ = rotateZMatch[1]; if (rotateXMatch) fromProps.rotateX = rotateXMatch[1]; if (rotateYMatch) fromProps.rotateY = rotateYMatch[1]; if (skewXMatch) fromProps.skewX = skewXMatch[1]; if (skewYMatch) fromProps.skewY = skewYMatch[1]; if (scaleMatch) fromProps.scale = scaleMatch[1]; } // Parse other CSS properties (custom properties) // Match all property: value pairs that aren't opacity or transform // Split by semicolon and parse each declaration const declarations = fromContent .split(";") .map((d) => d.trim()) .filter(Boolean); declarations.forEach((declaration) => { const colonIndex = declaration.indexOf(":"); if (colonIndex === -1) { return; } const propName = declaration.substring(0, colonIndex).trim(); const propValue = declaration.substring(colonIndex + 1).trim(); // Skip opacity and transform as we handle those specially if ( propName && propValue && propName !== "opacity" && propName !== "transform" ) { if (!customProps[propName]) { customProps[propName] = { from: "", to: "" }; } customProps[propName].from = propValue; } }); } if (toMatch) { const toContent = toMatch[1]; const opacityMatch = toContent.match(/opacity:\s*([^;]+)/); if (opacityMatch) toProps.opacity = opacityMatch[1].trim(); const transformMatch = toContent.match(/transform:\s*([^;]+)/); if (transformMatch) { const transform = transformMatch[1].trim(); const translateXMatch = transform.match(/translateX\(([^)]+)\)/); const translateYMatch = transform.match(/translateY\(([^)]+)\)/); const rotateZMatch = transform.match(/rotateZ\(([^)]+)\)/); const rotateXMatch = transform.match(/rotateX\(([^)]+)\)/); const rotateYMatch = transform.match(/rotateY\(([^)]+)\)/); const skewXMatch = transform.match(/skewX\(([^)]+)\)/); const skewYMatch = transform.match(/skewY\(([^)]+)\)/); const scaleMatch = transform.match(/scale\(([^)]+)\)/); if (translateXMatch) toProps.translateX = translateXMatch[1]; if (translateYMatch) toProps.translateY = translateYMatch[1]; if (rotateZMatch) toProps.rotateZ = rotateZMatch[1]; if (rotateXMatch) toProps.rotateX = rotateXMatch[1]; if (rotateYMatch) toProps.rotateY = rotateYMatch[1]; if (skewXMatch) toProps.skewX = skewXMatch[1]; if (skewYMatch) toProps.skewY = skewYMatch[1]; if (scaleMatch) toProps.scale = scaleMatch[1]; } // Parse other CSS properties (custom properties) // Split by semicolon and parse each declaration const declarations = toContent .split(";") .map((d) => d.trim()) .filter(Boolean); declarations.forEach((declaration) => { const colonIndex = declaration.indexOf(":"); if (colonIndex === -1) return; const propName = declaration.substring(0, colonIndex).trim(); const propValue = declaration.substring(colonIndex + 1).trim(); // Skip opacity and transform as we handle those specially if ( propName && propValue && propName !== "opacity" && propName !== "transform" ) { if (!customProps[propName]) { customProps[propName] = { from: "", to: "" }; } customProps[propName].to = propValue; } }); } // Create property objects for standard properties if (fromProps.opacity !== undefined || toProps.opacity !== undefined) { properties.push({ name: "Opacity", startValue: fromProps.opacity || "", endValue: toProps.opacity || "", isReadonly: true, }); } if ( fromProps.translateX !== undefined || toProps.translateX !== undefined ) { properties.push({ name: "Move X", startValue: fromProps.translateX || "", endValue: toProps.translateX || "", isReadonly: true, }); } if ( fromProps.translateY !== undefined || toProps.translateY !== undefined ) { properties.push({ name: "Move Y", startValue: fromProps.translateY || "", endValue: toProps.translateY || "", isReadonly: true, }); } if (fromProps.rotateZ !== undefined || toProps.rotateZ !== undefined) { properties.push({ name: "Rotate", startValue: fromProps.rotateZ || "", endValue: toProps.rotateZ || "", isReadonly: true, }); } if (fromProps.rotateX !== undefined || toProps.rotateX !== undefined) { properties.push({ name: "Rotate X", startValue: fromProps.rotateX || "", endValue: toProps.rotateX || "", isReadonly: true, }); } if (fromProps.rotateY !== undefined || toProps.rotateY !== undefined) { properties.push({ name: "Rotate Y", startValue: fromProps.rotateY || "", endValue: toProps.rotateY || "", isReadonly: true, }); } if (fromProps.skewX !== undefined || toProps.skewX !== undefined) { properties.push({ name: "Skew X", startValue: fromProps.skewX || "", endValue: toProps.skewX || "", isReadonly: true, }); } if (fromProps.skewY !== undefined || toProps.skewY !== undefined) { properties.push({ name: "Skew Y", startValue: fromProps.skewY || "", endValue: toProps.skewY || "", isReadonly: true, }); } if (fromProps.scale !== undefined || toProps.scale !== undefined) { properties.push({ name: "Scale", startValue: fromProps.scale || "", endValue: toProps.scale || "", isReadonly: true, }); } // Add custom properties Object.keys(customProps).forEach((propName) => { const prop = customProps[propName]; properties.push({ name: propName, startValue: prop.from || "", endValue: prop.to || "", isReadonly: false, }); }); return properties; } function parseStaggerList(cssText, selector, keyframeName) { // Find stagger list pattern that comes AFTER the animation rule for this specific selector // Pattern: Find the animation rule first, then look for nth-child pattern after it // Escape special regex characters in selector const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // Find the animation rule position const animationPattern = new RegExp( `${escapedSelector}\\s*\\{[^}]*animation:\\s*${keyframeName}[^}]*\\}`, "g" ); const animationMatch = animationPattern.exec(cssText); if (!animationMatch) return ""; const animationEndPos = animationMatch.index + animationMatch[0].length; // Look for stagger list AFTER this animation rule and BEFORE the next @keyframes const afterAnimation = cssText.slice(animationEndPos); const nextKeyframe = afterAnimation.indexOf("@keyframes"); const searchText = nextKeyframe > -1 ? afterAnimation.slice(0, nextKeyframe) : afterAnimation; // Find selector with nth-child pattern const staggerMatch = searchText.match( /([^\s{]+)\s*\{\s*>\s*:nth-child\(\d+\)\{--i:\d+\}/ ); if (staggerMatch) { return staggerMatch[1].trim(); } return ""; } function parseStaggerCount(cssText) { // Count how many nth-child entries there are const matches = cssText.match(/>\s*:nth-child\(\d+\)/g); return matches ? matches.length : 10; } function loadExistingTimelinesFromIframe() { const doc = iframe.contentDocument; if (!doc) return false; const existingStyles = doc.querySelectorAll("style[data-css-animator]"); if (!existingStyles.length) return false; let hasLoadedTimelines = false; existingStyles.forEach((styleEl) => { const timelineName = styleEl.getAttribute("data-css-animator"); if (!timelineName) return; let cssText = styleEl.textContent; // Normalize whitespace - replace newlines with spaces for easier parsing cssText = cssText.replace(/\n/g, " ").replace(/\s+/g, " "); // Create timeline const timeline = createNewTimeline(timelineName); if (!timeline) return; hasLoadedTimelines = true; // First, find all @keyframes declarations // Use a more robust approach that handles nested braces const keyframesData = []; const keyframesStartPattern = /@keyframes\s+([^\s{]+)\s*\{/g; let keyframeStartMatch; while ( (keyframeStartMatch = keyframesStartPattern.exec(cssText)) !== null ) { const animName = keyframeStartMatch[1]; const startPos = keyframeStartMatch.index; let braceCount = 1; let pos = keyframeStartMatch.index + keyframeStartMatch[0].length; // Find the matching closing brace while (pos < cssText.length && braceCount > 0) { if (cssText[pos] === "{") braceCount++; if (cssText[pos] === "}") braceCount--; pos++; } const keyframeContent = cssText.substring(startPos, pos); keyframesData.push({ name: animName, content: keyframeContent, fullMatch: keyframeContent, }); } // Now find all animation rules // Match: selector { animation: name duration easing delay both; } // This will match any selector followed by animation property const animationPattern = /([^{}@]+?)\s*\{\s*animation:\s*([^;}]+)[;}]/g; let animMatch; while ((animMatch = animationPattern.exec(cssText)) !== null) { const [fullMatch, rawSelector, animProps] = animMatch; // Skip if this is a stagger list (contains nth-child pattern with --i assignment) // The pattern is: > :nth-child(N){--i:N} if (fullMatch.includes(":nth-child") && fullMatch.includes("{--i:")) { continue; } // Clean selector - remove any leading/trailing whitespace and closing braces const cleanSelector = rawSelector .trim() .replace(/^[}\s]+/, "") .replace(/\s+/g, " "); // Skip empty selectors if (!cleanSelector) { continue; } // Extract animation name from props (first word) const animNameMatch = animProps.trim().match(/^([^\s]+)/); if (!animNameMatch) { continue; } const animName = animNameMatch[1]; // Find matching keyframe (remove -r#### suffix from animation name) const baseAnimName = animName.replace(/-r\d+$/, ""); const matchingKeyframe = keyframesData.find((kf) => { const kfBaseName = kf.name.replace(/-r\d+$/, ""); return kfBaseName === baseAnimName; }); if (!matchingKeyframe) { continue; } // Parse animation properties const animData = parseAnimationFromCSS( `animation: ${animProps.trim()}` ); if (!animData) { continue; } // Parse keyframe properties const properties = parseKeyframeProperties(matchingKeyframe.content); // Parse stagger list and count for this specific animation const staggerList = parseStaggerList(cssText, cleanSelector, animName); const staggerCount = staggerList ? parseStaggerCount(cssText) : 10; // Create a new wrap for this animation const newWrap = createTimelineWrap(); if (!newWrap || !timelineList) continue; timelineList.appendChild(newWrap); // Create animation data const fullAnimData = { counter: animationCounter++, selector: cleanSelector, animationName: `${animData.animationName}-${animationCounter - 1}`, duration: animData.duration, delay: animData.delay, ease: animData.ease, properties: properties.length ? properties : [ { name: "Opacity", startValue: "0", endValue: "1", isReadonly: true, }, ], stagger: animData.stagger, staggerCount: staggerCount, staggerList: staggerList, }; timeline.animations.set(newWrap, fullAnimData); timeline.wraps.push(newWrap); // Update visual position updateTimelineItemVisuals(newWrap); } // Set first wrap as primary if (timeline.wraps.length > 0) { timeline.primaryWrap = timeline.wraps[0]; timeline.selectedWraps = [timeline.wraps[0]]; } }); if (hasLoadedTimelines) { // Switch to first timeline const firstTimelineName = Array.from(timelines.keys())[0]; if (firstTimelineName) { switchToTimeline(firstTimelineName); } updateTimelineSelect(); } return hasLoadedTimelines; } function initTimelineDefaults() { const wraps = document.querySelectorAll(".animator_timeline_line_wrap"); if (!wraps || !wraps.length) return; // Save the first wrap as a template wrapTemplate = wraps[0].cloneNode(true); // First, try to load existing timelines from iframe if (loadExistingTimelinesFromIframe()) { // Successfully loaded existing timelines // Remove placeholder wraps from DOM wraps.forEach((wrap) => wrap.remove()); return; } // No existing timelines found, create default timeline // Create the first timeline const firstTimeline = createNewTimeline("timeline-1"); if (!firstTimeline) return; currentTimelineName = firstTimeline.name; if (timelineNameInput) { timelineNameInput.value = firstTimeline.name; } // Only use the first wrap for the initial timeline const firstWrap = wraps[0]; const item = firstWrap.querySelector(".animator_timeline_line_item"); if (!item) return; if (!item.style.width) item.style.width = "5em"; if (!item.style.left) item.style.left = "0em"; const computed = getComputedStyle(item); const fontSize = parseFloat(computed.fontSize) || 16; const widthEm = parseEmValue(item.style.width, computed.width, fontSize) || 5; const leftEm = parseEmValue(item.style.left, computed.left, fontSize) || 0; const animData = createNewAnimation(); animData.duration = emToSeconds(widthEm); animData.delay = emToSeconds(leftEm); firstTimeline.animations.set(firstWrap, animData); firstTimeline.wraps.push(firstWrap); // Remove all other wraps from DOM (they were just placeholders) for (let i = 1; i < wraps.length; i++) { wraps[i].remove(); } setActiveTimeline(firstWrap); updateTimelineSelect(); } function initPanelValues() { document.querySelectorAll(".animator_panel_item_wrap").forEach((wrap) => { const titleEl = wrap.querySelector(".animator_panel_item_title"); const startInput = wrap.querySelector('[data-animator="start-value"]'); const endInput = wrap.querySelector('[data-animator="end-value"]'); if (!startInput || !endInput || !titleEl) return; if (titleEl.value === "Opacity") { startInput.value = "0"; endInput.value = "1"; } else { if (!startInput.value) { startInput.value = startInput.placeholder; } if (!endInput.value) { endInput.value = endInput.placeholder; } } }); updatePanelActiveStates(); } function updatePanelActiveStates() { document.querySelectorAll(".animator_panel_item_wrap").forEach((wrap) => { const startInput = wrap.querySelector('[data-animator="start-value"]'); const endInput = wrap.querySelector('[data-animator="end-value"]'); if (startInput && endInput) { const hasValue = startInput.value.trim() !== "" || endInput.value.trim() !== ""; wrap.classList.toggle("is-active", hasValue); } }); } function handleNumericInputKeys(e) { if (e.key !== "ArrowUp" && e.key !== "ArrowDown") { return; } e.preventDefault(); const input = e.target; const increment = e.shiftKey ? 1.0 : 0.1; const direction = e.key === "ArrowUp" ? 1 : -1; const value = input.value; const numberPart = parseFloat(value); if (isNaN(numberPart)) { return; } const unitMatch = value.match(/[a-zA-Z%]+$/); const unit = unitMatch ? unitMatch[0] : ""; let newValue = numberPart + direction * increment; newValue = Math.max(0, newValue); input.value = parseFloat(newValue.toFixed(2)) + unit; const inputEvent = new Event("input", { bubbles: true, cancelable: true }); input.dispatchEvent(inputEvent); } function bindTimelineDrag() { if (!timelineList) return; let dragState = null; function onDragMove(e) { if (!dragState) return; const animations = getCurrentAnimations(); const deltaEm = Math.round( (e.clientX - dragState.startX) / dragState.fontSize ); if (dragState.isMultiSelection && dragState.type === "delay") { // Multi-selection: adjust delay for all selected items dragState.wraps.forEach((wrap, index) => { const animData = animations.get(wrap); const item = wrap.querySelector(".animator_timeline_line_item"); if (!animData || !item) return; const startLeftEm = dragState.startLeftEms[index]; const nextLeftEm = Math.max(0, startLeftEm + deltaEm); item.style.left = `${nextLeftEm}em`; animData.delay = emToSeconds(nextLeftEm); }); } else { // Single selection const animData = animations.get(dragState.wrap); if (!animData) return; if (dragState.type === "duration") { const nextWidthEm = Math.max(1, dragState.startWidthEm + deltaEm); dragState.item.style.width = `${nextWidthEm}em`; animData.duration = emToSeconds(nextWidthEm); } else if (dragState.type === "delay") { const nextLeftEm = Math.max(0, dragState.startLeftEm + deltaEm); dragState.item.style.left = `${nextLeftEm}em`; animData.delay = emToSeconds(nextLeftEm); } else if (dragState.type === "start") { const startLeft = dragState.startLeftEm; const startWidth = dragState.startWidthEm; let newLeft = startLeft + deltaEm; let newWidth = startWidth - deltaEm; if (newLeft < 0) { newWidth += newLeft; newLeft = 0; } if (newWidth < 1) { newLeft += newWidth - 1; newWidth = 1; } if (newLeft < 0) { newLeft = 0; } dragState.item.style.left = `${newLeft}em`; dragState.item.style.width = `${newWidth}em`; animData.duration = emToSeconds(newWidth); animData.delay = emToSeconds(newLeft); } } updateDurationDelayInputs(); rebuildCurrentTimelineStyles(); } function endDrag() { dragState = null; window.removeEventListener("pointermove", onDragMove); window.removeEventListener("pointerup", endDrag); window.removeEventListener("pointercancel", endDrag); } timelineList.addEventListener("pointerdown", (e) => { const wrap = e.target.closest(".animator_timeline_line_wrap"); const item = e.target.closest(".animator_timeline_line_item"); if (!item || !wrap || !timelineList.contains(item)) return; // Blur any focused input when interacting with timeline const activeElement = document.activeElement; if ( activeElement && (activeElement.tagName === "INPUT" || activeElement.tagName === "TEXTAREA" || activeElement.tagName === "SELECT") ) { activeElement.blur(); } const handle1 = e.target.closest(".animator_timeline_line_drag.is-1"); const handle2 = e.target.closest(".animator_timeline_line_drag.is-2"); let type; if (handle1 && handle1.closest(".animator_timeline_line_item") === item) { type = "start"; } else if ( handle2 && handle2.closest(".animator_timeline_line_item") === item ) { type = "duration"; } else if (!e.target.closest(".animator_timeline_line_drag")) { type = "delay"; } else { return; } const selectedTimelineWraps = getCurrentSelectedWraps(); // Only allow delay dragging for multi-selection const isMultiSelection = selectedTimelineWraps.length > 1 && selectedTimelineWraps.includes(wrap); if (isMultiSelection && type !== "delay") { return; // Don't allow duration/start dragging in multi-selection } e.preventDefault(); const computed = getComputedStyle(item); const fontSize = parseFloat(computed.fontSize) || 16; if (isMultiSelection && type === "delay") { // Multi-selection delay drag const startLeftEms = selectedTimelineWraps.map((w) => { const wItem = w.querySelector(".animator_timeline_line_item"); if (!wItem) return 0; const wComputed = getComputedStyle(wItem); return parseEmValue(wItem.style.left, wComputed.left, fontSize) || 0; }); dragState = { type, isMultiSelection: true, wraps: selectedTimelineWraps, startX: e.clientX, startLeftEms, fontSize, }; } else { // Single selection drag const startWidthEm = parseEmValue(item.style.width, computed.width, fontSize) || 5; const startLeftEm = parseEmValue(item.style.left, computed.left, fontSize) || 0; dragState = { type, item, wrap, isMultiSelection: false, startX: e.clientX, startWidthEm, startLeftEm, fontSize, }; } window.addEventListener("pointermove", onDragMove); window.addEventListener("pointerup", endDrag); window.addEventListener("pointercancel", endDrag); }); // Click handler for timeline selection timelineList.addEventListener("click", (e) => { const wrap = e.target.closest(".animator_timeline_line_wrap"); if (wrap) { setActiveTimeline(wrap, e.shiftKey); } }); } function absolutizeUrls(html) { const origin = `https://${currentDomain}`; html = html.replace( /(href|src)=["'](?!https?:|\/\/|data:|blob:|#)([^"']+)["']/gi, (m, attr, url) => `${attr}="${origin}${url.startsWith("/") ? "" : "/"}${url}"` ); html = html.replace( /url\(["']?(?!https?:|\/\/|data:|blob:)([^"')]+)["']?\)/gi, (m, url) => `url("${origin}${url.startsWith("/") ? "" : "/"}${url}")` ); html = html.replace( /(srcset=["'])([^"']+)/gi, (m, prefix, value) => prefix + value .split(",") .map((v) => { const p = v.trim().split(" "); if (/^(https?:|\/\/|data:|blob:)/i.test(p[0])) return v; p[0] = `${origin}${p[0].startsWith("/") ? "" : "/"}${p[0]}`; return p.join(" "); }) .join(", ") ); return html; } function isEditable(el) { if (!el) return false; if (el.closest?.("[contenteditable='true']")) return true; const tag = el.tagName?.toLowerCase(); return tag === "input" || tag === "textarea" || tag === "select"; } function isArrowKey(key) { return ( key === "ArrowUp" || key === "ArrowDown" || key === "ArrowLeft" || key === "ArrowRight" ); } function isPartiallyInView(el, doc) { const rect = el.getBoundingClientRect(); const vh = doc.documentElement.clientHeight; return rect.bottom > 0 && rect.top < vh; } let currentHoveredDom = null; let currentHoveredLayer = null; function clearHoveredLayer() { if (currentHoveredLayer) { currentHoveredLayer.classList.remove("is-hovered"); currentHoveredLayer = null; } } function clearHoveredDom() { if (currentHoveredDom) { currentHoveredDom.classList.remove("moden-animator-hover"); currentHoveredDom = null; } } function setHoveredDom(domEl) { if (!domEl || currentHoveredDom === domEl) return; clearHoveredDom(); clearHoveredLayer(); currentHoveredDom = domEl; domEl.classList.add("moden-animator-hover"); lastHoverEl = domEl; const layerItem = domToLayer.get(domEl); if (layerItem) { currentHoveredLayer = layerItem; layerItem.classList.add("is-hovered"); } } function setHoveredLayer(layerItem) { if (!layerItem || currentHoveredLayer === layerItem) return; clearHoveredLayer(); clearHoveredDom(); currentHoveredLayer = layerItem; layerItem.classList.add("is-hovered"); const domEl = layerToDom.get(layerItem); if (domEl) { currentHoveredDom = domEl; domEl.classList.add("moden-animator-hover"); lastHoverEl = domEl; } } function clearAllHover() { clearHoveredLayer(); clearHoveredDom(); lastHoverEl = null; } function clearSelectedLayer() { layerList .querySelectorAll(".animator_layer_item.is-selected") .forEach((n) => n.classList.remove("is-selected")); } function clearActiveDom() { const doc = iframe.contentDocument; if (!doc) return; doc .querySelectorAll(".moden-animator-active") .forEach((n) => n.classList.remove("moden-animator-active")); } function setActiveDom(domEl, shouldScroll) { const doc = iframe.contentDocument; if (!doc || !domEl) return; clearActiveDom(); domEl.classList.add("moden-animator-active"); lastActiveEl = domEl; const layerItem = domToLayer.get(domEl); if (layerItem) { clearSelectedLayer(); layerItem.classList.add("is-selected"); let parent = layerItem.parentElement?.closest(".animator_layer_item"); while (parent) { parent.classList.add("is-open"); parent = parent.parentElement?.closest(".animator_layer_item"); } // Scroll layer item if out of view (top or bottom) const rect = layerItem.getBoundingClientRect(); const containerRect = layerList.getBoundingClientRect(); if (rect.top < containerRect.top || rect.bottom > containerRect.bottom) { layerItem.scrollIntoView({ block: "start", behavior: "auto" }); } } if (shouldScroll && !isPartiallyInView(domEl, doc)) { domEl.scrollIntoView({ block: "start", behavior: "smooth" }); } } function setSelectedLayer(layerItem) { if (!layerItem) return; clearSelectedLayer(); layerItem.classList.add("is-selected"); let parent = layerItem.parentElement?.closest(".animator_layer_item"); while (parent) { parent.classList.add("is-open"); parent = parent.parentElement?.closest(".animator_layer_item"); } // Scroll if out of view (top or bottom) const rect = layerItem.getBoundingClientRect(); const containerRect = layerList.getBoundingClientRect(); if (rect.top < containerRect.top || rect.bottom > containerRect.bottom) { layerItem.scrollIntoView({ block: "start", behavior: "auto" }); } const domEl = layerToDom.get(layerItem); if (domEl) setActiveDom(domEl, true); } function enableIframeSelection() { const doc = iframe.contentDocument; if (!doc) return; doc.addEventListener( "click", (e) => { const t = e.target; if (!t || t.nodeType !== 1) return; e.preventDefault(); e.stopPropagation(); setActiveDom(t, true); }, { capture: true } ); doc.addEventListener( "mouseover", (e) => { const t = e.target; if (!t || t.nodeType !== 1) return; e.stopPropagation(); setHoveredDom(t); }, { capture: true } ); doc.addEventListener( "mouseout", () => { clearAllHover(); }, { capture: true } ); } function bindIframeScrollSync() { const doc = iframe.contentDocument; if (!doc) return; if (iframe.__animatorScrollHandler) { doc.removeEventListener("scroll", iframe.__animatorScrollHandler, true); } iframe.__animatorScrollHandler = () => { // Scroll events will be handled by the animation frame loop }; doc.addEventListener("scroll", iframe.__animatorScrollHandler, { passive: true, capture: true, }); } function getFocusedElementAcrossFrames() { const ae = document.activeElement; if (ae === iframe) { try { return iframe.contentDocument?.activeElement || iframe; } catch { return iframe; } } return ae; } function buildNavigationMap(domEl) { if (!domEl || domEl.nodeType !== 1) return; const navData = { parent: domEl.parentElement, firstChild: domEl.firstElementChild, prevSibling: domEl.previousElementSibling, nextSibling: domEl.nextElementSibling, }; layerNavMap.set(domEl, navData); [...domEl.children].forEach((child) => buildNavigationMap(child)); } function getNavigationTarget(current, direction) { const navData = layerNavMap.get(current); if (!navData) return null; switch (direction) { case "ArrowUp": return navData.parent; case "ArrowDown": return navData.firstChild; case "ArrowLeft": return navData.prevSibling; case "ArrowRight": return navData.nextSibling; default: return null; } } function createArrowHandler() { return (e) => { if (!isArrowKey(e.key)) return; const focused = getFocusedElementAcrossFrames(); if (isEditable(focused)) return; const doc = iframe.contentDocument; if (!doc || isEditable(doc.activeElement)) return; let active = doc.querySelector(".moden-animator-active") || doc.body; let next = getNavigationTarget(active, e.key); if (next && !domToLayer.has(next)) { let attempts = 0; while (next && !domToLayer.has(next) && attempts < 10) { next = getNavigationTarget(next, e.key); attempts++; } } e.preventDefault(); if (next && next !== active && domToLayer.has(next)) { setActiveDom(next, true); } }; } function bindGlobalArrowNav() { if (window.__animatorArrowHandler) { window.removeEventListener( "keydown", window.__animatorArrowHandler, true ); } window.__animatorArrowHandler = createArrowHandler(); window.addEventListener("keydown", window.__animatorArrowHandler, true); } function bindIframeArrowNav() { const doc = iframe.contentDocument; if (!doc) return; if (iframe.__animatorArrowHandler) { doc.removeEventListener("keydown", iframe.__animatorArrowHandler, true); } iframe.__animatorArrowHandler = createArrowHandler(); doc.addEventListener("keydown", iframe.__animatorArrowHandler, true); } // Global keyboard shortcuts window.addEventListener("keydown", (e) => { const focused = getFocusedElementAcrossFrames(); // Command/Ctrl + D - Duplicate (prevent default bookmark action) if ((e.metaKey || e.ctrlKey) && e.key === "d") { e.preventDefault(); if (isEditable(focused)) return; duplicateActiveTimeline(); return; } if (isEditable(focused)) return; // Spacebar - Replay animations if (e.key === " " || e.code === "Space") { e.preventDefault(); // Set play button to pressed state const playButton = document.querySelector( '[data-animator="play"] button' ); if (playButton) { playButton.setAttribute("aria-pressed", "true"); // Calculate longest animation duration const timeline = getCurrentTimeline(); if (timeline) { let maxDuration = 0; timeline.animations.forEach((animData) => { const totalDuration = (animData.duration + animData.delay) * 1000; if (animData.stagger && animData.staggerCount) { const staggerMs = parseFloat(animData.stagger) * 1000 * (animData.staggerCount - 1); maxDuration = Math.max(maxDuration, totalDuration + staggerMs); } else { maxDuration = Math.max(maxDuration, totalDuration); } }); // Reset button after animations finish setTimeout(() => { playButton.setAttribute("aria-pressed", "false"); }, maxDuration); } } rebuildCurrentTimelineStyles(); } // Delete or Backspace - Delete if (e.key === "Delete" || e.key === "Backspace") { e.preventDefault(); deleteActiveTimeline(); } }); bindGlobalArrowNav(); function getLabel(el) { return el.classList.length ? el.classList[0] : el.tagName.toLowerCase(); } function getElementTypeClass(el) { const tagName = el.tagName?.toLowerCase(); if (el.classList.contains("w-embed")) return "is-embed"; if (el.classList.contains("w-richtext")) return "is-richtext"; if (el.classList.contains("w-dyn-list")) return "is-cms-wrap"; if (el.classList.contains("w-dyn-items")) return "is-cms-list"; if (el.classList.contains("w-dyn-item")) return "is-cms-item"; if (el.classList.contains("w-dyn-empty")) return "is-cms-empty"; switch (tagName) { case "body": return "is-body"; case "div": return "is-div"; case "img": return "is-img"; case "section": return "is-section"; case "a": return "is-link"; case "h1": case "h2": case "h3": case "h4": case "h5": case "h6": return "is-heading"; case "p": return "is-paragraph"; case "ol": case "ul": return "is-list"; case "li": return "is-list-item"; case "blockquote": return "is-blockquote"; default: return "is-custom"; } } function cloneLayerItem(domEl) { const clone = templateItem.cloneNode(true); const text = clone.querySelector(".animator_layer_text"); const slot = clone.querySelector(".animator_layer_slot"); clone.classList.remove("is-open", "is-selected", "is-custom", "is-body"); clone.dataset.layerId = String(++idCounter); const typeClass = getElementTypeClass(domEl); clone.classList.add(typeClass); if (text) text.textContent = getLabel(domEl); if (slot) slot.innerHTML = ""; domToLayer.set(domEl, clone); layerToDom.set(clone, domEl); return { item: clone, slot }; } function buildLayerTree(domEl, parentSlot) { if (!domEl || domEl.nodeType !== 1 || !parentSlot) return; const tagName = domEl.tagName?.toLowerCase(); const { item, slot } = cloneLayerItem(domEl); parentSlot.appendChild(item); [...domEl.children].forEach((child) => buildLayerTree(child, slot)); } function rebuildLayers() { const doc = iframe.contentDocument; if (!doc || !doc.body) return; idCounter = 0; layerToDom.clear(); layerNavMap.clear(); layerList.innerHTML = ""; buildNavigationMap(doc.body); buildLayerTree(doc.body, layerList); const first = layerList.querySelector(".animator_layer_item"); if (first) { first.classList.add("is-open", "is-selected"); const domEl = layerToDom.get(first); if (domEl) { clearActiveDom(); domEl.classList.add("moden-animator-active"); lastActiveEl = domEl; } } } layerList.addEventListener("click", (e) => { const icon = e.target.closest(".animator_layer_icon"); if (icon) { const item = icon.closest(".animator_layer_item"); if (item) item.classList.toggle("is-open"); return; } const toggle = e.target.closest(".animator_layer_toggle"); if (!toggle) return; const item = toggle.closest(".animator_layer_item"); if (item) setSelectedLayer(item); }); layerList.addEventListener( "mouseover", (e) => { const item = e.target.closest(".animator_layer_item"); if (item) { e.stopPropagation(); setHoveredLayer(item); } }, { capture: true } ); layerList.addEventListener( "mouseout", (e) => { const item = e.target.closest(".animator_layer_item"); if (item) clearAllHover(); }, { capture: true } ); if (buttonWrap) { buttonWrap.__pressed = buttonWrap.getAttribute("aria-pressed") === "true" || layerList.querySelector(".animator_layer_item.is-open"); buttonWrap.addEventListener("click", (e) => { e.preventDefault(); e.stopImmediatePropagation(); const btn = e.currentTarget; btn.__pressed = !btn.__pressed; btn.setAttribute("aria-pressed", btn.__pressed ? "true" : "false"); layerList.querySelectorAll(".animator_layer_item").forEach((item) => { item.classList.toggle("is-open", btn.__pressed); }); }); } bindTimelineDrag(); document .querySelectorAll(".animator_panel_item_wrap input") .forEach((input) => { input.addEventListener("input", () => { updateActiveAnimation(); updatePanelActiveStates(); }); }); document .querySelectorAll( '[data-animator="start-value"], [data-animator="end-value"]' ) .forEach((input) => { input.addEventListener("keydown", handleNumericInputKeys); // Add auto-unit for rotate/skew fields on focusout input.addEventListener("focusout", () => { const wrap = input.closest(".animator_panel_item_wrap"); if (!wrap) return; const titleEl = wrap.querySelector(".animator_panel_item_title"); if (!titleEl) return; const titleValue = titleEl.value.trim(); const isRotateOrSkew = titleValue === "Rotate Z" || titleValue.toLowerCase().includes("rotate") || titleValue.toLowerCase().includes("skew"); if (isRotateOrSkew && input.value.trim() !== "") { const value = input.value.trim(); // Check if value is just a number without unit if (/^-?\d+\.?\d*$/.test(value)) { input.value = value + "deg"; // Trigger input event to update animation const inputEvent = new Event("input", { bubbles: true, cancelable: true, }); input.dispatchEvent(inputEvent); } } }); }); document.addEventListener("click", (e) => { const clearButton = e.target.closest(".animator_panel_button"); if (!clearButton) return; const wrap = clearButton.closest(".animator_panel_item_wrap"); if (!wrap) return; const startInput = wrap.querySelector('[data-animator="start-value"]'); const endInput = wrap.querySelector('[data-animator="end-value"]'); if (startInput) startInput.value = ""; if (endInput) endInput.value = ""; updatePanelActiveStates(); updateActiveAnimation(); }); if (animationNameInput) { animationNameInput.addEventListener("input", () => { updateActiveAnimation(); updateTimelineText(); }); animationNameInput.addEventListener("focusout", () => { let name = animationNameInput.value.trim(); name = name.toLowerCase().replace(/\s+/g, "-"); const primaryTimelineWrap = getCurrentPrimaryWrap(); // Check if name is taken by another animation if (isAnimationNameTaken(name, primaryTimelineWrap)) { // Find a unique name by appending numbers let counter = 1; let uniqueName = `${name}-${counter}`; while (isAnimationNameTaken(uniqueName, primaryTimelineWrap)) { counter++; uniqueName = `${name}-${counter}`; } name = uniqueName; } animationNameInput.value = name; updateActiveAnimation(); updateTimelineText(); }); animationNameInput.addEventListener("focus", () => { animationNameInput.select(); }); } if (selectorInput) { selectorInput.addEventListener("input", () => { updateActiveAnimation(); }); } if (durationInput) { durationInput.addEventListener("input", () => { const newDuration = parseFloat(durationInput.value) || 0; const primaryTimelineWrap = getCurrentPrimaryWrap(); if (primaryTimelineWrap) { const animations = getCurrentAnimations(); const animData = animations.get(primaryTimelineWrap); if (animData) { animData.duration = newDuration; updateTimelineItemVisuals(primaryTimelineWrap); rebuildCurrentTimelineStyles(); } } }); durationInput.addEventListener("keydown", handleNumericInputKeys); } if (delayInput) { delayInput.addEventListener("input", () => { const newDelay = parseFloat(delayInput.value) || 0; const primaryTimelineWrap = getCurrentPrimaryWrap(); if (primaryTimelineWrap) { const animations = getCurrentAnimations(); const animData = animations.get(primaryTimelineWrap); if (animData) { animData.delay = newDelay; updateTimelineItemVisuals(primaryTimelineWrap); rebuildCurrentTimelineStyles(); } } }); delayInput.addEventListener("keydown", handleNumericInputKeys); } if (easeInput) { easeInput.addEventListener("change", () => { updateActiveAnimation(); }); } if (staggerInput) { staggerInput.addEventListener("input", () => { updateActiveAnimation(); }); staggerInput.addEventListener("keydown", handleNumericInputKeys); } if (staggerCountInput) { staggerCountInput.addEventListener("input", () => { updateActiveAnimation(); }); } if (staggerListInput) { staggerListInput.addEventListener("input", () => { updateActiveAnimation(); }); } // Add event listener for timeline name input if (timelineNameInput) { timelineNameInput.addEventListener("input", () => { const newName = timelineNameInput.value.trim(); if (newName && newName !== currentTimelineName) { const oldName = currentTimelineName; const timeline = timelines.get(oldName); if (timeline && !timelines.has(newName)) { // Rename the timeline timelines.delete(oldName); timeline.name = newName; timelines.set(newName, timeline); currentTimelineName = newName; updateTimelineSelect(); rebuildAllAnimationStyles(); } } }); timelineNameInput.addEventListener("focusout", () => { // Reset to current timeline name if the value doesn't match if (timelineNameInput.value.trim() !== currentTimelineName) { timelineNameInput.value = currentTimelineName; } }); } // Add event listener for timeline select if (timelineSelectInput) { timelineSelectInput.addEventListener("change", () => { const selectedName = timelineSelectInput.value; if (selectedName && timelines.has(selectedName)) { switchToTimeline(selectedName); } }); } // Add event listener for new timeline button if (newTimelineButton) { newTimelineButton.addEventListener("click", () => { // Generate unique timeline name const newTimelineName = generateUniqueTimelineName("timeline"); // Create new timeline const newTimeline = createNewTimeline(newTimelineName); if (!newTimeline) return; // Create a single new wrap for this timeline const newWrap = createTimelineWrap(); if (!newWrap || !timelineList) return; // Add to DOM timelineList.appendChild(newWrap); // Create default animation data const animData = createNewAnimation(); animData.selector = ".my-class"; animData.animationName = `fade-in-${animData.counter}`; animData.duration = 0.5; animData.delay = 0; animData.ease = "ease-out"; animData.properties = [ { name: "Opacity", startValue: "0", endValue: "1", isReadonly: true }, ]; animData.stagger = ""; animData.staggerCount = 10; animData.staggerList = ""; // Add to new timeline newTimeline.animations.set(newWrap, animData); newTimeline.wraps.push(newWrap); newTimeline.primaryWrap = newWrap; newTimeline.selectedWraps = [newWrap]; // Switch to the new timeline switchToTimeline(newTimelineName); // Rebuild styles to create the new style tag rebuildAllAnimationStyles(); }); } // Add event listener for new-animation button const newAnimationButton = document.querySelector( 'button[data-animator="new-animation"]' ); if (newAnimationButton) { newAnimationButton.addEventListener("click", () => { duplicateActiveTimeline(); }); } // Add event listener for move-up button const moveUpButton = document.querySelector( 'button[data-animator="move-up"]' ); if (moveUpButton) { moveUpButton.addEventListener("click", () => { const primaryWrap = getCurrentPrimaryWrap(); const timeline = getCurrentTimeline(); if (!primaryWrap || !timeline) return; const currentIndex = timeline.wraps.indexOf(primaryWrap); if (currentIndex <= 0) return; // Already at top or not found // Swap with previous wrap in the wraps array const temp = timeline.wraps[currentIndex - 1]; timeline.wraps[currentIndex - 1] = timeline.wraps[currentIndex]; timeline.wraps[currentIndex] = temp; // Move the DOM element up const previousSibling = primaryWrap.previousElementSibling; if ( previousSibling && previousSibling.classList.contains("animator_timeline_line_wrap") ) { primaryWrap.parentElement.insertBefore(primaryWrap, previousSibling); } // Rebuild styles to update order in CSS rebuildCurrentTimelineStyles(); }); } // Add event listener for move-down button const moveDownButton = document.querySelector( 'button[data-animator="move-down"]' ); if (moveDownButton) { moveDownButton.addEventListener("click", () => { const primaryWrap = getCurrentPrimaryWrap(); const timeline = getCurrentTimeline(); if (!primaryWrap || !timeline) return; const currentIndex = timeline.wraps.indexOf(primaryWrap); if (currentIndex === -1 || currentIndex >= timeline.wraps.length - 1) return; // Already at bottom or not found // Swap with next wrap in the wraps array const temp = timeline.wraps[currentIndex + 1]; timeline.wraps[currentIndex + 1] = timeline.wraps[currentIndex]; timeline.wraps[currentIndex] = temp; // Move the DOM element down const nextSibling = primaryWrap.nextElementSibling; if ( nextSibling && nextSibling.classList.contains("animator_timeline_line_wrap") ) { primaryWrap.parentElement.insertBefore(nextSibling, primaryWrap); } // Rebuild styles to update order in CSS rebuildCurrentTimelineStyles(); }); } // Add event listener for play button const playButton = document.querySelector('[data-animator="play"] button'); if (playButton) { playButton.addEventListener("click", () => { // Set button to pressed state playButton.setAttribute("aria-pressed", "true"); // Trigger the same action as spacebar (rebuild current timeline styles) rebuildCurrentTimelineStyles(); // Calculate the longest animation duration + delay in current timeline const timeline = getCurrentTimeline(); if (timeline) { let maxDuration = 0; timeline.animations.forEach((animData) => { const totalDuration = (animData.duration + animData.delay) * 1000; // Convert to ms // If stagger is enabled, add stagger time for max stagger count if (animData.stagger && animData.staggerCount) { const staggerMs = parseFloat(animData.stagger) * 1000 * (animData.staggerCount - 1); maxDuration = Math.max(maxDuration, totalDuration + staggerMs); } else { maxDuration = Math.max(maxDuration, totalDuration); } }); // Set button back to unpressed after all animations finish setTimeout(() => { playButton.setAttribute("aria-pressed", "false"); }, maxDuration); } }); } // Add event listener for copy code button const copyCodeButton = document.querySelector( 'button[data-animator="copy-code"]' ); if (copyCodeButton) { copyCodeButton.addEventListener("click", () => { const timeline = getCurrentTimeline(); if (!timeline) return; // Build the CSS without replay counter let cssOutput = ""; // Iterate over wraps array (which has the correct order) instead of animations Map timeline.wraps.forEach((wrap) => { const animData = timeline.animations.get(wrap); if (!animData) return; // Build keyframes const animName = animData.animationName.replace(/-\d+$/, ""); // Remove counter suffix const { properties, selector, duration, delay, ease, stagger, staggerCount, staggerList, } = animData; let fromStyle = ""; let toStyle = ""; const fromTransforms = []; const toTransforms = []; properties.forEach((prop) => { const cssProp = prop.isReadonly ? propertyMap[prop.name] || prop.name : prop.name; if ( transformProperties.includes(cssProp) ) { if (prop.startValue !== "") fromTransforms.push(`${cssProp}(${prop.startValue})`); if (prop.endValue !== "") toTransforms.push(`${cssProp}(${prop.endValue})`); } else { if (prop.startValue !== "") fromStyle += `${cssProp}: ${prop.startValue}; `; if (prop.endValue !== "") toStyle += `${cssProp}: ${prop.endValue}; `; } }); if (fromTransforms.length) fromStyle += `transform: ${fromTransforms.join(" ")};`; if (toTransforms.length) toStyle += `transform: ${toTransforms.join(" ")};`; // Build keyframe if (fromStyle || toStyle) { cssOutput += `@keyframes ${animName} { `; if (fromStyle) cssOutput += `from { ${fromStyle} } `; if (toStyle) cssOutput += `to { ${toStyle} } `; cssOutput += `}\n`; // Build animation rule const hasStagger = stagger !== "" && stagger !== null && stagger !== undefined && !isNaN(parseFloat(stagger)); const animationDelay = hasStagger ? `calc((${stagger}s * var(--i)) + ${formatSeconds(delay)}s)` : `${formatSeconds(delay)}s`; cssOutput += `${selector} { animation: ${animName} ${formatSeconds( duration )}s ${ease} ${animationDelay} both; }\n`; // Add stagger list if present if (staggerList && staggerList.trim() !== "") { const count = parseInt(staggerCount) || 10; const nthParts = []; for (let i = 1; i <= count; i++) { nthParts.push(`> :nth-child(${i}){--i:${i - 1}}`); } cssOutput += `${staggerList.trim()} { ${nthParts.join("")} }\n`; } cssOutput += "\n"; } }); // Wrap in style tag const fullOutput = ``; // Copy to clipboard navigator.clipboard .writeText(fullOutput) .then(() => { // Show feedback with is-active class copyCodeButton.classList.add("is-active"); setTimeout(() => { copyCodeButton.classList.remove("is-active"); }, 1500); }) .catch((err) => { console.error("Failed to copy:", err); alert("Failed to copy to clipboard"); }); }); } initPanelValues(); updateTimelineText(); const activeOutline = document.querySelector( ".animator_overlay_outline.is-active" ); const hoverOutline = document.querySelector( ".animator_overlay_outline.is-hover" ); function syncOutline(outlineEl, targetEl) { if (!outlineEl || !targetEl) { if (outlineEl) outlineEl.style.display = "none"; return; } const r = targetEl.getBoundingClientRect(); outlineEl.style.display = "block"; outlineEl.style.transform = `translate(${r.left}px, ${r.top}px)`; outlineEl.style.width = `${r.width}px`; outlineEl.style.height = `${r.height}px`; } function mountHtml() { iframe.onload = () => { const doc = iframe.contentDocument; // Initialize timelines from existing CSS or create defaults initTimelineDefaults(); // Don't rebuild if we loaded existing timelines (they're already in the DOM) const hasExistingStyles = doc.querySelectorAll("style[data-css-animator]").length > 0; if (!hasExistingStyles) { rebuildAllAnimationStyles(); } if (doc) { if (iframe.__animatorStyleObserver) { iframe.__animatorStyleObserver.disconnect(); } iframe.__animatorStyleObserver = new MutationObserver(() => { // Check if any timeline's style tag is missing let needsRebuild = false; timelines.forEach((timeline) => { if ( !doc.querySelector(`style[data-css-animator="${timeline.name}"]`) ) { needsRebuild = true; } }); if (needsRebuild) { rebuildAllAnimationStyles(); } }); iframe.__animatorStyleObserver.observe(doc.documentElement, { childList: true, subtree: true, }); } enableIframeSelection(); rebuildLayers(); bindIframeArrowNav(); bindIframeScrollSync(); }; iframe.srcdoc = htmlString; } input.addEventListener("keydown", async (e) => { if (e.key !== "Enter") return; e.preventDefault(); const value = input.value.trim(); if (!value) return; // Clear all timelines before loading new HTML timelines.clear(); currentTimelineName = "timeline-1"; // Remove all existing timeline wraps from current timeline if (timelineList) { const allWraps = timelineList.querySelectorAll( ".animator_timeline_line_wrap" ); // If we don't have a template yet, save the first wrap as template if (!wrapTemplate && allWraps.length > 0) { wrapTemplate = allWraps[0].cloneNode(true); } // Remove all wraps allWraps.forEach((wrap) => wrap.remove()); // Add back one fresh wrap from the template if (wrapTemplate) { const freshWrap = wrapTemplate.cloneNode(true); // Reset the wrap to default state const item = freshWrap.querySelector(".animator_timeline_line_item"); if (item) { item.style.width = "5em"; item.style.left = "0em"; } const textElement = freshWrap.querySelector( ".animator_timeline_line_text" ); if (textElement) { textElement.textContent = "fade-in"; } freshWrap.classList.remove("is-active"); timelineList.appendChild(freshWrap); } } currentDomain = value.replace(/^https?:\/\//, "").replace(/\/$/, ""); htmlString = absolutizeUrls(await fetchHTML(currentDomain)); mountHtml(); }); function overlayLoop() { const doc = iframe.contentDocument; if (doc) { if (lastActiveEl && doc.contains(lastActiveEl)) { syncOutline(activeOutline, lastActiveEl); } else { if (activeOutline) activeOutline.style.display = "none"; } if (lastHoverEl && doc.contains(lastHoverEl)) { syncOutline(hoverOutline, lastHoverEl); } else { if (hoverOutline) hoverOutline.style.display = "none"; } } requestAnimationFrame(overlayLoop); } requestAnimationFrame(overlayLoop); });