컴포넌트 다중 선택 및 복붙, Re/undo 구현
This commit is contained in:
parent
771dc8cf56
commit
46aa81ce6f
|
|
@ -11,6 +11,7 @@ interface CanvasComponentProps {
|
||||||
export function CanvasComponent({ component }: CanvasComponentProps) {
|
export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
const {
|
const {
|
||||||
selectedComponentId,
|
selectedComponentId,
|
||||||
|
selectedComponentIds,
|
||||||
selectComponent,
|
selectComponent,
|
||||||
updateComponent,
|
updateComponent,
|
||||||
getQueryResult,
|
getQueryResult,
|
||||||
|
|
@ -25,6 +26,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
const componentRef = useRef<HTMLDivElement>(null);
|
const componentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const isSelected = selectedComponentId === component.id;
|
const isSelected = selectedComponentId === component.id;
|
||||||
|
const isMultiSelected = selectedComponentIds.includes(component.id);
|
||||||
|
|
||||||
// 드래그 시작
|
// 드래그 시작
|
||||||
const handleMouseDown = (e: React.MouseEvent) => {
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
|
@ -33,7 +35,11 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
selectComponent(component.id);
|
|
||||||
|
// Ctrl/Cmd 키 감지 (다중 선택)
|
||||||
|
const isMultiSelect = e.ctrlKey || e.metaKey;
|
||||||
|
selectComponent(component.id, isMultiSelect);
|
||||||
|
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
setDragStart({
|
setDragStart({
|
||||||
x: e.clientX - component.x,
|
x: e.clientX - component.x,
|
||||||
|
|
@ -271,7 +277,9 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={componentRef}
|
ref={componentRef}
|
||||||
className={`absolute cursor-move p-2 shadow-sm ${isSelected ? "ring-2 ring-blue-500" : ""}`}
|
className={`absolute cursor-move p-2 shadow-sm ${
|
||||||
|
isSelected ? "ring-2 ring-blue-500" : isMultiSelected ? "ring-2 ring-blue-300" : ""
|
||||||
|
}`}
|
||||||
style={{
|
style={{
|
||||||
left: `${component.x}px`,
|
left: `${component.x}px`,
|
||||||
top: `${component.y}px`,
|
top: `${component.y}px`,
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,16 @@ export function ReportDesignerCanvas() {
|
||||||
canvasHeight,
|
canvasHeight,
|
||||||
selectComponent,
|
selectComponent,
|
||||||
selectedComponentId,
|
selectedComponentId,
|
||||||
|
selectedComponentIds,
|
||||||
removeComponent,
|
removeComponent,
|
||||||
showGrid,
|
showGrid,
|
||||||
gridSize,
|
gridSize,
|
||||||
snapValueToGrid,
|
snapValueToGrid,
|
||||||
alignmentGuides,
|
alignmentGuides,
|
||||||
|
copyComponents,
|
||||||
|
pasteComponents,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
} = useReportDesigner();
|
} = useReportDesigner();
|
||||||
|
|
||||||
const [{ isOver }, drop] = useDrop(() => ({
|
const [{ isOver }, drop] = useDrop(() => ({
|
||||||
|
|
@ -72,17 +77,58 @@ export function ReportDesignerCanvas() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Delete 키 삭제 처리
|
// 키보드 단축키 (Delete, Ctrl+C, Ctrl+V)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Delete" && selectedComponentId) {
|
// 입력 필드에서는 단축키 무시
|
||||||
removeComponent(selectedComponentId);
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 키: 삭제
|
||||||
|
if (e.key === "Delete") {
|
||||||
|
if (selectedComponentIds.length > 0) {
|
||||||
|
selectedComponentIds.forEach((id) => removeComponent(id));
|
||||||
|
} else if (selectedComponentId) {
|
||||||
|
removeComponent(selectedComponentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+C (또는 Cmd+C): 복사
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === "c") {
|
||||||
|
e.preventDefault();
|
||||||
|
copyComponents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+V (또는 Cmd+V): 붙여넣기
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === "v") {
|
||||||
|
e.preventDefault();
|
||||||
|
pasteComponents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+Shift+Z 또는 Ctrl+Y (또는 Cmd+Shift+Z / Cmd+Y): Redo (Undo보다 먼저 체크)
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "z") {
|
||||||
|
e.preventDefault();
|
||||||
|
redo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "y") {
|
||||||
|
e.preventDefault();
|
||||||
|
redo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+Z (또는 Cmd+Z): Undo
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
|
||||||
|
e.preventDefault();
|
||||||
|
undo();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [selectedComponentId, removeComponent]);
|
}, [selectedComponentId, selectedComponentIds, removeComponent, copyComponents, pasteComponents, undo, redo]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col overflow-hidden bg-gray-100">
|
<div className="flex flex-1 flex-col overflow-hidden bg-gray-100">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Save, Eye, RotateCcw, ArrowLeft, Loader2, BookTemplate, Grid3x3 } from "lucide-react";
|
import { Save, Eye, RotateCcw, ArrowLeft, Loader2, BookTemplate, Grid3x3, Undo2, Redo2 } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
@ -26,6 +26,10 @@ export function ReportDesignerToolbar() {
|
||||||
setSnapToGrid,
|
setSnapToGrid,
|
||||||
showGrid,
|
showGrid,
|
||||||
setShowGrid,
|
setShowGrid,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
} = useReportDesigner();
|
} = useReportDesigner();
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
|
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
|
||||||
|
|
@ -149,6 +153,26 @@ export function ReportDesignerToolbar() {
|
||||||
<Grid3x3 className="h-4 w-4" />
|
<Grid3x3 className="h-4 w-4" />
|
||||||
{snapToGrid && showGrid ? "Grid ON" : "Grid OFF"}
|
{snapToGrid && showGrid ? "Grid ON" : "Grid OFF"}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={undo}
|
||||||
|
disabled={!canUndo}
|
||||||
|
className="gap-2"
|
||||||
|
title="실행 취소 (Ctrl+Z)"
|
||||||
|
>
|
||||||
|
<Undo2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={redo}
|
||||||
|
disabled={!canRedo}
|
||||||
|
className="gap-2"
|
||||||
|
title="다시 실행 (Ctrl+Shift+Z)"
|
||||||
|
>
|
||||||
|
<Redo2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
<Button variant="outline" size="sm" onClick={handleReset} className="gap-2">
|
<Button variant="outline" size="sm" onClick={handleReset} className="gap-2">
|
||||||
<RotateCcw className="h-4 w-4" />
|
<RotateCcw className="h-4 w-4" />
|
||||||
초기화
|
초기화
|
||||||
|
|
|
||||||
|
|
@ -271,6 +271,7 @@ interface ReportDesignerContextType {
|
||||||
layout: ReportLayout | null;
|
layout: ReportLayout | null;
|
||||||
components: ComponentConfig[];
|
components: ComponentConfig[];
|
||||||
selectedComponentId: string | null;
|
selectedComponentId: string | null;
|
||||||
|
selectedComponentIds: string[]; // 다중 선택된 컴포넌트 ID 배열
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
|
|
||||||
|
|
@ -287,7 +288,7 @@ interface ReportDesignerContextType {
|
||||||
addComponent: (component: ComponentConfig) => void;
|
addComponent: (component: ComponentConfig) => void;
|
||||||
updateComponent: (id: string, updates: Partial<ComponentConfig>) => void;
|
updateComponent: (id: string, updates: Partial<ComponentConfig>) => void;
|
||||||
removeComponent: (id: string) => void;
|
removeComponent: (id: string) => void;
|
||||||
selectComponent: (id: string | null) => void;
|
selectComponent: (id: string | null, isMultiSelect?: boolean) => void;
|
||||||
|
|
||||||
// 레이아웃 관리
|
// 레이아웃 관리
|
||||||
updateLayout: (updates: Partial<ReportLayout>) => void;
|
updateLayout: (updates: Partial<ReportLayout>) => void;
|
||||||
|
|
@ -321,6 +322,16 @@ interface ReportDesignerContextType {
|
||||||
alignmentGuides: { vertical: number[]; horizontal: number[] };
|
alignmentGuides: { vertical: number[]; horizontal: number[] };
|
||||||
calculateAlignmentGuides: (draggingId: string, x: number, y: number, width: number, height: number) => void;
|
calculateAlignmentGuides: (draggingId: string, x: number, y: number, width: number, height: number) => void;
|
||||||
clearAlignmentGuides: () => void;
|
clearAlignmentGuides: () => void;
|
||||||
|
|
||||||
|
// 복사/붙여넣기
|
||||||
|
copyComponents: () => void;
|
||||||
|
pasteComponents: () => void;
|
||||||
|
|
||||||
|
// Undo/Redo
|
||||||
|
undo: () => void;
|
||||||
|
redo: () => void;
|
||||||
|
canUndo: boolean;
|
||||||
|
canRedo: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReportDesignerContext = createContext<ReportDesignerContextType | undefined>(undefined);
|
const ReportDesignerContext = createContext<ReportDesignerContextType | undefined>(undefined);
|
||||||
|
|
@ -332,6 +343,7 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
const [queries, setQueries] = useState<ReportQuery[]>([]);
|
const [queries, setQueries] = useState<ReportQuery[]>([]);
|
||||||
const [queryResults, setQueryResults] = useState<QueryResult[]>([]);
|
const [queryResults, setQueryResults] = useState<QueryResult[]>([]);
|
||||||
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
||||||
|
const [selectedComponentIds, setSelectedComponentIds] = useState<string[]>([]); // 다중 선택
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
@ -347,6 +359,14 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
horizontal: number[];
|
horizontal: number[];
|
||||||
}>({ vertical: [], horizontal: [] });
|
}>({ vertical: [], horizontal: [] });
|
||||||
|
|
||||||
|
// 클립보드 (복사/붙여넣기)
|
||||||
|
const [clipboard, setClipboard] = useState<ComponentConfig[]>([]);
|
||||||
|
|
||||||
|
// Undo/Redo 히스토리
|
||||||
|
const [history, setHistory] = useState<ComponentConfig[][]>([]);
|
||||||
|
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||||
|
const [isUndoRedoing, setIsUndoRedoing] = useState(false); // Undo/Redo 실행 중 플래그
|
||||||
|
|
||||||
// Grid Snap 함수
|
// Grid Snap 함수
|
||||||
const snapValueToGrid = useCallback(
|
const snapValueToGrid = useCallback(
|
||||||
(value: number): number => {
|
(value: number): number => {
|
||||||
|
|
@ -359,7 +379,6 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
// 정렬 가이드라인 계산 (드래그 중인 컴포넌트 제외)
|
// 정렬 가이드라인 계산 (드래그 중인 컴포넌트 제외)
|
||||||
const calculateAlignmentGuides = useCallback(
|
const calculateAlignmentGuides = useCallback(
|
||||||
(draggingId: string, x: number, y: number, width: number, height: number) => {
|
(draggingId: string, x: number, y: number, width: number, height: number) => {
|
||||||
const threshold = 5; // 정렬 감지 임계값 (px)
|
|
||||||
const verticalLines: number[] = [];
|
const verticalLines: number[] = [];
|
||||||
const horizontalLines: number[] = [];
|
const horizontalLines: number[] = [];
|
||||||
|
|
||||||
|
|
@ -382,19 +401,19 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
const compBottom = comp.y + comp.height;
|
const compBottom = comp.y + comp.height;
|
||||||
const compCenterY = comp.y + comp.height / 2;
|
const compCenterY = comp.y + comp.height / 2;
|
||||||
|
|
||||||
// 세로 정렬 체크 (left, center, right)
|
// 세로 정렬 체크 (left, center, right) - 정확히 일치할 때만
|
||||||
if (Math.abs(left - compLeft) < threshold) verticalLines.push(compLeft);
|
if (left === compLeft) verticalLines.push(compLeft);
|
||||||
if (Math.abs(left - compRight) < threshold) verticalLines.push(compRight);
|
if (left === compRight) verticalLines.push(compRight);
|
||||||
if (Math.abs(right - compLeft) < threshold) verticalLines.push(compLeft);
|
if (right === compLeft) verticalLines.push(compLeft);
|
||||||
if (Math.abs(right - compRight) < threshold) verticalLines.push(compRight);
|
if (right === compRight) verticalLines.push(compRight);
|
||||||
if (Math.abs(centerX - compCenterX) < threshold) verticalLines.push(compCenterX);
|
if (centerX === compCenterX) verticalLines.push(compCenterX);
|
||||||
|
|
||||||
// 가로 정렬 체크 (top, center, bottom)
|
// 가로 정렬 체크 (top, center, bottom) - 정확히 일치할 때만
|
||||||
if (Math.abs(top - compTop) < threshold) horizontalLines.push(compTop);
|
if (top === compTop) horizontalLines.push(compTop);
|
||||||
if (Math.abs(top - compBottom) < threshold) horizontalLines.push(compBottom);
|
if (top === compBottom) horizontalLines.push(compBottom);
|
||||||
if (Math.abs(bottom - compTop) < threshold) horizontalLines.push(compTop);
|
if (bottom === compTop) horizontalLines.push(compTop);
|
||||||
if (Math.abs(bottom - compBottom) < threshold) horizontalLines.push(compBottom);
|
if (bottom === compBottom) horizontalLines.push(compBottom);
|
||||||
if (Math.abs(centerY - compCenterY) < threshold) horizontalLines.push(compCenterY);
|
if (centerY === compCenterY) horizontalLines.push(compCenterY);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 중복 제거
|
// 중복 제거
|
||||||
|
|
@ -411,6 +430,100 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
setAlignmentGuides({ vertical: [], horizontal: [] });
|
setAlignmentGuides({ vertical: [], horizontal: [] });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 복사 (Ctrl+C)
|
||||||
|
const copyComponents = useCallback(() => {
|
||||||
|
if (selectedComponentIds.length > 0) {
|
||||||
|
const componentsToCopy = components.filter((comp) => selectedComponentIds.includes(comp.id));
|
||||||
|
setClipboard(componentsToCopy);
|
||||||
|
toast({
|
||||||
|
title: "복사 완료",
|
||||||
|
description: `${componentsToCopy.length}개의 컴포넌트가 복사되었습니다.`,
|
||||||
|
});
|
||||||
|
} else if (selectedComponentId) {
|
||||||
|
const componentToCopy = components.find((comp) => comp.id === selectedComponentId);
|
||||||
|
if (componentToCopy) {
|
||||||
|
setClipboard([componentToCopy]);
|
||||||
|
toast({
|
||||||
|
title: "복사 완료",
|
||||||
|
description: "컴포넌트가 복사되었습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedComponentId, selectedComponentIds, components, toast]);
|
||||||
|
|
||||||
|
// 붙여넣기 (Ctrl+V)
|
||||||
|
const pasteComponents = useCallback(() => {
|
||||||
|
if (clipboard.length === 0) return;
|
||||||
|
|
||||||
|
const newComponents = clipboard.map((comp) => ({
|
||||||
|
...comp,
|
||||||
|
id: `comp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
|
||||||
|
x: comp.x + 20, // 약간 오프셋
|
||||||
|
y: comp.y + 20,
|
||||||
|
zIndex: components.length,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// setComponents를 직접 사용
|
||||||
|
setComponents((prev) => [...prev, ...newComponents]);
|
||||||
|
|
||||||
|
// 새로 생성된 컴포넌트 선택
|
||||||
|
if (newComponents.length === 1) {
|
||||||
|
setSelectedComponentId(newComponents[0].id);
|
||||||
|
setSelectedComponentIds([newComponents[0].id]);
|
||||||
|
} else {
|
||||||
|
setSelectedComponentIds(newComponents.map((c) => c.id));
|
||||||
|
setSelectedComponentId(newComponents[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "붙여넣기 완료",
|
||||||
|
description: `${newComponents.length}개의 컴포넌트가 추가되었습니다.`,
|
||||||
|
});
|
||||||
|
}, [clipboard, components.length, toast]);
|
||||||
|
|
||||||
|
// 히스토리에 현재 상태 저장
|
||||||
|
const saveToHistory = useCallback(
|
||||||
|
(newComponents: ComponentConfig[]) => {
|
||||||
|
setHistory((prev) => {
|
||||||
|
// 현재 인덱스 이후의 히스토리 제거 (새 분기 시작)
|
||||||
|
const newHistory = prev.slice(0, historyIndex + 1);
|
||||||
|
// 새 상태 추가 (최대 50개까지만 유지)
|
||||||
|
newHistory.push(JSON.parse(JSON.stringify(newComponents)));
|
||||||
|
return newHistory.slice(-50);
|
||||||
|
});
|
||||||
|
setHistoryIndex((prev) => Math.min(prev + 1, 49));
|
||||||
|
},
|
||||||
|
[historyIndex],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Undo
|
||||||
|
const undo = useCallback(() => {
|
||||||
|
if (historyIndex > 0) {
|
||||||
|
setIsUndoRedoing(true);
|
||||||
|
setHistoryIndex((prev) => prev - 1);
|
||||||
|
setComponents(JSON.parse(JSON.stringify(history[historyIndex - 1])));
|
||||||
|
setTimeout(() => setIsUndoRedoing(false), 100);
|
||||||
|
toast({
|
||||||
|
title: "실행 취소",
|
||||||
|
description: "이전 상태로 되돌렸습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [historyIndex, history, toast]);
|
||||||
|
|
||||||
|
// Redo
|
||||||
|
const redo = useCallback(() => {
|
||||||
|
if (historyIndex < history.length - 1) {
|
||||||
|
setIsUndoRedoing(true);
|
||||||
|
setHistoryIndex((prev) => prev + 1);
|
||||||
|
setComponents(JSON.parse(JSON.stringify(history[historyIndex + 1])));
|
||||||
|
setTimeout(() => setIsUndoRedoing(false), 100);
|
||||||
|
toast({
|
||||||
|
title: "다시 실행",
|
||||||
|
description: "다음 상태로 이동했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [historyIndex, history, toast]);
|
||||||
|
|
||||||
// 캔버스 설정 (기본값)
|
// 캔버스 설정 (기본값)
|
||||||
const [canvasWidth, setCanvasWidth] = useState(210);
|
const [canvasWidth, setCanvasWidth] = useState(210);
|
||||||
const [canvasHeight, setCanvasHeight] = useState(297);
|
const [canvasHeight, setCanvasHeight] = useState(297);
|
||||||
|
|
@ -491,6 +604,36 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [reportId]);
|
}, [reportId]);
|
||||||
|
|
||||||
|
// 초기 히스토리 설정
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && components.length > 0 && history.length === 0) {
|
||||||
|
// 최초 컴포넌트 로드 시 히스토리에 추가
|
||||||
|
setHistory([JSON.parse(JSON.stringify(components))]);
|
||||||
|
setHistoryIndex(0);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isLoading, components.length]);
|
||||||
|
|
||||||
|
// 컴포넌트 변경 시 히스토리 저장 (디바운스 적용)
|
||||||
|
useEffect(() => {
|
||||||
|
if (components.length === 0) return;
|
||||||
|
if (isLoading) return; // 로딩 중에는 히스토리 저장 안 함
|
||||||
|
if (isUndoRedoing) return; // Undo/Redo 중에는 히스토리 저장 안 함
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
// 현재 히스토리의 마지막 항목과 비교하여 다를 때만 저장
|
||||||
|
const lastHistory = history[historyIndex];
|
||||||
|
const isDifferent = !lastHistory || JSON.stringify(lastHistory) !== JSON.stringify(components);
|
||||||
|
|
||||||
|
if (isDifferent) {
|
||||||
|
saveToHistory(components);
|
||||||
|
}
|
||||||
|
}, 300); // 300ms 디바운스
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [components, isUndoRedoing]);
|
||||||
|
|
||||||
// 쿼리 결과 저장
|
// 쿼리 결과 저장
|
||||||
const setQueryResult = useCallback((queryId: string, fields: string[], rows: Record<string, unknown>[]) => {
|
const setQueryResult = useCallback((queryId: string, fields: string[], rows: Record<string, unknown>[]) => {
|
||||||
setQueryResults((prev) => {
|
setQueryResults((prev) => {
|
||||||
|
|
@ -531,9 +674,34 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
[selectedComponentId],
|
[selectedComponentId],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 컴포넌트 선택
|
// 컴포넌트 선택 (단일/다중)
|
||||||
const selectComponent = useCallback((id: string | null) => {
|
const selectComponent = useCallback((id: string | null, isMultiSelect = false) => {
|
||||||
setSelectedComponentId(id);
|
if (id === null) {
|
||||||
|
// 선택 해제
|
||||||
|
setSelectedComponentId(null);
|
||||||
|
setSelectedComponentIds([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMultiSelect) {
|
||||||
|
// Ctrl+클릭: 다중 선택 토글
|
||||||
|
setSelectedComponentIds((prev) => {
|
||||||
|
if (prev.includes(id)) {
|
||||||
|
// 이미 선택되어 있으면 제거
|
||||||
|
const newSelection = prev.filter((compId) => compId !== id);
|
||||||
|
setSelectedComponentId(newSelection.length > 0 ? newSelection[0] : null);
|
||||||
|
return newSelection;
|
||||||
|
} else {
|
||||||
|
// 선택 추가
|
||||||
|
setSelectedComponentId(id);
|
||||||
|
return [...prev, id];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 일반 클릭: 단일 선택
|
||||||
|
setSelectedComponentId(id);
|
||||||
|
setSelectedComponentIds([id]);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 레이아웃 업데이트
|
// 레이아웃 업데이트
|
||||||
|
|
@ -720,6 +888,7 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
setQueryResult,
|
setQueryResult,
|
||||||
getQueryResult,
|
getQueryResult,
|
||||||
selectedComponentId,
|
selectedComponentId,
|
||||||
|
selectedComponentIds,
|
||||||
isLoading,
|
isLoading,
|
||||||
isSaving,
|
isSaving,
|
||||||
addComponent,
|
addComponent,
|
||||||
|
|
@ -746,6 +915,14 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
||||||
alignmentGuides,
|
alignmentGuides,
|
||||||
calculateAlignmentGuides,
|
calculateAlignmentGuides,
|
||||||
clearAlignmentGuides,
|
clearAlignmentGuides,
|
||||||
|
// 복사/붙여넣기
|
||||||
|
copyComponents,
|
||||||
|
pasteComponents,
|
||||||
|
// Undo/Redo
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
canUndo: historyIndex > 0,
|
||||||
|
canRedo: historyIndex < history.length - 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <ReportDesignerContext.Provider value={value}>{children}</ReportDesignerContext.Provider>;
|
return <ReportDesignerContext.Provider value={value}>{children}</ReportDesignerContext.Provider>;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue