Rich text formatting
Per-segment formatting inside a single cell — bold, italic, underline, strikethrough, color, font family, font size — plus `@mention` chips.
Spreadsheet supports per-segment formatting inside a single cell. A run of bold inside an otherwise plain string, a colored phrase in the middle of a sentence, or @mention chips inline with text all round-trip through XLSX and render directly on the canvas.
Storage model
Per-segment formatting is not stored on CellData. Cells reference a shared-strings entry via ss, and rich entries on the SharedStrings side carry { text, runs }. This mirrors XLSX <si><r> semantics — one canonical location per rich string, no per-cell duplication.
// libs/spreadsheet-state/types.ts
export type RichSharedString = {
text: string;
runs: TextFormatRun[];
};
export type SharedStringValue = string | RichSharedString;
export type SharedStrings = Map<string, SharedStringValue>;Two cells with the same rich text + same runs share a single SharedStrings slot. Two cells with the same text but different runs get separate slots (matches Excel — runs are part of the dedup key).
TextFormatRun
A run is a [startIndex, endIndex) slice over the cell's text plus either a TextFormat (text run) or a Mention (mention chip).
// libs/common-types/index.ts
export type TextFormatRun<
Format extends TextFormat = TextFormat,
M extends Mention = Mention,
> =
| {
startIndex: number;
endIndex: number;
format: Format;
nodeType: "text";
}
| {
startIndex: number;
endIndex: number;
nodeType: "mention";
mention: M;
};
export type TextFormat = {
color?: Color | string;
fontFamily?: string;
fontSize?: number;
bold?: boolean;
italic?: boolean;
strikethrough?: boolean;
underline?: boolean;
baselineOffset?: "superscript" | "subscript";
};A cell with "Hello world" where "world" is bold red would carry:
Rendering rich text
The canvas grid takes a getTextFormatRuns prop. When you don't pass it, cells with rich runs render as plain text — the formatting still exists in the data, but the renderer can't see it.
getTextFormatRuns(sheetId, rowIndex, columnIndex) resolves the cell's ss key against the SharedStrings map and returns the rich runs (or undefined for plain cells).
Editing rich text
The default cell editor is a ProseMirror-based rich text editor. Standard shortcuts work out of the box:
Cmd/Ctrl + B
Bold the selection
Cmd/Ctrl + I
Italic the selection
Cmd/Ctrl + U
Underline the selection
Cmd/Ctrl + Shift + X
Strikethrough the selection
The toolbar's font color, font size, font family, and decoration buttons also write into the selected range when a cell is open for editing.
When the user commits the edit, the spreadsheet:
Builds a
TextFormatRun[]from the editor stateWrites (or reuses) a rich entry in
sharedStringsSets
cellData.ssto point at the slot
onChange receives the new value and the runs:
previousTextFormatRuns lets a host detect a pure-formatting edit (value unchanged, runs differ — for example Cmd+B on existing text) and persist it as a dirty change. useSpreadsheetState already handles this.
XLSX round-trip
Rich text round-trips through XLSX with no extra wiring. On import, <si><r><rPr/>...<t/></r></si> blocks parse into RichSharedString entries; on export, rich shared strings emit the corresponding <si><r> markup.
Supported run properties on the XLSX path:
<b/>— bold<i/>— italic<u/>— underline<strike/>— strikethrough<sz val=N/>— font size<rFont val="…"/>— font family<color rgb="…"/>— ARGB color<color theme="…" tint="…"/>— theme color
Caveats
The canvas grid renders runs only when the cell isn't using one of the modes that fundamentally changes how text is painted. Cells in any of these modes fall back to flat-text rendering:
Chip cells (data validation chips, structured chips)
shrinkToFittextRotation(any non-horizontal angle, including"vertical")baselineOffsetset on the cell-level format (superscript / subscript at the run level is fine)
Mention runs (nodeType: "mention") render as inline chips. See Mentions for the autocomplete plumbing.
Last updated