SnapDOMGitHub★ 8K
Labs Experiment

The PNG is the canvas

The drawing surface is an <img> backed by an SVG stroke layer. A SnapDOM plugin saves the clone from the last capture and adds each stroke to it, then exports the next PNG. SnapDOM uses Canvas internally for that raster export.

TL;DR

The plugin's afterRender hook saves the clone; afterClone swaps it back in on the next call and appends the newest stroke. Successive PNG exports preserve the accumulated drawing.

What you're looking at

Pick a color and brush size, then drag across the board. What looks like a normal <canvas> painting widget is actually a single hidden <div> holding an empty SVG layer, plus an <img> that gets replaced every time SnapDOM re-renders it. There is no persistent bitmap buffer anywhere in the page's own code — the persistence lives entirely inside the plugin.

Try it

Draw on the board below. The line under your cursor is a local SVG path with zero SnapDOM involvement — it never waits on a capture. Underneath, roughly every 120ms, that same stroke gets folded into the plugin's saved clone and re-exported as a real PNG.

How it works

  1. A hidden source element, never the visible board

    A 420×420 <div> off-screen holds one empty <svg class="strokes">. This — not the board you see — is what SnapDOM actually captures.

  2. The visible line is free

    While you drag, a local SVG <path> on the visible board follows the pointer using getCoalescedEvents() for smooth sampling. This never touches SnapDOM, so the line itself has zero capture latency.

  3. The stroke is handed to the plugin periodically

    Roughly every 120ms, the in-progress stroke's path data is passed to plugin.recordStroke() and snapdom.toPng() runs again on the hidden source.

  4. afterClone restores state, then applies the stroke

    Instead of cloning the pristine (empty) source, the plugin swaps in the clone it saved from the previous render. It then finds the in-progress stroke's <path> by a data-stroke-id and updates it — or appends a new one if this is a new stroke. One continuous drag stays one <path> element, never hundreds.

  5. afterRender checkpoints the result

    After SnapDOM finishes rendering, the plugin clones what it just produced and holds onto it as the new baseline for the next capture.

  6. The output <img> replaces the board's picture

    The resulting PNG is inserted directly into the board. The download button just points at that same <img>.src — no second capture needed.

Why this needed a plugin

SnapDOM v3.x.x can reuse eligible captures, but the accumulated drawing here belongs to the plugin. On each stroke, the plugin replaces the new clone with its saved clone and adds the latest mark. Its stateful hooks run on fresh captures.

The afterClone and afterRender hooks share the plugin closure. afterClone adds the new mark to savedClone; afterRender saves the resulting tree for the next call. The plugin owns this drawing state independently of capture reuse.

Copy-paste example

The plugin factory — swap in your own persistence logic and this pattern works for any "keep building on the last capture" use case, not just painting:

function createPaintPlugin (sourceEl) {
  let savedClone = null
  let skipNext = false
  let pendingStroke = null

  function makePath (stroke) {
    const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
    path.setAttribute('data-stroke-id', stroke.id)
    path.setAttribute('d', stroke.d)
    path.setAttribute('fill', 'none')
    path.setAttribute('stroke', stroke.color)
    path.setAttribute('stroke-width', stroke.size)
    path.setAttribute('stroke-linecap', 'round')
    path.setAttribute('stroke-linejoin', 'round')
    return path
  }

  return {
    name: 'paint',
    recordStroke (stroke) { pendingStroke = stroke },
    afterClone (ctx) {
      if (skipNext) { skipNext = false; pendingStroke = null; return }
      if (savedClone) ctx.clone = savedClone.cloneNode(true)
      if (!pendingStroke) return
      const layer = ctx.clone.querySelector('.strokes')
      const existing = layer.querySelector(`[data-stroke-id="${pendingStroke.id}"]`)
      if (existing) existing.setAttribute('d', pendingStroke.d)
      else layer.append(makePath(pendingStroke))
    },
    afterRender (ctx) {
      savedClone = ctx.clone.cloneNode(true)
      pendingStroke = null
    },
    clear () { skipNext = true }
  }
}

And the usage — capture again on every stroke update, plugin included:

const plugin = createPaintPlugin(canvasSource)

async function render (stroke) {
  if (stroke) plugin.recordStroke(stroke)
  const img = await snapdom.toPng(canvasSource, { plugins: [plugin] })
  board.replaceChildren(img)
}

// on clear: plugin.clear() then render() again — skipNext lets SnapDOM
// clone the pristine live element once, instead of hand-building an
// empty replacement clone that might miss pipeline setup.
stroke recordStroke() snapdom.toPng() afterClone (restore + append) afterRender (checkpoint) <img>

Limits and things to watch

Every stroke update is a full SnapDOM capture, so the render loop is throttled (120ms here) rather than firing on every pointermove — captures aren't free, and a paint app that tries to snapshot on every mouse event will fall behind. The visible "live" path is what keeps the drawing feeling instant while the real PNG catches up underneath. The skipNext flag matters more than it looks: without it, clear() would need to hand-build an empty clone instead of letting SnapDOM's own cloning pipeline produce one, and any pipeline behavior added later (pseudo-elements, SVG defs inlining, etc.) would silently stop applying to the very first render after a clear.

Build your own plugin

Every hook this demo uses — afterClone, afterRender, plus defineExports for adding new export formats — is documented in the plugin spec.

Explore pluginsRead PLUGIN_SPEC.md