import { computed, ref, shallowRef, watch, type Ref } from 'vue'; import { createFillRangeCommand, iterateRange, normalizeRange } 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, 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.getNormalizedSelection() ?? 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) { const sel = selectionOverride ?? selectionApi.getNormalizedSelection(); if (!sel) return; const rows: unknown[][] = []; iterateRange(sel, (viewRow, col) => { const relRow = viewRow - sel.startRow; if (!rows[relRow]) rows[relRow] = []; const colCfg = colConfig(col); if (!colCfg) { rows[relRow]![col - sel.startCol] = ''; return; } const dataRow = dataRowFromViewRow(viewRow); const row = options.data.value[dataRow]; const raw = resolveDisplayValue(viewRow, colCfg, row); rows[relRow]![col - sel.startCol] = formatCellWithSuffix(raw, colCfg); }); const matrix = { rows, startRow: sel.startRow, startCol: sel.startCol, }; clipboard.setMatrix(matrix); void clipboard.writeSystemClipboard(matrix); } async function copyAsImage(selectionOverride?: CellRange) { const sel = selectionOverride ?? selectionApi.getNormalizedSelection(); if (!sel) return; const blob = await renderSelectionImage({ selection: sel, 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.getNormalizedSelection(), canCopyAsImage: () => !!selectionApi.getNormalizedSelection(), canPaste: () => clipboard.hasContent() || true, canEdit: (_row, col) => { const cfg = colConfig(col); return !!cfg && isEditable(cfg); }, getSelection: selectionApi.getNormalizedSelection, 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, colCount.value, fixedCols.value, fixedWidth.value, ), HEADER_HEIGHT, ROW_NUMBER_WIDTH, }; } export type SpreadsheetEngine = ReturnType;