Skip to main content

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 toDataURL to generate a thumbnail
  • Restored later with loadJSON

Restore from JSON

await store.loadJSON(json);

Internally, loadJSON:

  1. Clears the canvas
  2. Calls sceneHandler.importFromJSON(json) to reload objects
  3. Restores workarea size / background / fonts
  4. Fits to screen (workareaHandler.auto())
  5. Resets the history stack (historyHandler.init())
  6. 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:

StepPurpose
Clear the canvasAvoid leftover objects
Record objects with originX/Y === 'center'loadFromJSON positions by left/top; we reapply center after
formatObjects pre-processingNormalize origin, stringify ids, add crossOrigin to images, lock workarea
Call canvas.loadFromJSONFabric native load
Reapply centered positionssetPositionByOrigin('center', 'center')
Restore canvas dimensionsloadFromJSON overwrites width/height; we restore it
Re-fetch workarea referenceAll objects are re-created; old refs are stale
auto() + historyHandler.init()Fit to screen, reset history stack
restoreStrokesFromCanvasRestore 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

LayerPackageResponsibility
Engine@ydesign/coreSceneHandler page switch; HistoryHandler per-page stacks (cacheable by page)
State@ydesign/editor-storepages / loadJSON / unified timeline undo·redo
UI@ydesign/react-editor/pages<Pages store={store} />

Hard rules

RuleNotes
Persist as multi-page alwaysUpstream may supply single-page JSON; toDocumentJSON() is the project file
Template ≠ project importExtract a single page first, then replacePage / importPage; append merges projects
Don't write disk mid-switch; write afterContent 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:

modeBehavior
replace (default)Full document swap; legacy single-page JSON is auto-wrapped as Page 1
appendProject merge: single-page adds 1 page; multi-page appends all pages
replace-pageReplace a given page; multi-page JSON uses only its active page

Sugar: importPage ≡ append one page; replacePagereplace-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 call loadJSON(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() omits thumbnail / name on exported pages
  • Legacy single-page JSON (objects only) still loads via loadJSON, auto-wrapped as 1 page; root-level thumb becomes page 1 thumbnail
  • Legacy envelope { kind: 'ydesign-document', scenes } remains readable
  • Thumbnails: thumbnail / thumb are UI cache (data URL or remote URL). loadJSON can ingest them; after load, hydrateMissingThumbnails() fills missing ones; Demo local draft uses toDraftJSON() to keep thumbnails

Page-switch internal steps

  1. _commitActivePage: sceneHandler.exportToJSON() → current page data
  2. sceneHandler.switchScene(deepClone(target page data))
  3. Emit scene:changed; history init() 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)

Next