diff --git a/apps/web-antd/src/components/canvas-spreadsheet/CanvasSpreadsheet.vue b/apps/web-antd/src/components/canvas-spreadsheet/CanvasSpreadsheet.vue index 34a377f5..243dd4b4 100644 --- a/apps/web-antd/src/components/canvas-spreadsheet/CanvasSpreadsheet.vue +++ b/apps/web-antd/src/components/canvas-spreadsheet/CanvasSpreadsheet.vue @@ -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 | 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( > + + @@ -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; diff --git a/apps/web-antd/src/components/canvas-spreadsheet/canvas/SpreadsheetRenderer.ts b/apps/web-antd/src/components/canvas-spreadsheet/canvas/SpreadsheetRenderer.ts index 631d461d..8f4c6901 100644 --- a/apps/web-antd/src/components/canvas-spreadsheet/canvas/SpreadsheetRenderer.ts +++ b/apps/web-antd/src/components/canvas-spreadsheet/canvas/SpreadsheetRenderer.ts @@ -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; filterValues: Record; 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 { - 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(); + const colSet = new Set(); + 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); diff --git a/apps/web-antd/src/components/canvas-spreadsheet/canvas/theme.ts b/apps/web-antd/src/components/canvas-spreadsheet/canvas/theme.ts index c9003ab5..dc90c90b 100644 --- a/apps/web-antd/src/components/canvas-spreadsheet/canvas/theme.ts +++ b/apps/web-antd/src/components/canvas-spreadsheet/canvas/theme.ts @@ -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', }; } diff --git a/apps/web-antd/src/components/canvas-spreadsheet/commands/FillRangeCommand.ts b/apps/web-antd/src/components/canvas-spreadsheet/commands/FillRangeCommand.ts index 965e2ffe..216f5690 100644 --- a/apps/web-antd/src/components/canvas-spreadsheet/commands/FillRangeCommand.ts +++ b/apps/web-antd/src/components/canvas-spreadsheet/commands/FillRangeCommand.ts @@ -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; +} diff --git a/apps/web-antd/src/components/canvas-spreadsheet/composables/useAutoMeasure.ts b/apps/web-antd/src/components/canvas-spreadsheet/composables/useAutoMeasure.ts index 5aca52ab..8a894f05 100644 --- a/apps/web-antd/src/components/canvas-spreadsheet/composables/useAutoMeasure.ts +++ b/apps/web-antd/src/components/canvas-spreadsheet/composables/useAutoMeasure.ts @@ -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, diff --git a/apps/web-antd/src/components/canvas-spreadsheet/composables/useSelection.ts b/apps/web-antd/src/components/canvas-spreadsheet/composables/useSelection.ts index 4a5634a0..2b719ac5 100644 --- a/apps/web-antd/src/components/canvas-spreadsheet/composables/useSelection.ts +++ b/apps/web-antd/src/components/canvas-spreadsheet/composables/useSelection.ts @@ -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(null); + const ranges = ref([]); 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, }; diff --git a/apps/web-antd/src/components/canvas-spreadsheet/composables/useSpreadsheetContextMenu.ts b/apps/web-antd/src/components/canvas-spreadsheet/composables/useSpreadsheetContextMenu.ts index b6814eb9..e159cf17 100644 --- a/apps/web-antd/src/components/canvas-spreadsheet/composables/useSpreadsheetContextMenu.ts +++ b/apps/web-antd/src/components/canvas-spreadsheet/composables/useSpreadsheetContextMenu.ts @@ -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('已复制为图片'); }) diff --git a/apps/web-antd/src/components/canvas-spreadsheet/composables/useSpreadsheetEngine.ts b/apps/web-antd/src/components/canvas-spreadsheet/composables/useSpreadsheetEngine.ts index d257f10b..1020e11b 100644 --- a/apps/web-antd/src/components/canvas-spreadsheet/composables/useSpreadsheetEngine.ts +++ b/apps/web-antd/src/components/canvas-spreadsheet/composables/useSpreadsheetEngine.ts @@ -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(); + const colSet = new Set(); + 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, ), diff --git a/apps/web-antd/src/components/canvas-spreadsheet/composables/useSpreadsheetFind.ts b/apps/web-antd/src/components/canvas-spreadsheet/composables/useSpreadsheetFind.ts new file mode 100644 index 00000000..14c684e3 --- /dev/null +++ b/apps/web-antd/src/components/canvas-spreadsheet/composables/useSpreadsheetFind.ts @@ -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; + columns: Ref; + rows: Ref[]>; + resolveDisplayValue: ( + viewRow: number, + colCfg: SpreadsheetColumnConfig, + row?: Record, + ) => unknown; +}) { + const open = ref(false); + const query = ref(''); + const matches = ref([]); + 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, + }; +} diff --git a/apps/web-antd/src/components/canvas-spreadsheet/toolbar/SpreadsheetFindBar.vue b/apps/web-antd/src/components/canvas-spreadsheet/toolbar/SpreadsheetFindBar.vue new file mode 100644 index 00000000..e9448938 --- /dev/null +++ b/apps/web-antd/src/components/canvas-spreadsheet/toolbar/SpreadsheetFindBar.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/apps/web-antd/src/components/canvas-spreadsheet/types.ts b/apps/web-antd/src/components/canvas-spreadsheet/types.ts index fff291fa..3dc6a277 100644 --- a/apps/web-antd/src/components/canvas-spreadsheet/types.ts +++ b/apps/web-antd/src/components/canvas-spreadsheet/types.ts @@ -167,8 +167,8 @@ export interface ClipboardMatrix { } export interface SpreadsheetEngineExpose { - copy: (selectionOverride?: CellRange) => void; - copyAsImage: (selectionOverride?: CellRange) => Promise; + copy: (selectionOverride?: CellRange | CellRange[]) => void; + copyAsImage: (selectionOverride?: CellRange | CellRange[]) => Promise; paste: () => Promise; undo: () => Promise; redo: () => Promise; @@ -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: (