From e9f00fd2f2a52caa5d39525bd7030abc06967d75 Mon Sep 17 00:00:00 2001 From: Stefan N Date: Mon, 1 Jun 2026 10:12:45 +0200 Subject: [PATCH 1/8] fix: misc fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Delete key should only clear content, not formatting 2. Multi-select header tint — selecting multiple rows/cols only tints the first header green 3. Top-left corner cell should select all 4. Paste Special → "Format only" should include number/cell format and colors as well 5. Paste Special → "Format only" keyboard shortcut doesn’t work as advertised --- tests/clipboard-disjoint-refusal.test.ts | 1 + tests/freeze-panes.test.ts | 2 + tests/grid-store-disjoint.test.ts | 14 +++--- tests/grid-store.test.ts | 1 + tests/merge-selection-snap.test.ts | 1 + tinycld/calc/components/Grid.tsx | 2 +- tinycld/calc/components/grid/ColumnHeader.tsx | 49 +++++++++++------- tinycld/calc/components/grid/CornerCell.tsx | 33 +++++++++--- tinycld/calc/components/grid/RowHeader.tsx | 50 ++++++++++++------- tinycld/calc/hooks/grid-store.ts | 3 +- .../hooks/grid/use-grid-store-instance.ts | 3 +- tinycld/calc/hooks/use-calc-shortcuts.ts | 4 +- tinycld/calc/hooks/use-y-cell.ts | 22 ++++++++ tinycld/calc/lib/clipboard/decode-html.ts | 7 ++- tinycld/calc/lib/clipboard/encode-html.ts | 3 ++ 15 files changed, 139 insertions(+), 56 deletions(-) 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.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/tinycld/calc/components/Grid.tsx b/tinycld/calc/components/Grid.tsx index 2177804..de70001 100644 --- a/tinycld/calc/components/Grid.tsx +++ b/tinycld/calc/components/Grid.tsx @@ -601,7 +601,7 @@ function GridInner({ onAnchorLayout={formulaBar.onAnchorLayout} /> - + primaryAnchor(s.selection)?.col ?? null) - // Mirror of RowHeader: bolden the column label when the user - // selected the WHOLE column (any sub-range with scope='column' - // anchored at this col). Disjoint column selections light up - // every selected column header, matching Sheets. - const colScopeActive = useGridStore(s => { - if (s.selection == null) return false - if (activeCol == null) return false - for (const sr of s.selection.ranges) { - if (sr.scope === 'column' && sr.anchor.col === activeCol) return true + // Sorted comma-separated string of all column-scope anchor cols. + // Primitive return keeps the selector stable across calls (a new Set + // each time would trigger infinite re-renders via useSyncExternalStore). + const selectedColsKey = useGridStore(s => { + const cols: number[] = [] + if (s.selection != null) { + for (const sr of s.selection.ranges) { + if (sr.scope === 'column') { + for (let c = sr.range.startCol; c <= sr.range.endCol; c++) { + cols.push(c) + } + } + } } - return false + return cols.sort((a, b) => a - b).join(',') }) + const selectedColSet = useMemo( + () => + selectedColsKey + ? new Set(selectedColsKey.split(',').map(Number)) + : new Set(), + [selectedColsKey] + ) const store = useGridStoreApi() const accent = useThemeColor('accent') // Skip the synthetic onPress after a modifier mousedown. @@ -118,7 +129,7 @@ export function ColumnHeader({ fCols, 0, activeCol, - colScopeActive, + selectedColSet, rowCount, makeHandleProps, dragState, @@ -133,7 +144,7 @@ export function ColumnHeader({ lastCol, frozenW, activeCol, - colScopeActive, + selectedColSet, rowCount, makeHandleProps, dragState, @@ -216,15 +227,17 @@ function appendHeaderCells( last: number, xShift: number, activeCol: number | null, - colScopeActive: boolean, + selectedColSet: ReadonlySet, rowCount: number, makeHandleProps: (col: number) => Record, dragState: DragState | null, filter: HeaderFilterCtx ): void { for (let col = first; col <= last; col++) { + // isActive: primary anchor column — gets the inset shadow affordance const isActive = col === activeCol - const isColScope = colScopeActive && isActive + // isSelected: any column-scope sub-range covers this col → green tint + const isSelected = isActive || selectedColSet.has(col) const absLeft = colOffsets[col - 1] const width = colOffsets[col] - absLeft const left = absLeft - xShift @@ -318,7 +331,7 @@ function appendHeaderCells( onLongPress={onLongPress} accessibilityLabel={`Select column ${columnLabel(col)}`} className={`border-r border-b border-border flex-row items-center justify-center web:outline-none ${ - isActive ? 'bg-accent' : 'bg-surface-secondary' + isSelected ? 'bg-accent' : 'bg-surface-secondary' }`} style={{ position: 'absolute', @@ -332,8 +345,8 @@ function appendHeaderCells( {...((webMouseDownProp ?? {}) as any)} > {columnLabel(col)} diff --git a/tinycld/calc/components/grid/CornerCell.tsx b/tinycld/calc/components/grid/CornerCell.tsx index 78b67cc..bc9e067 100644 --- a/tinycld/calc/components/grid/CornerCell.tsx +++ b/tinycld/calc/components/grid/CornerCell.tsx @@ -1,14 +1,33 @@ -import { View } from 'react-native' +import { Platform, Pressable } from 'react-native' +import type { GridStoreApi } from '../../hooks/grid-store' import { HEADER_HEIGHT, ROW_HEADER_WIDTH } from './constants' -// Top-left corner stub. Renders nothing interactive — just fills the -// intersection of the column-header row and row-header column with -// the same surface color so the grid lines align cleanly. -export function CornerCell() { +interface CornerCellProps { + store: GridStoreApi + rowCount: number + colCount: number +} + +export function CornerCell({ store, rowCount, colCount }: CornerCellProps) { + const webProps = + Platform.OS === 'web' + ? { + onKeyDown: (e: { key: string; preventDefault: () => void }) => { + if (e.key === 'Delete' || e.key === 'Backspace') { + e.preventDefault() + store.getState().clearSelection() + } + }, + } + : null return ( - store.getState().selectAll(rowCount, colCount)} + // biome-ignore lint/suspicious/noExplicitAny: web-only DOM event prop on RN Pressable + {...((webProps ?? {}) as any)} /> ) } diff --git a/tinycld/calc/components/grid/RowHeader.tsx b/tinycld/calc/components/grid/RowHeader.tsx index 546926b..e9443b3 100644 --- a/tinycld/calc/components/grid/RowHeader.tsx +++ b/tinycld/calc/components/grid/RowHeader.tsx @@ -1,5 +1,5 @@ import { useThemeColor } from '@tinycld/core/lib/use-app-theme' -import { useRef } from 'react' +import { useMemo, useRef } from 'react' import { type GestureResponderEvent, Platform, @@ -49,18 +49,30 @@ export function RowHeader({ }: RowHeaderProps) { const borderColor = useThemeColor('border') const activeRow = useGridStore(s => primaryAnchor(s.selection)?.row ?? null) - // Highlight the row label more strongly when the user has selected - // the WHOLE row (any sub-range with scope='row' anchored at this - // row). Disjoint row selections light up every selected row - // header, matching Sheets. - const rowScopeActive = useGridStore(s => { - if (s.selection == null) return false - if (activeRow == null) return false - for (const sr of s.selection.ranges) { - if (sr.scope === 'row' && sr.anchor.row === activeRow) return true + // Sorted comma-separated string of all row-scope anchor rows. Using + // a primitive return value keeps the selector's result stable across + // calls (useSyncExternalStore compares with Object.is, so returning + // a new Set each time would trigger infinite re-renders). + const selectedRowsKey = useGridStore(s => { + const rows: number[] = [] + if (s.selection != null) { + for (const sr of s.selection.ranges) { + if (sr.scope === 'row') { + for (let r = sr.range.startRow; r <= sr.range.endRow; r++) { + rows.push(r) + } + } + } } - return false + return rows.sort((a, b) => a - b).join(',') }) + const selectedRowSet = useMemo( + () => + selectedRowsKey + ? new Set(selectedRowsKey.split(',').map(Number)) + : new Set(), + [selectedRowsKey] + ) const store = useGridStoreApi() // Skip the synthetic onPress after a modifier mousedown — see the // matching ref in ColumnHeader.tsx for the rationale. @@ -81,7 +93,7 @@ export function RowHeader({ fRows, 0, activeRow, - rowScopeActive, + selectedRowSet, store, colCount, makeHandleProps, @@ -97,7 +109,7 @@ export function RowHeader({ lastRow, frozenH, activeRow, - rowScopeActive, + selectedRowSet, store, colCount, makeHandleProps, @@ -165,7 +177,7 @@ function appendRowHeaderCells( last: number, yShift: number, activeRow: number | null, - rowScopeActive: boolean, + selectedRowSet: ReadonlySet, store: ReturnType, colCount: number, makeHandleProps: (row: number) => Record, @@ -173,8 +185,10 @@ function appendRowHeaderCells( skipNextPressRef: React.MutableRefObject ): void { for (let row = first; row <= last; row++) { + // isActive: primary anchor row — gets the inset shadow affordance const isActive = row === activeRow - const isRowScope = rowScopeActive && isActive + // isSelected: any row-scope sub-range covers this row → green tint + const isSelected = isActive || selectedRowSet.has(row) const absTop = rowOffsets[row - 1] const height = rowOffsets[row] - absTop const top = absTop - yShift @@ -250,7 +264,7 @@ function appendRowHeaderCells( onLongPress={onLongPress} accessibilityLabel={`Select row ${row}`} className={`border-r border-b border-border items-center justify-center web:outline-none ${ - isActive ? 'bg-accent' : 'bg-surface-secondary' + isSelected ? 'bg-accent' : 'bg-surface-secondary' }`} style={{ position: 'absolute', @@ -264,8 +278,8 @@ function appendRowHeaderCells( {...((webMouseDownProp ?? {}) as any)} > {row} diff --git a/tinycld/calc/hooks/grid-store.ts b/tinycld/calc/hooks/grid-store.ts index 149cc36..444c06b 100644 --- a/tinycld/calc/hooks/grid-store.ts +++ b/tinycld/calc/hooks/grid-store.ts @@ -208,6 +208,7 @@ export type StructuralOp = export interface GridStoreDeps { readOnly: boolean writeCell: (row: number, col: number, value: string) => void + clearCellContent: (row: number, col: number) => void focusActiveInput: () => void scrollToCell: (row: number, col: number) => void focusSentinel: () => void @@ -976,7 +977,7 @@ export function createGridStore(deps: GridStoreDeps): GridStoreApi { for (const sr of selection.ranges) { for (let r = sr.range.startRow; r <= sr.range.endRow; r++) { for (let c = sr.range.startCol; c <= sr.range.endCol; c++) { - deps.writeCell(r, c, '') + deps.clearCellContent(r, c) } } } diff --git a/tinycld/calc/hooks/grid/use-grid-store-instance.ts b/tinycld/calc/hooks/grid/use-grid-store-instance.ts index 177c765..4d432d0 100644 --- a/tinycld/calc/hooks/grid/use-grid-store-instance.ts +++ b/tinycld/calc/hooks/grid/use-grid-store-instance.ts @@ -20,7 +20,7 @@ import { } from '../../lib/structural-mutations' import type { CellRange } from '../grid-store' import { createGridStore, type GridStoreApi, type GridStoreDeps } from '../grid-store' -import { setYCell } from '../use-y-cell' +import { clearYCellContent, setYCell } from '../use-y-cell' import { setFrozenCols, setFrozenRows } from '../use-y-sheets' export interface GridStoreInstance { @@ -76,6 +76,7 @@ export function useGridStoreInstance({ const deps: GridStoreDeps = { readOnly, writeCell: (row, col, value) => setYCell(doc, sheetId, row, col, value), + clearCellContent: (row, col) => clearYCellContent(doc, sheetId, row, col), focusActiveInput: () => { const surface = holder.api?.getState().activeSurface ?? 'cell' const target = diff --git a/tinycld/calc/hooks/use-calc-shortcuts.ts b/tinycld/calc/hooks/use-calc-shortcuts.ts index 2ab085b..e3f5eb5 100644 --- a/tinycld/calc/hooks/use-calc-shortcuts.ts +++ b/tinycld/calc/hooks/use-calc-shortcuts.ts @@ -127,7 +127,7 @@ const SHORTCUT_DOCS: readonly ShortcutEntry[] = [ }, { id: 'calc.clipboard.pasteFormulas', - keys: '$mod+Alt+v', + keys: '$mod+Alt+Shift+v', description: 'Paste formulas only', group: 'Calc', scope: 'global', @@ -136,7 +136,7 @@ const SHORTCUT_DOCS: readonly ShortcutEntry[] = [ }, { id: 'calc.clipboard.pasteFormat', - keys: '$mod+Alt+Shift+v', + keys: '$mod+Alt+v', description: 'Paste format only', group: 'Calc', scope: 'global', diff --git a/tinycld/calc/hooks/use-y-cell.ts b/tinycld/calc/hooks/use-y-cell.ts index 9e51a22..160a2a9 100644 --- a/tinycld/calc/hooks/use-y-cell.ts +++ b/tinycld/calc/hooks/use-y-cell.ts @@ -253,6 +253,28 @@ export function deleteYCell(doc: Y.Doc, sheetId: string, row: number, col: numbe }, LOCAL_ORIGIN) } +// clearYCellContent wipes the value fields (kind/raw/display/formula) of +// a cell while preserving its style Y.Map. Used by the Delete key so +// number format and text formatting survive the keystroke. If the cell +// has no style the entry is removed entirely (same result as deleteYCell). +export function clearYCellContent(doc: Y.Doc, sheetId: string, row: number, col: number): void { + const cellsMap = doc.getMap>(CELLS_MAP) + const key = yCellKey(sheetId, row, col) + doc.transact(() => { + const cell = cellsMap.get(key) + if (cell == null) return + const hasStyle = cell.has(STYLE_KEY) + if (!hasStyle) { + cellsMap.delete(key) + return + } + cell.delete('kind') + cell.delete('raw') + cell.delete('display') + cell.delete('formula') + }, LOCAL_ORIGIN) +} + // setYCellStyle deep-merges a partial CellStyle patch onto the cell at // (sheetId, row, col). Cells that don't exist yet are created with no // raw/display, just style — toggling bold on an empty cell is valid diff --git a/tinycld/calc/lib/clipboard/decode-html.ts b/tinycld/calc/lib/clipboard/decode-html.ts index 883f90b..56296ea 100644 --- a/tinycld/calc/lib/clipboard/decode-html.ts +++ b/tinycld/calc/lib/clipboard/decode-html.ts @@ -114,6 +114,7 @@ function buildCell(attrs: Map, innerHtml: string): ClipboardCell const rawAttr = attrs.get('data-tinycld-raw') const formulaAttr = attrs.get('data-tinycld-formula') ?? attrs.get('data-sheets-formula') ?? undefined + const numFmtAttr = attrs.get('data-tinycld-numfmt') const style = parseInlineStyle(attrs.get('style')) const displayText = stripTags(innerHtml).trim() @@ -122,7 +123,11 @@ function buildCell(attrs: Map, innerHtml: string): ClipboardCell raw: coerceRaw(kindAttr ?? 'string', rawAttr, displayText), } if (formulaAttr != null) cell.formula = formulaAttr - if (style != null) cell.style = style + if (numFmtAttr != null && numFmtAttr.length > 0) { + cell.style = { ...(style ?? {}), numFmt: numFmtAttr } + } else if (style != null) { + cell.style = style + } return cell } diff --git a/tinycld/calc/lib/clipboard/encode-html.ts b/tinycld/calc/lib/clipboard/encode-html.ts index 0889ca3..af757f4 100644 --- a/tinycld/calc/lib/clipboard/encode-html.ts +++ b/tinycld/calc/lib/clipboard/encode-html.ts @@ -53,6 +53,9 @@ function renderTd(cell: ClipboardCell): string { const rawAttr = typeof cell.raw === 'string' ? cell.raw : String(cell.raw) attrs.push(`data-tinycld-raw="${escapeAttr(rawAttr)}"`) } + if (cell.style?.numFmt != null && cell.style.numFmt.length > 0) { + attrs.push(`data-tinycld-numfmt="${escapeAttr(cell.style.numFmt)}"`) + } const style = inlineStyleFor(cell) if (style.length > 0) { attrs.push(`style="${escapeAttr(style)}"`) From 6d278fdaf55aa5bfb72711e6d0d722cf05c920c3 Mon Sep 17 00:00:00 2001 From: Stefan N Date: Mon, 1 Jun 2026 11:03:24 +0200 Subject: [PATCH 2/8] feat: format paintbrush MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new toolbar button that copies formatting (text+colors+number format) and then applies it to a click/drag target, with Excel-style row-major source→destination mapping and wrap. Also works for whole rows / columns. --- tinycld/calc/components/Grid.tsx | 51 +++++++++++ tinycld/calc/components/Toolbar.tsx | 13 +++ tinycld/calc/components/grid/Body.tsx | 6 ++ tinycld/calc/components/grid/Cell.tsx | 41 ++++++++- tinycld/calc/components/grid/ColumnHeader.tsx | 5 ++ .../components/grid/FormatPainterOverlay.tsx | 85 +++++++++++++++++++ tinycld/calc/components/grid/RowHeader.tsx | 12 ++- tinycld/calc/components/grid/style-helpers.ts | 25 ++++++ tinycld/calc/hooks/grid-store.ts | 14 +++ 9 files changed, 247 insertions(+), 5 deletions(-) create mode 100644 tinycld/calc/components/grid/FormatPainterOverlay.tsx diff --git a/tinycld/calc/components/Grid.tsx b/tinycld/calc/components/Grid.tsx index de70001..7d6505a 100644 --- a/tinycld/calc/components/Grid.tsx +++ b/tinycld/calc/components/Grid.tsx @@ -32,6 +32,7 @@ import { useWorkbook } from '../hooks/use-workbook-context' import type { WorkbookFileActions } from '../hooks/use-workbook-file-actions' import { useAllYSheets, useYSheets } from '../hooks/use-y-sheets' import { classifyCellKey } from '../lib/cell-key-action' +import type { CellStyle } from '../lib/workbook-types' import { rangeToSheetRelativeA1 } from '../lib/conditional-format/a1' import { buildColOffsets, buildRowOffsets } from '../lib/dimensions' import { buildA1Range } from '../lib/pivot/range-parse' @@ -39,6 +40,7 @@ import { allRanges, computeShiftArrowTarget, primaryAnchor, + primaryRange, unionBoundingBox, } from '../lib/selection-range' import { useConditionalFormatPanelStore } from '../lib/stores/conditional-format-panel-store' @@ -60,6 +62,7 @@ import { HandleContextMenu } from './grid/HandleContextMenu' import { HeaderContextMenu } from './grid/HeaderContextMenu' import { RowHeader } from './grid/RowHeader' import { autosizeCol, commitColWidth, commitRowHeight } from './grid/resize-actions' +import { applyFormatPainterStyles, readCellStyle } from './grid/style-helpers' import { SortDialog } from './grid/SortDialog' import { KeyboardAccessoryHost } from './KeyboardAccessoryHost' import { MenuBar } from './menubar/MenuBar' @@ -347,6 +350,50 @@ 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 + applyFormatPainterStyles(doc, sheetId, state.formatPainterCells, range) + state.clearFormatPainter() + }, [doc, sheetId, instance.store]) + + useEffect(() => { + if (Platform.OS !== 'web') return + const el = document.documentElement + if (isFormatPainterActive) { + el.style.setProperty('--calc-grid-cursor', 'cell') + } else { + el.style.removeProperty('--calc-grid-cursor') + } + return () => { + el.style.removeProperty('--calc-grid-cursor') + } + }, [isFormatPainterActive]) + const toolbar = useGridToolbarToggles({ doc, sheetId, readOnly }) const format = useGridFormatControls({ doc, @@ -520,6 +567,8 @@ function GridInner({ onSetBorders: format.setBorders, horizontalAlign: format.horizontalAlign, onSetHorizontalAlign: format.setHorizontalAlign, + isFormatPainterActive, + onActivateFormatPainter: activateFormatPainter, onOpenFind: onOpenFind, onDownloadCsvCurrent: csvDownload.downloadCurrent, onDownloadCsvAll: csvDownload.downloadAll, @@ -616,6 +665,7 @@ function GridInner({ activeFilterCols={filter.activeFilterCols} filterMode={filter.filterView?.mode ?? null} onRemoveColumnCriterion={filter.removeHeaderCriterion} + onFormatPainterApply={applyFormatPainterIfActive} /> @@ -629,6 +679,7 @@ function GridInner({ frozenRows={frozenRows} makeHandleProps={rowResize.makeHandleProps} dragState={rowResize.dragState} + onFormatPainterApply={applyFormatPainterIfActive} /> void + isFormatPainterActive: boolean + onActivateFormatPainter: () => void + onOpenFind: () => void onDownloadCsvCurrent: () => void @@ -149,6 +153,8 @@ function ToolbarImpl(props: ToolbarProps) { onSetBorders, horizontalAlign, onSetHorizontalAlign, + isFormatPainterActive, + onActivateFormatPainter, onOpenFind, doc, pivotSourceRangeDefault, @@ -166,6 +172,13 @@ function ToolbarImpl(props: ToolbarProps) { + + + 0 && srcCols > 0) { + destRange = { + startRow: destRange.startRow, + startCol: destRange.startCol, + endRow: destRange.startRow + srcRows - 1, + endCol: destRange.startCol + srcCols - 1, + } + } + applyFormatPainterStyles(doc, sheetId, state.formatPainterCells, destRange) + } + state.clearFormatPainter() + } }, }) @@ -281,6 +302,22 @@ export const Cell = memo(function Cell({ } const state = store.getState() if (state.cellRefTap(row, col)) return + if (state.formatPainterCells != null && doc != null) { + state.selectCell({ row, col }) + const cells = state.formatPainterCells + const srcRows = cells.length + const srcCols = cells[0]?.length ?? 0 + if (srcRows > 0 && srcCols > 0) { + applyFormatPainterStyles(doc, sheetId, cells, { + startRow: row, + startCol: col, + endRow: row + srcRows - 1, + endCol: col + srcCols - 1, + }) + } + state.clearFormatPainter() + return + } if (isSelected) { state.editCell({ row, col }, editDraft) } else { diff --git a/tinycld/calc/components/grid/ColumnHeader.tsx b/tinycld/calc/components/grid/ColumnHeader.tsx index 66ce644..3064515 100644 --- a/tinycld/calc/components/grid/ColumnHeader.tsx +++ b/tinycld/calc/components/grid/ColumnHeader.tsx @@ -53,6 +53,7 @@ interface ColumnHeaderProps { // Callback to remove the criterion for a single column (header-mode // only). Wired to the ✕ press. onRemoveColumnCriterion: (col: number) => void + onFormatPainterApply?: () => void } export function ColumnHeader({ @@ -69,6 +70,7 @@ export function ColumnHeader({ activeFilterCols, filterMode, onRemoveColumnCriterion, + onFormatPainterApply, }: ColumnHeaderProps) { const borderColor = useThemeColor('border') const activeCol = useGridStore(s => primaryAnchor(s.selection)?.col ?? null) @@ -118,6 +120,7 @@ export function ColumnHeader({ store, accent, skipNextPressRef, + onFormatPainterApply, } const frozenCells: React.ReactNode[] = [] @@ -213,6 +216,7 @@ interface HeaderFilterCtx { store: GridStoreApi accent: string skipNextPressRef: React.MutableRefObject + onFormatPainterApply?: () => void } // appendHeaderCells emits one column-header label cell + one resize @@ -319,6 +323,7 @@ function appendHeaderCells( return } filter.store.getState().selectColumn(col, rowCount) + filter.onFormatPainterApply?.() } const onLongPress = (e: GestureResponderEvent) => { const { pageX, pageY } = e.nativeEvent diff --git a/tinycld/calc/components/grid/FormatPainterOverlay.tsx b/tinycld/calc/components/grid/FormatPainterOverlay.tsx new file mode 100644 index 0000000..96aeb67 --- /dev/null +++ b/tinycld/calc/components/grid/FormatPainterOverlay.tsx @@ -0,0 +1,85 @@ +import { useEffect } from 'react' +import { Platform, View } from 'react-native' +import { useGridStore } from '../../hooks/use-grid-store' + +// Paints the marching-ants outline around the format-painter source range +// while the painter is armed. Cleared automatically after the first +// click/drag apply. Uses blue (#2563eb) so the ring is visually distinct +// from the clipboard copy ring. + +const PAINTER_STYLE_ID = 'tinycld-calc-painter-ants-style' + +interface FormatPainterOverlayProps { + colOffsets: Float64Array + rowOffsets: Float64Array +} + +export function FormatPainterOverlay({ colOffsets, rowOffsets }: FormatPainterOverlayProps) { + useEffect(() => { + if (Platform.OS !== 'web') return + if (document.getElementById(PAINTER_STYLE_ID) != null) return + const style = document.createElement('style') + style.id = PAINTER_STYLE_ID + style.textContent = ` +@keyframes tinycld-calc-painter-ants { + from { background-position: 0 0, 8px 100%, 100% 8px, 0 0; } + to { background-position: 8px 0, 0 100%, 100% 0, 0 8px; } +} +.tinycld-calc-painter-ants { + position: absolute; + pointer-events: none; + background-image: + linear-gradient(90deg, #2563eb 50%, transparent 50%), + linear-gradient(90deg, #2563eb 50%, transparent 50%), + linear-gradient(0deg, #2563eb 50%, transparent 50%), + linear-gradient(0deg, #2563eb 50%, transparent 50%); + background-size: 8px 2px, 8px 2px, 2px 8px, 2px 8px; + background-position: 0 0, 0 100%, 0 0, 100% 0; + background-repeat: repeat-x, repeat-x, repeat-y, repeat-y; + animation: tinycld-calc-painter-ants 0.6s linear infinite; +} +` + document.head.appendChild(style) + }, []) + + const range = useGridStore(s => s.formatPainterSourceRange) + if (range == null) return null + + const left = colOffsets[range.startCol - 1] ?? 0 + const right = colOffsets[range.endCol] ?? left + const width = right - left + if (width <= 0) return null + const top = rowOffsets[range.startRow - 1] ?? 0 + const bottom = rowOffsets[range.endRow] ?? top + const height = bottom - top + if (height <= 0) return null + + if (Platform.OS === 'web') { + const webProps = { + className: 'tinycld-calc-painter-ants', + } as unknown as Record + return ( + + ) + } + + return ( + + ) +} diff --git a/tinycld/calc/components/grid/RowHeader.tsx b/tinycld/calc/components/grid/RowHeader.tsx index e9443b3..fb4ce67 100644 --- a/tinycld/calc/components/grid/RowHeader.tsx +++ b/tinycld/calc/components/grid/RowHeader.tsx @@ -34,6 +34,7 @@ interface RowHeaderProps { frozenRows: number makeHandleProps: (row: number) => Record dragState: RowDragState | null + onFormatPainterApply?: () => void } export function RowHeader({ @@ -46,6 +47,7 @@ export function RowHeader({ frozenRows, makeHandleProps, dragState, + onFormatPainterApply, }: RowHeaderProps) { const borderColor = useThemeColor('border') const activeRow = useGridStore(s => primaryAnchor(s.selection)?.row ?? null) @@ -98,7 +100,8 @@ export function RowHeader({ colCount, makeHandleProps, dragState, - skipNextPressRef + skipNextPressRef, + onFormatPainterApply ) } const scrollableCells: React.ReactNode[] = [] @@ -114,7 +117,8 @@ export function RowHeader({ colCount, makeHandleProps, dragState, - skipNextPressRef + skipNextPressRef, + onFormatPainterApply ) if (fRows <= 0) { @@ -182,7 +186,8 @@ function appendRowHeaderCells( colCount: number, makeHandleProps: (row: number) => Record, dragState: RowDragState | null, - skipNextPressRef: React.MutableRefObject + skipNextPressRef: React.MutableRefObject, + onFormatPainterApply: (() => void) | undefined ): void { for (let row = first; row <= last; row++) { // isActive: primary anchor row — gets the inset shadow affordance @@ -252,6 +257,7 @@ function appendRowHeaderCells( return } store.getState().selectRow(row, colCount) + onFormatPainterApply?.() } const onLongPress = (e: GestureResponderEvent) => { const { pageX, pageY } = e.nativeEvent diff --git a/tinycld/calc/components/grid/style-helpers.ts b/tinycld/calc/components/grid/style-helpers.ts index 929d317..a73360d 100644 --- a/tinycld/calc/components/grid/style-helpers.ts +++ b/tinycld/calc/components/grid/style-helpers.ts @@ -144,6 +144,31 @@ export function toggleCellFontAttrAcrossRanges( }, LOCAL_ORIGIN) } +// applyFormatPainterStyles tiles the source style grid onto destRange +// using row-major modulo wrap. Each destination cell (dr, dc) gets the +// source style at (dr % srcRows, dc % srcCols) — identical to Excel's +// format-painter multi-cell tiling semantics. +export function applyFormatPainterStyles( + doc: Y.Doc, + sheetId: string, + cells: CellStyle[][], + destRange: CellRange +): void { + const srcRows = cells.length + if (srcRows === 0) return + const srcCols = cells[0].length + if (srcCols === 0) return + doc.transact(() => { + for (let r = destRange.startRow; r <= destRange.endRow; r++) { + for (let c = destRange.startCol; c <= destRange.endCol; c++) { + const srcR = (r - destRange.startRow) % srcRows + const srcC = (c - destRange.startCol) % srcCols + setYCellStyle(doc, sheetId, r, c, cells[srcR][srcC]) + } + } + }, LOCAL_ORIGIN) +} + // locateCellAtGridCoord maps an (x, y) inside the grid body to the // 1-based (row, col) of the cell at that point. Used by the cell // PanResponder to translate pointer-move locations into the cell the diff --git a/tinycld/calc/hooks/grid-store.ts b/tinycld/calc/hooks/grid-store.ts index 444c06b..5619e51 100644 --- a/tinycld/calc/hooks/grid-store.ts +++ b/tinycld/calc/hooks/grid-store.ts @@ -29,6 +29,7 @@ // `lib/selection-range.ts` for the helper layer call sites use. import { createStore as createVanillaStore, type StoreApi } from 'zustand/vanilla' import type { ArrowDirection } from '../lib/cell-key-action' +import type { CellStyle } from '../lib/workbook-types' import { applyFunctionInsertion, applyNameInsertion, @@ -178,6 +179,11 @@ export interface GridState { // refused copy on a disjoint selection). Consumers clear it via // dismissSelectionStatus or the next selection-mutating action. selectionStatus: SelectionStatus + // Format painter: source styles to apply on next click/drag. Set by + // the toolbar paintbrush button; cleared after the first apply or on + // button toggle. Row-major 2D array matching the source range shape. + formatPainterCells: CellStyle[][] | null + formatPainterSourceRange: CellRange | null } // Live cursor position inside the editing input. Stored as a @@ -358,6 +364,8 @@ export interface GridActions { setSortStatus: (status: { mergesBroken: number } | null) => void setSelectionStatus: (status: SelectionStatus) => void dismissSelectionStatus: () => void + setFormatPainter: (cells: CellStyle[][], sourceRange: CellRange) => void + clearFormatPainter: () => void mergeSelection: () => void mergeSelectionHorizontal: () => void mergeSelectionVertical: () => void @@ -401,6 +409,8 @@ const initialState: GridState = { filterDialogCol: null, sortStatus: null, selectionStatus: null, + formatPainterCells: null, + formatPainterSourceRange: null, } const CLIPBOARD_MARKER_TTL_MS = 30_000 @@ -1423,6 +1433,10 @@ export function createGridStore(deps: GridStoreDeps): GridStoreApi { setSortStatus: status => set({ sortStatus: status }), setSelectionStatus: status => set({ selectionStatus: status }), dismissSelectionStatus: () => set({ selectionStatus: null }), + setFormatPainter: (cells, sourceRange) => + set({ formatPainterCells: cells, formatPainterSourceRange: sourceRange }), + clearFormatPainter: () => + set({ formatPainterCells: null, formatPainterSourceRange: null }), fillDragStart: () => { if (deps.readOnly) return false From a6c6f2d30bd2e735e7305cd43922c6e81be4db5d Mon Sep 17 00:00:00 2001 From: Stefan N Date: Mon, 1 Jun 2026 11:21:36 +0200 Subject: [PATCH 3/8] fix: format painter cursor and apply de-duplication Follow-up cleanups on the format-painter feature surfaced in review: 1. Paintbrush cursor was a no-op. The painter set a `--calc-grid-cursor` CSS variable on that no rule ever consumed, so the cursor never changed when the painter was armed. Replace it with a `calc-format-painter-active` class toggled on , and add a rule in the overlay's injected stylesheet that applies an Excel-style paintbrush cursor (inline SVG data-URI, white outline halo + blue bristle tip, hotspot at the tip, `copy` system fallback). The `!important` + `*` selector overrides the per-cell cursors. 2. Apply logic was duplicated three ways. The "expand a single-cell target to the source dimensions, then tile" logic lived inline in Cell onPress, the Cell PanResponder onDragEnd, and Grid's applyFormatPainterIfActive. Extract it into applyFormatPainterToDest() in style-helpers and route all three call sites through it. Header clicks always produce multi-cell selections, so unifying the header path is behavior-preserving. readCellStyle's potential Y.Doc reference aliasing was reviewed and left as-is: the re-apply goes through setYCellStyle (a deep-merge patch), so it is safe in practice regardless. Co-Authored-By: Claude Opus 4.8 --- tinycld/calc/components/Grid.tsx | 13 +++---- tinycld/calc/components/grid/Cell.tsx | 36 +++++-------------- .../components/grid/FormatPainterOverlay.tsx | 10 ++++++ tinycld/calc/components/grid/style-helpers.ts | 26 ++++++++++++++ 4 files changed, 52 insertions(+), 33 deletions(-) diff --git a/tinycld/calc/components/Grid.tsx b/tinycld/calc/components/Grid.tsx index 7d6505a..19ad04e 100644 --- a/tinycld/calc/components/Grid.tsx +++ b/tinycld/calc/components/Grid.tsx @@ -62,7 +62,7 @@ import { HandleContextMenu } from './grid/HandleContextMenu' import { HeaderContextMenu } from './grid/HeaderContextMenu' import { RowHeader } from './grid/RowHeader' import { autosizeCol, commitColWidth, commitRowHeight } from './grid/resize-actions' -import { applyFormatPainterStyles, readCellStyle } from './grid/style-helpers' +import { applyFormatPainterToDest, readCellStyle } from './grid/style-helpers' import { SortDialog } from './grid/SortDialog' import { KeyboardAccessoryHost } from './KeyboardAccessoryHost' import { MenuBar } from './menubar/MenuBar' @@ -377,20 +377,21 @@ function GridInner({ if (state.formatPainterCells == null || doc == null) return const range = primaryRange(state.selection) if (range == null) return - applyFormatPainterStyles(doc, sheetId, state.formatPainterCells, range) + applyFormatPainterToDest(doc, sheetId, state.formatPainterCells, range) state.clearFormatPainter() }, [doc, sheetId, instance.store]) useEffect(() => { if (Platform.OS !== 'web') return - const el = document.documentElement + const cls = 'calc-format-painter-active' + const root = document.documentElement if (isFormatPainterActive) { - el.style.setProperty('--calc-grid-cursor', 'cell') + root.classList.add(cls) } else { - el.style.removeProperty('--calc-grid-cursor') + root.classList.remove(cls) } return () => { - el.style.removeProperty('--calc-grid-cursor') + root.classList.remove(cls) } }, [isFormatPainterActive]) diff --git a/tinycld/calc/components/grid/Cell.tsx b/tinycld/calc/components/grid/Cell.tsx index e63b653..7986f05 100644 --- a/tinycld/calc/components/grid/Cell.tsx +++ b/tinycld/calc/components/grid/Cell.tsx @@ -24,7 +24,7 @@ import { columnLabel, formatCell } from '../../lib/workbook-types' import type { FormulaSpecialKey } from '../FormulaBar' import { FORMULA_BAR_ACCESSORY_ID } from '../formula-accessory-id' import { CommentIndicator } from './CommentIndicator' -import { applyFormatPainterStyles, locateCellAtGridCoord } from './style-helpers' +import { applyFormatPainterToDest, locateCellAtGridCoord } from './style-helpers' interface CellProps { sheetId: string @@ -237,22 +237,9 @@ export const Cell = memo(function Cell({ cellOriginRef.current = null const state = store.getState() if (state.formatPainterCells != null && doc != null) { - let destRange = primaryRange(state.selection) + const destRange = primaryRange(state.selection) if (destRange != null) { - const srcRows = state.formatPainterCells.length - const srcCols = state.formatPainterCells[0]?.length ?? 0 - const isSingleCell = - destRange.startRow === destRange.endRow && - destRange.startCol === destRange.endCol - if (isSingleCell && srcRows > 0 && srcCols > 0) { - destRange = { - startRow: destRange.startRow, - startCol: destRange.startCol, - endRow: destRange.startRow + srcRows - 1, - endCol: destRange.startCol + srcCols - 1, - } - } - applyFormatPainterStyles(doc, sheetId, state.formatPainterCells, destRange) + applyFormatPainterToDest(doc, sheetId, state.formatPainterCells, destRange) } state.clearFormatPainter() } @@ -304,17 +291,12 @@ export const Cell = memo(function Cell({ if (state.cellRefTap(row, col)) return if (state.formatPainterCells != null && doc != null) { state.selectCell({ row, col }) - const cells = state.formatPainterCells - const srcRows = cells.length - const srcCols = cells[0]?.length ?? 0 - if (srcRows > 0 && srcCols > 0) { - applyFormatPainterStyles(doc, sheetId, cells, { - startRow: row, - startCol: col, - endRow: row + srcRows - 1, - endCol: col + srcCols - 1, - }) - } + applyFormatPainterToDest(doc, sheetId, state.formatPainterCells, { + startRow: row, + startCol: col, + endRow: row, + endCol: col, + }) state.clearFormatPainter() return } diff --git a/tinycld/calc/components/grid/FormatPainterOverlay.tsx b/tinycld/calc/components/grid/FormatPainterOverlay.tsx index 96aeb67..8113ba7 100644 --- a/tinycld/calc/components/grid/FormatPainterOverlay.tsx +++ b/tinycld/calc/components/grid/FormatPainterOverlay.tsx @@ -9,6 +9,13 @@ import { useGridStore } from '../../hooks/use-grid-store' const PAINTER_STYLE_ID = 'tinycld-calc-painter-ants-style' +// Paintbrush cursor shown while the painter is armed (Excel-style). White +// outline halo under filled shapes so it reads on any background; hotspot +// at the bristle tip (4, 28). Toggled by the `calc-format-painter-active` +// class on , set from Grid's painter effect. +const PAINTER_CURSOR_SVG = `` +const PAINTER_CURSOR = `url("data:image/svg+xml,${encodeURIComponent(PAINTER_CURSOR_SVG)}") 4 28, copy` + interface FormatPainterOverlayProps { colOffsets: Float64Array rowOffsets: Float64Array @@ -38,6 +45,9 @@ export function FormatPainterOverlay({ colOffsets, rowOffsets }: FormatPainterOv background-repeat: repeat-x, repeat-x, repeat-y, repeat-y; animation: tinycld-calc-painter-ants 0.6s linear infinite; } +html.calc-format-painter-active, html.calc-format-painter-active * { + cursor: ${PAINTER_CURSOR} !important; +} ` document.head.appendChild(style) }, []) diff --git a/tinycld/calc/components/grid/style-helpers.ts b/tinycld/calc/components/grid/style-helpers.ts index a73360d..b95e110 100644 --- a/tinycld/calc/components/grid/style-helpers.ts +++ b/tinycld/calc/components/grid/style-helpers.ts @@ -169,6 +169,32 @@ export function applyFormatPainterStyles( }, LOCAL_ORIGIN) } +// applyFormatPainterToDest applies the painter onto a destination range, +// expanding a single-cell target to the full source dimensions first (so +// clicking one cell stamps the whole captured block). Multi-cell targets +// — e.g. a dragged region or a whole row/column — are tiled as-is. +export function applyFormatPainterToDest( + doc: Y.Doc, + sheetId: string, + cells: CellStyle[][], + destRange: CellRange +): void { + const srcRows = cells.length + const srcCols = cells[0]?.length ?? 0 + if (srcRows === 0 || srcCols === 0) return + const isSingleCell = + destRange.startRow === destRange.endRow && destRange.startCol === destRange.endCol + const target = isSingleCell + ? { + startRow: destRange.startRow, + startCol: destRange.startCol, + endRow: destRange.startRow + srcRows - 1, + endCol: destRange.startCol + srcCols - 1, + } + : destRange + applyFormatPainterStyles(doc, sheetId, cells, target) +} + // locateCellAtGridCoord maps an (x, y) inside the grid body to the // 1-based (row, col) of the cell at that point. Used by the cell // PanResponder to translate pointer-move locations into the cell the From 45371401242b8a3dff1a716d63dc418bb117cae6 Mon Sep 17 00:00:00 2001 From: Stefan N Date: Mon, 1 Jun 2026 11:25:47 +0200 Subject: [PATCH 4/8] fix: redraw format painter cursor to match Excel glyph The first paintbrush-only cursor read as an ugly blob. Replace it with the classic Excel format-painter glyph: a bold white cross (the click target, hotspot at its center) with a small paintbrush to the right. White fills with a black outline so it stays legible on any cell background. Co-Authored-By: Claude Opus 4.8 --- .../calc/components/grid/FormatPainterOverlay.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tinycld/calc/components/grid/FormatPainterOverlay.tsx b/tinycld/calc/components/grid/FormatPainterOverlay.tsx index 8113ba7..a384554 100644 --- a/tinycld/calc/components/grid/FormatPainterOverlay.tsx +++ b/tinycld/calc/components/grid/FormatPainterOverlay.tsx @@ -9,12 +9,13 @@ import { useGridStore } from '../../hooks/use-grid-store' const PAINTER_STYLE_ID = 'tinycld-calc-painter-ants-style' -// Paintbrush cursor shown while the painter is armed (Excel-style). White -// outline halo under filled shapes so it reads on any background; hotspot -// at the bristle tip (4, 28). Toggled by the `calc-format-painter-active` -// class on , set from Grid's painter effect. -const PAINTER_CURSOR_SVG = `` -const PAINTER_CURSOR = `url("data:image/svg+xml,${encodeURIComponent(PAINTER_CURSOR_SVG)}") 4 28, copy` +// Paintbrush cursor shown while the painter is armed: the classic Excel +// format-painter glyph — a bold white cross (the click target) with a +// small paintbrush to its right. White fills with black outline so it +// reads on any background; hotspot at the cross center (8, 9). Toggled by +// the `calc-format-painter-active` class on , set from Grid's effect. +const PAINTER_CURSOR_SVG = `` +const PAINTER_CURSOR = `url("data:image/svg+xml,${encodeURIComponent(PAINTER_CURSOR_SVG)}") 8 9, copy` interface FormatPainterOverlayProps { colOffsets: Float64Array From bbb774ee8d152f563901039050572dd1d076b9b8 Mon Sep 17 00:00:00 2001 From: Stefan N Date: Mon, 1 Jun 2026 11:36:16 +0200 Subject: [PATCH 5/8] test: cover format painter, clear-content, paste-format, select-all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fill the unit-test gaps for the recent fixes and the format-painter feature — all pure / Y.Doc-level functions, no React harness needed: 1. applyFormatPainterStyles / applyFormatPainterToDest — row-major modulo tiling, the single-cell→source-size expansion, and multi-cell targets left un-expanded. 2. clearYCellContent — wipes value fields while preserving the style map, and removes the entry entirely when the cell has no style. 3. Clipboard numFmt round-trip — guards that Paste → Format only carries number format (and colors) through the HTML encode/decode path. 4. selectAll store action — scope/anchor/range and empty-grid clamp, the action the corner-cell click depends on. Co-Authored-By: Claude Opus 4.8 --- tests/clipboard-decode-html.test.ts | 16 ++++++ tests/grid-store-scope.test.ts | 34 ++++++++++++ tests/range-style-helpers.test.ts | 81 +++++++++++++++++++++++++++++ tests/use-y-cell.test.ts | 44 +++++++++++++++- 4 files changed, 174 insertions(+), 1 deletion(-) diff --git a/tests/clipboard-decode-html.test.ts b/tests/clipboard-decode-html.test.ts index 8c2a8d0..f0c03bf 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?.toLowerCase()).toContain('ff0000') + }) + it('recovers cells containing HTML special chars', () => { const source = p([[{ kind: 'string', raw: '&"' }]]) const html = payloadToHtml(source, 'm') diff --git a/tests/grid-store-scope.test.ts b/tests/grid-store-scope.test.ts index efa25e2..b7d6e78 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/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() From ec05069ac5106fca460f176a4c2d14cf80c3a661 Mon Sep 17 00:00:00 2001 From: Stefan N Date: Mon, 1 Jun 2026 11:41:06 +0200 Subject: [PATCH 6/8] fix: normalize pasted colors to canonical #RRGGBB form The HTML clipboard decoder stored colors sans `#` (e.g. `FF0000`) while the color picker writes `#`-prefixed hex (e.g. `#B00020`) to font.color / fill.fgColor. Both render fine via normalizeColor, but the forms aren't byte-identical, so swatch active-matching (ColorPickerGrid compares with `selected === swatch.hex`) and any color equality break for pasted cells. Make parseCssColor emit canonical `#RRGGBB` uppercase for both hex and rgb() inputs, matching the picker. Update the decode tests accordingly. Co-Authored-By: Claude Opus 4.8 --- tests/clipboard-decode-html.test.ts | 6 +++--- tinycld/calc/lib/clipboard/decode-html.ts | 19 +++++++++++-------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/tests/clipboard-decode-html.test.ts b/tests/clipboard-decode-html.test.ts index f0c03bf..f3a7199 100644 --- a/tests/clipboard-decode-html.test.ts +++ b/tests/clipboard-decode-html.test.ts @@ -107,7 +107,7 @@ describe('htmlToPayload — own-encoder round trip', () => { const html = payloadToHtml(source, 'm') const style = htmlToPayload(html)?.payload.cells[0][0].style expect(style?.numFmt).toBe('#,##0.00') - expect(style?.font?.color?.toLowerCase()).toContain('ff0000') + expect(style?.font?.color).toBe('#FF0000') }) it('recovers cells containing HTML special chars', () => { @@ -170,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/tinycld/calc/lib/clipboard/decode-html.ts b/tinycld/calc/lib/clipboard/decode-html.ts index 56296ea..27b73e2 100644 --- a/tinycld/calc/lib/clipboard/decode-html.ts +++ b/tinycld/calc/lib/clipboard/decode-html.ts @@ -290,27 +290,30 @@ function parsePx(value: string): number | null { } function parseCssColor(value: string): string | null { - // Hex with hash: keep verbatim, sans hash for excelize-compat - // storage (matches what payloadToHtml normalises FROM). + // Normalise to canonical `#RRGGBB` (uppercase): the same form the + // color picker writes to font.color / fill.fgColor. Keeping pasted + // and picker-set colors byte-identical lets swatch active-matching + // and color equality work uniformly. const hex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.exec(value) if (hex != null) { const h = hex[1] if (h.length === 3) { - return h + return `#${h .split('') .map(c => c + c) - .join('') + .join('')}`.toUpperCase() } - if (h.length === 8) return h.slice(2) - return h + // 8-digit is #AARRGGBB — drop the leading alpha pair. + if (h.length === 8) return `#${h.slice(2)}`.toUpperCase() + return `#${h}`.toUpperCase() } // rgb()/rgba() — convert to hex for symmetry with our encoder. const rgb = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(value) if (rgb != null) { - return [rgb[1], rgb[2], rgb[3]] + return `#${[rgb[1], rgb[2], rgb[3]] .map(n => Number(n).toString(16).padStart(2, '0')) .join('') - .toUpperCase() + .toUpperCase()}` } return null } From 2c034d1a799470032766371e6855c90b37f087f1 Mon Sep 17 00:00:00 2001 From: Stefan N Date: Mon, 1 Jun 2026 21:37:07 +0200 Subject: [PATCH 7/8] implement review comments --- help/clipboard.md | 16 +++++++-- help/keyboard-shortcuts.md | 6 ++-- tests/grid-store-scope.test.ts | 2 +- tinycld/calc/components/Grid.tsx | 8 ++--- tinycld/calc/components/grid/Body.tsx | 2 +- tinycld/calc/components/grid/Cell.tsx | 35 ++++++++++++++----- tinycld/calc/components/grid/ColumnHeader.tsx | 4 +-- tinycld/calc/components/grid/RowHeader.tsx | 4 +-- tinycld/calc/components/grid/style-helpers.ts | 12 +++++-- tinycld/calc/components/menubar/EditMenu.tsx | 4 +-- tinycld/calc/hooks/grid-store.ts | 2 +- tinycld/calc/hooks/use-calc-shortcuts.ts | 4 +-- 12 files changed, 66 insertions(+), 33 deletions(-) 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/grid-store-scope.test.ts b/tests/grid-store-scope.test.ts index b7d6e78..1749213 100644 --- a/tests/grid-store-scope.test.ts +++ b/tests/grid-store-scope.test.ts @@ -78,7 +78,7 @@ describe('selectColumn', () => { }) describe('selectAll (corner-cell click)', () => { - it("sets scope to sheet, anchor to (1, 1), and a full-grid range", () => { + 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() diff --git a/tinycld/calc/components/Grid.tsx b/tinycld/calc/components/Grid.tsx index 19ad04e..86cbea7 100644 --- a/tinycld/calc/components/Grid.tsx +++ b/tinycld/calc/components/Grid.tsx @@ -32,7 +32,6 @@ import { useWorkbook } from '../hooks/use-workbook-context' import type { WorkbookFileActions } from '../hooks/use-workbook-file-actions' import { useAllYSheets, useYSheets } from '../hooks/use-y-sheets' import { classifyCellKey } from '../lib/cell-key-action' -import type { CellStyle } from '../lib/workbook-types' import { rangeToSheetRelativeA1 } from '../lib/conditional-format/a1' import { buildColOffsets, buildRowOffsets } from '../lib/dimensions' import { buildA1Range } from '../lib/pivot/range-parse' @@ -46,6 +45,7 @@ import { 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' @@ -62,8 +62,8 @@ import { HandleContextMenu } from './grid/HandleContextMenu' import { HeaderContextMenu } from './grid/HeaderContextMenu' import { RowHeader } from './grid/RowHeader' import { autosizeCol, commitColWidth, commitRowHeight } from './grid/resize-actions' -import { applyFormatPainterToDest, readCellStyle } from './grid/style-helpers' 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' @@ -377,9 +377,9 @@ function GridInner({ if (state.formatPainterCells == null || doc == null) return const range = primaryRange(state.selection) if (range == null) return - applyFormatPainterToDest(doc, sheetId, state.formatPainterCells, range) + applyFormatPainterToDest(doc, sheetId, state.formatPainterCells, range, rows, cols) state.clearFormatPainter() - }, [doc, sheetId, instance.store]) + }, [doc, sheetId, instance.store, rows, cols]) useEffect(() => { if (Platform.OS !== 'web') return diff --git a/tinycld/calc/components/grid/Body.tsx b/tinycld/calc/components/grid/Body.tsx index b990b18..33f0f95 100644 --- a/tinycld/calc/components/grid/Body.tsx +++ b/tinycld/calc/components/grid/Body.tsx @@ -54,8 +54,8 @@ import type { SheetWithId } from '../../hooks/use-y-sheets' import type { FormulaSpecialKey } from '../FormulaBar' import { Cell } from './Cell' import { CutMarchingAntsOverlay } from './CutMarchingAntsOverlay' -import { FormatPainterOverlay } from './FormatPainterOverlay' import { FindMatchOverlay } from './FindMatchOverlay' +import { FormatPainterOverlay } from './FormatPainterOverlay' import { FillPreviewOverlay, LocalSelectionOverlay, diff --git a/tinycld/calc/components/grid/Cell.tsx b/tinycld/calc/components/grid/Cell.tsx index 7986f05..a906374 100644 --- a/tinycld/calc/components/grid/Cell.tsx +++ b/tinycld/calc/components/grid/Cell.tsx @@ -19,7 +19,12 @@ import { useYCell } from '../../hooks/use-y-cell' import type { ArrowDirection } from '../../lib/cell-key-action' import { type CellKeyEvent, classifyCellKey } from '../../lib/cell-key-action' import { cellStyleToRenderProps, mergeCellStyles } from '../../lib/cell-style-render' -import { computeShiftArrowTarget, containsAny, primaryAnchor, primaryRange } from '../../lib/selection-range' +import { + computeShiftArrowTarget, + containsAny, + primaryAnchor, + primaryRange, +} from '../../lib/selection-range' import { columnLabel, formatCell } from '../../lib/workbook-types' import type { FormulaSpecialKey } from '../FormulaBar' import { FORMULA_BAR_ACCESSORY_ID } from '../formula-accessory-id' @@ -239,7 +244,14 @@ export const Cell = memo(function Cell({ if (state.formatPainterCells != null && doc != null) { const destRange = primaryRange(state.selection) if (destRange != null) { - applyFormatPainterToDest(doc, sheetId, state.formatPainterCells, destRange) + applyFormatPainterToDest( + doc, + sheetId, + state.formatPainterCells, + destRange, + rowOffsets.length - 1, + colOffsets.length - 1 + ) } state.clearFormatPainter() } @@ -291,12 +303,19 @@ export const Cell = memo(function Cell({ if (state.cellRefTap(row, col)) return if (state.formatPainterCells != null && doc != null) { state.selectCell({ row, col }) - applyFormatPainterToDest(doc, sheetId, state.formatPainterCells, { - startRow: row, - startCol: col, - endRow: row, - endCol: col, - }) + applyFormatPainterToDest( + doc, + sheetId, + state.formatPainterCells, + { + startRow: row, + startCol: col, + endRow: row, + endCol: col, + }, + rowOffsets.length - 1, + colOffsets.length - 1 + ) state.clearFormatPainter() return } diff --git a/tinycld/calc/components/grid/ColumnHeader.tsx b/tinycld/calc/components/grid/ColumnHeader.tsx index 3064515..a796060 100644 --- a/tinycld/calc/components/grid/ColumnHeader.tsx +++ b/tinycld/calc/components/grid/ColumnHeader.tsx @@ -92,9 +92,7 @@ export function ColumnHeader({ }) const selectedColSet = useMemo( () => - selectedColsKey - ? new Set(selectedColsKey.split(',').map(Number)) - : new Set(), + selectedColsKey ? new Set(selectedColsKey.split(',').map(Number)) : new Set(), [selectedColsKey] ) const store = useGridStoreApi() diff --git a/tinycld/calc/components/grid/RowHeader.tsx b/tinycld/calc/components/grid/RowHeader.tsx index fb4ce67..18c7af8 100644 --- a/tinycld/calc/components/grid/RowHeader.tsx +++ b/tinycld/calc/components/grid/RowHeader.tsx @@ -70,9 +70,7 @@ export function RowHeader({ }) const selectedRowSet = useMemo( () => - selectedRowsKey - ? new Set(selectedRowsKey.split(',').map(Number)) - : new Set(), + selectedRowsKey ? new Set(selectedRowsKey.split(',').map(Number)) : new Set(), [selectedRowsKey] ) const store = useGridStoreApi() diff --git a/tinycld/calc/components/grid/style-helpers.ts b/tinycld/calc/components/grid/style-helpers.ts index b95e110..bb86ae5 100644 --- a/tinycld/calc/components/grid/style-helpers.ts +++ b/tinycld/calc/components/grid/style-helpers.ts @@ -177,14 +177,16 @@ export function applyFormatPainterToDest( doc: Y.Doc, sheetId: string, cells: CellStyle[][], - destRange: CellRange + destRange: CellRange, + rowCount = Number.POSITIVE_INFINITY, + colCount = Number.POSITIVE_INFINITY ): void { const srcRows = cells.length const srcCols = cells[0]?.length ?? 0 if (srcRows === 0 || srcCols === 0) return const isSingleCell = destRange.startRow === destRange.endRow && destRange.startCol === destRange.endCol - const target = isSingleCell + const expanded = isSingleCell ? { startRow: destRange.startRow, startCol: destRange.startCol, @@ -192,6 +194,12 @@ export function applyFormatPainterToDest( endCol: destRange.startCol + srcCols - 1, } : destRange + const target = { + startRow: expanded.startRow, + startCol: expanded.startCol, + endRow: Math.min(expanded.endRow, rowCount), + endCol: Math.min(expanded.endCol, colCount), + } applyFormatPainterStyles(doc, sheetId, cells, target) } diff --git a/tinycld/calc/components/menubar/EditMenu.tsx b/tinycld/calc/components/menubar/EditMenu.tsx index 4a0395b..c84bb19 100644 --- a/tinycld/calc/components/menubar/EditMenu.tsx +++ b/tinycld/calc/components/menubar/EditMenu.tsx @@ -32,11 +32,11 @@ export function EditMenu(props: MenuBarProps) { Values only - + Format only - + diff --git a/tinycld/calc/hooks/grid-store.ts b/tinycld/calc/hooks/grid-store.ts index 5619e51..cb58562 100644 --- a/tinycld/calc/hooks/grid-store.ts +++ b/tinycld/calc/hooks/grid-store.ts @@ -29,7 +29,6 @@ // `lib/selection-range.ts` for the helper layer call sites use. import { createStore as createVanillaStore, type StoreApi } from 'zustand/vanilla' import type { ArrowDirection } from '../lib/cell-key-action' -import type { CellStyle } from '../lib/workbook-types' import { applyFunctionInsertion, applyNameInsertion, @@ -55,6 +54,7 @@ import { singleRectSelection, subRangeAtCell, } from '../lib/selection-range' +import type { CellStyle } from '../lib/workbook-types' export interface SelectedCell { row: number diff --git a/tinycld/calc/hooks/use-calc-shortcuts.ts b/tinycld/calc/hooks/use-calc-shortcuts.ts index e3f5eb5..971989c 100644 --- a/tinycld/calc/hooks/use-calc-shortcuts.ts +++ b/tinycld/calc/hooks/use-calc-shortcuts.ts @@ -118,7 +118,7 @@ const SHORTCUT_DOCS: readonly ShortcutEntry[] = [ }, { id: 'calc.clipboard.pasteValues', - keys: '$mod+Shift+v', + keys: '$mod+Alt+v', description: 'Paste values only', group: 'Calc', scope: 'global', @@ -136,7 +136,7 @@ const SHORTCUT_DOCS: readonly ShortcutEntry[] = [ }, { id: 'calc.clipboard.pasteFormat', - keys: '$mod+Alt+v', + keys: '$mod+Shift+v', description: 'Paste format only', group: 'Calc', scope: 'global', From 8b9a96e68fa63515e344ce1d2343033196774256 Mon Sep 17 00:00:00 2001 From: Stefan N Date: Mon, 1 Jun 2026 22:50:25 +0200 Subject: [PATCH 8/8] fix: paste doesn't work on safari MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This addresses a Safari paste failure. The root cause was that Safari rejects the async navigator.clipboard.read()/readText() when invoked from a keydown-driven tinykeys shortcut (after any await, the user-gesture context is lost and permission is required). The fix: 1. Removes the $mod+v tinykeys shortcut (use-calc-shortcuts.ts) so the browser fires its native paste event instead of swallowing the key. 2. Adds a native paste listener in Grid.tsx that reads event.clipboardData synchronously — no permission, works in all browsers. 3. readFromClipboardEvent mirrors the existing async readFromOsClipboard logic exactly (html → marker → fidelity store → fallback to tsv), so paste fidelity is preserved. --- tinycld/calc/components/Grid.tsx | 25 ++++++++ tinycld/calc/hooks/use-calc-shortcuts.ts | 14 ----- tinycld/calc/hooks/use-clipboard.ts | 24 +++++++- tinycld/calc/lib/clipboard/adapter-web.ts | 73 ++++++++++++++--------- 4 files changed, 93 insertions(+), 43 deletions(-) diff --git a/tinycld/calc/components/Grid.tsx b/tinycld/calc/components/Grid.tsx index 86cbea7..98ffc61 100644 --- a/tinycld/calc/components/Grid.tsx +++ b/tinycld/calc/components/Grid.tsx @@ -437,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