Review and share: comments, protection, two people
A sheet that leaves one person's hands: a note on a cell and a thread of replies under it, cells that refuse a change while the sheet is protected, and a second person editing the same document at the same time with their cursor in view. Getting started shows the document these live in; Files how one person's document is saved.
The examples share an expense claim sheet.
<script lang="ts">
import { SvSheet, createWorkbook, createSheetDocument, createDeltaStream, newEditRangeId, type SheetPresence } from '@svgrid/enterprise'
const claims: string[][] = [
['Claim', 'Who', 'Category', 'Amount', 'Receipt', 'Status', 'Reviewer notes'],
['EXP-2041', 'Ben', 'Travel', '412.50', 'Yes', 'Submitted', ''],
['EXP-2042', 'Dev', 'Meals', '210.00', 'No', 'On hold', ''],
['EXP-2043', 'Ana', 'Software', '89.00', 'Yes', 'Approved', ''],
['EXP-2044', 'Ben', 'Travel', '412.50', 'Yes', 'Submitted', ''],
['', '', '', '', '', '', ''],
['Approved so far', '', '', '=SUMIF(F2:F5,"Approved",D2:D5)', '', '', ''],
]
const at = { rowIdAt: (i: number) => `r${i}`, columnIdAt: (i: number) => String.fromCharCode(65 + i) }
function claimsDoc() {
const d = createSheetDocument({ workbook: createWorkbook([{ name: 'Claims', cells: claims }]) })
const s = d.get('Claims')
s.formats.set([[0, 0, 0, 6]], { bold: true, fill: '#e2e8f0', color: '#0f172a' }, at)
s.formats.set([[1, 3, 6, 3]], { numFmt: '$#,##0.00' }, at)
s.formats.set([[6, 0, 6, 3]], { bold: true }, at)
s.widths.A = 100
s.widths.G = 180
s.freeze = { rows: 1, cols: 0 }
return d
}
</script>
Notes and threads
A comment is text on a cell, marked by a red corner and read by hovering
the cell. Review > New Comment, Shift+F2 or the cell menu opens the
box beside the active cell; Ctrl+Enter or Save closes it, as does a
click elsewhere, keeping what was typed. On a cell that has one, the box
is the thread: the first entry with Edit and Delete, the replies with
theirs, a reply box, and Resolve, which greys the thread and hides the
reply box until Reopen. Previous and Next walk the sheet's comments, and
Show All Comments opens a strip under the formula bar listing every one
with its author, each a jump to its cell.
A document carries comments in notes, keyed by row and column: a
string is a plain note; an object with text, author, at, replies
and resolved is a thread.
<script lang="ts">
const doc = claimsDoc()
doc.get('Claims').notes = {
r2: { D: {
text: 'Three days of meals at 70 a day: over the 60 limit. Needs a sign-off before it goes through.',
author: 'Finance review', at: '2026-03-02T09:10:00.000Z',
replies: [{ text: 'Signed off by M. Chen this morning, see the ticket.', author: 'Ben Okafor', at: '2026-03-03T08:05:00.000Z' }],
} },
r4: { A: 'Same fare and dates as EXP-2041 on row 2. Check it is not the same ticket claimed twice.' },
}
</script>
<SvSheet document={doc} rows={9} columns={8} commentAuthor="Finance review" />
commentAuthor is who signs: with it set, a new comment and every reply
carry that name and the time, the way Excel signs a threaded comment
with the signed-in user; without it a new comment is a plain note.
Comments move with an insert or a delete, ride in getState() as
comments, report { kind: 'comments' } on onChange, and go into the
.xlsx as Excel's legacy note or its threaded comment with the persons
part, so Excel shows the thread with its authors.
A protected sheet
Excel's model, without the password. Every cell is locked to begin with; Format Cells > Protection (or Home > Format > Lock Cell) unlocks the ones that may change; Review > Protect Sheet turns the flags on. From then on a locked cell refuses a change wherever it comes from: typing, Delete, the formula bar, paste, a fill, Find and Replace, a sort of the region, and the formats. The refusal is a sentence in the status bar, Excel's own. Unlocked cells take every edit as before.
From code, locked: false in a cell's format entry unlocks it, and
protected = true on the sheet protects it:
<script lang="ts">
const doc = claimsDoc()
const sheet = doc.get('Claims')
// The reviewer may set a Status and a Receipt; nothing else changes.
sheet.formats.set([[1, 4, 4, 5]], { locked: false, fill: '#fffbeb' }, at)
sheet.validation = [
{ id: 'status', rects: [[1, 5, 4, 5]], allow: 'list', value1: 'Submitted,Approved,Rejected,On hold', ignoreBlank: true, inCellDropdown: true, alert: { style: 'stop' } },
{ id: 'receipt', rects: [[1, 4, 4, 4]], allow: 'list', value1: 'Yes,No', ignoreBlank: true, inCellDropdown: true, alert: { style: 'stop' } },
]
sheet.protected = true
</script>
<SvSheet document={doc} rows={9} columns={8} />
Try to type over an Amount: refused, with the reason in the status bar.
Pick a Status from the arrow: fine. Review > Unprotect Sheet is one
click, and one Ctrl+Z protects again; there is no password, on the
sheet or on a range, because the protection is against mistakes, not
against the person at the keyboard.
Protect Sheet opens Excel's dialog with its "allow all users of this
worksheet to" list, and Review > Allow Edit Ranges is Excel's Allow
Users to Edit Ranges: titled blocks that take an edit on a protected
sheet whether their cells are locked or not. Both are protection on
the sheet, allow with the permissions ticked (formatCells,
formatColumns, formatRows, insertColumns, insertRows,
deleteColumns, deleteRows, sort, autoFilter) and ranges with
the blocks:
<script lang="ts">
const doc = claimsDoc()
const sheet = doc.get('Claims')
sheet.protection = {
allow: { formatRows: true, sort: true, autoFilter: true },
ranges: [{ id: newEditRangeId(), title: 'Reviewer notes', rects: [[1, 6, 4, 6]] }],
}
sheet.protected = true
</script>
<SvSheet document={doc} rows={9} columns={8} />
The notes column takes typing though every cell in it is locked; Row
Height and Hide work on rows; a sort of a block with no locked cell
goes through. The flag rides in getState() as protected, the list
and the ranges as protection, the unlocked cells as locked: false in
their entries; onChange reports { kind: 'protection' }; Save As
writes them as the file's sheetProtection and protectedRanges, and
Open reads Excel's. A command of your own asks cmd.canEdit(row, col)
before it writes and gets the same answer the editor does.
Open the live example: Cell comments + Protect Sheet (Spreadsheet)
Two people on one sheet
createDeltaStream turns a document's changes into small messages and
applies the ones that arrive from somewhere else. Put a socket between
two of them and the sheet is collaborative:
<script lang="ts">
import { createSheetDocument, createDeltaStream } from '@svgrid/enterprise'
const doc = createSheetDocument({ sheets })
const socket = new WebSocket(url)
const stream = createDeltaStream(doc, {
onDelta: (delta) => socket.send(JSON.stringify(delta)),
})
socket.onmessage = (event) => stream.apply(JSON.parse(event.data))
// A new participant asks for everything once.
socket.onopen = () => stream.resync()
</script>
<SvSheet document={doc} height="100%" />
Five kinds of delta go out, each as small as it can be. cells carries
the raw text of the cells written, not their values: a formula travels
as =SUM(B2:B4) and the other side works out its own answer, so the two
cannot disagree about a number. structure carries an insert or a
delete itself, so both sides rewrite their formulas identically. state
carries the one part of one sheet that changed, the way getState
serialises it: the formats, a rule, the objects. document carries the
whole state, for what cannot be described piecemeal: a sheet added or
removed, a restore, the workbook's names, tables and calculation
settings; resync() sends the same kind. The shell follows its
document, so a delta applied from outside repaints the sheet.
There is no merge and no version number. When two people type into
the same cell at the same moment, the message that arrives second is
the one both of them end up seeing; when they type into different
cells, which is nearly always, nothing collides. A cells or state
delta can safely arrive twice; a structure delta cannot, since a
second insert inserts again, so a transport that may redeliver has to
drop duplicates on the way in.
Here the wire is two callbacks handing each delta straight to the other stream, which is what a socket does with a network in between:
<script lang="ts">
const left = claimsDoc()
const right = createSheetDocument({ state: left.getState() })
let inLeft = $state<SheetPresence[]>([])
let inRight = $state<SheetPresence[]>([])
let wire = $state<string[]>([])
const describe = (d: { kind: string }) => d.kind
const a = createDeltaStream(left, {
onDelta: (delta) => { wire = [`A -> B: ${describe(delta)}`, ...wire].slice(0, 6); b.apply(delta) },
onPresence: (who) => { inLeft = [who] },
})
const b = createDeltaStream(right, {
onDelta: (delta) => { wire = [`B -> A: ${describe(delta)}`, ...wire].slice(0, 6); a.apply(delta) },
onPresence: (who) => { inRight = [who] },
})
$effect(() => () => { a.stop(); b.stop() })
</script>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px">
<SvSheet document={left} height={300} rows={9} columns={7} showRibbon={false} presence={inLeft}
onPresence={(me) => a.sendPresence({ id: 'ada', name: 'Ada', colour: '#2563eb', ...me })} />
<SvSheet document={right} height={300} rows={9} columns={7} showRibbon={false} presence={inRight}
onPresence={(me) => b.sendPresence({ id: 'grace', name: 'Grace', colour: '#15803d', ...me })} />
</div>
<p style="font-size: 12px; font-family: monospace">{wire.join(' ยท ') || 'Type in either sheet.'}</p>
Type in the left sheet and the right follows; the line under them is what crossed the wire. Click a cell in one and the other shows a coloured box with a name on it: that is presence.
Presence: where everyone is
A delta says what someone typed; it says nothing about where they are,
and seeing where a colleague is is what keeps two people out of one
cell. That is the presence prop: the list of everyone else on the
workbook, each drawn on the active sheet as a coloured outline around
their selection with their name at the cursor. Your own position goes
out through onPresence, which fires on every move of the selection
with the sheet, the rectangle and the active cell; sendPresence puts
it on the same wire as the deltas, and on the far side it lands in the
stream's onPresence, never in the document.
That separation is deliberate. A cursor is a fact about a session, so
it is not in getState() and not in the file. A peer with no colour
is given a steady one from their id; one that has said nothing for
fifteen seconds is dropped, because a closed tab sends no goodbye. Every
arrow key is a new position, so throttle onPresence where the
transport bills per message.
See also
- Files -
getState, autosave, and why one person's autosave is the wrong thing for two. - Data validation - the dropdowns the reviewer picks from on a protected sheet.
- Real-time collaboration on the grid - the same idea over a grid's rows.
- The spreadsheet shell - the Protect Sheet dialog line by line, and the delta kinds in full.
Live examples
- Cell comments + Protect Sheet - An expense sheet under review on the Excel shell. It opens protected: every cell is locked except the reviewer's Receipt and Status columns, so a keystroke on an amount is refused and the status bar says why. Three reviewer notes sit on the cells they are about (Excel's red corner): hover, Shift+F2, Review > Next, Show All Comments. Status is a list, the totals by status are SUMIFs, and protection, notes and rules all ride in the document.
- Two people on one sheet: the delta stream - Two full spreadsheets over two separate documents, wired to each other by createDeltaStream: type in either and the other follows. What crosses the wire is a delta rather than the document, and the log shows each one as it goes - a formula travels as its text so the other side works out its own answer, an insert travels as the edit so both rewrite their own formulas, a format travels as the one part of the one sheet that changed. Presence rides the same wire and is never part of the document: each window draws the other's selection as a coloured box with a name on it. Conflicts are last writer wins, per cell. A real app puts a socket in the middle.
Related articles
- Sync Grid State to the URL in Svelte - Keep sort, filter, and page state in the URL query string so every grid view is bookmarkable and shareable - with real SvelteKit code.