Scenes & Import/Export
A Scene is Ydesign's abstraction for "a complete design". A scene contains:
- Workarea info (size, background, bleed)
- All canvas elements (text, images, shapes, groups)
- The list of fonts in use
Every import, export, and design-switch flows through a scene, implemented by SceneHandler in @ydesign/core.
💡 A note about "pages": Ydesign supports multi-page — still one
store/ one Fabric canvas. Pages are multiple JSON snapshots hot-swapped onto the same canvas. See Multi-page below.
Import / export
Export the current design (JSON)
const json = store.toJSON();
/* => {
version: '6.x',
objects: [ ... ], // all canvas objects
background: '#ffffff',
// ... other fabric metadata
}
*/
The JSON can be:
- Stored in your database or object storage
- Combined with
toDataURLto generate a thumbnail - Restored later with
loadJSON
Restore from JSON
await store.loadJSON(json);
Internally, loadJSON:
- Clears the canvas
- Calls
sceneHandler.importFromJSON(json)to reload objects - Restores workarea size / background / fonts
- Fits to screen (
workareaHandler.auto()) - Resets the history stack (
historyHandler.init()) - Restores any custom image strokes
This is a full hot-swap — no need to recreate the store or remount the editor.
Export as image (PNG / JPEG / WebP)
// base64
const dataUrl = await store.toDataURL({
multiplier: 2, // scale factor for high-DPI output
format: 'png',
quality: 0.9,
});
// Blob (easy to upload / download)
const blob = await store.toBlob({ multiplier: 2, format: 'jpeg' });
// Download directly
await store.saveAsImage({
multiplier: 2,
format: 'png',
fileName: 'my-design.png',
});
Full ExportOptions
interface ExportOptions {
multiplier: number; // scale factor (required)
format?: 'jpeg' | 'png' | 'webp';
quality?: number; // 0-1, for jpeg / webp
enableRetinaScaling?: boolean; // add device pixel ratio
left?: number; // crop start x
top?: number; // crop start y
width?: number; // crop width
height?: number; // crop height
filter?: (object: any) => boolean; // skip objects returning false
}
Handy patterns:
// Export without watermark elements
await store.toBlob({
multiplier: 2,
filter: obj => obj.name !== 'watermark',
});
// Export only the 1000×1000 region centered on the canvas
await store.toDataURL({
multiplier: 1,
left: (store.width - 1000) / 2,
top: (store.height - 1000) / 2,
width: 1000,
height: 1000,
});
Template center / backend integration
A typical production flow:
import { reaction } from 'mobx';
// 1) Open a template
async function openTemplate(templateId: string) {
const res = await fetch(`/api/templates/${templateId}`);
const { json } = await res.json();
await store.loadJSON(json);
}
// 2) Auto-save (1s debounce)
reaction(
() => store.toJSON(),
json => {
fetch('/api/designs/current', {
method: 'PUT',
body: JSON.stringify(json),
});
},
{ delay: 1000 },
);
// 3) On publish, submit JSON + preview PNG
async function publish() {
const [json, dataUrl] = await Promise.all([
store.toJSON(),
store.toDataURL({ multiplier: 2, format: 'png' }),
]);
await fetch('/api/publish', {
method: 'POST',
body: JSON.stringify({ json, preview: dataUrl }),
});
}
The built-in Templates panel uses this same mechanism. Point it at your own backend with setAPI('templateList', ...).
Scenes & fonts
During loadJSON, Ydesign handles fonts automatically:
await store.loadJSON(json);
// Internally:
// 1. Scans json.objects for every fontFamily
// 2. Matches against the global font registry (via addGlobalFont)
// 3. Adds matched fonts into store.fonts, triggering on-demand load
That means:
- User fonts (
store.fonts) are serialized with the JSON — different users opening the same design get the same rendering - Global fonts (
addGlobalFont) stay out of JSON but are matched at runtime
See Editor Configuration · Fonts.
SceneHandler core API
For lower-level control, go directly to @ydesign/core's SceneHandler:
import type { ITemplate } from '@ydesign/core';
const scene: ITemplate = {
version: '6.0.0',
objects: [ /* ... */ ],
background: '#fff',
};
// Import (normalizes origin, stringifies ids, locks workarea, …)
const workarea = await store.editor!.sceneHandler.importFromJSON(scene);
SceneHandler.importFromJSON does far more than Fabric's native canvas.loadFromJSON:
| Step | Purpose |
|---|---|
| Clear the canvas | Avoid leftover objects |
Record objects with originX/Y === 'center' | loadFromJSON positions by left/top; we reapply center after |
formatObjects pre-processing | Normalize origin, stringify ids, add crossOrigin to images, lock workarea |
Call canvas.loadFromJSON | Fabric native load |
| Reapply centered positions | setPositionByOrigin('center', 'center') |
| Restore canvas dimensions | loadFromJSON overwrites width/height; we restore it |
Re-fetch workarea reference | All objects are re-created; old refs are stale |
auto() + historyHandler.init() | Fit to screen, reset history stack |
restoreStrokesFromCanvas | Restore custom image strokes |
That glue is all centralized in SceneHandler. In practice, store.loadJSON() is enough for almost every case.
Multi-page
Multi-page = orchestration of multiple page JSONs, not multiple Fabric canvases.
Switching pages: commit the current canvas → cache per-page history → deep-clone hot-load the target page. UI: bottom thumbnail strip via @ydesign/react-editor/pages.
Required reading for collaborators (mental model + data flow + load landing + save-on-switch + unified timeline):
👉 multi-page-scenes.md · Logical overview
Layers
| Layer | Package | Responsibility |
|---|---|---|
| Engine | @ydesign/core | SceneHandler page switch; HistoryHandler per-page stacks (cacheable by page) |
| State | @ydesign/editor-store | pages / loadJSON / unified timeline undo·redo |
| UI | @ydesign/react-editor/pages | <Pages store={store} /> |
Hard rules
| Rule | Notes |
|---|---|
| Persist as multi-page always | Upstream may supply single-page JSON; toDocumentJSON() is the project file |
| Template ≠ project import | Extract a single page first, then replacePage / importPage; append merges projects |
| Don't write disk mid-switch; write after | Content via _commitActivePage; activePageId via post-switch persist |
Undo only via store.undo() | Page structure and in-page edits interleave in time order; don't call historyHandler.undo directly |
Store API
store.pages; // page list (thumbnail / name / data)
store.activePageId;
await store.addPage({ width: 1080, height: 1080 });
await store.setActivePage(pageId);
await store.clonePage(pageId);
await store.deletePage(pageId); // keeps ≥1 page; deleting active jumps to previous page
store.renamePage(id, name);
store.movePage(fromIndex, toIndex);
await store.undo();
await store.redo();
// Multi-page document (recommended for persistence)
const doc = store.toDocumentJSON();
store.saveAsJSON('design.json');
await store.loadJSON(doc); // default mode: replace — full document swap
// Append / replace page (second arg controls where data lands)
await store.importPage(singlePageTemplate); // append one page at end
await store.replacePage(singlePageTemplate); // overwrite active page
await store.loadJSON(multiPageDoc, { mode: 'append' }); // append all pages from another doc
loadJSON three modes:
| mode | Behavior |
|---|---|
replace (default) | Full document swap; legacy single-page JSON is auto-wrapped as Page 1 |
append | Project merge: single-page adds 1 page; multi-page appends all pages |
replace-page | Replace a given page; multi-page JSON uses only its active page |
Sugar: importPage ≡ append one page; replacePage ≡ replace-page. See multi-page-scenes.md §4.0.
Template card ≠ project import. On a template card click: extract a single page first, then
importPage/replacePage. Only “replace the whole project” should callloadJSON(replace)on the original document.
Undo: use store.undo() / store.redo() (not historyHandler.undo directly).
Page structure and in-page edits undo in timeline reverse order; per-page stacks are cached by page, so after “edit text → delete page → Undo restores page” you can still undo the text edit. Page switches alone do not enter history. See multi-page-scenes.md §9.
Page content vs thumbnail vs save: three independent pipelines (data / thumbnail / draft persist). Switch timing: multi-page-scenes.md §J.
Deprecated aliases still work: scenes / setActiveScene / addScene, etc.
Mount UI
import { Pages } from '@ydesign/react-editor/pages';
<WorkspaceWrap>
<Toolbar store={store} />
<Workspace store={store} />
<ZoomButtons store={store} />
<Pages store={store} />
</WorkspaceWrap>
Document JSON (toDocumentJSON)
{
"width": 1080,
"height": 1080,
"fonts": [],
"pages": [
{
"id": "...",
"objects": [],
"width": 1080,
"height": 1080,
"background": "#ffffff",
"bleed": 0,
"clipPath": {},
"version": "6.9.1"
}
],
"activePageId": "...",
"unit": "px",
"dpi": 72,
"custom": null
}
- No
schemaVersion/audios;toDocumentJSON()omitsthumbnail/nameon exported pages - Legacy single-page JSON (objects only) still loads via
loadJSON, auto-wrapped as 1 page; root-levelthumbbecomes page 1 thumbnail - Legacy envelope
{ kind: 'ydesign-document', scenes }remains readable - Thumbnails:
thumbnail/thumbare UI cache (data URL or remote URL).loadJSONcan ingest them; after load,hydrateMissingThumbnails()fills missing ones; Demo local draft usestoDraftJSON()to keep thumbnails
Page-switch internal steps
_commitActivePage:sceneHandler.exportToJSON()→ current pagedatasceneHandler.switchScene(deepClone(target page data))- Emit
scene:changed; historyinit()for this page (independent undo stack per page)
Roadmap
- M0–M3 — Docs + Core switch + Store
pages+<Pages />+ demo draft / download JSON - M4 — Cross-page clipboard, drag reorder
- M5 — Multi-page PDF / per-page export (
@ydesign/core/node)