grid structure

Row and column ordering, sizing, visibility, and merged cells.

Row and Column Ordering

Each sheet maintains two Y.Array structures for row and column ordering:

Sub-type Yjs Type Description
rowOrder Y.Array<RowId> Ordered list of row IDs (9-char base64url)
colOrder Y.Array<ColId> Ordered list of column IDs (5-char base64url)

The position of an ID in its array determines the display position. Row rowOrder[0] displays as row 1, rowOrder[1] as row 2, etc. Column colOrder[0] displays as column A, colOrder[1] as column B, etc.

Row and Column Operations

// Insert a new row at position 5
const newRowId = generateRowId()
rowOrder.insert(5, [newRowId])

// Delete row at position 3
rowOrder.delete(3, 1)
// Also clean up cell data:
rows.delete(rowOrder.get(3))

// Move a column from position 2 to position 5
const colId = colOrder.get(2)
colOrder.delete(2, 1)
colOrder.insert(5, [colId])
ID-based stability

Because rows and columns are identified by random IDs rather than sequential indices, concurrent insertions and deletions merge cleanly. Two users inserting rows at different positions will both succeed without conflict, and the resulting order is deterministic.

Row and Column Sizing

Custom dimensions override the application defaults:

Sub-type Yjs Type Description
rowHeights Y.Map<number> rowId → height in pixels
colWidths Y.Map<number> colId → width in pixels

Rows and columns not present in these maps use application default sizes. Only non-default sizes are stored.

// Set column width to 200px
colWidths.set(colId, 200)

// Get row height (with default fallback)
const height = rowHeights.get(rowId) ?? DEFAULT_ROW_HEIGHT

Hidden Rows and Columns

Sub-type Yjs Type Description
hiddenRows Y.Map<boolean> rowId → true for hidden rows
hiddenCols Y.Map<boolean> colId → true for hidden columns

Hidden rows and columns remain in the rowOrder/colOrder arrays (preserving their position) but are not rendered. Only rows/columns that are actually hidden have entries in these maps.

// Hide a row
hiddenRows.set(rowId, true)

// Unhide a row
hiddenRows.delete(rowId)

// Check if a column is hidden
const isHidden = hiddenCols.get(colId) === true

Merged Cells

Merged cells are stored in the per-sheet merges map:

Sub-type Yjs Type Key Format
merges Y.Map<MergeInfo> "${startRowId}_${startColId}"

MergeInfo Structure

interface MergeInfo {
    startRow: RowId    // Top-left row ID
    endRow: RowId      // Bottom-right row ID
    startCol: ColId    // Top-left column ID
    endCol: ColId      // Bottom-right column ID
}

The merge key uses the top-left cell’s composite key. The value stores all four corner IDs, which define the rectangular merge region.

Merge Operations

// Create a merge spanning rows 2-4, columns B-D
merges.set(`${rowId2}_${colIdB}`, {
    startRow: rowId2,
    endRow: rowId4,
    startCol: colIdB,
    endCol: colIdD
})

// Remove a merge
merges.delete(`${rowId2}_${colIdB}`)
Concurrent deletion and merges

When a row or column that participates in a merge is deleted concurrently, the merge must be validated. If the start row/column of a merge no longer exists in rowOrder/colOrder, the merge entry becomes orphaned and should be cleaned up. The application layer handles this validation when processing CRDT updates.

Cell Content in Merged Regions

Only the top-left cell of a merged region holds content. Other cells in the region should be empty. When a merge is created, content from non-top-left cells is discarded. When a merge is removed, only the top-left cell retains its content.

Cell Access Patterns

Common patterns for working with the grid:

// Iterate over all cells in a row
const rowMap = rows.get(rowId) as Y.Map<Cell>
if (rowMap) {
    for (const [colId, cell] of rowMap.entries()) {
        // Process cell
    }
}

// Get the display position of a cell
const rowIndex = rowOrder.toArray().indexOf(rowId)  // 0-based
const colIndex = colOrder.toArray().indexOf(colId)  // 0-based
const cellRef = `${indexToColumnLetter(colIndex)}${rowIndex + 1}`  // e.g. "B3"

// Find the ID for a display reference like "C5"
const colId = colOrder.get(2)   // C = index 2
const rowId = rowOrder.get(4)   // 5 = index 4 (0-based)