sheets

Multi-sheet support, sheet ordering, and sheet operations.

Sheet Definition

Each sheet is a self-contained Y.Map entry in the root sheets map, keyed by a SheetId (12-char base64url). The sheet’s name is stored as a Y.Text instance, enabling collaborative editing of the sheet name itself.

// Get sheet name
const sheet = sheets.get(sheetId) as Y.Map<unknown>
const name = sheet.get('name') as Y.Text
console.log(name.toString())  // "Sheet 1"

// Rename a sheet (collaborative)
name.delete(0, name.length)
name.insert(0, 'Revenue Data')
Why Y.Text for sheet names?

Using Y.Text instead of a plain string allows two users to concurrently edit a sheet name (e.g. both typing in the rename field). While rare, this prevents the last-writer-wins conflict that a plain string would cause.

Sheet Ordering

Sheet tab order is maintained by the root sheetOrder array:

const sheetOrder = yDoc.getArray('sheetOrder')  // Y.Array<SheetId>

The position of a SheetId in this array determines its tab position. The first entry is the leftmost tab, etc.

// Get the first sheet
const firstSheetId = sheetOrder.get(0) as string
const firstSheet = sheets.get(firstSheetId) as Y.Map<unknown>

// Iterate over all sheets in tab order
for (let i = 0; i < sheetOrder.length; i++) {
    const sheetId = sheetOrder.get(i) as string
    const sheet = sheets.get(sheetId) as Y.Map<unknown>
    const name = (sheet.get('name') as Y.Text).toString()
    console.log(`Tab ${i + 1}: ${name}`)
}

Sheet Independence

Each sheet is fully self-contained. All of the following are scoped to individual sheets and do not reference other sheets:

  • Row and column ordering (rowOrder, colOrder)
  • Cell data (rows)
  • Merged cells (merges)
  • Borders, hyperlinks, validations, conditional formats
  • Hidden rows/columns, sizing, frozen panes

This design means:

  • Deleting a sheet cleanly removes all associated data
  • Sheets can be synced independently (only observe active sheets)
  • No orphaned references when sheets are removed

Sheet Operations

Adding a Sheet

yDoc.transact(() => {
    const sheetId = generateSheetId()

    // Create sheet structure
    const sheet = new Y.Map()
    sheets.set(sheetId, sheet)

    // Set name
    const name = new Y.Text()
    name.insert(0, 'Sheet 2')
    sheet.set('name', name)

    // Initialize columns and rows
    const colOrder = new Y.Array()
    for (let i = 0; i < 26; i++) {
        colOrder.push([generateColId()])
    }
    sheet.set('colOrder', colOrder)

    const rowOrder = new Y.Array()
    for (let i = 0; i < 100; i++) {
        rowOrder.push([generateRowId()])
    }
    sheet.set('rowOrder', rowOrder)

    // Initialize empty sub-type maps
    sheet.set('rows', new Y.Map())
    sheet.set('merges', new Y.Map())
    sheet.set('borders', new Y.Map())
    sheet.set('hyperlinks', new Y.Map())
    sheet.set('validations', new Y.Map())
    sheet.set('conditionalFormats', new Y.Array())
    sheet.set('hiddenRows', new Y.Map())
    sheet.set('hiddenCols', new Y.Map())
    sheet.set('rowHeights', new Y.Map())
    sheet.set('colWidths', new Y.Map())
    sheet.set('frozen', new Y.Map())

    // Add to tab order (at the end)
    sheetOrder.push([sheetId])
})

Removing a Sheet

yDoc.transact(() => {
    // Remove from tab order
    const index = sheetOrder.toArray().indexOf(sheetId)
    if (index !== -1) {
        sheetOrder.delete(index, 1)
    }

    // Remove sheet data
    sheets.delete(sheetId)
})
Last sheet protection

The application layer should prevent deleting the last sheet. A Calcillo document must always have at least one sheet.

Reordering Sheets

yDoc.transact(() => {
    // Move sheet from position 2 to position 0 (make it first tab)
    const sheetId = sheetOrder.get(2) as string
    sheetOrder.delete(2, 1)
    sheetOrder.insert(0, [sheetId])
})

Duplicating a Sheet

Sheet duplication involves deep-copying all sub-type data into a new sheet with fresh IDs. The new sheet gets a new SheetId, but internal row and column IDs must also be regenerated to avoid ID collisions. Cell content and formatting are copied, but formulas referencing the original sheet’s cells are not automatically updated.

Active Sheet

The currently active (visible) sheet is not stored in the CRDT. Each user can view a different sheet independently. The active sheet is communicated through Yjs awareness:

awareness.setLocalStateField('activeSheet', sheetId)

This ensures that one user switching tabs does not affect other users’ views.