From 32e5cda438b0f182c2a39142bc9247c6a067c0c6 Mon Sep 17 00:00:00 2001 From: tlartem Date: Thu, 24 Jul 2025 05:10:56 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BA=D0=BE=D0=BF=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DualSpreadsheet/DualSpreadsheet.tsx | 216 +++++++++++++++++- .../DualSpreadsheet/components/Cell.tsx | 13 ++ .../components/CellRenderer.tsx | 12 + .../components/Spreadsheet.tsx | 2 + src/component/DualSpreadsheet/types.ts | 12 + .../TemplateManager/TemplateManager.tsx | 10 - 6 files changed, 252 insertions(+), 13 deletions(-) diff --git a/src/component/DualSpreadsheet/DualSpreadsheet.tsx b/src/component/DualSpreadsheet/DualSpreadsheet.tsx index 57f84e5..6fc008b 100644 --- a/src/component/DualSpreadsheet/DualSpreadsheet.tsx +++ b/src/component/DualSpreadsheet/DualSpreadsheet.tsx @@ -101,6 +101,13 @@ const DualSpreadsheet: FC = ({ [] ) + // Состояние для копирования/вставки + const [copiedCells, setCopiedCells] = useState<{ + data: { row: number; col: number; value: string; rawValue: string }[] + sourceSheet: SheetType + isCut: boolean + } | null>(null) + // React Query хуки для оптимизации API запросов const { data: latestFile, isLoading: isLoadingLatestFile } = useLatestFileForTemplate(templateId) @@ -354,6 +361,24 @@ const DualSpreadsheet: FC = ({ } }, [isEditing, editingValue, updateCellHighlights]) + // Сбрасываем скопированные ячейки при смене активного листа + useEffect(() => { + if (copiedCells) { + setCopiedCells(null) + } + }, [activeSheet]) + + // Автоматический сброс скопированных ячеек через 30 секунд + useEffect(() => { + if (!copiedCells) return + + const timeout = setTimeout(() => { + setCopiedCells(null) + }, 30000) // 30 секунд + + return () => clearTimeout(timeout) + }, [copiedCells]) + const startEditing = useCallback(() => { if (!selectedCell) return // Сбрасываем множественное выделение при начале редактирования @@ -427,6 +452,11 @@ const DualSpreadsheet: FC = ({ stopEditing(true) } + // Сбрасываем скопированные ячейки при навигации + if (copiedCells) { + setCopiedCells(null) + } + const newRow = Math.max( 0, Math.min(ROW_COUNT - 1, selectedCell.row + deltaRow) @@ -440,7 +470,7 @@ const DualSpreadsheet: FC = ({ // Сбрасываем множественное выделение при навигации setMultiSelection([]) }, - [selectedCell, isEditing, stopEditing] + [selectedCell, isEditing, stopEditing, copiedCells] ) // Функция для очистки множества выделенных ячеек @@ -460,6 +490,152 @@ const DualSpreadsheet: FC = ({ setMultiSelection([]) }, [multiSelection, activeSheet, multiSheetEngine, updateRevision]) + // Функция копирования выделенных ячеек + const handleCopy = useCallback( + async (isCut = false) => { + if (!selectedCell && multiSelection.length === 0) return + + const sheetName = SHEET_NAMES[activeSheet] + let cellsToCopy: { row: number; col: number }[] = [] + + // Определяем какие ячейки копировать + if (multiSelection.length > 0) { + cellsToCopy = getAllCellsInSelections(multiSelection) + } else if (selectedCell) { + cellsToCopy = [selectedCell] + } + + if (cellsToCopy.length === 0) return + + // Собираем данные ячеек + const copiedData = cellsToCopy.map(({ row, col }) => ({ + row, + col, + value: String(multiSheetEngine.getCellValue(sheetName, row, col) || ''), + rawValue: String( + multiSheetEngine.getCellRawValue(sheetName, row, col) || '' + ), + })) + + // Сохраняем в state + setCopiedCells({ + data: copiedData, + sourceSheet: activeSheet, + isCut, + }) + + // Подготавливаем данные для буфера обмена в формате TSV (Tab-Separated Values) + // Находим границы выделения + const minRow = Math.min(...cellsToCopy.map(c => c.row)) + const maxRow = Math.max(...cellsToCopy.map(c => c.row)) + const minCol = Math.min(...cellsToCopy.map(c => c.col)) + const maxCol = Math.max(...cellsToCopy.map(c => c.col)) + + // Создаем двумерный массив для TSV + const rows: string[][] = [] + for (let row = minRow; row <= maxRow; row++) { + const rowData: string[] = [] + for (let col = minCol; col <= maxCol; col++) { + const cellData = copiedData.find(c => c.row === row && c.col === col) + rowData.push(cellData ? String(cellData.value) : '') + } + rows.push(rowData) + } + + // Конвертируем в TSV строку + const tsvData = rows.map(row => row.join('\t')).join('\n') + + // Копируем в буфер обмена + try { + await navigator.clipboard.writeText(tsvData) + + // Если это вырезание, очищаем ячейки + if (isCut) { + cellsToCopy.forEach(({ row, col }) => { + multiSheetEngine.setCellValueWithoutRecalc(sheetName, row, col, '') + }) + setUserEdited(true) + updateRevision() + multiSheetEngine.debouncedRecalc() + } + } catch (error) { + console.error('Ошибка при копировании в буфер обмена:', error) + } + }, + [ + selectedCell, + multiSelection, + activeSheet, + multiSheetEngine, + updateRevision, + ] + ) + + // Функция вставки из буфера обмена + const handlePaste = useCallback(async () => { + if (!selectedCell) return + + try { + const clipboardText = await navigator.clipboard.readText() + + if (!clipboardText.trim()) return + + const sheetName = SHEET_NAMES[activeSheet] + const startRow = selectedCell.row + const startCol = selectedCell.col + + // Парсим TSV/CSV данные + const rows = clipboardText.split('\n').filter(row => row !== '') + const data: string[][] = rows.map(row => { + // Поддерживаем как табуляции (TSV), так и запятые (CSV) + if (row.includes('\t')) { + return row.split('\t') + } else { + // Простой парсинг CSV (без учета кавычек с запятыми внутри) + return row.split(',') + } + }) + + if (data.length === 0) return + + // Вставляем данные + let hasChanges = false + for (let rowIndex = 0; rowIndex < data.length; rowIndex++) { + for (let colIndex = 0; colIndex < data[rowIndex].length; colIndex++) { + const targetRow = startRow + rowIndex + const targetCol = startCol + colIndex + + // Проверяем границы + if (targetRow >= ROW_COUNT || targetCol >= COL_COUNT) continue + + const cellValue = data[rowIndex][colIndex] + + // Устанавливаем значение ячейки + multiSheetEngine.setCellValueWithoutRecalc( + sheetName, + targetRow, + targetCol, + cellValue + ) + hasChanges = true + } + } + + if (hasChanges) { + setUserEdited(true) + updateRevision() + multiSheetEngine.debouncedRecalc() + } + + // Очищаем состояние копирования если это была операция вырезания + if (copiedCells?.isCut) { + setCopiedCells(null) + } + } catch (error) { + console.error('Ошибка при вставке из буфера обмена:', error) + } + }, [selectedCell, activeSheet, multiSheetEngine, updateRevision, copiedCells]) + const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (isEditing) return @@ -519,6 +695,27 @@ const DualSpreadsheet: FC = ({ return } + // Ctrl+C - копировать + if (e.key === 'c' && (e.ctrlKey || e.metaKey)) { + e.preventDefault() + handleCopy(false) + return + } + + // Ctrl+X - вырезать + if (e.key === 'x' && (e.ctrlKey || e.metaKey)) { + e.preventDefault() + handleCopy(true) + return + } + + // Ctrl+V - вставить + if (e.key === 'v' && (e.ctrlKey || e.metaKey)) { + e.preventDefault() + handlePaste() + return + } + if (keyHandlers[e.key]) { e.preventDefault() keyHandlers[e.key]() @@ -538,6 +735,8 @@ const DualSpreadsheet: FC = ({ handleClearCell, multiSelection, handleClearMultipleCells, + handleCopy, + handlePaste, ] ) @@ -547,6 +746,11 @@ const DualSpreadsheet: FC = ({ stopEditing(true) } + // Сбрасываем скопированные ячейки при клике на новую ячейку + if (copiedCells) { + setCopiedCells(null) + } + // Если удерживается Ctrl (или Cmd на Mac), добавляем к множественному выделению if (event && (event.ctrlKey || event.metaKey)) { const newSelection = createSelectionFromRange(row, col, row, col) @@ -572,7 +776,7 @@ const DualSpreadsheet: FC = ({ setSelectedCell({ row, col }) containerRefs.current[activeSheet]?.focus({ preventScroll: true }) }, - [isEditing, stopEditing, activeSheet, selectedCell] + [isEditing, stopEditing, activeSheet, selectedCell, copiedCells] ) // Новый обработчик для начала drag selection @@ -584,12 +788,17 @@ const DualSpreadsheet: FC = ({ // Если удерживаются модификаторы, не начинаем drag if (event.ctrlKey || event.metaKey || event.shiftKey) return + // Сбрасываем скопированные ячейки при drag selection + if (copiedCells) { + setCopiedCells(null) + } + setIsDragging(true) setDragStart({ row, col }) setMultiSelection([]) setSelectedCell({ row, col }) }, - [] + [copiedCells] ) // Обработчик для drag selection @@ -983,6 +1192,7 @@ const DualSpreadsheet: FC = ({ onGridScroll={onGridScroll} handleColumnResize={handleColumnResize} mergedCells={mergedCells} + copiedCells={copiedCells} /> {index === 0 && (
{ // Если ячейка объединена, но не является основной, не рендерим её if (mergedCellInfo?.isMerged && !mergedCellInfo.isMainCell) { @@ -67,6 +69,17 @@ export const Cell = memo( } } + // Стили для скопированных/вырезанных ячеек + if (isCopied || isCut) { + cellStyle = { + ...cellStyle, + border: '2px dashed hsl(var(--primary))', + backgroundColor: isCut + ? 'rgba(239, 68, 68, 0.1)' + : 'rgba(34, 197, 94, 0.1)', // красноватый для вырезанных, зеленоватый для скопированных + } + } + // Отладка стилей для измененных ячеек if (isModified) { // console.log(`🎨 Стили для измененной ячейки ${rowIndex},${colIndex}:`, //{ diff --git a/src/component/DualSpreadsheet/components/CellRenderer.tsx b/src/component/DualSpreadsheet/components/CellRenderer.tsx index 1d95d22..ee9257f 100644 --- a/src/component/DualSpreadsheet/components/CellRenderer.tsx +++ b/src/component/DualSpreadsheet/components/CellRenderer.tsx @@ -20,6 +20,7 @@ export const CellRenderer = ({ cachedData, mergedCells, highlightedCells, + copiedCells, handleCellClick, handleCellDoubleClick, handleCellChange, @@ -121,6 +122,15 @@ export const CellRenderer = ({ } : undefined + // Проверяем, является ли ячейка скопированной + const isCopied = + copiedCells && + copiedCells.sourceSheet === sheetType && + copiedCells.data.some( + cell => cell.row === rowIndex && cell.col === columnIndex + ) + const isCut = isCopied && copiedCells?.isCut + return ( ) } diff --git a/src/component/DualSpreadsheet/components/Spreadsheet.tsx b/src/component/DualSpreadsheet/components/Spreadsheet.tsx index 7a4b843..daabf55 100644 --- a/src/component/DualSpreadsheet/components/Spreadsheet.tsx +++ b/src/component/DualSpreadsheet/components/Spreadsheet.tsx @@ -38,6 +38,7 @@ export const Spreadsheet = ({ onGridScroll, handleColumnResize, mergedCells, + copiedCells, }: SpreadsheetProps) => { const widths = columnWidths const cachedData = getCachedCellData(sheetType) @@ -53,6 +54,7 @@ export const Spreadsheet = ({ cachedData, mergedCells, highlightedCells, + copiedCells, multiSelectionActive: activeSheet === sheetType && multiSelection.length > 0, handleCellClick, diff --git a/src/component/DualSpreadsheet/types.ts b/src/component/DualSpreadsheet/types.ts index c16ec7d..c35cc0b 100644 --- a/src/component/DualSpreadsheet/types.ts +++ b/src/component/DualSpreadsheet/types.ts @@ -98,6 +98,8 @@ export interface CellProps { rowSpan?: number colSpan?: number } + isCopied?: boolean // Является ли ячейка скопированной + isCut?: boolean // Является ли ячейка вырезанной highlightColor?: string // Цвет подсветки для формул } @@ -153,6 +155,11 @@ export interface CellRendererData { stopEditing: (save: boolean) => void setActiveSheet: (type: SheetType) => void activeCellInputRef: React.RefObject + copiedCells?: { + data: { row: number; col: number; value: string; rawValue: string }[] + sourceSheet: SheetType + isCut: boolean + } | null } export interface CellRendererProps { @@ -211,6 +218,11 @@ export interface SpreadsheetProps { newWidth: number ) => void mergedCells?: MergedCell[] // Добавляем поддержку объединенных ячеек + copiedCells?: { + data: { row: number; col: number; value: string; rawValue: string }[] + sourceSheet: SheetType + isCut: boolean + } | null } export interface MergedCellInfo { diff --git a/src/component/TemplateManager/TemplateManager.tsx b/src/component/TemplateManager/TemplateManager.tsx index cd65bd3..bc75cb0 100644 --- a/src/component/TemplateManager/TemplateManager.tsx +++ b/src/component/TemplateManager/TemplateManager.tsx @@ -83,16 +83,6 @@ export const TemplateManager: React.FC = ({ } } - // const handleTemplateUpdate = async (updatedTemplate: Template) => { - // try { - // await updateTemplate(updatedTemplate) - // setSelectedTemplate(updatedTemplate) - // } catch (err) { - // console.error('Error updating template:', err) - // alert('Ошибка при обновлении шаблона. Попробуйте еще раз.') - // } - // } - const handleDeleteTemplates = () => { templatesToDelete.forEach(t => deleteTemplate(t.id)) setTemplatesToDelete([])