diff --git a/help/clipboard.md b/help/clipboard.md
index a1479e2..78fddaf 100644
--- a/help/clipboard.md
+++ b/help/clipboard.md
@@ -19,11 +19,21 @@ After a cut, the source range gets a **marching-ants** dashed border that animat
When you only want part of what's on the clipboard, use **Edit → Paste special**:
-- **Values only** (⌘⇧V) — paste just the computed values; formulas and formatting are dropped.
-- **Formulas only** (⌘⌥V) — paste the formula text; formatting is dropped.
-- **Format only** (⌘⌥⇧V) — paste fonts, fills, borders, and number formats; cell values are untouched.
+- **Values only** (⌘⌥V) — paste just the computed values; formulas and formatting are dropped.
+- **Formulas only** (⌘⌥⇧V) — paste the formula text; formatting is dropped.
+- **Format only** (⌘⇧V) — paste fonts, fills, borders, and number formats; cell values are untouched.
- **Transposed** (⌘⌥T) — swap rows and columns. A 3-row × 2-column range becomes 2-row × 3-column.
+## Format Painter toolbar button
+
+The **Format Painter** button in the toolbar (paintbrush icon) copies the formatting of the selected cell and lets you apply it to another range without affecting values.
+
+1. Select the cell whose formatting you want to copy.
+2. Click the paintbrush button — the cursor changes to a crosshair-paintbrush to indicate paint mode.
+3. Click a cell or drag across a range to stamp the formatting onto it. Paint mode exits automatically after one application.
+
+This is equivalent to **Paste special → Format only**, but without going through the clipboard — useful when you want to reuse formatting from a cell without overwriting what is currently on the clipboard.
+
## Pasting from outside Calc
Calc accepts TSV (tab-separated) and HTML clipboard content from other apps — spreadsheets, web tables, plain text files. Each tab becomes a column break and each newline becomes a row break.
diff --git a/help/keyboard-shortcuts.md b/help/keyboard-shortcuts.md
index d54b739..d9ae930 100644
--- a/help/keyboard-shortcuts.md
+++ b/help/keyboard-shortcuts.md
@@ -16,9 +16,9 @@ The reference below is the complete set.
| ⌘C | Copy |
| ⌘X | Cut |
| ⌘V | Paste |
-| ⌘⇧V | Paste values only |
-| ⌘⌥V | Paste formulas only |
-| ⌘⌥⇧V | Paste format only |
+| ⌘⌥V | Paste values only |
+| ⌘⌥⇧V | Paste formulas only |
+| ⌘⇧V | Paste format only |
| ⌘⌥T | Paste transposed |
| Esc | Cancel a pending cut |
diff --git a/tests/clipboard-decode-html.test.ts b/tests/clipboard-decode-html.test.ts
index 8c2a8d0..f3a7199 100644
--- a/tests/clipboard-decode-html.test.ts
+++ b/tests/clipboard-decode-html.test.ts
@@ -94,6 +94,22 @@ describe('htmlToPayload — own-encoder round trip', () => {
})
})
+ it('recovers numFmt and colors so Paste → Format only carries number format', () => {
+ const source = p([
+ [
+ {
+ kind: 'number',
+ raw: 1234.5,
+ style: { numFmt: '#,##0.00', font: { color: '#ff0000' } },
+ },
+ ],
+ ])
+ const html = payloadToHtml(source, 'm')
+ const style = htmlToPayload(html)?.payload.cells[0][0].style
+ expect(style?.numFmt).toBe('#,##0.00')
+ expect(style?.font?.color).toBe('#FF0000')
+ })
+
it('recovers cells containing HTML special chars', () => {
const source = p([[{ kind: 'string', raw: '&"' }]])
const html = payloadToHtml(source, 'm')
@@ -154,13 +170,13 @@ describe('htmlToPayload — foreign producer fixtures', () => {
it('parses background-color from inline style', () => {
const html = '
x
'
const out = htmlToPayload(html)
- expect(out?.payload.cells[0][0].style?.fill?.fgColor).toBe('FFCC00')
+ expect(out?.payload.cells[0][0].style?.fill?.fgColor).toBe('#FFCC00')
})
it('converts rgb() to hex on the way in', () => {
const html = '
x
'
const out = htmlToPayload(html)
- expect(out?.payload.cells[0][0].style?.font?.color).toBe('FF0000')
+ expect(out?.payload.cells[0][0].style?.font?.color).toBe('#FF0000')
})
it('pads short rows to make the grid rectangular', () => {
diff --git a/tests/clipboard-disjoint-refusal.test.ts b/tests/clipboard-disjoint-refusal.test.ts
index bbdcbc8..6985d47 100644
--- a/tests/clipboard-disjoint-refusal.test.ts
+++ b/tests/clipboard-disjoint-refusal.test.ts
@@ -14,6 +14,7 @@ function makeStubDeps(): GridStoreDeps {
return {
readOnly: false,
writeCell: () => {},
+ clearCellContent: () => {},
focusActiveInput: () => {},
focusSentinel: () => {},
scrollToCell: () => {},
diff --git a/tests/freeze-panes.test.ts b/tests/freeze-panes.test.ts
index 16520f9..a0b30fa 100644
--- a/tests/freeze-panes.test.ts
+++ b/tests/freeze-panes.test.ts
@@ -174,6 +174,7 @@ describe('grid-store freeze actions delegate through deps', () => {
const store = createGridStore({
readOnly: false,
writeCell: () => {},
+ clearCellContent: () => {},
focusActiveInput: () => {},
focusSentinel: () => {},
scrollToCell: () => {},
@@ -201,6 +202,7 @@ describe('grid-store freeze actions delegate through deps', () => {
const store = createGridStore({
readOnly: true,
writeCell: () => {},
+ clearCellContent: () => {},
focusActiveInput: () => {},
focusSentinel: () => {},
scrollToCell: () => {},
diff --git a/tests/grid-store-disjoint.test.ts b/tests/grid-store-disjoint.test.ts
index 4ebb2f9..f76157f 100644
--- a/tests/grid-store-disjoint.test.ts
+++ b/tests/grid-store-disjoint.test.ts
@@ -21,6 +21,7 @@ function makeStubDeps(opts: { readOnly?: boolean } = {}): GridStoreDeps {
return {
readOnly: opts.readOnly ?? false,
writeCell: () => {},
+ clearCellContent: () => {},
focusActiveInput: () => {},
focusSentinel: () => {},
scrollToCell: () => {},
@@ -269,21 +270,20 @@ describe('mergeSelection — disjoint refusal', () => {
describe('clearSelection on disjoint', () => {
it('clears every cell in every sub-range', () => {
- const writes: Array<{ row: number; col: number; value: string }> = []
+ const cleared: Array<{ row: number; col: number }> = []
const deps = {
...makeStubDeps(),
- writeCell: (row: number, col: number, value: string) =>
- writes.push({ row, col, value }),
+ clearCellContent: (row: number, col: number) => cleared.push({ row, col }),
}
const store = createGridStore(deps)
store.getState().selectCell({ row: 1, col: 1 })
store.getState().extendActiveRangeTo({ row: 1, col: 2 })
store.getState().addSubRange({ row: 5, col: 5 })
store.getState().clearSelection()
- expect(writes).toEqual([
- { row: 1, col: 1, value: '' },
- { row: 1, col: 2, value: '' },
- { row: 5, col: 5, value: '' },
+ expect(cleared).toEqual([
+ { row: 1, col: 1 },
+ { row: 1, col: 2 },
+ { row: 5, col: 5 },
])
})
})
diff --git a/tests/grid-store-scope.test.ts b/tests/grid-store-scope.test.ts
index efa25e2..1749213 100644
--- a/tests/grid-store-scope.test.ts
+++ b/tests/grid-store-scope.test.ts
@@ -77,6 +77,40 @@ describe('selectColumn', () => {
})
})
+describe('selectAll (corner-cell click)', () => {
+ it('sets scope to sheet, anchor to (1, 1), and a full-grid range', () => {
+ const store = createGridStore(makeStubDeps())
+ store.getState().selectAll(10, 26)
+ const s = store.getState()
+ expect(overallScope(s.selection)).toBe('sheet')
+ expect(primaryAnchor(s.selection)).toEqual({ row: 1, col: 1 })
+ expect(primaryRange(s.selection)).toEqual({
+ startRow: 1,
+ endRow: 10,
+ startCol: 1,
+ endCol: 26,
+ })
+ })
+
+ it('clamps an empty grid to a 1×1 range', () => {
+ const store = createGridStore(makeStubDeps())
+ store.getState().selectAll(0, 0)
+ expect(primaryRange(store.getState().selection)).toEqual({
+ startRow: 1,
+ endRow: 1,
+ startCol: 1,
+ endCol: 1,
+ })
+ })
+
+ it('clears any in-flight edit session', () => {
+ const store = createGridStore(makeStubDeps())
+ store.getState().editCell({ row: 1, col: 1 })
+ store.getState().selectAll(5, 5)
+ expect(store.getState().editSession).toBeNull()
+ })
+})
+
describe('overallScope reset by body interactions', () => {
it('selectCell resets scope from row back to cells', () => {
const store = createGridStore(makeStubDeps())
diff --git a/tests/grid-store.test.ts b/tests/grid-store.test.ts
index a8b341c..acc444e 100644
--- a/tests/grid-store.test.ts
+++ b/tests/grid-store.test.ts
@@ -31,6 +31,7 @@ function makeStubDeps(opts: { readOnly?: boolean } = {}): StubDeps {
deps: {
readOnly: opts.readOnly ?? false,
writeCell: (row, col, value) => writeCalls.push({ row, col, value }),
+ clearCellContent: (row, col) => writeCalls.push({ row, col, value: '' }),
focusActiveInput: () => {
focusCalls += 1
},
diff --git a/tests/merge-selection-snap.test.ts b/tests/merge-selection-snap.test.ts
index b14a346..c40f959 100644
--- a/tests/merge-selection-snap.test.ts
+++ b/tests/merge-selection-snap.test.ts
@@ -31,6 +31,7 @@ function makeDepsWithMerges(merges: FakeMerge[]): GridStoreDeps {
return {
readOnly: false,
writeCell: () => {},
+ clearCellContent: () => {},
focusActiveInput: () => {},
focusSentinel: () => {},
scrollToCell: () => {},
diff --git a/tests/range-style-helpers.test.ts b/tests/range-style-helpers.test.ts
index 9c2cdfa..b011324 100644
--- a/tests/range-style-helpers.test.ts
+++ b/tests/range-style-helpers.test.ts
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest'
import * as Y from 'yjs'
import {
+ applyFormatPainterStyles,
+ applyFormatPainterToDest,
applyStyleToRange,
toggleCellFontAttrInRange,
} from '../tinycld/calc/components/grid/style-helpers'
@@ -97,6 +99,85 @@ describe('applyStyleToRange', () => {
})
})
+describe('applyFormatPainterStyles (modulo tiling)', () => {
+ // numFmt strings double as position markers so we can assert exactly
+ // which source cell each destination cell was painted from.
+ const SRC = [
+ [{ numFmt: 'A' }, { numFmt: 'B' }],
+ [{ numFmt: 'C' }, { numFmt: 'D' }],
+ ]
+
+ it('tiles a 2×2 source over a larger range with row-major modulo wrap', () => {
+ const doc = new Y.Doc()
+ applyFormatPainterStyles(doc, SHEET, SRC, {
+ startRow: 1,
+ startCol: 1,
+ endRow: 4,
+ endCol: 4,
+ })
+ // Top-left maps 1:1; the rest wraps every 2 rows / 2 cols.
+ expect(readNumFmt(doc, 1, 1)).toBe('A')
+ expect(readNumFmt(doc, 1, 2)).toBe('B')
+ expect(readNumFmt(doc, 2, 1)).toBe('C')
+ expect(readNumFmt(doc, 2, 2)).toBe('D')
+ expect(readNumFmt(doc, 3, 3)).toBe('A') // (2%2, 2%2)
+ expect(readNumFmt(doc, 4, 4)).toBe('D') // (3%2, 3%2)
+ expect(readNumFmt(doc, 4, 1)).toBe('C') // (3%2, 0)
+ })
+
+ it('is a no-op when the source has no cells', () => {
+ const doc = new Y.Doc()
+ const cellsMap = doc.getMap>(CELLS_MAP)
+ applyFormatPainterStyles(doc, SHEET, [], {
+ startRow: 1,
+ startCol: 1,
+ endRow: 2,
+ endCol: 2,
+ })
+ expect(cellsMap.size).toBe(0)
+ })
+})
+
+describe('applyFormatPainterToDest (single-cell expansion)', () => {
+ const SRC = [
+ [{ numFmt: 'A' }, { numFmt: 'B' }],
+ [{ numFmt: 'C' }, { numFmt: 'D' }],
+ ]
+
+ it('expands a single-cell target to the full source dimensions', () => {
+ const doc = new Y.Doc()
+ applyFormatPainterToDest(doc, SHEET, SRC, {
+ startRow: 3,
+ startCol: 3,
+ endRow: 3,
+ endCol: 3,
+ })
+ expect(readNumFmt(doc, 3, 3)).toBe('A')
+ expect(readNumFmt(doc, 3, 4)).toBe('B')
+ expect(readNumFmt(doc, 4, 3)).toBe('C')
+ expect(readNumFmt(doc, 4, 4)).toBe('D')
+ // Nothing painted beyond the expanded block.
+ expect(readNumFmt(doc, 3, 5)).toBeUndefined()
+ expect(readNumFmt(doc, 5, 3)).toBeUndefined()
+ })
+
+ it('tiles a multi-cell target as-is without expanding past it', () => {
+ const doc = new Y.Doc()
+ // One-row destination must stay one row even though the source
+ // has two — the painter only auto-grows single-cell targets.
+ applyFormatPainterToDest(doc, SHEET, SRC, {
+ startRow: 1,
+ startCol: 1,
+ endRow: 1,
+ endCol: 3,
+ })
+ expect(readNumFmt(doc, 1, 1)).toBe('A')
+ expect(readNumFmt(doc, 1, 2)).toBe('B')
+ expect(readNumFmt(doc, 1, 3)).toBe('A') // col wraps
+ expect(readNumFmt(doc, 2, 1)).toBeUndefined()
+ })
+})
+
describe('toggleCellFontAttrInRange (mixed-toggle semantics)', () => {
it('promotes the whole range to bold when any cell is currently un-bold', () => {
const doc = new Y.Doc()
diff --git a/tests/use-y-cell.test.ts b/tests/use-y-cell.test.ts
index 9130536..bbe4ed9 100644
--- a/tests/use-y-cell.test.ts
+++ b/tests/use-y-cell.test.ts
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest'
import * as Y from 'yjs'
-import { setYCell, setYCellStyle, setYCellTyped } from '../tinycld/calc/hooks/use-y-cell'
+import {
+ clearYCellContent,
+ setYCell,
+ setYCellStyle,
+ setYCellTyped,
+} from '../tinycld/calc/hooks/use-y-cell'
import { yCellKey } from '../tinycld/calc/lib/y-cell-key'
import { CELLS_MAP, readYCell, STYLE_KEY } from '../tinycld/calc/lib/y-doc-bootstrap'
@@ -247,6 +252,43 @@ describe('setYCellStyle', () => {
})
})
+describe('clearYCellContent (Delete key — clear content, keep format)', () => {
+ it('wipes value fields but preserves the style Y.Map', () => {
+ const doc = new Y.Doc()
+ setYCell(doc, 'sheet1', 1, 1, 'hello')
+ setYCellStyle(doc, 'sheet1', 1, 1, { font: { bold: true } })
+
+ clearYCellContent(doc, 'sheet1', 1, 1)
+
+ const cellsMap = doc.getMap>(CELLS_MAP)
+ const cell = cellsMap.get(yCellKey('sheet1', 1, 1))
+ expect(cell).toBeDefined()
+ expect(cell?.has('kind')).toBe(false)
+ expect(cell?.has('raw')).toBe(false)
+ expect(cell?.has('display')).toBe(false)
+ expect(cell?.has('formula')).toBe(false)
+ const style = cell?.get(STYLE_KEY) as Y.Map | undefined
+ const font = style?.get('font') as Y.Map | undefined
+ expect(font?.get('bold')).toBe(true)
+ })
+
+ it('removes the whole entry when the cell has no style', () => {
+ const doc = new Y.Doc()
+ setYCell(doc, 'sheet1', 2, 2, 'plain')
+
+ clearYCellContent(doc, 'sheet1', 2, 2)
+
+ const cellsMap = doc.getMap>(CELLS_MAP)
+ expect(cellsMap.has(yCellKey('sheet1', 2, 2))).toBe(false)
+ })
+
+ it('is a no-op for a cell that does not exist', () => {
+ const doc = new Y.Doc()
+ expect(() => clearYCellContent(doc, 'sheet1', 9, 9)).not.toThrow()
+ expect(doc.getMap>(CELLS_MAP).size).toBe(0)
+ })
+})
+
describe('setYCellTyped + inferred commit path', () => {
it('typing a number stores kind=number with a numeric raw', () => {
const doc = new Y.Doc()
diff --git a/tinycld/calc/components/Grid.tsx b/tinycld/calc/components/Grid.tsx
index 2177804..98ffc61 100644
--- a/tinycld/calc/components/Grid.tsx
+++ b/tinycld/calc/components/Grid.tsx
@@ -39,11 +39,13 @@ import {
allRanges,
computeShiftArrowTarget,
primaryAnchor,
+ primaryRange,
unionBoundingBox,
} from '../lib/selection-range'
import { useConditionalFormatPanelStore } from '../lib/stores/conditional-format-panel-store'
import { useNamedRangesDialogStore } from '../lib/stores/named-ranges-dialog-store'
import { usePivotPanelStore } from '../lib/stores/pivot-panel-store'
+import type { CellStyle } from '../lib/workbook-types'
import { CalcCommentDrawer } from './comments/CalcCommentDrawer'
import { ConditionalFormatPanel } from './conditional-format/ConditionalFormatPanel'
import { FindReplaceDialogGate } from './FindReplaceDialog'
@@ -61,6 +63,7 @@ import { HeaderContextMenu } from './grid/HeaderContextMenu'
import { RowHeader } from './grid/RowHeader'
import { autosizeCol, commitColWidth, commitRowHeight } from './grid/resize-actions'
import { SortDialog } from './grid/SortDialog'
+import { applyFormatPainterToDest, readCellStyle } from './grid/style-helpers'
import { KeyboardAccessoryHost } from './KeyboardAccessoryHost'
import { MenuBar } from './menubar/MenuBar'
import { NameBox } from './NameBox'
@@ -347,6 +350,51 @@ function GridInner({
[presence, sheetId]
)
+ const isFormatPainterActive = useGridStore(s => s.formatPainterCells != null)
+
+ const activateFormatPainter = useCallback(() => {
+ if (readOnly || doc == null) return
+ const state = instance.store.getState()
+ if (state.formatPainterCells != null) {
+ state.clearFormatPainter()
+ return
+ }
+ const range = primaryRange(state.selection)
+ if (range == null) return
+ const cells: CellStyle[][] = []
+ for (let r = range.startRow; r <= range.endRow; r++) {
+ const row: CellStyle[] = []
+ for (let c = range.startCol; c <= range.endCol; c++) {
+ row.push(readCellStyle(doc, sheetId, r, c) ?? {})
+ }
+ cells.push(row)
+ }
+ state.setFormatPainter(cells, range)
+ }, [readOnly, doc, sheetId, instance.store])
+
+ const applyFormatPainterIfActive = useCallback(() => {
+ const state = instance.store.getState()
+ if (state.formatPainterCells == null || doc == null) return
+ const range = primaryRange(state.selection)
+ if (range == null) return
+ applyFormatPainterToDest(doc, sheetId, state.formatPainterCells, range, rows, cols)
+ state.clearFormatPainter()
+ }, [doc, sheetId, instance.store, rows, cols])
+
+ useEffect(() => {
+ if (Platform.OS !== 'web') return
+ const cls = 'calc-format-painter-active'
+ const root = document.documentElement
+ if (isFormatPainterActive) {
+ root.classList.add(cls)
+ } else {
+ root.classList.remove(cls)
+ }
+ return () => {
+ root.classList.remove(cls)
+ }
+ }, [isFormatPainterActive])
+
const toolbar = useGridToolbarToggles({ doc, sheetId, readOnly })
const format = useGridFormatControls({
doc,
@@ -389,6 +437,31 @@ function GridInner({
// the shortcuts live for the lifetime of the Grid mount. The
// clipboard hook owns the actual copy/paste plumbing.
const clipboard = useClipboard({ doc, sheetId, store: instance.store, readOnly })
+
+ // Native paste event listener — reads clipboard data synchronously
+ // from event.clipboardData, bypassing the async Clipboard API which
+ // requires clipboard-read permission and breaks in Safari from a
+ // keydown context. The Cmd+V tinykeys shortcut is intentionally NOT
+ // registered so the browser fires this native paste event instead.
+ //
+ // Guard: skip when a cell editor TextInput has focus (the input
+ // handles its own paste to insert text into the formula). Any
+ // or