Preview

Loading document…

${svg}${""} ${""}`; } function escapeAttribute(value) { return String(value) .replace(/&/g, "&") .replace(/"/g, """) .replace(//g, ">"); } const htmlAllowedCdns = [ "https://cdnjs.cloudflare.com", "cdnjs.cloudflare.com", "https://cdn.jsdelivr.net", "cdn.jsdelivr.net", "https://unpkg.com", "unpkg.com", "https://esm.sh", "esm.sh" ].join(" "); const htmlPreviewCsp = [ "default-src 'none'", `script-src 'unsafe-inline' 'unsafe-eval' ${htmlAllowedCdns}`, `style-src 'unsafe-inline' ${htmlAllowedCdns}`, `img-src data: blob: ${htmlAllowedCdns}`, `font-src data: ${htmlAllowedCdns}`, `connect-src ${htmlAllowedCdns}`, `media-src ${htmlAllowedCdns}`, "frame-src 'none'", "object-src 'none'", "form-action 'none'", "base-uri 'none'" ].join("; ") + ";"; function htmlPreviewDocument(html) { const meta = ``; const trimmed = (html || "").trimStart(); if (/^]*>/i.test(trimmed)) { return trimmed.replace(/^]*>/i, (match) => `${match}\n${meta}`); } return `\n${meta}\n${html}`; } // Inline SVG previews use HTML parsing, which accepts comments containing // "--". SVG image decoding and DOMParser use XML, where those comments are // invalid. Remove comments from the export copy, leaving the source and any // literal comment text inside CDATA (such as styles or text) unchanged. function stripSvgComments(code) { // Escape the comment opener so HTML's inline-script parser ignores it. return code.replace(/|\x3c!--[\s\S]*?-->/g, (part) => part.startsWith("\x3c!--") ? "" : part ); } // ---- Animated SVG detection ---- // Parse an SMIL clock value ("2s", "150ms", "1.5min", bare seconds) into // seconds, or null if it is not a plain clock value (e.g. "click", "0:02"). function smilClockSeconds(value) { const m = /^\s*(-?\d+(?:\.\d+)?)(h|min|s|ms)?\s*$/.exec(value || ""); if (!m) return null; const n = parseFloat(m[1]); const unit = m[2]; if (unit === "h") return n * 3600; if (unit === "min") return n * 60; if (unit === "ms") return n / 1000; return n; } const cssTimeRe = /(\d+(?:\.\d+)?|\.\d+)\s*(ms|s)\b/gi; function cssTimesSeconds(value) { const times = []; for (const m of value.matchAll(cssTimeRe)) { times.push(m[2].toLowerCase() === "ms" ? parseFloat(m[1]) / 1000 : parseFloat(m[1])); } return times; } // Inspect SVG source for SMIL or CSS animations. Returns null for a static // SVG, otherwise { duration } — a best-effort guess in seconds at how long // one loop of the animation takes (null when it could not be determined). function detectSvgAnimation(code) { const durations = []; let animated = false; let doc = null; try { doc = new DOMParser().parseFromString(stripSvgComments(code), "image/svg+xml"); if (doc.querySelector("parsererror")) doc = null; } catch { doc = null; } // SMIL: , , , if (doc) { doc.querySelectorAll("animate, set, animateTransform, animateMotion").forEach((el) => { animated = true; const dur = smilClockSeconds(el.getAttribute("dur")); if (dur === null || dur <= 0) return; const repeatDur = smilClockSeconds(el.getAttribute("repeatDur")); const repeatCountAttr = (el.getAttribute("repeatCount") || "").trim(); const repeatCount = /^\d+(\.\d+)?$/.test(repeatCountAttr) ? parseFloat(repeatCountAttr) : null; const begin = smilClockSeconds(el.getAttribute("begin")) || 0; let total; if (repeatDur !== null && repeatDur > 0) { total = Math.max(0, begin) + repeatDur; } else if (repeatCount !== null) { total = Math.max(0, begin) + dur * repeatCount; } else { // Indefinite (or single-shot) repeat: one period is the loop length. total = dur; } durations.push(total); }); } else if (/<(animate|set|animateTransform|animateMotion)[\s>]/.test(code)) { animated = true; } // CSS animations: require @keyframes plus an animation property. if (/@keyframes\s/i.test(code) && /animation(?:-name|-duration)?\s*:/i.test(code)) { animated = true; for (const m of code.matchAll(/animation-duration\s*:\s*([^;}"']+)/gi)) { durations.push(...cssTimesSeconds(m[1])); } for (const m of code.matchAll(/animation\s*:\s*([^;}"']+)/gi)) { // Each comma-separated layer: first