<?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>
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
-
Save the canvas as a `.json` blob — `getData()` is the round-trip source of truth
builder.getData()returns the canonical JSON tree (the same shapebuilder.load()reads). Write it to aBlob, 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
.jsonand not.bundle.zip? Two reasons. (1) Re-hydration only NEEDS the JSON — the rendered HTML + theme assets that.bundle.zipships are output, not input. The engine reads JSON viaload()and rebuilds the canvas from there. (2) Reading.zipclient-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.jsonstays 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 samegetData()+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); }); -
Wire the dropzone — `FileReader` → `JSON.parse` → `builder.load(...)`
The dropzone is the whole demo wrapper.
dragenter+dragoversetdropEffect = 'copy'+ flip a CSS class so the buyer sees a clear "release here" affordance;dragleave+droptear down. The drop handler reads the file viaFileReader.readAsText, parses JSON, and feeds it intobuilder.load(parsed, templates, configData, mediaUrl). The 4 args after the first MUST be the samethemeTemplates/themeConfigData/themeMediaUrlthe 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 — seebuilder.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 samehandleFile(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
blocksarray, missingtheme) show "Not a BuilderJS page". The engine'sload()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.JavaScriptconst 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]); }); -
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.jsonback onto the wrapper → canvas re-hydrates byte-identically. The trip is symmetric becausegetData()andload()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 ine2e/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_dirfield; 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.jsonwrapper 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; thehandleFilehelper 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'); }); }
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.
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.