/
kan64
/
spreadsheet-lab
Обзор
Документация
Войти
/
kan64
/
spreadsheet-lab
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
develop
client/src/components/GridCanvas.tsx
1 850 строк
73 KB
Anton Kravchenkov
feat(ui): Insert copied rows above below
30 мар 2026, 21:56
30 мар 2026, 21:56
d7502fe
Код
Авторство
О чём код?
import React, { useRef, useEffect, useLayoutEffect, useCallback, useMemo, useState, startTransition, forwardRef, useImperativeHandle, } from 'react'; import { createPortal } from 'react-dom'; import type { CellBorder, SheetData, CellPosition, CellRange, TextRun } from '../types/types'; import { DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, ROW_HEADER_WIDTH, COL_HEADER_HEIGHT, TOTAL_ROWS, TOTAL_COLS, MAX_TOTAL_COLS, } from '../types/types'; import { cellId, colToLetter, normalizeRange } from '../utils/cellUtils'; import { formatCellValue } from '../utils/cellUtils'; import { textRunsToPlainText } from '../utils/richTextUtils'; import { findVisibleColRange, findVisibleRowRange, visibleRangeToKey, type VisibleRange, } from '../utils/gridVisibleRange'; import { RiInsertRowTop, RiInsertRowBottom, RiInsertColumnLeft, RiDeleteRow, RiDeleteColumn, RiChat3Line, RiLink, RiClipboardLine } from 'react-icons/ri'; import { ContextMenu, type ContextMenuItem } from './ContextMenu'; const FONT_FAMILY = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"; const HEADER_BG = '#f0f0f0'; const HEADER_ACTIVE_BG = '#d6e4f0'; const HEADER_MENU_OPEN_BG = '#a8c8e8'; const HEADER_BORDER = '#c0c0c0'; const CELL_BG = '#ffffff'; const CELL_BORDER = '#e0e0e0'; const TEXT_COLOR = '#202124'; const LINK_COLOR = '#1a73e8'; const HEADER_TEXT = '#444'; const SELECTION_BORDER = '#1a73e8'; /** Обрезка под ширину: O(log n) вызовов measureText вместо цикла по символам */ function truncateTextToWidth(ctx: CanvasRenderingContext2D, text: string, maxW: number): string { const t = String(text); if (maxW <= 0) return ''; if (ctx.measureText(t).width <= maxW) return t; const ell = '…'; let lo = 0; let hi = t.length; while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (ctx.measureText(t.slice(0, mid) + ell).width <= maxW) lo = mid; else hi = mid - 1; } return lo > 0 ? t.slice(0, lo) + ell : ell; } /** Перенос текста по словам и по переводам строк. Возвращает массив строк. */ function wrapTextToLines(ctx: CanvasRenderingContext2D, text: string, maxW: number): string[] { const lines: string[] = []; const paragraphs = String(text).split('\n'); for (const para of paragraphs) { if (maxW <= 0) { lines.push(para); continue; } const words = para.split(/\s+/); if (words.length === 0) { lines.push(''); continue; } let currentLine = words[0]; for (let i = 1; i < words.length; i++) { const test = currentLine + ' ' + words[i]; if (ctx.measureText(test).width <= maxW) { currentLine = test; } else { lines.push(currentLine); currentLine = words[i]; } } lines.push(currentLine); } return lines; } function drawRichTextRuns( ctx: CanvasRenderingContext2D, runs: TextRun[], x: number, y: number, w: number, h: number, cellStyle: { bold?: boolean; italic?: boolean; underline?: boolean; strikethrough?: boolean; color?: string; fontSize?: number; textAlign?: string; verticalAlign?: string; textOrientation?: string; wrapText?: boolean } | undefined, hasLink: boolean, ) { const padding = 4; const maxW = w - padding * 2; const maxH = h - padding * 2; if (maxW <= 0 || maxH <= 0 || runs.length === 0) return; ctx.save(); ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.setLineDash([]); const defaultFontSize = cellStyle?.fontSize ?? 13; const vAlign = cellStyle?.verticalAlign ?? 'middle'; const align = cellStyle?.textAlign ?? 'left'; type Seg = { text: string; bold: boolean; italic: boolean; underline: boolean; strike: boolean; color: string; fontSize: number; link?: string }; const lines: Seg[][] = [[]]; for (const run of runs) { const parts = run.text.split('\n'); for (let pi = 0; pi < parts.length; pi++) { if (pi > 0) lines.push([]); const t = parts[pi]; if (t.length > 0) { lines[lines.length - 1].push({ text: t, bold: !!(run.bold ?? cellStyle?.bold), italic: !!(run.italic ?? cellStyle?.italic), underline: !!(run.underline ?? cellStyle?.underline), strike: !!(run.strikethrough ?? cellStyle?.strikethrough), color: run.link ? LINK_COLOR : (run.color || cellStyle?.color || TEXT_COLOR), fontSize: run.fontSize ?? defaultFontSize, link: run.link, }); } } } function segFont(seg: Seg): string { const p: string[] = []; if (seg.bold) p.push('bold'); if (seg.italic) p.push('italic'); p.push(`${seg.fontSize}px`); p.push(FONT_FAMILY); return p.join(' '); } type ML = { segs: (Seg & { w: number })[]; totalW: number; lh: number }; const measured: ML[] = []; for (const segs of lines) { let totalW = 0; let maxFs = defaultFontSize; const ms: (Seg & { w: number })[] = []; for (const seg of segs) { if (seg.fontSize > maxFs) maxFs = seg.fontSize; ctx.font = segFont(seg); const sw = ctx.measureText(seg.text).width; totalW += sw; ms.push({ ...seg, w: sw }); } measured.push({ segs: ms, totalW, lh: maxFs * 1.25 }); } let visCount = 0; let usedH = 0; for (const ml of measured) { if (usedH + ml.lh > maxH + ml.lh * 0.5) break; usedH += ml.lh; visCount++; } if (visCount === 0) visCount = 1; const drawLines = measured.slice(0, visCount); const drawH = drawLines.reduce((s, l) => s + l.lh, 0); let startY: number; if (vAlign === 'top') startY = y + padding; else if (vAlign === 'bottom') startY = y + h - padding - drawH; else startY = y + h / 2 - drawH / 2; let curY = startY; for (const line of drawLines) { let lx: number; if (align === 'center') lx = x + padding + (maxW - line.totalW) / 2; else if (align === 'right') lx = x + w - padding - line.totalW; else lx = x + padding; for (const seg of line.segs) { ctx.font = segFont(seg); const segHasLink = hasLink || !!seg.link; ctx.fillStyle = segHasLink ? LINK_COLOR : seg.color; let drawText = seg.text; let sw = seg.w; const avail = x + w - padding - lx; if (sw > avail) { if (avail <= 0) break; drawText = truncateTextToWidth(ctx, drawText, avail); sw = ctx.measureText(drawText).width; } ctx.fillText(drawText, lx, curY); if (segHasLink || seg.underline) { ctx.beginPath(); ctx.moveTo(lx, curY + seg.fontSize + 1); ctx.lineTo(lx + sw, curY + seg.fontSize + 1); ctx.strokeStyle = segHasLink ? LINK_COLOR : seg.color; ctx.lineWidth = 1; ctx.stroke(); } if (seg.strike) { ctx.beginPath(); const sy = curY + seg.fontSize * 0.55; ctx.moveTo(lx, sy); ctx.lineTo(lx + sw, sy); ctx.strokeStyle = seg.color; ctx.lineWidth = 1; ctx.stroke(); } lx += sw; } curY += line.lh; } ctx.restore(); } const _hitCtx = document.createElement('canvas').getContext('2d')!; function hitTestRunLink( runs: TextRun[], localX: number, localY: number, cellW: number, cellH: number, cellStyle?: { bold?: boolean; italic?: boolean; fontSize?: number; textAlign?: string; verticalAlign?: string }, ): string | undefined { const padding = 4; const maxW = cellW - padding * 2; const maxH = cellH - padding * 2; if (maxW <= 0 || maxH <= 0 || runs.length === 0) return undefined; const ctx = _hitCtx; const defaultFs = cellStyle?.fontSize ?? 13; const vAlign = cellStyle?.verticalAlign ?? 'middle'; const align = cellStyle?.textAlign ?? 'left'; type S = { text: string; fs: number; bold: boolean; italic: boolean; link?: string }; const lines: S[][] = [[]]; for (const run of runs) { const parts = run.text.split('\n'); for (let i = 0; i < parts.length; i++) { if (i > 0) lines.push([]); if (parts[i].length > 0) { lines[lines.length - 1].push({ text: parts[i], fs: run.fontSize ?? defaultFs, bold: !!(run.bold ?? cellStyle?.bold), italic: !!(run.italic ?? cellStyle?.italic), link: run.link, }); } } } function font(s: S): string { const p: string[] = []; if (s.bold) p.push('bold'); if (s.italic) p.push('italic'); p.push(`${s.fs}px`); p.push(FONT_FAMILY); return p.join(' '); } type ML = { segs: (S & { w: number })[]; totalW: number; lh: number }; const measured: ML[] = []; for (const segs of lines) { let totalW = 0; let maxFs = defaultFs; const ms: (S & { w: number })[] = []; for (const seg of segs) { if (seg.fs > maxFs) maxFs = seg.fs; ctx.font = font(seg); const sw = ctx.measureText(seg.text).width; totalW += sw; ms.push({ ...seg, w: sw }); } measured.push({ segs: ms, totalW, lh: maxFs * 1.25 }); } let visCount = 0; let usedH = 0; for (const ml of measured) { if (usedH + ml.lh > maxH + ml.lh * 0.5) break; usedH += ml.lh; visCount++; } if (visCount === 0) visCount = 1; const drawLines = measured.slice(0, visCount); const drawH = drawLines.reduce((s, l) => s + l.lh, 0); let startY: number; if (vAlign === 'top') startY = padding; else if (vAlign === 'bottom') startY = cellH - padding - drawH; else startY = cellH / 2 - drawH / 2; let curY = startY; for (const line of drawLines) { let lx: number; if (align === 'center') lx = padding + (maxW - line.totalW) / 2; else if (align === 'right') lx = cellW - padding - line.totalW; else lx = padding; for (const seg of line.segs) { if (seg.link && localX >= lx && localX <= lx + seg.w && localY >= curY && localY <= curY + line.lh) { return seg.link; } lx += seg.w; } curY += line.lh; } return undefined; } /** Пустой объект {} или отсутствие color — не считаем кастомной границей (иначе stroke не рисуется) */ function normalizeCellBorder(b: CellBorder | undefined | null): CellBorder | undefined { if (b == null || typeof b !== 'object') return undefined; const rec = b as Record<string, unknown>; const color = (b as CellBorder).color ?? rec.Color; if (typeof color !== 'string' || !color.trim()) return undefined; const raw = ((b as CellBorder).style ?? rec.Style ?? 'solid').toString().toLowerCase(); const style: CellBorder['style'] = raw === 'dashed' ? 'dashed' : raw === 'dotted' ? 'dotted' : 'solid'; const wRaw = (b as CellBorder).width ?? rec.Width; let width: number | undefined; if (typeof wRaw === 'number' && Number.isFinite(wRaw)) width = wRaw; else if (typeof wRaw === 'string') { const p = parseFloat(wRaw); if (Number.isFinite(p)) width = p; } return { color: color.trim(), width, style, }; } /** Граница ячейки по её данным */ function getCellBorder(sheet: SheetData, r: number, c: number, side: 'borderTop' | 'borderRight' | 'borderBottom' | 'borderLeft'): CellBorder | undefined { const style = sheet.cells?.[cellId(r, c)]?.style; return normalizeCellBorder(style?.[side]); } /** Общее ребро — приоритет у кастомной границы (чтобы не рисовать серую поверх чёрной) */ function resolveBottomBorder(sheet: SheetData, r: number, c: number): CellBorder | undefined { const a = getCellBorder(sheet, r, c, 'borderBottom'); if (a) return a; if (r < TOTAL_ROWS - 1) return getCellBorder(sheet, r + 1, c, 'borderTop'); return undefined; } function resolveRightBorder(sheet: SheetData, r: number, c: number, cols: number): CellBorder | undefined { const a = getCellBorder(sheet, r, c, 'borderRight'); if (a) return a; if (c < cols - 1) return getCellBorder(sheet, r, c + 1, 'borderLeft'); return undefined; } /** Рисует ребро линией (цвет, толщина, штрих из border или дефолт) */ function strokeCellEdge( ctx: CanvasRenderingContext2D, x1: number, y1: number, x2: number, y2: number, border: CellBorder | undefined ) { const b = normalizeCellBorder(border); ctx.setLineDash([]); if (b) { ctx.strokeStyle = b.color; ctx.lineWidth = Math.max(1, b.width ?? 1); const s = b.style ?? 'solid'; if (s === 'dashed') ctx.setLineDash([4, 3]); else if (s === 'dotted') ctx.setLineDash([2, 2]); } else { ctx.strokeStyle = CELL_BORDER; ctx.lineWidth = 1; } ctx.lineCap = 'butt'; ctx.beginPath(); const horizontal = Math.abs(y1 - y2) < 1e-6; if (horizontal) { ctx.moveTo(x1, y1); ctx.lineTo(x2, y1); } else { ctx.moveTo(x1, y1); ctx.lineTo(x1, y2); } ctx.stroke(); ctx.setLineDash([]); } /** Добавляем столбцы при приближении к правому краю (как в Grid.tsx) */ const EXTEND_COLS_STEP = 50; const EXTEND_EDGE_PX = 400; const RESIZE_HIT_ZONE = 5; const MIN_COL_WIDTH = 20; const MIN_ROW_HEIGHT = 16; type ResizeZone = { type: 'col'; col: number } | { type: 'row'; row: number } | null; interface GridCanvasProps { sheet: SheetData; selectedCell: CellPosition | null; selectedRange: { start: CellPosition; end: CellPosition } | null; clipboard?: { range: { start: CellPosition; end: CellPosition } | null } | null; isEditing?: boolean; editValue?: string; readOnly?: boolean; commentCells?: Set<string>; onSelectCell?: (pos: CellPosition) => void; onSelectRange?: (range: { start: CellPosition; end: CellPosition }) => void; onStartEditing?: () => void; onOpenComments?: (row: number, col: number, anchorRect: DOMRect) => void; onHoverCommentCell?: (data: { row: number; col: number; anchorRect: DOMRect } | null) => void; onVisibleRangeChange?: (range: { startRow: number; endRow: number; startCol: number; endCol: number }) => void; onColumnResize?: (col: number, width: number) => void; onRowResize?: (row: number, height: number) => void; onInsertRow?: (row: number) => void; onInsertColumn?: (col: number) => void; onDeleteRow?: (row: number) => void; onDeleteColumn?: (col: number) => void; onPasteRows?: (row: number, direction: 'above' | 'below') => void; onClearSelection?: () => void; onInsertLink?: () => void; onRemoveLink?: () => void; } export interface GridCanvasHandle { getCellRect: (row: number, col: number) => DOMRect | null; getScrollElement: () => HTMLDivElement | null; } export const GridCanvas = forwardRef<GridCanvasHandle, GridCanvasProps>(({ sheet, selectedCell, selectedRange, clipboard = null, isEditing = false, editValue = '', readOnly = false, commentCells, onSelectCell, onSelectRange, onStartEditing, onOpenComments, onHoverCommentCell, onVisibleRangeChange, onColumnResize, onRowResize, onInsertRow, onInsertColumn, onDeleteRow, onDeleteColumn, onPasteRows, onClearSelection, onInsertLink, onRemoveLink, }, ref) => { const containerRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null); const scrollRef = useRef<HTMLDivElement>(null); const [extendedCols, setExtendedCols] = useState(0); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; row: number; col: number; headerSource?: { type: 'row'; index: number } | { type: 'col'; index: number }; } | null>(null); const cols = TOTAL_COLS + extendedCols; const getColWidth = useCallback( (col: number) => sheet.columnWidths?.[col] ?? DEFAULT_COL_WIDTH, [sheet.columnWidths] ); const getRowHeight = useCallback( (row: number) => sheet.rowHeights?.[row] ?? DEFAULT_ROW_HEIGHT, [sheet.rowHeights] ); const colPositions = useMemo(() => { const positions: number[] = [0]; for (let c = 0; c < cols; c++) { positions.push(positions[c] + getColWidth(c)); } return positions; }, [getColWidth, cols]); const rowPositions = useMemo(() => { const positions: number[] = [0]; for (let r = 0; r < TOTAL_ROWS; r++) { positions.push(positions[r] + getRowHeight(r)); } return positions; }, [getRowHeight]); const totalWidth = colPositions[cols] ?? 0; const totalHeight = rowPositions[TOTAL_ROWS] ?? 0; const frozenRows = Math.max(0, sheet.frozenRows ?? 0); const frozenCols = Math.max(0, sheet.frozenCols ?? 0); const frozenHeight = rowPositions[frozenRows] ?? 0; const frozenWidth = colPositions[frozenCols] ?? 0; const scrollableWidth = totalWidth - frozenWidth; const scrollableHeight = totalHeight - frozenHeight; const contentWidth = ROW_HEADER_WIDTH + totalWidth; const contentHeight = COL_HEADER_HEIGHT + totalHeight; const mergeMap = useMemo(() => { const map = new Map<string, { isOrigin: boolean; range: CellRange }>(); for (const m of sheet.mergedCells ?? []) { const nm = normalizeRange(m); for (let r = nm.start.row; r <= nm.end.row; r++) { for (let c = nm.start.col; c <= nm.end.col; c++) { const id = cellId(r, c); map.set(id, { isOrigin: r === nm.start.row && c === nm.start.col, range: nm }); } } } return map; }, [sheet.mergedCells]); const findVisibleRange = useCallback( (scrollLeft: number, scrollTop: number, viewW: number, viewH: number): VisibleRange => { const bodyViewW = Math.max(0, viewW - ROW_HEADER_WIDTH - frozenWidth); const bodyViewH = Math.max(0, viewH - COL_HEADER_HEIGHT - frozenHeight); const { startRow, endRow } = findVisibleRowRange( rowPositions, frozenRows > 0 ? scrollTop + frozenHeight : scrollTop, frozenRows > 0 ? bodyViewH : Math.max(0, viewH - COL_HEADER_HEIGHT) ); const { startCol, endCol } = findVisibleColRange( colPositions, frozenCols > 0 ? scrollLeft + frozenWidth : scrollLeft, frozenCols > 0 ? bodyViewW : Math.max(0, viewW - ROW_HEADER_WIDTH), cols ); const adjStartRow = frozenRows > 0 ? 0 : startRow; const adjEndRow = frozenRows > 0 ? Math.max(frozenRows - 1, endRow) : endRow; const adjStartCol = frozenCols > 0 ? 0 : startCol; const adjEndCol = frozenCols > 0 ? Math.max(frozenCols - 1, endCol) : endCol; return { startRow: adjStartRow, endRow: adjEndRow, startCol: adjStartCol, endCol: adjEndCol }; }, [colPositions, rowPositions, cols, frozenRows, frozenCols, frozenHeight, frozenWidth] ); const draw = useCallback( (ctx: CanvasRenderingContext2D, scrollLeft: number, scrollTop: number, width: number, height: number) => { ctx.clearRect(0, 0, width, height); // При alpha: false clearRect даёт чёрный фон; без полной заливки частично видимые ячейки у края «чёрные» ctx.fillStyle = CELL_BG; ctx.fillRect(0, 0, width, height); const raw = findVisibleRange(scrollLeft, scrollTop, width, height); const startRow = Math.max(0, raw.startRow - 1); const endRow = Math.min(TOTAL_ROWS - 1, raw.endRow + 1); const startCol = Math.max(0, raw.startCol - 1); const endCol = Math.min(cols - 1, raw.endCol + 1); const bodyW = Math.max(0, width - ROW_HEADER_WIDTH); const bodyH = Math.max(0, height - COL_HEADER_HEIGHT); const bodyScrollLeft = scrollLeft; const bodyScrollTop = scrollTop; const drawCellRegion = (clipX: number, clipY: number, clipW: number, clipH: number, tx: number, ty: number, rFrom: number, rTo: number, cFrom: number, cTo: number) => { ctx.save(); ctx.beginPath(); ctx.rect(clipX, clipY, clipW, clipH); ctx.clip(); ctx.translate(tx, ty); for (let r = rFrom; r <= rTo; r++) { for (let c = cFrom; c <= cTo; c++) { const mergeInfo = mergeMap.get(cellId(r, c)); if (mergeInfo && !mergeInfo.isOrigin) continue; const x = ROW_HEADER_WIDTH + colPositions[c]; const y = COL_HEADER_HEIGHT + rowPositions[r]; let w = getColWidth(c); let h = getRowHeight(r); let mergeEndCol = c; let mergeEndRow = r; if (mergeInfo?.isOrigin) { const mr = mergeInfo.range; mergeEndCol = mr.end.col; mergeEndRow = mr.end.row; w = colPositions[mr.end.col + 1] - colPositions[c]; h = rowPositions[mr.end.row + 1] - rowPositions[r]; } const originId = cellId(mergeInfo?.range?.start.row ?? r, mergeInfo?.range?.start.col ?? c); const cell = sheet.cells?.[originId]; const isSelected = selectedCell?.row === r && selectedCell?.col === c; const inRange = selectedRange && r <= selectedRange.end.row && mergeEndRow >= selectedRange.start.row && c <= selectedRange.end.col && mergeEndCol >= selectedRange.start.col; const rawDisplayVal = cell?.computedValue !== undefined && cell?.computedValue !== '' ? formatCellValue(cell.computedValue, cell?.style?.format) : (cell?.richText && cell.richText.length > 0 ? textRunsToPlainText(cell.richText) : formatCellValue(cell?.value, cell?.style?.format)); const displayVal = isEditing && isSelected ? undefined : rawDisplayVal; const clipboardRange = clipboard?.range ? normalizeRange(clipboard.range) : null; const inClipboard = clipboardRange && r <= clipboardRange.end.row && mergeEndRow >= clipboardRange.start.row && c <= clipboardRange.end.col && mergeEndCol >= clipboardRange.start.col; const cellBg = cell?.style?.backgroundColor?.trim() || CELL_BG; ctx.fillStyle = cellBg; ctx.fillRect(x, y, w, h); if (!inClipboard && (isSelected || inRange) && !(isEditing && isSelected)) { ctx.fillStyle = 'rgba(46, 115, 252, 0.08)'; ctx.fillRect(x, y, w, h); } if (mergeInfo?.isOrigin) { strokeCellEdge(ctx, x, y + h, x + w, y + h, resolveBottomBorder(sheet, mergeEndRow, c)); strokeCellEdge(ctx, x + w, y, x + w, y + h, resolveRightBorder(sheet, r, mergeEndCol, cols)); if (r === 0) strokeCellEdge(ctx, x, y, x + w, y, getCellBorder(sheet, r, c, 'borderTop')); if (c === 0) strokeCellEdge(ctx, x, y, x, y + h, getCellBorder(sheet, r, c, 'borderLeft')); } else { strokeCellEdge(ctx, x, y + h, x + w, y + h, resolveBottomBorder(sheet, r, c)); strokeCellEdge(ctx, x + w, y, x + w, y + h, resolveRightBorder(sheet, r, c, cols)); if (r === 0) strokeCellEdge(ctx, x, y, x + w, y, getCellBorder(sheet, r, c, 'borderTop')); if (c === 0) strokeCellEdge(ctx, x, y, x, y + h, getCellBorder(sheet, r, c, 'borderLeft')); } const useClipboardStyle = !!clipboardRange && inClipboard; const showSelection = (isSelected || inRange) && !inClipboard; const showClipboardBorder = inClipboard && clipboardRange; const selRange = selectedRange ? normalizeRange(selectedRange) : null; const isSingleCell = !selRange || (selRange.start.row === selRange.end.row && selRange.start.col === selRange.end.col); const rangeTop = showClipboardBorder ? r === clipboardRange!.start.row : showSelection && (isSingleCell ? isSelected : r === selRange!.start.row); const rangeBottom = showClipboardBorder ? mergeEndRow === clipboardRange!.end.row : showSelection && (isSingleCell ? isSelected : mergeEndRow === selRange!.end.row); const rangeLeft = showClipboardBorder ? c === clipboardRange!.start.col : showSelection && (isSingleCell ? isSelected : c === selRange!.start.col); const rangeRight = showClipboardBorder ? mergeEndCol === clipboardRange!.end.col : showSelection && (isSingleCell ? isSelected : mergeEndCol === selRange!.end.col); if ((showSelection || showClipboardBorder) && (rangeTop || rangeBottom || rangeLeft || rangeRight)) { ctx.strokeStyle = SELECTION_BORDER; ctx.lineWidth = 2; ctx.setLineDash(useClipboardStyle ? [4, 3] : []); const inset = 1; if (rangeTop) { ctx.beginPath(); ctx.moveTo(x, y + inset); ctx.lineTo(x + w, y + inset); ctx.stroke(); } if (rangeBottom) { ctx.beginPath(); ctx.moveTo(x, y + h - inset); ctx.lineTo(x + w, y + h - inset); ctx.stroke(); } if (rangeLeft) { ctx.beginPath(); ctx.moveTo(x + inset, y); ctx.lineTo(x + inset, y + h); ctx.stroke(); } if (rangeRight) { ctx.beginPath(); ctx.moveTo(x + w - inset, y); ctx.lineTo(x + w - inset, y + h); ctx.stroke(); } if (!useClipboardStyle && isSingleCell && isSelected) { const handleSize = 8; const handleRadius = 2; const hx = x + w - handleSize - inset; const hy = y + h - handleSize - inset; ctx.fillStyle = SELECTION_BORDER; ctx.beginPath(); ctx.moveTo(hx + handleRadius, hy); ctx.lineTo(hx + handleSize - handleRadius, hy); ctx.quadraticCurveTo(hx + handleSize, hy, hx + handleSize, hy + handleRadius); ctx.lineTo(hx + handleSize, hy + handleSize - handleRadius); ctx.quadraticCurveTo(hx + handleSize, hy + handleSize, hx + handleSize - handleRadius, hy + handleSize); ctx.lineTo(hx + handleRadius, hy + handleSize); ctx.quadraticCurveTo(hx, hy + handleSize, hx, hy + handleSize - handleRadius); ctx.lineTo(hx, hy + handleRadius); ctx.quadraticCurveTo(hx, hy, hx + handleRadius, hy); ctx.fill(); } } const originRow = mergeInfo?.range?.start.row ?? r; const originCol = mergeInfo?.range?.start.col ?? c; if (commentCells?.has(`${sheet.id}:${originRow}:${originCol}`)) { const triSize = 6; ctx.fillStyle = '#f57c00'; ctx.beginPath(); ctx.moveTo(x + w, y); ctx.lineTo(x + w, y + triSize); ctx.lineTo(x + w - triSize, y); ctx.closePath(); ctx.fill(); } const richRuns = !(isEditing && isSelected) && cell?.richText && cell.richText.length > 0 && cell.richText.some((rr) => rr.bold || rr.italic || rr.underline || rr.strikethrough || rr.color || rr.fontSize || rr.link) ? cell.richText : null; if (richRuns) { drawRichTextRuns(ctx, richRuns, x, y, w, h, cell?.style, !!cell?.link); } else if (displayVal !== undefined && displayVal !== '') { const hasLink = !!cell?.link; ctx.fillStyle = hasLink ? LINK_COLOR : (cell?.style?.color ?? TEXT_COLOR); const fontSize = cell?.style?.fontSize ?? 13; const lineHeight = fontSize * 1.25; const fontParts: string[] = []; if (cell?.style?.bold) fontParts.push('bold'); if (cell?.style?.italic) fontParts.push('italic'); fontParts.push(`${fontSize}px`); fontParts.push(FONT_FAMILY); ctx.font = fontParts.join(' '); const padding = 4; const textOrientation = cell?.style?.textOrientation ?? 'horizontal'; const wrapText = cell?.style?.wrapText ?? false; const vAlign = cell?.style?.verticalAlign ?? 'middle'; const align = cell?.style?.textAlign ?? 'left'; if (textOrientation === 'vertical') { const maxH = h - padding * 2; const text = truncateTextToWidth(ctx, String(displayVal).replace(/\n/g, ' '), maxH); let textX: number; let textY: number; if (align === 'center') textX = x + w / 2; else if (align === 'right') textX = x + w - padding; else textX = x + padding; if (vAlign === 'top') textY = y + padding; else if (vAlign === 'bottom') textY = y + h - padding; else textY = y + h / 2; ctx.save(); ctx.translate(textX, textY); ctx.rotate(-Math.PI / 2); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(text, 0, 0); ctx.restore(); } else { const maxW = w - padding * 2; const maxH = h - padding * 2; const textStr = String(displayVal); const needsMultiLine = textStr.includes('\n') || wrapText; if (needsMultiLine && maxW > 0) { const lines = wrapText ? wrapTextToLines(ctx, textStr, maxW) : textStr.split('\n').map((ln) => (ctx.measureText(ln).width <= maxW ? ln : truncateTextToWidth(ctx, ln, maxW))); const visibleLines = Math.max(1, Math.floor(maxH / lineHeight)); const drawLines = lines.slice(0, visibleLines); const totalHeight = drawLines.length * lineHeight; let startY: number; if (vAlign === 'top') startY = y + padding; else if (vAlign === 'bottom') startY = y + h - padding - totalHeight; else startY = y + h / 2 - totalHeight / 2; ctx.textAlign = align === 'center' || align === 'right' ? align : 'left'; ctx.textBaseline = 'top'; for (let li = 0; li < drawLines.length; li++) { const line = drawLines[li]; const lineY = startY + li * lineHeight; let lineX: number; const lineW = ctx.measureText(line).width; if (align === 'center') lineX = x + padding + (maxW - lineW) / 2; else if (align === 'right') lineX = x + w - padding - lineW; else lineX = x + padding; ctx.fillText(line, lineX, lineY); if (hasLink || cell?.style?.underline) { const uy = lineY + fontSize + 1; ctx.beginPath(); ctx.moveTo(lineX, uy); ctx.lineTo(lineX + lineW, uy); ctx.strokeStyle = hasLink ? LINK_COLOR : (cell?.style?.color ?? TEXT_COLOR); ctx.lineWidth = 1; ctx.stroke(); } } } else { const text = truncateTextToWidth(ctx, textStr, maxW); ctx.textAlign = align === 'center' || align === 'right' ? align : 'left'; let textY: number; if (vAlign === 'top') { ctx.textBaseline = 'top'; textY = y + padding; } else if (vAlign === 'bottom') { ctx.textBaseline = 'bottom'; textY = y + h - padding; } else { ctx.textBaseline = 'middle'; textY = y + h / 2; } let textX: number; if (align === 'center') textX = x + w / 2; else if (align === 'right') textX = x + w - padding; else textX = x + padding; ctx.fillText(text, textX, textY); if (hasLink || cell?.style?.underline) { const metrics = ctx.measureText(text); const underlineY = vAlign === 'top' ? textY + fontSize * 1.2 + 1 : vAlign === 'bottom' ? textY + 1 : textY + fontSize / 2 + 1; const lineLeft = align === 'center' ? textX - metrics.width / 2 : align === 'right' ? textX - metrics.width : textX; ctx.beginPath(); ctx.moveTo(lineLeft, underlineY); ctx.lineTo(lineLeft + metrics.width, underlineY); ctx.strokeStyle = hasLink ? LINK_COLOR : (cell?.style?.color ?? TEXT_COLOR); ctx.lineWidth = 1; ctx.stroke(); } } } } } } ctx.restore(); }; if (frozenRows > 0 || frozenCols > 0) { if (frozenRows > 0 && frozenCols > 0) { drawCellRegion(ROW_HEADER_WIDTH, COL_HEADER_HEIGHT, frozenWidth, frozenHeight, 0, 0, 0, Math.min(endRow, frozenRows - 1), 0, Math.min(endCol, frozenCols - 1)); } if (frozenRows > 0) { drawCellRegion(ROW_HEADER_WIDTH + frozenWidth, COL_HEADER_HEIGHT, bodyW - frozenWidth, frozenHeight, -bodyScrollLeft, 0, 0, Math.min(endRow, frozenRows - 1), Math.max(startCol, frozenCols), endCol); } if (frozenCols > 0) { drawCellRegion(ROW_HEADER_WIDTH, COL_HEADER_HEIGHT + frozenHeight, frozenWidth, bodyH - frozenHeight, 0, -bodyScrollTop, Math.max(startRow, frozenRows), endRow, 0, Math.min(endCol, frozenCols - 1)); } drawCellRegion(ROW_HEADER_WIDTH + frozenWidth, COL_HEADER_HEIGHT + frozenHeight, bodyW - frozenWidth, bodyH - frozenHeight, -bodyScrollLeft, -bodyScrollTop, Math.max(startRow, frozenRows), endRow, Math.max(startCol, frozenCols), endCol); } else { drawCellRegion(ROW_HEADER_WIDTH, COL_HEADER_HEIGHT, bodyW, bodyH, -scrollLeft, -scrollTop, startRow, endRow, startCol, endCol); } // 2) Номера строк — закреплённые (1, 2…) и прокручиваемые const selRange = selectedRange ? normalizeRange(selectedRange) : null; const isFullRowSelection = selRange && selRange.start.row === selRange.end.row && selRange.start.col === 0 && selRange.end.col >= cols - 1; const isFullColSelection = selRange && selRange.start.col === selRange.end.col && selRange.start.row === 0 && selRange.end.row === TOTAL_ROWS - 1; const isRowSelected = (r: number) => selRange ? r >= selRange.start.row && r <= selRange.end.row : selectedCell?.row === r; const isRowHeaderClick = (r: number) => isRowSelected(r) && !!isFullRowSelection; const isRowMenuOpen = (r: number) => contextMenu?.headerSource?.type === 'row' && contextMenu.headerSource.index === r; ctx.font = `11px ${FONT_FAMILY}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const rowHeaderScrollTop = bodyScrollTop; if (frozenRows > 0) { ctx.save(); ctx.beginPath(); ctx.rect(0, COL_HEADER_HEIGHT, ROW_HEADER_WIDTH, frozenHeight); ctx.clip(); for (let r = 0; r < frozenRows && r <= endRow; r++) { const y = COL_HEADER_HEIGHT + rowPositions[r]; const h = getRowHeight(r); ctx.fillStyle = (isRowMenuOpen(r) || isRowHeaderClick(r)) ? HEADER_MENU_OPEN_BG : isRowSelected(r) ? HEADER_ACTIVE_BG : HEADER_BG; ctx.fillRect(0, y, ROW_HEADER_WIDTH, h); ctx.strokeStyle = CELL_BORDER; ctx.strokeRect(0, y, ROW_HEADER_WIDTH, h); ctx.fillStyle = HEADER_TEXT; ctx.fillText(String(r + 1), ROW_HEADER_WIDTH / 2, y + h / 2); } ctx.restore(); } ctx.save(); ctx.beginPath(); ctx.rect(0, COL_HEADER_HEIGHT + frozenHeight, ROW_HEADER_WIDTH, bodyH - frozenHeight); ctx.clip(); for (let r = Math.max(startRow, frozenRows); r <= endRow; r++) { const y = COL_HEADER_HEIGHT + frozenHeight + (rowPositions[r] - rowPositions[frozenRows]) - rowHeaderScrollTop; const h = getRowHeight(r); ctx.fillStyle = (isRowMenuOpen(r) || isRowHeaderClick(r)) ? HEADER_MENU_OPEN_BG : isRowSelected(r) ? HEADER_ACTIVE_BG : HEADER_BG; ctx.fillRect(0, y, ROW_HEADER_WIDTH, h); ctx.strokeStyle = CELL_BORDER; ctx.strokeRect(0, y, ROW_HEADER_WIDTH, h); ctx.fillStyle = HEADER_TEXT; ctx.fillText(String(r + 1), ROW_HEADER_WIDTH / 2, y + h / 2); } ctx.restore(); // 3) Буквы столбцов — закреплённые (A, B…) и прокручиваемые const isColSelected = (c: number) => selRange ? c >= selRange.start.col && c <= selRange.end.col : selectedCell?.col === c; const isColHeaderClick = (c: number) => isColSelected(c) && !!isFullColSelection; const isColMenuOpen = (c: number) => contextMenu?.headerSource?.type === 'col' && contextMenu.headerSource.index === c; ctx.font = `11px ${FONT_FAMILY}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const colHeaderScrollLeft = bodyScrollLeft; if (frozenCols > 0) { ctx.save(); ctx.beginPath(); ctx.rect(ROW_HEADER_WIDTH, 0, frozenWidth, COL_HEADER_HEIGHT); ctx.clip(); for (let c = 0; c < frozenCols && c <= endCol; c++) { const x = ROW_HEADER_WIDTH + colPositions[c]; const w = getColWidth(c); ctx.fillStyle = (isColMenuOpen(c) || isColHeaderClick(c)) ? HEADER_MENU_OPEN_BG : isColSelected(c) ? HEADER_ACTIVE_BG : HEADER_BG; ctx.fillRect(x, 0, w, COL_HEADER_HEIGHT); ctx.strokeStyle = CELL_BORDER; ctx.strokeRect(x, 0, w, COL_HEADER_HEIGHT); ctx.fillStyle = HEADER_TEXT; ctx.fillText(colToLetter(c), x + w / 2, COL_HEADER_HEIGHT / 2); } ctx.restore(); } ctx.save(); ctx.beginPath(); ctx.rect(ROW_HEADER_WIDTH + frozenWidth, 0, bodyW - frozenWidth, COL_HEADER_HEIGHT); ctx.clip(); for (let c = Math.max(startCol, frozenCols); c <= endCol; c++) { const x = ROW_HEADER_WIDTH + frozenWidth + (colPositions[c] - colPositions[frozenCols]) - colHeaderScrollLeft; const w = getColWidth(c); ctx.fillStyle = (isColMenuOpen(c) || isColHeaderClick(c)) ? HEADER_MENU_OPEN_BG : isColSelected(c) ? HEADER_ACTIVE_BG : HEADER_BG; ctx.fillRect(x, 0, w, COL_HEADER_HEIGHT); ctx.strokeStyle = CELL_BORDER; ctx.strokeRect(x, 0, w, COL_HEADER_HEIGHT); ctx.fillStyle = HEADER_TEXT; ctx.fillText(colToLetter(c), x + w / 2, COL_HEADER_HEIGHT / 2); } ctx.restore(); // 4) Угол (пересечение шапки и номеров) — всегда на месте ctx.fillStyle = HEADER_BG; ctx.fillRect(0, 0, ROW_HEADER_WIDTH, COL_HEADER_HEIGHT); ctx.strokeStyle = HEADER_BORDER; ctx.lineWidth = 1; ctx.strokeRect(0, 0, ROW_HEADER_WIDTH, COL_HEADER_HEIGHT); // 5) Линия-разделитель закреплённой области if (frozenRows > 0 || frozenCols > 0) { ctx.strokeStyle = HEADER_BORDER; ctx.lineWidth = 2; if (frozenRows > 0) { ctx.beginPath(); ctx.moveTo(0, COL_HEADER_HEIGHT + frozenHeight); ctx.lineTo(width, COL_HEADER_HEIGHT + frozenHeight); ctx.stroke(); } if (frozenCols > 0) { ctx.beginPath(); ctx.moveTo(ROW_HEADER_WIDTH + frozenWidth, 0); ctx.lineTo(ROW_HEADER_WIDTH + frozenWidth, height); ctx.stroke(); } } }, [sheet, mergeMap, colPositions, rowPositions, getColWidth, getRowHeight, selectedCell, selectedRange, clipboard, isEditing, editValue, commentCells, findVisibleRange, cols, frozenRows, frozenCols, frozenHeight, frozenWidth, contextMenu] ); const drawRef = useRef(draw); const findVisibleRangeRef = useRef(findVisibleRange); const onVisibleRangeChangeRef = useRef(onVisibleRangeChange); const lastVisibleRangeKeyRef = useRef<string>(''); const redrawRef = useRef<(() => void) | null>(null); drawRef.current = draw; findVisibleRangeRef.current = findVisibleRange; onVisibleRangeChangeRef.current = onVisibleRangeChange; /** Без setState при скролле — иначе каждый кадр рендер React и портит INP */ const contentWidthRef = useRef(contentWidth); contentWidthRef.current = contentWidth; const scrollContentWidthRef = useRef(scrollableWidth); scrollContentWidthRef.current = scrollableWidth; /** Скролл/resize: подписки один раз; draw через ref — без пересоздания слушателей при каждом изменении ячеек */ useEffect(() => { const canvas = canvasRef.current; const scrollEl = scrollRef.current; const container = containerRef.current; if (!canvas || !scrollEl || !container) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const maybeExtendColsRight = (scrollLeft: number, viewW: number) => { if (viewW <= 0) return; const cw = scrollContentWidthRef.current; if (scrollLeft + viewW > cw - EXTEND_EDGE_PX) { setExtendedCols((prev) => { const cap = MAX_TOTAL_COLS - TOTAL_COLS; if (prev >= cap) return prev; return Math.min(prev + EXTEND_COLS_STEP, cap); }); } }; const syncSizeAndRedraw = () => { const cssW = container.clientWidth; const cssH = container.clientHeight; const dpr = Math.min(window.devicePixelRatio || 1, 2); const wPx = Math.round(cssW * dpr); const hPx = Math.round(cssH * dpr); if (canvas.width !== wPx || canvas.height !== hPx) { canvas.width = wPx; canvas.height = hPx; canvas.style.width = `${cssW}px`; canvas.style.height = `${cssH}px`; } ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const sl = scrollEl.scrollLeft; const st = scrollEl.scrollTop; const vw = scrollEl.clientWidth; drawRef.current(ctx, sl, st, cssW, cssH); maybeExtendColsRight(sl, vw); }; redrawRef.current = syncSizeAndRedraw; let scrollRaf = 0; const handleScroll = () => { if (scrollRaf) return; scrollRaf = requestAnimationFrame(() => { scrollRaf = 0; const cssW = container.clientWidth; const cssH = container.clientHeight; const dpr = Math.min(window.devicePixelRatio || 1, 2); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const sl = scrollEl.scrollLeft; const st = scrollEl.scrollTop; const vw = scrollEl.clientWidth; drawRef.current(ctx, sl, st, cssW, cssH); maybeExtendColsRight(sl, vw); const range = findVisibleRangeRef.current(sl, st, cssW, cssH); const key = visibleRangeToKey(range); if (lastVisibleRangeKeyRef.current !== key) { lastVisibleRangeKeyRef.current = key; startTransition(() => { onVisibleRangeChangeRef.current?.(range); }); } }); }; syncSizeAndRedraw(); scrollEl.addEventListener('scroll', handleScroll, { passive: true }); const ro = new ResizeObserver(syncSizeAndRedraw); ro.observe(container); return () => { if (scrollRaf) cancelAnimationFrame(scrollRaf); scrollEl.removeEventListener('scroll', handleScroll); ro.disconnect(); }; }, []); /** Данные / выделение: перерисовка при изменении ячеек или draw */ useLayoutEffect(() => { const canvas = canvasRef.current; const scrollEl = scrollRef.current; const container = containerRef.current; if (!canvas || !scrollEl || !container) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const cssW = container.clientWidth; const cssH = container.clientHeight; const dpr = Math.min(window.devicePixelRatio || 1, 2); const wPx = Math.round(cssW * dpr); const hPx = Math.round(cssH * dpr); if (canvas.width !== wPx || canvas.height !== hPx) { canvas.width = wPx; canvas.height = hPx; canvas.style.width = `${cssW}px`; canvas.style.height = `${cssH}px`; } ctx.setTransform(dpr, 0, 0, dpr, 0, 0); draw(ctx, scrollEl.scrollLeft, scrollEl.scrollTop, cssW, cssH); }, [draw, sheet.cells]); const getResizeZone = useCallback( (clientX: number, clientY: number): ResizeZone => { const scrollEl = scrollRef.current; const container = containerRef.current; if (!scrollEl || !container || readOnly || (!onColumnResize && !onRowResize)) return null; const rect = container.getBoundingClientRect(); const vx = clientX - rect.left; const vy = clientY - rect.top; if (vy < COL_HEADER_HEIGHT && vx > ROW_HEADER_WIDTH && onColumnResize) { const sl = scrollEl.scrollLeft; for (let c = 0; c < frozenCols; c++) { const bx = ROW_HEADER_WIDTH + colPositions[c + 1]; if (Math.abs(vx - bx) < RESIZE_HIT_ZONE) return { type: 'col', col: c }; } const colBase = frozenCols > 0 ? colPositions[frozenCols] : 0; for (let c = Math.max(frozenCols, 0); c < cols; c++) { const bx = ROW_HEADER_WIDTH + frozenWidth + (colPositions[c + 1] - colBase) - sl; if (bx > rect.width) break; if (bx < ROW_HEADER_WIDTH + frozenWidth - RESIZE_HIT_ZONE) continue; if (Math.abs(vx - bx) < RESIZE_HIT_ZONE) return { type: 'col', col: c }; } } if (vx < ROW_HEADER_WIDTH && vy > COL_HEADER_HEIGHT && onRowResize) { const st = scrollEl.scrollTop; for (let r = 0; r < frozenRows; r++) { const by = COL_HEADER_HEIGHT + rowPositions[r + 1]; if (Math.abs(vy - by) < RESIZE_HIT_ZONE) return { type: 'row', row: r }; } const rowBase = frozenRows > 0 ? rowPositions[frozenRows] : 0; for (let r = Math.max(frozenRows, 0); r < TOTAL_ROWS; r++) { const by = COL_HEADER_HEIGHT + frozenHeight + (rowPositions[r + 1] - rowBase) - st; if (by > rect.height) break; if (by < COL_HEADER_HEIGHT + frozenHeight - RESIZE_HIT_ZONE) continue; if (Math.abs(vy - by) < RESIZE_HIT_ZONE) return { type: 'row', row: r }; } } return null; }, [colPositions, rowPositions, cols, frozenRows, frozenCols, frozenHeight, frozenWidth, readOnly, onColumnResize, onRowResize] ); const [resizeState, setResizeState] = useState<{ type: 'col' | 'row'; index: number; startX: number; startY: number; startSize: number; } | null>(null); const [hoverResizeZone, setHoverResizeZone] = useState<ResizeZone>(null); const [hoverLinkCell, setHoverLinkCell] = useState(false); const [hoverLinkTooltip, setHoverLinkTooltip] = useState<{ url: string; x: number; y: number } | null>(null); const linkTooltipTimeoutRef = useRef<ReturnType<typeof setTimeout>>(); const linkTooltipMouseRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); const lastHoveredCommentKeyRef = useRef<string | null>(null); const getCellRect = useCallback( (row: number, col: number): DOMRect | null => { const scrollEl = scrollRef.current; const container = containerRef.current; if (!scrollEl || !container) return null; const rect = container.getBoundingClientRect(); const scrollLeft = scrollEl.scrollLeft; const scrollTop = scrollEl.scrollTop; const mergeInfo = mergeMap.get(cellId(row, col)); const origin = mergeInfo?.range?.start ?? { row, col }; const endPos = mergeInfo?.range ? mergeInfo.range.end : { row, col }; const r0 = origin.row; const c0 = origin.col; const re = endPos.row; const ce = endPos.col; const w = colPositions[ce + 1] - colPositions[c0]; const h = rowPositions[re + 1] - rowPositions[r0]; let canvasX: number; let canvasY: number; if (r0 < frozenRows && c0 < frozenCols) { canvasX = ROW_HEADER_WIDTH + colPositions[c0]; canvasY = COL_HEADER_HEIGHT + rowPositions[r0]; } else if (r0 < frozenRows) { canvasX = ROW_HEADER_WIDTH + frozenWidth + (colPositions[c0] - colPositions[frozenCols] - scrollLeft); canvasY = COL_HEADER_HEIGHT + rowPositions[r0]; } else if (c0 < frozenCols) { canvasX = ROW_HEADER_WIDTH + colPositions[c0]; canvasY = COL_HEADER_HEIGHT + frozenHeight + (rowPositions[r0] - rowPositions[frozenRows] - scrollTop); } else { canvasX = ROW_HEADER_WIDTH + frozenWidth + (colPositions[c0] - colPositions[frozenCols] - scrollLeft); canvasY = COL_HEADER_HEIGHT + frozenHeight + (rowPositions[r0] - rowPositions[frozenRows] - scrollTop); } return new DOMRect(rect.left + canvasX, rect.top + canvasY, w, h); }, [mergeMap, colPositions, rowPositions, frozenRows, frozenCols, frozenWidth, frozenHeight] ); useImperativeHandle(ref, () => ({ getCellRect, getScrollElement: () => scrollRef.current, }), [getCellRect]); type HeaderHit = { type: 'row'; row: number } | { type: 'col'; col: number }; const getHeaderFromEvent = useCallback( (clientX: number, clientY: number): HeaderHit | null => { const scrollEl = scrollRef.current; const container = containerRef.current; if (!scrollEl || !container) return null; const rect = container.getBoundingClientRect(); const vx = clientX - rect.left; const vy = clientY - rect.top; const scrollLeft = scrollEl.scrollLeft; const scrollTop = scrollEl.scrollTop; const inColHeader = vy < COL_HEADER_HEIGHT && vx >= ROW_HEADER_WIDTH; const inRowHeader = vx < ROW_HEADER_WIDTH && vy >= COL_HEADER_HEIGHT; if (!inColHeader && !inRowHeader) return null; if (inRowHeader) { let relY: number; if (vy < COL_HEADER_HEIGHT + frozenHeight) { relY = vy - COL_HEADER_HEIGHT; } else { relY = vy - COL_HEADER_HEIGHT - frozenHeight + scrollTop + (rowPositions[frozenRows] ?? 0); } for (let r = 0; r < TOTAL_ROWS; r++) { if ((rowPositions[r + 1] ?? 0) > relY) return { type: 'row', row: r }; } return { type: 'row', row: TOTAL_ROWS - 1 }; } if (inColHeader) { let relX: number; if (vx < ROW_HEADER_WIDTH + frozenWidth) { relX = vx - ROW_HEADER_WIDTH; } else { relX = vx - ROW_HEADER_WIDTH - frozenWidth + scrollLeft + (colPositions[frozenCols] ?? 0); } for (let c = 0; c < cols; c++) { if ((colPositions[c + 1] ?? 0) > relX) return { type: 'col', col: c }; } return { type: 'col', col: cols - 1 }; } return null; }, [colPositions, rowPositions, cols, frozenRows, frozenCols, frozenWidth, frozenHeight] ); const getHeaderRect = useCallback( (header: HeaderHit): DOMRect | null => { const scrollEl = scrollRef.current; const container = containerRef.current; if (!scrollEl || !container) return null; const rect = container.getBoundingClientRect(); const scrollLeft = scrollEl.scrollLeft; const scrollTop = scrollEl.scrollTop; if (header.type === 'row') { const r = header.row; const h = getRowHeight(r); let canvasY: number; if (r < frozenRows) { canvasY = COL_HEADER_HEIGHT + (rowPositions[r] ?? 0); } else { canvasY = COL_HEADER_HEIGHT + frozenHeight + (rowPositions[r] ?? 0) - (rowPositions[frozenRows] ?? 0) - scrollTop; } return new DOMRect(rect.left, rect.top + canvasY, ROW_HEADER_WIDTH, h); } const c = header.col; const w = getColWidth(c); let canvasX: number; if (c < frozenCols) { canvasX = ROW_HEADER_WIDTH + (colPositions[c] ?? 0); } else { canvasX = ROW_HEADER_WIDTH + frozenWidth + (colPositions[c] ?? 0) - (colPositions[frozenCols] ?? 0) - scrollLeft; } return new DOMRect(rect.left + canvasX, rect.top, w, COL_HEADER_HEIGHT); }, [rowPositions, colPositions, getRowHeight, getColWidth, frozenRows, frozenCols, frozenWidth, frozenHeight] ); const getCellFromEvent = useCallback( (clientX: number, clientY: number): CellPosition | null => { const scrollEl = scrollRef.current; const container = containerRef.current; if (!scrollEl || !container) return null; const rect = container.getBoundingClientRect(); const vx = clientX - rect.left; const vy = clientY - rect.top; const addScrollX = frozenRows > 0 || frozenCols > 0 ? vx >= ROW_HEADER_WIDTH + frozenWidth : true; const addScrollY = frozenRows > 0 || frozenCols > 0 ? vy >= COL_HEADER_HEIGHT + frozenHeight : true; const x = vx + (addScrollX ? scrollEl.scrollLeft : 0); const y = vy + (addScrollY ? scrollEl.scrollTop : 0); if (x < ROW_HEADER_WIDTH || y < COL_HEADER_HEIGHT) return null; const bodyX = x - ROW_HEADER_WIDTH; const bodyY = y - COL_HEADER_HEIGHT; let col = 0; for (let c = 0; c < cols; c++) { if (colPositions[c + 1] > bodyX) { col = c; break; } col = c; } let row = 0; for (let r = 0; r < TOTAL_ROWS; r++) { if (rowPositions[r + 1] > bodyY) { row = r; break; } row = r; } const mergeInfo = mergeMap.get(cellId(row, col)); if (mergeInfo?.range) { return mergeInfo.range.start; } return { row, col }; }, [colPositions, rowPositions, cols, mergeMap, frozenRows, frozenCols, frozenWidth] ); const getRunLinkAtMouse = useCallback( (clientX: number, clientY: number, cellPos: CellPosition): string | undefined => { const scrollEl = scrollRef.current; const container = containerRef.current; if (!scrollEl || !container) return undefined; const cell = sheet.cells?.[cellId(cellPos.row, cellPos.col)]; if (!cell?.richText || cell.richText.length === 0) return undefined; if (!cell.richText.some((rr) => rr.link)) return undefined; const cRect = container.getBoundingClientRect(); const vx = clientX - cRect.left; const vy = clientY - cRect.top; const addScrollX = frozenRows > 0 || frozenCols > 0 ? vx >= ROW_HEADER_WIDTH + frozenWidth : true; const addScrollY = frozenRows > 0 || frozenCols > 0 ? vy >= COL_HEADER_HEIGHT + frozenHeight : true; const bodyX = vx + (addScrollX ? scrollEl.scrollLeft : 0) - ROW_HEADER_WIDTH; const bodyY = vy + (addScrollY ? scrollEl.scrollTop : 0) - COL_HEADER_HEIGHT; const cellX = colPositions[cellPos.col]; const cellY = rowPositions[cellPos.row]; const cellW = (colPositions[cellPos.col + 1] ?? cellX + DEFAULT_COL_WIDTH) - cellX; const cellH = (rowPositions[cellPos.row + 1] ?? cellY + DEFAULT_ROW_HEIGHT) - cellY; return hitTestRunLink(cell.richText, bodyX - cellX, bodyY - cellY, cellW, cellH, cell.style); }, [sheet.cells, colPositions, rowPositions, frozenRows, frozenCols, frozenWidth], ); const isSelectingRef = useRef(false); const dragAnchorRef = useRef<CellPosition | null>(null); const autoScrollFrameRef = useRef<number | null>(null); const lastMouseRef = useRef({ x: 0, y: 0 }); const getCellFromEventRef = useRef(getCellFromEvent); const getHeaderFromEventRef = useRef(getHeaderFromEvent); const getRunLinkAtMouseRef = useRef(getRunLinkAtMouse); getCellFromEventRef.current = getCellFromEvent; getHeaderFromEventRef.current = getHeaderFromEvent; getRunLinkAtMouseRef.current = getRunLinkAtMouse; const openContextMenuForHeader = useCallback( (header: HeaderHit) => { const headerSource = header.type === 'row' ? { type: 'row' as const, index: header.row } : { type: 'col' as const, index: header.col }; if (header.type === 'row') { if (onSelectCell) onSelectCell({ row: header.row, col: 0 }); if (onSelectRange) onSelectRange({ start: { row: header.row, col: 0 }, end: { row: header.row, col: MAX_TOTAL_COLS - 1 } }); const rect = getHeaderRect(header); if (rect) setContextMenu({ x: rect.right + 5, y: rect.top, row: header.row, col: 0, headerSource }); else setContextMenu({ x: 0, y: 0, row: header.row, col: 0, headerSource }); } else { if (onSelectCell) onSelectCell({ row: 0, col: header.col }); if (onSelectRange) onSelectRange({ start: { row: 0, col: header.col }, end: { row: TOTAL_ROWS - 1, col: header.col } }); const rect = getHeaderRect(header); if (rect) setContextMenu({ x: rect.right + 5, y: rect.top, row: 0, col: header.col, headerSource }); else setContextMenu({ x: 0, y: 0, row: 0, col: header.col, headerSource }); } }, [onSelectCell, onSelectRange, getHeaderRect] ); const handleCanvasMouseDown = useCallback( (e: React.MouseEvent<HTMLCanvasElement>) => { if (e.button !== 0) return; const zone = getResizeZone(e.clientX, e.clientY); if (zone) { const scrollEl = scrollRef.current; const container = containerRef.current; if (!scrollEl || !container) return; const rect = container.getBoundingClientRect(); const contentX = e.clientX - rect.left + scrollEl.scrollLeft; const contentY = e.clientY - rect.top + scrollEl.scrollTop; if (zone.type === 'col' && onColumnResize) { const startSize = getColWidth(zone.col); setResizeState({ type: 'col', index: zone.col, startX: contentX, startY: contentY, startSize }); } else if (zone.type === 'row' && onRowResize) { const startSize = getRowHeight(zone.row); setResizeState({ type: 'row', index: zone.row, startX: contentX, startY: contentY, startSize }); } return; } const header = getHeaderFromEvent(e.clientX, e.clientY); if (header && !readOnly) { openContextMenuForHeader(header); return; } const cell = getCellFromEvent(e.clientX, e.clientY); if (!cell) { onClearSelection?.(); return; } // Ctrl/Cmd+Click открывает ссылку if ((e.ctrlKey || e.metaKey) && !e.shiftKey) { const cellLink = sheet.cells?.[cellId(cell.row, cell.col)]?.link; const runLink = getRunLinkAtMouse(e.clientX, e.clientY, cell); const link = cellLink || runLink; if (link) { e.preventDefault(); window.open(link, '_blank', 'noopener'); return; } } if (isEditing && selectedCell && cell.row === selectedCell.row && cell.col === selectedCell.col) { return; } if (e.shiftKey && selectedCell && onSelectRange) { onSelectRange({ start: selectedCell, end: cell }); } else if (onSelectCell) { onSelectCell(cell); isSelectingRef.current = true; dragAnchorRef.current = cell; } }, [sheet.cells, getCellFromEvent, getRunLinkAtMouse, getHeaderFromEvent, getResizeZone, getColWidth, getRowHeight, selectedCell, isEditing, onSelectCell, onSelectRange, onColumnResize, onRowResize, readOnly, openContextMenuForHeader, onClearSelection] ); useEffect(() => { const EDGE_ZONE = 40; const BASE_SPEED = 12; const stopAutoScroll = () => { if (autoScrollFrameRef.current !== null) { cancelAnimationFrame(autoScrollFrameRef.current); autoScrollFrameRef.current = null; } }; // Вычисляет скорость авто-прокрутки: чем дальше курсор за край, тем быстрее const calcSpeed = (overshoot: number) => Math.min(BASE_SPEED + Math.floor(Math.abs(overshoot) / 20) * 4, 60); const updateSelectionFromMouse = () => { const cell = getCellFromEventRef.current(lastMouseRef.current.x, lastMouseRef.current.y); if (cell && dragAnchorRef.current && onSelectRange) { onSelectRange({ start: dragAnchorRef.current, end: cell }); } }; const startAutoScroll = (getDelta: () => { dx: number; dy: number }) => { stopAutoScroll(); const tick = () => { if (!isSelectingRef.current || !scrollRef.current) return; const { dx, dy } = getDelta(); if (dx === 0 && dy === 0) { autoScrollFrameRef.current = null; return; } scrollRef.current.scrollLeft += dx; scrollRef.current.scrollTop += dy; updateSelectionFromMouse(); autoScrollFrameRef.current = requestAnimationFrame(tick); }; autoScrollFrameRef.current = requestAnimationFrame(tick); }; const handleMouseMove = (e: MouseEvent) => { if (!isSelectingRef.current || !dragAnchorRef.current || !onSelectRange) return; lastMouseRef.current = { x: e.clientX, y: e.clientY }; updateSelectionFromMouse(); const container = containerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); const getDelta = () => { const { x, y } = lastMouseRef.current; let dx = 0, dy = 0; if (y > rect.bottom - EDGE_ZONE) dy = calcSpeed(y - (rect.bottom - EDGE_ZONE)); else if (y < rect.top + COL_HEADER_HEIGHT + EDGE_ZONE) dy = -calcSpeed((rect.top + COL_HEADER_HEIGHT + EDGE_ZONE) - y); if (x > rect.right - EDGE_ZONE) dx = calcSpeed(x - (rect.right - EDGE_ZONE)); else if (x < rect.left + ROW_HEADER_WIDTH + EDGE_ZONE) dx = -calcSpeed((rect.left + ROW_HEADER_WIDTH + EDGE_ZONE) - x); return { dx, dy }; }; const { dx, dy } = getDelta(); if (dx !== 0 || dy !== 0) { startAutoScroll(getDelta); } else { stopAutoScroll(); } }; const handleMouseUp = () => { isSelectingRef.current = false; dragAnchorRef.current = null; stopAutoScroll(); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); stopAutoScroll(); }; }, [onSelectRange]); const getResizeZoneRef = useRef(getResizeZone); getResizeZoneRef.current = getResizeZone; useEffect(() => { if (!resizeState) return; document.body.style.userSelect = 'none'; document.body.style.cursor = resizeState.type === 'col' ? 'col-resize' : 'row-resize'; const { type, index, startX, startY, startSize } = resizeState; const handleResizeMove = (e: MouseEvent) => { const scrollEl = scrollRef.current; const container = containerRef.current; if (!scrollEl || !container) return; const rect = container.getBoundingClientRect(); const contentX = e.clientX - rect.left + scrollEl.scrollLeft; const contentY = e.clientY - rect.top + scrollEl.scrollTop; if (type === 'col' && onColumnResize) { const delta = contentX - startX; const newWidth = Math.max(MIN_COL_WIDTH, startSize + delta); onColumnResize(index, newWidth); } else if (type === 'row' && onRowResize) { const delta = contentY - startY; const newHeight = Math.max(MIN_ROW_HEIGHT, startSize + delta); onRowResize(index, newHeight); } }; const handleResizeUp = () => { document.body.style.userSelect = ''; document.body.style.cursor = ''; setResizeState(null); }; document.addEventListener('mousemove', handleResizeMove); document.addEventListener('mouseup', handleResizeUp); return () => { document.removeEventListener('mousemove', handleResizeMove); document.removeEventListener('mouseup', handleResizeUp); }; }, [resizeState, onColumnResize, onRowResize]); const handleCanvasMouseMove = useCallback( (e: React.MouseEvent<HTMLCanvasElement>) => { if (resizeState) return; const zone = getResizeZoneRef.current(e.clientX, e.clientY); setHoverResizeZone(zone); if (zone) { setHoverLinkCell(false); clearTimeout(linkTooltipTimeoutRef.current); setHoverLinkTooltip(null); return; } const cell = getCellFromEventRef.current(e.clientX, e.clientY); const cellLink = cell ? sheet.cells?.[cellId(cell.row, cell.col)]?.link : undefined; const runLink = cell ? getRunLinkAtMouseRef.current(e.clientX, e.clientY, cell) : undefined; const link = cellLink || runLink; const hasLink = !!link; setHoverLinkCell(hasLink); linkTooltipMouseRef.current = { x: e.clientX, y: e.clientY }; if (hasLink && link) { if (hoverLinkTooltip) { setHoverLinkTooltip((prev) => (prev ? { ...prev, x: e.clientX, y: e.clientY } : null)); } else { clearTimeout(linkTooltipTimeoutRef.current); linkTooltipTimeoutRef.current = setTimeout(() => { const { x, y } = linkTooltipMouseRef.current; setHoverLinkTooltip({ url: link, x, y }); }, 400); } } else { clearTimeout(linkTooltipTimeoutRef.current); setHoverLinkTooltip(null); } if (onHoverCommentCell) { const commentKey = cell ? `${sheet.id}:${cell.row}:${cell.col}` : null; const hasComment = commentKey ? commentCells?.has(commentKey) : false; const hoveredKey = hasComment ? commentKey : null; if (hoveredKey !== lastHoveredCommentKeyRef.current) { lastHoveredCommentKeyRef.current = hoveredKey; if (hoveredKey && cell) { const anchorRect = getCellRect(cell.row, cell.col); if (anchorRect) onHoverCommentCell({ row: cell.row, col: cell.col, anchorRect }); } else { onHoverCommentCell(null); } } } }, [resizeState, onHoverCommentCell, commentCells, sheet.id, sheet.cells, getCellRect, hoverLinkTooltip] ); const handleCanvasMouseLeave = useCallback(() => { if (!resizeState) { setHoverResizeZone(null); setHoverLinkCell(false); clearTimeout(linkTooltipTimeoutRef.current); setHoverLinkTooltip(null); } if (lastHoveredCommentKeyRef.current) { lastHoveredCommentKeyRef.current = null; onHoverCommentCell?.(null); } }, [resizeState, onHoverCommentCell]); const handleContextMenu = useCallback( (e: React.MouseEvent<HTMLCanvasElement>) => { e.preventDefault(); const zone = getResizeZoneRef.current(e.clientX, e.clientY); if (zone) return; const header = getHeaderFromEventRef.current(e.clientX, e.clientY); if (header && !readOnly) { openContextMenuForHeader(header); return; } const cell = getCellFromEventRef.current(e.clientX, e.clientY); if (!cell) return; if (onSelectCell && (!selectedCell || selectedCell.row !== cell.row || selectedCell.col !== cell.col)) { onSelectCell(cell); } const rect = getCellRect(cell.row, cell.col); if (rect) { setContextMenu({ x: rect.right + 5, y: rect.top, row: cell.row, col: cell.col }); } else { setContextMenu({ x: e.clientX, y: e.clientY, row: cell.row, col: cell.col }); } }, [onSelectCell, selectedCell, getCellRect, readOnly, openContextMenuForHeader] ); const contextMenuItems: ContextMenuItem[] = contextMenu && !readOnly ? [ { label: 'Вставить строку сверху', icon: <RiInsertRowTop size={16} />, action: () => onInsertRow?.(contextMenu.row) }, { label: 'Вставить строку снизу', icon: <RiInsertRowBottom size={16} />, action: () => onInsertRow?.(contextMenu.row + 1) }, { label: 'Вставить столбец слева', icon: <RiInsertColumnLeft size={16} />, action: () => onInsertColumn?.(contextMenu.col) }, ...(clipboard?.range && onPasteRows ? [ { label: '', action: () => {}, separator: true }, { label: 'Вставить скопированные строки сверху', icon: <RiClipboardLine size={16} />, action: () => onPasteRows(contextMenu.row, 'above') }, { label: 'Вставить скопированные строки снизу', icon: <RiClipboardLine size={16} />, action: () => onPasteRows(contextMenu.row, 'below') }, ] : []), { label: '', action: () => {}, separator: true }, { label: 'Удалить строку', icon: <RiDeleteRow size={16} />, action: () => onDeleteRow?.(contextMenu.row) }, { label: 'Удалить столбец', icon: <RiDeleteColumn size={16} />, action: () => onDeleteColumn?.(contextMenu.col) }, ...(onOpenComments ? [ { label: '', action: () => {}, separator: true }, { label: 'Добавить комментарий', icon: <RiChat3Line size={16} />, action: () => { const rect = getCellRect(contextMenu.row, contextMenu.col); if (rect) { setContextMenu(null); onOpenComments(contextMenu.row, contextMenu.col, rect); } } }, ] : []), ...(!contextMenu.headerSource && onInsertLink ? (() => { const mergeInfo = mergeMap.get(cellId(contextMenu.row, contextMenu.col)); const origin = mergeInfo?.range?.start ?? { row: contextMenu.row, col: contextMenu.col }; const cellLink = sheet.cells?.[cellId(origin.row, origin.col)]?.link; return [ { label: '', action: () => {}, separator: true }, { label: cellLink ? 'Редактировать ссылку' : 'Вставить ссылку', icon: <RiLink size={16} />, action: () => { setContextMenu(null); onInsertLink(); } }, ...(cellLink ? [ { label: 'Открыть ссылку', action: () => { setContextMenu(null); window.open(cellLink, '_blank', 'noopener'); } }, { label: 'Удалить ссылку', action: () => { setContextMenu(null); onRemoveLink?.(); } }, ] : []), ]; })() : []), ] : []; const handleCanvasDoubleClick = useCallback( (e: React.MouseEvent<HTMLCanvasElement>) => { const cell = getCellFromEvent(e.clientX, e.clientY); if (!cell) return; const originRow = cell.row; const originCol = cell.col; if (!readOnly && onStartEditing && selectedCell && selectedCell.row === originRow && selectedCell.col === originCol) { onStartEditing(); return; } const hasComment = commentCells?.has(`${sheet.id}:${originRow}:${originCol}`); const anchorRect = getCellRect(originRow, originCol); if (onOpenComments && anchorRect && hasComment) { onOpenComments(originRow, originCol, anchorRect); } }, [getCellFromEvent, getCellRect, onOpenComments, onStartEditing, selectedCell, readOnly, commentCells, sheet.id] ); const handleWheel = useCallback((e: WheelEvent) => { const el = scrollRef.current; if (!el) return; e.preventDefault(); let dx = e.deltaX; let dy = e.deltaY; if (e.shiftKey && Math.abs(dy) > Math.abs(dx)) { dx = dy; dy = 0; } el.scrollLeft += dx; el.scrollTop += dy; }, []); useEffect(() => { const container = containerRef.current; if (!container) return; container.addEventListener('wheel', handleWheel, { passive: false, capture: true }); return () => container.removeEventListener('wheel', handleWheel, true); }, [handleWheel]); const cursor = resizeState?.type === 'col' ? 'col-resize' : resizeState?.type === 'row' ? 'row-resize' : hoverResizeZone?.type === 'col' ? 'col-resize' : hoverResizeZone?.type === 'row' ? 'row-resize' : hoverLinkCell ? 'pointer' : 'default'; const handleContainerMouseLeave = useCallback(() => { if (!resizeState) { setHoverResizeZone(null); setHoverLinkCell(false); clearTimeout(linkTooltipTimeoutRef.current); setHoverLinkTooltip(null); } }, [resizeState]); useEffect(() => () => clearTimeout(linkTooltipTimeoutRef.current), []); return ( <div ref={containerRef} onMouseLeave={handleContainerMouseLeave} style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden', cursor, }} > <div ref={scrollRef} className="grid-canvas-scroll" style={{ position: 'absolute', left: ROW_HEADER_WIDTH + frozenWidth, top: COL_HEADER_HEIGHT + frozenHeight, right: 0, bottom: 0, overflow: 'auto', overscrollBehaviorX: 'none', overscrollBehaviorY: 'none', touchAction: 'pan-x pan-y', }} > <div style={{ width: scrollableWidth, height: scrollableHeight, }} /> </div> <canvas ref={canvasRef} onMouseDown={handleCanvasMouseDown} onMouseMove={handleCanvasMouseMove} onMouseLeave={handleCanvasMouseLeave} onDoubleClick={handleCanvasDoubleClick} onContextMenu={handleContextMenu} style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'auto', backgroundColor: CELL_BG, }} /> {contextMenu && contextMenuItems.length > 0 && ( <ContextMenu x={contextMenu.x} y={contextMenu.y} items={contextMenuItems} onClose={() => setContextMenu(null)} /> )} {hoverLinkTooltip && createPortal( <div className="tooltip tooltip-link" style={{ top: hoverLinkTooltip.y - 8, left: hoverLinkTooltip.x, }} > Ctrl+Click чтобы открыть <br /> <span className="tooltip-link-url"> {hoverLinkTooltip.url.length > 60 ? `${hoverLinkTooltip.url.slice(0, 57)}…` : hoverLinkTooltip.url} </span> </div>, document.body, )} </div> ); }); GridCanvas.displayName = 'GridCanvas';