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;
  vertAlign?: "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).

Rich-text rendering needs both sharedStrings (provided to useSpreadsheetState) and getTextFormatRuns (forwarded to CanvasGrid). The runs live on the SharedStrings side, so without shared strings there's nowhere to read them from. See Shared strings.

Editing rich text

The default cell editor is a ProseMirror-based rich text editor. Standard shortcuts work out of the box:

Shortcut
Effect

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:

  1. Builds a TextFormatRun[] from the editor state

  2. Writes (or reuses) a rich entry in sharedStrings

  3. Sets cellData.ss to 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)

  • shrinkToFit

  • textRotation (any non-horizontal angle, including "vertical")

  • vertAlign set 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