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
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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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('已复制为图片');
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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: (
|
||||
|
||||
Reference in New Issue
Block a user