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

  1. User selects a frame containing child elements
  2. The plugin clones the DOM element offscreen
  3. All images are converted to inline data URLs (handles cross-origin)
  4. The clone is rasterized to PNG using html-to-image
  5. The PNG is uploaded to your project's storage
  6. 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:

  1. 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.
  2. Server proxy — For cross-origin images, fetch through /api/proxy-image which 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.