From 5fe4e512b96c2251cca2704ade80cea0dc32a0fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Fri, 26 Jun 2026 14:27:23 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../canvas-spreadsheet/CanvasSpreadsheet.vue | 2795 +++++++++++++++++ .../canvas/SpreadsheetRenderer.ts | 950 ++++++ .../canvas-spreadsheet/canvas/columnLayout.ts | 83 + .../canvas-spreadsheet/canvas/theme.ts | 146 + .../commands/FillRangeCommand.ts | 64 + .../commands/PasteRangeCommand.ts | 15 + .../commands/SetCellValueCommand.ts | 12 + .../composables/cellDisplay.ts | 55 + .../composables/columnTextAlign.ts | 13 + .../composables/filterSort.ts | 155 + .../composables/useAutoMeasure.ts | 219 ++ .../composables/useClipboard.ts | 60 + .../composables/useEditHistory.ts | 67 + .../composables/useFormulaReferenceMode.ts | 49 + .../composables/useSelection.ts | 268 ++ .../composables/useSpreadsheetContextMenu.ts | 270 ++ .../composables/useSpreadsheetEngine.ts | 784 +++++ .../composables/useSpreadsheetFind.ts | 125 + .../composables/useUndoRedo.ts | 47 + .../editor/CellEditorOverlay.vue | 238 ++ .../canvas-spreadsheet/formula/evaluator.ts | 191 ++ .../header/HeaderColumnFilter.vue | 297 ++ .../header/HeaderFilterLayer.vue | 150 + .../SpreadsheetHeaderColorPickerPopover.vue | 117 + .../history/layoutLocalStore.ts | 41 + .../history/localHistoryStore.ts | 94 + .../history/remoteHistoryApi.ts | 54 + src/components/canvas-spreadsheet/index.ts | 4 + .../toolbar/SpreadsheetFindBar.vue | 161 + .../toolbar/SpreadsheetFormulaBar.vue | 292 ++ .../SpreadsheetFormulaLibraryModal.vue | 194 ++ .../toolbar/SpreadsheetToolbar.vue | 304 ++ src/components/canvas-spreadsheet/types.ts | 206 ++ 33 files changed, 8520 insertions(+) create mode 100644 src/components/canvas-spreadsheet/CanvasSpreadsheet.vue create mode 100644 src/components/canvas-spreadsheet/canvas/SpreadsheetRenderer.ts create mode 100644 src/components/canvas-spreadsheet/canvas/columnLayout.ts create mode 100644 src/components/canvas-spreadsheet/canvas/theme.ts create mode 100644 src/components/canvas-spreadsheet/commands/FillRangeCommand.ts create mode 100644 src/components/canvas-spreadsheet/commands/PasteRangeCommand.ts create mode 100644 src/components/canvas-spreadsheet/commands/SetCellValueCommand.ts create mode 100644 src/components/canvas-spreadsheet/composables/cellDisplay.ts create mode 100644 src/components/canvas-spreadsheet/composables/columnTextAlign.ts create mode 100644 src/components/canvas-spreadsheet/composables/filterSort.ts create mode 100644 src/components/canvas-spreadsheet/composables/useAutoMeasure.ts create mode 100644 src/components/canvas-spreadsheet/composables/useClipboard.ts create mode 100644 src/components/canvas-spreadsheet/composables/useEditHistory.ts create mode 100644 src/components/canvas-spreadsheet/composables/useFormulaReferenceMode.ts create mode 100644 src/components/canvas-spreadsheet/composables/useSelection.ts create mode 100644 src/components/canvas-spreadsheet/composables/useSpreadsheetContextMenu.ts create mode 100644 src/components/canvas-spreadsheet/composables/useSpreadsheetEngine.ts create mode 100644 src/components/canvas-spreadsheet/composables/useSpreadsheetFind.ts create mode 100644 src/components/canvas-spreadsheet/composables/useUndoRedo.ts create mode 100644 src/components/canvas-spreadsheet/editor/CellEditorOverlay.vue create mode 100644 src/components/canvas-spreadsheet/formula/evaluator.ts create mode 100644 src/components/canvas-spreadsheet/header/HeaderColumnFilter.vue create mode 100644 src/components/canvas-spreadsheet/header/HeaderFilterLayer.vue create mode 100644 src/components/canvas-spreadsheet/header/SpreadsheetHeaderColorPickerPopover.vue create mode 100644 src/components/canvas-spreadsheet/history/layoutLocalStore.ts create mode 100644 src/components/canvas-spreadsheet/history/localHistoryStore.ts create mode 100644 src/components/canvas-spreadsheet/history/remoteHistoryApi.ts create mode 100644 src/components/canvas-spreadsheet/index.ts create mode 100644 src/components/canvas-spreadsheet/toolbar/SpreadsheetFindBar.vue create mode 100644 src/components/canvas-spreadsheet/toolbar/SpreadsheetFormulaBar.vue create mode 100644 src/components/canvas-spreadsheet/toolbar/SpreadsheetFormulaLibraryModal.vue create mode 100644 src/components/canvas-spreadsheet/toolbar/SpreadsheetToolbar.vue create mode 100644 src/components/canvas-spreadsheet/types.ts diff --git a/src/components/canvas-spreadsheet/CanvasSpreadsheet.vue b/src/components/canvas-spreadsheet/CanvasSpreadsheet.vue new file mode 100644 index 0000000..243dd4b --- /dev/null +++ b/src/components/canvas-spreadsheet/CanvasSpreadsheet.vue @@ -0,0 +1,2795 @@ + + + + + + + + + + diff --git a/src/components/canvas-spreadsheet/canvas/SpreadsheetRenderer.ts b/src/components/canvas-spreadsheet/canvas/SpreadsheetRenderer.ts new file mode 100644 index 0000000..8f4c690 --- /dev/null +++ b/src/components/canvas-spreadsheet/canvas/SpreadsheetRenderer.ts @@ -0,0 +1,950 @@ +import type { SpreadsheetTheme } from './theme'; +import { getSpreadsheetTheme, resolveHeaderColor } from './theme'; +import type { + CellRange, + CellType, + SpreadsheetColumnConfig, + SpreadsheetHeaderConfig, + SpreadsheetTextAlign, +} from '../types'; +import { + COLUMN_LETTER_HEIGHT, + DEFAULT_COL_WIDTH, + DEFAULT_ROW_HEIGHT, + HEADER_HEIGHT, + HEADER_TITLE_HEIGHT, + ROW_NUMBER_WIDTH, + SCROLL_BUFFER_ROWS, +} from '../types'; +import { getRowTop } from '../composables/useAutoMeasure'; +import { formatCellWithSuffix } from '../composables/cellDisplay'; +import { resolveColumnTextAlign } from '../composables/columnTextAlign'; +import { colIndexToLetters } from '../formula/evaluator'; +import { iterateRanges, normalizeRange, rangesContainCell } from '../commands/FillRangeCommand'; +import { + computeFixedWidth, + getDataColX, + getFixedColIndices, + isFixedCol, +} from './columnLayout'; + +export { + computeFixedWidth, + getDataColX, + getFixedColIndices, +} from './columnLayout'; + +export interface RenderOptions { + headers: SpreadsheetHeaderConfig[]; + columns: SpreadsheetColumnConfig[]; + rows: Record[]; + colWidths: number[]; + rowHeight: number; + scrollTop: number; + scrollLeft: number; + viewportWidth: number; + viewportHeight: number; + displayRowCount: number; + colCount: number; + displayColCount: number; + defaultColWidth: number; + defaultTextAlign: SpreadsheetTextAlign; + isDark: boolean; + fixedCols: number[]; + fixedWidth: number; + selectionRanges: CellRange[]; + activeCell: { row: number; col: number } | null; + dirtyKeys: Set; + filterValues: Record; + filteredRowIndices: number[]; + showFillHandle: boolean; + theme: SpreadsheetTheme; + findMatches?: Array<{ row: number; col: number }>; + findActiveIndex?: number; + resolveDisplayValue?: ( + viewRow: number, + colCfg: SpreadsheetColumnConfig, + row?: Record, + ) => unknown; + resolveCellStyle?: ( + viewRow: number, + colCfg: SpreadsheetColumnConfig, + row?: Record, + ) => { bgColor?: string; textColor?: string } | undefined; + referenceCell?: { row: number; col: number } | null; +} + +function colByIndex(columns: SpreadsheetColumnConfig[], col: number) { + return columns.find((c) => c.col === col); +} + +function isInSelection( + row: number, + col: number, + ranges: CellRange[], +): boolean { + return rangesContainCell(ranges, row, col); +} + +function isFindMatch( + row: number, + col: number, + matches?: Array<{ row: number; col: number }>, +): boolean { + if (!matches?.length) return false; + return matches.some((m) => m.row === row && m.col === col); +} + +function isActiveFindMatch( + row: number, + col: number, + matches?: Array<{ row: number; col: number }>, + activeIndex?: number, +): boolean { + if (activeIndex === undefined || activeIndex < 0 || !matches?.length) { + return false; + } + const m = matches[activeIndex]; + return !!m && m.row === row && m.col === col; +} + +function isRowInSelection( + row: number, + ranges: CellRange[], + maxCol: number, +): boolean { + for (const range of ranges) { + const r = normalizeRange(range); + if (r.startCol === 0 && r.endCol === maxCol && row >= r.startRow && row <= r.endRow) { + return true; + } + } + return false; +} + +function isColInSelection( + col: number, + ranges: CellRange[], + maxRow: number, +): boolean { + for (const range of ranges) { + const r = normalizeRange(range); + if (r.startRow === 0 && r.endRow === maxRow && col >= r.startCol && col <= r.endCol) { + return true; + } + } + return false; +} + +function isAllSelected( + ranges: CellRange[], + maxRow: number, + maxCol: number, +): boolean { + if (ranges.length !== 1) return false; + const r = normalizeRange(ranges[0]!); + return r.startRow === 0 && r.endRow === maxRow && r.startCol === 0 && r.endCol === maxCol; +} + +function formatCellDisplay(value: unknown, cellType: CellType): string { + if (value === null || value === undefined) return ''; + if (cellType === 'number') { + const n = Number(value); + return Number.isFinite(n) ? String(n) : String(value); + } + if (typeof value === 'object') return JSON.stringify(value); + return String(value); +} + +function drawAlignedText( + ctx: CanvasRenderingContext2D, + text: string, + x: number, + y: number, + w: number, + align: SpreadsheetTextAlign, +) { + ctx.textBaseline = 'middle'; + if (align === 'center') { + ctx.textAlign = 'center'; + ctx.fillText(text, x + w / 2, y); + } else if (align === 'right') { + ctx.textAlign = 'right'; + ctx.fillText(text, x + w - 8, y); + } else { + ctx.textAlign = 'left'; + ctx.fillText(text, x + 8, y, w - 16); + } + ctx.textAlign = 'left'; +} + +function drawCellContent( + ctx: CanvasRenderingContext2D, + value: unknown, + cellType: CellType, + colCfg: SpreadsheetColumnConfig | undefined, + x: number, + y: number, + w: number, + h: number, + theme: SpreadsheetTheme, + textColor: string | undefined, + defaultTextAlign: SpreadsheetTextAlign, +) { + ctx.save(); + ctx.beginPath(); + ctx.rect(x, y, w, h); + ctx.clip(); + + const align = resolveColumnTextAlign(colCfg, defaultTextAlign); + + if (cellType === 'image' && value) { + ctx.fillStyle = theme.imagePlaceholder; + ctx.fillRect(x + 4, y + 4, h - 8, h - 8); + ctx.fillStyle = theme.textMuted; + ctx.font = '11px sans-serif'; + drawAlignedText(ctx, 'IMG', x, y + h / 2, w, align); + } else { + ctx.fillStyle = textColor ?? theme.text; + ctx.font = '13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; + const text = colCfg + ? formatCellWithSuffix(value, colCfg) + : formatCellDisplay(value, cellType); + drawAlignedText(ctx, text, x, y + h / 2, w, align); + } + ctx.restore(); +} + +function drawRowNumber( + ctx: CanvasRenderingContext2D, + y: number, + rowHeight: number, + label: string, + theme: SpreadsheetTheme, + selected = false, +) { + ctx.fillStyle = selected ? theme.selectionBg : theme.rowNumBg; + ctx.fillRect(0, y, ROW_NUMBER_WIDTH, rowHeight); + ctx.strokeStyle = theme.border; + ctx.strokeRect(0, y, ROW_NUMBER_WIDTH, rowHeight); + ctx.fillStyle = theme.textMuted; + ctx.font = '12px sans-serif'; + ctx.textBaseline = 'middle'; + ctx.fillText(label, ROW_NUMBER_WIDTH / 2 - 4, y + rowHeight / 2); +} + +function drawHeaderCorner( + ctx: CanvasRenderingContext2D, + theme: SpreadsheetTheme, + allSelected = false, +) { + ctx.fillStyle = allSelected ? theme.selectionBg : theme.rowNumBg; + ctx.fillRect(0, 0, ROW_NUMBER_WIDTH, HEADER_HEIGHT); + ctx.strokeStyle = theme.border; + ctx.strokeRect(0, 0, ROW_NUMBER_WIDTH, HEADER_HEIGHT); + ctx.fillStyle = theme.textMuted; + ctx.font = '12px sans-serif'; + ctx.textBaseline = 'middle'; + ctx.fillText( + '#', + ROW_NUMBER_WIDTH / 2 - 4, + COLUMN_LETTER_HEIGHT + HEADER_TITLE_HEIGHT / 2, + ); +} + +function drawDataCell( + ctx: CanvasRenderingContext2D, + options: { + x: number; + y: number; + cw: number; + rowHeight: number; + bg: string; + theme: SpreadsheetTheme; + value?: unknown; + cellType?: CellType; + colCfg?: SpreadsheetColumnConfig; + textColor?: string; + empty?: boolean; + defaultTextAlign: SpreadsheetTextAlign; + }, +) { + const { + x, + y, + cw, + rowHeight, + bg, + theme, + value, + cellType, + colCfg, + textColor, + empty, + defaultTextAlign, + } = options; + ctx.fillStyle = bg; + ctx.fillRect(x, y, cw, rowHeight); + ctx.strokeStyle = theme.border; + ctx.strokeRect(x, y, cw, rowHeight); + if (!empty && cellType && value !== undefined) { + drawCellContent( + ctx, + value, + cellType, + colCfg, + x, + y, + cw, + rowHeight, + theme, + textColor, + defaultTextAlign, + ); + } +} + +function resolveCellBg( + options: RenderOptions, + vi: number, + col: number, + colCfg: SpreadsheetColumnConfig | undefined, + row: Record | undefined, + isEmpty: boolean, + dataRow: number | undefined, +): string { + const { theme } = options; + const selected = isInSelection(vi, col, options.selectionRanges); + const findActive = isActiveFindMatch( + vi, + col, + options.findMatches, + options.findActiveIndex, + ); + const findMatch = isFindMatch(vi, col, options.findMatches); + + if (isEmpty || !colCfg) { + if (findActive) return theme.findActiveMatchBg; + if (findMatch) return theme.findMatchBg; + return selected ? theme.selectionBg : theme.cellBg; + } + const dirtyKey = + dataRow !== undefined ? `${dataRow}:${colCfg.fieldId}` : ''; + const cellStyle = options.resolveCellStyle?.(vi, colCfg, row); + if (options.dirtyKeys.has(dirtyKey)) return theme.dirtyBg; + if (findActive) return theme.findActiveMatchBg; + if (findMatch) return theme.findMatchBg; + if (cellStyle?.bgColor) return cellStyle.bgColor; + if (selected) return theme.selectionBg; + return theme.cellBg; +} + +function drawHeaderCell( + ctx: CanvasRenderingContext2D, + options: RenderOptions, + col: number, + cw: number, + x: number, + title: string, + headerColor?: string, +) { + const { theme } = options; + const letterY = 0; + const titleY = COLUMN_LETTER_HEIGHT; + const maxRow = Math.max(0, options.filteredRowIndices.length - 1); + const colSelected = isColInSelection(col, options.selectionRanges, maxRow); + + ctx.fillStyle = colSelected ? theme.selectionBg : theme.headerBg; + ctx.fillRect(x, letterY, cw, COLUMN_LETTER_HEIGHT); + ctx.strokeStyle = theme.border; + ctx.strokeRect(x, letterY, cw, COLUMN_LETTER_HEIGHT); + ctx.fillStyle = theme.textMuted; + ctx.font = '11px sans-serif'; + ctx.textBaseline = 'middle'; + ctx.textAlign = 'center'; + ctx.fillText(colIndexToLetters(col), x + cw / 2, letterY + COLUMN_LETTER_HEIGHT / 2); + ctx.textAlign = 'left'; + + const bg = resolveHeaderColor( + headerColor, + options.isDark, + theme.headerBg, + ); + ctx.fillStyle = bg; + ctx.fillRect(x, titleY, cw, HEADER_TITLE_HEIGHT); + ctx.strokeStyle = theme.border; + ctx.strokeRect(x, titleY, cw, HEADER_TITLE_HEIGHT); + ctx.fillStyle = theme.headerText; + ctx.font = '600 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; + const colCfg = colByIndex(options.columns, col); + const align = resolveColumnTextAlign(colCfg, options.defaultTextAlign); + drawAlignedText( + ctx, + title, + x, + titleY + HEADER_TITLE_HEIGHT / 2, + cw, + align, + ); +} + +function drawHeaderTitleOnly( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + cw: number, + title: string, + headerColor: string | undefined, + col: number, + columns: SpreadsheetColumnConfig[], + defaultTextAlign: SpreadsheetTextAlign, + isDark: boolean, + theme: SpreadsheetTheme, +) { + const bg = resolveHeaderColor(headerColor, isDark, theme.headerBg); + ctx.fillStyle = bg; + ctx.fillRect(x, y, cw, HEADER_TITLE_HEIGHT); + ctx.strokeStyle = theme.border; + ctx.strokeRect(x, y, cw, HEADER_TITLE_HEIGHT); + ctx.fillStyle = theme.headerText; + ctx.font = '600 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; + const colCfg = colByIndex(columns, col); + const align = resolveColumnTextAlign(colCfg, defaultTextAlign); + drawAlignedText(ctx, title, x, y + HEADER_TITLE_HEIGHT / 2, cw, align); +} + +function resolveExportCellBg( + theme: SpreadsheetTheme, + viewRow: number, + col: number, + colCfg: SpreadsheetColumnConfig | undefined, + row: Record | undefined, + dataRow: number | undefined, + dirtyKeys: Set, + resolveCellStyle?: RenderOptions['resolveCellStyle'], +): string { + if (!colCfg) return theme.cellBg; + const dirtyKey = + dataRow !== undefined ? `${dataRow}:${colCfg.fieldId}` : ''; + const cellStyle = resolveCellStyle?.(viewRow, colCfg, row); + if (dirtyKeys.has(dirtyKey)) return theme.dirtyBg; + if (cellStyle?.bgColor) return cellStyle.bgColor; + return theme.cellBg; +} + +export interface SelectionImageParams { + selectionRanges: CellRange[]; + headers: SpreadsheetHeaderConfig[]; + columns: SpreadsheetColumnConfig[]; + colWidths: number[]; + rowHeight: number; + defaultColWidth: number; + defaultTextAlign: SpreadsheetTextAlign; + isDark: boolean; + filteredRowIndices: number[]; + rows: Record[]; + dirtyKeys: Set; + resolveDisplayValue?: RenderOptions['resolveDisplayValue']; + resolveCellStyle?: RenderOptions['resolveCellStyle']; +} + +export async function renderSelectionImage( + params: SelectionImageParams, +): Promise { + const selRanges = params.selectionRanges.map((r) => normalizeRange(r)); + if (!selRanges.length) throw new Error('No selection'); + + const rowSet = new Set(); + const colSet = new Set(); + iterateRanges(selRanges, (viewRow, col) => { + rowSet.add(viewRow); + colSet.add(col); + }); + + const uniqueRows = [...rowSet].sort((a, b) => a - b); + const uniqueCols = [...colSet].sort((a, b) => a - b); + if (!uniqueRows.length || !uniqueCols.length) { + throw new Error('No selection'); + } + + const theme = getSpreadsheetTheme(params.isDark); + + let width = 0; + for (const col of uniqueCols) { + width += params.colWidths[col] ?? params.defaultColWidth; + } + const height = + HEADER_TITLE_HEIGHT + uniqueRows.length * params.rowHeight; + + const canvas = document.createElement('canvas'); + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.ceil(width * dpr); + canvas.height = Math.ceil(height * dpr); + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Canvas not supported'); + + ctx.scale(dpr, dpr); + ctx.fillStyle = theme.cellBg; + ctx.fillRect(0, 0, width, height); + + let headerX = 0; + for (const col of uniqueCols) { + const cw = params.colWidths[col] ?? params.defaultColWidth; + const header = params.headers.find((h) => h.col === col); + const colCfg = colByIndex(params.columns, col); + const title = header?.title ?? colCfg?.fieldName ?? ''; + drawHeaderTitleOnly( + ctx, + headerX, + 0, + cw, + title, + header?.headerColor, + col, + params.columns, + params.defaultTextAlign, + params.isDark, + theme, + ); + headerX += cw; + } + + uniqueRows.forEach((viewRow, rowIndex) => { + const dataRow = params.filteredRowIndices[viewRow]; + const row = + dataRow !== undefined ? params.rows[dataRow] : undefined; + const y = HEADER_TITLE_HEIGHT + rowIndex * params.rowHeight; + let cellX = 0; + for (const col of uniqueCols) { + const cw = params.colWidths[col] ?? params.defaultColWidth; + const colCfg = colByIndex(params.columns, col); + const inSel = rangesContainCell(selRanges, viewRow, col); + const isEmpty = + !inSel || dataRow === undefined || !row || !colCfg; + const bg = inSel + ? resolveExportCellBg( + theme, + viewRow, + col, + colCfg, + row, + dataRow, + params.dirtyKeys, + params.resolveCellStyle, + ) + : theme.cellBg; + const cellStyle = + inSel && !isEmpty && colCfg + ? params.resolveCellStyle?.(viewRow, colCfg, row) + : undefined; + const displayCellType = colCfg?.cellType === 'formula' + ? 'number' + : colCfg?.cellType; + drawDataCell(ctx, { + x: cellX, + y, + cw, + rowHeight: params.rowHeight, + bg, + theme, + empty: isEmpty, + value: + inSel && !isEmpty && colCfg + ? params.resolveDisplayValue?.(viewRow, colCfg, row) + : undefined, + cellType: displayCellType, + colCfg: inSel ? colCfg : undefined, + textColor: cellStyle?.textColor, + defaultTextAlign: params.defaultTextAlign, + }); + cellX += cw; + } + }); + + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) resolve(blob); + else reject(new Error('Failed to create image')); + }, 'image/png'); + }); +} + +function drawHeaderFillerCell( + ctx: CanvasRenderingContext2D, + options: RenderOptions, + col: number, + cw: number, + x: number, +) { + const { theme } = options; + const maxRow = Math.max(0, options.filteredRowIndices.length - 1); + const colSelected = isColInSelection(col, options.selectionRanges, maxRow); + const letterBg = colSelected ? theme.selectionBg : theme.headerBg; + ctx.fillStyle = letterBg; + ctx.fillRect(x, 0, cw, COLUMN_LETTER_HEIGHT); + ctx.strokeStyle = theme.border; + ctx.strokeRect(x, 0, cw, COLUMN_LETTER_HEIGHT); + ctx.fillStyle = theme.textMuted; + ctx.font = '11px sans-serif'; + ctx.textBaseline = 'middle'; + ctx.textAlign = 'center'; + ctx.fillText(colIndexToLetters(col), x + cw / 2, COLUMN_LETTER_HEIGHT / 2); + ctx.textAlign = 'left'; + ctx.fillRect(x, COLUMN_LETTER_HEIGHT, cw, HEADER_TITLE_HEIGHT); + ctx.strokeRect(x, COLUMN_LETTER_HEIGHT, cw, HEADER_TITLE_HEIGHT); +} + +function drawScrollableHeaders( + ctx: CanvasRenderingContext2D, + options: RenderOptions, + w: number, +) { + const { fixedCols, fixedWidth } = options; + ctx.save(); + ctx.beginPath(); + ctx.rect(fixedWidth, 0, w - fixedWidth, HEADER_HEIGHT); + ctx.clip(); + + for (const header of options.headers) { + if (isFixedCol(header.col, fixedCols)) continue; + const x = getDataColX( + header.col, + options.colWidths, + options.scrollLeft, + fixedCols, + fixedWidth, + ); + const cw = options.colWidths[header.col] ?? options.defaultColWidth; + if (x + cw <= fixedWidth || x > w) continue; + drawHeaderCell(ctx, options, header.col, cw, x, header.title, header.headerColor); + } + + for (let col = options.colCount; col < options.displayColCount; col += 1) { + if (isFixedCol(col, fixedCols)) continue; + const x = getDataColX( + col, + options.colWidths, + options.scrollLeft, + fixedCols, + fixedWidth, + ); + const cw = options.defaultColWidth; + if (x + cw <= fixedWidth || x > w) continue; + drawHeaderFillerCell(ctx, options, col, cw, x); + } + ctx.restore(); +} + +function drawFrozenHeaders( + ctx: CanvasRenderingContext2D, + options: RenderOptions, + w: number, +) { + const { fixedCols, fixedWidth, theme } = options; + const maxRow = Math.max(0, options.filteredRowIndices.length - 1); + const maxCol = options.colCount - 1; + drawHeaderCorner( + ctx, + theme, + isAllSelected(options.selectionRanges, maxRow, maxCol), + ); + + for (const header of options.headers) { + if (!isFixedCol(header.col, fixedCols)) continue; + const x = getDataColX( + header.col, + options.colWidths, + options.scrollLeft, + fixedCols, + fixedWidth, + ); + const cw = options.colWidths[header.col] ?? options.defaultColWidth; + if (x + cw <= ROW_NUMBER_WIDTH) continue; + drawHeaderCell(ctx, options, header.col, cw, x, header.title, header.headerColor); + } + + if (fixedCols.length > 0 && fixedWidth < w) { + ctx.fillStyle = theme.fixedShadow; + ctx.fillRect(fixedWidth, 0, 1, HEADER_HEIGHT); + } +} + +export function renderHeader(canvas: HTMLCanvasElement, options: RenderOptions) { + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + const w = canvas.clientWidth; + const h = HEADER_HEIGHT; + canvas.width = w * dpr; + canvas.height = h * dpr; + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, w, h); + + ctx.fillStyle = options.theme.cellBg; + ctx.fillRect(0, 0, w, h); + + drawScrollableHeaders(ctx, options, w); + drawFrozenHeaders(ctx, options, w); +} + +function drawScrollableBodyRow( + ctx: CanvasRenderingContext2D, + options: RenderOptions, + vi: number, + y: number, + dataRow: number | undefined, + row: Record | undefined, + w: number, +) { + const { theme, rowHeight, fixedCols, fixedWidth } = options; + const isEmpty = dataRow === undefined || !row; + + ctx.save(); + ctx.beginPath(); + ctx.rect(fixedWidth, y, w - fixedWidth, rowHeight); + ctx.clip(); + + for (const colCfg of options.columns) { + const col = colCfg.col; + if (isFixedCol(col, fixedCols)) continue; + const x = getDataColX( + col, + options.colWidths, + options.scrollLeft, + fixedCols, + fixedWidth, + ); + const cw = options.colWidths[col] ?? options.defaultColWidth; + if (x + cw <= fixedWidth || x > options.viewportWidth) continue; + + const bg = resolveCellBg(options, vi, col, colCfg, row, isEmpty, dataRow); + const cellStyle = !isEmpty + ? options.resolveCellStyle?.(vi, colCfg, row) + : undefined; + const displayCellType = + colCfg.cellType === 'formula' ? 'number' : colCfg.cellType; + + drawDataCell(ctx, { + x, + y, + cw, + rowHeight, + bg, + theme, + empty: isEmpty, + value: options.resolveDisplayValue + ? options.resolveDisplayValue(vi, colCfg, row) + : row?.[colCfg.fieldId], + cellType: displayCellType, + colCfg, + textColor: cellStyle?.textColor, + defaultTextAlign: options.defaultTextAlign, + }); + } + + for (let col = options.colCount; col < options.displayColCount; col += 1) { + if (isFixedCol(col, fixedCols)) continue; + const x = getDataColX( + col, + options.colWidths, + options.scrollLeft, + fixedCols, + fixedWidth, + ); + const cw = options.defaultColWidth; + if (x + cw <= fixedWidth || x > options.viewportWidth) continue; + const bg = resolveCellBg(options, vi, col, undefined, undefined, true, dataRow); + drawDataCell(ctx, { + x, + y, + cw, + rowHeight, + bg, + theme, + empty: true, + defaultTextAlign: options.defaultTextAlign, + }); + } + ctx.restore(); +} + +function drawFrozenBodyRow( + ctx: CanvasRenderingContext2D, + options: RenderOptions, + vi: number, + y: number, + dataRow: number | undefined, + row: Record | undefined, +) { + const { theme, rowHeight, fixedCols, fixedWidth } = options; + const isEmpty = dataRow === undefined || !row; + const maxCol = options.colCount - 1; + const rowSelected = isRowInSelection(vi, options.selectionRanges, maxCol); + + drawRowNumber(ctx, y, rowHeight, String(vi + 1), theme, rowSelected); + + for (const colCfg of options.columns) { + const col = colCfg.col; + if (!isFixedCol(col, fixedCols)) continue; + const x = getDataColX( + col, + options.colWidths, + options.scrollLeft, + fixedCols, + fixedWidth, + ); + const cw = options.colWidths[col] ?? options.defaultColWidth; + + const bg = resolveCellBg(options, vi, col, colCfg, row, isEmpty, dataRow); + const cellStyle = !isEmpty + ? options.resolveCellStyle?.(vi, colCfg, row) + : undefined; + const displayCellType = + colCfg.cellType === 'formula' ? 'number' : colCfg.cellType; + + drawDataCell(ctx, { + x, + y, + cw, + rowHeight, + bg, + theme, + empty: isEmpty, + value: options.resolveDisplayValue + ? options.resolveDisplayValue(vi, colCfg, row) + : row?.[colCfg.fieldId], + cellType: displayCellType, + colCfg, + textColor: cellStyle?.textColor, + defaultTextAlign: options.defaultTextAlign, + }); + } +} + +export function renderBody(canvas: HTMLCanvasElement, options: RenderOptions) { + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const { theme, rowHeight, scrollTop, fixedWidth, fixedCols } = options; + const dpr = window.devicePixelRatio || 1; + const w = canvas.clientWidth; + const h = canvas.clientHeight; + canvas.width = w * dpr; + canvas.height = h * dpr; + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, w, h); + + const visibleCount = Math.ceil(h / rowHeight) + SCROLL_BUFFER_ROWS; + const startRow = Math.max(0, Math.floor(scrollTop / rowHeight) - 2); + const endRow = Math.min(options.displayRowCount - 1, startRow + visibleCount); + + for (let vi = startRow; vi <= endRow; vi += 1) { + const dataRow = options.filteredRowIndices[vi]; + const row = + dataRow !== undefined ? options.rows[dataRow] : undefined; + const y = getRowTop(vi, rowHeight) - scrollTop; + drawScrollableBodyRow(ctx, options, vi, y, dataRow, row, w); + } + + for (let vi = startRow; vi <= endRow; vi += 1) { + const dataRow = options.filteredRowIndices[vi]; + const row = + dataRow !== undefined ? options.rows[dataRow] : undefined; + const y = getRowTop(vi, rowHeight) - scrollTop; + drawFrozenBodyRow(ctx, options, vi, y, dataRow, row); + } + + if (fixedCols.length > 0 && fixedWidth < w) { + const bodyH = Math.min(h, options.displayRowCount * rowHeight - scrollTop); + ctx.fillStyle = theme.fixedShadow; + ctx.fillRect(fixedWidth, 0, 1, Math.max(0, bodyH)); + } + + if (options.activeCell) { + const { activeCell } = options; + const x = getDataColX( + activeCell.col, + options.colWidths, + options.scrollLeft, + fixedCols, + fixedWidth, + ); + const y = getRowTop(activeCell.row, rowHeight) - scrollTop; + const cw = options.colWidths[activeCell.col] ?? options.defaultColWidth; + + const findActive = isActiveFindMatch( + activeCell.row, + activeCell.col, + options.findMatches, + options.findActiveIndex, + ); + + ctx.strokeStyle = findActive ? theme.findActiveBorder : theme.selectionBorder; + ctx.lineWidth = 2; + ctx.setLineDash([]); + ctx.strokeRect(x + 1, y + 1, cw - 2, rowHeight - 2); + + if (options.showFillHandle) { + const fh = 6; + ctx.fillStyle = theme.fillHandle; + ctx.fillRect(x + cw - fh - 2, y + rowHeight - fh - 2, fh, fh); + } + } + + if (options.referenceCell) { + const { row, col } = options.referenceCell; + const x = getDataColX( + col, + options.colWidths, + options.scrollLeft, + fixedCols, + fixedWidth, + ); + const y = getRowTop(row, rowHeight) - scrollTop; + const cw = options.colWidths[col] ?? options.defaultColWidth; + + ctx.strokeStyle = theme.selectionBorder; + ctx.lineWidth = 2; + ctx.setLineDash([4, 4]); + ctx.strokeRect(x + 1, y + 1, cw - 2, rowHeight - 2); + ctx.setLineDash([]); + } +} + +export function getVisibleRowIndices( + rows: Record[], + columns: SpreadsheetColumnConfig[], + filterValues: Record, +): number[] { + const activeFilters = Object.entries(filterValues).filter(([, v]) => v.trim()); + if (!activeFilters.length) { + return rows.map((_, i) => i); + } + + return rows + .map((row, index) => ({ row, index })) + .filter(({ row }) => + activeFilters.every(([colStr, keyword]) => { + const col = Number(colStr); + const colCfg = colByIndex(columns, col); + if (!colCfg) return true; + const val = formatCellDisplay(row[colCfg.fieldId], colCfg.cellType); + return val.toLowerCase().includes(keyword.trim().toLowerCase()); + }), + ) + .map(({ index }) => index); +} + +export function getContentSize( + colWidths: number[], + displayRowCount: number, + rowHeight: number, + displayContentWidth?: number, +): { width: number; height: number } { + const dataWidth = ROW_NUMBER_WIDTH + colWidths.reduce((s, w) => s + w, 0); + const width = displayContentWidth ?? dataWidth; + return { width, height: displayRowCount * rowHeight }; +} + +export { ROW_NUMBER_WIDTH, HEADER_HEIGHT, DEFAULT_ROW_HEIGHT }; diff --git a/src/components/canvas-spreadsheet/canvas/columnLayout.ts b/src/components/canvas-spreadsheet/canvas/columnLayout.ts new file mode 100644 index 0000000..3842011 --- /dev/null +++ b/src/components/canvas-spreadsheet/canvas/columnLayout.ts @@ -0,0 +1,83 @@ +import type { SpreadsheetHeaderConfig } from '../types'; +import { DEFAULT_COL_WIDTH, ROW_NUMBER_WIDTH } from '../types'; + +export function getFixedColOffset( + col: number, + colWidths: number[], + fixedCols: number[], +): number { + let w = 0; + for (const c of fixedCols) { + if (c >= col) break; + w += colWidths[c] ?? DEFAULT_COL_WIDTH; + } + return w; +} + +export function getFixedDataWidth( + col: number, + colWidths: number[], + fixedCols: number[], +): number { + return getFixedColOffset(col, colWidths, fixedCols); +} + +export function isFixedCol(col: number, fixedCols: number[]) { + return fixedCols.includes(col); +} + +export function getDataColX( + col: number, + colWidths: number[], + scrollLeft: number, + fixedCols: number[], + fixedWidth: number, +): number { + if (isFixedCol(col, fixedCols)) { + return ROW_NUMBER_WIDTH + getFixedColOffset(col, colWidths, fixedCols); + } + const scrollColOffset = + getColLeft(col, colWidths) - getFixedDataWidth(col, colWidths, fixedCols); + return fixedWidth + scrollColOffset - scrollLeft; +} + +export function getColLeft(colIndex: number, colWidths: number[]): number { + let left = 0; + for (let i = 0; i < colIndex; i += 1) { + left += colWidths[i] ?? DEFAULT_COL_WIDTH; + } + return left; +} + +export function computeFixedWidth( + colWidths: number[], + fixedCols: number[], +): number { + let w = ROW_NUMBER_WIDTH; + for (const col of fixedCols) { + w += colWidths[col] ?? DEFAULT_COL_WIDTH; + } + return w; +} + +/** Keep only contiguous frozen columns starting from col 0 (Excel freeze semantics). */ +export function normalizeFixedCols(fixedCols: number[]): number[] { + if (fixedCols.length === 0) return []; + const sorted = [...fixedCols].sort((a, b) => a - b); + const result: number[] = []; + for (let i = 0; i < sorted.length; i += 1) { + if (sorted[i] === i) result.push(i); + else break; + } + return result; +} + +export function getFixedColIndices( + headers: SpreadsheetHeaderConfig[], +): number[] { + const raw = headers + .filter((h) => h.fixed) + .map((h) => h.col) + .sort((a, b) => a - b); + return normalizeFixedCols(raw); +} diff --git a/src/components/canvas-spreadsheet/canvas/theme.ts b/src/components/canvas-spreadsheet/canvas/theme.ts new file mode 100644 index 0000000..dc90c90 --- /dev/null +++ b/src/components/canvas-spreadsheet/canvas/theme.ts @@ -0,0 +1,146 @@ +export interface SpreadsheetTheme { + headerBg: string; + headerText: string; + cellBg: string; + border: string; + text: string; + textMuted: string; + selectionBg: string; + selectionBorder: string; + dirtyBg: string; + rowNumBg: string; + fillHandle: string; + fixedShadow: string; + imagePlaceholder: string; + findMatchBg: string; + findActiveMatchBg: string; + findActiveBorder: string; +} + +export function getSpreadsheetTheme(isDark: boolean): SpreadsheetTheme { + if (isDark) { + return { + headerBg: '#1f1f1f', + headerText: 'rgba(255, 255, 255, 0.85)', + cellBg: '#141414', + border: '#424242', + text: 'rgba(255, 255, 255, 0.85)', + textMuted: 'rgba(255, 255, 255, 0.45)', + selectionBg: 'rgba(22, 93, 255, 0.25)', + selectionBorder: '#1668dc', + dirtyBg: 'rgba(255, 125, 0, 0.2)', + rowNumBg: '#1a1a1a', + fillHandle: '#1668dc', + fixedShadow: 'rgba(0, 0, 0, 0.45)', + imagePlaceholder: '#303030', + findMatchBg: 'rgba(255, 212, 0, 0.35)', + findActiveMatchBg: 'rgba(255, 212, 0, 0.55)', + findActiveBorder: '#faad14', + }; + } + return { + headerBg: '#f5f7fa', + headerText: '#1d2129', + cellBg: '#ffffff', + border: '#e5e6eb', + text: '#1d2129', + textMuted: '#86909c', + selectionBg: 'rgba(22, 93, 255, 0.08)', + selectionBorder: '#165dff', + dirtyBg: 'rgba(255, 125, 0, 0.12)', + rowNumBg: '#fafafa', + fillHandle: '#165dff', + fixedShadow: 'rgba(0, 0, 0, 0.12)', + imagePlaceholder: '#f2f3f5', + findMatchBg: '#fff566', + findActiveMatchBg: '#ffe58f', + findActiveBorder: '#faad14', + }; +} + +export const HEADER_COLOR_PRESETS = [ + '#f0f5ff', + '#fff7e6', + '#f6ffed', + '#fff1f0', + '#f9f0ff', + '#e6fffb', +]; + +function parseHexColor(hex: string): { r: number; g: number; b: number } | null { + const normalized = hex.trim().replace('#', ''); + if (normalized.length === 3) { + return { + r: Number.parseInt(normalized[0]! + normalized[0], 16), + g: Number.parseInt(normalized[1]! + normalized[1], 16), + b: Number.parseInt(normalized[2]! + normalized[2], 16), + }; + } + if (normalized.length === 6) { + return { + r: Number.parseInt(normalized.slice(0, 2), 16), + g: Number.parseInt(normalized.slice(2, 4), 16), + b: Number.parseInt(normalized.slice(4, 6), 16), + }; + } + return null; +} + +function rgbToHsl(r: number, g: number, b: number) { + const rn = r / 255; + const gn = g / 255; + const bn = b / 255; + const max = Math.max(rn, gn, bn); + const min = Math.min(rn, gn, bn); + const l = (max + min) / 2; + if (max === min) { + return { h: 0, s: 0, l }; + } + const d = max - min; + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + let h = 0; + if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6; + else if (max === gn) h = ((bn - rn) / d + 2) / 6; + else h = ((rn - gn) / d + 4) / 6; + return { h, s, l }; +} + +function hslToHex(h: number, s: number, l: number): string { + if (s === 0) { + const v = Math.round(l * 255); + const hex = v.toString(16).padStart(2, '0'); + return `#${hex}${hex}${hex}`; + } + const hue2rgb = (p: number, q: number, t: number) => { + let tt = t; + if (tt < 0) tt += 1; + if (tt > 1) tt -= 1; + if (tt < 1 / 6) return p + (q - p) * 6 * tt; + if (tt < 1 / 2) return q; + if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6; + return p; + }; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + const r = Math.round(hue2rgb(p, q, h + 1 / 3) * 255); + const g = Math.round(hue2rgb(p, q, h) * 255); + const b = Math.round(hue2rgb(p, q, h - 1 / 3) * 255); + return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; +} + +export function resolveHeaderColor( + color: string | undefined, + isDark: boolean, + fallback: string, +): string { + if (!color) return fallback; + if (!isDark) return color; + + const rgb = parseHexColor(color); + if (!rgb) return fallback; + + const { h, s, l } = rgbToHsl(rgb.r, rgb.g, rgb.b); + const darkL = Math.min(0.28, Math.max(0.16, l * 0.35 + 0.12)); + const darkS = Math.min(0.55, s * 0.75 + 0.08); + return hslToHex(h, darkS, darkL); +} diff --git a/src/components/canvas-spreadsheet/commands/FillRangeCommand.ts b/src/components/canvas-spreadsheet/commands/FillRangeCommand.ts new file mode 100644 index 0000000..216f569 --- /dev/null +++ b/src/components/canvas-spreadsheet/commands/FillRangeCommand.ts @@ -0,0 +1,64 @@ +import type { CellRange, SpreadsheetCommand } from '../types'; + +export function createFillRangeCommand( + persistBatch: ( + changes: Array<{ row: number; col: number; value: unknown }>, + ) => void | Promise, + before: Array<{ row: number; col: number; value: unknown }>, + after: Array<{ row: number; col: number; value: unknown }>, +): SpreadsheetCommand { + return { + label: 'fillRange', + undo: () => persistBatch(before), + redo: () => persistBatch(after), + }; +} + +export function normalizeRange(range: CellRange): CellRange { + return { + startRow: Math.min(range.startRow, range.endRow), + startCol: Math.min(range.startCol, range.endCol), + endRow: Math.max(range.startRow, range.endRow), + endCol: Math.max(range.startCol, range.endCol), + }; +} + +export function iterateRange( + range: CellRange, + fn: (row: number, col: number) => void, +) { + const r = normalizeRange(range); + for (let row = r.startRow; row <= r.endRow; row += 1) { + for (let col = r.startCol; col <= r.endCol; col += 1) { + fn(row, col); + } + } +} + +export function iterateRanges( + ranges: CellRange[], + fn: (row: number, col: number) => void, +) { + for (const range of ranges) { + iterateRange(range, fn); + } +} + +export function rangesContainCell( + ranges: CellRange[], + row: number, + col: number, +): boolean { + for (const range of ranges) { + const r = normalizeRange(range); + if ( + row >= r.startRow && + row <= r.endRow && + col >= r.startCol && + col <= r.endCol + ) { + return true; + } + } + return false; +} diff --git a/src/components/canvas-spreadsheet/commands/PasteRangeCommand.ts b/src/components/canvas-spreadsheet/commands/PasteRangeCommand.ts new file mode 100644 index 0000000..34fe2a8 --- /dev/null +++ b/src/components/canvas-spreadsheet/commands/PasteRangeCommand.ts @@ -0,0 +1,15 @@ +import type { SpreadsheetCommand } from '../types'; + +export function createPasteRangeCommand( + persistBatch: ( + changes: Array<{ row: number; col: number; value: unknown }>, + ) => void | Promise, + before: Array<{ row: number; col: number; value: unknown }>, + after: Array<{ row: number; col: number; value: unknown }>, +): SpreadsheetCommand { + return { + label: 'pasteRange', + undo: () => persistBatch(before), + redo: () => persistBatch(after), + }; +} diff --git a/src/components/canvas-spreadsheet/commands/SetCellValueCommand.ts b/src/components/canvas-spreadsheet/commands/SetCellValueCommand.ts new file mode 100644 index 0000000..c7b5c89 --- /dev/null +++ b/src/components/canvas-spreadsheet/commands/SetCellValueCommand.ts @@ -0,0 +1,12 @@ +import type { SpreadsheetCommand } from '../types'; + +export function createSetCellValueCommand( + undo: () => void | Promise, + redo: () => void | Promise, +): SpreadsheetCommand { + return { + label: 'setCellValue', + undo, + redo, + }; +} diff --git a/src/components/canvas-spreadsheet/composables/cellDisplay.ts b/src/components/canvas-spreadsheet/composables/cellDisplay.ts new file mode 100644 index 0000000..3510da1 --- /dev/null +++ b/src/components/canvas-spreadsheet/composables/cellDisplay.ts @@ -0,0 +1,55 @@ +import type { + ConditionalFormatRule, + SpreadsheetColumnConfig, +} from '../types'; +import { formatCellDisplay } from './useAutoMeasure'; + +export function shouldShowSuffix(displayText: string): boolean { + if (!displayText) return false; + if (displayText === '#ERROR' || displayText === '-') return false; + return true; +} + +export function formatCellWithSuffix( + value: unknown, + colCfg?: SpreadsheetColumnConfig, +): string { + const cellType = + colCfg?.cellType === 'formula' ? 'number' : (colCfg?.cellType ?? 'text'); + const base = formatCellDisplay(value, cellType); + if (!shouldShowSuffix(base) || !colCfg?.suffix) return base; + return base + colCfg.suffix; +} + +function resolveRuleStyle( + rule: ConditionalFormatRule, +): { bgColor?: string; textColor?: string } { + const mode = rule.colorMode ?? 'background'; + if (mode === 'text') { + return rule.textColor ? { textColor: rule.textColor } : {}; + } + if (mode === 'both') { + return { + bgColor: rule.bgColor, + textColor: rule.textColor, + }; + } + return rule.bgColor ? { bgColor: rule.bgColor } : {}; +} + +export function resolveConditionalStyle( + colCfg: SpreadsheetColumnConfig, + numericValue: number | null, +): { bgColor?: string; textColor?: string } | undefined { + const rules = colCfg.conditionalFormat; + if (rules == null || numericValue == null || Number.isNaN(numericValue)) { + return undefined; + } + if (rules.gt && numericValue > rules.gt.threshold) { + return resolveRuleStyle(rules.gt); + } + if (rules.lt && numericValue < rules.lt.threshold) { + return resolveRuleStyle(rules.lt); + } + return undefined; +} diff --git a/src/components/canvas-spreadsheet/composables/columnTextAlign.ts b/src/components/canvas-spreadsheet/composables/columnTextAlign.ts new file mode 100644 index 0000000..bd535df --- /dev/null +++ b/src/components/canvas-spreadsheet/composables/columnTextAlign.ts @@ -0,0 +1,13 @@ +import type { + SpreadsheetColumnConfig, + SpreadsheetTextAlign, +} from '../types'; + +export function resolveColumnTextAlign( + colCfg: SpreadsheetColumnConfig | undefined, + defaultAlign: SpreadsheetTextAlign = 'left', +): SpreadsheetTextAlign { + const a = colCfg?.textAlign; + if (!a || a === 'inherit') return defaultAlign; + return a; +} diff --git a/src/components/canvas-spreadsheet/composables/filterSort.ts b/src/components/canvas-spreadsheet/composables/filterSort.ts new file mode 100644 index 0000000..1cfaf6e --- /dev/null +++ b/src/components/canvas-spreadsheet/composables/filterSort.ts @@ -0,0 +1,155 @@ +import { formatCellWithSuffix } from './cellDisplay'; +import type { + SpreadsheetColumnConfig, + SpreadsheetColumnFilter, + SpreadsheetViewState, +} from '../types'; + +type ResolveDisplayValue = ( + viewRow: number, + colCfg: SpreadsheetColumnConfig, + row?: Record, +) => unknown; + +function formatDisplayText(value: unknown, colCfg: SpreadsheetColumnConfig): string { + if (value === null || value === undefined) return ''; + const cellType = colCfg.cellType === 'formula' ? 'number' : colCfg.cellType; + if (colCfg.suffix) { + return formatCellWithSuffix(value, colCfg); + } + if (cellType === 'number') { + const n = Number(value); + return Number.isFinite(n) ? String(n) : String(value); + } + if (typeof value === 'object') return JSON.stringify(value); + return String(value); +} + +export function getCellDisplayText( + row: Record, + dataRowIndex: number, + colCfg: SpreadsheetColumnConfig, + resolveDisplayValue: ResolveDisplayValue, +): string { + const raw = resolveDisplayValue(dataRowIndex, colCfg, row); + return formatDisplayText(raw, colCfg); +} + +export function collectColumnUniqueValues( + rows: Record[], + col: number, + columns: SpreadsheetColumnConfig[], + resolveDisplayValue: ResolveDisplayValue, +): string[] { + const colCfg = columns.find((c) => c.col === col); + if (!colCfg) return []; + const set = new Set(); + rows.forEach((row, dataRowIndex) => { + set.add(getCellDisplayText(row, dataRowIndex, colCfg, resolveDisplayValue)); + }); + return [...set].sort((a, b) => a.localeCompare(b, 'zh-CN')); +} + +export function isColumnFilterActive(filter?: SpreadsheetColumnFilter): boolean { + return filter?.checkedValues !== undefined; +} + +export function isColumnFilterMatching( + displayText: string, + filter?: SpreadsheetColumnFilter, + allValues?: string[], +): boolean { + if (!filter || filter.checkedValues === undefined) return true; + if (filter.checkedValues.length === 0) return false; + if (allValues && filter.checkedValues.length >= allValues.length) return true; + return filter.checkedValues.includes(displayText); +} + +function compareRows( + a: number, + b: number, + sortCol: number, + rows: Record[], + columns: SpreadsheetColumnConfig[], + resolveDisplayValue: ResolveDisplayValue, +): number { + const colCfg = columns.find((c) => c.col === sortCol); + if (!colCfg) return 0; + const rowA = rows[a]; + const rowB = rows[b]; + if (!rowA || !rowB) return 0; + + const textA = getCellDisplayText(rowA, a, colCfg, resolveDisplayValue); + const textB = getCellDisplayText(rowB, b, colCfg, resolveDisplayValue); + + if (colCfg.cellType === 'number' || colCfg.cellType === 'formula') { + const nA = Number(textA); + const nB = Number(textB); + const aValid = Number.isFinite(nA); + const bValid = Number.isFinite(nB); + if (!aValid && !bValid) return 0; + if (!aValid) return 1; + if (!bValid) return -1; + return nA - nB; + } + return textA.localeCompare(textB, 'zh-CN'); +} + +export function applyViewState( + rows: Record[], + columns: SpreadsheetColumnConfig[], + viewState: SpreadsheetViewState, + resolveDisplayValue: ResolveDisplayValue, +): number[] { + const activeFilterCols = Object.entries(viewState.filters).filter(([, f]) => + isColumnFilterActive(f), + ); + + let indices = rows.map((_, i) => i); + + if (activeFilterCols.length > 0) { + const uniqueValuesMap = new Map(); + for (const [colStr] of activeFilterCols) { + const col = Number(colStr); + if (!uniqueValuesMap.has(col)) { + uniqueValuesMap.set( + col, + collectColumnUniqueValues( + rows, + col, + columns, + resolveDisplayValue, + ), + ); + } + } + + indices = indices.filter((dataRowIndex) => { + const row = rows[dataRowIndex]; + if (!row) return false; + return activeFilterCols.every(([colStr, filter]) => { + const col = Number(colStr); + const colCfg = columns.find((c) => c.col === col); + if (!colCfg) return true; + const allValues = uniqueValuesMap.get(col); + const text = getCellDisplayText( + row, + dataRowIndex, + colCfg, + resolveDisplayValue, + ); + return isColumnFilterMatching(text, filter, allValues); + }); + }); + } + + if (viewState.sort) { + const { col, direction } = viewState.sort; + indices = [...indices].sort((a, b) => { + const cmp = compareRows(a, b, col, rows, columns, resolveDisplayValue); + return direction === 'asc' ? cmp : -cmp; + }); + } + + return indices; +} diff --git a/src/components/canvas-spreadsheet/composables/useAutoMeasure.ts b/src/components/canvas-spreadsheet/composables/useAutoMeasure.ts new file mode 100644 index 0000000..8a894f0 --- /dev/null +++ b/src/components/canvas-spreadsheet/composables/useAutoMeasure.ts @@ -0,0 +1,219 @@ +import type { SpreadsheetColumnConfig } from '../types'; +import { + COLUMN_LETTER_HEIGHT, + DEFAULT_COL_WIDTH, + DEFAULT_ROW_HEIGHT, + MIN_COL_WIDTH, + ROW_NUMBER_WIDTH, +} from '../types'; +import { getDataColX } from '../canvas/columnLayout'; + +const CELL_PADDING = 16; +const SAMPLE_ROWS = 50; + +function measureTextWidth( + ctx: CanvasRenderingContext2D, + text: string, +): number { + return ctx.measureText(text).width + CELL_PADDING; +} + +function formatCellDisplay(value: unknown, cellType: import('../types').CellType): string { if (value === null || value === undefined) return ''; + if (cellType === 'number') { + const n = Number(value); + return Number.isFinite(n) ? String(n) : String(value); + } + if (typeof value === 'object') return JSON.stringify(value); + return String(value); +} + +export function measureColumnWidths( + canvas: HTMLCanvasElement, + headers: Array<{ title: string; col: number }>, + columns: SpreadsheetColumnConfig[], + rows: Record[], + defaultWidth: number, + resolveDisplayValue?: ( + dataRowIndex: number, + colCfg: SpreadsheetColumnConfig, + row: Record, + ) => unknown, + formatWithSuffix?: ( + value: unknown, + colCfg: SpreadsheetColumnConfig, + ) => string, +): number[] { const ctx = canvas.getContext('2d'); + if (!ctx) return headers.map(() => defaultWidth); + + ctx.font = '13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; + + const maxCol = Math.max(...headers.map((h) => h.col), 0); + const widths = new Array(maxCol + 1).fill(defaultWidth); + + for (const header of headers) { + widths[header.col] = Math.max( + widths[header.col] ?? defaultWidth, + measureTextWidth(ctx, header.title), + MIN_COL_WIDTH, + ); + } + + const sample = rows.slice(0, SAMPLE_ROWS); + for (const colCfg of columns) { + let maxW = widths[colCfg.col] ?? defaultWidth; + sample.forEach((row, dataRowIdx) => { + const raw = resolveDisplayValue + ? resolveDisplayValue(dataRowIdx, colCfg, row) + : row[colCfg.fieldId]; + const text = formatWithSuffix + ? formatWithSuffix(raw, colCfg) + : formatCellDisplay(raw, colCfg.cellType === 'formula' ? 'number' : colCfg.cellType); + maxW = Math.max(maxW, measureTextWidth(ctx, text)); + }); + widths[colCfg.col] = Math.max(maxW, MIN_COL_WIDTH); + } + return widths; +} + +export function getRowHeight( + cellType: import('../types').CellType, configured: number | 'auto' | undefined, +): number { + if (typeof configured === 'number') return configured; + switch (cellType) { + case 'image': + case 'video': + return 48; + default: + return DEFAULT_ROW_HEIGHT; + } +} + +export function getTotalWidth(colWidths: number[]): number { + return colWidths.reduce((sum, w) => sum + w, 0); +} + +export function getRowTop(rowIndex: number, rowHeight: number): number { + return rowIndex * rowHeight; +} + +export function getColLeft(colIndex: number, colWidths: number[]): number { + let left = 0; + for (let i = 0; i < colIndex; i += 1) { + left += colWidths[i] ?? DEFAULT_COL_WIDTH; + } + return left; +} + +function getDataColScreenX( + col: number, + colWidths: number[], + scrollLeft: number, + fixedCols: number[], + fixedWidth: number, +): number { + return getDataColX(col, colWidths, scrollLeft, fixedCols, fixedWidth); +} + +export function hitTestCell( + x: number, + y: number, + scrollTop: number, + scrollLeft: number, + colWidths: number[], + rowHeight: number, + rowCount: number, + colCount: number, + fixedCols: number[] = [], + fixedWidth = ROW_NUMBER_WIDTH, +): { row: number; col: number } | null { + const bodyY = y + scrollTop; + if (bodyY < 0) return null; + + const row = Math.floor(bodyY / rowHeight); + if (row < 0 || row >= rowCount) return null; + + for (let col = 0; col < colCount; col += 1) { + const cellX = getDataColScreenX( + col, + colWidths, + scrollLeft, + fixedCols, + fixedWidth, + ); + const w = colWidths[col] ?? DEFAULT_COL_WIDTH; + if (x >= cellX && x < cellX + w) { + return { row, col }; + } + } + return null; +} + +export function hitTestRowNumber( + x: number, + y: number, + scrollTop: number, + rowHeight: number, + rowCount: number, +): number | null { + if (x < 0 || x >= ROW_NUMBER_WIDTH) return null; + const bodyY = y + scrollTop; + if (bodyY < 0) return null; + const row = Math.floor(bodyY / rowHeight); + if (row < 0 || row >= rowCount) return null; + return row; +} + +export type HeaderHitArea = 'corner' | 'columnLetter' | 'columnTitle'; + +export function hitTestHeaderArea( + x: number, + y: number, + scrollLeft: number, + colWidths: number[], + colCount: number, + fixedCols: number[] = [], + fixedWidth = ROW_NUMBER_WIDTH, +): { area: HeaderHitArea; col?: number } | null { + if (x >= 0 && x < ROW_NUMBER_WIDTH) { + return { area: 'corner' }; + } + const col = hitTestHeaderCol( + x, + scrollLeft, + colWidths, + colCount, + fixedCols, + fixedWidth, + ); + if (col === null) return null; + if (y < COLUMN_LETTER_HEIGHT) { + return { area: 'columnLetter', col }; + } + return { area: 'columnTitle', col }; +} + +export function hitTestHeaderCol( + x: number, + scrollLeft: number, + colWidths: number[], + colCount: number, + fixedCols: number[] = [], + fixedWidth = ROW_NUMBER_WIDTH, +): number | null { + for (let col = 0; col < colCount; col += 1) { + const cellX = getDataColScreenX( + col, + colWidths, + scrollLeft, + fixedCols, + fixedWidth, + ); + const w = colWidths[col] ?? DEFAULT_COL_WIDTH; + if (x >= cellX && x < cellX + w) return col; + } + return null; +} + +export { getDataColScreenX }; + +export { formatCellDisplay, CELL_PADDING }; diff --git a/src/components/canvas-spreadsheet/composables/useClipboard.ts b/src/components/canvas-spreadsheet/composables/useClipboard.ts new file mode 100644 index 0000000..00e9fdc --- /dev/null +++ b/src/components/canvas-spreadsheet/composables/useClipboard.ts @@ -0,0 +1,60 @@ +import { ref } from 'vue'; + +import type { ClipboardMatrix } from '../types'; + +export function useClipboard() { + const internal = ref(null); + + function setMatrix(matrix: ClipboardMatrix) { + internal.value = matrix; + } + + function getMatrix() { + return internal.value; + } + + function hasContent() { + return !!internal.value?.rows?.length; + } + + function matrixToTsv(matrix: ClipboardMatrix): string { + return matrix.rows.map((row) => row.map(String).join('\t')).join('\n'); + } + + function tsvToMatrix(text: string): unknown[][] { + return text + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + .split('\n') + .filter((line, i, arr) => line.length > 0 || i < arr.length - 1) + .map((line) => line.split('\t')); + } + + async function writeSystemClipboard(matrix: ClipboardMatrix) { + const text = matrixToTsv(matrix); + try { + await navigator.clipboard.writeText(text); + } catch { + // ignore + } + } + + async function readSystemClipboard(): Promise { + try { + return await navigator.clipboard.readText(); + } catch { + return null; + } + } + + return { + internal, + setMatrix, + getMatrix, + hasContent, + matrixToTsv, + tsvToMatrix, + writeSystemClipboard, + readSystemClipboard, + }; +} diff --git a/src/components/canvas-spreadsheet/composables/useEditHistory.ts b/src/components/canvas-spreadsheet/composables/useEditHistory.ts new file mode 100644 index 0000000..1962586 --- /dev/null +++ b/src/components/canvas-spreadsheet/composables/useEditHistory.ts @@ -0,0 +1,67 @@ +import { useUserStore } from '@vben/stores'; + +import type { SpreadsheetHistoryEntry } from '../types'; +import { + appendLocalHistory, + listUnsyncedHistory, + markHistorySynced, +} from '../history/localHistoryStore'; +import { saveHistoryBatch } from '../history/remoteHistoryApi'; + +let syncTimer: ReturnType | null = null; + +function uuid() { + return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; +} + +export function useEditHistory( + tableName: string, + sheetKey?: string, + enabled = true, +) { + const userStore = useUserStore(); + + async function recordChange( + payload: Omit< + SpreadsheetHistoryEntry, + 'id' | 'tableName' | 'sheetKey' | 'userId' | 'userName' | 'synced' | 'createdAt' + >, + ) { + if (!enabled) return; + + const entry: SpreadsheetHistoryEntry = { + id: uuid(), + tableName, + sheetKey, + userId: Number(userStore.userInfo?.userId ?? 0), + userName: String( + userStore.userInfo?.nick_name ?? + userStore.userInfo?.username ?? + '未知用户', + ), + createdAt: new Date().toISOString(), + synced: false, + ...payload, + }; + + await appendLocalHistory(entry); + scheduleSync(); + return entry; + } + + function scheduleSync() { + if (syncTimer) clearTimeout(syncTimer); + syncTimer = setTimeout(async () => { + try { + const pending = await listUnsyncedHistory(); + if (!pending.length) return; + const res = await saveHistoryBatch(pending); + await markHistorySynced(res.saved_ids ?? pending.map((p) => p.id)); + } catch { + // retry on next change + } + }, 2000); + } + + return { recordChange, scheduleSync }; +} diff --git a/src/components/canvas-spreadsheet/composables/useFormulaReferenceMode.ts b/src/components/canvas-spreadsheet/composables/useFormulaReferenceMode.ts new file mode 100644 index 0000000..b58d521 --- /dev/null +++ b/src/components/canvas-spreadsheet/composables/useFormulaReferenceMode.ts @@ -0,0 +1,49 @@ +import { ref } from 'vue'; + +export function useFormulaReferenceMode() { + const active = ref(false); + const pickedCell = ref<{ row: number; col: number } | null>(null); + const caret = ref(0); + + function setCaret(pos: number) { + caret.value = Math.max(0, pos); + } + + function enterIfNeeded(draft: string, focused: boolean, editable: boolean) { + if (!focused || !editable) { + active.value = false; + pickedCell.value = null; + return; + } + const on = draft.trimStart().startsWith('='); + active.value = on; + if (!on) pickedCell.value = null; + } + + function exit() { + active.value = false; + pickedCell.value = null; + } + + function insertAtCaret(draft: string, text: string): string { + const pos = caret.value; + const next = draft.slice(0, pos) + text + draft.slice(pos); + caret.value = pos + text.length; + return next; + } + + function pickCell(row: number, col: number) { + pickedCell.value = { row, col }; + } + + return { + active, + pickedCell, + caret, + setCaret, + enterIfNeeded, + exit, + insertAtCaret, + pickCell, + }; +} diff --git a/src/components/canvas-spreadsheet/composables/useSelection.ts b/src/components/canvas-spreadsheet/composables/useSelection.ts new file mode 100644 index 0000000..2b719ac --- /dev/null +++ b/src/components/canvas-spreadsheet/composables/useSelection.ts @@ -0,0 +1,268 @@ +import { ref } from 'vue'; + +import type { CellRange } from '../types'; +import { + normalizeRange, + rangesContainCell, +} from '../commands/FillRangeCommand'; + +export interface SelectionModifiers { + shift?: boolean; + ctrl?: boolean; +} + +function fullRowRange(row: number, maxCol: number): CellRange { + return normalizeRange({ + startRow: row, + endRow: row, + startCol: 0, + endCol: maxCol, + }); +} + +function fullColRange(col: number, maxRow: number): CellRange { + return normalizeRange({ + startRow: 0, + endRow: maxRow, + startCol: col, + endCol: col, + }); +} + +function rowRange(r0: number, r1: number, maxCol: number): CellRange { + return normalizeRange({ + startRow: r0, + endRow: r1, + startCol: 0, + endCol: maxCol, + }); +} + +function colRange(c0: number, c1: number, maxRow: number): CellRange { + return normalizeRange({ + startRow: 0, + endRow: maxRow, + startCol: c0, + endCol: c1, + }); +} + +function isFullRowRange(range: CellRange, maxCol: number): boolean { + const r = normalizeRange(range); + return r.startCol === 0 && r.endCol === maxCol; +} + +function isFullColRange(range: CellRange, maxRow: number): boolean { + const r = normalizeRange(range); + return r.startRow === 0 && r.endRow === maxRow; +} + +function rangeContainsRow(range: CellRange, row: number, maxCol: number): boolean { + const r = normalizeRange(range); + return isFullRowRange(r, maxCol) && row >= r.startRow && row <= r.endRow; +} + +function rangeContainsCol(range: CellRange, col: number, maxRow: number): boolean { + const r = normalizeRange(range); + return isFullColRange(r, maxRow) && col >= r.startCol && col <= r.endCol; +} + +export function useSelection() { + const ranges = ref([]); + const anchor = ref<{ row: number; col: number } | null>(null); + const activeCell = ref<{ row: number; col: number } | null>(null); + const isFillDragging = ref(false); + + /** @deprecated use ranges */ + const selection = ranges; + + function selectCell(row: number, col: number) { + activeCell.value = { row, col }; + anchor.value = { row, col }; + ranges.value = [ + { startRow: row, startCol: col, endRow: row, endCol: col }, + ]; + } + + function extendSelection(row: number, col: number) { + if (!anchor.value) { + selectCell(row, col); + return; + } + ranges.value = [ + { + startRow: anchor.value.row, + startCol: anchor.value.col, + endRow: row, + endCol: col, + }, + ]; + activeCell.value = { row, col }; + } + + function selectRange(range: CellRange) { + const normalized = normalizeRange(range); + ranges.value = [normalized]; + activeCell.value = { + row: normalized.startRow, + col: normalized.startCol, + }; + anchor.value = { ...activeCell.value }; + } + + function getSelectionRanges(): CellRange[] { + return ranges.value.map((r) => normalizeRange(r)); + } + + function getNormalizedSelection(): CellRange | null { + const primary = getPrimaryRange(); + return primary; + } + + function getPrimaryRange(): CellRange | null { + if (!ranges.value.length) return null; + if (!activeCell.value) return normalizeRange(ranges.value[0]!); + const { row, col } = activeCell.value; + for (const range of ranges.value) { + const r = normalizeRange(range); + if ( + row >= r.startRow && + row <= r.endRow && + col >= r.startCol && + col <= r.endCol + ) { + return r; + } + } + return normalizeRange(ranges.value[ranges.value.length - 1]!); + } + + function containsCell(row: number, col: number): boolean { + return rangesContainCell(ranges.value, row, col); + } + + function selectAll(maxRow: number, maxCol: number) { + const range = normalizeRange({ + startRow: 0, + endRow: maxRow, + startCol: 0, + endCol: maxCol, + }); + ranges.value = [range]; + activeCell.value = { row: 0, col: 0 }; + anchor.value = { row: 0, col: 0 }; + } + + function selectEntireRow( + row: number, + maxCol: number, + opts: SelectionModifiers = {}, + ) { + const { shift, ctrl } = opts; + if (ctrl) { + const next = [...ranges.value]; + const idx = next.findIndex((r) => rangeContainsRow(r, row, maxCol)); + if (idx >= 0) { + next.splice(idx, 1); + } else { + next.push(fullRowRange(row, maxCol)); + } + ranges.value = next.length ? next : []; + if (ranges.value.length) { + activeCell.value = { row, col: 0 }; + if (!shift) anchor.value = { row, col: 0 }; + } else { + activeCell.value = null; + } + return; + } + if (shift && anchor.value) { + const range = rowRange(anchor.value.row, row, maxCol); + ranges.value = [range]; + activeCell.value = { row, col: 0 }; + return; + } + ranges.value = [fullRowRange(row, maxCol)]; + activeCell.value = { row, col: 0 }; + anchor.value = { row, col: 0 }; + } + + function selectEntireColumn( + col: number, + maxRow: number, + opts: SelectionModifiers = {}, + ) { + const { shift, ctrl } = opts; + if (ctrl) { + const next = [...ranges.value]; + const idx = next.findIndex((r) => rangeContainsCol(r, col, maxRow)); + if (idx >= 0) { + next.splice(idx, 1); + } else { + next.push(fullColRange(col, maxRow)); + } + ranges.value = next.length ? next : []; + if (ranges.value.length) { + activeCell.value = { row: 0, col }; + if (!shift) anchor.value = { row: 0, col }; + } else { + activeCell.value = null; + } + return; + } + if (shift && anchor.value) { + const range = colRange(anchor.value.col, col, maxRow); + ranges.value = [range]; + activeCell.value = { row: 0, col }; + return; + } + ranges.value = [fullColRange(col, maxRow)]; + activeCell.value = { row: 0, col }; + anchor.value = { row: 0, col }; + } + + function extendRowSelection(row: number, maxCol: number) { + if (!anchor.value) { + selectEntireRow(row, maxCol); + return; + } + ranges.value = [rowRange(anchor.value.row, row, maxCol)]; + activeCell.value = { row, col: 0 }; + } + + function extendColumnSelection(col: number, maxRow: number) { + if (!anchor.value) { + selectEntireColumn(col, maxRow); + return; + } + ranges.value = [colRange(anchor.value.col, col, maxRow)]; + activeCell.value = { row: 0, col }; + } + + function clearSelection() { + ranges.value = []; + anchor.value = null; + activeCell.value = null; + } + + return { + ranges, + selection, + anchor, + activeCell, + isFillDragging, + selectCell, + extendSelection, + selectRange, + selectAll, + selectEntireRow, + selectEntireColumn, + extendRowSelection, + extendColumnSelection, + getSelectionRanges, + getNormalizedSelection, + getPrimaryRange, + containsCell, + clearSelection, + }; +} diff --git a/src/components/canvas-spreadsheet/composables/useSpreadsheetContextMenu.ts b/src/components/canvas-spreadsheet/composables/useSpreadsheetContextMenu.ts new file mode 100644 index 0000000..e159cf1 --- /dev/null +++ b/src/components/canvas-spreadsheet/composables/useSpreadsheetContextMenu.ts @@ -0,0 +1,270 @@ +import type { ContextMenuItem } from '#/components/context-menu'; +import { menuDivider, showContextMenu } from '#/components/context-menu'; + +import { message } from 'ant-design-vue'; +import type { + CellEditContext, + HeaderContextMenuContext, + SpreadsheetColumnConfig, + SpreadsheetEngineExpose, + SpreadsheetHeaderConfig, + SpreadsheetTableConfig, +} from '../types'; + +export interface HeaderLayoutActions { + toggleFixed: (col: number) => void; + setHeaderColor: (col: number, color?: string) => void; + isFixed: (col: number) => boolean; + getHeaderColor: (col: number) => string | undefined; +} + +export interface SpreadsheetAdminMenuActions { + onOpenConfig: () => void; + onAddColumn: () => void; + onSetColumn: (col: number) => void; +} + +export interface SpreadsheetMenuActions { + col: number; + layoutActions?: HeaderLayoutActions; + onOpenHeaderColorPicker: (col: number) => void; + admin?: SpreadsheetAdminMenuActions; +} + +function resolveCustomItems( + config: + | ContextMenuItem[] + | ((ctx: T) => ContextMenuItem[]) + | undefined, + ctx: T, +): ContextMenuItem[] { + if (!config) return []; + return typeof config === 'function' ? config(ctx) : config; +} + +function buildCellBuiltinItems( + ctx: CellEditContext, + engine: SpreadsheetEngineExpose, + onStartEdit: (row: number, col: number) => void, +): ContextMenuItem[] { + const canEditCell = engine.canEdit(ctx.rowIndex, ctx.colIndex); + const sel = ctx.selection; + return [ + { + key: 'edit', + label: '编辑', + iconName: 'ant-design:edit-outlined', + disabled: !canEditCell, + handler: () => onStartEdit(ctx.rowIndex, ctx.colIndex), + }, + { + key: 'copy', + label: '复制', + iconName: 'ant-design:copy-outlined', + disabled: !sel, + handler: () => { + if (sel) { + engine.copy(); + message.success('复制成功'); + } + }, + }, + { + key: 'copy-image', + label: '复制为图片', + iconName: 'ant-design:picture-outlined', + disabled: !sel, + handler: () => { + if (!sel) return; + void engine + .copyAsImage() + .then(() => { + message.success('已复制为图片'); + }) + .catch(() => { + message.warning('复制图片失败,请检查浏览器剪贴板权限'); + }); + }, + }, + { + key: 'paste', + label: '粘贴', + iconName: 'ant-design:snippets-outlined', + disabled: !engine.canPaste(), + handler: () => { + void engine.paste(); + }, + }, + { + key: 'undo', + label: '撤回', + iconName: 'ant-design:undo-outlined', + disabled: !engine.canUndo(), + handler: () => { + void engine.undo(); + }, + }, + ]; +} + +function buildFixedItem( + col: number, + layoutActions?: HeaderLayoutActions, +): ContextMenuItem | null { + if (!layoutActions) return null; + const fixed = layoutActions.isFixed(col); + return { + key: 'toggle-fixed', + label: fixed ? '取消固定' : '固定此列', + iconName: 'ant-design:pushpin-outlined', + handler: () => layoutActions.toggleFixed(col), + }; +} + +function buildHeaderColorItem( + col: number, + onOpen: (col: number) => void, +): ContextMenuItem { + return { + key: 'header-color', + label: '表头颜色', + iconName: 'ant-design:bg-colors-outlined', + handler: () => onOpen(col), + }; +} + +function buildAdminFlatItemsForCol( + col: number, + admin: SpreadsheetAdminMenuActions, +): ContextMenuItem[] { + return [ + { + key: 'admin-config', + label: 'Excel 配置', + iconName: 'mdi:table-cog', + handler: () => admin.onOpenConfig(), + }, + { + key: 'admin-add-col', + label: '新增一列', + iconName: 'ant-design:plus-outlined', + handler: () => admin.onAddColumn(), + }, + { + key: 'admin-set-col', + label: '设置列', + iconName: 'ant-design:setting-outlined', + handler: () => admin.onSetColumn(col), + }, + ]; +} + +function buildColumnSubmenu(menuActions: SpreadsheetMenuActions): ContextMenuItem { + const { col, layoutActions, onOpenHeaderColorPicker, admin } = menuActions; + const children: ContextMenuItem[] = []; + + if (admin) { + children.push({ + key: 'admin-add-col', + label: '新增一列', + iconName: 'ant-design:plus-outlined', + handler: () => admin.onAddColumn(), + }); + children.push({ + key: 'admin-set-col', + label: '设置列', + iconName: 'ant-design:setting-outlined', + handler: () => admin.onSetColumn(col), + }); + } + + const fixedItem = buildFixedItem(col, layoutActions); + if (fixedItem) children.push(fixedItem); + + children.push(buildHeaderColorItem(col, onOpenHeaderColorPicker)); + + return { + key: 'column-submenu', + label: '列', + iconName: 'ant-design:table-outlined', + children, + }; +} + +function buildHeaderMenuItems(menuActions: SpreadsheetMenuActions): ContextMenuItem[] { + const { col, layoutActions, onOpenHeaderColorPicker, admin } = menuActions; + const items: ContextMenuItem[] = []; + + const fixedItem = buildFixedItem(col, layoutActions); + if (fixedItem) items.push(fixedItem); + + items.push(buildHeaderColorItem(col, onOpenHeaderColorPicker)); + + if (admin) { + items.push(...buildAdminFlatItemsForCol(col, admin)); + } + + return items; +} + +function buildCellAdminItems(menuActions: SpreadsheetMenuActions): ContextMenuItem[] { + if (!menuActions.admin) return []; + return [ + { + key: 'admin-config', + label: 'Excel 配置', + iconName: 'mdi:table-cog', + handler: () => menuActions.admin!.onOpenConfig(), + }, + buildColumnSubmenu(menuActions), + ]; +} + +function mergeMenus( + builtin: ContextMenuItem[], + custom: ContextMenuItem[], +): ContextMenuItem[] { + if (!custom.length) return builtin; + return [...builtin, menuDivider('__divider__'), ...custom]; +} + +function appendAdminBlock( + items: ContextMenuItem[], + adminItems: ContextMenuItem[], +): ContextMenuItem[] { + if (!adminItems.length) return items; + return [...items, menuDivider('__admin_divider__'), ...adminItems]; +} + +export function openCellContextMenu( + e: MouseEvent, + ctx: CellEditContext, + column: SpreadsheetColumnConfig | undefined, + engine: SpreadsheetEngineExpose, + onStartEdit: (row: number, col: number) => void, + menuActions?: SpreadsheetMenuActions, +) { + const builtin = buildCellBuiltinItems(ctx, engine, onStartEdit); + const custom = resolveCustomItems(column?.contextMenu, ctx); + const adminItems = menuActions ? buildCellAdminItems(menuActions) : []; + showContextMenu( + e, + appendAdminBlock(mergeMenus(builtin, custom), adminItems), + ctx, + ); +} + +export function openHeaderContextMenu( + e: MouseEvent, + ctx: HeaderContextMenuContext, + header: SpreadsheetHeaderConfig | undefined, + tableConfig: SpreadsheetTableConfig, + menuActions: SpreadsheetMenuActions, +) { + const builtin = buildHeaderMenuItems(menuActions); + let custom = resolveCustomItems(header?.contextMenu, ctx); + if (!custom.length) { + custom = resolveCustomItems(tableConfig.headerContextMenu, ctx); + } + showContextMenu(e, mergeMenus(builtin, custom), ctx); +} diff --git a/src/components/canvas-spreadsheet/composables/useSpreadsheetEngine.ts b/src/components/canvas-spreadsheet/composables/useSpreadsheetEngine.ts new file mode 100644 index 0000000..1020e11 --- /dev/null +++ b/src/components/canvas-spreadsheet/composables/useSpreadsheetEngine.ts @@ -0,0 +1,784 @@ +import { computed, ref, shallowRef, watch, type Ref } from 'vue'; + +import { createFillRangeCommand, iterateRange, iterateRanges, normalizeRange, rangesContainCell } from '../commands/FillRangeCommand'; +import { createPasteRangeCommand } from '../commands/PasteRangeCommand'; +import { createSetCellValueCommand } from '../commands/SetCellValueCommand'; +import { + computeFixedWidth, + getContentSize, + getFixedColIndices, + renderSelectionImage, +} from '../canvas/SpreadsheetRenderer'; +import { applyViewState, isColumnFilterActive } from './filterSort'; +import { + getRowHeight, + hitTestCell, + hitTestHeaderCol, + hitTestRowNumber, + hitTestHeaderArea, + measureColumnWidths, +} from '../composables/useAutoMeasure'; +import { useClipboard } from '../composables/useClipboard'; +import { useEditHistory } from '../composables/useEditHistory'; +import { useSelection } from '../composables/useSelection'; +import { useUndoRedo } from '../composables/useUndoRedo'; +import type { + CellEditContext, + CellRange, + SpreadsheetBatchCellChange, + SpreadsheetColumnConfig, + SpreadsheetEngineExpose, + SpreadsheetHeaderConfig, + SpreadsheetHistoryEntry, + SpreadsheetTableConfig, + SpreadsheetViewState, + SortDirection, +} from '../types'; +import { + createDefaultViewState, + DEFAULT_COL_WIDTH, + DEFAULT_ROW_HEIGHT, + HEADER_HEIGHT, + ROW_NUMBER_WIDTH, +} from '../types'; +import { + evaluateFormula, + formatFormulaResult, + type CellValueReader, + type FormulaResult, +} from '../formula/evaluator'; +import { + formatCellWithSuffix, + resolveConditionalStyle, +} from '../composables/cellDisplay'; + +export function useSpreadsheetEngine(options: { + tableConfig: Ref; + mergedHeaders: Ref; + columns: Ref; + data: Ref[]>; + rowKeyField: Ref; + layoutColWidths: Ref>; + scrollViewportHeight: Ref; + isDark: Ref; + onChange: (data: Record[]) => void; +}) { + const sortedColumns = computed(() => + [...options.columns.value].sort((a, b) => a.col - b.col), + ); + + const colWidths = ref([]); + const rowHeight = ref(DEFAULT_ROW_HEIGHT); + const scrollTop = ref(0); + const scrollLeft = ref(0); + const viewportWidth = ref(800); + const viewportHeight = ref(600); + const filterValues = ref>({}); + const viewState = ref(createDefaultViewState()); + const initialSnapshot = shallowRef>(new Map()); + const dirtyKeys = ref>(new Set()); + const editing = ref<{ row: number; col: number } | null>(null); + const measureCanvas = ref(null); + + const selectionApi = useSelection(); + const undoRedo = useUndoRedo(); + const clipboard = useClipboard(); + const history = useEditHistory( + options.tableConfig.value.tableName, + options.tableConfig.value.sheetKey, + options.tableConfig.value.enableHistory !== false, + ); + + const filteredRowIndices = computed(() => + applyViewState( + options.data.value, + sortedColumns.value, + viewState.value, + (dataRowIndex, colCfg, row) => + resolveDisplayValueForDataRow(dataRowIndex, colCfg, row), + ), + ); + + function resolveDisplayValueForDataRow( + dataRowIndex: number, + colCfg: SpreadsheetColumnConfig, + row?: Record, + ): unknown { + if (colCfg.cellType === 'formula' && colCfg.formula) { + const result = evaluateFormula( + colCfg.formula, + dataRowIndex, + (viewRow, col) => { + const cfg = colConfig(col); + if (!cfg || cfg.cellType === 'formula') return 0; + const dr = options.data.value[viewRow]; + return dr ? getCellValue(viewRow, cfg.fieldId) : 0; + }, + ); + return formatFormulaResult(result); + } + const rowData = row ?? options.data.value[dataRowIndex]; + if (!rowData) return undefined; + return rowData[colCfg.fieldId]; + } + + const fixedCols = computed(() => + getFixedColIndices(options.mergedHeaders.value), + ); + + const fixedWidth = computed(() => + computeFixedWidth(colWidths.value, fixedCols.value), + ); + + const displayRowCount = computed(() => { + const minRows = Math.max( + 1, + Math.ceil(options.scrollViewportHeight.value / rowHeight.value), + ); + return Math.max(filteredRowIndices.value.length, minRows); + }); + + const colCount = computed( + () => Math.max(...sortedColumns.value.map((c) => c.col), 0) + 1, + ); + + const defaultColWidth = computed( + () => options.tableConfig.value.defaultColWidth ?? DEFAULT_COL_WIDTH, + ); + + const dataContentWidth = computed( + () => ROW_NUMBER_WIDTH + colWidths.value.reduce((s, w) => s + w, 0), + ); + + const displayContentWidth = computed(() => + Math.max(dataContentWidth.value, viewportWidth.value), + ); + + const fillerColCount = computed(() => { + const remaining = displayContentWidth.value - dataContentWidth.value; + if (remaining <= 0) return 0; + return Math.ceil(remaining / defaultColWidth.value); + }); + + const displayColCount = computed( + () => colCount.value + fillerColCount.value, + ); + + const paddedColWidths = computed(() => { + const defaultW = defaultColWidth.value; + const result: number[] = []; + for (let c = 0; c < displayColCount.value; c += 1) { + result.push(colWidths.value[c] ?? defaultW); + } + return result; + }); + + function colConfig(col: number) { + return sortedColumns.value.find((c) => c.col === col); + } + + function dataRowFromViewRow(viewRow: number) { + return filteredRowIndices.value[viewRow] ?? viewRow; + } + + function viewRowFromDataRow(dataRow: number) { + return filteredRowIndices.value.indexOf(dataRow); + } + + function isEditable(colCfg?: SpreadsheetColumnConfig) { + if (!colCfg) return false; + if (colCfg.cellType === 'readonly' || colCfg.cellType === 'formula') { + return false; + } + return colCfg.editable !== false; + } + + const readCellForFormula: CellValueReader = (viewRow, col) => { + const cfg = colConfig(col); + if (!cfg || cfg.cellType === 'formula') return 0; + const dataRow = dataRowFromViewRow(viewRow); + return getCellValue(dataRow, cfg.fieldId); + }; + + function resolveNumericValue( + viewRow: number, + colCfg: SpreadsheetColumnConfig, + row?: Record, + ): number | null { + if (colCfg.cellType === 'formula' && colCfg.formula) { + const result: FormulaResult = evaluateFormula( + colCfg.formula, + viewRow, + readCellForFormula, + ); + if (result === 'DIV_ZERO' || result == null) return null; + return result; + } + if (colCfg.cellType === 'number' && row) { + const n = Number(row[colCfg.fieldId]); + return Number.isFinite(n) ? n : null; + } + return null; + } + + function resolveCellStyle( + viewRow: number, + colCfg: SpreadsheetColumnConfig, + row?: Record, + ) { + return resolveConditionalStyle( + colCfg, + resolveNumericValue(viewRow, colCfg, row), + ); + } + + function resolveDisplayValue( + viewRow: number, + colCfg: SpreadsheetColumnConfig, + row?: Record, + ): unknown { + if (colCfg.cellType === 'formula' && colCfg.formula) { + const result = evaluateFormula( + colCfg.formula, + viewRow, + readCellForFormula, + ); + return formatFormulaResult(result); + } + if (!row) return undefined; + return row[colCfg.fieldId]; + } + + function getCellValue(dataRow: number, fieldId: string) { + return options.data.value[dataRow]?.[fieldId]; + } + + function buildCellContext(viewRow: number, col: number): CellEditContext | null { + const colCfg = colConfig(col); + if (!colCfg) return null; + const dataRow = dataRowFromViewRow(viewRow); + const rowData = options.data.value[dataRow]; + if (!rowData) return null; + return { + fieldId: colCfg.fieldId, + fieldValue: rowData[colCfg.fieldId], + rowIndex: viewRow, + colIndex: col, + rowKey: rowData[options.rowKeyField.value] as string | number, + rowData, + editParams: colCfg.editParams ?? {}, + selection: selectionApi.getPrimaryRange() ?? undefined, + }; + } + + function snapshotKey(dataRow: number, fieldId: string) { + const row = options.data.value[dataRow]; + const rowKey = row?.[options.rowKeyField.value]; + return `${rowKey}:${fieldId}`; + } + + function markDirty(dataRow: number, fieldId: string, value: unknown) { + const key = snapshotKey(dataRow, fieldId); + const initial = initialSnapshot.value.get(key); + const next = new Set(dirtyKeys.value); + if (initial !== undefined && initial !== value) { + next.add(`${dataRow}:${fieldId}`); + } else { + next.delete(`${dataRow}:${fieldId}`); + } + dirtyKeys.value = next; + } + + function applyLocalValue(dataRow: number, fieldId: string, value: unknown) { + const rows = options.data.value.map((r, i) => + i === dataRow ? { ...r, [fieldId]: value } : r, + ); + options.onChange(rows); + markDirty(dataRow, fieldId, value); + } + + async function persistChanges( + items: Array<{ viewRow: number; col: number; value: unknown }>, + opts: { + recordHistory?: boolean; + action?: SpreadsheetHistoryEntry['action']; + } = {}, + ) { + const batchChanges: SpreadsheetBatchCellChange[] = []; + + for (const item of items) { + const colCfg = colConfig(item.col); + if (!colCfg || !isEditable(colCfg)) continue; + + const dataRow = dataRowFromViewRow(item.viewRow); + const ctx = buildCellContext(item.viewRow, item.col); + if (!ctx) continue; + + const before = getCellValue(dataRow, colCfg.fieldId); + applyLocalValue(dataRow, colCfg.fieldId, item.value); + batchChanges.push({ + ctx: { ...ctx, fieldValue: item.value }, + before, + after: item.value, + col: item.col, + viewRow: item.viewRow, + }); + } + + if (batchChanges.length === 0) return; + + const onBatchEdit = options.tableConfig.value.onBatchEdit; + if (onBatchEdit) { + await onBatchEdit(batchChanges); + } else { + for (const ch of batchChanges) { + const cfg = colConfig(ch.col); + if (cfg?.onEdit) { + await cfg.onEdit({ ...ch.ctx, fieldValue: ch.after }); + } + } + } + + if ( + opts.recordHistory !== false && + options.tableConfig.value.enableHistory !== false + ) { + for (const ch of batchChanges) { + await history.recordChange({ + action: opts.action ?? 'edit', + rowKey: ch.ctx.rowKey, + fieldId: ch.ctx.fieldId, + beforeSnapshot: ch.before, + afterSnapshot: ch.after, + }); + } + } + } + + function toPersistItems( + batch: Array<{ row: number; col: number; value: unknown }>, + ) { + return batch.map((ch) => ({ + viewRow: ch.row, + col: ch.col, + value: ch.value, + })); + } + + async function commitCellChange( + viewRow: number, + col: number, + newValue: unknown, + recordHistory = true, + action: SpreadsheetHistoryEntry['action'] = 'edit', + ) { + const colCfg = colConfig(col); + if (!colCfg || !isEditable(colCfg)) return; + + const dataRow = dataRowFromViewRow(viewRow); + const before = getCellValue(dataRow, colCfg.fieldId); + if (before === newValue) return; + + await persistChanges([{ viewRow, col, value: newValue }], { + recordHistory, + action, + }); + + undoRedo.push( + createSetCellValueCommand( + () => + persistChanges([{ viewRow, col, value: before }], { + recordHistory: false, + }), + () => + persistChanges([{ viewRow, col, value: newValue }], { + recordHistory: false, + }), + ), + ); + } + + async function setCellValue( + viewRow: number, + col: number, + value: unknown, + recordHistory = true, + action: SpreadsheetHistoryEntry['action'] = 'edit', + ) { + await commitCellChange(viewRow, col, value, recordHistory, action); + } + + async function fillRange(source: CellRange, target: CellRange) { + const src = normalizeRange(source); + const tgt = normalizeRange(target); + const before: Array<{ row: number; col: number; value: unknown }> = []; + const after: Array<{ row: number; col: number; value: unknown }> = []; + + const srcRows = src.endRow - src.startRow + 1; + const srcCols = src.endCol - src.startCol + 1; + + iterateRange(tgt, (row, col) => { + const colCfg = colConfig(col); + if (!colCfg || !isEditable(colCfg)) return; + const relRow = (row - tgt.startRow) % srcRows; + const relCol = (col - tgt.startCol) % srcCols; + const srcRow = src.startRow + relRow; + const srcCol = src.startCol + relCol; + const srcColCfg = colConfig(srcCol); + if (!srcColCfg) return; + + const dataRow = dataRowFromViewRow(row); + const beforeVal = getCellValue(dataRow, colCfg.fieldId); + + const afterVal = getCellValue( + dataRowFromViewRow(srcRow), + srcColCfg.fieldId, + ); + + before.push({ row, col, value: beforeVal }); + after.push({ row, col, value: afterVal }); + }); + + await persistChanges(toPersistItems(after), { + recordHistory: true, + action: 'fill', + }); + + undoRedo.push( + createFillRangeCommand( + (batch) => persistChanges(toPersistItems(batch), { recordHistory: false }), + before, + after, + ), + ); + } + + function copy(selectionOverride?: CellRange | CellRange[]) { + const selRanges = Array.isArray(selectionOverride) + ? selectionOverride.map((r) => normalizeRange(r)) + : selectionOverride + ? [normalizeRange(selectionOverride)] + : selectionApi.getSelectionRanges(); + if (!selRanges.length) return; + + const rowSet = new Set(); + const colSet = new Set(); + iterateRanges(selRanges, (viewRow, col) => { + rowSet.add(viewRow); + colSet.add(col); + }); + + const uniqueRows = [...rowSet].sort((a, b) => a - b); + const uniqueCols = [...colSet].sort((a, b) => a - b); + if (!uniqueRows.length || !uniqueCols.length) return; + + const rows: unknown[][] = uniqueRows.map((viewRow) => + uniqueCols.map((col) => { + if (!rangesContainCell(selRanges, viewRow, col)) return ''; + const colCfg = colConfig(col); + if (!colCfg) return ''; + const dataRow = dataRowFromViewRow(viewRow); + const row = options.data.value[dataRow]; + const raw = resolveDisplayValue(viewRow, colCfg, row); + return formatCellWithSuffix(raw, colCfg); + }), + ); + + const matrix = { + rows, + startRow: uniqueRows[0]!, + startCol: uniqueCols[0]!, + }; + clipboard.setMatrix(matrix); + void clipboard.writeSystemClipboard(matrix); + } + + async function copyAsImage(selectionOverride?: CellRange | CellRange[]) { + const selRanges = Array.isArray(selectionOverride) + ? selectionOverride.map((r) => normalizeRange(r)) + : selectionOverride + ? [normalizeRange(selectionOverride)] + : selectionApi.getSelectionRanges(); + if (!selRanges.length) return; + const blob = await renderSelectionImage({ + selectionRanges: selRanges, + headers: options.mergedHeaders.value, + columns: sortedColumns.value, + colWidths: paddedColWidths.value, + rowHeight: rowHeight.value, + defaultColWidth: defaultColWidth.value, + defaultTextAlign: options.tableConfig.value.defaultTextAlign ?? 'left', + isDark: options.isDark.value, + filteredRowIndices: filteredRowIndices.value, + rows: options.data.value, + dirtyKeys: dirtyKeys.value, + resolveDisplayValue, + resolveCellStyle, + }); + await navigator.clipboard.write([ + new ClipboardItem({ 'image/png': blob }), + ]); + } + + async function paste() { + let matrix = clipboard.getMatrix(); + if (!matrix) { + const text = await clipboard.readSystemClipboard(); + if (!text) return; + matrix = { + rows: clipboard.tsvToMatrix(text), + startRow: 0, + startCol: 0, + }; + } + + const start = selectionApi.activeCell.value ?? { + row: 0, + col: sortedColumns.value[0]?.col ?? 0, + }; + + const before: Array<{ row: number; col: number; value: unknown }> = []; + const after: Array<{ row: number; col: number; value: unknown }> = []; + + matrix.rows.forEach((rowVals, rOff) => { + rowVals.forEach((val, cOff) => { + const viewRow = start.row + rOff; + const col = start.col + cOff; + const colCfg = colConfig(col); + if (!colCfg || !isEditable(colCfg)) return; + const dataRow = dataRowFromViewRow(viewRow); + if (dataRow >= options.data.value.length) return; + before.push({ + row: viewRow, + col, + value: getCellValue(dataRow, colCfg.fieldId), + }); + after.push({ row: viewRow, col, value: val }); + }); + }); + + const applyBatch = ( + changes: Array<{ row: number; col: number; value: unknown }>, + ) => persistChanges(toPersistItems(changes), { recordHistory: false }); + + await persistChanges(toPersistItems(after), { + recordHistory: true, + action: 'paste', + }); + + undoRedo.push(createPasteRangeCommand(applyBatch, before, after)); + } + + function remeasure() { + if (!measureCanvas.value) return; + const defaultW = + options.tableConfig.value.defaultColWidth ?? DEFAULT_COL_WIDTH; + const measured = measureColumnWidths( + measureCanvas.value, + options.mergedHeaders.value, + sortedColumns.value, + options.data.value, + defaultW, + (dataRowIdx, colCfg, row) => + resolveDisplayValue(dataRowIdx, colCfg, row), + formatCellWithSuffix, + ); + const overrides = options.layoutColWidths.value; + colWidths.value = measured.map((w, i) => overrides[i] ?? w); + const firstType = sortedColumns.value[0]?.cellType ?? 'text'; + rowHeight.value = getRowHeight( + firstType, + options.tableConfig.value.rowHeight, + ); + } + + function initSnapshot() { + const map = new Map(); + options.data.value.forEach((row, dataRow) => { + sortedColumns.value.forEach((col) => { + map.set(snapshotKey(dataRow, col.fieldId), row[col.fieldId]); + }); + }); + initialSnapshot.value = map; + dirtyKeys.value = new Set(); + } + + watch( + () => options.data.value, + () => { + if (initialSnapshot.value.size === 0) initSnapshot(); + remeasure(); + }, + { deep: true, immediate: true }, + ); + + watch( + () => options.mergedHeaders.value, + () => remeasure(), + { deep: true }, + ); + + watch( + () => options.layoutColWidths.value, + () => remeasure(), + { deep: true }, + ); + + const contentSize = computed(() => + getContentSize( + colWidths.value, + displayRowCount.value, + rowHeight.value, + displayContentWidth.value, + ), + ); + + function setColumnFilter(col: number, checkedValues: string[] | undefined) { + const filters = { ...viewState.value.filters }; + if (checkedValues === undefined) { + delete filters[col]; + } else { + filters[col] = { checkedValues }; + } + viewState.value = { ...viewState.value, filters }; + } + + function clearColumnFilter(col: number) { + const next = { ...viewState.value.filters }; + delete next[col]; + viewState.value = { ...viewState.value, filters: next }; + } + + function setSort(col: number, direction: SortDirection) { + viewState.value = { ...viewState.value, sort: { col, direction } }; + } + + function clearSort() { + viewState.value = { ...viewState.value, sort: null }; + } + + const engine: SpreadsheetEngineExpose = { + copy, + copyAsImage, + paste, + undo: undoRedo.undo, + redo: undoRedo.redo, + canUndo: undoRedo.canUndo, + canRedo: undoRedo.canRedo, + canCopy: () => selectionApi.getSelectionRanges().length > 0, + canCopyAsImage: () => selectionApi.getSelectionRanges().length > 0, + canPaste: () => clipboard.hasContent() || true, + canEdit: (_row, col) => { + const cfg = colConfig(col); + return !!cfg && isEditable(cfg); + }, + getSelection: selectionApi.getSelectionRanges, + getCellEditContext: (row, col) => buildCellContext(row, col), + getHeaderContext: (col) => { + const header = + options.mergedHeaders.value.find((h) => h.col === col) ?? { + title: '', + col, + }; + return { + colIndex: col, + fieldId: header.fieldId ?? colConfig(col)?.fieldId, + headerTitle: header.title, + filterValue: isColumnFilterActive(viewState.value.filters[col]) + ? 'active' + : '', + }; + }, + setCellValue, + fillRange, + getColumnConfig: colConfig, + getFilterValue: (col) => + isColumnFilterActive(viewState.value.filters[col]) ? 'active' : '', + }; + + return { + engine, + sortedColumns, + colWidths, + rowHeight, + scrollTop, + scrollLeft, + viewportWidth, + viewportHeight, + filterValues, + viewState, + setColumnFilter, + clearColumnFilter, + setSort, + clearSort, + dirtyKeys, + editing, + measureCanvas, + filteredRowIndices, + contentSize, + displayRowCount, + displayColCount, + displayContentWidth, + defaultColWidth, + paddedColWidths, + fixedCols, + fixedWidth, + colCount, + selectionApi, + undoRedo, + dataRowFromViewRow, + viewRowFromDataRow, + colConfig, + isEditable, + resolveDisplayValue, + resolveCellStyle, + buildCellContext, + commitCellChange, + remeasure, + initSnapshot, + hitTestCell: (x: number, y: number) => + hitTestCell( + x, + y, + scrollTop.value, + scrollLeft.value, + paddedColWidths.value, + rowHeight.value, + displayRowCount.value, + colCount.value, + fixedCols.value, + fixedWidth.value, + ), + hitTestHeaderCol: (x: number) => + hitTestHeaderCol( + x, + scrollLeft.value, + paddedColWidths.value, + displayColCount.value, + fixedCols.value, + fixedWidth.value, + ), + hitTestRowNumber: (x: number, y: number) => + hitTestRowNumber( + x, + y, + scrollTop.value, + rowHeight.value, + displayRowCount.value, + ), + hitTestHeaderArea: (x: number, y: number) => + hitTestHeaderArea( + x, + y, + scrollLeft.value, + paddedColWidths.value, + displayColCount.value, + fixedCols.value, + fixedWidth.value, + ), + HEADER_HEIGHT, + ROW_NUMBER_WIDTH, + }; +} + +export type SpreadsheetEngine = ReturnType; diff --git a/src/components/canvas-spreadsheet/composables/useSpreadsheetFind.ts b/src/components/canvas-spreadsheet/composables/useSpreadsheetFind.ts new file mode 100644 index 0000000..14c684e --- /dev/null +++ b/src/components/canvas-spreadsheet/composables/useSpreadsheetFind.ts @@ -0,0 +1,125 @@ +import { computed, ref, watch, type Ref } from 'vue'; + +import { useDebounceFn } from '@vueuse/core'; + +import { formatCellWithSuffix } from './cellDisplay'; +import type { SpreadsheetColumnConfig } from '../types'; + +export interface FindMatch { + row: number; + col: number; +} + +export function useSpreadsheetFind(options: { + filteredRowIndices: Ref; + columns: Ref; + rows: Ref[]>; + resolveDisplayValue: ( + viewRow: number, + colCfg: SpreadsheetColumnConfig, + row?: Record, + ) => unknown; +}) { + const open = ref(false); + const query = ref(''); + const matches = ref([]); + const activeIndex = ref(-1); + + function computeMatches(keyword: string): FindMatch[] { + const q = keyword.trim().toLowerCase(); + if (!q) return []; + + const result: FindMatch[] = []; + const cols = [...options.columns.value].sort((a, b) => a.col - b.col); + const rowCount = options.filteredRowIndices.value.length; + + for (let viewRow = 0; viewRow < rowCount; viewRow += 1) { + const dataRow = options.filteredRowIndices.value[viewRow]; + const row = + dataRow !== undefined ? options.rows.value[dataRow] : undefined; + if (!row) continue; + + for (const colCfg of cols) { + const raw = options.resolveDisplayValue(viewRow, colCfg, row); + const text = formatCellWithSuffix(raw, colCfg).toLowerCase(); + if (text.includes(q)) { + result.push({ row: viewRow, col: colCfg.col }); + } + } + } + return result; + } + + const debouncedSearch = useDebounceFn((keyword: string) => { + matches.value = computeMatches(keyword); + activeIndex.value = matches.value.length > 0 ? 0 : -1; + }, 150); + + watch(query, (v) => { + if (!open.value) return; + void debouncedSearch(v); + }); + + const matchCount = computed(() => matches.value.length); + + const activeMatch = computed(() => { + if (activeIndex.value < 0 || activeIndex.value >= matches.value.length) { + return null; + } + return matches.value[activeIndex.value] ?? null; + }); + + function openFind(initialQuery?: string) { + open.value = true; + if (initialQuery !== undefined) { + query.value = initialQuery; + } + matches.value = computeMatches(query.value); + activeIndex.value = matches.value.length > 0 ? 0 : -1; + } + + function closeFind() { + open.value = false; + query.value = ''; + matches.value = []; + activeIndex.value = -1; + } + + function toggleFind() { + if (open.value) closeFind(); + else openFind(); + } + + function goNext(): FindMatch | null { + if (!matches.value.length) return null; + activeIndex.value = + activeIndex.value < 0 + ? 0 + : (activeIndex.value + 1) % matches.value.length; + return matches.value[activeIndex.value] ?? null; + } + + function goPrev(): FindMatch | null { + if (!matches.value.length) return null; + activeIndex.value = + activeIndex.value < 0 + ? matches.value.length - 1 + : (activeIndex.value - 1 + matches.value.length) % + matches.value.length; + return matches.value[activeIndex.value] ?? null; + } + + return { + open, + query, + matches, + activeIndex, + matchCount, + activeMatch, + openFind, + closeFind, + toggleFind, + goNext, + goPrev, + }; +} diff --git a/src/components/canvas-spreadsheet/composables/useUndoRedo.ts b/src/components/canvas-spreadsheet/composables/useUndoRedo.ts new file mode 100644 index 0000000..12ec4b9 --- /dev/null +++ b/src/components/canvas-spreadsheet/composables/useUndoRedo.ts @@ -0,0 +1,47 @@ +import { ref } from 'vue'; + +import type { SpreadsheetCommand } from '../types'; + +const MAX_STACK = 100; + +export function useUndoRedo() { + const undoStack = ref([]); + const redoStack = ref([]); + + function push(cmd: SpreadsheetCommand) { + undoStack.value.push(cmd); + if (undoStack.value.length > MAX_STACK) { + undoStack.value.shift(); + } + redoStack.value = []; + } + + async function undo() { + const cmd = undoStack.value.pop(); + if (!cmd) return; + await cmd.undo(); + redoStack.value.push(cmd); + } + + async function redo() { + const cmd = redoStack.value.pop(); + if (!cmd) return; + await cmd.redo(); + undoStack.value.push(cmd); + } + + function canUndo() { + return undoStack.value.length > 0; + } + + function canRedo() { + return redoStack.value.length > 0; + } + + function clear() { + undoStack.value = []; + redoStack.value = []; + } + + return { push, undo, redo, canUndo, canRedo, clear }; +} diff --git a/src/components/canvas-spreadsheet/editor/CellEditorOverlay.vue b/src/components/canvas-spreadsheet/editor/CellEditorOverlay.vue new file mode 100644 index 0000000..ab7c715 --- /dev/null +++ b/src/components/canvas-spreadsheet/editor/CellEditorOverlay.vue @@ -0,0 +1,238 @@ + + + + + diff --git a/src/components/canvas-spreadsheet/formula/evaluator.ts b/src/components/canvas-spreadsheet/formula/evaluator.ts new file mode 100644 index 0000000..3ded0ef --- /dev/null +++ b/src/components/canvas-spreadsheet/formula/evaluator.ts @@ -0,0 +1,191 @@ +export type CellValueReader = (viewRow: number, col: number) => unknown; + +export type FormulaResult = number | 'DIV_ZERO' | null; + +class DivZeroError extends Error { + constructor() { + super('Division by zero'); + this.name = 'DivZeroError'; + } +} + +export function colLettersToIndex(letters: string): number { + let n = 0; + for (const ch of letters.toUpperCase()) { + n = n * 26 + (ch.charCodeAt(0) - 64); + } + return n - 1; +} + +export function colIndexToLetters(index: number): string { + let n = index + 1; + let result = ''; + while (n > 0) { + n -= 1; + result = String.fromCharCode(65 + (n % 26)) + result; + n = Math.floor(n / 26); + } + return result; +} + +function toNumber(value: unknown): number { + if (value == null || value === '') return 0; + const n = Number(value); + return Number.isFinite(n) ? n : Number.NaN; +} + +function tokenize(expr: string): string[] { + const tokens: string[] = []; + let i = 0; + while (i < expr.length) { + const ch = expr[i]!; + if (ch === ' ' || ch === '\t') { + i += 1; + continue; + } + if ('+-*/()'.includes(ch)) { + tokens.push(ch); + i += 1; + continue; + } + if (/[0-9.]/.test(ch)) { + let num = ch; + i += 1; + while (i < expr.length && /[0-9.]/.test(expr[i]!)) { + num += expr[i]; + i += 1; + } + tokens.push(num); + continue; + } + if (/[A-Za-z]/.test(ch)) { + let ref = ch; + i += 1; + while (i < expr.length && /[A-Za-z0-9]/.test(expr[i]!)) { + ref += expr[i]; + i += 1; + } + tokens.push(ref); + continue; + } + throw new Error(`Unexpected character: ${ch}`); + } + return tokens; +} + +function parseFactor( + tokens: string[], + pos: { i: number }, + readCell: CellValueReader, + viewRow: number, +): number { + const token = tokens[pos.i]; + if (token === undefined) throw new Error('Unexpected end'); + + if (token === '(') { + pos.i += 1; + const value = parseExpression(tokens, pos, readCell, viewRow); + if (tokens[pos.i] !== ')') throw new Error('Missing )'); + pos.i += 1; + return value; + } + + if (token === '+') { + pos.i += 1; + return parseFactor(tokens, pos, readCell, viewRow); + } + + if (token === '-') { + pos.i += 1; + return -parseFactor(tokens, pos, readCell, viewRow); + } + + if (/^[0-9]+(\.[0-9]+)?$/.test(token)) { + pos.i += 1; + return Number(token); + } + + const match = /^([A-Za-z]+)(\d+)$/.exec(token); + if (match) { + pos.i += 1; + const col = colLettersToIndex(match[1]!); + return toNumber(readCell(viewRow, col)); + } + + throw new Error(`Invalid token: ${token}`); +} + +function parseTerm( + tokens: string[], + pos: { i: number }, + readCell: CellValueReader, + viewRow: number, +): number { + let value = parseFactor(tokens, pos, readCell, viewRow); + while (pos.i < tokens.length) { + const op = tokens[pos.i]; + if (op === '*' || op === '/') { + pos.i += 1; + const rhs = parseFactor(tokens, pos, readCell, viewRow); + if (op === '/') { + if (rhs === 0) throw new DivZeroError(); + value /= rhs; + } else { + value *= rhs; + } + } else { + break; + } + } + return value; +} + +function parseExpression( + tokens: string[], + pos: { i: number }, + readCell: CellValueReader, + viewRow: number, +): number { + let value = parseTerm(tokens, pos, readCell, viewRow); + while (pos.i < tokens.length) { + const op = tokens[pos.i]; + if (op === '+' || op === '-') { + pos.i += 1; + const rhs = parseTerm(tokens, pos, readCell, viewRow); + value = op === '+' ? value + rhs : value - rhs; + } else { + break; + } + } + return value; +} + +export function evaluateFormula( + formula: string, + viewRow: number, + readCell: CellValueReader, +): FormulaResult { + if (!formula?.trim()) return null; + let expr = formula.trim(); + if (expr.startsWith('=')) expr = expr.slice(1); + if (!expr) return null; + + try { + const tokens = tokenize(expr); + const pos = { i: 0 }; + const result = parseExpression(tokens, pos, readCell, viewRow); + if (pos.i < tokens.length) throw new Error('Trailing tokens'); + if (!Number.isFinite(result)) return null; + return result; + } catch (err) { + if (err instanceof DivZeroError) return 'DIV_ZERO'; + return null; + } +} + +export function formatFormulaResult(value: FormulaResult): string { + if (value === 'DIV_ZERO') return '-'; + if (value == null || Number.isNaN(value)) return '#ERROR'; + if (Number.isInteger(value)) return String(value); + return String(Math.round(value * 100) / 100); +} diff --git a/src/components/canvas-spreadsheet/header/HeaderColumnFilter.vue b/src/components/canvas-spreadsheet/header/HeaderColumnFilter.vue new file mode 100644 index 0000000..9770d4d --- /dev/null +++ b/src/components/canvas-spreadsheet/header/HeaderColumnFilter.vue @@ -0,0 +1,297 @@ + + + + + + + diff --git a/src/components/canvas-spreadsheet/header/HeaderFilterLayer.vue b/src/components/canvas-spreadsheet/header/HeaderFilterLayer.vue new file mode 100644 index 0000000..df67541 --- /dev/null +++ b/src/components/canvas-spreadsheet/header/HeaderFilterLayer.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/src/components/canvas-spreadsheet/header/SpreadsheetHeaderColorPickerPopover.vue b/src/components/canvas-spreadsheet/header/SpreadsheetHeaderColorPickerPopover.vue new file mode 100644 index 0000000..90e9080 --- /dev/null +++ b/src/components/canvas-spreadsheet/header/SpreadsheetHeaderColorPickerPopover.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/src/components/canvas-spreadsheet/history/layoutLocalStore.ts b/src/components/canvas-spreadsheet/history/layoutLocalStore.ts new file mode 100644 index 0000000..01a0d1a --- /dev/null +++ b/src/components/canvas-spreadsheet/history/layoutLocalStore.ts @@ -0,0 +1,41 @@ +import type { SpreadsheetLayoutState } from '../types'; + +function storageKey(tableName: string, sheetKey?: string) { + return `xk_spreadsheet_layout_${tableName}_${sheetKey ?? 'default'}`; +} + +export function readSpreadsheetLayout( + tableName: string, + sheetKey?: string, +): SpreadsheetLayoutState | null { + try { + const raw = localStorage.getItem(storageKey(tableName, sheetKey)); + if (!raw) return null; + return JSON.parse(raw) as SpreadsheetLayoutState; + } catch { + return null; + } +} + +export function writeSpreadsheetLayout( + tableName: string, + sheetKey: string | undefined, + state: SpreadsheetLayoutState, +) { + localStorage.setItem(storageKey(tableName, sheetKey), JSON.stringify(state)); +} + +let saveTimer: ReturnType | null = null; + +export function debouncedWriteSpreadsheetLayout( + tableName: string, + sheetKey: string | undefined, + state: SpreadsheetLayoutState, + delayMs = 400, +) { + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(() => { + writeSpreadsheetLayout(tableName, sheetKey, state); + saveTimer = null; + }, delayMs); +} diff --git a/src/components/canvas-spreadsheet/history/localHistoryStore.ts b/src/components/canvas-spreadsheet/history/localHistoryStore.ts new file mode 100644 index 0000000..ad8ce08 --- /dev/null +++ b/src/components/canvas-spreadsheet/history/localHistoryStore.ts @@ -0,0 +1,94 @@ +import type { SpreadsheetHistoryEntry } from '../types'; + +const DB_NAME = 'xk_spreadsheet_history'; +const STORE_NAME = 'entries'; +const DB_VERSION = 1; + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onerror = () => reject(req.error); + req.onsuccess = () => resolve(req.result); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' }); + store.createIndex('tableName', 'tableName', { unique: false }); + store.createIndex('tableSheet', ['tableName', 'sheetKey'], { + unique: false, + }); + store.createIndex('createdAt', 'createdAt', { unique: false }); + store.createIndex('synced', 'synced', { unique: false }); + } + }; + }); +} + +export async function appendLocalHistory(entry: SpreadsheetHistoryEntry) { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + tx.objectStore(STORE_NAME).put(entry); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +export async function listLocalHistory( + tableName: string, + sheetKey?: string, + limit = 100, +): Promise { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const store = tx.objectStore(STORE_NAME); + const index = store.index('tableName'); + const req = index.getAll(tableName); + req.onsuccess = () => { + let items = (req.result as SpreadsheetHistoryEntry[]) ?? []; + if (sheetKey) { + items = items.filter((i) => i.sheetKey === sheetKey); + } + items.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + resolve(items.slice(0, limit)); + }; + req.onerror = () => reject(req.error); + }); +} + +export async function listUnsyncedHistory(): Promise { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const req = tx.objectStore(STORE_NAME).getAll(); + req.onsuccess = () => { + const items = ((req.result as SpreadsheetHistoryEntry[]) ?? []).filter( + (i) => !i.synced, + ); + resolve(items); + }; + req.onerror = () => reject(req.error); + }); +} + +export async function markHistorySynced(ids: string[]) { + if (!ids.length) return; + const db = await openDb(); + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + for (const id of ids) { + const getReq = store.get(id); + getReq.onsuccess = () => { + const entry = getReq.result as SpreadsheetHistoryEntry | undefined; + if (entry) { + entry.synced = true; + store.put(entry); + } + }; + } + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} diff --git a/src/components/canvas-spreadsheet/history/remoteHistoryApi.ts b/src/components/canvas-spreadsheet/history/remoteHistoryApi.ts new file mode 100644 index 0000000..e351c3a --- /dev/null +++ b/src/components/canvas-spreadsheet/history/remoteHistoryApi.ts @@ -0,0 +1,54 @@ +import { requestClient } from '#/api/request'; + +import type { SpreadsheetHistoryEntry } from '../types'; + +const prefix = 'spreadsheet-history/'; + +export interface RemoteHistoryItem { + id: number; + table_name: string; + sheet_key?: string; + admin_user_id: number; + admin_user_name: string; + row_key: string; + field_id: string; + action: string; + before_snapshot: unknown; + after_snapshot: unknown; + client_entry_id?: string; + created_at: string; +} + +export async function saveHistoryBatch( + entries: SpreadsheetHistoryEntry[], +) { + return requestClient.post<{ saved_ids: string[] }>(`${prefix}save-batch`, { + entries: entries.map((e) => ({ + client_entry_id: e.id, + table_name: e.tableName, + sheet_key: e.sheetKey, + row_key: String(e.rowKey), + field_id: e.fieldId, + action: e.action, + before_snapshot: e.beforeSnapshot, + after_snapshot: e.afterSnapshot, + created_at: e.createdAt, + })), + }); +} + +export async function listRemoteHistory(params: { + table_name: string; + sheet_key?: string; + page?: number; + pageSize?: number; +}) { + return requestClient.get<{ + items: RemoteHistoryItem[]; + total: number; + }>(`${prefix}list`, { params }); +} + +export async function rollbackRemoteHistory(id: number) { + return requestClient.post<{ success: boolean }>(`${prefix}rollback`, { id }); +} diff --git a/src/components/canvas-spreadsheet/index.ts b/src/components/canvas-spreadsheet/index.ts new file mode 100644 index 0000000..ba2fc36 --- /dev/null +++ b/src/components/canvas-spreadsheet/index.ts @@ -0,0 +1,4 @@ +export { default as CanvasSpreadsheet } from './CanvasSpreadsheet.vue'; +export * from './types'; +export { resolveColumnTextAlign } from './composables/columnTextAlign'; +export { colIndexToLetters, colLettersToIndex } from './formula/evaluator'; \ No newline at end of file diff --git a/src/components/canvas-spreadsheet/toolbar/SpreadsheetFindBar.vue b/src/components/canvas-spreadsheet/toolbar/SpreadsheetFindBar.vue new file mode 100644 index 0000000..e944893 --- /dev/null +++ b/src/components/canvas-spreadsheet/toolbar/SpreadsheetFindBar.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/src/components/canvas-spreadsheet/toolbar/SpreadsheetFormulaBar.vue b/src/components/canvas-spreadsheet/toolbar/SpreadsheetFormulaBar.vue new file mode 100644 index 0000000..ec8773a --- /dev/null +++ b/src/components/canvas-spreadsheet/toolbar/SpreadsheetFormulaBar.vue @@ -0,0 +1,292 @@ + + + + + + + diff --git a/src/components/canvas-spreadsheet/toolbar/SpreadsheetFormulaLibraryModal.vue b/src/components/canvas-spreadsheet/toolbar/SpreadsheetFormulaLibraryModal.vue new file mode 100644 index 0000000..8947184 --- /dev/null +++ b/src/components/canvas-spreadsheet/toolbar/SpreadsheetFormulaLibraryModal.vue @@ -0,0 +1,194 @@ + + + + + diff --git a/src/components/canvas-spreadsheet/toolbar/SpreadsheetToolbar.vue b/src/components/canvas-spreadsheet/toolbar/SpreadsheetToolbar.vue new file mode 100644 index 0000000..077d64f --- /dev/null +++ b/src/components/canvas-spreadsheet/toolbar/SpreadsheetToolbar.vue @@ -0,0 +1,304 @@ + + + + + diff --git a/src/components/canvas-spreadsheet/types.ts b/src/components/canvas-spreadsheet/types.ts new file mode 100644 index 0000000..3dc6a27 --- /dev/null +++ b/src/components/canvas-spreadsheet/types.ts @@ -0,0 +1,206 @@ +import type { ContextMenuItem } from '#/components/context-menu'; + +export type CellType = + | 'text' + | 'number' + | 'image' + | 'video' + | 'audio' + | 'file' + | 'readonly' + | 'formula'; + +export interface CellRange { + startRow: number; + startCol: number; + endRow: number; + endCol: number; +} + +export interface CellEditContext { + fieldId: string; + fieldValue: unknown; + rowIndex: number; + colIndex: number; + rowKey: string | number; + rowData: Record; + editParams: Record; + selection?: CellRange; +} + +export interface HeaderContextMenuContext { + colIndex: number; + fieldId?: string; + headerTitle: string; + filterValue?: string; +} + +export type ConditionalColorMode = 'background' | 'text' | 'both'; + +export type SpreadsheetTextAlign = 'left' | 'center' | 'right'; + +/** undefined | 'inherit' 均表示跟随全局 defaultTextAlign */ +export type ColumnTextAlign = SpreadsheetTextAlign | 'inherit'; + +export interface ConditionalFormatRule { + threshold: number; + bgColor?: string; + textColor?: string; + colorMode?: ConditionalColorMode; +} + +export interface ColumnConditionalFormat { + gt?: ConditionalFormatRule; + lt?: ConditionalFormatRule; +} + +export interface SpreadsheetColumnConfig { + fieldId: string; + fieldName: string; + cellType: CellType; + col: number; + editable?: boolean; + formula?: string; + suffix?: string; + textAlign?: ColumnTextAlign; + conditionalFormat?: ColumnConditionalFormat; + onEdit?: (ctx: CellEditContext) => void | Promise; + onDoubleClick?: (ctx: CellEditContext) => void; + editParams?: Record; + contextMenu?: + | ContextMenuItem[] + | ((ctx: CellEditContext) => ContextMenuItem[]); +} + +export interface SpreadsheetHeaderConfig { + title: string; + col: number; + fieldId?: string; + filterable?: boolean; + width?: number | 'auto'; + headerColor?: string; + fixed?: boolean; + contextMenu?: + | ContextMenuItem[] + | ((ctx: HeaderContextMenuContext) => ContextMenuItem[]); +} + +export interface SpreadsheetLayoutState { + colWidths?: Record; + headers?: Array<{ + col: number; + headerColor?: string; + fixed?: boolean; + }>; +} + +export interface SpreadsheetBatchCellChange { + ctx: CellEditContext; + before: unknown; + after: unknown; + col: number; + viewRow: number; +} + +export interface SpreadsheetTableConfig { + tableName: string; + sheetKey?: string; + headers: SpreadsheetHeaderConfig[]; + enableFilter?: boolean; + enableHistory?: boolean; + rowHeight?: number | 'auto'; + defaultColWidth?: number; + defaultTextAlign?: SpreadsheetTextAlign; + onBatchEdit?: (changes: SpreadsheetBatchCellChange[]) => Promise; + onRefresh?: () => void | Promise; + headerContextMenu?: + | ContextMenuItem[] + | ((ctx: HeaderContextMenuContext) => ContextMenuItem[]); +} + +export type SortDirection = 'asc' | 'desc'; + +export interface SpreadsheetColumnSort { + col: number; + direction: SortDirection; +} + +/** checkedValues === undefined means all selected (no filter) */ +export interface SpreadsheetColumnFilter { + checkedValues?: string[]; +} + +export interface SpreadsheetViewState { + filters: Record; + sort: SpreadsheetColumnSort | null; +} + +export function createDefaultViewState(): SpreadsheetViewState { + return { filters: {}, sort: null }; +} + +export interface SpreadsheetHistoryEntry { + id: string; + tableName: string; + sheetKey?: string; + userId: number; + userName: string; + action: 'edit' | 'fill' | 'paste' | 'rollback'; + rowKey: string | number; + fieldId: string; + beforeSnapshot: unknown; + afterSnapshot: unknown; + createdAt: string; + synced: boolean; +} + +export interface SpreadsheetCommand { + label: string; + undo: () => void | Promise; + redo: () => void | Promise; +} + +export interface ClipboardMatrix { + rows: unknown[][]; + startRow: number; + startCol: number; +} + +export interface SpreadsheetEngineExpose { + copy: (selectionOverride?: CellRange | CellRange[]) => void; + copyAsImage: (selectionOverride?: CellRange | CellRange[]) => Promise; + paste: () => Promise; + undo: () => Promise; + redo: () => Promise; + canUndo: () => boolean; + canRedo: () => boolean; + canCopy: () => boolean; + canCopyAsImage: () => boolean; + canPaste: () => boolean; + canEdit: (row: number, col: number) => boolean; + getSelection: () => CellRange[]; + getCellEditContext: (row: number, col: number) => CellEditContext | null; + getHeaderContext: (col: number) => HeaderContextMenuContext; + setCellValue: ( + row: number, + col: number, + value: unknown, + recordHistory?: boolean, + action?: SpreadsheetHistoryEntry['action'], + ) => Promise; + fillRange: ( + source: CellRange, + target: CellRange, + ) => Promise; + getColumnConfig: (col: number) => SpreadsheetColumnConfig | undefined; + getFilterValue: (col: number) => string; +} + +export const COLUMN_LETTER_HEIGHT = 22; +export const HEADER_TITLE_HEIGHT = 36; +export const HEADER_HEIGHT = COLUMN_LETTER_HEIGHT + HEADER_TITLE_HEIGHT; +export const ROW_NUMBER_WIDTH = 48; +export const DEFAULT_ROW_HEIGHT = 32; +export const DEFAULT_COL_WIDTH = 100; +export const MIN_COL_WIDTH = 60; +export const SCROLL_BUFFER_ROWS = 8;