Files
xk-admin/apps/web-antd/src/components/canvas-spreadsheet/composables/useSpreadsheetEngine.ts
李琦 69862cd982
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
1. Excel交互组件封装
2026-06-25 16:09:07 +08:00

747 lines
21 KiB
TypeScript

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<SpreadsheetTableConfig>;
mergedHeaders: Ref<SpreadsheetHeaderConfig[]>;
columns: Ref<SpreadsheetColumnConfig[]>;
data: Ref<Record<string, unknown>[]>;
rowKeyField: Ref<string>;
layoutColWidths: Ref<Record<number, number>>;
scrollViewportHeight: Ref<number>;
isDark: Ref<boolean>;
onChange: (data: Record<string, unknown>[]) => void;
}) {
const sortedColumns = computed(() =>
[...options.columns.value].sort((a, b) => a.col - b.col),
);
const colWidths = ref<number[]>([]);
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<Record<number, string>>({});
const viewState = ref<SpreadsheetViewState>(createDefaultViewState());
const initialSnapshot = shallowRef<Map<string, unknown>>(new Map());
const dirtyKeys = ref<Set<string>>(new Set());
const editing = ref<{ row: number; col: number } | null>(null);
const measureCanvas = ref<HTMLCanvasElement | null>(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<string, unknown>,
): 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<string, unknown>,
): 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<string, unknown>,
) {
return resolveConditionalStyle(
colCfg,
resolveNumericValue(viewRow, colCfg, row),
);
}
function resolveDisplayValue(
viewRow: number,
colCfg: SpreadsheetColumnConfig,
row?: Record<string, unknown>,
): 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<string, unknown>();
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<typeof useSpreadsheetEngine>;