Example: Flatten to Image
This plugin takes a frame and all its children, rasterizes them into a single PNG image, uploads it, and replaces the frame's contents with that image as a background. Useful for performance optimization or exporting complex layouts as static images.
What it does
- User selects a frame containing child elements
- The plugin clones the DOM element offscreen
- All images are converted to inline data URLs (handles cross-origin)
- The clone is rasterized to PNG using
html-to-image - The PNG is uploaded to your project's storage
- All children are deleted and the frame gets the image as a background
Full source
import { definePlugin } from '@revyme/plugin-sdk';
import { toPng } from 'html-to-image';
export default definePlugin({
name: 'Flatten to Image',
icon: 'image',
description: 'Rasterize a frame and its children into a single background image',
submitText: 'Flatten',
controls: {
quality: {
type: 'select',
label: 'Quality',
default: '2',
options: [
{ label: '1x', value: '1' },
{ label: '2x (Recommended)', value: '2' },
{ label: '3x (High-res)', value: '3' },
],
},
},
async run(values, sdk) {
const selected = sdk.nodes.getSelected();
const frame = selected.find((n) => n.type === 'frame');
if (!frame) return;
const children = sdk.nodes.getChildren(frame.id);
if (children.length === 0) return;
// Find the actual DOM element
const domEl = document.querySelector(
`[data-node-id="${frame.id}"]`
) as HTMLElement | null;
if (!domEl) return;
const pixelRatio = parseInt(values.quality) || 2;
// Clone element offscreen to avoid layout shifts
const clone = domEl.cloneNode(true) as HTMLElement;
const tempContainer = document.createElement('div');
tempContainer.style.cssText =
'position:fixed;top:-9999px;left:-9999px;z-index:-1;pointer-events:none';
const computedStyle = window.getComputedStyle(domEl);
clone.style.width = computedStyle.width;
clone.style.height = computedStyle.height;
clone.style.transform = 'none';
clone.style.position = 'relative';
clone.style.top = '0';
clone.style.left = '0';
clone.style.margin = '0';
// Remove elements that cause cross-origin issues
clone.querySelectorAll('iframe, video, audio').forEach((el) => el.remove());
// Convert images to inline data URLs
const cloneImgs = clone.querySelectorAll('img');
const origImgs = domEl.querySelectorAll('img');
for (let i = 0; i < cloneImgs.length; i++) {
const cloneImg = cloneImgs[i];
const origImg = origImgs[i] as HTMLImageElement | undefined;
const src = cloneImg.getAttribute('src') || '';
// Try canvas approach first (fast, works for same-origin)
if (origImg && origImg.complete && origImg.naturalWidth > 0) {
try {
const c = document.createElement('canvas');
c.width = origImg.naturalWidth;
c.height = origImg.naturalHeight;
c.getContext('2d')!.drawImage(origImg, 0, 0);
cloneImg.src = c.toDataURL('image/png');
continue;
} catch {
// Tainted canvas — fall through to proxy
}
}
// Proxy approach for cross-origin images
if (src.startsWith('http')) {
try {
const resp = await fetch(
`/api/proxy-image?url=${encodeURIComponent(src)}`
);
if (resp.ok) {
const blob = await resp.blob();
const dataUrl: string = await new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
cloneImg.src = dataUrl;
continue;
}
} catch {
// Failed — remove to prevent crash
}
}
cloneImg.remove();
}
tempContainer.appendChild(clone);
document.body.appendChild(tempContainer);
let dataUrl: string;
try {
dataUrl = await toPng(clone, {
quality: 0.95,
pixelRatio,
skipFonts: true,
cacheBust: true,
});
} finally {
document.body.removeChild(tempContainer);
}
// Convert data URL to blob for upload
const [header, base64] = dataUrl.split(',');
const mime = header.match(/:(.*?);/)?.[1] || 'image/png';
const bytes = atob(base64);
const arr = new Uint8Array(bytes.length);
for (let i = 0; i < bytes.length; i++) {
arr[i] = bytes.charCodeAt(i);
}
const blob = new Blob([arr], { type: mime });
// Get website ID from URL
const pathParts = window.location.pathname.split('/');
const builderIndex = pathParts.indexOf('builder');
const websiteId = pathParts[builderIndex + 1];
if (!websiteId) return;
// Upload to storage
const formData = new FormData();
formData.append('file', blob, `flatten-${frame.id}-${Date.now()}.webp`);
formData.append('type', 'image');
formData.append('source', 'uploaded');
formData.append('websiteId', websiteId);
const response = await fetch('/api/upload', { method: 'POST', body: formData });
const data = await response.json();
if (!data.success || !data.url) return;
// Delete all children and set background image
for (const child of [...children]) {
sdk.nodes.delete(child.id);
}
sdk.nodes.update(frame.id, {
styles: {
...frame.styles,
backgroundImage: `url("${data.url}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
},
});
},
});How it works
DOM cloning
The plugin clones the frame's DOM element into a hidden container. This avoids visual glitches during the rasterization process. The clone gets its dimensions from getComputedStyle and has transforms stripped so it renders flat.
Image handling
Cross-origin images (from CDNs, external URLs) can't be drawn to canvas directly. The plugin uses a two-step approach:
- Canvas method — For already-loaded same-origin images, draw to canvas and extract as data URL. This is fast and doesn't need network requests.
- Server proxy — For cross-origin images, fetch through
/api/proxy-imagewhich downloads the image server-side and returns it without CORS restrictions.
Rasterization
The html-to-image library (toPng) converts the cloned DOM into a PNG data URL. The pixelRatio setting controls resolution — 2x is recommended for crisp results on retina displays.
Cleanup
After upload, the plugin deletes all children from the frame and sets the uploaded image as a CSS background. The frame keeps its dimensions and position, but now renders as a single image instead of a complex DOM tree.
Dependencies
This plugin uses html-to-image for DOM rasterization:
{
"dependencies": {
"@revyme/plugin-sdk": "^0.3.0",
"html-to-image": "^1.11.0"
}
}External dependencies get bundled into the output by tsup, so the final bundle is self-contained.