From 6a1343b847c6e1d2bef58609150f60018d7c59de Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 8 Jan 2026 12:14:04 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B3=B5=EC=82=AC=20=EB=B6=99=EC=97=AC?= =?UTF-8?q?=EB=84=A3=EA=B8=B0=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/globals.css | 43 ++ .../components/common/EditableSpreadsheet.tsx | 492 ++++++++++++++---- 2 files changed, 447 insertions(+), 88 deletions(-) diff --git a/frontend/app/globals.css b/frontend/app/globals.css index b332f5a0..2fbbe7c5 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -567,4 +567,47 @@ select { scrollbar-width: none; } +/* ===== Marching Ants Animation (Excel Copy Border) ===== */ +@keyframes marching-ants-h { + 0% { + background-position: 0 0; + } + 100% { + background-position: 16px 0; + } +} + +@keyframes marching-ants-v { + 0% { + background-position: 0 0; + } + 100% { + background-position: 0 16px; + } +} + +.animate-marching-ants-h { + background: repeating-linear-gradient( + 90deg, + hsl(var(--primary)) 0, + hsl(var(--primary)) 4px, + transparent 4px, + transparent 8px + ); + background-size: 16px 2px; + animation: marching-ants-h 0.4s linear infinite; +} + +.animate-marching-ants-v { + background: repeating-linear-gradient( + 180deg, + hsl(var(--primary)) 0, + hsl(var(--primary)) 4px, + transparent 4px, + transparent 8px + ); + background-size: 2px 16px; + animation: marching-ants-v 0.4s linear infinite; +} + /* ===== End of Global Styles ===== */ diff --git a/frontend/components/common/EditableSpreadsheet.tsx b/frontend/components/common/EditableSpreadsheet.tsx index de9c827d..94b080e5 100644 --- a/frontend/components/common/EditableSpreadsheet.tsx +++ b/frontend/components/common/EditableSpreadsheet.tsx @@ -11,13 +11,22 @@ interface EditableSpreadsheetProps { maxHeight?: string; } +// 셀 범위 정의 +interface CellRange { + startRow: number; + startCol: number; + endRow: number; + endCol: number; +} + /** * 엑셀처럼 편집 가능한 스프레드시트 컴포넌트 * - 셀 클릭으로 편집 * - Tab/Enter로 다음 셀 이동 * - 마지막 행/열에서 자동 추가 * - 헤더(컬럼명)도 편집 가능 - * - 자동 채우기 (드래그 핸들) + * - 다중 셀 선택 (드래그) + * - 자동 채우기 (드래그 핸들) - 다중 셀 지원 */ export const EditableSpreadsheet: React.FC = ({ columns, @@ -33,29 +42,108 @@ export const EditableSpreadsheet: React.FC = ({ } | null>(null); const [editValue, setEditValue] = useState(""); - // 현재 선택된 셀 (편집 모드 아닐 때도 표시) - const [selectedCell, setSelectedCell] = useState<{ - row: number; - col: number; - } | null>(null); + // 선택 범위 (다중 셀 선택) + 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); + const inputRef = useRef(null); const tableRef = useRef(null); - // 셀 선택 (클릭만, 편집 아님) - const selectCell = useCallback((row: number, col: number) => { - setSelectedCell({ row, col }); - }, []); + // 범위 정규화 (시작이 끝보다 크면 교환) + 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); + + // 복사 범위 초기화 (새로운 선택 시작하면 이전 복사 표시 제거) + setCopiedRange(null); + + // 테이블에 포커스 (키보드 이벤트 수신용) + 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 }); - setSelectedCell({ row, col }); + setSelection({ + startRow: row, + startCol: col, + endRow: row, + endCol: col, + }); if (row === -1) { // 헤더 편집 setEditValue(columns[col] || ""); @@ -242,7 +330,7 @@ export const EditableSpreadsheet: React.FC = ({ const handleClickOutside = (e: MouseEvent) => { if (tableRef.current && !tableRef.current.contains(e.target as Node)) { finishEditing(); - setSelectedCell(null); + setSelection(null); } }; @@ -250,6 +338,207 @@ export const EditableSpreadsheet: React.FC = ({ 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 === "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(); + } + }; + + document.addEventListener("keydown", handleGlobalKeyDown); + return () => document.removeEventListener("keydown", handleGlobalKeyDown); + }, [editingCell, selection, handleCopy, handlePaste, handleDelete]); + // 행 삭제 const handleDeleteRow = (rowIndex: number) => { const newData = data.filter((_, i) => i !== rowIndex); @@ -284,7 +573,7 @@ export const EditableSpreadsheet: React.FC = ({ // ============ 자동 채우기 로직 ============ - // 값에서 마지막 숫자 패턴 추출 (예: "26-item-0005" → prefix: "26-item-", number: 5, suffix: "", numLength: 4) + // 값에서 마지막 숫자 패턴 추출 const extractNumberPattern = (value: string): { prefix: string; number: number; @@ -292,7 +581,6 @@ export const EditableSpreadsheet: React.FC = ({ numLength: number; isZeroPadded: boolean; } | null => { - // 숫자만 있는 경우 if (/^-?\d+(\.\d+)?$/.test(value)) { const isZeroPadded = value.startsWith("0") && value.length > 1 && !value.includes("."); return { @@ -304,8 +592,6 @@ export const EditableSpreadsheet: React.FC = ({ }; } - // 마지막 숫자 시퀀스를 찾기 (greedy하게 prefix를 찾음) - // 예: "26-item-0005" → prefix: "26-item-", number: "0005", suffix: "" const match = value.match(/^(.*)(\d+)(\D*)$/); if (match) { const numStr = match[2]; @@ -324,7 +610,6 @@ export const EditableSpreadsheet: React.FC = ({ // 날짜 패턴 인식 const extractDatePattern = (value: string): Date | null => { - // YYYY-MM-DD 형식 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])); @@ -337,12 +622,10 @@ export const EditableSpreadsheet: React.FC = ({ // 다음 값 생성 const generateNextValue = (sourceValue: string, step: number): string => { - // 빈 값이면 그대로 if (!sourceValue || sourceValue.trim() === "") { return ""; } - // 날짜 패턴 체크 const datePattern = extractDatePattern(sourceValue); if (datePattern) { const newDate = new Date(datePattern); @@ -353,17 +636,13 @@ export const EditableSpreadsheet: React.FC = ({ 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) { - // 제로패딩 유지 (예: 0005 → 0006) numStr = String(absNumber).padStart(numberPattern.numLength, "0"); } else { numStr = String(absNumber); @@ -372,7 +651,6 @@ export const EditableSpreadsheet: React.FC = ({ return numberPattern.prefix + numStr + numberPattern.suffix; } - // 패턴 없으면 복사 return sourceValue; }; @@ -381,21 +659,22 @@ export const EditableSpreadsheet: React.FC = ({ e.preventDefault(); e.stopPropagation(); - if (!selectedCell || selectedCell.row < 0) return; + if (!selection) return; + const norm = normalizeRange(selection); + if (norm.startRow < 0) return; // 헤더는 제외 setIsDraggingFill(true); - setFillPreviewEnd(selectedCell.row); + setFillPreviewEnd(norm.endRow); }; // 자동 채우기 드래그 중 const handleFillDragMove = useCallback((e: MouseEvent) => { - if (!isDraggingFill || !selectedCell || !tableRef.current) return; + 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++) { // 마지막 행(추가 영역) 제외 + for (let i = 0; i < rows.length - 1; i++) { const row = rows[i] as HTMLElement; const rect = row.getBoundingClientRect(); @@ -406,53 +685,71 @@ export const EditableSpreadsheet: React.FC = ({ setFillPreviewEnd(i); } } - }, [isDraggingFill, selectedCell]); + }, [isDraggingFill, selection]); - // 자동 채우기 드래그 종료 + // 자동 채우기 드래그 종료 (다중 셀 지원) const handleFillDragEnd = useCallback(() => { - if (!isDraggingFill || !selectedCell || fillPreviewEnd === null) { + if (!isDraggingFill || !selection || fillPreviewEnd === null) { setIsDraggingFill(false); setFillPreviewEnd(null); return; } - const { row: startRow, col } = selectedCell; + const norm = normalizeRange(selection); const endRow = fillPreviewEnd; + const selectionHeight = norm.endRow - norm.startRow + 1; - if (startRow !== endRow && startRow >= 0) { - const colName = columns[col]; - const sourceValue = String(data[startRow]?.[colName] ?? ""); + if (endRow !== norm.endRow && norm.startRow >= 0) { const newData = [...data]; - if (endRow > startRow) { + if (endRow > norm.endRow) { // 아래로 채우기 - for (let i = startRow + 1; i <= endRow; i++) { - const step = i - startRow; - if (!newData[i]) { - newData[i] = {}; + for (let targetRow = norm.endRow + 1; targetRow <= endRow; targetRow++) { + // 선택 범위 내 행 순환 + const sourceRowOffset = (targetRow - norm.startRow) % selectionHeight; + const sourceRow = norm.startRow + sourceRowOffset; + const stepMultiplier = Math.floor((targetRow - norm.startRow) / selectionHeight); + + if (!newData[targetRow]) { + newData[targetRow] = {}; columns.forEach((c) => { - newData[i][c] = ""; + newData[targetRow][c] = ""; }); } - newData[i] = { - ...newData[i], - [colName]: generateNextValue(sourceValue, step), - }; + + // 선택된 모든 열에 대해 채우기 + for (let col = norm.startCol; col <= norm.endCol; col++) { + const colName = columns[col]; + const sourceValue = String(data[sourceRow]?.[colName] ?? ""); + const step = targetRow - sourceRow; + newData[targetRow] = { + ...newData[targetRow], + [colName]: generateNextValue(sourceValue, step), + }; + } } - } else { + } else if (endRow < norm.startRow) { // 위로 채우기 - for (let i = startRow - 1; i >= endRow; i--) { - const step = i - startRow; - if (!newData[i]) { - newData[i] = {}; + for (let targetRow = norm.startRow - 1; targetRow >= endRow; targetRow--) { + const sourceRowOffset = (norm.startRow - targetRow - 1) % selectionHeight; + const sourceRow = norm.endRow - sourceRowOffset; + + if (!newData[targetRow]) { + newData[targetRow] = {}; columns.forEach((c) => { - newData[i][c] = ""; + newData[targetRow][c] = ""; }); } - newData[i] = { - ...newData[i], - [colName]: generateNextValue(sourceValue, step), - }; + + for (let col = norm.startCol; col <= norm.endCol; col++) { + const colName = columns[col]; + const sourceValue = String(data[sourceRow]?.[colName] ?? ""); + const step = targetRow - sourceRow; + newData[targetRow] = { + ...newData[targetRow], + [colName]: generateNextValue(sourceValue, step), + }; + } } } @@ -461,7 +758,7 @@ export const EditableSpreadsheet: React.FC = ({ setIsDraggingFill(false); setFillPreviewEnd(null); - }, [isDraggingFill, selectedCell, fillPreviewEnd, columns, data, onDataChange]); + }, [isDraggingFill, selection, fillPreviewEnd, columns, data, onDataChange]); // 드래그 이벤트 리스너 useEffect(() => { @@ -477,23 +774,27 @@ export const EditableSpreadsheet: React.FC = ({ // 셀이 자동 채우기 미리보기 범위에 있는지 확인 const isInFillPreview = (rowIndex: number, colIndex: number): boolean => { - if (!isDraggingFill || !selectedCell || fillPreviewEnd === null) return false; - if (colIndex !== selectedCell.col) return false; + if (!isDraggingFill || !selection || fillPreviewEnd === null) return false; + + const norm = normalizeRange(selection); + + // 열이 선택 범위 내에 있어야 함 + if (colIndex < norm.startCol || colIndex > norm.endCol) return false; - const startRow = selectedCell.row; - const endRow = fillPreviewEnd; - - if (endRow > startRow) { - return rowIndex > startRow && rowIndex <= endRow; - } else { - return rowIndex >= endRow && rowIndex < startRow; + if (fillPreviewEnd > norm.endRow) { + return rowIndex > norm.endRow && rowIndex <= fillPreviewEnd; + } else if (fillPreviewEnd < norm.startRow) { + return rowIndex >= fillPreviewEnd && rowIndex < norm.startRow; } + + return false; }; return (
@@ -527,7 +828,6 @@ export const EditableSpreadsheet: React.FC = ({
{ - selectCell(rowIndex, colIndex); - if (!isEditing) { - // 단일 클릭은 선택만 - } - }} + onMouseDown={(e) => handleCellMouseDown(rowIndex, colIndex, e)} + onMouseEnter={() => handleCellMouseEnter(rowIndex, colIndex)} onDoubleClick={() => startEditing(rowIndex, colIndex)} > {isEditing ? ( @@ -638,10 +937,28 @@ export const EditableSpreadsheet: React.FC = ({ )} - {/* 자동 채우기 핸들 - 선택된 셀에서만 표시 */} - {isSelected && !isEditing && ( + {/* 복사 범위 점선 테두리 (Marching Ants) */} + {isCopied && ( + <> + {copiedBorder.top && ( +
+ )} + {copiedBorder.right && ( +
+ )} + {copiedBorder.bottom && ( +
+ )} + {copiedBorder.left && ( +
+ )} + + )} + + {/* 자동 채우기 핸들 - 선택 범위의 우하단에서만 표시 */} + {isSelectionEnd && !isEditing && selection && normalizeRange(selection).startRow >= 0 && (
@@ -679,7 +996,6 @@ export const EditableSpreadsheet: React.FC = ({ newRow[c] = ""; }); onDataChange([...data, newRow]); - // 새 행의 첫 번째 셀 편집 시작 setTimeout(() => { startEditing(data.length, 0); }, 0);