1. Excel交互组件封装
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
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
This commit is contained in:
@@ -0,0 +1,746 @@
|
||||
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>;
|
||||
Reference in New Issue
Block a user