Table Component
Extract Table from GFM pipe surgery into a standalone Component + Reducer with per-cell LoroText. Works in Whiteboard (via NativeElement) and Document Modality (via BlockType::Table). Table state is not embedded in Prose, but Prose gains opaque-block awareness for table sentinels in materialized documents. Block composition lives on Document (Document Modality RFC). Layout ownership (Q1) is an open question.
Non-goals
Section titled “Non-goals”- Tables inside Prose or text elements. A whiteboard text box or a Prose instance cannot contain an embedded table. Tables are sibling blocks in Document, or standalone elements in Whiteboard.
- Near-parity with Google Docs tables in v1. Border model (per-edge, border-collapse), cell padding controls, row height controls, style inheritance (table → row → column → cell) are all deferred. See v1 support matrix below.
Open Design Questions
Section titled “Open Design Questions”Q1: Layout ownership — Rust (taffy) vs Dart
Section titled “Q1: Layout ownership — Rust (taffy) vs Dart”Table needs column widths and row heights. Column widths are computed from track definitions (pure arithmetic). Row heights depend on cell content height — which is Flutter-rendered rich text that Rust cannot measure.
AutoGrid sidesteps this because all cells are the same fixed height (no content measurement). Table is the first component where layout depends on content measurement.
Option A — Dart owns layout (leaning toward): Rust emits column track definitions in the snapshot. Dart resolves tracks to pixel widths (arithmetic) and measures cell text height using Flutter’s layout engine to compute row heights locally. Taffy is not needed for v1. Simple, accurate, no cross-language measurement bridge. Downside: Rust doesn’t know final row heights, which matters if we later need server-side rendering or AI-aware layout.
Option B — Rust owns full layout via taffy + measurement bridge: TaffyTree<CellContext> with compute_layout_with_measure(). Taffy calls back per cell to ask “how tall are you at this width?” The callback crosses into Dart via FRB for text measurement. Requires an async measurement bridge and caching. More complex but gives Rust full layout knowledge. AutoGrid already uses taffy (but with fixed heights, no measurement callback).
Option C — Hybrid: Rust uses taffy for column widths only (track definitions → taffy grid → column pixel widths). Dart handles row heights. Gets taffy’s track resolution logic (minmax, fr, auto) without the measurement bridge. More useful than Option A if column track logic gets complex.
Cell content: plain strings as v1 fallback
Section titled “Cell content: plain strings as v1 fallback”The primary design uses root LoroText per cell for full rich text editing. A simpler alternative is plain strings stored as LoroMap values — no LoroText containers, no Prose reducer per cell, no orphaned containers. Tradeoff: LWW on concurrent edits, no inline formatting. This is a viable v1 simplification if rich-text cells are not needed immediately, with an upgrade path to LoroText later. The rest of this RFC assumes root LoroText (the primary design).
Motivation
Section titled “Motivation”Today’s table is inline GFM markup inside a single LoroText — 530 lines of byte-level pipe surgery in Rust, 580 lines of CustomPaint pixel drawing in Dart. It can’t do multi-line cells, per-cell formatting, column resize, merge, sort, or any of the features users expect from a modern table editor.
Table should be its own component — a standalone native Flutter widget backed by Rust state, reusable in both Whiteboard and Document. Block composition (interleaving Prose instances + native components) belongs on Document Modality, not inside Prose. Prose remains the text editor — table state is not embedded in Prose, but Prose needs opaque-block awareness for table sentinels in materialized documents (see Document integration).
Architecture Overview
Section titled “Architecture Overview”Table is a standalone native component — not embedded in Prose, not reimplemented via whiteboard drawing primitives. It’s a Flutter widget that receives all data from Rust via snapshots.
Two integration points:
| Context | Integration |
|---|---|
| Whiteboard | TableComponent (TypedComponent) → NativeElement { widget_type: "table" } |
| Document | BlockType::Table { table_id } marker in Vec<ProseBlock> |
In Whiteboard: the component compiles to a NativeElement — Dart’s whiteboard renderer sees the widget type and spawns the appropriate Flutter widget at that position. Table has a paired Reducer in WhiteboardEphemeral.table_editors for runtime editing.
In Document: ProseBlock is the universal block element; non-text components emit blocks with non-text BlockType variants and empty segments. Table and Image both use this pattern but differ in how data reaches Dart:
- Image/File (stateless): All data lives in properties (
url,alt_text).BlockType::Image { url, alt }carries everything inline — Dart renders directly from the block. No side-channel, no reducer, no snapshot lookup. - Table (stateful): Has a live Reducer with mutable state (rows, columns, cells) that changes on every keystroke.
BlockType::Table { table_id }is a reference marker only — Dart looks up the liveTableSnapshotby ID fromDocumentSnapshot.table_snapshots. ProseBlock can’t carry table state because it’s large, structured, and changes continuously between compilations.
Document manages embedded Table reducers in DocumentEphemeral.table_editors. Any future component with live mutable state (e.g. interactive chart, embedded whiteboard) would follow Table’s reference-marker pattern.
Identity
Section titled “Identity”table_id (UUID, generated at creation) is the canonical identity for a table instance. It is used everywhere:
- CRDT container key prefix:
table:{table_id}:* - Reducer lookup:
table_editors[table_id] - Snapshot keying:
native_snapshots[table_id],table_snapshots[table_id] - Dart event routing:
TableEdit { table_id, intent }
Lifecycle:
- On create: Allocate a fresh UUID before placement/block creation. The creating code (whiteboard add-element, document insert-block) generates the
table_idand passes it as a property binding. - On duplicate/paste: Allocate a new
table_idand deep-copy all table CRDT roots (rows, columns, config, cell text, cell meta) under the new ID. Never copy atable_idbinding or sentinel marker verbatim — that would alias two elements to the same table state. - On delete: The
table_idbecomes orphaned. CRDT roots persist but are unreferenced (see root container lifecycle).
Placement/block/widget IDs are container-level identifiers. They map to table_id at the integration boundary:
- Whiteboard:
table_idstored as a property binding on the element’s placement. The element’smeta.idis a placement-level identifier. - Document:
table_idstored inBlockType::Table { table_id }.
CRDT model
Section titled “CRDT model”doc.get_movable_list("table:{table_id}:rows") // row ID orderingdoc.get_movable_list("table:{table_id}:columns") // column ID orderingdoc.get_map("table:{table_id}:config") // { has_header }doc.get_map("table:{table_id}:colmeta:{col_id}") // { track, value, min_px }doc.get_map("table:{table_id}:cellmeta:{row_id}:{col_id}") // { h_align, v_align, background, col_span, row_span, merged_into }doc.get_text("table:{table_id}:cell:{row_id}:{col_id}") // cell rich text (root LoroText)Cells addressed by stable (row_id, col_id), not indices. Robust for reorder, merge/unmerge, concurrency.
Header row: config.has_header: bool — first row in ordering gets header styling. No per-row flag.
Column metadata: track + value compile to a track definition (see layout engine section).
v1 CRDT scope: Only fields that v1 actually uses are included above. Deferred fields (borders, stripe, row-level styling, column-level alignment, style inheritance) will be added to the relevant maps when implemented.
Lazy root creation: Cell containers (text, cellmeta, colmeta) are created on first write, not eagerly at table creation. A missing root = empty/default cell. This avoids a burst of root container creation for large tables.
Read without materializing: Snapshot and rebuild code must check for root existence before reading — doc.get_text("name") in Loro is idempotent but creates a permanent empty root on first call. Use doc.contains_root("name") (or equivalent existence check) to avoid accidentally materializing empty roots during reads. If the root doesn’t exist, use defaults.
Root container lifecycle: Deleted cells leave orphaned root LoroTexts and LoroMaps (cellmeta, colmeta). Loro has no API to delete root containers. Each orphan is tiny (container ID + empty content). Cleanup path: full compaction (export → rebuild LoroDoc with only referenced roots → re-export). Low priority unless documents see extreme churn.
Why root containers for cell LoroTexts
Section titled “Why root containers for cell LoroTexts”Loro supports nesting (row_map.setContainer("col_0", new LoroText())), but concurrent setContainer() on the same key is last-write-wins — one peer’s LoroText gets silently overwritten, losing content. Root containers (doc.get_text("name")) are idempotent — concurrent calls get the same container, no data loss. Cell text is the most important data to protect.
Why LoroMovableList for ordering
Section titled “Why LoroMovableList for ordering”MoveRow/MoveColumn need real move semantics. LoroList only has insert/delete — simulating move under concurrency produces duplicates or losses. LoroMovableList handles this correctly. Overhead (~50% more memory) is fine for tens of entries.
Ordering lists store only stable ID strings. Metadata lives in separate root LoroMaps keyed by those IDs. This avoids coarse last-write-wins — peer A changing a column’s track while peer B changing its alignment merge cleanly as different map keys.
Important: Table must use real LoroMovableList move operations for reorder/sort, not the clear-and-rewrite pattern used by Layout’s sync_order_to_loro(). Clear-and-rewrite under concurrency loses peer moves.
Sort and merge constraints
Section titled “Sort and merge constraints”Sort reorders the rows movable list by stable row IDs — cell addressing stays valid. Sort is disallowed when merged cells span multiple rows in the sorted region. Header row (when has_header is true) is excluded from sort — it always stays first.
Invariants
Section titled “Invariants”- Row IDs unique within a table
- Column IDs unique within a table
- Every
merged_intotarget cell exists and is an owner cell (hascol_span >= 1,row_span >= 1) - Covered cells cannot themselves be owners
- Focused cell must exist in current row/column sets
- Sort excludes header row when
has_header = true - Delete/move of rows/columns that participate in a merged region: unmerge first, then delete/move
Reducer
Section titled “Reducer”Follows the Layout Reducer pattern: in-memory primary read model + Loro write-through + init_from_loro() / rebuild_from_loro().
pub struct TableServices { pub agent: Arc<AgentService> }
pub type TableFx = Effect<TableServices, TableIntent, TableFeedback>;
pub struct Table { rt: Runtime<Self> }
/// Flat state — same pattern as Layout's LayoutState./// State<Synced, Ephemeral> is reserved for top-level modalities with their own Session.pub struct TableState { pub table_id: String, pub doc: loro::LoroDoc, // shared doc reference — for lazy root access
// --- In-memory read model (primary for reduce/snapshot) --- pub rows: Vec<RowState>, // ordered row IDs + metadata pub columns: Vec<ColumnState>, // ordered column IDs + track defs pub config: TableConfig, // has_header pub cell_meta: HashMap<(String, String), CellMeta>, // (row_id, col_id) → formatting
// --- Loro handles (persistence, write-through on every mutation) --- pub loro_rows: loro::LoroMovableList, pub loro_columns: loro::LoroMovableList, pub loro_config: loro::LoroMap, // Lazy handle caches — populated on first access, not eagerly: pub colmeta_handles: HashMap<String, loro::LoroMap>, pub cellmeta_handles: HashMap<(String, String), loro::LoroMap>,
// --- Runtime-only --- pub cell_editors: HashMap<(String, String), Prose>, // lazy, created on focus pub focused_cell: Option<(String, String)>,}Source of truth: Structure and metadata are persisted in Loro containers. Cell content is persisted in root LoroTexts. The in-memory read model (rows, columns, config, cell_meta) is derived from Loro — rebuilt via init_from_loro() on load and rebuild_from_loro() on undo/redo. The reducer is the runtime orchestrator. Snapshots are derived render state, not persisted.
Constructor and lifecycle:
Table::new(doc, table_id, seed_props)— if CRDT roots exist, callsinit_from_loro()to populate in-memory model from existing state (seed props ignored). If no roots exist (first mount), creates initial CRDT state from seed props (N rows, M columns, empty cells, header config), then callsinit_from_loro().init_from_loro(doc, table_id)— reads all Loro containers, populates in-memory Vecs/maps. Uses existence checks to avoid creating empty roots for absent cells.rebuild_from_loro()— called after undo/redo mutates Loro state under the reducer. Re-reads Loro into in-memory model. Drops cell editors for cells that no longer exist. Preserves focus only if the focused cell still exists in the rebuilt model, otherwise clears focus.- All
reduce_intent()mutations write-through: update in-memory model AND Loro handles in the same call.
impl Reducer for Table { type State = TableState; type Intent = TableIntent; type Feedback = TableFeedback; type Error = TableError; type Services = TableServices;
fn runtime(&self) -> &Runtime<Self> { &self.rt } fn runtime_mut(&mut self) -> &mut Runtime<Self> { &mut self.rt }
fn reduce( cmd: Command<TableIntent, TableFeedback>, state: &mut TableState, ) -> TableFx { match cmd { Command::Intent(intent) => reduce_intent(intent, state), Command::Feedback(_) => TableFx::none(), } }}Table is the first component that also has a paired Reducer. Existing components (File, Shape, Path) are stateless — they implement TypedComponent only. Table needs runtime state (cell focus, row CRUD), so it pairs a TableComponent (compilation, property schema) with a Table reducer (runtime editing) in the parent’s ephemeral state. Same split as TextComponent (compilation) + Prose (editing) today.
Cell editors created on focus, destroyed on blur. Root LoroTexts are idempotent — concurrent focus by two peers is safe. CellEdit { intent } dispatches synchronously into the focused cell’s Prose, same as WhiteboardIntent::TextEdit → prose_editors.get_mut(&id).dispatch().
Intents
Section titled “Intents”pub enum TableIntent { // Cell editing FocusCell { row_id: String, col_id: String }, Blur, CellEdit { intent: ProseIntent },
// Row ops InsertRowAbove, InsertRowBelow, DeleteRow { row_id: String }, MoveRow { row_id: String, to_index: usize },
// Column ops InsertColumnLeft, InsertColumnRight, DeleteColumn { col_id: String }, MoveColumn { col_id: String, to_index: usize }, SetColumnTrack { col_id: String, track: ColumnTrack }, DistributeColumnsEvenly,
// Cell formatting SetCellAlign { h_align: HorizontalAlignment }, SetCellVerticalAlign { v_align: VerticalAlignment }, SetCellBackground { color: Option<u32> },
// Merge MergeCells { row_range: (usize, usize), col_range: (usize, usize) }, UnmergeCells { row_id: String, col_id: String },
// Header / sort ToggleHeaderRow, // flips config.has_header — first row gets header styling SortByColumn { col_id: String, ascending: bool }, // header excluded, disallowed with merged rows
// Nav TabNext, TabPrev,}Formatting boundary
Section titled “Formatting boundary”Prose (cell text) owns: bold, italic, code, strikethrough, math, links, text color, bg color — Loro marks on the cell LoroText.
Table (structural) owns: horizontal/vertical alignment, cell background, column widths, row/column CRUD, merge/split, header, sorting.
No duplication — table never touches inline formatting, Prose never touches cell layout.
Merge model
Section titled “Merge model”col_span/row_span on owner cell’s metadata. Covered cells have merged_into referencing the owner cell by row_id and col_id fields (not a colon-joined string — stored as two separate fields on the cellmeta map). Unmerge clears spans and merged_into.
Non-empty covered cells: When merging a region, covered cells may already contain content. Merge is rejected if any covered cell is non-empty — user must clear them first. This avoids silent data loss (Google Sheets behavior: warns, then discards). Unmerge always succeeds — covered cells reappear empty.
Snapshot
Section titled “Snapshot”pub struct TableSnapshot { pub table_id: String, // canonical identity pub rows: Vec<TableRowSnapshot>, pub columns: Vec<ColumnSnapshot>, pub has_header: bool, pub focused_cell: Option<FocusedCellSnapshot>, pub table_width: f64,}
pub struct ColumnSnapshot { pub id: String, pub track: ColumnTrack, // Fixed/Proportional/Auto — resize handles know the mode // Q1-A: Dart resolves tracks to pixel widths // Q1-B/C: Rust emits computed_width after taffy layout}
pub struct TableRowSnapshot { pub id: String, pub is_header: bool, // header styling (bold, background, sticky) pub cells: Vec<CellSnapshot>, // one per column, left-to-right // Row height: Q1-A = Dart-computed; Q1-B/C = Rust-computed (taffy)}
pub struct CellSnapshot { pub row_id: String, // cell identity — row_id + col_id, not opaque id pub col_id: String, pub blocks: Vec<ProseBlockView>, // rendered rich text content pub h_align: HorizontalAlignment, pub v_align: VerticalAlignment, pub background: Option<u32>, pub col_span: u32, pub row_span: u32, pub merged_into: Option<CellRef>, // if spanned over by another cell}
pub struct CellRef { pub row_id: String, pub col_id: String,}
/// Editing state only — blocks come from CellSnapshot above./// Matches WhiteboardSnapshot's focused_prose: Option<FocusedProseSnapshot> pattern.pub struct FocusedCellSnapshot { pub row_id: String, pub col_id: String, pub prose: FocusedProseSnapshot, // cursor/selection/marks — no blocks}Dart receives a structural frame: column track definitions, every cell’s rendered rich text content, and optionally which cell has the cursor. How column widths and row heights are resolved depends on Q1 (layout ownership). Only the focused cell carries a FocusedProseSnapshot (cursor position, selection, active marks). All other cells carry pre-rendered Vec<ProseBlockView>.
Layout engine
Section titled “Layout engine”A table is conceptually a CSS Grid: columns = grid template column tracks, rows = auto-height grid rows, merged cells = col_span/row_span on children. Whether this is computed via taffy in Rust or Flutter’s layout engine in Dart depends on Q1 (see Open Design Questions).
If Q1-B/C (Rust uses taffy): AutoGrid already uses taffy (crates/modality/autogrid/src/taffy_bridge.rs), but its TaffyBridge is AutoGrid-shaped (square cells, AxisMode, dense packing, drag/resize). Table would need a separate adapter or a shared lower-level grid bridge.
Column WidthMode maps to track definitions (used by taffy if Q1-B/C, or by Dart arithmetic if Q1-A):
enum ColumnTrack { Fixed { px: f32 }, Proportional { weight: f32, min_px: Option<f32> }, Auto { min_px: Option<f32> },}
// Track compilation:match track { Fixed { px } => length(px), Proportional { weight, min_px: None } => flex(weight), Proportional { weight, min_px: Some(min) } => minmax(length(min), fr(weight)), Auto { min_px: None } => auto(), Auto { min_px: Some(min) } => minmax(length(min), auto()),}- DistributeColumnsEvenly = all columns become
flex(1.0) - SetColumnWidth { fixed } = that column becomes
length(px) - Column min-width =
minmax(length(min), ...)wrapping
Row tracks are always auto() (or minmax(length(min_row_px), auto()) with a floor). Rows auto-size to tallest cell content, including multi-line text wrapping.
Layout ownership is an open question — see Q1 in Open Design Questions. The CRDT model and snapshot structures work regardless of which option is chosen. Column track definitions are always stored in Loro and emitted in ColumnSnapshot.track.
Table-specific constraints vs AutoGrid:
- Always row-major flow (no dense packing — reordering cells is wrong for tabular semantics)
- X-axis always bounded (table has a definite width from container)
- Y-axis always unbounded (table grows vertically with content)
- No drag/resize/reorder UX — row/column reorder is via explicit intents, not gesture-based layout
Sticky headers and header row styling are Dart rendering concerns, not layout engine concerns.
Whiteboard integration
Section titled “Whiteboard integration”Uses Typed Component Properties RFC TypedComponent + #[derive(ComponentProperties)] pattern. Blanket impls provide Component + WhiteboardComponent automatically.
Initialization: On first mount (when reconcile_table_editors() encounters a table element with no existing CRDT containers), the Table constructor reads seed props and creates the initial CRDT state (N rows, M columns, empty cells, header config). On subsequent mounts, seed props are ignored — the reducer loads from existing CRDT containers. This is a one-time initialization, not ongoing config.
Props are creation-time seed, not live state. initial_columns/initial_rows define the table’s initial dimensions. After creation, the Table reducer owns all state via CRDT containers — seed props are never re-read. The props are hidden in the property editor after initialization (they’re not useful to change post-creation). This avoids dual source of truth between placement bindings and table containers.
#[derive(modality_core::ComponentProperties)]pub struct TableProps { #[property(name = "Table ID", hidden)] pub table_id: String, // canonical identity — persisted, generated at creation
#[property(name = "Initial Columns", min = 1.0, max = 20.0, default = "3")] pub initial_columns: u32,
#[property(name = "Initial Rows", min = 1.0, max = 50.0, default = "3")] pub initial_rows: u32,
#[property(name = "Header Row", default = "true")] pub initial_has_header: bool,}
pub struct TableComponent;
inventory::submit!(WhiteboardComponentEntry(&TableComponent));
impl TypedComponent for TableComponent { type Properties = TableProps; type Metadata = WhiteboardChildMeta; type Output = Vec<WhiteboardElement>;
fn id() -> &'static str { "table" } fn name() -> &'static str { "Table" } fn description() -> &'static str { "Structured table with per-cell rich text editing" } fn icon() -> &'static str { "table_chart" }
fn compile( &self, props: &TableProps, meta: &WhiteboardChildMeta, ) -> Result<Vec<WhiteboardElement>, CompileError> { let properties = HashMap::from([ ("table_id".into(), PropertyValue::String(props.table_id.clone())), ("initial_columns".into(), PropertyValue::Number(props.initial_columns as f64)), ("initial_rows".into(), PropertyValue::Number(props.initial_rows as f64)), ("initial_has_header".into(), PropertyValue::Bool(props.initial_has_header)), ]);
Ok(vec![WhiteboardElement::Native(NativeElement { id: meta.id.clone(), position: Offset { dx: meta.bounds.x, dy: meta.bounds.y }, size: Size { width: meta.bounds.width, height: meta.bounds.height }, widget_type: "table".into(), properties, })]) }}Snapshot transport — how live table state reaches Dart in Whiteboard:
WhiteboardSnapshot gains a map of native editor snapshots alongside the existing focused_prose:
pub struct WhiteboardSnapshot { pub elements: Vec<WhiteboardElement>, pub focused_prose: Option<FocusedProseSnapshot>, // New: live snapshots for native components with reducers pub native_snapshots: HashMap<String, NativeSnapshot>, // keyed by table_id (not element id)}
pub enum NativeSnapshot { Table(TableSnapshot), // future: other reducer-backed native components}Dart’s whiteboard renderer: when painting a NativeElement { widget_type: "table" }, reads table_id from properties["table_id"] and looks up native_snapshots[table_id] to get the full TableSnapshot. If present, renders the live interactive table. If absent (table not yet initialized), renders a placeholder from seed props.
Whiteboard sizing: Whiteboard elements have a fixed user-set frame (position + size). Table renders within that frame with internal vertical scroll if content exceeds the frame height. The element’s synced size does not auto-grow on every cell edit — that would cause undo/sync churn. Users resize the element frame manually via drag handles, same as any other whiteboard element. In Document, tables flow vertically with no fixed frame — height is fully determined by content.
Editor lifecycle: reconcile_table_editors() runs during Whiteboard’s dispatch lifecycle, same pattern as reconcile_prose_editors(). Creates Table reducers for newly visible table elements, drops them for deleted ones.
Whiteboard ephemeral manages the paired Table reducer — same pattern as prose editors:
pub struct WhiteboardEphemeral { pub prose_editors: HashMap<String, Prose>, pub table_editors: HashMap<String, Table>, // keyed by table_id pub focused_table_id: Option<String>, // which table has focus (if any)}// WhiteboardIntent::TableEdit { table_id, intent: TableIntent }// WhiteboardIntent::FocusTable { table_id }Document integration
Section titled “Document integration”Table integrates with the Document Modality RFC. Unlike stateless components (Image, HorizontalRule) whose data is carried inline in BlockType fields, Table emits a reference marker BlockType::Table { table_id } — Dart resolves it via DocumentSnapshot.table_snapshots.
Materialization sentinel: During materialization, a table block is written into the LoroText as an opaque block — a single line with a non-printable prefix that cannot collide with user text. Exact format TBD (e.g. zero-width object replacement character + structured reference). The Prose parser recognizes the sentinel and emits BlockType::Table { table_id }. The table’s CRDT containers are not flattened — they remain independent root containers.
Opaque block editing semantics: The sentinel in the LoroText behaves as an atomic, non-editable block:
- Cursor: Skips over the sentinel — arrow keys move from the block before it to the block after it. The sentinel is never “inside” the cursor position.
- Backspace/Delete: Treats the entire sentinel as one unit. Backspace from the block after the sentinel deletes the entire table reference. Forward-delete from the block before does the same.
- Selection: The sentinel can be part of a selection range. Cut/delete removes the entire table reference. Copy includes a table reference marker in the clipboard.
- Typing: If the cursor is adjacent to the sentinel, new text goes into the text block before/after — never into the sentinel itself.
These semantics require Prose to have opaque-block awareness — the text model is still a single LoroText, but the reducer must treat sentinel ranges as atomic. This is new Prose behavior, not existing.
// BlockType extensionenum BlockType { // ...existing (Paragraph, Heading, ListItem, Image, etc.)... Table { table_id: String }, // marker — Dart looks up TableSnapshot by ID}DocumentSnapshot carries live table snapshots alongside the block list:
pub struct DocumentSnapshot { pub blocks: Vec<ProseBlock>, pub table_snapshots: HashMap<String, TableSnapshot>, // keyed by table_id // ...existing (sections, focused_section, chat, export, version)...}Dart iterates blocks in order. When it hits BlockType::Table { table_id }, it looks up table_snapshots[table_id] and renders the interactive TableWidget. All other blocks render as normal prose content.
Document ephemeral manages embedded Table reducers — same pattern as Whiteboard:
pub struct DocumentEphemeral { pub prose_editors: HashMap<String, Prose>, pub table_editors: HashMap<String, Table>, // keyed by table_id pub focused_block_id: Option<String>,}Document-level intents route to the table reducer by table_id:
pub enum DocumentIntent { // ...existing (FocusSection, Blur, SectionEdit, etc.)... TableEdit { table_id: String, intent: TableIntent },}Note on Document focus model: The current Document Modality RFC uses focused_section_id. This RFC introduces focused_block_id as a more general concept — table focus is a block-level concern, not nested within a section. Whether this means revising the Document focus model or nesting table focus within sections is a Document RFC concern.
Dart TableWidget
Section titled “Dart TableWidget”Replaces existing ProseTable. Renders from TableSnapshot — a complete frame — instead of parsing GFM pipes:
- Multi-line cells (each cell’s
Vec<ProseBlockView>as mini prose editors, row height adjusts) - Per-cell background, alignment
- Column resize handles →
SetColumnTrack - Cell focus + Tab/Shift+Tab navigation
- Header row styling (bold, sticky)
- Merge visualization (span grid areas)
- Add row/col buttons on hover (same UX as current)
Same widget used in both Whiteboard (positioned absolutely on canvas) and Document (inline in flow layout). The widget doesn’t care about its container — it receives a TableSnapshot and renders.
Table vs Layout
Section titled “Table vs Layout”Not the same thing.
| Table | Layout | |
|---|---|---|
| Children | Cells — uniform grid, each is a root LoroText | Components with property schemas |
| Semantics | Headers, sorting, merge, tabular data | Spatial arrangement, drag, lock/unlock |
| Content | Rich text per cell (inline editing) | Compiled component output |
Relationship to Document Modality RFC
Section titled “Relationship to Document Modality RFC”This RFC defines Table as a standalone component. The Document Modality RFC defines the composition layer. They’re independent — Table can be used in Whiteboard without Document Modality, and Document Modality can compose blocks without Table.
Table plugs into Document via BlockType::Table (same pattern as all non-text block types). The additions are additive:
BlockType::Table { table_id }variantDocumentSnapshot.table_snapshots: HashMap<String, TableSnapshot>for live table stateDocumentEphemeral.table_editors: HashMap<String, Table>(same pattern asprose_editors)DocumentIntent::TableEdit { table_id, intent: TableIntent }routing- Materialization writes a table sentinel into the LoroText (see Document integration section)
- Prose gains opaque-block awareness for sentinel editing semantics
Note: BlockType::Table does not currently exist in the codebase (crates/modality/prose/src/commands.rs). Neither does BlockType::Image. Adding non-text BlockType variants and the sentinel parser are new work that touches Prose’s parser/editor surface — not just adding an enum variant.
Migration
Section titled “Migration”Legacy GFM tables (inline BlockType::TableRow in LoroText) need migration to standalone Table components. This happens at the Document level during Document Modality adoption:
- On document load, detect legacy
BlockType::TableRowgroups in existing LoroTexts. - For each consecutive group: create Table CRDT containers (rows, columns, cells), populate cell root LoroTexts from pipe-delimited content.
- Replace the table text range with a table sentinel in the LoroText.
- Surrounding text stays in text blocks.
Legacy fallback: Dart checks BlockType::TableRow → old ProseTable (deprecated), BlockType::Table { table_id } → new TableWidget. Gradual rollout.
After migration window: delete table.rs from prose crate, remove BlockType::TableRow, delete old ProseTable dart widget.
Implementation Sequence
Section titled “Implementation Sequence”- Table crate —
crates/modality/table/, types + CRDT model + reducer + snapshot +init_from_loro/rebuild_from_loro - Layout engine (depends on Q1): either Dart-side layout or extract shared grid bridge from
autogrid/taffy_bridge.rs - Cell Prose editing (lazy per-cell,
CellEdit→ synchronous dispatch into Prose) - Whiteboard
TableComponentregistration (TypedComponent, NativeElement,table_idproperty) - Whiteboard integration —
table_editorsinWhiteboardEphemeral,TableEditintent routing - Dart
TableWidget(fromTableSnapshot) — column resize, cell focus, header styling, merge viz - FRB codegen (new types, NativeElement rendering pipeline)
- Document integration (requires Document Modality RFC work):
BlockType::Table,table_snapshotson snapshot,table_editorsin ephemeral,TableEditrouting - Prose opaque-block awareness — sentinel parsing, atomic editing semantics
- Migration upgrader (legacy GFM table detection → standalone Table extraction)
- Cleanup — delete legacy
table.rs,BlockType::TableRow,ProseTabledart widget - Tests — table CRUD, merge/unmerge, sort, tab nav, layout, concurrency (concurrent cell focus, undo across cell edits)
v1 Support Matrix (vs Google Docs)
Section titled “v1 Support Matrix (vs Google Docs)”Included in v1:
- Multi-line cells with full Prose rich text (bold, italic, code, math, links, color)
- Per-cell horizontal/vertical alignment
- Per-cell background color
- Column track modes (fixed, proportional, auto) with resize handles
- Merge/unmerge cells (rejected if covered cells non-empty)
- Row/column insert, delete, move
- Header row toggle (first row)
- Sort by column (header excluded, merge guard)
- Tab/Shift+Tab cell navigation
Deferred:
- Border model (per-edge color/width, border-collapse)
- Cell padding controls
- Row height controls (currently auto-only)
- Style inheritance (table → row → column → cell cascading defaults)
- Alternating row colors (zebra striping — needs reducer-level toggle)
- Clipboard fidelity (copy table as HTML/GFM/TSV)
- PDF export rendering for tables
- Frozen/pinned rows and columns
Unresolved
Section titled “Unresolved”-
Undo grouping: Table creation in Document produces multiple Loro mutations (create block entry + create table containers). Likely one undo group within a single
dispatch()cycle, but needs validation with Session Undo/Redo RFC. -
Cell vs table undo granularity: Cell edits are character-level (Prose), table structure changes are coarse (one step). How these interleave in the parent Session’s undo stack needs design.
-
Undo/reconcile lifecycle: When parent undo/redo changes row/column structure (e.g. undoing a row deletion restores a row), focused cell editors may become stale.
rebuild_from_loro()handles the in-memory model and drops stale editors, but the exact UX (restore focus? clear focus? flash feedback?) needs design. -
Sentinel format: Exact byte-level format of the materialization sentinel in LoroText is TBD. Needs to be parseable, not collide with user text, and support versioning if the reference format changes.
-
Shared grid bridge scope: Whether to extract a shared taffy grid bridge now (Table + AutoGrid) or just build Table’s adapter independently and refactor later when duplication hurts. AutoGrid’s
TaffyBridgeis heavily AutoGrid-shaped; the shared surface may be small. -
Export/clipboard for tables: How tables render in PDF export and what clipboard format to use when copying a table (HTML table? Markdown GFM? TSV?). Likely a follow-up RFC or implementation detail.