Multi-Editor Grid Architecture with Per-Cell CRDT
Multi-Editor Grid Architecture with Per-Cell CRDT
Problem
When introducing CSS Grid template areas-based layouts (INK-235), each named grid area needs its own independent rich text editor. The existing single-editor-per-page model (one LoroDoc per page) doesn’t work because:
- Shared undo history: Undo in one cell would undo changes in another
- Content cross-talk: A single ProseMirror document can’t enforce cell boundaries
- Partial saves: Saving one cell shouldn’t require serializing all cells
- Loading overhead: Opening a page would load all cells’ CRDT history at once
Solution
Architecture: One LoroDoc Per Block, Grid Container Orchestration
Each block in a layout gets its own LoroDoc (CRDT document), loaded and saved independently via block-specific Tauri
commands. A GridLayout React component orchestrates the grid rendering, while each BlockCell manages its own editor
lifecycle.
Domain Layer: Layout as Validated Value Object
The Layout struct validates CSS Grid template areas at construction time:
pub struct Layout { pub version: u8, // Always 1 pub grid_type: GridType, // Always CssGrid pub template: String, // "portrait stats\nportrait bio\nnotes notes" pub areas: Vec<String>, // ["portrait", "stats", "bio", "notes"] (reading order)}Validation guarantees (enforced by Layout::new()):
- All rows have the same number of columns
- Each named area forms a contiguous rectangle (bounding box check)
- Area names are valid identifiers (alphanumeric + hyphen + underscore)
- Areas extracted in left-to-right, top-to-bottom reading order for stable UI ordering
The contiguous rectangle check uses a bounding box algorithm:
// For each area, find bounding box (min/max row/col)// Expected cell count = (max_row - min_row + 1) * (max_col - min_col + 1)// If actual cell count != expected, area is not rectangular// Also verify every cell inside the bounding box belongs to this areaThis rejects L-shapes, T-shapes, and disjoint regions at domain creation time.
Storage Layer: Per-Block BLOB Commands
Two pairs of Tauri commands handle per-block CRDT data:
// Load CRDT snapshot for a specific block#[tauri::command]async fn get_block_content_snapshot_by_id( slug: &str, block_id: Uuid) -> Result<Option<Vec<u8>>>
// Save CRDT snapshot + materialized text for a specific block#[tauri::command]async fn save_block_content_by_id( slug: &str, block_id: Uuid, content_bytes: Vec<u8>, content_text: &str) -> Result<()>These delegate to PageRepository::get_block_content_blob_by_id() and save_block_content_blob_by_id(), which operate
on blocks.content_loro and blocks.content columns filtered by both slug and block_id.
Frontend Layer: Isolated Cell Lifecycle
Each grid cell (BlockCell) independently manages its LoroDoc:
function BlockCell({ block, slug, editorRef }: BlockCellProps) { const [loroDoc, setLoroDoc] = useState<LoroDoc | undefined>();
// Load per-block CRDT snapshot on mount useEffect(() => { invoke<number[]>("get_block_content_snapshot_by_id", { slug, blockId: block.id, }).then(bytes => { const doc = new LoroDoc(); if (bytes?.length) doc.import(new Uint8Array(bytes)); setLoroDoc(doc); }); }, [block.id, slug]);
// Debounced auto-save (1.5s after last edit) const handleContentChange = useCallback( debounce(async (markdown: string, loroBytes: number[]) => { await invoke("save_block_content_by_id", { slug, blockId: block.id, contentBytes: loroBytes, contentText: markdown, }); }, 1500), [slug, block.id] );
return loroDoc ? ( <InklingsEditor loroDoc={loroDoc} onContentChange={handleContentChange} /> ) : <LoadingSpinner />;}Key isolation properties:
- Each cell has its own
LoroDocinstance — no shared state - Each cell has its own undo/redo stack (via
LoroUndoPlugin) - Saves are per-cell, debounced independently
- Cell creation/destruction follows React mount/unmount lifecycle
Grid Container: CSS Grid with Template Areas
function GridLayout({ page, slug }: GridLayoutProps) { const layout = page.layout!;
// Parse template into CSS const style = { display: "grid", gridTemplateAreas: layout.template .split("\n") .map(row => `"${row}"`) .join(" "), gridTemplateColumns: `repeat(${colCount}, 1fr)`, gridTemplateRows: `repeat(${rowCount}, minmax(120px, auto))`, gap: "16px", };
return ( <div style={style}> {layout.areas.map(areaName => { const block = blocks.find(b => b.area === areaName); return ( <div key={areaName} style={{ gridArea: areaName }}> {block ? <BlockCell block={block} /> : <EmptyCell area={areaName} />} </div> ); })} </div> );}On-Demand Block Creation: EmptyCell Pattern
Empty grid areas show a clickable placeholder that creates a block on demand:
function EmptyCell({ area, slug, onBlockCreated }: EmptyCellProps) { const handleClick = async () => { const block = await invoke<Block>("create_block_in_area", { slug, area, contentType: { type: "Markdown" }, }); onBlockCreated?.(block); // Optimistic UI update }; // Renders: dashed border + "+" icon + area name label}This avoids pre-creating empty blocks for all areas — blocks are created only when the user clicks into a cell.
Layout Compatibility: Pre-Flight Check
Before applying a new layout, check which existing blocks would be displaced:
pub struct LayoutCompatibilityResult { pub compatible: bool, pub displaced_blocks: Vec<DisplacedBlock>,}
pub struct DisplacedBlock { pub block_id: Uuid, pub current_area: String, pub content_preview: String, // First 50 chars}The frontend shows a LayoutConflictDialog listing displaced blocks with content previews, requiring explicit user
confirmation before proceeding.
Keyboard Navigation
Tab/Shift+Tab navigates between grid cells in reading order:
// GridLayout container intercepts Tab keyconst handleKeyDown = (e: KeyboardEvent) => { if (e.key !== "Tab") return; e.preventDefault();
const occupiedAreas = layout.areas.filter(a => blocks.some(b => b.area === a) ); const currentIdx = occupiedAreas.indexOf(currentArea); const nextIdx = e.shiftKey ? (currentIdx - 1 + occupiedAreas.length) % occupiedAreas.length : (currentIdx + 1) % occupiedAreas.length;
editorRefsMap.get(occupiedAreas[nextIdx])?.current?.commands.focus();};Navigation wraps around and skips empty cells.
Prevention
Best Practices
- Never share a LoroDoc between cells. Each block’s CRDT history must be fully independent. Cross-contamination breaks undo/redo and makes partial saves impossible.
- Validate layout templates at the domain layer. Reject non-rectangular areas before they reach the database. The CSS Grid spec silently handles invalid templates, but downstream code (area iteration, block assignment) assumes rectangularity.
- Use on-demand block creation, not pre-allocation. Don’t create N empty blocks when applying an N-area layout. Users may only use some areas.
- Always run compatibility checks before layout changes. Blocks assigned to areas that don’t exist in the new layout become orphaned. The check-then-confirm pattern prevents silent data loss.
- Debounce per-cell saves independently. A 1.5s debounce per cell prevents save storms when the user is typing in one cell. Each cell’s timer is independent.
Warning Signs
- Editor undo in one cell affecting another cell — indicates shared LoroDoc
- Save producing stale content — indicates shared debounce timer
- Layout applying without displaced block warning — indicates missing compatibility check
- Empty cells not showing on page reload — indicates blocks weren’t persisted (check
create_block_in_areapath)
References
- Branch:
matt/ink-235-layout-definition-and-rendering(commitsd4a8566..b376190) - ADR-006:
docs/ADR/006-loro-crdt-block-storage.md - CRDT pass-through:
docs/solutions/architecture/crdt-blob-passthrough-pipeline.md - Layout value object:
crates/domain/src/layout.rs - Grid editor:
apps/desktop/src-react/components/editor/GridLayout.tsx - Compatibility check:
crates/application/src/page/check_layout_compatibility.rs - Conflict dialog:
apps/desktop/src-react/components/editor/LayoutConflictDialog.tsx
LWW Metadata Sync: Timestamp Preservation and Cascade Semantics Next
Permission Guard: Single Source of Truth Pattern
Was this page helpful?
Thanks for your feedback!