nigig-org/crates/apps/doc/doc-engine
arena-agent 08c3eed18d
Some checks failed
doc-engine / engine (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
feat(doc): copy and clear cell ranges through the clipboard
An armed table cell range now copies as tab/newline text (rows top
to bottom, cells left to right, the spreadsheet convention) through
the same copyable_selection_text the TextCopy/TextCut hits answer
with, so the long-press cell menu floats the full action set instead
of a paste-only one. Cut and Backspace/Delete clear every non-empty
spanned cell via the engine's new set_table_cells, which pops each
sub-write's compensation into one Compensation::Group — the span
un-clears in a single undo step where Backspace previously dropped
the range and edited only the caret cell. Single writes keep the
leaf compensation and empty write lists record nothing.

Grouping cell writes surfaced a real undo asymmetry: an undo after a
redo re-applied the redone cell text, because redo pushed the write's
own text as its undo compensation (inverse() only swaps the verb,
keeping the stale text) while undo's leaf special-case captured live
cell state only in one direction. undo()/redo() now resolve
cell-text inverses per compensation member against the projected
text at both transition boundaries (redo_compensation/
undo_compensation), so leaves and groups round-trip through
arbitrary cycles and the "cells stay out of groups" restriction is
gone.
2026-08-02 08:21:31 +00:00
..
src feat(doc): copy and clear cell ranges through the clipboard 2026-08-02 08:21:31 +00:00
tests feat(doc): copy and clear cell ranges through the clipboard 2026-08-02 08:21:31 +00:00
Cargo.toml Initial commit 2026-07-26 19:38:26 +03:00
README.md feat(doc): copy and clear cell ranges through the clipboard 2026-08-02 08:21:31 +00:00

doc-engine

Standalone CRDT document engine rewrite crate.

Add to workspace members:

"crates/doc-engine",

Add to nigig-build dependencies:

doc-engine = { path = "../../doc-engine" }

The next migration replaces legacy document controller ownership with doc_engine::DocumentController and materialized projections.

Projection invariants

  • The insert after chain is unified across blocks and advanced nodes: a block anchored after a node materializes in chain order. (order and blocks are derived from the same chain.)
  • Siblings sharing one anchor serialize by OpId ascending, independent of operation kind, so peer materializations converge deterministically.
  • SplitBlock opens a split session keyed by the parent block id and MergeBlocks folds the parent and its successor into the parent and closes any open session. Materialization keeps the parent's trailing runs in a synthetic child block spliced after the parent's real-chain descendants (none for a plain Return, so it sits directly behind the parent; the middle lines of a multi-block paste keep it last in the pasted range), so peers replaying the same op log converge on the same visible sequence without a new InsertBlock op. Undo/redo uses the symmetric Compensation::{SplitBlock, MergeBlocks} pair.
  • Synthetic split children are first-class text targets: atoms and style ops ADDRESSED TO a split op id splice into the transplanted suffix RGA-wise during materialization (suffix atoms re-link head-to-tail with their original ids, so the counter-descending sibling rule orders text typed into the child exactly like mid-word typing), and suffix atoms keep the style runs they inherited from the parent while child-addressed text takes the block default. A split child with nothing addressed to it materializes byte-identically to before.
  • Undo entries may group several leaf compensations (Compensation::Group): undo applies the members in reverse, redo applies the inverse group in authored order. A multi-line paste (insert_multiline_text) authors one block per line — line 0 spliced into the caret block, middle lines as sibling blocks inheriting the caret block's kind, and the split carrying the caret block's suffix onto the last line — folded into one group so the whole paste retracts in a single undo step. The caret anchor may be tombstoned (the caret a selection delete leaves behind): CrdtDocument::live_offset_of resolves it to the same live-text offset RGA splicing uses, so pasting over a deleted selection lands exactly where a single-line paste would. Cell writes group the same way: set_table_cells pops each sub-write's compensation into one group (a single write keeps the leaf), so clearing or range-filling N cells un-applies in one undo step.
  • Cell-text compensations capture the projected text at BOTH transition boundaries, per group member: undo's redo side (redo_compensation) re-applies the live text an undo is about to overwrite, and redo's undo side (undo_compensation) restores the live text a redo is about to overwrite. The compensation inverse() only swaps the verb and keeps the carried text, which is always the stale side of a cell write — captured live state is what makes cell edits round-trip through arbitrary undo/redo cycles, as leaves and inside groups.
  • Table rows and columns order by their after anchor chain: inserting below an existing row/col stays right after it even when peers appended further rows elsewhere. Siblings sharing one anchor serialize RGA-style (counter descending, then actor ascending), exactly like text atoms, so a newer "insert below X" renders before older rows anchored on X.
  • Text atom sibling order compares raw per-actor counters (counter descending, then actor ascending) rather than wall-clock time, so a second actor inserting into someone else's mid-run anchors deterministically but not chronologically. Production runs a single "local" actor per editing surface, where counter order is chronological order and this artifact cannot appear; either way every peer materializes the same order.
  • Cell text is last-writer-wins by op id: one actor per editing surface keeps chronological edits winning locally, and peer edits converge deterministically to the highest OpId.
  • Tombstone pairs — text atoms, blocks, table rows/columns, merge splits, cell style clears and block range cancels — resolve chronologically per target in operation order: a restore clears exactly the tombstones set before it, so delete/undo/redo cycles re-delete instead of sticking live. delete_text pushes the symmetric RestoreText compensation, which is what makes selection deletions (cut, Backspace-over-selection, type-over) undoable exactly like inserts.
  • ReplaceBlockRange only rewrites the projection: the drained middle blocks and every atom beneath the span stay in the op log untouched. Its compensation therefore tombstones the range op itself (CancelBlockRange/RestoreBlockRange, mirroring the merge split/restore pair), so undo of a cross-block delete or cut is lossless — splices, blocks and text re-materialize exactly — and one undo step covers the whole span. Spans whose endpoints do not resolve against the projection in order are refused before the op records, so no-op ranges never strand entries on the undo stack.
  • Cell style runs (SetTableCellStyle/ClearTableCellStyle/ RestoreTableCellStyle) are char-offset snapshots applied against the cell's current LWW text: an authored span clamps when the text shrinks but does not stretch when it grows, and ops replay in operation order so newer patches win on overlap. Undo tombstones the style op exactly like a merge split, which leaves overlapping later styles intact and keeps undo/redo convergent across peers. Because ranges follow offsets rather than anchored atoms, styles visually keep their position through a whole-cell text replacement.
  • MergeTableCells projects a span (stable start/end row+column ids); SplitTableCells hides one merge by tombstoning its op id and RestoreTableMerge clears the tombstone, so undo/redo of either side converges. Merges never delete cell text: covered cells keep their stored value in the projection and only stop rendering, which is what makes split lossless (the hidden text reappears). Overlapping merges are an authoring-layer concern — the engine applies them as written, so editors validate ranges before emitting the op.