Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions help/clipboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions help/keyboard-shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
20 changes: 18 additions & 2 deletions tests/clipboard-decode-html.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<b>&"</b>' }]])
const html = payloadToHtml(source, 'm')
Expand Down Expand Up @@ -154,13 +170,13 @@ describe('htmlToPayload — foreign producer fixtures', () => {
it('parses background-color from inline style', () => {
const html = '<table><tr><td style="background-color: #FFCC00">x</td></tr></table>'
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 = '<table><tr><td style="color: rgb(255, 0, 0)">x</td></tr></table>'
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', () => {
Expand Down
1 change: 1 addition & 0 deletions tests/clipboard-disjoint-refusal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ function makeStubDeps(): GridStoreDeps {
return {
readOnly: false,
writeCell: () => {},
clearCellContent: () => {},
focusActiveInput: () => {},
focusSentinel: () => {},
scrollToCell: () => {},
Expand Down
2 changes: 2 additions & 0 deletions tests/freeze-panes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ describe('grid-store freeze actions delegate through deps', () => {
const store = createGridStore({
readOnly: false,
writeCell: () => {},
clearCellContent: () => {},
focusActiveInput: () => {},
focusSentinel: () => {},
scrollToCell: () => {},
Expand Down Expand Up @@ -201,6 +202,7 @@ describe('grid-store freeze actions delegate through deps', () => {
const store = createGridStore({
readOnly: true,
writeCell: () => {},
clearCellContent: () => {},
focusActiveInput: () => {},
focusSentinel: () => {},
scrollToCell: () => {},
Expand Down
14 changes: 7 additions & 7 deletions tests/grid-store-disjoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ function makeStubDeps(opts: { readOnly?: boolean } = {}): GridStoreDeps {
return {
readOnly: opts.readOnly ?? false,
writeCell: () => {},
clearCellContent: () => {},
focusActiveInput: () => {},
focusSentinel: () => {},
scrollToCell: () => {},
Expand Down Expand Up @@ -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 },
])
})
})
Expand Down
34 changes: 34 additions & 0 deletions tests/grid-store-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
1 change: 1 addition & 0 deletions tests/grid-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand Down
1 change: 1 addition & 0 deletions tests/merge-selection-snap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function makeDepsWithMerges(merges: FakeMerge[]): GridStoreDeps {
return {
readOnly: false,
writeCell: () => {},
clearCellContent: () => {},
focusActiveInput: () => {},
focusSentinel: () => {},
scrollToCell: () => {},
Expand Down
81 changes: 81 additions & 0 deletions tests/range-style-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<Y.Map<unknown>>(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()
Expand Down
44 changes: 43 additions & 1 deletion tests/use-y-cell.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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<Y.Map<unknown>>(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<unknown> | undefined
const font = style?.get('font') as Y.Map<unknown> | 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<Y.Map<unknown>>(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<Y.Map<unknown>>(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()
Expand Down
Loading
Loading