Flows · 2 of 3

Import saved page — drag-drop .json → builder.load() round-trip

The symmetric pair to W6.C.15 export. Save the canvas as a `.json` blob via `getData()`, drag it back onto the canvas to re-hydrate via `builder.load()`. No JSZip dependency — pure native FileReader + JSON.parse + the canonical 4-arg `load()` signature.

Walkthrough

  1. Save the canvas as a `.json` blob — `getData()` is the round-trip source of truth

    builder.getData() returns the canonical JSON tree (the same shape builder.load() reads). Write it to a Blob, create an object URL, click an off-screen <a download> to drop it in the buyer's Downloads folder. No backend round-trip; no provider lock-in; the file lives on the buyer's disk.

    Why .json and not .bundle.zip? Two reasons. (1) Re-hydration only NEEDS the JSON — the rendered HTML + theme assets that .bundle.zip ships are output, not input. The engine reads JSON via load() and rebuilds the canvas from there. (2) Reading .zip client-side requires JSZip (or alternative) — adds 30+ KB to the buyer's page weight for a feature most production buyers solve server-side anyway. Save+import as .json stays drop-in dependency-free.

    Pair this with the C.15 export pipeline: ship the rendered HTML + assets as .zip (production output), ship the JSON as .json (editable source). The JSON is your "save" format; the ZIP is your "publish" format. Both come from the same getData()+ getHtml() read pair.

    JavaScript
    // Save the canvas as a portable .json file. No backend, no JSZip.
    document.getElementById('c16SaveBtn').addEventListener('click', () => {
      const data = builder.getData();              // canonical JSON tree
      const text = JSON.stringify(data, null, 2);  // pretty-print for diffability
      const blob = new Blob([text], { type: 'application/json;charset=utf-8' });
      const url  = URL.createObjectURL(blob);
    
      const a   = document.createElement('a');
      a.href     = url;
      a.download = `page-${Date.now()}.json`;     // suggest a unique filename
      document.body.appendChild(a);
      a.click();
      a.remove();
      setTimeout(() => URL.revokeObjectURL(url), 0);
    });
  2. Wire the dropzone — `FileReader` → `JSON.parse` → `builder.load(...)`

    The dropzone is the whole demo wrapper. dragenter + dragover set dropEffect = 'copy' + flip a CSS class so the buyer sees a clear "release here" affordance; dragleave + drop tear down. The drop handler reads the file via FileReader.readAsText, parses JSON, and feeds it into builder.load(parsed, templates, configData, mediaUrl). The 4 args after the first MUST be the same themeTemplates / themeConfigData / themeMediaUrl the editor was constructed against — the JSON file ONLY contains the page tree, not the theme bundle. Mixing themes mid-load is a separate feature (theme picker — see builder.php + demo/_partials/builder/theme-picker.php).

    Two anchor points in the same dropzone: a hidden <input type="file"> as a click-to-open keyboard fallback (drag-drop is mouse-only — anyone navigating with a keyboard / screen reader needs the input). Both code paths funnel into the same handleFile(file) helper so the buyer maintains ONE round-trip implementation.

    Robust error handling: invalid JSON shows a "Couldn't parse JSON" status. Files that don't look like a builder page (no blocks array, missing theme) show "Not a BuilderJS page". The engine's load() is forgiving but if the JSON is shaped wrong the canvas comes out blank — pre-validate so you can tell the buyer WHY before the canvas blanks.

    JavaScript
    const dropZone = document.getElementById('c16DropZone');
    const fileInput = document.getElementById('c16FileInput');
    const statusPill = document.getElementById('c16StatusPill');
    
    function setStatus(text, tone) {
      statusPill.textContent = text;
      statusPill.dataset.tone = tone || '';
    }
    
    function handleFile(file) {
      if (!file || !file.name.endsWith('.json')) {
        setStatus('Drop a .json file', 'error');
        return;
      }
      const reader = new FileReader();
      reader.onload = () => {
        let parsed;
        try { parsed = JSON.parse(reader.result); }
        catch (e) { setStatus('Could not parse JSON: ' + e.message, 'error'); return; }
    
        // Light validation — fail loud BEFORE load() blanks the canvas.
        if (!parsed || !Array.isArray(parsed.blocks)) {
          setStatus('Not a BuilderJS page (missing blocks[])', 'error');
          return;
        }
    
        setStatus('Importing…', 'busy');
        builder.load(parsed, themeTemplates, themeConfigData, mediaUrl, () => {
          const sizeKb = (file.size / 1024).toFixed(1);
          setStatus(`Imported ${file.name} (${sizeKb} KB)`, 'success');
        });
      };
      reader.onerror = () => setStatus('Could not read file', 'error');
      reader.readAsText(file);
    }
    
    // Drag-drop path — mouse users.
    dropZone.addEventListener('dragover', (e) => {
      e.preventDefault();
      e.dataTransfer.dropEffect = 'copy';
      dropZone.classList.add('is-drop-active');
    });
    dropZone.addEventListener('dragleave', () => dropZone.classList.remove('is-drop-active'));
    dropZone.addEventListener('drop', (e) => {
      e.preventDefault();
      dropZone.classList.remove('is-drop-active');
      if (e.dataTransfer.files.length > 0) handleFile(e.dataTransfer.files[0]);
    });
    
    // File-input path — keyboard / screen-reader users.
    fileInput.addEventListener('change', (e) => {
      if (e.target.files.length > 0) handleFile(e.target.files[0]);
    });
  3. Round-trip end to end — Save, refresh, drop the .json back, watch the canvas re-hydrate

    The full buyer journey: edit page → click Save → reload page (or open in a different tab) → drag-drop the saved .json back onto the wrapper → canvas re-hydrates byte-identically. The trip is symmetric because getData() and load() are sibling pure functions: load(getData()) is a no-op identity for any valid page tree (Builder.js Lesson 5 — verified by 60+ engine specs in e2e/test-data-blob-mass.spec.js).

    Production hardening — what to add when this leaves localhost:

    • File-size cap. A 10MB JSON parses fine but blocks the main thread for ~50ms. Set a cap (1MB is generous) + show a friendly error above it. Catches accidental "I dropped the wrong 1GB log file" mistakes early.
    • Theme compatibility check. The JSON carries a theme + theme_dir field; if it doesn't match the editor's current theme, the canvas may render with mismatched templates. Either (a) reject with "Saved with theme X, editor is theme Y" or (b) auto-switch the theme picker to match the import.
    • Confirm-overwrite gate. If the canvas has unsaved changes, the import wipes them. Wire a history.canUndo() check + a confirm dialog ("Importing replaces the current canvas. Discard changes?") so dropped files don't silently destroy work.
    • Multi-page imports. If your app handles multi-page projects, dropped files might be a single page or a multi-page bundle. Sniff the JSON shape (single object vs array) + branch accordingly. The C.15 bundle pattern is one extension surface; another is a .bjs-project.json wrapper carrying multiple page trees.

    For server-side import (drop the file, POST it to a backend, return the parsed JSON): keep the dropzone but route through fetch('/backend/import.php', { method: 'POST', body: file }) + parse the response. Useful when the buyer's app needs to record the import (audit trail, multi-tenant scoping, format migration). Same dropzone wiring; the handleFile helper just routes through fetch instead of FileReader.

    JavaScript
    // Production hardening — file-size cap + theme check + unsaved-work gate.
    const MAX_IMPORT_BYTES = 1 * 1024 * 1024;  // 1 MB
    
    function handleFileHardened(file) {
      if (file.size > MAX_IMPORT_BYTES) {
        setStatus('File too large (' + (file.size / 1024).toFixed(0) + ' KB > 1024 KB)', 'error');
        return;
      }
      if (builder.history && builder.history.canUndo() && !confirm('Importing replaces the current canvas. Discard changes?')) {
        setStatus('Import cancelled', '');
        return;
      }
    
      const reader = new FileReader();
      reader.onload = () => {
        let parsed;
        try { parsed = JSON.parse(reader.result); }
        catch (e) { setStatus('Could not parse JSON', 'error'); return; }
    
        if (parsed.theme && parsed.theme !== currentThemeDir) {
          setStatus(`Theme mismatch — editor=${currentThemeDir} file=${parsed.theme}`, 'error');
          return;
        }
    
        setStatus('Importing…', 'busy');
        builder.load(parsed, themeTemplates, themeConfigData, mediaUrl, () => {
          setStatus(`Imported ${file.name}`, 'success');
        });
      };
      reader.readAsText(file);
    }
    
    // Server-side import variant — same dropzone, fetch in place of FileReader.
    async function handleFileServer(file) {
      setStatus('Uploading…', 'busy');
      const body = new FormData();
      body.append('file', file);
      const resp = await fetch('/backend/import.php', { method: 'POST', body });
      if (!resp.ok) { setStatus('Server rejected import', 'error'); return; }
      const parsed = await resp.json();
      builder.load(parsed, themeTemplates, themeConfigData, mediaUrl, () => {
        setStatus(`Imported ${file.name}`, 'success');
      });
    }

Live demo

demo-mini-builder--rich-3col-header
Save / Import — round-trip via .jsonDrag a .json onto the canvas

The whole snippet

Click Copy on any file to grab it byte-identical, or Expand to read inline (capped at ~520 px so the page stays scannable). Multi-file snippets surface a side-by-side grid — copy only the files you need.

<?php
/**
 * snippet.php — drag-drop a saved page `.json` onto the canvas wrapper
 * → re-hydrate via `builder.load()`. Pairs with the C.15 export
 * (saves the same JSON shape).
 *
 * Drop alongside `dist/` + `themes/default/`, run a PHP server. Click
 * Save .json to download the current canvas; reload, drag the file
 * back to re-hydrate. Pure native FileReader + JSON.parse — no JSZip
 * dependency.
 *
 * IDs `c16SaveBtn` / `c16DropZone` / `c16FileInput` / `c16StatusPill`
 * match the live demo on /examples/flows/2-import-bundle/ AND Steps
 * 1-3 of the walkthrough (BUILDER.md RULE H, D13.B.parity).
 */
declare(strict_types=1);

require_once __DIR__ . '/../../../backend/_lib/ThemeRegistry.php';

$bundle = (new \DemoBuilder\ThemeRegistry(__DIR__ . '/../../../themes'))
    ->resolveBundle('default', 'master/sample/email/Minimal');
?>
<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Import saved page — drag-drop .json</title>
    <link rel="stylesheet" href="/dist/builder.css">
    <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=block">
    <style>
        body { margin: 0; font-family: system-ui, sans-serif; display: grid; grid-template-rows: 56px 1fr; height: 100vh; }
        .my-header  { display: flex; align-items: center; gap: 8px; padding: 0 16px; background: #f8f9fa; border-bottom: 1px solid #e5e7eb; }
        .my-header__title { font-weight: 600; font-size: 14px; margin-right: auto; }
        .my-btn { padding: 6px 12px; background: #fff; border: 1px solid #d1d5db; border-radius: 6px; cursor: pointer; font: inherit; font-size: 12px; }
        .my-btn:hover { background: #f3f4f6; }
        .my-drop-label { padding: 6px 12px; border: 1px dashed #d1d5db; border-radius: 6px; cursor: pointer; font-size: 12px; color: #6b7280; }
        .my-drop-label:hover { border-color: #4338ca; color: #4338ca; background: #eef2ff; }
        #c16FileInput { position: absolute; left: -9999px; }

        #c16StatusPill { font-size: 11px; padding: 4px 10px; border-radius: 999px; background: #f4f4f5; color: #71717a; font-weight: 600; max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
        #c16StatusPill[data-tone="busy"]    { background: #e0e7ff; color: #4338ca; }
        #c16StatusPill[data-tone="success"] { background: #dcfce7; color: #15803d; }
        #c16StatusPill[data-tone="error"]   { background: #fee2e2; color: #b91c1c; }

        .my-builder-host { display: grid; grid-template-columns: 220px 1fr 280px; min-height: 0; position: relative; }
        .my-builder-host.is-drop-active { outline: 2px dashed #4338ca; outline-offset: -4px; background: #eef2ff; }
        #MyWidgets, #MySettings { background: #f8f9fa; padding: 12px; overflow: auto; }
        #MyCanvas { overflow: auto; }
    </style>
</head>
<body>

<header class="my-header">
    <span class="my-header__title">Save / Import — round-trip via .json</span>
    <button type="button" id="c16SaveBtn" class="my-btn">Save .json</button>
    <label for="c16FileInput" class="my-drop-label">↥ Open .json</label>
    <input type="file" id="c16FileInput" accept=".json,application/json" aria-hidden="true">
    <span id="c16StatusPill" data-tone="" aria-live="polite">Drag a .json onto the canvas</span>
</header>

<div id="c16DropZone" class="my-builder-host">
    <div id="MyWidgets"></div>
    <div id="MyCanvas"></div>
    <div id="MySettings"></div>
</div>

<script>
    window.THEME_JSON        = <?= json_encode($bundle->themeJson,      JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
    window.THEME_TEMPLATES   = <?= json_encode($bundle->themeTemplates, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
    window.THEME_CONFIG_DATA = <?= json_encode($bundle->configData,     JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
    window.MEDIA_URL         = <?= json_encode($bundle->mediaUrl,       JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
</script>

<script src="/dist/builder.js"></script>

<script>
    const builder = new Builder({
        mainContainer:     '#MyCanvas',
        widgetsContainer:  '#MyWidgets',
        settingsContainer: '#MySettings',
    });

    const dropZone   = document.getElementById('c16DropZone');
    const fileInput  = document.getElementById('c16FileInput');
    const statusPill = document.getElementById('c16StatusPill');

    function setStatus(text, tone) {
        statusPill.textContent = text;
        statusPill.dataset.tone = tone || '';
    }

    function handleFile(file) {
        if (!file) return;
        if (!file.name.endsWith('.json')) {
            setStatus('Drop a .json file (got ' + file.name + ')', 'error');
            return;
        }

        const reader = new FileReader();
        reader.onload = () => {
            let parsed;
            try { parsed = JSON.parse(reader.result); }
            catch (e) { setStatus('Could not parse JSON: ' + e.message, 'error'); return; }

            if (!parsed || !Array.isArray(parsed.blocks)) {
                setStatus('Not a BuilderJS page (missing blocks[])', 'error');
                return;
            }

            setStatus('Importing…', 'busy');
            builder.load(parsed, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL, () => {
                const sizeKb = (file.size / 1024).toFixed(1);
                setStatus(`Imported ${file.name} (${sizeKb} KB)`, 'success');
            });
        };
        reader.onerror = () => setStatus('Could not read file', 'error');
        reader.readAsText(file);
    }

    document.getElementById('c16SaveBtn').addEventListener('click', () => {
        const data = builder.getData();
        const text = JSON.stringify(data, null, 2);
        const blob = new Blob([text], { type: 'application/json;charset=utf-8' });
        const url  = URL.createObjectURL(blob);
        const a    = document.createElement('a');
        a.href     = url;
        a.download = `page-${Date.now()}.json`;
        document.body.appendChild(a);
        a.click();
        a.remove();
        setTimeout(() => URL.revokeObjectURL(url), 0);
        setStatus(`Saved ${a.download}`, 'success');
    });

    dropZone.addEventListener('dragover', (e) => {
        e.preventDefault();
        if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
        dropZone.classList.add('is-drop-active');
    });
    dropZone.addEventListener('dragleave', () => dropZone.classList.remove('is-drop-active'));
    dropZone.addEventListener('drop', (e) => {
        e.preventDefault();
        dropZone.classList.remove('is-drop-active');
        if (e.dataTransfer.files.length > 0) handleFile(e.dataTransfer.files[0]);
    });

    fileInput.addEventListener('change', (e) => {
        if (e.target.files.length > 0) handleFile(e.target.files[0]);
    });

    builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL);
</script>

</body>
</html>

Notes

Why .json and not .bundle.zip for re-hydration? The engine reads JSON. The rendered HTML + theme assets that .bundle.zip ships are output, not input — they\'re the artefact you serve to your end-users (email render, PDF, web page). The JSON is the editable source. So the buyer\'s typical workflow is "save as JSON for editing, export as ZIP/HTML for serving." Both come from the same engine read pair; both flow back through this dropzone if you wire JSZip.

If you DO need .bundle.zip import. The bundle has index.html (rendered) + theme assets but no page.json by default. Two fix options: (a) extend C.15\'s export-bundle.php to add page.json (the getData() output) into the zip alongside index.html — re-run the export, the bundle now round-trips. (b) Use JSZip in the dropzone: const zip = await JSZip.loadAsync(file); const text = await zip.file('page.json').async('string'); const parsed = JSON.parse(text); builder.load(parsed, ...);. JSZip is ~30 KB minified; load it via <script src="https://cdn.jsdelivr.net/npm/jszip"> in the buyer\'s page.

Two anchor points, same handler. #c16DropZone is the visual drag target (the whole mini-builder wrapper). #c16FileInput is the keyboard / screen-reader fallback (label "↥ Open .json" opens the OS file picker on click). Both funnel into handleFile(file). A11y rule: every drag-drop affordance MUST have a non-mouse alternative — input[type="file"] is the cheapest.

Validation order matters. File-extension check (.endsWith('.json')) → FileReader read → JSON.parse (try/catch) → shape sniff (Array.isArray(parsed.blocks)) → load(). Each gate halts BEFORE the next so the buyer sees a useful error per failure mode. Loading garbage into builder.load() blanks the canvas silently — pre-validate.

The "Save .json" button uses Date.now() in the filename. Buyers exporting more than once an hour appreciate distinct filenames in their Downloads folder. The cookbook pattern: prefix with the page slug if the buyer\'s app has one (const slug = parsed.slug || 'page'; a.download = `${slug}-${Date.now()}.json`). Avoid hardcoding "export.json" — buyers re-export and overwrite their last good copy by accident.

Round-trip identity check. load(getData()) is a no-op identity for any valid page tree. The C.15 + C.16 pair is provably symmetric: the JSON dropped back into the editor renders byte-identical HTML to what was on screen at save time. Verified by 60+ engine specs in e2e/test-data-blob-mass.spec.js. If you find a tree where round-trip diverges, that\'s an engine bug — file an issue with the offending JSON attached.

Phase C flows tier — 2 of 3 closed. Next: flows/3-history-keyboard-shortcuts/ (W6.C.17) — Cmd-Z / Cmd-Shift-Z chord ladder + max-history stepper. Closes Phase C at C.17 (5 events + 5 api + 4 extensions + 3 flows = 17 examples). After Phase C closes, Phase D 3-item multi-tenant cookbook lands. Then F.B story-driven layers (6) + F.C 6-round audit (9) + Phase E close-out (6) close the wave. After W6 closes, W7 Gallery opens.