Skip to content
Documentation GitHub
Architecture

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:

  1. Shared undo history: Undo in one cell would undo changes in another
  2. Content cross-talk: A single ProseMirror document can’t enforce cell boundaries
  3. Partial saves: Saving one cell shouldn’t require serializing all cells
  4. 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:

crates/domain/src/layout.rs
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 area

This 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:

apps/desktop/src-react/components/editor/GridLayout.tsx
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 LoroDoc instance — 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:

crates/application/src/page/check_layout_compatibility.rs
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 key
const 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_area path)

References

  • Branch: matt/ink-235-layout-definition-and-rendering (commits d4a8566..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

Was this page helpful?