"use client"; import React, { useState, useRef, useEffect, useCallback } from "react"; import { cn } from "@/lib/utils"; interface EditableSpreadsheetProps { columns: string[]; data: Record[]; onColumnsChange: (columns: string[]) => void; onDataChange: (data: Record[]) => void; maxHeight?: string; } // 셀 범위 정의 interface CellRange { startRow: number; startCol: number; endRow: number; endCol: number; } /** * 엑셀처럼 편집 가능한 스프레드시트 컴포넌트 * - 셀 클릭으로 편집 * - Tab/Enter로 다음 셀 이동 * - 마지막 행/열에서 자동 추가 * - 헤더(컬럼명)도 편집 가능 * - 다중 셀 선택 (드래그) * - 자동 채우기 (드래그 핸들) - 다중 셀 지원 */ export const EditableSpreadsheet: React.FC = ({ columns, data, onColumnsChange, onDataChange, maxHeight = "350px", }) => { // 현재 편집 중인 셀 (row: -1은 헤더) const [editingCell, setEditingCell] = useState<{ row: number; col: number; } | null>(null); const [editValue, setEditValue] = useState(""); // 선택 범위 (다중 셀 선택) const [selection, setSelection] = useState(null); // 셀 선택 드래그 중 const [isDraggingSelection, setIsDraggingSelection] = useState(false); // 자동 채우기 드래그 상태 const [isDraggingFill, setIsDraggingFill] = useState(false); const [fillPreviewEnd, setFillPreviewEnd] = useState(null); // 복사된 범위 (점선 애니메이션 표시용) const [copiedRange, setCopiedRange] = useState(null); // Undo/Redo 히스토리 interface HistoryState { columns: string[]; data: Record[]; } const [history, setHistory] = useState([]); const [historyIndex, setHistoryIndex] = useState(-1); const [isUndoRedo, setIsUndoRedo] = useState(false); const inputRef = useRef(null); const tableRef = useRef(null); // 히스토리에 현재 상태 저장 const saveToHistory = useCallback(() => { if (isUndoRedo) return; const newState: HistoryState = { columns: [...columns], data: data.map(row => ({ ...row })), }; setHistory(prev => { // 현재 인덱스 이후의 히스토리는 삭제 (새로운 분기) const newHistory = prev.slice(0, historyIndex + 1); newHistory.push(newState); // 최대 50개까지만 유지 if (newHistory.length > 50) { newHistory.shift(); return newHistory; } return newHistory; }); setHistoryIndex(prev => Math.min(prev + 1, 49)); }, [columns, data, historyIndex, isUndoRedo]); // 초기 상태 저장 useEffect(() => { if (history.length === 0 && (columns.length > 0 || data.length > 0)) { setHistory([{ columns: [...columns], data: data.map(row => ({ ...row })) }]); setHistoryIndex(0); } }, []); // 데이터 변경 시 히스토리 저장 (Undo/Redo가 아닌 경우) useEffect(() => { if (!isUndoRedo && historyIndex >= 0) { const currentState = history[historyIndex]; if (currentState) { const columnsChanged = JSON.stringify(columns) !== JSON.stringify(currentState.columns); const dataChanged = JSON.stringify(data) !== JSON.stringify(currentState.data); if (columnsChanged || dataChanged) { saveToHistory(); } } } setIsUndoRedo(false); }, [columns, data]); // Undo 실행 const handleUndo = useCallback(() => { if (historyIndex <= 0) return; const prevIndex = historyIndex - 1; const prevState = history[prevIndex]; if (prevState) { setIsUndoRedo(true); setHistoryIndex(prevIndex); onColumnsChange([...prevState.columns]); onDataChange(prevState.data.map(row => ({ ...row }))); } }, [history, historyIndex, onColumnsChange, onDataChange]); // Redo 실행 const handleRedo = useCallback(() => { if (historyIndex >= history.length - 1) return; const nextIndex = historyIndex + 1; const nextState = history[nextIndex]; if (nextState) { setIsUndoRedo(true); setHistoryIndex(nextIndex); onColumnsChange([...nextState.columns]); onDataChange(nextState.data.map(row => ({ ...row }))); } }, [history, historyIndex, onColumnsChange, onDataChange]); // 범위 정규화 (시작이 끝보다 크면 교환) const normalizeRange = (range: CellRange): CellRange => { return { startRow: Math.min(range.startRow, range.endRow), startCol: Math.min(range.startCol, range.endCol), endRow: Math.max(range.startRow, range.endRow), endCol: Math.max(range.startCol, range.endCol), }; }; // 셀이 선택 범위 내에 있는지 확인 const isCellInSelection = (row: number, col: number): boolean => { if (!selection) return false; const norm = normalizeRange(selection); return ( row >= norm.startRow && row <= norm.endRow && col >= norm.startCol && col <= norm.endCol ); }; // 셀이 선택 범위의 끝(우하단)인지 확인 const isCellSelectionEnd = (row: number, col: number): boolean => { if (!selection) return false; const norm = normalizeRange(selection); return row === norm.endRow && col === norm.endCol; }; // 셀 선택 시작 (클릭) const handleCellMouseDown = useCallback((row: number, col: number, e: React.MouseEvent) => { // 편집 중이면 종료 if (editingCell) { setEditingCell(null); setEditValue(""); } // 새 선택 시작 setSelection({ startRow: row, startCol: col, endRow: row, endCol: col, }); setIsDraggingSelection(true); // 테이블에 포커스 (키보드 이벤트 수신용) tableRef.current?.focus(); }, [editingCell]); // 셀 선택 드래그 중 const handleCellMouseEnter = useCallback((row: number, col: number) => { if (isDraggingSelection && selection) { setSelection((prev) => prev ? { ...prev, endRow: row, endCol: col, } : null); } }, [isDraggingSelection, selection]); // 셀 선택 드래그 종료 useEffect(() => { const handleMouseUp = () => { if (isDraggingSelection) { setIsDraggingSelection(false); } }; document.addEventListener("mouseup", handleMouseUp); return () => document.removeEventListener("mouseup", handleMouseUp); }, [isDraggingSelection]); // 셀 편집 시작 (더블클릭) const startEditing = useCallback( (row: number, col: number) => { setEditingCell({ row, col }); setSelection({ startRow: row, startCol: col, endRow: row, endCol: col, }); if (row === -1) { // 헤더 편집 setEditValue(columns[col] || ""); } else { // 데이터 셀 편집 const colName = columns[col]; setEditValue(String(data[row]?.[colName] ?? "")); } }, [columns, data] ); // 편집 완료 const finishEditing = useCallback(() => { if (!editingCell) return; const { row, col } = editingCell; if (row === -1) { // 헤더(컬럼명) 변경 const newColumns = [...columns]; const oldColName = newColumns[col]; const newColName = editValue.trim() || `Column${col + 1}`; if (oldColName !== newColName) { newColumns[col] = newColName; onColumnsChange(newColumns); // 데이터의 키도 함께 변경 const newData = data.map((rowData) => { const newRowData: Record = {}; Object.keys(rowData).forEach((key) => { if (key === oldColName) { newRowData[newColName] = rowData[key]; } else { newRowData[key] = rowData[key]; } }); return newRowData; }); onDataChange(newData); } } else { // 데이터 셀 변경 const colName = columns[col]; const newData = [...data]; if (!newData[row]) { newData[row] = {}; } newData[row] = { ...newData[row], [colName]: editValue }; onDataChange(newData); } setEditingCell(null); setEditValue(""); }, [editingCell, editValue, columns, data, onColumnsChange, onDataChange]); // 다음 셀로 이동 const moveToNextCell = useCallback( (direction: "right" | "down" | "left" | "up") => { if (!editingCell) return; finishEditing(); const { row, col } = editingCell; let nextRow = row; let nextCol = col; switch (direction) { case "right": if (col < columns.length - 1) { nextCol = col + 1; } else { // 마지막 열에서 Tab → 새 열 추가 (빈 헤더로) const tempColId = `__temp_${Date.now()}`; const newColumns = [...columns, ""]; onColumnsChange(newColumns); // 모든 행에 새 컬럼 추가 (임시 키 사용) const newData = data.map((rowData) => ({ ...rowData, [tempColId]: "", })); onDataChange(newData); nextCol = columns.length; } break; case "down": if (row === -1) { nextRow = 0; } else if (row < data.length - 1) { nextRow = row + 1; } else { // 마지막 행에서 Enter → 새 행 추가 const newRow: Record = {}; columns.forEach((c) => { newRow[c] = ""; }); onDataChange([...data, newRow]); nextRow = data.length; } break; case "left": if (col > 0) { nextCol = col - 1; } break; case "up": if (row > -1) { nextRow = row - 1; } break; } // 다음 셀 편집 시작 setTimeout(() => { startEditing(nextRow, nextCol); }, 0); }, [editingCell, columns, data, onColumnsChange, onDataChange, finishEditing, startEditing] ); // 키보드 이벤트 처리 const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { switch (e.key) { case "Tab": e.preventDefault(); moveToNextCell(e.shiftKey ? "left" : "right"); break; case "Enter": e.preventDefault(); moveToNextCell("down"); break; case "Escape": setEditingCell(null); setEditValue(""); break; case "ArrowUp": if (!e.shiftKey) { e.preventDefault(); moveToNextCell("up"); } break; case "ArrowDown": if (!e.shiftKey) { e.preventDefault(); moveToNextCell("down"); } break; case "ArrowLeft": // 커서가 맨 앞이면 왼쪽 셀로 if (inputRef.current?.selectionStart === 0) { e.preventDefault(); moveToNextCell("left"); } break; case "ArrowRight": // 커서가 맨 뒤면 오른쪽 셀로 if (inputRef.current?.selectionStart === editValue.length) { e.preventDefault(); moveToNextCell("right"); } break; } }, [moveToNextCell, editValue] ); // 편집 모드일 때 input에 포커스 useEffect(() => { if (editingCell && inputRef.current) { inputRef.current.focus(); inputRef.current.select(); } }, [editingCell]); // 외부 클릭 시 편집 종료 useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (tableRef.current && !tableRef.current.contains(e.target as Node)) { finishEditing(); setSelection(null); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [finishEditing]); // ============ 복사/붙여넣기 ============ // 셀이 복사 범위 내에 있는지 확인 const isCellInCopiedRange = (row: number, col: number): boolean => { if (!copiedRange) return false; const norm = normalizeRange(copiedRange); return ( row >= norm.startRow && row <= norm.endRow && col >= norm.startCol && col <= norm.endCol ); }; // 복사 범위의 테두리 위치 확인 const getCopiedBorderPosition = (row: number, col: number): { top: boolean; right: boolean; bottom: boolean; left: boolean } => { if (!copiedRange) return { top: false, right: false, bottom: false, left: false }; const norm = normalizeRange(copiedRange); if (!isCellInCopiedRange(row, col)) { return { top: false, right: false, bottom: false, left: false }; } return { top: row === norm.startRow, right: col === norm.endCol, bottom: row === norm.endRow, left: col === norm.startCol, }; }; // 선택 범위 복사 (Ctrl+C) const handleCopy = useCallback(async () => { if (!selection || editingCell) return; const norm = normalizeRange(selection); const rows: string[] = []; for (let r = norm.startRow; r <= norm.endRow; r++) { const rowValues: string[] = []; for (let c = norm.startCol; c <= norm.endCol; c++) { if (r === -1) { // 헤더 복사 rowValues.push(columns[c] || ""); } else { // 데이터 복사 const colName = columns[c]; rowValues.push(String(data[r]?.[colName] ?? "")); } } rows.push(rowValues.join("\t")); } const text = rows.join("\n"); try { await navigator.clipboard.writeText(text); // 복사 범위 저장 (점선 애니메이션 표시) setCopiedRange({ ...norm }); } catch (err) { console.warn("클립보드 복사 실패:", err); } }, [selection, editingCell, columns, data]); // 붙여넣기 (Ctrl+V) const handlePaste = useCallback(async () => { if (!selection || editingCell) return; try { const text = await navigator.clipboard.readText(); if (!text) return; const norm = normalizeRange(selection); const pasteRows = text.split(/\r?\n/).map((row) => row.split("\t")); // 빈 행 제거 const filteredRows = pasteRows.filter((row) => row.some((cell) => cell.trim() !== "")); if (filteredRows.length === 0) return; const newData = [...data]; const newColumns = [...columns]; let columnsChanged = false; for (let ri = 0; ri < filteredRows.length; ri++) { const pasteRow = filteredRows[ri]; const targetRow = norm.startRow + ri; for (let ci = 0; ci < pasteRow.length; ci++) { const targetCol = norm.startCol + ci; const value = pasteRow[ci]; if (targetRow === -1) { // 헤더에 붙여넣기 if (targetCol < newColumns.length) { newColumns[targetCol] = value; columnsChanged = true; } } else { // 데이터에 붙여넣기 if (targetCol < columns.length) { // 필요시 행 추가 while (newData.length <= targetRow) { const emptyRow: Record = {}; columns.forEach((c) => { emptyRow[c] = ""; }); newData.push(emptyRow); } const colName = columns[targetCol]; newData[targetRow] = { ...newData[targetRow], [colName]: value, }; } } } } if (columnsChanged) { onColumnsChange(newColumns); } onDataChange(newData); // 붙여넣기 범위로 선택 확장 setSelection({ startRow: norm.startRow, startCol: norm.startCol, endRow: Math.min(norm.startRow + filteredRows.length - 1, data.length - 1), endCol: Math.min(norm.startCol + (filteredRows[0]?.length || 1) - 1, columns.length - 1), }); // 붙여넣기 후 복사 범위 초기화 setCopiedRange(null); } catch (err) { console.warn("클립보드 붙여넣기 실패:", err); } }, [selection, editingCell, columns, data, onColumnsChange, onDataChange]); // Delete 키로 선택 범위 삭제 const handleDelete = useCallback(() => { if (!selection || editingCell) return; const norm = normalizeRange(selection); const newData = [...data]; for (let r = norm.startRow; r <= norm.endRow; r++) { if (r >= 0 && r < newData.length) { for (let c = norm.startCol; c <= norm.endCol; c++) { if (c < columns.length) { const colName = columns[c]; newData[r] = { ...newData[r], [colName]: "", }; } } } } onDataChange(newData); }, [selection, editingCell, columns, data, onDataChange]); // 전역 키보드 이벤트 (복사/붙여넣기/삭제) useEffect(() => { const handleGlobalKeyDown = (e: KeyboardEvent) => { // 편집 중이면 무시 (input에서 자체 처리) if (editingCell) return; // 선택이 없으면 무시 if (!selection) return; // 다른 입력 필드에 포커스가 있으면 무시 const activeElement = document.activeElement; const isInputFocused = activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement || activeElement instanceof HTMLSelectElement; // 테이블 내부의 input이 아닌 다른 input에 포커스가 있으면 무시 if (isInputFocused && !tableRef.current?.contains(activeElement)) { return; } if ((e.ctrlKey || e.metaKey) && e.key === "z") { // Ctrl+Z: Undo e.preventDefault(); handleUndo(); } else if ((e.ctrlKey || e.metaKey) && e.key === "y") { // Ctrl+Y: Redo e.preventDefault(); handleRedo(); } else if ((e.ctrlKey || e.metaKey) && e.key === "c") { e.preventDefault(); handleCopy(); } else if ((e.ctrlKey || e.metaKey) && e.key === "v") { e.preventDefault(); handlePaste(); } else if (e.key === "Delete" || e.key === "Backspace") { // 다른 곳에 포커스가 있으면 Delete 무시 if (isInputFocused) return; e.preventDefault(); handleDelete(); } else if (e.key === "Escape") { // Esc로 복사 범위 표시 취소 setCopiedRange(null); } else if (e.key === "F2") { // F2로 편집 모드 진입 (기존 값 유지) const norm = normalizeRange(selection); if (norm.startRow >= 0 && norm.startRow === norm.endRow && norm.startCol === norm.endCol) { e.preventDefault(); const colName = columns[norm.startCol]; setEditingCell({ row: norm.startRow, col: norm.startCol }); setEditValue(String(data[norm.startRow]?.[colName] ?? "")); } } else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { // 일반 문자 키 입력 시 편집 모드 진입 (엑셀처럼) const norm = normalizeRange(selection); if (norm.startRow >= 0 && norm.startRow === norm.endRow && norm.startCol === norm.endCol) { // 단일 셀 선택 시에만 e.preventDefault(); setEditingCell({ row: norm.startRow, col: norm.startCol }); setEditValue(e.key); // 입력한 문자로 시작 } } }; document.addEventListener("keydown", handleGlobalKeyDown); return () => document.removeEventListener("keydown", handleGlobalKeyDown); }, [editingCell, selection, handleCopy, handlePaste, handleDelete, handleUndo, handleRedo]); // 행 삭제 const handleDeleteRow = (rowIndex: number) => { const newData = data.filter((_, i) => i !== rowIndex); onDataChange(newData); }; // 열 삭제 const handleDeleteColumn = (colIndex: number) => { if (columns.length <= 1) return; const colName = columns[colIndex]; const newColumns = columns.filter((_, i) => i !== colIndex); onColumnsChange(newColumns); const newData = data.map((row) => { const { [colName]: removed, ...rest } = row; return rest; }); onDataChange(newData); }; // 컬럼 문자 (A, B, C, ...) const getColumnLetter = (index: number): string => { let letter = ""; let i = index; while (i >= 0) { letter = String.fromCharCode(65 + (i % 26)) + letter; i = Math.floor(i / 26) - 1; } return letter; }; // ============ 자동 채우기 로직 ============ // 값에서 마지막 숫자 패턴 추출 const extractNumberPattern = (value: string): { prefix: string; number: number; suffix: string; numLength: number; isZeroPadded: boolean; } | null => { if (/^-?\d+(\.\d+)?$/.test(value)) { const isZeroPadded = value.startsWith("0") && value.length > 1 && !value.includes("."); return { prefix: "", number: parseFloat(value), suffix: "", numLength: value.replace("-", "").split(".")[0].length, isZeroPadded }; } const match = value.match(/^(.*)(\d+)(\D*)$/); if (match) { const numStr = match[2]; const isZeroPadded = numStr.startsWith("0") && numStr.length > 1; return { prefix: match[1], number: parseInt(numStr, 10), suffix: match[3], numLength: numStr.length, isZeroPadded }; } return null; }; // 날짜 패턴 인식 const extractDatePattern = (value: string): Date | null => { const dateMatch = value.match(/^(\d{4})-(\d{2})-(\d{2})$/); if (dateMatch) { const date = new Date(parseInt(dateMatch[1]), parseInt(dateMatch[2]) - 1, parseInt(dateMatch[3])); if (!isNaN(date.getTime())) { return date; } } return null; }; // 다음 값 생성 const generateNextValue = (sourceValue: string, step: number): string => { if (!sourceValue || sourceValue.trim() === "") { return ""; } const datePattern = extractDatePattern(sourceValue); if (datePattern) { const newDate = new Date(datePattern); newDate.setDate(newDate.getDate() + step); const year = newDate.getFullYear(); const month = String(newDate.getMonth() + 1).padStart(2, "0"); const day = String(newDate.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; } const numberPattern = extractNumberPattern(sourceValue); if (numberPattern) { const newNumber = numberPattern.number + step; const absNumber = Math.max(0, newNumber); let numStr: string; if (numberPattern.isZeroPadded) { numStr = String(absNumber).padStart(numberPattern.numLength, "0"); } else { numStr = String(absNumber); } return numberPattern.prefix + numStr + numberPattern.suffix; } return sourceValue; }; // 자동 채우기 드래그 시작 const handleFillDragStart = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); // 편집 중이면 먼저 현재 편집 값을 저장 if (editingCell) { const { row, col } = editingCell; if (row === -1) { // 헤더 변경 const newColumns = [...columns]; const oldColName = newColumns[col]; const newColName = editValue.trim() || `Column${col + 1}`; if (oldColName !== newColName) { newColumns[col] = newColName; onColumnsChange(newColumns); } } else { // 데이터 셀 변경 const colName = columns[col]; const newData = [...data]; if (!newData[row]) { newData[row] = {}; } newData[row] = { ...newData[row], [colName]: editValue }; onDataChange(newData); } setEditingCell(null); setEditValue(""); } if (!selection) return; const norm = normalizeRange(selection); if (norm.startRow < 0) return; // 헤더는 제외 setIsDraggingFill(true); setFillPreviewEnd(norm.endRow); }; // 자동 채우기 드래그 중 const handleFillDragMove = useCallback((e: MouseEvent) => { if (!isDraggingFill || !selection || !tableRef.current) return; const rows = tableRef.current.querySelectorAll("tbody tr"); const mouseY = e.clientY; for (let i = 0; i < rows.length - 1; i++) { const row = rows[i] as HTMLElement; const rect = row.getBoundingClientRect(); if (mouseY >= rect.top && mouseY <= rect.bottom) { setFillPreviewEnd(i); break; } else if (mouseY > rect.bottom && i === rows.length - 2) { setFillPreviewEnd(i); } } }, [isDraggingFill, selection]); // 열의 숫자 패턴 간격 계산 (예: 201, 202 → 간격 1) const calculateColumnIncrement = (colIndex: number, startRow: number, endRow: number): number | null => { if (startRow === endRow) return 1; // 단일 행이면 기본 증가 1 const colName = columns[colIndex]; const increments: number[] = []; for (let row = startRow; row < endRow; row++) { const currentValue = String(data[row]?.[colName] ?? ""); const nextValue = String(data[row + 1]?.[colName] ?? ""); const currentPattern = extractNumberPattern(currentValue); const nextPattern = extractNumberPattern(nextValue); if (currentPattern && nextPattern) { // 접두사와 접미사가 같은지 확인 if (currentPattern.prefix === nextPattern.prefix && currentPattern.suffix === nextPattern.suffix) { increments.push(nextPattern.number - currentPattern.number); } else { return null; // 패턴이 다르면 복사 모드 } } else { return null; // 숫자 패턴이 없으면 복사 모드 } } // 모든 간격이 같은지 확인 if (increments.length > 0 && increments.every(inc => inc === increments[0])) { return increments[0]; } return null; }; // 자동 채우기 드래그 종료 (다중 셀 지원) // - 숫자 패턴이 있으면: 패턴 간격을 인식하여 증가 (201, 202 → 203, 204) // - 숫자 패턴이 없으면: 선택된 패턴 그대로 반복 (복사) const handleFillDragEnd = useCallback(() => { if (!isDraggingFill || !selection || fillPreviewEnd === null) { setIsDraggingFill(false); setFillPreviewEnd(null); return; } const norm = normalizeRange(selection); const endRow = fillPreviewEnd; const selectionHeight = norm.endRow - norm.startRow + 1; if (endRow !== norm.endRow && norm.startRow >= 0) { const newData = [...data]; // 각 열별로 증가 패턴 계산 const columnIncrements: Map = new Map(); for (let col = norm.startCol; col <= norm.endCol; col++) { columnIncrements.set(col, calculateColumnIncrement(col, norm.startRow, norm.endRow)); } if (endRow > norm.endRow) { // 아래로 채우기 for (let targetRow = norm.endRow + 1; targetRow <= endRow; targetRow++) { if (!newData[targetRow]) { newData[targetRow] = {}; columns.forEach((c) => { newData[targetRow][c] = ""; }); } // 선택된 모든 열에 대해 채우기 for (let col = norm.startCol; col <= norm.endCol; col++) { const colName = columns[col]; const increment = columnIncrements.get(col); if (increment !== null) { // 숫자 패턴 증가 모드 // 마지막 선택 행의 값을 기준으로 증가 const lastValue = String(data[norm.endRow]?.[colName] ?? ""); const step = (targetRow - norm.endRow) * increment; newData[targetRow] = { ...newData[targetRow], [colName]: generateNextValue(lastValue, step), }; } else { // 복사 모드 (패턴 반복) const sourceRowOffset = (targetRow - norm.endRow - 1) % selectionHeight; const sourceRow = norm.startRow + sourceRowOffset; const sourceValue = String(data[sourceRow]?.[colName] ?? ""); newData[targetRow] = { ...newData[targetRow], [colName]: sourceValue, }; } } } } else if (endRow < norm.startRow) { // 위로 채우기 for (let targetRow = norm.startRow - 1; targetRow >= endRow; targetRow--) { if (!newData[targetRow]) { newData[targetRow] = {}; columns.forEach((c) => { newData[targetRow][c] = ""; }); } for (let col = norm.startCol; col <= norm.endCol; col++) { const colName = columns[col]; const increment = columnIncrements.get(col); if (increment !== null) { // 숫자 패턴 감소 모드 const firstValue = String(data[norm.startRow]?.[colName] ?? ""); const step = (targetRow - norm.startRow) * increment; newData[targetRow] = { ...newData[targetRow], [colName]: generateNextValue(firstValue, step), }; } else { // 복사 모드 (패턴 반복) const sourceRowOffset = (norm.startRow - targetRow - 1) % selectionHeight; const sourceRow = norm.endRow - sourceRowOffset; const sourceValue = String(data[sourceRow]?.[colName] ?? ""); newData[targetRow] = { ...newData[targetRow], [colName]: sourceValue, }; } } } } onDataChange(newData); } setIsDraggingFill(false); setFillPreviewEnd(null); }, [isDraggingFill, selection, fillPreviewEnd, columns, data, onDataChange]); // 드래그 이벤트 리스너 useEffect(() => { if (isDraggingFill) { document.addEventListener("mousemove", handleFillDragMove); document.addEventListener("mouseup", handleFillDragEnd); return () => { document.removeEventListener("mousemove", handleFillDragMove); document.removeEventListener("mouseup", handleFillDragEnd); }; } }, [isDraggingFill, handleFillDragMove, handleFillDragEnd]); // 셀이 자동 채우기 미리보기 범위에 있는지 확인 const isInFillPreview = (rowIndex: number, colIndex: number): boolean => { if (!isDraggingFill || !selection || fillPreviewEnd === null) return false; const norm = normalizeRange(selection); // 열이 선택 범위 내에 있어야 함 if (colIndex < norm.startCol || colIndex > norm.endCol) return false; if (fillPreviewEnd > norm.endRow) { return rowIndex > norm.endRow && rowIndex <= fillPreviewEnd; } else if (fillPreviewEnd < norm.startRow) { return rowIndex >= fillPreviewEnd && rowIndex < norm.startRow; } return false; }; return (
{/* 열 인덱스 헤더 (A, B, C, ...) */} {/* 빈 코너 셀 */} {columns.map((_, colIndex) => ( ))} {/* 새 열 추가 버튼 */} {/* 컬럼명 헤더 (편집 가능) */} {columns.map((colName, colIndex) => ( ))} {data.map((row, rowIndex) => ( {/* 행 번호 */} {/* 데이터 셀 */} {columns.map((colName, colIndex) => { const isSelected = isCellInSelection(rowIndex, colIndex); const isEditing = editingCell?.row === rowIndex && editingCell?.col === colIndex; const inFillPreview = isInFillPreview(rowIndex, colIndex); const isSelectionEnd = isCellSelectionEnd(rowIndex, colIndex); const copiedBorder = getCopiedBorderPosition(rowIndex, colIndex); const isCopied = isCellInCopiedRange(rowIndex, colIndex); return ( ); })} ))} {/* 새 행 추가 영역 */}
{getColumnLetter(colIndex)} {columns.length > 1 && ( )}
1 handleCellMouseDown(-1, colIndex, e)} onMouseEnter={() => handleCellMouseEnter(-1, colIndex)} onDoubleClick={() => startEditing(-1, colIndex)} > {editingCell?.row === -1 && editingCell?.col === colIndex ? ( setEditValue(e.target.value)} onKeyDown={handleKeyDown} onBlur={finishEditing} className="w-full bg-white px-2 py-1 text-xs font-medium text-primary outline-none" /> ) : (
{colName || 빈 헤더}
)}
{rowIndex + 2}
handleCellMouseDown(rowIndex, colIndex, e)} onMouseEnter={() => handleCellMouseEnter(rowIndex, colIndex)} onDoubleClick={() => startEditing(rowIndex, colIndex)} > {isEditing ? ( setEditValue(e.target.value)} onKeyDown={handleKeyDown} onBlur={finishEditing} className="w-full bg-white px-2 py-1 text-xs outline-none" /> ) : (
{String(row[colName] ?? "")}
)} {/* 복사 범위 점선 테두리 (Marching Ants) */} {isCopied && ( <> {copiedBorder.top && (
)} {copiedBorder.right && (
)} {copiedBorder.bottom && (
)} {copiedBorder.left && (
)} )} {/* 자동 채우기 핸들 - 선택 범위의 우하단에서만 표시 (편집 중에도 표시) */} {isSelectionEnd && selection && normalizeRange(selection).startRow >= 0 && (
)}
{ const newRow: Record = {}; columns.forEach((c) => { newRow[c] = ""; }); onDataChange([...data, newRow]); setTimeout(() => { startEditing(data.length, 0); }, 0); }} > 클릭하여 새 행 추가...
); };