Paste
URL
Load
Preview
Loading document…
Retry
Edit URL
${svg}${"" + "body>"} ${"" + "html>"}`; } 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
is the duration. for (const layer of m[1].split(",")) { const times = cssTimesSeconds(layer); if (!times.length) continue; const infinite = /\binfinite\b/i.test(layer); const countMatch = /(?:^|\s)(\d+(?:\.\d+)?)(?:\s|$)/.exec( layer.replace(cssTimeRe, " ") ); const count = infinite || !countMatch ? 1 : parseFloat(countMatch[1]); const delay = infinite || times.length < 2 ? 0 : times[1]; durations.push(delay + times[0] * count); } } } if (!animated) return null; const max = Math.max(0, ...durations); return { duration: max > 0 ? Math.min(30, Math.round(max * 100) / 100) : null }; } // Produce a copy of the SVG frozen at time t: CSS animations are shifted via // a negative animation-delay and paused, SMIL animations via negative begin // offsets. Rasterizing the result captures the frame at time t, because SVG // images drawn to a canvas render at document time 0. function snapshotSvgAtTime(code, t) { const doc = new DOMParser().parseFromString(stripSvgComments(code), "image/svg+xml"); if (doc.querySelector("parsererror")) { throw new Error("SVG could not be parsed"); } const svg = doc.documentElement; const style = doc.createElementNS("http://www.w3.org/2000/svg", "style"); style.textContent = `*, *::before, *::after { animation-delay: -${t}s !important; ` + "animation-play-state: paused !important; }"; svg.insertBefore(style, svg.firstChild); doc.querySelectorAll("animate, set, animateTransform, animateMotion").forEach((el) => { const begin = el.getAttribute("begin"); if (!begin || !begin.trim()) { el.setAttribute("begin", `${-t}s`); return; } // Shift each clock value in a begin list; leave event-based begins alone. const shifted = begin.split(";").map((part) => { const seconds = smilClockSeconds(part); return seconds === null ? null : `${seconds - t}s`; }); if (shifted.every((part) => part !== null)) { el.setAttribute("begin", shifted.join(";")); } }); return new XMLSerializer().serializeToString(svg); } // ---- WebP and AVIF encoding ---- // Browsers that cannot encode a format from a canvas silently hand back a PNG // instead: Safari (desktop and iOS) cannot do WebP, and most browsers cannot // do AVIF. Feature-detect each format once, and only when the browser cannot // do it natively (or ignores quality) load a jSquash wasm encoder (Squoosh's // libwebp or libavif build) from jsdelivr, the first time that format is // actually requested. const JSQUASH_WEBP_ENCODE_URL = "https://cdn.jsdelivr.net/npm/@jsquash/webp@1.5.0/encode.js/+esm"; const JSQUASH_AVIF_ENCODE_URL = "https://cdn.jsdelivr.net/npm/@jsquash/avif@2.1.1/encode.js/+esm"; // A native encoder is only used if it produces the requested format and // honours the quality setting. Mobile Safari encodes AVIF but ignores // quality, which turned every export into the same (huge) file. const nativeEncodeSupportPromises = {}; function canvasCanEncode(mimeType) { if (!nativeEncodeSupportPromises[mimeType]) { nativeEncodeSupportPromises[mimeType] = (async () => { const canvas = document.createElement("canvas"); canvas.width = 64; canvas.height = 64; if (!canvas.toBlob) return false; // Noise, so that a lower quality has detail to throw away. const ctx = canvas.getContext("2d"); const noise = ctx.createImageData(64, 64); for (let i = 0; i < noise.data.length; i++) { noise.data[i] = i % 4 === 3 ? 255 : Math.random() * 256; } ctx.putImageData(noise, 0, 0); const encodeAt = (quality) => new Promise((resolve) => { try { canvas.toBlob(resolve, mimeType, quality); } catch (err) { resolve(null); } }); const [low, high] = await Promise.all([encodeAt(0.2), encodeAt(0.9)]); return Boolean( low && high && low.type === mimeType && high.type === mimeType && low.size !== high.size ); })(); } return nativeEncodeSupportPromises[mimeType]; } function wasmQuality(quality) { return Math.round(Math.max(0, Math.min(1, quality)) * 100); } let jsquashWebpPromise = null; function loadJsquashWebpEncoder() { if (!jsquashWebpPromise) { // Call init() explicitly: encode() would otherwise cache a failed wasm // load for the lifetime of the page, so a flaky connection could never // be retried. jsquashWebpPromise = import(JSQUASH_WEBP_ENCODE_URL).then(async (mod) => { await mod.init(); return mod.default; }); jsquashWebpPromise.catch(() => { jsquashWebpPromise = null; }); } return jsquashWebpPromise; } async function encodeWebpWithWasm(canvas, quality) { // Grab the pixels before awaiting anything so later canvas drawing cannot // change what gets encoded. const ctx = canvas.getContext("2d"); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const encode = await loadJsquashWebpEncoder(); const buffer = await encode(imageData, { quality: wasmQuality(quality) }); return new Blob([buffer], { type: "image/webp" }); } // The AVIF encoder takes seconds per image, so unlike WebP it runs in a // module worker to keep the page responsive. const avifWorkerSource = ` let encoderPromise = null; self.onmessage = async (e) => { const { data, width, height, quality } = e.data; try { if (!encoderPromise) { encoderPromise = import(${JSON.stringify(JSQUASH_AVIF_ENCODE_URL)}) .then(async (mod) => { await mod.init(); return mod.default; }); } const encode = await encoderPromise; const buffer = await encode({ data, width, height }, { quality }); self.postMessage({ buffer }, [buffer]); } catch (err) { self.postMessage({ error: String((err && err.message) || err) }); } }; `; let avifWorkerUrl = null; let avifWorker = null; // Encodes run one at a time, in the order they were requested. let avifQueue = Promise.resolve(); function runAvifWorkerJob(imageData, quality) { if (!avifWorker) { avifWorkerUrl = avifWorkerUrl || URL.createObjectURL( new Blob([avifWorkerSource], { type: "text/javascript" }) ); avifWorker = new Worker(avifWorkerUrl, { type: "module" }); } const worker = avifWorker; return new Promise((resolve, reject) => { const fail = (message) => { // Start from a fresh worker next time, so a failed encoder download // can be retried instead of being cached for the life of the page. worker.terminate(); if (avifWorker === worker) avifWorker = null; reject(new Error(message)); }; worker.onmessage = (e) => { if (e.data.error !== undefined) fail(e.data.error); else resolve(new Blob([e.data.buffer], { type: "image/avif" })); }; worker.onerror = (e) => { e.preventDefault(); fail(e.message || "AVIF encoder worker failed"); }; const { data, width, height } = imageData; worker.postMessage({ data, width, height, quality }, [data.buffer]); }); } // The pixels are read when the job starts, so the canvas must not be drawn // on again until the returned promise settles. function encodeAvifWithWasm(canvas, quality) { const job = avifQueue.then(() => { const ctx = canvas.getContext("2d"); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); return runAvifWorkerJob(imageData, wasmQuality(quality)); }); avifQueue = job.catch(() => {}); return job; } const WASM_ENCODERS = { "image/webp": encodeWebpWithWasm, "image/avif": encodeAvifWithWasm, }; // Encode a canvas as a Blob of the given raster type. `quality` is 0..1 as // for canvas.toBlob. WebP and AVIF fall back to wasm where needed. async function canvasToBlob(canvas, mimeType, quality = 0.9) { const wasmEncode = WASM_ENCODERS[mimeType]; if (wasmEncode && !(await canvasCanEncode(mimeType))) { return wasmEncode(canvas, quality); } return new Promise((resolve, reject) => { if (!canvas.toBlob) return reject(new Error("canvas.toBlob is unavailable")); canvas.toBlob( (blob) => { if (blob && blob.type === mimeType) resolve(blob); else reject(new Error(`Browser could not encode ${mimeType}`)); }, mimeType, quality ); }); } // ---- ffmpeg.wasm (lazy singleton, loaded from jsdelivr on first use) ---- // The @ffmpeg/ffmpeg wrapper's UMD build cannot start its worker from a CDN // (cross-origin worker restrictions), so this drives @ffmpeg/core directly // from a small same-origin worker created from a blob URL. The core files are // also fetched into blob URLs so importScripts works cross-origin. const FFMPEG_CORE_BASE = "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd"; const FFMPEG_DOWNLOAD_MB = 31; async function fetchAsBlobUrl(url, mimeType) { const res = await fetch(url); if (!res.ok) throw new Error(`Fetch of ${url} returned ${res.status}`); const blob = new Blob([await res.arrayBuffer()], { type: mimeType }); return URL.createObjectURL(blob); } const ffmpegWorkerSource = ` let core = null; self.onmessage = async (e) => { const msg = e.data; try { let result = null; let transfer = []; if (msg.type === "load") { importScripts(msg.coreURL); core = await self.createFFmpegCore({ mainScriptUrlOrBlob: msg.coreURL + "#" + btoa(JSON.stringify({ wasmURL: msg.wasmURL, workerURL: msg.coreURL })) }); core.setProgress((p) => self.postMessage({ type: "progress", progress: p.progress })); } else if (msg.type === "writeFile") { core.FS.writeFile(msg.name, msg.data); } else if (msg.type === "run") { core.setTimeout(-1); core.exec(...msg.args); result = core.ret; core.reset(); } else if (msg.type === "readFile") { result = core.FS.readFile(msg.name, { encoding: "binary" }); transfer = [result.buffer]; } else if (msg.type === "deleteFile") { core.FS.unlink(msg.name); } self.postMessage({ id: msg.id, result }, transfer); } catch (err) { self.postMessage({ id: msg.id, error: String((err && err.message) || err) }); } }; `; let ffmpegPromise = null; // Generations run one at a time: the worker has a single filesystem. let ffmpegJobQueue = Promise.resolve(); function getFfmpeg() { if (!ffmpegPromise) { ffmpegPromise = (async () => { const [coreURL, wasmURL] = await Promise.all([ fetchAsBlobUrl(`${FFMPEG_CORE_BASE}/ffmpeg-core.js`, "text/javascript"), fetchAsBlobUrl(`${FFMPEG_CORE_BASE}/ffmpeg-core.wasm`, "application/wasm") ]); const workerUrl = URL.createObjectURL( new Blob([ffmpegWorkerSource], { type: "text/javascript" }) ); const worker = new Worker(workerUrl); const pending = new Map(); let nextId = 0; const handlers = { progress: null }; worker.onmessage = (e) => { const msg = e.data; if (msg.type === "progress") { if (handlers.progress) handlers.progress(msg.progress); return; } const entry = pending.get(msg.id); if (!entry) return; pending.delete(msg.id); if (msg.error !== undefined) entry.reject(new Error(msg.error)); else entry.resolve(msg.result); }; worker.onerror = () => { pending.forEach((entry) => entry.reject(new Error("ffmpeg worker failed"))); pending.clear(); }; const call = (type, data, transfer) => new Promise((resolve, reject) => { const id = nextId++; pending.set(id, { resolve, reject }); worker.postMessage({ id, type, ...data }, transfer || []); }); await call("load", { coreURL, wasmURL }); return { call, handlers }; })(); ffmpegPromise.catch(() => { ffmpegPromise = null; }); } return ffmpegPromise; } const COPY_ICON_SVG = `
`; const CHECK_ICON_SVG = `
`; function copyTextToClipboard(text) { if (navigator.clipboard && navigator.clipboard.writeText) { return navigator.clipboard.writeText(text).catch(() => fallbackCopy(text)); } return Promise.resolve(fallbackCopy(text)); } function fallbackCopy(text) { const ta = document.createElement("textarea"); ta.value = text; ta.style.position = "fixed"; ta.style.left = "-9999px"; document.body.appendChild(ta); ta.select(); try { document.execCommand("copy"); } finally { ta.remove(); } } function setupCopyButton(shadow, code) { const copyBtn = shadow.querySelector(".copy-btn"); if (!copyBtn) return; let timer = null; copyBtn.addEventListener("click", async () => { await copyTextToClipboard(code); copyBtn.classList.add("copied"); copyBtn.innerHTML = `${CHECK_ICON_SVG}
Copied!
`; clearTimeout(timer); timer = setTimeout(() => { copyBtn.classList.remove("copied"); copyBtn.innerHTML = `${COPY_ICON_SVG}
Copy
`; }, 1500); }); } class SvgBlock extends HTMLElement { connectedCallback() { if (this.shadowRoot) { this.observeImageSize(); return; } const code = this.getAttribute("data-svg") || ""; const shadow = this.attachShadow({ mode: "open" }); this._animation = detectSvgAnimation(code); const mp4Tab = this._animation ? '
MP4
' : ""; const mp4Panel = this._animation ? `
Loop length
s
Generate MP4
Generates a looping H.264 MP4 with ffmpeg.wasm, loaded from jsdelivr the first time this tab is opened (~${FFMPEG_DOWNLOAD_MB} MB, once per page).
Download MP4
` : ""; shadow.innerHTML = `
Rendered
PNG
JPEG
WEBP
AVIF
${mp4Tab}
Code
Rendering PNG…
Download PNG
Rendering JPEG…
Download JPEG
Rendering WEBP…
Download WEBP
Rendering AVIF…
Download AVIF
${mp4Panel}
${COPY_ICON_SVG}
Copy
`; shadow.querySelector(".svg-wrap").style.aspectRatio = svgAspectRatio(code); shadow.querySelector("iframe").srcdoc = svgPreviewDocument(code); shadow.querySelector("pre").textContent = code; setupCopyButton(shadow, code); const buttons = shadow.querySelectorAll(".tabs button"); const panels = shadow.querySelectorAll(".panel"); buttons.forEach((btn) => { btn.addEventListener("click", () => { const tab = btn.dataset.tab; buttons.forEach((b) => b.classList.toggle("active", b === btn)); panels.forEach((p) => p.classList.toggle("active", p.dataset.panel === tab) ); if (["png", "jpeg", "webp", "avif"].includes(tab)) { this.renderImage(tab); } if (tab === "mp4") this.prefetchFfmpeg(); }); }); if (this._animation) this.setupMp4Panel(); this.observeImageSize(); } observeImageSize() { this._onImageResize = this._onImageResize || (() => { const width = this.clientWidth; const pixelRatio = window.devicePixelRatio; if (this._imageWidth === width && this._imagePixelRatio === pixelRatio) return; this._imageWidth = width; this._imagePixelRatio = pixelRatio; const format = this.shadowRoot.querySelector(".tabs button.active").dataset.tab; if (["png", "jpeg", "webp", "avif"].includes(format)) { this.renderImage(format); } }); this._onImageResize(); this._imageResizeObserver = this._imageResizeObserver || new ResizeObserver(this._onImageResize); this._imageResizeObserver.observe(this); // A display's pixel ratio can change without changing the content width. window.addEventListener("resize", this._onImageResize); } disconnectedCallback() { this._imageResizeObserver?.disconnect(); window.removeEventListener("resize", this._onImageResize); } setupMp4Panel() { const shadow = this.shadowRoot; const panel = shadow.querySelector('.panel[data-panel="mp4"]'); const durationInput = panel.querySelector("input"); const generateBtn = panel.querySelector(".mp4-generate"); durationInput.value = this._animation.duration || 2; generateBtn.addEventListener("click", () => this.generateMp4()); } // Start downloading ffmpeg.wasm as soon as the MP4 tab is first opened, so // it is (mostly) ready by the time the user clicks Generate. prefetchFfmpeg() { if (this._ffmpegPrefetched) return; this._ffmpegPrefetched = true; const panel = this.shadowRoot.querySelector('.panel[data-panel="mp4"]'); const status = panel.querySelector(".image-status"); const alreadyLoading = !!ffmpegPromise; if (!this._generating) { status.hidden = false; status.classList.remove("error"); status.textContent = alreadyLoading ? "Loading ffmpeg.wasm…" : `Loading ffmpeg.wasm (~${FFMPEG_DOWNLOAD_MB} MB)…`; } getFfmpeg().then( () => { if (!this._generating) status.hidden = true; }, (err) => { // Allow Generate to retry: getFfmpeg() resets itself on failure. this._ffmpegPrefetched = false; if (!this._generating) { status.classList.add("error"); status.textContent = `Could not load ffmpeg.wasm: ${err.message}`; } } ); } // Capture one frame of the animated SVG at time t, as PNG bytes. snapshotFrame(t, width, height) { const code = this.getAttribute("data-svg") || ""; const frameSvg = snapshotSvgAtTime(code, t); const svgBlob = new Blob([frameSvg], { type: "image/svg+xml;charset=utf-8" }); const svgUrl = URL.createObjectURL(svgBlob); return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d"); ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, width, height); ctx.drawImage(img, 0, 0, width, height); URL.revokeObjectURL(svgUrl); canvas.toBlob(async (blob) => { if (!blob) return reject(new Error("Could not encode frame")); resolve(new Uint8Array(await blob.arrayBuffer())); }, "image/png"); }; img.onerror = () => { URL.revokeObjectURL(svgUrl); reject(new Error("Could not rasterize this SVG (is xmlns present?)")); }; img.src = svgUrl; }); } async generateMp4() { const shadow = this.shadowRoot; const code = this.getAttribute("data-svg") || ""; const panel = shadow.querySelector('.panel[data-panel="mp4"]'); const durationInput = panel.querySelector("input"); const generateBtn = panel.querySelector(".mp4-generate"); const status = panel.querySelector(".image-status"); const videoWrap = panel.querySelector(".video-wrap"); const video = panel.querySelector("video"); const actions = panel.querySelector(".image-actions"); const downloadBtn = actions.querySelector("button"); const duration = Math.min(30, Math.max(0.1, parseFloat(durationInput.value) || 2)); durationInput.value = duration; const fps = 30; const frameCount = Math.max(2, Math.round(duration * fps)); // Mobile-friendly output: scale to fit 720px on the longest side, with // even dimensions as required by yuv420p. const [ratioW, ratioH] = svgAspectRatioNumbers(code); const scale = 720 / Math.max(ratioW, ratioH); const width = Math.max(2, 2 * Math.round((ratioW * scale) / 2)); const height = Math.max(2, 2 * Math.round((ratioH * scale) / 2)); this._generating = true; generateBtn.disabled = true; videoWrap.hidden = true; actions.hidden = true; status.hidden = false; status.classList.remove("error"); const setStatus = (msg) => { status.textContent = msg; }; try { setStatus(ffmpegPromise ? "Loading ffmpeg.wasm…" : `Loading ffmpeg.wasm (~${FFMPEG_DOWNLOAD_MB} MB)…`); const ffmpeg = await getFfmpeg(); const frames = []; for (let i = 0; i < frameCount; i++) { setStatus(`Capturing frame ${i + 1}/${frameCount}…`); frames.push(await this.snapshotFrame((i / fps) % duration, width, height)); } // One generation at a time: the shared worker has a single filesystem. const mp4 = await (ffmpegJobQueue = ffmpegJobQueue.catch(() => {}).then(async () => { setStatus("Encoding…"); ffmpeg.handlers.progress = (progress) => { if (progress >= 0 && progress <= 1) { setStatus(`Encoding… ${Math.round(progress * 100)}%`); } }; for (let i = 0; i < frames.length; i++) { const data = frames[i]; await ffmpeg.call( "writeFile", { name: `frame${String(i).padStart(5, "0")}.png`, data }, [data.buffer] ); } const rc = await ffmpeg.call("run", { args: [ "-framerate", String(fps), "-i", "frame%05d.png", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-profile:v", "main", "-preset", "veryfast", "-crf", "26", "-movflags", "+faststart", "-y", "out.mp4" ]}); const cleanup = []; for (let i = 0; i < frames.length; i++) { cleanup.push(ffmpeg.call("deleteFile", { name: `frame${String(i).padStart(5, "0")}.png` }).catch(() => {})); } await Promise.all(cleanup); if (rc !== 0) throw new Error(`ffmpeg exited with code ${rc}`); const bytes = await ffmpeg.call("readFile", { name: "out.mp4" }); await ffmpeg.call("deleteFile", { name: "out.mp4" }).catch(() => {}); return bytes; })); const blob = new Blob([mp4.buffer], { type: "video/mp4" }); const url = URL.createObjectURL(blob); if (this._mp4Url) URL.revokeObjectURL(this._mp4Url); this._mp4Url = url; status.hidden = true; video.onerror = () => { status.hidden = false; status.classList.remove("error"); setStatus("This browser cannot preview H.264 video — the download should still play elsewhere."); }; video.src = url; videoWrap.hidden = false; actions.hidden = false; const sizeKB = (mp4.byteLength / 1024).toFixed(1); downloadBtn.textContent = `Download MP4 (${sizeKB} KB)`; downloadBtn.onclick = () => { const link = document.createElement("a"); link.href = url; link.download = "animation.mp4"; link.click(); }; } catch (err) { status.hidden = false; status.classList.add("error"); setStatus(`Could not generate MP4: ${err.message}`); } finally { this._generating = false; generateBtn.disabled = false; } } // Rasterize at the displayed size in device pixels, keeping the CSS preview // size independent of the canvas resolution. Cache each format at that size; // refresh the active tab on resize and other formats when next opened. // canvasToBlob loads a wasm encoder for WebP or AVIF if needed. renderImage(format) { const code = this.getAttribute("data-svg") || ""; const [ratioW, ratioH] = svgAspectRatioNumbers(code); const pixelRatio = Math.max(1, window.devicePixelRatio || 1); const width = Math.max(1, Math.round(this.clientWidth * pixelRatio)); const height = Math.max(1, Math.round((width * ratioH) / ratioW)); this._rendered = this._rendered || {}; const previous = this._rendered[format]; if (previous && previous.width === width && previous.height === height) return; const rendering = { width, height }; this._rendered[format] = rendering; const isCurrent = () => this._rendered[format] === rendering; const shadow = this.shadowRoot; const panel = shadow.querySelector(`.panel[data-panel="${format}"]`); const status = panel.querySelector(".image-status"); const imageWrap = panel.querySelector(".image-wrap"); const imageEl = panel.querySelector("img"); const actions = panel.querySelector(".image-actions"); const downloadBtn = actions.querySelector("button"); status.hidden = false; status.textContent = `Rendering ${format.toUpperCase()}…`; status.classList.remove("error"); const fail = (message) => { if (!isCurrent()) return; delete this._rendered[format]; status.hidden = false; status.textContent = message; status.classList.add("error"); }; const RASTER_FORMATS = { png: { mimeType: "image/png", extension: "png" }, jpeg: { mimeType: "image/jpeg", extension: "jpg" }, webp: { mimeType: "image/webp", extension: "webp" }, avif: { mimeType: "image/avif", extension: "avif" }, }; const { mimeType, extension } = RASTER_FORMATS[format]; const svgBlob = new Blob([stripSvgComments(code)], { type: "image/svg+xml;charset=utf-8" }); const svgUrl = URL.createObjectURL(svgBlob); const img = new Image(); img.onload = () => { URL.revokeObjectURL(svgUrl); if (!isCurrent()) return; const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d"); ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, width, height); ctx.drawImage(img, 0, 0, width, height); canvasToBlob(canvas, mimeType, 0.9).then( (blob) => { if (!isCurrent()) return; if (this._imageUrls && this._imageUrls[format]) { URL.revokeObjectURL(this._imageUrls[format]); } const url = URL.createObjectURL(blob); this._imageUrls = this._imageUrls || {}; this._imageUrls[format] = url; status.hidden = true; imageEl.src = url; imageWrap.hidden = false; actions.hidden = false; const sizeKB = (blob.size / 1024).toFixed(2); downloadBtn.textContent = `Download ${format.toUpperCase()} (${sizeKB} KB)`; downloadBtn.onclick = () => { const link = document.createElement("a"); link.href = url; link.download = `image.${extension}`; link.click(); }; }, (err) => { fail(`Could not render ${format.toUpperCase()}: ${err.message}`); } ); }; img.onerror = () => { URL.revokeObjectURL(svgUrl); fail(`Could not render this SVG as ${format.toUpperCase()}.`); }; img.src = svgUrl; } } customElements.define("svg-block", SvgBlock); class HtmlBlock extends HTMLElement { connectedCallback() { if (this.shadowRoot) return; const code = this.getAttribute("data-html") || ""; const shadow = this.attachShadow({ mode: "open" }); shadow.innerHTML = `
Rendered
Code
${COPY_ICON_SVG}
Copy
`; shadow.querySelector("iframe").srcdoc = htmlPreviewDocument(code); shadow.querySelector("pre").textContent = code; setupCopyButton(shadow, code); const buttons = shadow.querySelectorAll(".tabs button"); const panels = shadow.querySelectorAll(".panel"); buttons.forEach((btn) => { btn.addEventListener("click", () => { const tab = btn.dataset.tab; buttons.forEach((b) => b.classList.toggle("active", b === btn)); panels.forEach((p) => p.classList.toggle("active", p.dataset.panel === tab) ); }); }); } } customElements.define("html-block", HtmlBlock); // ---- Markdown rendering ---- const markdownSanitizeConfig = { USE_PROFILES: { html: true }, ADD_ATTR: ["data-svg-id", "data-html-id"], FORBID_ATTR: ["style"], FORBID_TAGS: ["style"] }; const md = window.markdownit({ // Preserve disclosure elements such as details/summary. renderMarkdown() // sanitizes the resulting HTML before it is inserted into the preview. html: true, linkify: false, typographer: false }); // Raw SVG and HTML sources are stashed here and represented in the sanitized Markdown // by numeric ids. hydrateSvgBlocks() and hydrateHtmlBlocks() pass them to the sandboxed // iframes using DOM APIs, so raw markup is never parsed in the parent document. const svgStore = new Map(); const htmlStore = new Map(); const defaultInlineCodeRenderer = md.renderer.rules.code_inline; md.renderer.rules.code_inline = (tokens, idx, options, env, self) => { const token = tokens[idx]; const imageType = /^data:image\/(png|jpeg|gif|webp);base64,/i.exec(token.content); if (token.markup === "`" && imageType) { return `
`; } return defaultInlineCodeRenderer(tokens, idx, options, env, self); }; const defaultFenceRenderer = md.renderer.rules.fence; md.renderer.rules.fence = (tokens, idx, options, env, self) => { const token = tokens[idx]; const lang = (token.info || "").trim().split(/\s+/)[0].toLowerCase(); const trimmed = token.content.trim(); const isSvg = lang === "svg" || (lang === "xml" && trimmed.startsWith("
")); const isHtml = lang === "html"; if (isSvg) { if (trimmed) { const id = String(svgStore.size); svgStore.set(id, token.content); return `
\n`; } } if (isHtml) { if (trimmed) { const id = String(htmlStore.size); htmlStore.set(id, token.content); return `
\n`; } } return defaultFenceRenderer(tokens, idx, options, env, self); }; function hydrateSvgBlocks(root) { root.querySelectorAll(".svg-block-placeholder[data-svg-id]").forEach((placeholder) => { const svg = svgStore.get(placeholder.getAttribute("data-svg-id")); if (!svg) { placeholder.remove(); return; } const block = document.createElement("svg-block"); block.setAttribute("data-svg", svg); placeholder.replaceWith(block); }); } function hydrateHtmlBlocks(root) { root.querySelectorAll(".html-block-placeholder[data-html-id]").forEach((placeholder) => { const html = htmlStore.get(placeholder.getAttribute("data-html-id")); if (!html) { placeholder.remove(); return; } const block = document.createElement("html-block"); block.setAttribute("data-html", html); placeholder.replaceWith(block); }); } function renderMarkdown(src) { svgStore.clear(); htmlStore.clear(); const html = md.render(src); return window.DOMPurify.sanitize(html, markdownSanitizeConfig); } const app = document.querySelector(".app"); const input = document.getElementById("input"); const output = document.getElementById("output"); const tabPaste = document.getElementById("tab-paste"); const tabUrl = document.getElementById("tab-url"); const urlBar = document.getElementById("url-bar"); const urlInput = document.getElementById("url-input"); const urlLoad = document.getElementById("url-load"); const urlStatus = document.getElementById("url-status"); const previewStatus = document.getElementById("preview-status"); const previewStatusMessage = document.getElementById("preview-status-message"); const previewStatusActions = document.getElementById("preview-status-actions"); const sourceToggle = document.getElementById("source-toggle"); // Give every heading a GitHub-style id (derived from its text) plus a small // "#" link so individual sections can be linked to. This runs on the sanitized // DOM rather than in the markdown-it renderer so DOMPurify cannot strip the ids. function slugify(text) { return text .trim() .toLowerCase() .replace(/[^\p{L}\p{N}\s_-]+/gu, "") .replace(/\s+/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, ""); } function addHeadingAnchors(root) { const seen = new Map(); root.querySelectorAll("h1, h2, h3, h4, h5, h6").forEach((heading) => { let slug = slugify(heading.textContent) || "section"; const count = seen.get(slug) || 0; seen.set(slug, count + 1); if (count) slug = `${slug}-${count}`; heading.id = slug; const anchor = document.createElement("a"); anchor.className = "header-anchor"; anchor.href = "#" + encodeURIComponent(slug); anchor.setAttribute("aria-label", "Link to this section"); anchor.textContent = "#"; heading.appendChild(anchor); }); } function scrollToHash() { const hash = location.hash.slice(1); if (!hash) return; let id; try { id = decodeURIComponent(hash); } catch { id = hash; } const if (!target) return; // Linking to a heading such as "Reasoning" expands every details element // in its section (up to the next heading), so the linked content is visible. const isHeading = (el) => /^H[1-6]$/.test(el.tagName); if (isHeading(target)) { for (let el = target.nextElementSibling; el && !isHeading(el); el = el.nextElementSibling) { if (el.tagName === "DETAILS") el.open = true; } } target.scrollIntoView(); } window.addEventListener("hashchange", scrollToHash); function update() { output.innerHTML = renderMarkdown(input.value); addHeadingAnchors(output); hydrateSvgBlocks(output); hydrateHtmlBlocks(output); } input.addEventListener("input", update); function setViewerMode(enabled) { app.classList.toggle("viewer-mode", enabled); document.documentElement.classList.remove("url-startup"); sourceToggle.setAttribute("aria-pressed", enabled ? "true" : "false"); sourceToggle.setAttribute("aria-label", enabled ? "Show source" : "Hide source"); sourceToggle.title = enabled ? "Show source" : "Hide source"; } function setUrlContentLoaded(enabled) { app.classList.toggle("has-url-content", enabled); if (!enabled) setViewerMode(false); } sourceToggle.addEventListener("click", () => { setViewerMode(!app.classList.contains("viewer-mode")); }); // ---- Source mode switching ---- function setMode(mode) { const isUrl = mode === "url"; tabUrl.classList.toggle("active", isUrl); tabPaste.classList.toggle("active", !isUrl); urlBar.classList.toggle("active", isUrl); input.readOnly = isUrl; input.style.background = isUrl ? "#f9f9f9" : "#fff"; } tabPaste.addEventListener("click", () => { setUrlContentLoaded(false); setMode("paste"); input.readOnly = false; input.style.background = "#fff"; if (location.search || location.hash) history.pushState("", document.title, location.pathname); }); tabUrl.addEventListener("click", () => setMode("url")); function setStatus(msg, isError) { previewStatus.classList.toggle("active", !!msg); previewStatus.classList.toggle("error", !!isError); previewStatusMessage.textContent = msg; previewStatusActions.hidden = !isError; if (!msg) { urlStatus.classList.remove("active", "error"); urlStatus.textContent = ""; return; } urlStatus.textContent = msg; urlStatus.classList.add("active"); urlStatus.classList.toggle("error", !!isError); } // Capture the owner, gist id, and optional revision from a gist page URL. const gistRe = /^https?:\/\/gist\.github\.com\/(?:([^/?#]+)\/)?([0-9a-f]+)(?:\/([0-9a-f]+))?(?=[/?#]|$)/i; function normalizeSourceUrl(rawUrl) { let parsed; try { parsed = new URL(rawUrl.trim()); } catch { throw new Error("Enter a valid URL"); } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { throw new Error("Only http and https URLs are supported"); } return parsed.href; } async function fetchSource(url) { const sourceUrl = normalizeSourceUrl(url); const gistMatch = gistRe.exec(sourceUrl); if (gistMatch) { const [, owner, id, revision] = gistMatch; // Raw gist hosting supports CORS and avoids the shared-IP API rate limit. // Without a filename it returns the first file, just like our API loader. if (owner) { const rawUrl = `https://gist.githubusercontent.com/${owner}/${id}/raw/${revision || ""}`; const raw = await fetch(rawUrl); if (!raw.ok) throw new Error(`Raw gist fetch returned ${raw.status}`); return await raw.text(); } // Ownerless gist links still need the API to discover the raw URL. const res = await fetch(`https://api.github.com/gists/${id}`); if (!res.ok) throw new Error(`Gist API returned ${res.status}`); const data = await res.json(); const files = data.files || {}; const names = Object.keys(files); if (!names.length) throw new Error("Gist contains no files"); const first = files[names[0]]; // The API truncates large files; fall back to raw_url if so. if (first.truncated && first.raw_url) { const raw = await fetch(first.raw_url); if (!raw.ok) throw new Error(`Raw fetch returned ${raw.status}`); return await raw.text(); } return first.content; } // Plain raw markdown URL. const res = await fetch(sourceUrl); if (!res.ok) throw new Error(`Fetch returned ${res.status}`); return await res.text(); } async function loadFromUrl(url, recordUrl) { setMode("url"); urlInput.value = url; setStatus(gistRe.test(url) ? "Loading Gist…" : "Loading document…"); setViewerMode(true); try { const text = await fetchSource(url); input.value = text; update(); setStatus(""); setUrlContentLoaded(true); setViewerMode(true); if (recordUrl) { const newSearch = "?url=" + encodeURIComponent(url); if (location.search !== newSearch) { history.pushState({ url }, "", location.pathname + newSearch); } } scrollToHash(); } catch (err) { setStatus("Error: " + err.message, true); } } urlLoad.addEventListener("click", () => { const url = urlInput.value.trim(); if (url) loadFromUrl(url, true); }); document.getElementById("url-retry").addEventListener("click", () => urlLoad.click()); document.getElementById("url-edit").addEventListener("click", () => { setViewerMode(false); urlInput.focus(); urlInput.select(); }); urlInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); urlLoad.click(); } }); // ---- Query-string based load on startup / navigation ---- function urlFromQuery() { const url = new URLSearchParams(location.search).get("url"); return url ? url.trim() : null; } // Older links used #url=... in the fragment; redirect those to ?url=... so the // fragment is free for section anchors. function legacyUrlFromHash() { const m = /^#url=(.+)$/.exec(location.hash); if (!m) return null; try { return decodeURIComponent(m[1]); } catch { return m[1]; } } window.addEventListener("popstate", () => { const url = urlFromQuery(); if (url) { loadFromUrl(url, false); } }); const sample = `# Markdown renderer Paste **markdown** here and watch it render on the right. ## Features - Headings, *emphasis*, and \`inline code\` - Lists, tables, blockquotes - Standard \`\`\`code\`\`\` blocks - Special SVG blocks with tabs - Load from a raw markdown URL or a Gist - Collapsible \`
\` sections that [open when linked to](#reasoning) > SVG fenced blocks render as a tabbed component. \`\`\`svg
SVG
\`\`\` Animated SVGs get an extra **MP4** tab that renders a looping video in your browser using ffmpeg.wasm: \`\`\`svg
\`\`\` HTML blocks render in a sandboxed iframe with scripts allowed, restricted by a CSP allowing resources only from cdnjs, jsdelivr, unpkg, and esm.sh: \`\`\`html
Interactive HTML Preview
Clicks: 0