ERP-node/frontend/components/common/EditableSpreadsheet.tsx

1210 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useState, useRef, useEffect, useCallback } from "react";
import { cn } from "@/lib/utils";
interface EditableSpreadsheetProps {
columns: string[];
data: Record<string, any>[];
onColumnsChange: (columns: string[]) => void;
onDataChange: (data: Record<string, any>[]) => void;
maxHeight?: string;
}
// 셀 범위 정의
interface CellRange {
startRow: number;
startCol: number;
endRow: number;
endCol: number;
}
/**
* 엑셀처럼 편집 가능한 스프레드시트 컴포넌트
* - 셀 클릭으로 편집
* - Tab/Enter로 다음 셀 이동
* - 마지막 행/열에서 자동 추가
* - 헤더(컬럼명)도 편집 가능
* - 다중 셀 선택 (드래그)
* - 자동 채우기 (드래그 핸들) - 다중 셀 지원
*/
export const EditableSpreadsheet: React.FC<EditableSpreadsheetProps> = ({
columns,
data,
onColumnsChange,
onDataChange,
maxHeight = "350px",
}) => {
// 현재 편집 중인 셀 (row: -1은 헤더)
const [editingCell, setEditingCell] = useState<{
row: number;
col: number;
} | null>(null);
const [editValue, setEditValue] = useState<string>("");
// 선택 범위 (다중 셀 선택)
const [selection, setSelection] = useState<CellRange | null>(null);
// 셀 선택 드래그 중
const [isDraggingSelection, setIsDraggingSelection] = useState(false);
// 자동 채우기 드래그 상태
const [isDraggingFill, setIsDraggingFill] = useState(false);
const [fillPreviewEnd, setFillPreviewEnd] = useState<number | null>(null);
// 복사된 범위 (점선 애니메이션 표시용)
const [copiedRange, setCopiedRange] = useState<CellRange | null>(null);
// Undo/Redo 히스토리
interface HistoryState {
columns: string[];
data: Record<string, any>[];
}
const [history, setHistory] = useState<HistoryState[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
const [isUndoRedo, setIsUndoRedo] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const tableRef = useRef<HTMLDivElement>(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<string, any> = {};
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<string, any> = {};
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<HTMLInputElement>) => {
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<string, any> = {};
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<number, number | null> = 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 (
<div
ref={tableRef}
tabIndex={0}
className="overflow-auto rounded-md border border-border select-none outline-none focus:ring-2 focus:ring-primary/20"
style={{ maxHeight }}
>
<table className="min-w-full border-collapse text-xs">
{/* 열 인덱스 헤더 (A, B, C, ...) */}
<thead className="sticky top-0 z-10 bg-muted">
<tr>
{/* 빈 코너 셀 */}
<th className="sticky left-0 z-20 w-10 border-b border-r border-border bg-muted px-1 py-1 text-center text-[10px] text-muted-foreground">
</th>
{columns.map((_, colIndex) => (
<th
key={colIndex}
className="min-w-[100px] border-b border-r border-border bg-muted px-2 py-1 text-center font-normal text-muted-foreground"
>
<div className="flex items-center justify-center gap-1">
<span>{getColumnLetter(colIndex)}</span>
{columns.length > 1 && (
<button
onClick={() => handleDeleteColumn(colIndex)}
className="ml-1 text-muted-foreground/50 hover:text-destructive"
title="열 삭제"
>
×
</button>
)}
</div>
</th>
))}
{/* 새 열 추가 버튼 */}
<th className="w-8 border-b border-border bg-muted px-1 py-1">
<button
onClick={() => {
onColumnsChange([...columns, ""]);
const tempColId = `__temp_${Date.now()}`;
const newData = data.map((row) => ({ ...row, [tempColId]: "" }));
onDataChange(newData);
}}
className="text-muted-foreground/50 hover:text-primary"
title="열 추가"
>
+
</button>
</th>
</tr>
{/* 컬럼명 헤더 (편집 가능) */}
<tr>
<th className="sticky left-0 z-20 w-10 border-b border-r border-border bg-primary/10 px-1 py-1 text-center font-medium">
1
</th>
{columns.map((colName, colIndex) => (
<th
key={colIndex}
className={cn(
"min-w-[100px] cursor-pointer border-b border-r border-border bg-primary/5 px-0 py-0 font-medium text-primary",
(editingCell?.row === -1 && editingCell?.col === colIndex) ||
isCellInSelection(-1, colIndex)
? "ring-2 ring-primary ring-inset"
: ""
)}
onMouseDown={(e) => handleCellMouseDown(-1, colIndex, e)}
onMouseEnter={() => handleCellMouseEnter(-1, colIndex)}
onDoubleClick={() => startEditing(-1, colIndex)}
>
{editingCell?.row === -1 && editingCell?.col === colIndex ? (
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => 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"
/>
) : (
<div className="px-2 py-1">{colName || <span className="text-muted-foreground/50 italic"> </span>}</div>
)}
</th>
))}
<th className="w-8 border-b border-border bg-primary/5"></th>
</tr>
</thead>
<tbody>
{data.map((row, rowIndex) => (
<tr key={rowIndex} className="group">
{/* 행 번호 */}
<td className="sticky left-0 z-10 w-10 border-b border-r border-border bg-muted/50 px-1 py-1 text-center text-muted-foreground">
<div className="flex items-center justify-center gap-0.5">
<span>{rowIndex + 2}</span>
<button
onClick={() => handleDeleteRow(rowIndex)}
className="hidden text-muted-foreground/50 hover:text-destructive group-hover:inline"
title="행 삭제"
>
×
</button>
</div>
</td>
{/* 데이터 셀 */}
{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 (
<td
key={colIndex}
className={cn(
"relative cursor-cell border-b border-r border-border px-0 py-0",
isSelected ? "bg-primary/10" : "",
isEditing ? "ring-2 ring-primary ring-inset" : "",
inFillPreview ? "bg-primary/20" : ""
)}
onMouseDown={(e) => handleCellMouseDown(rowIndex, colIndex, e)}
onMouseEnter={() => handleCellMouseEnter(rowIndex, colIndex)}
onDoubleClick={() => startEditing(rowIndex, colIndex)}
>
{isEditing ? (
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={finishEditing}
className="w-full bg-white px-2 py-1 text-xs outline-none"
/>
) : (
<div
className="max-w-[200px] truncate px-2 py-1"
title={String(row[colName] ?? "")}
>
{String(row[colName] ?? "")}
</div>
)}
{/* 복사 범위 점선 테두리 (Marching Ants) */}
{isCopied && (
<>
{copiedBorder.top && (
<div className="pointer-events-none absolute left-0 right-0 top-0 h-[2px] animate-marching-ants-h" />
)}
{copiedBorder.right && (
<div className="pointer-events-none absolute bottom-0 right-0 top-0 w-[2px] animate-marching-ants-v" />
)}
{copiedBorder.bottom && (
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-[2px] animate-marching-ants-h" />
)}
{copiedBorder.left && (
<div className="pointer-events-none absolute bottom-0 left-0 top-0 w-[2px] animate-marching-ants-v" />
)}
</>
)}
{/* 자동 채우기 핸들 - 선택 범위의 우하단에서만 표시 (편집 중에도 표시) */}
{isSelectionEnd && selection && normalizeRange(selection).startRow >= 0 && (
<div
className="absolute bottom-0 right-0 z-20 h-2.5 w-2.5 translate-x-1/2 translate-y-1/2 cursor-crosshair border border-white bg-primary"
onMouseDown={handleFillDragStart}
title="자동 채우기"
/>
)}
</td>
);
})}
<td className="w-8 border-b border-border"></td>
</tr>
))}
{/* 새 행 추가 영역 */}
<tr className="bg-muted/20">
<td className="sticky left-0 z-10 w-10 border-r border-border bg-muted/30 px-1 py-1 text-center">
<button
onClick={() => {
const newRow: Record<string, any> = {};
columns.forEach((c) => {
newRow[c] = "";
});
onDataChange([...data, newRow]);
}}
className="text-muted-foreground/50 hover:text-primary"
title="행 추가"
>
+
</button>
</td>
<td
colSpan={columns.length + 1}
className="cursor-pointer px-2 py-1 text-muted-foreground/50 hover:bg-muted/50"
onClick={() => {
const newRow: Record<string, any> = {};
columns.forEach((c) => {
newRow[c] = "";
});
onDataChange([...data, newRow]);
setTimeout(() => {
startEditing(data.length, 0);
}, 0);
}}
>
...
</td>
</tr>
</tbody>
</table>
</div>
);
};