DocMosaicdocs
Concepts

Document model

The Document → Page → Section hierarchy. One source of truth shared by the canvas, the PDF generator, and the headless hook.

The document model is a single normalized tree shared between the canvas, the PDF generator, and the headless useDocumentState hook. Everything else in the library reads from it; nothing else holds long-lived state.

The shape

interface Document {
    id: string;
    name: string;
    pageSize: PageSize; // 'A4' | 'LETTER' | ...
    orientation: PageOrientation; // 'portrait' | 'landscape'
    pages: Page[];
    sections: Section[]; // flat across all pages; each section links to its page via a 1-based `page` number
    createdAt: Date;
    updatedAt: Date;
}

Two design choices to call out:

  • Sections are flat, not nested under pages. Each Section carries a 1-based page field. This keeps reordering and cross-page moves cheap (no array surgery to lift a section out of one tree into another) and makes the reducer's update logic a single map over one array.
  • Document-level pageSize and orientation. All pages in a document share the same paper size - the editor is built for documents, not arbitrary multi-format canvases. If you need per-page sizes, fork the reducer; it's pure.

Page

interface Page {
    id: string;
    backgroundPDF: string | null;
    background?: { color?: string; image?: string };
    guides?: PageGuides;
}

Page is intentionally thin - it's a slot, not a container. The expensive content (sections) is keyed off the section's page number, so a page can be moved or deleted without rewriting every section underneath it.

background and guides are both optional. Documents migrated from earlier versions render identically - the renderer treats undefined as "no override."

Section

interface Section {
    id: string;
    page: number; // 1-based page number
    type: 'image' | 'text' | 'shape' | 'drawing' | 'frame';
    x: number; // PDF points (72 DPI)
    y: number;
    width: number;
    height: number;
    zIndex: number; // higher renders on top; see Layers
    hidden?: boolean; // skip on canvas + PDF
    locked?: boolean; // refuse selection/drag/resize
    parentFrameId?: string; // set when dropped inside a container frame
    // ...type-specific payload fields
}

The type-specific payload differs:

  • image - imageUrl, an optional crop rectangle, and optional maskShape: 'rect' | 'circle' | 'line' (a placeholder/image-mask frame).
  • text - text, fontSize, and optional fontFamily, fontWeight, fontStyle, color, align, lineHeight.
  • shape - shape: 'rect' | 'circle' | 'line', fill, stroke, strokeWidth, opacity.
  • drawing - an array of stroke paths in points.
  • frame - a container box: optional fill, stroke, strokeWidth, radius. Other sections become its children.

The reducer doesn't care about the payload - it routes updates through updateSection(section) (you pass the whole updated Section). Per-type behavior lives in the canvas primitives and the PDF generator.

Container frames

A FrameSection (type: 'frame') is a box that owns other sections. Children stay in the same flat sections array - they're linked to their frame only by a parentFrameId back-pointer, never nested. This keeps the tree flat (the design choice above) while still letting a frame move, duplicate, or delete as a unit:

  • Dragging a section so its center lands inside a frame stamps parentFrameId; dragging it out clears it.
  • Moving a frame translates its children with it; deleting or duplicating a frame cascades to them.
  • At render time a frame draws behind its children (see Layers).

See the Frames concept for container vs. placeholder (image-mask) frames.

Where geometry lives

Every x/y/width/height is in PDF points. Always. The canvas applies finalScale = pageScale * zoom at display time; the PDF generator hands the values straight to jspdf without any conversion. See Unit system for the conversion API and why this matters.

Mutating the document

There is exactly one way to mutate the document: dispatch through the reducer. Two surfaces wrap it:

// 1. The headless hook - owns history internally.
const { document, actions } = useDocumentState();
actions.addSection({ type: 'image' }); // appends to the current page

// 2. Editor.Root - same hook, mounted under context.
<Editor.Root>
    {/* primitives call useEditor().actions */}
</Editor.Root>

Both paths funnel through reducer + withHistory from @docmosaic/core. If you add a new mutator, route it through there or the timeline will desync.

Serialization

Document is plain JSON. Send it over the wire, persist it to localStorage, embed it in a Postgres jsonb column - the model is intentionally serialization-friendly.

const json = JSON.stringify(document);
const restored: Document = JSON.parse(json);

The only thing to watch for: imageUrl is stored as a data URL by default. Documents with embedded images can get large. See the persisting templates recipe for a pattern that keeps blobs on the side.

See also