복사 붙여넣기 기능
This commit is contained in:
parent
b61cb17aea
commit
6a1343b847
|
|
@ -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 ===== */
|
||||
|
|
|
|||
|
|
@ -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<EditableSpreadsheetProps> = ({
|
||||
columns,
|
||||
|
|
@ -33,29 +42,108 @@ export const EditableSpreadsheet: React.FC<EditableSpreadsheetProps> = ({
|
|||
} | null>(null);
|
||||
const [editValue, setEditValue] = useState<string>("");
|
||||
|
||||
// 현재 선택된 셀 (편집 모드 아닐 때도 표시)
|
||||
const [selectedCell, setSelectedCell] = useState<{
|
||||
row: number;
|
||||
col: number;
|
||||
} | null>(null);
|
||||
// 선택 범위 (다중 셀 선택)
|
||||
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);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const tableRef = useRef<HTMLDivElement>(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<EditableSpreadsheetProps> = ({
|
|||
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<EditableSpreadsheetProps> = ({
|
|||
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 === "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<EditableSpreadsheetProps> = ({
|
|||
|
||||
// ============ 자동 채우기 로직 ============
|
||||
|
||||
// 값에서 마지막 숫자 패턴 추출 (예: "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<EditableSpreadsheetProps> = ({
|
|||
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<EditableSpreadsheetProps> = ({
|
|||
};
|
||||
}
|
||||
|
||||
// 마지막 숫자 시퀀스를 찾기 (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<EditableSpreadsheetProps> = ({
|
|||
|
||||
// 날짜 패턴 인식
|
||||
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<EditableSpreadsheetProps> = ({
|
|||
|
||||
// 다음 값 생성
|
||||
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<EditableSpreadsheetProps> = ({
|
|||
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<EditableSpreadsheetProps> = ({
|
|||
return numberPattern.prefix + numStr + numberPattern.suffix;
|
||||
}
|
||||
|
||||
// 패턴 없으면 복사
|
||||
return sourceValue;
|
||||
};
|
||||
|
||||
|
|
@ -381,21 +659,22 @@ export const EditableSpreadsheet: React.FC<EditableSpreadsheetProps> = ({
|
|||
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<EditableSpreadsheetProps> = ({
|
|||
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<EditableSpreadsheetProps> = ({
|
|||
|
||||
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<EditableSpreadsheetProps> = ({
|
|||
|
||||
// 셀이 자동 채우기 미리보기 범위에 있는지 확인
|
||||
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 (
|
||||
<div
|
||||
ref={tableRef}
|
||||
className="overflow-auto rounded-md border border-border"
|
||||
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">
|
||||
|
|
@ -527,7 +828,6 @@ export const EditableSpreadsheet: React.FC<EditableSpreadsheetProps> = ({
|
|||
<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]: "" }));
|
||||
|
|
@ -552,14 +852,13 @@ export const EditableSpreadsheet: React.FC<EditableSpreadsheetProps> = ({
|
|||
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) ||
|
||||
(selectedCell?.row === -1 && selectedCell?.col === colIndex)
|
||||
isCellInSelection(-1, colIndex)
|
||||
? "ring-2 ring-primary ring-inset"
|
||||
: ""
|
||||
)}
|
||||
onClick={() => {
|
||||
selectCell(-1, colIndex);
|
||||
startEditing(-1, colIndex);
|
||||
}}
|
||||
onMouseDown={(e) => handleCellMouseDown(-1, colIndex, e)}
|
||||
onMouseEnter={() => handleCellMouseEnter(-1, colIndex)}
|
||||
onDoubleClick={() => startEditing(-1, colIndex)}
|
||||
>
|
||||
{editingCell?.row === -1 && editingCell?.col === colIndex ? (
|
||||
<input
|
||||
|
|
@ -599,24 +898,24 @@ export const EditableSpreadsheet: React.FC<EditableSpreadsheetProps> = ({
|
|||
|
||||
{/* 데이터 셀 */}
|
||||
{columns.map((colName, colIndex) => {
|
||||
const isSelected = selectedCell?.row === rowIndex && selectedCell?.col === 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-pointer border-b border-r border-border px-0 py-0",
|
||||
isSelected || isEditing ? "ring-2 ring-primary ring-inset" : "",
|
||||
inFillPreview ? "bg-primary/10" : ""
|
||||
"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" : ""
|
||||
)}
|
||||
onClick={() => {
|
||||
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<EditableSpreadsheetProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 자동 채우기 핸들 - 선택된 셀에서만 표시 */}
|
||||
{isSelected && !isEditing && (
|
||||
{/* 복사 범위 점선 테두리 (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 && !isEditing && selection && normalizeRange(selection).startRow >= 0 && (
|
||||
<div
|
||||
className="absolute bottom-0 right-0 h-2 w-2 translate-x-1/2 translate-y-1/2 cursor-crosshair bg-primary"
|
||||
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="자동 채우기"
|
||||
/>
|
||||
|
|
@ -679,7 +996,6 @@ export const EditableSpreadsheet: React.FC<EditableSpreadsheetProps> = ({
|
|||
newRow[c] = "";
|
||||
});
|
||||
onDataChange([...data, newRow]);
|
||||
// 새 행의 첫 번째 셀 편집 시작
|
||||
setTimeout(() => {
|
||||
startEditing(data.length, 0);
|
||||
}, 0);
|
||||
|
|
|
|||
Loading…
Reference in New Issue