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