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
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
CI / CI OK (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:
李琦
2026-06-26 12:52:26 +08:00
parent 69862cd982
commit a38da5ce92
11 changed files with 1121 additions and 123 deletions

View File

@@ -30,7 +30,7 @@ import { message } from 'ant-design-vue';
import { normalizeRange, iterateRange } from './commands/FillRangeCommand';
import { normalizeRange, iterateRanges } from './commands/FillRangeCommand';
import {
@@ -47,6 +47,7 @@ import { getDataColScreenX } from './composables/useAutoMeasure';
import { useSpreadsheetEngine } from './composables/useSpreadsheetEngine';
import { useFormulaReferenceMode } from './composables/useFormulaReferenceMode';
import { useSpreadsheetFind } from './composables/useSpreadsheetFind';
import {
@@ -70,6 +71,7 @@ import {
import SpreadsheetToolbar from './toolbar/SpreadsheetToolbar.vue';
import SpreadsheetFormulaBar from './toolbar/SpreadsheetFormulaBar.vue';
import SpreadsheetFindBar from './toolbar/SpreadsheetFindBar.vue';
import HeaderFilterLayer from './header/HeaderFilterLayer.vue';
import SpreadsheetHeaderColorPickerPopover from './header/SpreadsheetHeaderColorPickerPopover.vue';
@@ -470,10 +472,41 @@ const {
hitTestHeaderCol,
hitTestRowNumber,
hitTestHeaderArea,
} = engineState;
const findApi = useSpreadsheetFind({
filteredRowIndices,
columns: sortedColumns,
rows: localData,
resolveDisplayValue,
});
const selectionMaxRow = computed(() =>
Math.max(0, filteredRowIndices.value.length - 1),
);
const selectionMaxCol = computed(() => {
const cols = sortedColumns.value;
return cols.length ? Math.max(...cols.map((c) => c.col)) : 0;
});
const fillDragSource = shallowRef<ReturnType<typeof normalizeRange> | null>(
null,
@@ -488,6 +521,8 @@ const selectDragStart = ref<{ x: number; y: number; row: number; col: number } |
null,
);
const headerDragMode = ref<'row' | 'column' | null>(null);
const bodyCursor = ref<'cell' | 'crosshair'>('cell');
const lastPointerOnBody = ref<{ x: number; y: number } | null>(null);
@@ -717,7 +752,7 @@ function renderOptions() {
fixedWidth: fixedWidth.value,
selection: selectionApi.getNormalizedSelection(),
selectionRanges: selectionApi.getSelectionRanges(),
activeCell: selectionApi.activeCell.value,
@@ -731,6 +766,8 @@ function renderOptions() {
!!selectionApi.activeCell.value &&
selectionApi.getSelectionRanges().length === 1 &&
!isDraggingFill.value &&
!editing.value &&
@@ -747,6 +784,10 @@ function renderOptions() {
? formulaRefMode.pickedCell.value
: null,
findMatches: findApi.open.value ? findApi.matches.value : undefined,
findActiveIndex: findApi.open.value ? findApi.activeIndex.value : undefined,
};
}
@@ -961,6 +1002,32 @@ function startEdit(
function onFindNext() {
findApi.goNext();
}
function onFindPrev() {
findApi.goPrev();
}
function onFindQueryUpdate(v: string) {
findApi.query.value = v;
scheduleRender();
}
function getCanvasHit(e: MouseEvent) {
if (!bodyCanvasRef.value) return null;
@@ -977,7 +1044,67 @@ function onCanvasMouseDown(e: MouseEvent) {
if (e.button === 2) return;
const hit = getCanvasHit(e);
if (!bodyCanvasRef.value) return;
const rect = bodyCanvasRef.value.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const rowHit = hitTestRowNumber(x, y);
if (rowHit !== null && !formulaRefMode.active.value) {
containerRef.value?.focus({ preventScroll: true });
if (editing.value) {
void commitActiveEdit().then(() => {
selectionApi.selectEntireRow(rowHit, selectionMaxCol.value, {
shift: e.shiftKey,
ctrl: e.ctrlKey || e.metaKey,
});
headerDragMode.value = 'row';
selectDragStart.value = { x, y, row: rowHit, col: 0 };
isSelecting.value = false;
scheduleRender();
});
return;
}
selectionApi.selectEntireRow(rowHit, selectionMaxCol.value, {
shift: e.shiftKey,
ctrl: e.ctrlKey || e.metaKey,
});
headerDragMode.value = 'row';
selectDragStart.value = { x, y, row: rowHit, col: 0 };
isSelecting.value = false;
scheduleRender();
return;
}
const hit = hitTestCell(x, y);
if (formulaRefMode.active.value) {
e.preventDefault();
@@ -1032,9 +1159,7 @@ function onCanvasMouseDown(e: MouseEvent) {
containerRef.value?.focus({ preventScroll: true });
const x = e.clientX - (bodyCanvasRef.value?.getBoundingClientRect().left ?? 0);
const y = e.clientY - (bodyCanvasRef.value?.getBoundingClientRect().top ?? 0);
headerDragMode.value = null;
if (e.shiftKey) {
@@ -1050,7 +1175,7 @@ function onCanvasMouseDown(e: MouseEvent) {
}
const sel = selectionApi.getNormalizedSelection();
const sel = selectionApi.getPrimaryRange();
if (sel && isFillHandleHit(x, y, sel)) {
@@ -1140,7 +1265,7 @@ function updateBodyCursorAt(x: number, y: number) {
}
const sel = selectionApi.getNormalizedSelection();
const sel = selectionApi.getPrimaryRange();
const overFillHandle =
@@ -1186,6 +1311,22 @@ function onCanvasMouseMove(e: MouseEvent) {
}
if (headerDragMode.value === 'row' && selectDragStart.value && (e.buttons & 1) === 1) {
const rowHit = hitTestRowNumber(x, y);
if (rowHit !== null) {
selectionApi.extendRowSelection(rowHit, selectionMaxCol.value);
scheduleRender();
}
return;
}
if (selectDragStart.value && (e.buttons & 1) === 1) {
const dx = x - selectDragStart.value.x;
@@ -1236,7 +1377,7 @@ async function onCanvasMouseUp() {
if (isDraggingFill.value && fillDragSource.value) {
const target = selectionApi.getNormalizedSelection();
const target = selectionApi.getPrimaryRange();
if (target) {
@@ -1262,6 +1403,8 @@ async function onCanvasMouseUp() {
selectDragStart.value = null;
headerDragMode.value = null;
scheduleRender();
if (lastPointerOnBody.value) {
@@ -1278,6 +1421,78 @@ async function onCanvasMouseUp() {
function onHeaderMouseDown(e: MouseEvent) {
if (e.button === 2) return;
if (!headerCanvasRef.value) return;
if (formulaRefMode.active.value || editing.value) return;
const rect = headerCanvasRef.value.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const hit = hitTestHeaderArea(x, y);
if (!hit) return;
containerRef.value?.focus({ preventScroll: true });
const mods = { shift: e.shiftKey, ctrl: e.ctrlKey || e.metaKey };
if (hit.area === 'corner') {
selectionApi.selectAll(selectionMaxRow.value, selectionMaxCol.value);
scheduleRender();
return;
}
if (hit.col === undefined) return;
selectionApi.selectEntireColumn(hit.col, selectionMaxRow.value, mods);
headerDragMode.value = 'column';
selectDragStart.value = { x, y, row: 0, col: hit.col };
isSelecting.value = false;
scheduleRender();
}
function onHeaderMouseMove(e: MouseEvent) {
if (!headerCanvasRef.value) return;
if (headerDragMode.value !== 'column' || !selectDragStart.value) return;
if ((e.buttons & 1) !== 1) return;
const rect = headerCanvasRef.value.getBoundingClientRect();
const x = e.clientX - rect.left;
const col = hitTestHeaderCol(x);
if (col === null) return;
selectionApi.extendColumnSelection(col, selectionMaxRow.value);
scheduleRender();
}
function onHeaderContextMenu(e: MouseEvent) {
if (!headerCanvasRef.value) return;
@@ -1445,6 +1660,36 @@ function onKeyDown(e: KeyboardEvent) {
if (e.defaultPrevented) return;
if (findApi.open.value && e.key === 'Escape') {
e.preventDefault();
findApi.closeFind();
scheduleRender();
return;
}
if ((e.ctrlKey || e.metaKey) && (e.key === 'f' || e.key === 'F')) {
const inFindInput = (e.target as HTMLElement)?.closest?.('.spreadsheet-find-bar');
if (!isSpreadsheetShortcutBlocked() || inFindInput) {
e.preventDefault();
findApi.toggleFind();
scheduleRender();
return;
}
}
if (isSpreadsheetShortcutBlocked()) return;
const active = selectionApi.activeCell.value;
@@ -1491,7 +1736,7 @@ function onKeyDown(e: KeyboardEvent) {
e.preventDefault();
if (selectionApi.getNormalizedSelection()) {
if (selectionApi.getSelectionRanges().length) {
engine.copy();
message.success('复制成功');
}
@@ -1618,13 +1863,13 @@ function onKeyDown(e: KeyboardEvent) {
if (isTypingInOtherInput()) return;
const sel = selectionApi.getNormalizedSelection();
const selRanges = selectionApi.getSelectionRanges();
if (!sel) return;
if (!selRanges.length) return;
e.preventDefault();
iterateRange(sel, (row, c) => {
iterateRanges(selRanges, (row, c) => {
const cfg = colConfig(c);
@@ -1866,6 +2111,26 @@ onUnmounted(() => {
watch(
() => findApi.activeMatch.value,
(m) => {
if (!findApi.open.value || !m) return;
selectionApi.selectCell(m.row, m.col);
scrollCellIntoView(m.row, m.col);
scheduleRender();
},
);
watch(
() => props.tableConfig.headers,
@@ -1914,7 +2179,7 @@ watch(
watch(
[colWidths, paddedColWidths, filteredRowIndices, viewState, dirtyKeys, displayRowCount, displayColCount, () => selectionApi.selection.value],
[colWidths, paddedColWidths, filteredRowIndices, viewState, dirtyKeys, displayRowCount, displayColCount, () => selectionApi.ranges.value, () => findApi.matches.value, () => findApi.open.value],
scheduleRender,
@@ -1940,6 +2205,28 @@ watch(
>
<SpreadsheetFindBar
:active-index="findApi.activeIndex.value"
:is-dark="isDark"
:match-count="findApi.matchCount.value"
:open="findApi.open.value"
:query="findApi.query.value"
@close="findApi.closeFind(); scheduleRender()"
@next="onFindNext"
@prev="onFindPrev"
@update:query="onFindQueryUpdate"
/>
<canvas ref="measureCanvasRef" class="measure-canvas" />
@@ -2024,6 +2311,10 @@ watch(
@contextmenu="onHeaderContextMenu"
@mousedown="onHeaderMouseDown"
@mousemove="onHeaderMouseMove"
/>
@@ -2214,6 +2505,8 @@ watch(
.canvas-spreadsheet {
position: relative;
display: flex;
flex-direction: column;

View File

@@ -20,7 +20,7 @@ import { getRowTop } from '../composables/useAutoMeasure';
import { formatCellWithSuffix } from '../composables/cellDisplay';
import { resolveColumnTextAlign } from '../composables/columnTextAlign';
import { colIndexToLetters } from '../formula/evaluator';
import { normalizeRange } from '../commands/FillRangeCommand';
import { iterateRanges, normalizeRange, rangesContainCell } from '../commands/FillRangeCommand';
import {
computeFixedWidth,
getDataColX,
@@ -52,13 +52,15 @@ export interface RenderOptions {
isDark: boolean;
fixedCols: number[];
fixedWidth: number;
selection: CellRange | null;
selectionRanges: CellRange[];
activeCell: { row: number; col: number } | null;
dirtyKeys: Set<string>;
filterValues: Record<number, string>;
filteredRowIndices: number[];
showFillHandle: boolean;
theme: SpreadsheetTheme;
findMatches?: Array<{ row: number; col: number }>;
findActiveIndex?: number;
resolveDisplayValue?: (
viewRow: number,
colCfg: SpreadsheetColumnConfig,
@@ -79,14 +81,69 @@ function colByIndex(columns: SpreadsheetColumnConfig[], col: number) {
function isInSelection(
row: number,
col: number,
selection: CellRange | null,
ranges: CellRange[],
): boolean {
if (!selection) return false;
const r0 = Math.min(selection.startRow, selection.endRow);
const r1 = Math.max(selection.startRow, selection.endRow);
const c0 = Math.min(selection.startCol, selection.endCol);
const c1 = Math.max(selection.startCol, selection.endCol);
return row >= r0 && row <= r1 && col >= c0 && col <= c1;
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 {
@@ -164,8 +221,9 @@ function drawRowNumber(
rowHeight: number,
label: string,
theme: SpreadsheetTheme,
selected = false,
) {
ctx.fillStyle = theme.rowNumBg;
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);
@@ -178,8 +236,9 @@ function drawRowNumber(
function drawHeaderCorner(
ctx: CanvasRenderingContext2D,
theme: SpreadsheetTheme,
allSelected = false,
) {
ctx.fillStyle = theme.rowNumBg;
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);
@@ -255,16 +314,26 @@ function resolveCellBg(
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) {
return isInSelection(vi, col, options.selection)
? theme.selectionBg
: theme.cellBg;
if (findActive) return theme.findActiveMatchBg;
if (findMatch) return theme.findMatchBg;
return selected ? theme.selectionBg : theme.cellBg;
}
const dirtyKey =
dataRow !== undefined ? `${dataRow}:${colCfg.fieldId}` : '';
const selected = isInSelection(vi, col, options.selection);
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;
@@ -282,8 +351,10 @@ function drawHeaderCell(
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 = theme.headerBg;
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);
@@ -362,7 +433,7 @@ function resolveExportCellBg(
}
export interface SelectionImageParams {
selection: CellRange;
selectionRanges: CellRange[];
headers: SpreadsheetHeaderConfig[];
columns: SpreadsheetColumnConfig[];
colWidths: number[];
@@ -380,16 +451,30 @@ export interface SelectionImageParams {
export async function renderSelectionImage(
params: SelectionImageParams,
): Promise<Blob> {
const sel = normalizeRange(params.selection);
const { startRow: r0, endRow: r1, startCol: c0, endCol: c1 } = sel;
const selRanges = params.selectionRanges.map((r) => normalizeRange(r));
if (!selRanges.length) throw new Error('No selection');
const rowSet = new Set<number>();
const colSet = new Set<number>();
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 (let col = c0; col <= c1; col += 1) {
for (const col of uniqueCols) {
width += params.colWidths[col] ?? params.defaultColWidth;
}
const rowCount = r1 - r0 + 1;
const height = HEADER_TITLE_HEIGHT + rowCount * params.rowHeight;
const height =
HEADER_TITLE_HEIGHT + uniqueRows.length * params.rowHeight;
const canvas = document.createElement('canvas');
const dpr = window.devicePixelRatio || 1;
@@ -403,7 +488,7 @@ export async function renderSelectionImage(
ctx.fillRect(0, 0, width, height);
let headerX = 0;
for (let col = c0; col <= c1; col += 1) {
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);
@@ -424,29 +509,34 @@ export async function renderSelectionImage(
headerX += cw;
}
for (let viewRow = r0; viewRow <= r1; viewRow += 1) {
uniqueRows.forEach((viewRow, rowIndex) => {
const dataRow = params.filteredRowIndices[viewRow];
const row =
dataRow !== undefined ? params.rows[dataRow] : undefined;
const y = HEADER_TITLE_HEIGHT + (viewRow - r0) * params.rowHeight;
const y = HEADER_TITLE_HEIGHT + rowIndex * params.rowHeight;
let cellX = 0;
for (let col = c0; col <= c1; col += 1) {
for (const col of uniqueCols) {
const cw = params.colWidths[col] ?? params.defaultColWidth;
const colCfg = colByIndex(params.columns, col);
const isEmpty = dataRow === undefined || !row || !colCfg;
const bg = resolveExportCellBg(
theme,
viewRow,
col,
colCfg,
row,
dataRow,
params.dirtyKeys,
params.resolveCellStyle,
);
const cellStyle = !isEmpty && colCfg
? params.resolveCellStyle?.(viewRow, colCfg, row)
: undefined;
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;
@@ -458,17 +548,18 @@ export async function renderSelectionImage(
bg,
theme,
empty: isEmpty,
value: !isEmpty && colCfg
? params.resolveDisplayValue?.(viewRow, colCfg, row)
: undefined,
value:
inSel && !isEmpty && colCfg
? params.resolveDisplayValue?.(viewRow, colCfg, row)
: undefined,
cellType: displayCellType,
colCfg,
colCfg: inSel ? colCfg : undefined,
textColor: cellStyle?.textColor,
defaultTextAlign: params.defaultTextAlign,
});
cellX += cw;
}
}
});
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
@@ -486,7 +577,10 @@ function drawHeaderFillerCell(
x: number,
) {
const { theme } = options;
ctx.fillStyle = theme.headerBg;
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);
@@ -547,7 +641,13 @@ function drawFrozenHeaders(
w: number,
) {
const { fixedCols, fixedWidth, theme } = options;
drawHeaderCorner(ctx, theme);
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;
@@ -679,8 +779,10 @@ function drawFrozenBodyRow(
) {
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);
drawRowNumber(ctx, y, rowHeight, String(vi + 1), theme, rowSelected);
for (const colCfg of options.columns) {
const col = colCfg.col;
@@ -759,7 +861,7 @@ export function renderBody(canvas: HTMLCanvasElement, options: RenderOptions) {
ctx.fillRect(fixedWidth, 0, 1, Math.max(0, bodyH));
}
if (options.selection && options.activeCell) {
if (options.activeCell) {
const { activeCell } = options;
const x = getDataColX(
activeCell.col,
@@ -771,7 +873,14 @@ export function renderBody(canvas: HTMLCanvasElement, options: RenderOptions) {
const y = getRowTop(activeCell.row, rowHeight) - scrollTop;
const cw = options.colWidths[activeCell.col] ?? options.defaultColWidth;
ctx.strokeStyle = theme.selectionBorder;
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);

View File

@@ -12,6 +12,9 @@ export interface SpreadsheetTheme {
fillHandle: string;
fixedShadow: string;
imagePlaceholder: string;
findMatchBg: string;
findActiveMatchBg: string;
findActiveBorder: string;
}
export function getSpreadsheetTheme(isDark: boolean): SpreadsheetTheme {
@@ -30,6 +33,9 @@ export function getSpreadsheetTheme(isDark: boolean): SpreadsheetTheme {
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 {
@@ -46,6 +52,9 @@ export function getSpreadsheetTheme(isDark: boolean): SpreadsheetTheme {
fillHandle: '#165dff',
fixedShadow: 'rgba(0, 0, 0, 0.12)',
imagePlaceholder: '#f2f3f5',
findMatchBg: '#fff566',
findActiveMatchBg: '#ffe58f',
findActiveBorder: '#faad14',
};
}

View File

@@ -34,3 +34,31 @@ export function iterateRange(
}
}
}
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;
}

View File

@@ -1,5 +1,6 @@
import type { SpreadsheetColumnConfig } from '../types';
import {
COLUMN_LETTER_HEIGHT,
DEFAULT_COL_WIDTH,
DEFAULT_ROW_HEIGHT,
MIN_COL_WIDTH,
@@ -147,6 +148,50 @@ export function hitTestCell(
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,

View File

@@ -1,23 +1,87 @@
import { ref } from 'vue';
import type { CellRange } from '../types';
import { normalizeRange } from '../commands/FillRangeCommand';
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 selection = ref<CellRange | null>(null);
const ranges = ref<CellRange[]>([]);
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 };
selection.value = {
startRow: row,
startCol: col,
endRow: row,
endCol: col,
};
ranges.value = [
{ startRow: row, startCol: col, endRow: row, endCol: col },
];
}
function extendSelection(row: number, col: number) {
@@ -25,45 +89,164 @@ export function useSelection() {
selectCell(row, col);
return;
}
selection.value = {
startRow: anchor.value.row,
startCol: anchor.value.col,
endRow: row,
endCol: col,
};
ranges.value = [
{
startRow: anchor.value.row,
startCol: anchor.value.col,
endRow: row,
endCol: col,
},
];
activeCell.value = { row, col };
}
function selectRange(range: CellRange) {
selection.value = normalizeRange(range);
const normalized = normalizeRange(range);
ranges.value = [normalized];
activeCell.value = {
row: selection.value.startRow,
col: selection.value.startCol,
row: normalized.startRow,
col: normalized.startCol,
};
anchor.value = { ...activeCell.value };
}
function getSelectionRanges(): CellRange[] {
return ranges.value.map((r) => normalizeRange(r));
}
function getNormalizedSelection(): CellRange | null {
return selection.value ? normalizeRange(selection.value) : 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 {
const sel = getNormalizedSelection();
if (!sel) return false;
const r0 = Math.min(sel.startRow, sel.endRow);
const r1 = Math.max(sel.startRow, sel.endRow);
const c0 = Math.min(sel.startCol, sel.endCol);
const c1 = Math.max(sel.startCol, sel.endCol);
return row >= r0 && row <= r1 && col >= c0 && col <= c1;
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() {
selection.value = null;
ranges.value = [];
anchor.value = null;
activeCell.value = null;
}
return {
ranges,
selection,
anchor,
activeCell,
@@ -71,7 +254,14 @@ export function useSelection() {
selectCell,
extendSelection,
selectRange,
selectAll,
selectEntireRow,
selectEntireColumn,
extendRowSelection,
extendColumnSelection,
getSelectionRanges,
getNormalizedSelection,
getPrimaryRange,
containsCell,
clearSelection,
};

View File

@@ -64,7 +64,7 @@ function buildCellBuiltinItems(
disabled: !sel,
handler: () => {
if (sel) {
engine.copy(sel);
engine.copy();
message.success('复制成功');
}
},
@@ -77,7 +77,7 @@ function buildCellBuiltinItems(
handler: () => {
if (!sel) return;
void engine
.copyAsImage(sel)
.copyAsImage()
.then(() => {
message.success('已复制为图片');
})

View File

@@ -1,6 +1,6 @@
import { computed, ref, shallowRef, watch, type Ref } from 'vue';
import { createFillRangeCommand, iterateRange, normalizeRange } from '../commands/FillRangeCommand';
import { createFillRangeCommand, iterateRange, iterateRanges, normalizeRange, rangesContainCell } from '../commands/FillRangeCommand';
import { createPasteRangeCommand } from '../commands/PasteRangeCommand';
import { createSetCellValueCommand } from '../commands/SetCellValueCommand';
import {
@@ -14,6 +14,8 @@ import {
getRowHeight,
hitTestCell,
hitTestHeaderCol,
hitTestRowNumber,
hitTestHeaderArea,
measureColumnWidths,
} from '../composables/useAutoMeasure';
import { useClipboard } from '../composables/useClipboard';
@@ -265,7 +267,7 @@ export function useSpreadsheetEngine(options: {
rowKey: rowData[options.rowKeyField.value] as string | number,
rowData,
editParams: colCfg.editParams ?? {},
selection: selectionApi.getNormalizedSelection() ?? undefined,
selection: selectionApi.getPrimaryRange() ?? undefined,
};
}
@@ -451,37 +453,55 @@ export function useSpreadsheetEngine(options: {
);
}
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);
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<number>();
const colSet = new Set<number>();
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: sel.startRow,
startCol: sel.startCol,
startRow: uniqueRows[0]!,
startCol: uniqueCols[0]!,
};
clipboard.setMatrix(matrix);
void clipboard.writeSystemClipboard(matrix);
}
async function copyAsImage(selectionOverride?: CellRange) {
const sel = selectionOverride ?? selectionApi.getNormalizedSelection();
if (!sel) return;
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({
selection: sel,
selectionRanges: selRanges,
headers: options.mergedHeaders.value,
columns: sortedColumns.value,
colWidths: paddedColWidths.value,
@@ -645,14 +665,14 @@ export function useSpreadsheetEngine(options: {
redo: undoRedo.redo,
canUndo: undoRedo.canUndo,
canRedo: undoRedo.canRedo,
canCopy: () => !!selectionApi.getNormalizedSelection(),
canCopyAsImage: () => !!selectionApi.getNormalizedSelection(),
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.getNormalizedSelection,
getSelection: selectionApi.getSelectionRanges,
getCellEditContext: (row, col) => buildCellContext(row, col),
getHeaderContext: (col) => {
const header =
@@ -734,7 +754,25 @@ export function useSpreadsheetEngine(options: {
x,
scrollLeft.value,
paddedColWidths.value,
colCount.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,
),

View File

@@ -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<number[]>;
columns: Ref<SpreadsheetColumnConfig[]>;
rows: Ref<Record<string, unknown>[]>;
resolveDisplayValue: (
viewRow: number,
colCfg: SpreadsheetColumnConfig,
row?: Record<string, unknown>,
) => unknown;
}) {
const open = ref(false);
const query = ref('');
const matches = ref<FindMatch[]>([]);
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,
};
}

View File

@@ -0,0 +1,161 @@
<script lang="ts" setup>
import { nextTick, ref, watch } from 'vue';
import { Button, Input } from 'ant-design-vue';
import MIcon from '#/components/icon/icon.vue';
const props = defineProps<{
open: boolean;
query: string;
matchCount: number;
activeIndex: number;
isDark?: boolean;
}>();
const emit = defineEmits<{
'update:query': [value: string];
close: [];
next: [];
prev: [];
}>();
const inputRef = ref<InstanceType<typeof Input> | null>(null);
watch(
() => props.open,
(v) => {
if (v) {
void nextTick(() => {
const el = inputRef.value?.$el?.querySelector?.('input') as
| HTMLInputElement
| undefined;
el?.focus();
el?.select();
});
}
},
);
function onInput(v: string) {
emit('update:query', v);
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
emit('close');
return;
}
if (e.key === 'Enter') {
e.preventDefault();
if (e.shiftKey) emit('prev');
else emit('next');
}
}
const countLabel = () => {
if (!props.query.trim()) return '';
if (props.matchCount === 0) return '无匹配';
const current = props.activeIndex >= 0 ? props.activeIndex + 1 : 0;
return `${current} / ${props.matchCount}`;
};
</script>
<template>
<div
v-if="open"
class="spreadsheet-find-bar"
:class="{ 'is-dark': isDark }"
@mousedown.stop
>
<Input
ref="inputRef"
class="find-input"
:value="query"
placeholder="查找"
size="small"
allow-clear
@update:value="onInput"
@keydown="onKeydown"
/>
<span class="find-count">{{ countLabel() }}</span>
<Button
class="find-nav-btn"
size="small"
type="text"
title="上一个 (Shift+Enter)"
:disabled="matchCount === 0"
@click="emit('prev')"
>
<MIcon icon="mdi:chevron-up" />
</Button>
<Button
class="find-nav-btn"
size="small"
type="text"
title="下一个 (Enter)"
:disabled="matchCount === 0"
@click="emit('next')"
>
<MIcon icon="mdi:chevron-down" />
</Button>
<Button
class="find-close-btn"
size="small"
type="text"
title="关闭 (Esc)"
@click="emit('close')"
>
<MIcon icon="mdi:close" />
</Button>
</div>
</template>
<style scoped lang="scss">
.spreadsheet-find-bar {
position: absolute;
top: 8px;
right: 8px;
z-index: 20;
display: flex;
align-items: center;
gap: 4px;
padding: 4px 6px;
background: #fff;
border: 1px solid #e5e6eb;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
&.is-dark {
background: #1f1f1f;
border-color: #424242;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
}
}
.find-input {
width: 180px;
}
.find-count {
min-width: 52px;
font-size: 12px;
color: #86909c;
text-align: center;
white-space: nowrap;
.is-dark & {
color: rgba(255, 255, 255, 0.45);
}
}
.find-nav-btn,
.find-close-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 4px;
}
</style>

View File

@@ -167,8 +167,8 @@ export interface ClipboardMatrix {
}
export interface SpreadsheetEngineExpose {
copy: (selectionOverride?: CellRange) => void;
copyAsImage: (selectionOverride?: CellRange) => Promise<void>;
copy: (selectionOverride?: CellRange | CellRange[]) => void;
copyAsImage: (selectionOverride?: CellRange | CellRange[]) => Promise<void>;
paste: () => Promise<void>;
undo: () => Promise<void>;
redo: () => Promise<void>;
@@ -178,7 +178,7 @@ export interface SpreadsheetEngineExpose {
canCopyAsImage: () => boolean;
canPaste: () => boolean;
canEdit: (row: number, col: number) => boolean;
getSelection: () => CellRange | null;
getSelection: () => CellRange[];
getCellEditContext: (row: number, col: number) => CellEditContext | null;
getHeaderContext: (col: number) => HeaderContextMenuContext;
setCellValue: (