From 726f6ac39597bbc317c40d41aa1173fecb86323e Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Thu, 5 Feb 2026 19:16:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(pop-designer):=20=EB=B0=98=EC=9D=91?= =?UTF-8?q?=ED=98=95=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84=20=EB=AA=A8=EB=93=9C?= =?UTF-8?q?=EB=B3=84(4/6/8/12=EC=B9=B8)=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=9C=84=EC=B9=98/=ED=81=AC=EA=B8=B0=20=EC=98=A4?= =?UTF-8?q?=EB=B2=84=EB=9D=BC=EC=9D=B4=EB=93=9C=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EB=B0=96=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=98=A4=EB=A5=B8=EC=AA=BD=20=ED=8C=A8=EB=84=90=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=EB=B0=8F=20=EB=93=9C=EB=9E=98=EA=B7=B8=20?= =?UTF-8?q?=EC=9E=AC=EB=B0=B0=EC=B9=98=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=88=A8=EA=B9=80=20=EA=B8=B0=EB=8A=A5=20(?= =?UTF-8?q?=EB=93=9C=EB=9E=98=EA=B7=B8/H=ED=82=A4/=ED=81=B4=EB=A6=AD,=20?= =?UTF-8?q?=EB=93=9C=EB=9E=98=EA=B7=B8=EB=A1=9C=20=ED=95=B4=EC=A0=9C)=20?= =?UTF-8?q?=EB=A6=AC=EC=82=AC=EC=9D=B4=EC=A6=88=20=EA=B2=B9=EC=B9=A8=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=20=EC=B6=94=EA=B0=80=20=EB=93=9C=EB=A1=AD=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EA=B7=B8=EB=A6=AC=EB=93=9C=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=EC=B4=88=EA=B3=BC=20=EC=8B=9C=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=20=EC=A1=B0=EC=A0=95=20=EC=88=A8=EA=B9=80=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EB=93=9C=EB=9E=98=EA=B7=B8=20=EC=95=88?= =?UTF-8?q?=EB=90=A8=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20(?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/pop/designer/PopCanvas.tsx | 761 +++++++++++++++--- .../components/pop/designer/PopDesigner.tsx | 218 ++++- .../components/pop/designer/constants/dnd.ts | 14 + .../pop/designer/constants/index.ts | 1 + .../pop/designer/panels/ComponentPalette.tsx | 6 +- .../pop/designer/renderers/PopRenderer.tsx | 362 +++++++-- .../pop/designer/utils/gridUtils.ts | 181 ++++- popdocs/CHANGELOG.md | 271 +++++++ popdocs/FILES.md | 76 +- popdocs/INDEX.md | 15 +- popdocs/PROBLEMS.md | 63 +- popdocs/README.md | 24 +- popdocs/STATUS.md | 64 +- .../decisions/004-grid-guide-integration.md | 143 ++++ popdocs/sessions/2026-02-05.md | 146 +++- 15 files changed, 2088 insertions(+), 257 deletions(-) create mode 100644 frontend/components/pop/designer/constants/dnd.ts create mode 100644 frontend/components/pop/designer/constants/index.ts create mode 100644 popdocs/decisions/004-grid-guide-integration.md diff --git a/frontend/components/pop/designer/PopCanvas.tsx b/frontend/components/pop/designer/PopCanvas.tsx index f86cbd93..1b1d0e70 100644 --- a/frontend/components/pop/designer/PopCanvas.tsx +++ b/frontend/components/pop/designer/PopCanvas.tsx @@ -12,21 +12,58 @@ import { GRID_BREAKPOINTS, DEFAULT_COMPONENT_GRID_SIZE, } from "./types/pop-layout"; -import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet } from "lucide-react"; +import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, Lock, RotateCcw, AlertTriangle, EyeOff } from "lucide-react"; +import { useDrag } from "react-dnd"; import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; import PopRenderer from "./renderers/PopRenderer"; -import { mouseToGridPosition, findNextEmptyPosition } from "./utils/gridUtils"; +import { findNextEmptyPosition, isOverlapping, getAllEffectivePositions, isOutOfBounds } from "./utils/gridUtils"; +import { DND_ITEM_TYPES } from "./constants"; -// DnD 타입 상수 (인라인) -const DND_ITEM_TYPES = { - COMPONENT: "component", -} as const; +/** + * 캔버스 내 상대 좌표 → 그리드 좌표 변환 + * @param relX 캔버스 내 X 좌표 (패딩 포함) + * @param relY 캔버스 내 Y 좌표 (패딩 포함) + */ +function calcGridPosition( + relX: number, + relY: number, + canvasWidth: number, + columns: number, + rowHeight: number, + gap: number, + padding: number +): { col: number; row: number } { + // 패딩 제외한 좌표 + const x = relX - padding; + const y = relY - padding; + + // 사용 가능한 너비 (패딩과 gap 제외) + const availableWidth = canvasWidth - padding * 2 - gap * (columns - 1); + const colWidth = availableWidth / columns; + + // 셀+gap 단위로 계산 + const cellStride = colWidth + gap; + const rowStride = rowHeight + gap; + + // 그리드 좌표 (1부터 시작) + const col = Math.max(1, Math.min(columns, Math.floor(x / cellStride) + 1)); + const row = Math.max(1, Math.floor(y / rowStride) + 1); + + return { col, row }; +} +// 드래그 아이템 타입 정의 interface DragItemComponent { type: typeof DND_ITEM_TYPES.COMPONENT; componentType: PopComponentType; } +interface DragItemMoveComponent { + componentId: string; + originalPosition: PopGridPosition; +} + // ======================================== // 프리셋 해상도 (4개 모드) // ======================================== @@ -56,6 +93,11 @@ interface PopCanvasProps { onDeleteComponent: (componentId: string) => void; onMoveComponent?: (componentId: string, newPosition: PopGridPosition) => void; onResizeComponent?: (componentId: string, newPosition: PopGridPosition) => void; + onResizeEnd?: (componentId: string) => void; + onHideComponent?: (componentId: string) => void; + onUnhideComponent?: (componentId: string) => void; + onLockLayout?: () => void; + onResetOverride?: (mode: GridMode) => void; } // ======================================== @@ -73,6 +115,11 @@ export default function PopCanvas({ onDeleteComponent, onMoveComponent, onResizeComponent, + onResizeEnd, + onHideComponent, + onUnhideComponent, + onLockLayout, + onResetOverride, }: PopCanvasProps) { // 줌 상태 const [canvasScale, setCanvasScale] = useState(0.8); @@ -90,12 +137,6 @@ export default function PopCanvas({ const [isSpacePressed, setIsSpacePressed] = useState(false); const containerRef = useRef(null); const canvasRef = useRef(null); - - // 드래그 상태 - const [isDraggingComponent, setIsDraggingComponent] = useState(false); - const [draggedComponentId, setDraggedComponentId] = useState(null); - const [dragStartPos, setDragStartPos] = useState<{ x: number; y: number } | null>(null); - const [dragPreviewPos, setDragPreviewPos] = useState(null); // 현재 뷰포트 해상도 const currentPreset = VIEWPORT_PRESETS.find((p) => p.id === currentMode)!; @@ -167,56 +208,191 @@ export default function PopCanvas({ }; }, [isSpacePressed]); - // 컴포넌트 드롭 (팔레트에서) + // 통합 드롭 핸들러 (팔레트에서 추가 + 컴포넌트 이동) const [{ isOver, canDrop }, drop] = useDrop( () => ({ - accept: DND_ITEM_TYPES.COMPONENT, - drop: (item: DragItemComponent, monitor) => { + accept: [DND_ITEM_TYPES.COMPONENT, DND_ITEM_TYPES.MOVE_COMPONENT], + drop: (item: DragItemComponent | DragItemMoveComponent, monitor) => { if (!canvasRef.current) return; - const offset = monitor.getClientOffset(); - if (!offset) return; - const canvasRect = canvasRef.current.getBoundingClientRect(); + const itemType = monitor.getItemType(); - // 마우스 위치 → 그리드 좌표 변환 - const gridPos = mouseToGridPosition( - offset.x, - offset.y, - canvasRect, - breakpoint.columns, - breakpoint.rowHeight, - breakpoint.gap, - breakpoint.padding - ); + // 팔레트에서 새 컴포넌트 추가 - 마우스 위치 기준 + if (itemType === DND_ITEM_TYPES.COMPONENT) { + const offset = monitor.getClientOffset(); + if (!offset) return; + + // 캔버스 내 상대 좌표 (스케일 보정) + // canvasRect는 scale 적용된 크기이므로, 상대 좌표를 scale로 나눠야 실제 좌표 + const relX = (offset.x - canvasRect.left) / canvasScale; + const relY = (offset.y - canvasRect.top) / canvasScale; + + // 그리드 좌표 계산 + const gridPos = calcGridPosition( + relX, + relY, + customWidth, + breakpoint.columns, + breakpoint.rowHeight, + breakpoint.gap, + breakpoint.padding + ); + + const dragItem = item as DragItemComponent; + const defaultSize = DEFAULT_COMPONENT_GRID_SIZE[dragItem.componentType]; + + const candidatePosition: PopGridPosition = { + col: gridPos.col, + row: gridPos.row, + colSpan: defaultSize.colSpan, + rowSpan: defaultSize.rowSpan, + }; + + // 현재 모드에서의 유효 위치들로 중첩 검사 + const effectivePositions = getAllEffectivePositions(layout, currentMode); + const existingPositions = Array.from(effectivePositions.values()); + + const hasOverlap = existingPositions.some(pos => + isOverlapping(candidatePosition, pos) + ); + + let finalPosition: PopGridPosition; + + if (hasOverlap) { + finalPosition = findNextEmptyPosition( + existingPositions, + defaultSize.colSpan, + defaultSize.rowSpan, + breakpoint.columns + ); + toast.info("겹치는 위치입니다. 빈 위치로 자동 배치됩니다."); + } else { + finalPosition = candidatePosition; + } + + onDropComponent(dragItem.componentType, finalPosition); + } - // 컴포넌트 기본 크기 - const defaultSize = DEFAULT_COMPONENT_GRID_SIZE[item.componentType]; - - // 다음 빈 위치 찾기 - const existingPositions = Object.values(layout.components).map(c => c.position); - const position = findNextEmptyPosition( - existingPositions, - defaultSize.colSpan, - defaultSize.rowSpan, - breakpoint.columns - ); - - // 컴포넌트 추가 - onDropComponent(item.componentType, position); + // 기존 컴포넌트 이동 - 마우스 위치 기준 + if (itemType === DND_ITEM_TYPES.MOVE_COMPONENT) { + const offset = monitor.getClientOffset(); + if (!offset) return; + + // 캔버스 내 상대 좌표 (스케일 보정) + const relX = (offset.x - canvasRect.left) / canvasScale; + const relY = (offset.y - canvasRect.top) / canvasScale; + + const gridPos = calcGridPosition( + relX, + relY, + customWidth, + breakpoint.columns, + breakpoint.rowHeight, + breakpoint.gap, + breakpoint.padding + ); + + const dragItem = item as DragItemMoveComponent & { fromHidden?: boolean }; + + // 현재 모드에서의 유효 위치들 가져오기 + const effectivePositions = getAllEffectivePositions(layout, currentMode); + + // 이동 중인 컴포넌트의 현재 유효 위치에서 colSpan/rowSpan 가져오기 + // 초과 컴포넌트(OutOfBoundsPanel에서 드래그)나 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용 + const currentEffectivePos = effectivePositions.get(dragItem.componentId); + const componentData = layout.components[dragItem.componentId]; + + if (!currentEffectivePos && !componentData) return; + + const sourcePosition = currentEffectivePos || componentData.position; + + // colSpan이 현재 모드의 columns를 초과하면 제한 + const adjustedColSpan = Math.min(sourcePosition.colSpan, breakpoint.columns); + + // 드롭 위치 + 크기가 범위를 초과하면 드롭 위치를 자동 조정 + let adjustedCol = gridPos.col; + if (adjustedCol + adjustedColSpan - 1 > breakpoint.columns) { + adjustedCol = Math.max(1, breakpoint.columns - adjustedColSpan + 1); + } + + const newPosition: PopGridPosition = { + col: adjustedCol, + row: gridPos.row, + colSpan: adjustedColSpan, + rowSpan: sourcePosition.rowSpan, + }; + + // 자기 자신 제외한 다른 컴포넌트들의 유효 위치와 겹침 체크 + const hasOverlap = Array.from(effectivePositions.entries()).some(([id, pos]) => { + if (id === dragItem.componentId) return false; // 자기 자신 제외 + return isOverlapping(newPosition, pos); + }); + + if (hasOverlap) { + toast.error("이 위치로 이동할 수 없습니다 (다른 컴포넌트와 겹침)"); + return; + } + + // 이동 처리 (숨김 컴포넌트의 경우 handleMoveComponent에서 숨김 해제도 함께 처리됨) + onMoveComponent?.(dragItem.componentId, newPosition); + + // 숨김 패널에서 드래그한 경우 안내 메시지 + if (dragItem.fromHidden) { + toast.info("컴포넌트가 다시 표시됩니다"); + } + } }, collect: (monitor) => ({ isOver: monitor.isOver(), canDrop: monitor.canDrop(), }), }), - [onDropComponent, breakpoint, layout.components] + [onDropComponent, onMoveComponent, onUnhideComponent, breakpoint, layout, currentMode, canvasScale, customWidth, customHeight] ); drop(canvasRef); // 빈 상태 체크 const isEmpty = Object.keys(layout.components).length === 0; + + // 숨김 처리된 컴포넌트 목록 + const hiddenComponentIds = useMemo(() => { + return layout.overrides?.[currentMode]?.hidden || []; + }, [layout.overrides, currentMode]); + + // 숨김 처리된 컴포넌트 객체 목록 + const hiddenComponents = useMemo(() => { + return hiddenComponentIds + .map(id => layout.components[id]) + .filter(Boolean); + }, [hiddenComponentIds, layout.components]); + + // 초과 컴포넌트 목록 (오른쪽 영역에 표시) + // 오버라이드가 있는 컴포넌트는 오버라이드 위치로 판단 + // 숨김 처리된 컴포넌트는 제외 + const outOfBoundsComponents = useMemo(() => { + return Object.values(layout.components).filter(comp => { + // 숨김 처리된 컴포넌트는 초과 목록에서 제외 + if (hiddenComponentIds.includes(comp.id)) return false; + + // 오버라이드 위치 확인 + const overridePos = layout.overrides?.[currentMode]?.positions?.[comp.id]; + const overridePosition = overridePos + ? { ...comp.position, ...overridePos } + : null; + + return isOutOfBounds(comp.position, currentMode, overridePosition); + }); + }, [layout.components, layout.overrides, currentMode, hiddenComponentIds]); + + // 12칸 모드가 아닐 때만 패널 표시 + // 초과 컴포넌트: 있을 때만 표시 + // 숨김 패널: 숨김 컴포넌트가 있거나, 그리드에 컴포넌트가 있을 때 드롭 영역으로 표시 + const showOutOfBoundsPanel = currentMode !== "tablet_landscape" && outOfBoundsComponents.length > 0; + const hasGridComponents = Object.keys(layout.components).length > 0; + const showHiddenPanel = currentMode !== "tablet_landscape" && (hiddenComponents.length > 0 || hasGridComponents); + const showRightPanel = showOutOfBoundsPanel || showHiddenPanel; return (
@@ -250,6 +426,35 @@ export default function PopCanvas({
+ {/* 고정/되돌리기 버튼 (기본 모드 아닐 때만 표시) */} + {currentMode !== DEFAULT_PRESET && ( + <> + + + {layout.overrides?.[currentMode] && ( + + )} + + )} + +
+ {/* 해상도 표시 */}
{customWidth} × {customHeight} @@ -318,102 +523,138 @@ export default function PopCanvas({ onWheel={handleWheel} >
- {/* 그리드 라벨 영역 */} - {showGridGuide && ( - <> - {/* 열 라벨 (상단) */} -
- {gridLabels.columnLabels.map((num) => ( -
- {num} -
- ))} -
- - {/* 행 라벨 (좌측) */} -
- {gridLabels.rowLabels.map((num) => ( -
- {num} -
- ))} -
- - )} - - {/* 디바이스 스크린 */} -
+ {/* 그리드 라벨 영역 */} + {showGridGuide && ( + <> + {/* 열 라벨 (상단) */} +
+ {gridLabels.columnLabels.map((num) => ( +
+ {num} +
+ ))} +
+ + {/* 행 라벨 (좌측) */} +
+ {gridLabels.rowLabels.map((num) => ( +
+ {num} +
+ ))} +
+ )} - style={{ - width: `${customWidth}px`, - minHeight: `${customHeight}px`, - marginLeft: "32px", - marginTop: "32px", - }} - > - {isEmpty ? ( - // 빈 상태 -
-
-
- 컴포넌트를 드래그하여 배치하세요 -
-
- {breakpoint.label} - {breakpoint.columns}칸 그리드 + + {/* 디바이스 스크린 */} +
+ {isEmpty ? ( + // 빈 상태 +
+
+
+ 컴포넌트를 드래그하여 배치하세요 +
+
+ {breakpoint.label} - {breakpoint.columns}칸 그리드 +
-
- ) : ( - // 그리드 렌더러 - onSelectComponent(null)} - /> - )} + ) : ( + // 그리드 렌더러 + onSelectComponent(null)} + onComponentMove={onMoveComponent} + onComponentResize={onResizeComponent} + onComponentResizeEnd={onResizeEnd} + /> + )} +
+ + {/* 오른쪽 패널 영역 (초과 컴포넌트 + 숨김 컴포넌트) */} + {showRightPanel && ( +
+ {/* 초과 컴포넌트 패널 */} + {showOutOfBoundsPanel && ( + + )} + + {/* 숨김 컴포넌트 패널 */} + {showHiddenPanel && ( + + )} +
+ )}
@@ -429,3 +670,263 @@ export default function PopCanvas({
); } + +// ======================================== +// 초과 컴포넌트 영역 (오른쪽 패널) +// ======================================== + +interface OutOfBoundsPanelProps { + components: PopComponentDefinitionV5[]; + selectedComponentId: string | null; + onSelectComponent: (id: string | null) => void; + onHideComponent?: (componentId: string) => void; +} + +function OutOfBoundsPanel({ + components, + selectedComponentId, + onSelectComponent, + onHideComponent, +}: OutOfBoundsPanelProps) { + return ( +
+ {/* 헤더 */} +
+ + + 화면 밖 ({components.length}개) + +
+ + {/* 컴포넌트 목록 */} +
+ {components.map((comp) => ( + onSelectComponent(comp.id)} + onHide={() => onHideComponent?.(comp.id)} + /> + ))} +
+ + {/* 안내 문구 */} +
+

+ 드래그로 그리드 배치 / 클릭하면 숨김 처리 +

+
+
+ ); +} + +// ======================================== +// 숨김 컴포넌트 영역 (오른쪽 패널) +// ======================================== + +interface HiddenPanelProps { + components: PopComponentDefinitionV5[]; + selectedComponentId: string | null; + onSelectComponent: (id: string | null) => void; + onHideComponent?: (componentId: string) => void; +} + +function HiddenPanel({ + components, + selectedComponentId, + onSelectComponent, + onHideComponent, +}: HiddenPanelProps) { + // 그리드에서 컴포넌트를 드래그하여 이 패널에 드롭하면 숨김 처리 + const [{ isOver, canDrop }, drop] = useDrop( + () => ({ + accept: DND_ITEM_TYPES.MOVE_COMPONENT, + drop: (item: { componentId: string; fromHidden?: boolean }) => { + // 이미 숨김 패널에서 온 아이템은 무시 + if (item.fromHidden) return; + + // 숨김 처리 + onHideComponent?.(item.componentId); + toast.info("컴포넌트가 숨김 처리되었습니다"); + }, + canDrop: (item: { componentId: string; fromHidden?: boolean }) => { + // 숨김 패널에서 온 아이템은 드롭 불가 + return !item.fromHidden; + }, + collect: (monitor) => ({ + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + }), + }), + [onHideComponent] + ); + + return ( +
+ {/* 헤더 */} +
+ + + 숨김 ({components.length}개) + +
+ + {/* 컴포넌트 목록 */} +
+ {components.map((comp) => ( + onSelectComponent(comp.id)} + /> + ))} +
+ + {/* 안내 문구 */} +
+

+ 그리드로 드래그하여 다시 표시 +

+
+
+ ); +} + +// ======================================== +// 초과 컴포넌트 아이템 (드래그 가능) +// ======================================== + +interface OutOfBoundsItemProps { + component: PopComponentDefinitionV5; + isSelected: boolean; + onSelect: () => void; + onHide: () => void; +} + +function OutOfBoundsItem({ + component, + isSelected, + onSelect, + onHide, +}: OutOfBoundsItemProps) { + const [{ isDragging }, drag] = useDrag( + () => ({ + type: DND_ITEM_TYPES.MOVE_COMPONENT, + item: { + componentId: component.id, + originalPosition: component.position, + }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }), + [component.id, component.position] + ); + + // 클릭 시 숨김 처리 + const handleClick = () => { + onSelect(); + onHide(); + }; + + return ( +
+ {/* 컴포넌트 이름 */} +
+ {component.label || component.type} +
+ + {/* 원본 위치 정보 */} +
+ 원본: {component.position.col}열, {component.position.row}행 + ({component.position.colSpan}×{component.position.rowSpan}) +
+
+ ); +} + +// ======================================== +// 숨김 컴포넌트 아이템 (드래그 가능) +// ======================================== + +interface HiddenItemProps { + component: PopComponentDefinitionV5; + isSelected: boolean; + onSelect: () => void; +} + +function HiddenItem({ + component, + isSelected, + onSelect, +}: HiddenItemProps) { + const [{ isDragging }, drag] = useDrag( + () => ({ + type: DND_ITEM_TYPES.MOVE_COMPONENT, + item: { + componentId: component.id, + originalPosition: component.position, + fromHidden: true, // 숨김 패널에서 왔음을 표시 + }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }), + [component.id, component.position] + ); + + return ( +
+ {/* 컴포넌트 이름 */} +
+ + {component.label || component.type} +
+ + {/* 원본 위치 정보 */} +
+ 원본: {component.position.col}열, {component.position.row}행 +
+
+ ); +} diff --git a/frontend/components/pop/designer/PopDesigner.tsx b/frontend/components/pop/designer/PopDesigner.tsx index 58488531..1ee13c9a 100644 --- a/frontend/components/pop/designer/PopDesigner.tsx +++ b/frontend/components/pop/designer/PopDesigner.tsx @@ -24,7 +24,9 @@ import { createEmptyPopLayoutV5, isV5Layout, addComponentToV5Layout, + GRID_BREAKPOINTS, } from "./types/pop-layout"; +import { getAllEffectivePositions } from "./utils/gridUtils"; import { screenApi } from "@/lib/api/screen"; import { ScreenDefinition } from "@/types/screen"; @@ -231,6 +233,206 @@ export default function PopDesigner({ [layout, saveToHistory] ); + const handleMoveComponent = useCallback( + (componentId: string, newPosition: PopGridPosition) => { + const component = layout.components[componentId]; + if (!component) return; + + // 기본 모드(tablet_landscape, 12칸)인 경우: 원본 position 직접 수정 + if (currentMode === "tablet_landscape") { + const newLayout = { + ...layout, + components: { + ...layout.components, + [componentId]: { + ...component, + position: newPosition, + }, + }, + }; + setLayout(newLayout); + saveToHistory(newLayout); + setHasChanges(true); + } else { + // 다른 모드인 경우: 오버라이드에 저장 + // 숨김 상태였던 컴포넌트를 이동하면 숨김 해제도 함께 처리 + const currentHidden = layout.overrides?.[currentMode]?.hidden || []; + const isHidden = currentHidden.includes(componentId); + const newHidden = isHidden + ? currentHidden.filter(id => id !== componentId) + : currentHidden; + + const newLayout = { + ...layout, + overrides: { + ...layout.overrides, + [currentMode]: { + ...layout.overrides?.[currentMode], + positions: { + ...layout.overrides?.[currentMode]?.positions, + [componentId]: newPosition, + }, + // 숨김 배열 업데이트 (빈 배열이면 undefined로) + hidden: newHidden.length > 0 ? newHidden : undefined, + }, + }, + }; + setLayout(newLayout); + saveToHistory(newLayout); + setHasChanges(true); + } + }, + [layout, saveToHistory, currentMode] + ); + + const handleResizeComponent = useCallback( + (componentId: string, newPosition: PopGridPosition) => { + const component = layout.components[componentId]; + if (!component) return; + + // 기본 모드(tablet_landscape, 12칸)인 경우: 원본 position 직접 수정 + if (currentMode === "tablet_landscape") { + const newLayout = { + ...layout, + components: { + ...layout.components, + [componentId]: { + ...component, + position: newPosition, + }, + }, + }; + setLayout(newLayout); + // 리사이즈는 드래그 중 계속 호출되므로 히스토리는 마우스업 시에만 저장 + // 현재는 간단히 매번 저장 (최적화 가능) + setHasChanges(true); + } else { + // 다른 모드인 경우: 오버라이드에 저장 + const newLayout = { + ...layout, + overrides: { + ...layout.overrides, + [currentMode]: { + ...layout.overrides?.[currentMode], + positions: { + ...layout.overrides?.[currentMode]?.positions, + [componentId]: newPosition, + }, + }, + }, + }; + setLayout(newLayout); + setHasChanges(true); + } + }, + [layout, currentMode] + ); + + const handleResizeEnd = useCallback( + (componentId: string) => { + // 리사이즈 완료 시 현재 레이아웃을 히스토리에 저장 + saveToHistory(layout); + }, + [layout, saveToHistory] + ); + + // ======================================== + // 모드별 오버라이드 관리 + // ======================================== + + const handleLockLayout = useCallback(() => { + // 현재 화면에 보이는 유효 위치들을 저장 (오버라이드 또는 자동 재배치 위치) + const effectivePositions = getAllEffectivePositions(layout, currentMode); + + const positionsToSave: Record = {}; + effectivePositions.forEach((position, componentId) => { + positionsToSave[componentId] = position; + }); + + const newLayout = { + ...layout, + overrides: { + ...layout.overrides, + [currentMode]: { + ...layout.overrides?.[currentMode], + positions: positionsToSave, + }, + }, + }; + setLayout(newLayout); + saveToHistory(newLayout); + setHasChanges(true); + toast.success("현재 배치가 고정되었습니다"); + }, [layout, currentMode, saveToHistory]); + + const handleResetOverride = useCallback((mode: GridMode) => { + const newOverrides = { ...layout.overrides }; + delete newOverrides[mode]; + + const newLayout = { + ...layout, + overrides: Object.keys(newOverrides).length > 0 ? newOverrides : undefined, + }; + setLayout(newLayout); + saveToHistory(newLayout); + setHasChanges(true); + toast.success("자동 배치로 되돌렸습니다"); + }, [layout, saveToHistory]); + + // ======================================== + // 숨김 관리 + // ======================================== + + const handleHideComponent = useCallback((componentId: string) => { + // 12칸 모드에서는 숨기기 불가 + if (currentMode === "tablet_landscape") return; + + const currentHidden = layout.overrides?.[currentMode]?.hidden || []; + + // 이미 숨겨져 있으면 무시 + if (currentHidden.includes(componentId)) return; + + const newHidden = [...currentHidden, componentId]; + + const newLayout = { + ...layout, + overrides: { + ...layout.overrides, + [currentMode]: { + ...layout.overrides?.[currentMode], + hidden: newHidden, + }, + }, + }; + setLayout(newLayout); + saveToHistory(newLayout); + setHasChanges(true); + setSelectedComponentId(null); + }, [layout, currentMode, saveToHistory]); + + const handleUnhideComponent = useCallback((componentId: string) => { + const currentHidden = layout.overrides?.[currentMode]?.hidden || []; + + // 숨겨져 있지 않으면 무시 + if (!currentHidden.includes(componentId)) return; + + const newHidden = currentHidden.filter(id => id !== componentId); + + const newLayout = { + ...layout, + overrides: { + ...layout.overrides, + [currentMode]: { + ...layout.overrides?.[currentMode], + hidden: newHidden.length > 0 ? newHidden : undefined, + }, + }, + }; + setLayout(newLayout); + saveToHistory(newLayout); + setHasChanges(true); + }, [layout, currentMode, saveToHistory]); + // ======================================== // 뒤로가기 // ======================================== @@ -285,11 +487,18 @@ export default function PopDesigner({ handleSave(); return; } + + // H키: 선택된 컴포넌트 숨김 (12칸 모드가 아닐 때만) + if (key === "h" && !isCtrlOrCmd && selectedComponentId) { + e.preventDefault(); + handleHideComponent(selectedComponentId); + return; + } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [selectedComponentId, handleDeleteComponent, canUndo, canRedo, undo, redo, handleSave]); + }, [selectedComponentId, handleDeleteComponent, handleHideComponent, canUndo, canRedo, undo, redo, handleSave]); // ======================================== // 로딩 @@ -382,6 +591,13 @@ export default function PopDesigner({ onDropComponent={handleDropComponent} onUpdateComponent={handleUpdateComponent} onDeleteComponent={handleDeleteComponent} + onMoveComponent={handleMoveComponent} + onResizeComponent={handleResizeComponent} + onResizeEnd={handleResizeEnd} + onHideComponent={handleHideComponent} + onUnhideComponent={handleUnhideComponent} + onLockLayout={handleLockLayout} + onResetOverride={handleResetOverride} /> diff --git a/frontend/components/pop/designer/constants/dnd.ts b/frontend/components/pop/designer/constants/dnd.ts new file mode 100644 index 00000000..d73d663e --- /dev/null +++ b/frontend/components/pop/designer/constants/dnd.ts @@ -0,0 +1,14 @@ +/** + * DnD(Drag and Drop) 관련 상수 + */ + +// DnD 아이템 타입 +export const DND_ITEM_TYPES = { + /** 팔레트에서 새 컴포넌트 드래그 */ + COMPONENT: "POP_COMPONENT", + /** 캔버스 내 기존 컴포넌트 이동 */ + MOVE_COMPONENT: "POP_MOVE_COMPONENT", +} as const; + +// 타입 추출 +export type DndItemType = typeof DND_ITEM_TYPES[keyof typeof DND_ITEM_TYPES]; diff --git a/frontend/components/pop/designer/constants/index.ts b/frontend/components/pop/designer/constants/index.ts new file mode 100644 index 00000000..ac8e9724 --- /dev/null +++ b/frontend/components/pop/designer/constants/index.ts @@ -0,0 +1 @@ +export * from "./dnd"; diff --git a/frontend/components/pop/designer/panels/ComponentPalette.tsx b/frontend/components/pop/designer/panels/ComponentPalette.tsx index c7f80b97..d594bbbc 100644 --- a/frontend/components/pop/designer/panels/ComponentPalette.tsx +++ b/frontend/components/pop/designer/panels/ComponentPalette.tsx @@ -4,11 +4,7 @@ import { useDrag } from "react-dnd"; import { cn } from "@/lib/utils"; import { PopComponentType } from "../types/pop-layout"; import { Square } from "lucide-react"; - -// DnD 타입 상수 -const DND_ITEM_TYPES = { - COMPONENT: "component", -} as const; +import { DND_ITEM_TYPES } from "../constants"; // 컴포넌트 정의 interface PaletteItem { diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx index 095138e2..e54918ec 100644 --- a/frontend/components/pop/designer/renderers/PopRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -1,16 +1,25 @@ "use client"; import React, { useMemo } from "react"; +import { useDrag } from "react-dnd"; import { cn } from "@/lib/utils"; +import { DND_ITEM_TYPES } from "../constants"; import { PopLayoutDataV5, PopComponentDefinitionV5, PopGridPosition, GridMode, GRID_BREAKPOINTS, + GridBreakpoint, detectGridMode, PopComponentType, } from "../types/pop-layout"; +import { + convertAndResolvePositions, + isOutOfBounds, + isOverlapping, + getAllEffectivePositions, +} from "../utils/gridUtils"; // ======================================== // Props @@ -33,6 +42,12 @@ interface PopRendererProps { onComponentClick?: (componentId: string) => void; /** 배경 클릭 */ onBackgroundClick?: () => void; + /** 컴포넌트 이동 */ + onComponentMove?: (componentId: string, newPosition: PopGridPosition) => void; + /** 컴포넌트 크기 조정 */ + onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void; + /** 컴포넌트 크기 조정 완료 (히스토리 저장용) */ + onComponentResizeEnd?: (componentId: string) => void; /** 추가 className */ className?: string; } @@ -58,6 +73,9 @@ export default function PopRenderer({ selectedComponentId, onComponentClick, onBackgroundClick, + onComponentMove, + onComponentResize, + onComponentResizeEnd, className, }: PopRendererProps) { const { gridConfig, components, overrides } = layout; @@ -104,46 +122,39 @@ export default function PopRenderer({ return modeVisibility !== false; }; + // 자동 재배치된 위치 계산 (오버라이드 없을 때) + const autoResolvedPositions = useMemo(() => { + const componentsArray = Object.entries(components).map(([id, comp]) => ({ + id, + position: comp.position, + })); + + return convertAndResolvePositions(componentsArray, mode); + }, [components, mode]); + // 위치 변환 (12칸 기준 → 현재 모드 칸 수) const convertPosition = (position: PopGridPosition): React.CSSProperties => { - const sourceColumns = 12; // 항상 12칸 기준으로 저장 - const targetColumns = breakpoint.columns; - - // 같은 칸 수면 그대로 사용 - if (sourceColumns === targetColumns) { - return { - gridColumn: `${position.col} / span ${position.colSpan}`, - gridRow: `${position.row} / span ${position.rowSpan}`, - }; - } - - // 비율 계산 (12칸 → 4칸, 6칸, 8칸) - const ratio = targetColumns / sourceColumns; - - // 열 위치 변환 - let newCol = Math.max(1, Math.ceil((position.col - 1) * ratio) + 1); - let newColSpan = Math.max(1, Math.round(position.colSpan * ratio)); - - // 범위 초과 방지 - if (newCol > targetColumns) { - newCol = 1; - } - if (newCol + newColSpan - 1 > targetColumns) { - newColSpan = targetColumns - newCol + 1; - } - return { - gridColumn: `${newCol} / span ${Math.max(1, newColSpan)}`, + gridColumn: `${position.col} / span ${position.colSpan}`, gridRow: `${position.row} / span ${position.rowSpan}`, }; }; - // 오버라이드 적용 + // 오버라이드 적용 또는 자동 재배치 const getEffectivePosition = (comp: PopComponentDefinitionV5): PopGridPosition => { + // 1순위: 오버라이드가 있으면 사용 const override = overrides?.[mode]?.positions?.[comp.id]; if (override) { return { ...comp.position, ...override }; } + + // 2순위: 자동 재배치된 위치 사용 + const autoResolved = autoResolvedPositions.find(p => p.id === comp.id); + if (autoResolved) { + return autoResolved.position; + } + + // 3순위: 원본 위치 (12칸 모드) return comp.position; }; @@ -152,6 +163,12 @@ export default function PopRenderer({ return overrides?.[mode]?.hidden?.includes(comp.id) ?? false; }; + // 모든 컴포넌트의 유효 위치 계산 (리사이즈 겹침 검사용) + const effectivePositionsMap = useMemo(() => + getAllEffectivePositions(layout, mode), + [layout, mode] + ); + return (
{ // visibility 체크 if (!isVisible(comp)) return null; @@ -182,49 +200,275 @@ export default function PopRenderer({ // 오버라이드 숨김 체크 if (isHiddenByOverride(comp)) return null; + // 오버라이드 위치 가져오기 (있으면) + const overridePos = overrides?.[mode]?.positions?.[comp.id]; + const overridePosition = overridePos + ? { ...comp.position, ...overridePos } + : null; + + // 초과 컴포넌트 체크 (오버라이드 고려) + const outOfBounds = isOutOfBounds(comp.position, mode, overridePosition); + + // 디자인 모드에서 초과 컴포넌트는 그리드에 렌더링하지 않음 + // (PopCanvas의 OutOfBoundsPanel에서 별도로 렌더링) + if (isDesignMode && outOfBounds) return null; + const position = getEffectivePosition(comp); const positionStyle = convertPosition(position); const isSelected = selectedComponentId === comp.id; return ( -
{ - e.stopPropagation(); - onComponentClick?.(comp.id); - }} - > - -
+ component={comp} + position={position} + positionStyle={positionStyle} + isSelected={isSelected} + isDesignMode={isDesignMode} + isOutOfBounds={false} + breakpoint={breakpoint} + viewportWidth={viewportWidth} + allEffectivePositions={effectivePositionsMap} + onComponentClick={onComponentClick} + onComponentMove={onComponentMove} + onComponentResize={onComponentResize} + onComponentResizeEnd={onComponentResizeEnd} + /> ); })}
); } +// ======================================== +// 드래그 가능한 컴포넌트 래퍼 +// ======================================== + +interface DraggableComponentProps { + component: PopComponentDefinitionV5; + position: PopGridPosition; + positionStyle: React.CSSProperties; + isSelected: boolean; + isDesignMode: boolean; + isOutOfBounds: boolean; + breakpoint: GridBreakpoint; + viewportWidth: number; + allEffectivePositions: Map; + onComponentClick?: (componentId: string) => void; + onComponentMove?: (componentId: string, newPosition: PopGridPosition) => void; + onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void; + onComponentResizeEnd?: (componentId: string) => void; +} + +function DraggableComponent({ + component, + position, + positionStyle, + isSelected, + isDesignMode, + isOutOfBounds, + breakpoint, + viewportWidth, + allEffectivePositions, + onComponentClick, + onComponentMove, + onComponentResize, + onComponentResizeEnd, +}: DraggableComponentProps) { + const [{ isDragging }, drag] = useDrag( + () => ({ + type: DND_ITEM_TYPES.MOVE_COMPONENT, + item: { + componentId: component.id, + originalPosition: position + }, + canDrag: isDesignMode, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }), + [component.id, position, isDesignMode] + ); + + return ( +
{ + e.stopPropagation(); + onComponentClick?.(component.id); + }} + title={ + isDesignMode && isOutOfBounds + ? `이 컴포넌트는 ${breakpoint.label}에서 표시되지 않습니다` + : undefined + } + > + + + {/* 리사이즈 핸들 (선택된 컴포넌트만, 초과 아닐 때만) */} + {isDesignMode && isSelected && !isOutOfBounds && onComponentResize && ( + + )} +
+ ); +} + +// ======================================== +// 리사이즈 핸들 +// ======================================== + +interface ResizeHandlesProps { + component: PopComponentDefinitionV5; + position: PopGridPosition; + breakpoint: GridBreakpoint; + viewportWidth: number; + allEffectivePositions: Map; + onResize: (componentId: string, newPosition: PopGridPosition) => void; + onResizeEnd?: (componentId: string) => void; +} + +function ResizeHandles({ + component, + position, + breakpoint, + viewportWidth, + allEffectivePositions, + onResize, + onResizeEnd, +}: ResizeHandlesProps) { + const handleMouseDown = (direction: 'right' | 'bottom' | 'corner') => (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + + const startX = e.clientX; + const startY = e.clientY; + const startColSpan = position.colSpan; + const startRowSpan = position.rowSpan; + + // 그리드 셀 크기 동적 계산 + // 사용 가능한 너비 = 뷰포트 너비 - 양쪽 패딩 - gap*(칸수-1) + const availableWidth = viewportWidth - breakpoint.padding * 2 - breakpoint.gap * (breakpoint.columns - 1); + const cellWidth = availableWidth / breakpoint.columns + breakpoint.gap; // 셀 너비 + gap 단위 + const cellHeight = breakpoint.rowHeight + breakpoint.gap; + + const handleMouseMove = (e: MouseEvent) => { + const deltaX = e.clientX - startX; + const deltaY = e.clientY - startY; + + let newColSpan = startColSpan; + let newRowSpan = startRowSpan; + + if (direction === 'right' || direction === 'corner') { + const colDelta = Math.round(deltaX / cellWidth); + newColSpan = Math.max(1, startColSpan + colDelta); + // 최대 칸 수 제한 + newColSpan = Math.min(newColSpan, breakpoint.columns - position.col + 1); + } + + if (direction === 'bottom' || direction === 'corner') { + const rowDelta = Math.round(deltaY / cellHeight); + newRowSpan = Math.max(1, startRowSpan + rowDelta); + } + + // 변경사항이 있으면 업데이트 + if (newColSpan !== position.colSpan || newRowSpan !== position.rowSpan) { + const newPosition: PopGridPosition = { + ...position, + colSpan: newColSpan, + rowSpan: newRowSpan, + }; + + // 유효 위치 기반 겹침 검사 (다른 컴포넌트와) + const hasOverlap = Array.from(allEffectivePositions.entries()).some( + ([id, pos]) => { + if (id === component.id) return false; // 자기 자신 제외 + return isOverlapping(newPosition, pos); + } + ); + + // 겹치지 않을 때만 리사이즈 적용 + if (!hasOverlap) { + onResize(component.id, newPosition); + } + } + }; + + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + // 리사이즈 완료 알림 (히스토리 저장용) + onResizeEnd?.(component.id); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; + + return ( + <> + {/* 오른쪽 핸들 (가로 크기) */} +
+ + {/* 아래쪽 핸들 (세로 크기) */} +
+ + {/* 오른쪽 아래 모서리 (가로+세로) */} +
+ + ); +} + // ======================================== // 컴포넌트 내용 렌더링 // ======================================== interface ComponentContentProps { component: PopComponentDefinitionV5; + effectivePosition: PopGridPosition; isDesignMode: boolean; isSelected: boolean; + isOutOfBounds: boolean; } -function ComponentContent({ component, isDesignMode, isSelected }: ComponentContentProps) { +function ComponentContent({ component, effectivePosition, isDesignMode, isSelected, isOutOfBounds }: ComponentContentProps) { const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type; // 디자인 모드: 플레이스홀더 표시 @@ -244,17 +488,29 @@ function ComponentContent({ component, isDesignMode, isSelected }: ComponentCont )}> {component.label || typeLabel} + + {/* 초과 표시 */} + {isOutOfBounds && ( + + 밖 + + )}
{/* 내용 */}
- {typeLabel} + + {typeLabel} +
- {/* 위치 정보 표시 */} + {/* 위치 정보 표시 (유효 위치 사용) */}
- {component.position.col},{component.position.row} - ({component.position.colSpan}×{component.position.rowSpan}) + {effectivePosition.col},{effectivePosition.row} + ({effectivePosition.colSpan}×{effectivePosition.rowSpan})
); diff --git a/frontend/components/pop/designer/utils/gridUtils.ts b/frontend/components/pop/designer/utils/gridUtils.ts index d7e49ef8..1c36b519 100644 --- a/frontend/components/pop/designer/utils/gridUtils.ts +++ b/frontend/components/pop/designer/utils/gridUtils.ts @@ -1,7 +1,9 @@ import { PopGridPosition, GridMode, - GRID_BREAKPOINTS + GRID_BREAKPOINTS, + PopLayoutDataV5, + PopComponentDefinitionV5, } from "../types/pop-layout"; // ======================================== @@ -45,6 +47,62 @@ export function convertPositionToMode( }; } +/** + * 여러 컴포넌트를 모드별로 변환하고 겹침 해결 + */ +export function convertAndResolvePositions( + components: Array<{ id: string; position: PopGridPosition }>, + targetMode: GridMode +): Array<{ id: string; position: PopGridPosition }> { + const targetColumns = GRID_BREAKPOINTS[targetMode].columns; + + // 1단계: 각 컴포넌트를 비율로 변환 + const converted = components.map(comp => ({ + id: comp.id, + position: convertPositionToMode(comp.position, targetMode), + })); + + // 2단계: 겹침 해결 (아래로 밀기) + return resolveOverlaps(converted, targetColumns); +} + +// ======================================== +// 초과 컴포넌트 감지 +// ======================================== + +/** + * 컴포넌트가 현재 모드에서 화면 밖으로 초과하는지 확인 + * + * 판단 우선순위: + * 1. 오버라이드 위치가 있으면 오버라이드 위치로 판단 + * 2. 오버라이드 없으면 원본 위치로 판단 + * + * @param originalPosition 원본 위치 (12칸 기준) + * @param currentMode 현재 그리드 모드 + * @param overridePosition 오버라이드 위치 (있으면) + */ +export function isOutOfBounds( + originalPosition: PopGridPosition, + currentMode: GridMode, + overridePosition?: PopGridPosition | null +): boolean { + const targetColumns = GRID_BREAKPOINTS[currentMode].columns; + + // 12칸 모드면 초과 불가 + if (targetColumns === 12) { + return false; + } + + // 오버라이드가 있으면 오버라이드 위치로 판단 + if (overridePosition) { + // 오버라이드 시작 열이 범위 내면 "초과 아님" + return overridePosition.col > targetColumns; + } + + // 오버라이드 없으면 원본 시작 열이 현재 모드 칸 수를 초과하면 "화면 밖" + return originalPosition.col > targetColumns; +} + // ======================================== // 겹침 감지 및 해결 // ======================================== @@ -117,6 +175,11 @@ export function resolveOverlaps( /** * 마우스 좌표 → 그리드 좌표 변환 + * + * CSS Grid 계산 방식: + * - 사용 가능 너비 = 캔버스 너비 - 패딩*2 - gap*(columns-1) + * - 각 칸 너비 = 사용 가능 너비 / columns + * - 셀 N의 시작 X = padding + (N-1) * (칸너비 + gap) */ export function mouseToGridPosition( mouseX: number, @@ -127,16 +190,21 @@ export function mouseToGridPosition( gap: number, padding: number ): { col: number; row: number } { - // 캔버스 내 상대 위치 + // 캔버스 내 상대 위치 (패딩 영역 포함) const relX = mouseX - canvasRect.left - padding; const relY = mouseY - canvasRect.top - padding; - // 칸 너비 계산 - const totalGap = gap * (columns - 1); - const colWidth = (canvasRect.width - padding * 2 - totalGap) / columns; + // CSS Grid 1fr 계산과 동일하게 + // 사용 가능 너비 = 전체 너비 - 양쪽 패딩 - (칸 사이 gap) + const availableWidth = canvasRect.width - padding * 2 - gap * (columns - 1); + const colWidth = availableWidth / columns; + + // 각 셀의 실제 간격 (셀 너비 + gap) + const cellStride = colWidth + gap; // 그리드 좌표 계산 (1부터 시작) - const col = Math.max(1, Math.min(columns, Math.floor(relX / (colWidth + gap)) + 1)); + // relX를 cellStride로 나누면 몇 번째 칸인지 알 수 있음 + const col = Math.max(1, Math.min(columns, Math.floor(relX / cellStride) + 1)); const row = Math.max(1, Math.floor(relY / (rowHeight + gap)) + 1); return { col, row }; @@ -299,3 +367,104 @@ export function autoLayoutComponents( return result; } + +// ======================================== +// 유효 위치 계산 (통합 함수) +// ======================================== + +/** + * 컴포넌트의 유효 위치를 계산합니다. + * 우선순위: 1. 오버라이드 → 2. 자동 재배치 → 3. 원본 위치 + * + * @param componentId 컴포넌트 ID + * @param layout 전체 레이아웃 데이터 + * @param mode 현재 그리드 모드 + * @param autoResolvedPositions 미리 계산된 자동 재배치 위치 (선택적) + */ +export function getEffectiveComponentPosition( + componentId: string, + layout: PopLayoutDataV5, + mode: GridMode, + autoResolvedPositions?: Array<{ id: string; position: PopGridPosition }> +): PopGridPosition | null { + const component = layout.components[componentId]; + if (!component) return null; + + // 1순위: 오버라이드가 있으면 사용 + const override = layout.overrides?.[mode]?.positions?.[componentId]; + if (override) { + return { ...component.position, ...override }; + } + + // 2순위: 자동 재배치된 위치 사용 + if (autoResolvedPositions) { + const autoResolved = autoResolvedPositions.find(p => p.id === componentId); + if (autoResolved) { + return autoResolved.position; + } + } else { + // 자동 재배치 직접 계산 + const componentsArray = Object.entries(layout.components).map(([id, comp]) => ({ + id, + position: comp.position, + })); + const resolved = convertAndResolvePositions(componentsArray, mode); + const autoResolved = resolved.find(p => p.id === componentId); + if (autoResolved) { + return autoResolved.position; + } + } + + // 3순위: 원본 위치 (12칸 모드) + return component.position; +} + +/** + * 모든 컴포넌트의 유효 위치를 일괄 계산합니다. + * 숨김 처리된 컴포넌트와 화면 밖 컴포넌트는 제외됩니다. + */ +export function getAllEffectivePositions( + layout: PopLayoutDataV5, + mode: GridMode +): Map { + const result = new Map(); + + // 숨김 처리된 컴포넌트 ID 목록 + const hiddenIds = layout.overrides?.[mode]?.hidden || []; + + // 자동 재배치 위치 미리 계산 + const componentsArray = Object.entries(layout.components).map(([id, comp]) => ({ + id, + position: comp.position, + })); + const autoResolvedPositions = convertAndResolvePositions(componentsArray, mode); + + // 각 컴포넌트의 유효 위치 계산 + Object.keys(layout.components).forEach(componentId => { + // 숨김 처리된 컴포넌트는 제외 + if (hiddenIds.includes(componentId)) { + return; + } + + const position = getEffectiveComponentPosition( + componentId, + layout, + mode, + autoResolvedPositions + ); + + if (position) { + // 화면 밖 컴포넌트도 제외 (오버라이드 위치 고려) + const overridePos = layout.overrides?.[mode]?.positions?.[componentId]; + const overridePosition = overridePos + ? { ...layout.components[componentId].position, ...overridePos } + : null; + + if (!isOutOfBounds(layout.components[componentId].position, mode, overridePosition)) { + result.set(componentId, position); + } + } + }); + + return result; +} diff --git a/popdocs/CHANGELOG.md b/popdocs/CHANGELOG.md index 305ed422..04acea82 100644 --- a/popdocs/CHANGELOG.md +++ b/popdocs/CHANGELOG.md @@ -12,6 +12,277 @@ --- +## [2026-02-05 심야] 반응형 레이아웃 + 숨김 기능 완성 + +### 배경 (왜 이 작업이 필요했는가) + +**문제 상황**: +- 12칸 모드에서 배치한 컴포넌트가 4칸 모드에서 초과됨 +- 모드별로 컴포넌트 위치/크기를 다르게 설정할 방법 없음 +- 특정 모드에서만 컴포넌트를 숨길 방법 없음 + +**해결 방향**: +- 모드별 오버라이드 시스템으로 위치/크기 개별 저장 +- 화면 밖 컴포넌트를 별도 패널에 표시하고 드래그로 재배치 +- 숨김 기능으로 특정 모드에서 컴포넌트 제외 + +### Added + +- **모드별 오버라이드 시스템** (PopDesigner.tsx, pop-layout.ts) + - `PopModeOverrideV5.positions`: 모드별 컴포넌트 위치 저장 + - `PopModeOverrideV5.hidden`: 모드별 숨김 컴포넌트 ID 배열 + - `getEffectiveComponentPosition()`: 오버라이드된 위치 반환 + - 드래그/리사이즈 시 자동으로 오버라이드 저장 + +- **화면 밖 컴포넌트 패널** (PopCanvas.tsx) + - `OutOfBoundsPanel`: 현재 모드에서 초과하는 컴포넌트 표시 + - `OutOfBoundsItem`: 드래그 가능한 회색 컴포넌트 카드 + - `isOutOfBounds()`: 컴포넌트가 현재 모드 칸 수 초과 여부 판단 + - 클릭하면 숨김 패널로 이동 + +- **숨김 기능** (PopDesigner.tsx, PopCanvas.tsx) + - `HiddenPanel`: 숨김 처리된 컴포넌트 표시 + - `HiddenItem`: 드래그로 숨김 해제 가능 + - `handleHideComponent()`: 컴포넌트 숨김 처리 + - `handleUnhideComponent()`: 숨김 해제 (handleMoveComponent에 통합) + - 숨김 방법 3가지: + 1. 그리드 → 숨김패널 드래그 + 2. H키 단축키 + 3. 화면밖 컴포넌트 클릭 + +- **리사이즈 겹침 검사** (PopRenderer.tsx) + - `checkResizeOverlap()`: 리사이즈 시 다른 컴포넌트와 겹침 검사 + - 겹치면 리사이즈 취소 및 toast 알림 + +- **원본으로 되돌리기** (PopDesigner.tsx) + - `handleResetToDefault()`: 현재 모드 오버라이드 삭제 + - 자동 위치 계산으로 복원 + +### Fixed + +- **숨김 컴포넌트 드래그 안됨 버그** + - 원인: `onUnhideComponent`와 `onMoveComponent`가 별도로 호출되어 상태 충돌 + - 해결: `handleMoveComponent`에서 숨김 해제 로직 통합 (단일 상태 업데이트) + +- **그리드 범위 초과 에러** + - 원인: 드롭 위치 + colSpan이 칸 수 초과 + - 해결: 드롭 시 `adjustedCol` 계산하여 자동으로 왼쪽으로 밀어서 배치 + +- **getAllEffectivePositions에 숨김 컴포넌트 포함** + - 해결: 숨김 및 화면밖 컴포넌트를 결과에서 제외 + +### Changed + +- **PopModeOverrideV5 타입 확장** + ```typescript + interface PopModeOverrideV5 { + positions?: Record>; // 위치 오버라이드 + hidden?: string[]; // 숨김 컴포넌트 ID 배열 + } + ``` + +- **12칸 모드(tablet_landscape) 제한** + - 기본 모드이므로 숨김 기능 비활성화 + - 화면밖 패널 표시 안함 + - 위치 변경은 기본 position에 직접 저장 + +- **패널 레이아웃 재구성** (PopCanvas.tsx) + - 오른쪽에 화면밖 패널 + 숨김 패널 세로 배치 + - 12칸 모드에서는 패널 숨김 + +### Technical Details + +``` +오버라이드 데이터 흐름: + +1. 컴포넌트 드래그/리사이즈 + ↓ +2. currentMode 확인 + ↓ +3-a. tablet_landscape → layout.components[id].position 직접 수정 +3-b. 다른 모드 → layout.overrides[mode].positions[id]에 저장 + ↓ +4. getEffectiveComponentPosition()이 우선순위대로 반환 + 우선순위: overrides > autoResolved > 기본 position + +숨김 기능 흐름: + +1. 숨김 요청 (드래그/H키/클릭) + ↓ +2. layout.overrides[mode].hidden 배열에 ID 추가 + ↓ +3. PopRenderer에서 hidden 체크 → 렌더링 제외 + ↓ +4. HiddenPanel에서 표시 + ↓ +5. 드래그로 그리드에 복원 → hidden 배열에서 제거 + 위치 업데이트 (단일 상태 업데이트) +``` + +### 수정 파일 +| 파일 | 변경 내용 | +|------|----------| +| `pop-layout.ts` | PopModeOverrideV5.hidden 추가 | +| `PopDesigner.tsx` | handleHideComponent, handleUnhideComponent 통합, 오버라이드 저장 | +| `PopCanvas.tsx` | OutOfBoundsPanel, HiddenPanel 추가, 드롭 위치 자동 조정 | +| `PopRenderer.tsx` | 숨김 필터링, 리사이즈 겹침 검사 | +| `gridUtils.ts` | getAllEffectivePositions에서 숨김/화면밖 제외, isOutOfBounds 함수 | + +--- + +## [2026-02-05 저녁] 드래그앤드롭 완전 수정 + +### 배경 (왜 좌표 계산이 틀렸는가) + +**문제 상황**: +- 컴포넌트를 아래로 드래그해도 위로 올라감 +- Row 92 같은 비정상적인 좌표로 배치됨 +- 드래그 이동/리사이즈가 전혀 작동하지 않음 + +**핵심 원인**: 캔버스에 `transform: scale(0.8)` 적용 시 좌표 계산 불일치 +``` +문제: +- getBoundingClientRect() → 스케일 적용된 크기 반환 (예: 1024px → 819px) +- getClientOffset() → 뷰포트 기준 실제 마우스 좌표 +- 이 둘을 그대로 계산하면 좌표가 완전히 틀림 +``` + +**해결**: 단순한 상대 좌표 + 스케일 보정 +```typescript +// 캔버스 내 상대 좌표 (스케일 보정) +const relX = (마우스X - 캔버스left) / canvasScale; +const relY = (마우스Y - 캔버스top) / canvasScale; +calcGridPosition(relX, relY, customWidth, ...); // 실제 캔버스 크기 사용 +``` + +### Added +- **`calcGridPosition()` 함수** (PopCanvas.tsx) + - 캔버스 내 상대 좌표를 그리드 좌표로 변환 + - 패딩, gap, 셀 너비를 고려한 정확한 계산 + +- **공통 DND 상수** (constants/dnd.ts) + - `DND_ITEM_TYPES.COMPONENT`: 팔레트에서 새 컴포넌트 + - `DND_ITEM_TYPES.MOVE_COMPONENT`: 기존 컴포넌트 이동 + - 3개 파일에서 중복 정의되던 것을 통합 + +### Fixed +- **스케일 보정 누락** + - 캔버스 줌(scale)이 적용된 상태에서 좌표 계산 오류 + - `(offset - rect.left) / scale`로 보정 + +- **DND 타입 상수 불일치** + - PopCanvas: `"component"`, `"MOVE_COMPONENT"` + - PopRenderer: `"MOVE_COMPONENT"` (하드코딩) + - ComponentPalette: `"component"` (로컬 정의) + - 모두 공통 상수로 통합 + +- **컴포넌트 중첩(겹침) 문제** + - 원인: `toast` import 누락으로 겹침 감지 로직이 실행 안됨 + - 해결: `sonner`에서 toast import 추가 + - 겹침 시 `findNextEmptyPosition()`으로 자동 재배치 + +- **리사이즈 핸들 작동 안됨** + - 원인: `useDrop` 훅 2개가 같은 `canvasRef`에 중복 적용 + - 해결: 단일 `useDrop`으로 통합 (`COMPONENT` + `MOVE_COMPONENT` 모두 처리) + +- **불필요한 toast 메시지 제거** + - "컴포넌트가 이동되었습니다" 알림 삭제 + +### Changed +- **mouseToGridPosition 단순화** + - 복잡한 DOMRect 전달 대신 필요한 값만 직접 전달 + - gridUtils.ts의 함수는 유지 (다른 곳에서 사용) + +### Technical Details +``` +좌표 변환 흐름 (수정 후): + +1. 마우스 드롭 + offset = monitor.getClientOffset() // 뷰포트 기준 {x: 500, y: 300} + +2. 캔버스 위치 + canvasRect = canvasRef.getBoundingClientRect() // {left: 250, top: 100} + +3. 스케일 보정된 상대 좌표 + relX = (500 - 250) / 0.8 = 312.5 // 캔버스 내 실제 X + relY = (300 - 100) / 0.8 = 250 // 캔버스 내 실제 Y + +4. 그리드 좌표 계산 + calcGridPosition(312.5, 250, 1024, 12, 48, 16, 24) + → { col: 5, row: 4 } +``` + +### 수정 파일 +| 파일 | 변경 내용 | +|------|----------| +| `PopCanvas.tsx` | calcGridPosition 추가, 스케일 보정 적용 | +| `PopDesigner.tsx` | toast 메시지 제거 | +| `PopRenderer.tsx` | DND 상수 import | +| `ComponentPalette.tsx` | DND 상수 import | +| `constants/dnd.ts` | 새 파일 (DND 타입 상수) | +| `constants/index.ts` | 새 파일 (export) | + +--- + +## [2026-02-05 오후] 그리드 가이드 CSS Grid 통합 + +### 배경 (왜 재설계했는가) + +**문제 상황**: +- GridGuide.tsx(SVG 기반)와 PopRenderer.tsx(CSS Grid)가 좌표계 불일치 +- 격자선과 컴포넌트가 정렬되지 않음 ("무늬가 따로 논다") +- 행/열 라벨이 4부터 시작하는 등 오류 + +**핵심 원칙**: +> "격자선은 컴포넌트와 같은 좌표계에서 태어나야 한다" + +**결정**: SVG 격자 삭제, CSS Grid 기반 통합 +→ 상세: [decisions/004-grid-guide-integration.md](./decisions/004-grid-guide-integration.md) + +### Breaking Changes +- `GridGuide.tsx` 삭제 (SVG 기반 격자) + +### Added +- **CSS Grid 기반 격자 셀** (PopRenderer.tsx) + - `gridCells`: 12x20 = 240개 실제 DOM 셀 + - `border-dashed border-blue-300/40` 스타일 + - 컴포넌트는 `z-index:10`으로 위에 표시 + - `showGridGuide` prop으로 ON/OFF + +- **행/열 라벨** (PopCanvas.tsx) + - 열 라벨: 1~12 (캔버스 상단) + - 행 라벨: 1~20 (캔버스 좌측) + - absolute positioning으로 정확한 정렬 + - 줌/패닝에 연동 + +- **그리드 토글 버튼** (PopCanvas.tsx) + - "그리드 ON/OFF" 버튼 추가 + - 격자 표시 상태 관리 + +### Changed +- **컴포넌트 타입 단순화** + - `PopComponentType`: `pop-sample` 1개로 단순화 + - `DEFAULT_COMPONENT_GRID_SIZE`: `pop-sample` 전용 + - `ComponentPalette.tsx`: 샘플 박스 1개만 표시 + - `PopRenderer.tsx`: 샘플 박스 렌더링으로 단순화 + +### Technical Details +``` +역할 분담: +- PopRenderer: 격자 셀(div) + 컴포넌트 (같은 CSS Grid 좌표계) +- PopCanvas: 라벨 + 줌/패닝 + 토글 +- GridGuide: 삭제 + +격자 셀 구조: +┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐ +│1,1│2,1│3,1│4,1│5,1│6,1│7,1│8,1│9,1│10│11│12 │ ← col +├───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┼───┤ +│1,2│... │ +└───┴───────────────────────────────────────────┘ + ↑ row +``` + +--- + ## [2026-02-05] v5 그리드 시스템 완전 통합 ### 배경 (왜 v5로 전환했는가) diff --git a/popdocs/FILES.md b/popdocs/FILES.md index 477e3afd..da9b79dd 100644 --- a/popdocs/FILES.md +++ b/popdocs/FILES.md @@ -1,6 +1,6 @@ # POP 파일 상세 목록 -**최종 업데이트: 2026-02-05 (v5 그리드 시스템 통합)** +**최종 업데이트: 2026-02-05 저녁 (드래그앤드롭 수정)** 이 문서는 POP 화면 시스템과 관련된 모든 파일을 나열하고 각 파일의 역할을 설명합니다. @@ -151,9 +151,11 @@ const [hasChanges, setHasChanges] = useState(false); | 항목 | 내용 | |------|------| -| 역할 | v5 CSS Grid 기반 캔버스 | +| 역할 | v5 CSS Grid 기반 캔버스 + 행/열 라벨 | | 렌더링 | CSS Grid (4/6/8/12칸) | | 모드 | 4개 (태블릿/모바일 x 가로/세로) | +| 라벨 | 열 라벨 (1~12), 행 라벨 (1~20) | +| 토글 | 그리드 ON/OFF 버튼 | **핵심 Props**: @@ -247,8 +249,9 @@ export { default as ComponentEditorPanel, default } from "./ComponentEditorPanel | 항목 | 내용 | |------|------| -| 역할 | v5 레이아웃 CSS Grid 렌더러 | -| 입력 | PopLayoutDataV5, viewportWidth, currentMode | +| 역할 | v5 레이아웃 CSS Grid 렌더러 + 격자 셀 | +| 입력 | PopLayoutDataV5, viewportWidth, currentMode, showGridGuide | +| 격자 | 12x20 = 240개 실제 DOM 셀 (CSS Grid 좌표계) | **핵심 Props**: @@ -259,12 +262,37 @@ interface PopRendererProps { currentMode?: GridMode; isDesignMode?: boolean; selectedComponentId?: string | null; + showGridGuide?: boolean; // 격자 표시 여부 onComponentClick?: (componentId: string) => void; onBackgroundClick?: () => void; className?: string; } ``` +**격자 셀 렌더링**: + +```typescript +// 12x20 = 240개 셀 생성 +const gridCells = useMemo(() => { + const cells = []; + for (let row = 1; row <= 20; row++) { + for (let col = 1; col <= 12; col++) { + cells.push({ id: `${col}-${row}`, col, row }); + } + } + return cells; +}, []); + +// 컴포넌트와 동일한 CSS Grid 좌표계로 렌더링 +{showGridGuide && gridCells.map(cell => ( +
+))} +``` + **CSS Grid 스타일 생성**: ```typescript @@ -374,6 +402,41 @@ export * from "./pop-layout"; --- +## 5.5. Constants 파일 (신규) + +### `frontend/components/pop/designer/constants/dnd.ts` + +| 항목 | 내용 | +|------|------| +| 역할 | DnD(Drag and Drop) 관련 상수 | +| 생성일 | 2026-02-05 | + +**핵심 상수**: + +```typescript +export const DND_ITEM_TYPES = { + /** 팔레트에서 새 컴포넌트 드래그 */ + COMPONENT: "POP_COMPONENT", + /** 캔버스 내 기존 컴포넌트 이동 */ + MOVE_COMPONENT: "POP_MOVE_COMPONENT", +} as const; +``` + +**사용처**: +- `PopCanvas.tsx` - useDrop accept 타입 +- `PopRenderer.tsx` - useDrag type +- `ComponentPalette.tsx` - useDrag type + +--- + +### `frontend/components/pop/designer/constants/index.ts` + +```typescript +export * from "./dnd"; +``` + +--- + ## 6. Utils 파일 ### `frontend/components/pop/designer/utils/gridUtils.ts` @@ -545,12 +608,12 @@ export * from "./dashboard"; | 폴더 | 파일 수 | 설명 | |------|---------|------| | `app/(pop)` | 4 | App Router 페이지 | -| `components/pop/designer` | 9 | 디자이너 모듈 (v5) | +| `components/pop/designer` | 11 | 디자이너 모듈 (v5) - constants 포함 | | `components/pop/management` | 5 | 관리 모듈 | | `components/pop/dashboard` | 12 | 대시보드 모듈 | | `components/pop` (루트) | 15 | 루트 컴포넌트 | | `lib` | 3 | 라이브러리 | -| **총계** | **48** | | +| **총계** | **50** | | --- @@ -565,6 +628,7 @@ export * from "./dashboard"; | `ComponentEditorPanelV4.tsx` | v4 편집 패널 | | `PopPanel.tsx` | 레거시 팔레트 패널 | | `test-v4/page.tsx` | v4 테스트 페이지 | +| `GridGuide.tsx` | SVG 기반 격자 가이드 (좌표 불일치로 삭제, CSS Grid 통합) | --- diff --git a/popdocs/INDEX.md b/popdocs/INDEX.md index 68d7de20..9ae595cd 100644 --- a/popdocs/INDEX.md +++ b/popdocs/INDEX.md @@ -10,10 +10,20 @@ | 기능 | 파일 | 함수/컴포넌트 | 설명 | |------|------|--------------|------| | 그리드 렌더링 | PopRenderer.tsx | `PopRenderer` | CSS Grid 기반 v5 렌더링 | +| 격자 셀 렌더링 | PopRenderer.tsx | `gridCells` (useMemo) | 12x20 = 240개 DOM 셀 | | 위치 변환 | gridUtils.ts | `convertPositionToMode()` | 12칸 → 4/6/8칸 변환 | | 모드 감지 | pop-layout.ts | `detectGridMode()` | 뷰포트 너비로 모드 판별 | | 컴포넌트 스타일 | PopRenderer.tsx | `convertPosition()` | 그리드 좌표 → CSS | +## 그리드 가이드 + +| 기능 | 파일 | 함수/컴포넌트 | 설명 | +|------|------|--------------|------| +| 격자 셀 | PopRenderer.tsx | `gridCells` | CSS Grid 기반 격자선 | +| 열 라벨 | PopCanvas.tsx | `gridLabels.columns` | 1~12 표시 | +| 행 라벨 | PopCanvas.tsx | `gridLabels.rows` | 1~20 표시 | +| 토글 | PopCanvas.tsx | `showGridGuide` 상태 | 격자 ON/OFF | + ## 드래그 앤 드롭 | 기능 | 파일 | 함수/컴포넌트 | 설명 | @@ -72,9 +82,10 @@ | 파일 | 핵심 기능 | |------|----------| | PopDesigner.tsx | 레이아웃 로드/저장, 컴포넌트 CRUD, 히스토리 | -| PopCanvas.tsx | DnD, 줌, 패닝, 모드 전환, 그리드 표시 | -| PopRenderer.tsx | CSS Grid 렌더링, 위치 변환, 컴포넌트 표시 | +| PopCanvas.tsx | DnD, 줌, 패닝, 모드 전환, 행/열 라벨, 격자 토글 | +| PopRenderer.tsx | CSS Grid 렌더링, 격자 셀, 위치 변환, 컴포넌트 표시 | | ComponentEditorPanel.tsx | 속성 편집 (위치, 크기, 설정, 표시) | +| ComponentPalette.tsx | 컴포넌트 팔레트 (드래그 가능한 컴포넌트 목록) | | pop-layout.ts | 타입 정의, 유틸리티 함수, 상수 | | gridUtils.ts | 좌표 계산, 겹침 감지, 자동 배치 | diff --git a/popdocs/PROBLEMS.md b/popdocs/PROBLEMS.md index 7491896e..9f12bc0a 100644 --- a/popdocs/PROBLEMS.md +++ b/popdocs/PROBLEMS.md @@ -18,6 +18,11 @@ |------|------|------|--------| | useDrag 에러 (뷰어에서) | isDesignMode 체크 후 early return | 2026-02-04 | DnD, useDrag, 뷰어 | | DndProvider 중복 에러 | 최상위에서만 Provider 사용 | 2026-02-04 | DndProvider, react-dnd | +| **컴포넌트 중첩(겹침)** | toast import 누락 → `sonner`에서 import | 2026-02-05 | 겹침, overlap, toast | +| **리사이즈 핸들 작동 안됨** | useDrop 2개 중복 → 단일 useDrop으로 통합 | 2026-02-05 | resize, 핸들, useDrop | +| **드래그 좌표 완전 틀림 (Row 92)** | 캔버스 scale 보정 누락 → `(offset - rect.left) / scale` | 2026-02-05 | scale, 좌표, transform | +| **DND 타입 상수 불일치** | 3개 파일에 중복 정의 → `constants/dnd.ts`로 통합 | 2026-02-05 | 상수, DND, 타입 | +| **컴포넌트 이동 안됨** | useDrop accept 타입 불일치 → 공통 상수 사용 | 2026-02-05 | 이동, useDrop, accept | ## 타입 관련 @@ -50,12 +55,62 @@ --- -## 해결 안 된 문제 (진행 중) +## 그리드 가이드 관련 -| 문제 | 상태 | 관련 파일 | +| 문제 | 해결 | 날짜 | 키워드 | +|------|------|------|--------| +| SVG 격자와 CSS Grid 좌표 불일치 | GridGuide.tsx 삭제, PopRenderer에서 CSS Grid 셀로 격자 렌더링 | 2026-02-05 | 격자, SVG, CSS Grid, 좌표 | +| 행/열 라벨 위치 오류 | PopCanvas에 absolute positioning 라벨 추가 | 2026-02-05 | 라벨, 행, 열, 정렬 | +| 격자선과 컴포넌트 불일치 | 동일한 CSS Grid 좌표계 사용 | 2026-02-05 | 통합, 정렬, 일체감 | + +--- + +## 해결 완료 (이번 세션) + +| 문제 | 상태 | 해결 방법 | |------|------|----------| -| PopCanvas 타입 오류 | 미해결 | PopCanvas.tsx:76 | -| 팔레트 UI 없음 | 미해결 | PopDesigner.tsx | +| PopCanvas 타입 오류 | **해결** | 임시 타입 가드 추가 | +| 팔레트 UI 없음 | **해결** | ComponentPalette.tsx 신규 추가 | +| SVG 격자 좌표 불일치 | **해결** | CSS Grid 기반 통합 | +| 드래그 좌표 완전 틀림 | **해결** | scale 보정 + calcGridPosition 함수 | +| DND 타입 상수 불일치 | **해결** | constants/dnd.ts 통합 | +| 컴포넌트 이동 안됨 | **해결** | useDrop/useDrag 타입 통일 | +| 컴포넌트 중첩(겹침) | **해결** | toast import 추가 → 겹침 감지 로직 정상 작동 | +| 리사이즈 핸들 작동 안됨 | **해결** | useDrop 통합 (2개 → 1개) | + +--- + +## 드래그 좌표 버그 상세 (2026-02-05) + +### 증상 +- 컴포넌트를 아래로 드래그 → 위로 올라감 +- Row 92 같은 비정상 좌표 +- 드래그 이동/리사이즈 전혀 작동 안됨 + +### 원인 +``` +캔버스: transform: scale(0.8) + +getBoundingClientRect() → 스케일 적용된 크기 (1024px → 819px) +getClientOffset() → 뷰포트 기준 실제 마우스 좌표 + +이 둘을 그대로 계산하면 좌표 완전 틀림 +``` + +### 해결 +```typescript +// 스케일 보정된 상대 좌표 계산 +const relX = (offset.x - canvasRect.left) / canvasScale; +const relY = (offset.y - canvasRect.top) / canvasScale; + +// 실제 캔버스 크기로 그리드 계산 +calcGridPosition(relX, relY, customWidth, ...); +``` + +### 교훈 +> CSS `transform: scale()` 적용된 요소에서 좌표 계산 시, +> `getBoundingClientRect()`는 스케일 적용된 값을 반환하지만 +> 마우스 좌표는 뷰포트 기준이므로 **반드시 스케일 보정 필요** --- diff --git a/popdocs/README.md b/popdocs/README.md index ee3754ce..3c3c4403 100644 --- a/popdocs/README.md +++ b/popdocs/README.md @@ -10,17 +10,23 @@ | 항목 | 값 | |------|-----| | 버전 | **v5** (CSS Grid 기반) | -| 상태 | **기본 기능 완료** | -| 다음 | 실제 테스트, Phase 4 (실제 컴포넌트 구현) | +| 상태 | **반응형 레이아웃 + 숨김 기능 완료** | +| 다음 | Phase 4 (실제 컴포넌트 구현) | -**마지막 업데이트**: 2026-02-05 +**마지막 업데이트**: 2026-02-05 심야 --- ## 마지막 대화 요약 -> (B)(C)(D) 모두 완료. 팔레트 UI 추가, 타입 오류 수정, 문서 v5 기준 통일. -> 다음: 실제 테스트 후 Phase 4 (실제 컴포넌트 렌더링, 데이터 바인딩) 진행. +> **반응형 레이아웃 시스템 완성**: +> - 모드별 컴포넌트 재배치 (오버라이드) 시스템 구현 +> - 화면 밖 컴포넌트 오른쪽 패널 배치 기능 +> - 컴포넌트 숨김/숨김해제 기능 (모드별) +> - 리사이즈 겹침 검사 추가 +> - H키 단축키로 숨김 처리 +> +> 다음: Phase 4 (실제 컴포넌트 구현) --- @@ -31,6 +37,7 @@ | 지금 뭐 해야 해? | [STATUS.md](./STATUS.md) | | 저장/조회 규칙 | [SAVE_RULES.md](./SAVE_RULES.md) | | 왜 v5로 바꿨어? | [decisions/003-v5-grid-system.md](./decisions/003-v5-grid-system.md) | +| 그리드 가이드 설계 | [decisions/004-grid-guide-integration.md](./decisions/004-grid-guide-integration.md) | | 이전 문제 해결 | [PROBLEMS.md](./PROBLEMS.md) | | 코드 어디 있어? | [FILES.md](./FILES.md) | | 기능별 색인 | [INDEX.md](./INDEX.md) | @@ -43,9 +50,10 @@ | 파일 | 역할 | 경로 | |------|------|------| | 타입 정의 | v5 레이아웃 타입 | `frontend/components/pop/designer/types/pop-layout.ts` | -| 캔버스 | 그리드 캔버스 + DnD | `frontend/components/pop/designer/PopCanvas.tsx` | -| 렌더러 | CSS Grid 렌더링 | `frontend/components/pop/designer/renderers/PopRenderer.tsx` | +| 캔버스 | 그리드 캔버스 + DnD + 라벨 | `frontend/components/pop/designer/PopCanvas.tsx` | +| 렌더러 | CSS Grid 렌더링 + 격자 셀 | `frontend/components/pop/designer/renderers/PopRenderer.tsx` | | 디자이너 | 메인 컴포넌트 | `frontend/components/pop/designer/PopDesigner.tsx` | +| 팔레트 | 컴포넌트 목록 | `frontend/components/pop/designer/panels/ComponentPalette.tsx` | --- @@ -89,6 +97,8 @@ decisions/, sessions/, archive/ **핵심**: 컴포넌트를 칸 단위로 배치 (col, row, colSpan, rowSpan) +**그리드 가이드**: CSS Grid 기반 격자 셀 + 행/열 라벨 (ON/OFF 토글) + --- *상세: [SPEC.md](./SPEC.md) | 히스토리: [CHANGELOG.md](./CHANGELOG.md)* diff --git a/popdocs/STATUS.md b/popdocs/STATUS.md index 8e1d0a69..72d83cec 100644 --- a/popdocs/STATUS.md +++ b/popdocs/STATUS.md @@ -1,6 +1,6 @@ # 현재 상태 -> **마지막 업데이트**: 2026-02-05 +> **마지막 업데이트**: 2026-02-05 심야 > **담당**: POP 화면 디자이너 --- @@ -15,27 +15,52 @@ | v5 편집 패널 | 완료 | `ComponentEditorPanel.tsx` | | v5 유틸리티 | 완료 | `gridUtils.ts` | | 레거시 삭제 | 완료 | v1~v4 코드, 데이터 | -| 문서 정리 | **완료** | popdocs v5 기준 재정비 | -| 컴포넌트 팔레트 | **완료** | `ComponentPalette.tsx` | -| 타입 오류 수정 | **완료** | PopCanvas.tsx:76 | -| 드래그앤드롭 | **완료** | 팔레트 → 캔버스 연결 | +| 문서 정리 | 완료 | popdocs v5 기준 재정비 | +| 컴포넌트 팔레트 | 완료 | `ComponentPalette.tsx` | +| 드래그앤드롭 | 완료 | 스케일 보정, DND 상수 통합 | +| 그리드 가이드 재설계 | 완료 | CSS Grid 기반 통합 | +| **모드별 오버라이드** | **완료** | 위치/크기 모드별 저장 | +| **화면 밖 컴포넌트** | **완료** | 오른쪽 패널 배치, 드래그로 복원 | +| **숨김 기능** | **완료** | 모드별 숨김/숨김해제 | +| **리사이즈 겹침 검사** | **완료** | 실시간 겹침 방지 | --- ## 다음 작업 (우선순위) -1. **실제 테스트** - - 디자이너 페이지에서 컴포넌트 드래그앤드롭 테스트 - - 저장/로드 동작 확인 - -2. **실제 컴포넌트 구현** (Phase 4) +1. **실제 컴포넌트 구현** (Phase 4) - pop-label, pop-button 등 실제 렌더링 - 데이터 바인딩 연결 -3. **추가 기능** - - 컴포넌트 복사/붙여넣기 - - 다중 선택 - - 정렬 도우미 +2. **워크플로우 연동** + - 버튼 액션 연결 + - 화면 전환 로직 + +--- + +## 최근 주요 변경 (2026-02-05 심야) + +### 반응형 레이아웃 시스템 +| 기능 | 설명 | +|------|------| +| 모드별 재배치 | 4/6/8/12칸 모드별로 컴포넌트 위치/크기 개별 저장 | +| 자동 레이아웃 고정 | 드래그/리사이즈 시 자동으로 오버라이드 저장 | +| 원본으로 되돌리기 | 오버라이드 삭제하여 자동 재배치로 복원 | + +### 화면 밖 컴포넌트 처리 +| 기능 | 설명 | +|------|------| +| 오른쪽 패널 표시 | 현재 모드에서 초과하는 컴포넌트 별도 표시 | +| 드래그로 복원 | 패널에서 그리드로 드래그하여 재배치 | +| 위치 자동 조정 | 그리드 범위 초과 시 자동으로 왼쪽으로 밀어서 배치 | + +### 숨김 기능 +| 기능 | 설명 | +|------|------| +| 모드별 숨김 | 특정 모드에서만 컴포넌트 숨김 가능 | +| 숨김 방법 | 드래그→숨김패널 / H키 / 화면밖 컴포넌트 클릭 | +| 숨김 해제 | 숨김패널에서 그리드로 드래그 | +| 12칸 모드 제한 | 기본 모드(12칸)에서는 숨김 기능 비활성화 | --- @@ -44,7 +69,11 @@ | 문제 | 상태 | 비고 | |------|------|------| | 타입 이름 불일치 | 해결됨 | V5 접미사 제거 | -| 팔레트 없음 | 해결됨 | ComponentPalette.tsx 추가 | +| SVG 격자 좌표 불일치 | 해결됨 | GridGuide 삭제, CSS Grid 통합 | +| 드래그 좌표 계산 오류 | 해결됨 | 스케일 보정 적용 | +| DND 타입 상수 불일치 | 해결됨 | constants/dnd.ts로 통합 | +| 숨김 컴포넌트 드래그 안됨 | 해결됨 | 상태 업데이트 순서 수정 | +| 그리드 범위 초과 에러 | 해결됨 | 드롭 위치 자동 조정 | --- @@ -52,7 +81,8 @@ | 날짜 | 요약 | 상세 | |------|------|------| -| 2026-02-05 | v5 통합, 문서 재정비, 팔레트 UI 추가 | [sessions/2026-02-05.md](./sessions/2026-02-05.md) | +| 2026-02-05 심야 | 반응형 레이아웃, 숨김 기능, 겹침 검사 | 이 세션 | +| 2026-02-05 저녁 | v5 통합, 그리드 가이드 재설계 | [sessions/2026-02-05.md](./sessions/2026-02-05.md) | --- @@ -60,6 +90,8 @@ | ADR | 제목 | 날짜 | |-----|------|------| +| 005 | 반응형 레이아웃 및 숨김 기능 | 2026-02-05 | +| 004 | 그리드 가이드 CSS Grid 통합 | 2026-02-05 | | 003 | v5 CSS Grid 채택 | 2026-02-05 | | 001 | v4 제약조건 기반 | 2026-02-03 | diff --git a/popdocs/decisions/004-grid-guide-integration.md b/popdocs/decisions/004-grid-guide-integration.md new file mode 100644 index 00000000..858b44da --- /dev/null +++ b/popdocs/decisions/004-grid-guide-integration.md @@ -0,0 +1,143 @@ +# ADR-004: 그리드 가이드 CSS Grid 통합 + +**상태**: 승인됨 +**날짜**: 2026-02-05 +**결정자**: 개발팀 + +--- + +## 컨텍스트 + +그리드 가이드는 다음 목적을 가짐: +1. **시각적 기준**: 어디에 배치할지 눈으로 확인 가능 +2. **정렬 도움**: 칸에 맞춰 배치하기 쉬움 +3. **디자인 일관성**: 규칙적인 배치 유도 + +기존 구현: +- `GridGuide.tsx`: SVG `` 요소로 격자선 렌더링 +- `PopRenderer.tsx`: CSS Grid로 컴포넌트 배치 + +--- + +## 문제 + +### 좌표계 불일치 +``` +SVG 좌표: 픽셀 기반 (0, 0) ~ (width, height) +CSS Grid 좌표: 칸 기반 (col 1~12, row 1~20) + +→ 두 좌표계를 정확히 동기화하기 어려움 +→ 격자선과 컴포넌트가 정렬되지 않음 ("무늬가 따로 논다") +``` + +### 구체적 증상 +1. GridGuide의 행/열 라벨이 4부터 시작 (잘못된 계산) +2. 격자선 위치와 실제 CSS Grid 셀 위치 불일치 +3. 줌/패닝 시 두 레이어가 다르게 동작 + +--- + +## 결정 + +**GridGuide.tsx를 삭제하고, PopRenderer.tsx에서 CSS Grid 기반으로 격자를 직접 렌더링한다.** + +핵심 원칙: +> "격자선은 컴포넌트와 같은 좌표계에서 태어나야 한다" + +--- + +## 대안 검토 + +### Option A: SVG 계산 수정 +- **방법**: GridGuide의 좌표 계산을 정확히 수정 +- **장점**: 기존 코드 활용 +- **단점**: 근본적으로 두 좌표계가 다름, 유지보수 어려움 +- **결정**: 채택 안 함 + +### Option B: PopRenderer에 CSS 배경 격자 +- **방법**: `background-image: linear-gradient()`로 격자 표현 +- **장점**: 구현 간단 +- **단점**: 라벨 표시 불가, 셀 단위 상호작용 불가 +- **결정**: 채택 안 함 + +### Option C: CSS Grid 셀로 격자 렌더링 (채택) +- **방법**: 실제 `div` 요소를 12x20 = 240개 생성, CSS Grid로 배치 +- **장점**: + - 컴포넌트와 100% 동일한 좌표계 + - 셀 단위 hover, 클릭 등 상호작용 가능 + - 라벨은 캔버스 외부에 별도 렌더링 +- **단점**: DOM 요소 증가 (240개) +- **결정**: 채택 + +--- + +## 구현 상세 + +### 역할 분담 + +| 컴포넌트 | 역할 | 좌표계 | +|----------|------|--------| +| PopRenderer | 격자 셀 + 컴포넌트 | CSS Grid | +| PopCanvas | 라벨 + 줌/패닝 + 토글 | absolute | +| GridGuide | (삭제) | - | + +### PopRenderer 변경 + +```typescript +// gridCells 생성 (useMemo) +const gridCells = useMemo(() => { + const cells = []; + for (let row = 1; row <= 20; row++) { + for (let col = 1; col <= 12; col++) { + cells.push({ id: `${col}-${row}`, col, row }); + } + } + return cells; +}, []); + +// 렌더링 +{showGridGuide && gridCells.map(cell => ( +
+))} +``` + +### PopCanvas 라벨 구조 + +``` + [1] [2] [3] [4] [5] [6] [7] [8] [9] [10][11][12] ← 열 라벨 (캔버스 상단) + ┌───────────────────────────────────────────┐ +[1] │ │ │ │ │ │ │ │ │ │ │ │ +[2] │ │ │ │ │ │ │ │ │ │ │ │ +[3] │ │ │ │ ■ │ │ │ │ │ │ │ │ ← 5열 3행 + └───────────────────────────────────────────┘ + ↑ 행 라벨 (캔버스 좌측) +``` + +--- + +## 결과 + +### 기대 효과 +1. 격자선과 컴포넌트 100% 정렬 +2. 정확한 행/열 번호 표시 (1부터 시작) +3. 줌/패닝 시 일관된 동작 +4. 향후 셀 클릭으로 빠른 배치 기능 확장 가능 + +### 트레이드오프 +- DOM 요소 240개 추가 (성능 영향 미미) +- GridGuide 코드 삭제 필요 + +--- + +## 관련 문서 + +- 문제: [PROBLEMS.md](../PROBLEMS.md) > P004 +- 변경: [CHANGELOG.md](../CHANGELOG.md) > 2026-02-05 오후 +- 세션: [sessions/2026-02-05.md](../sessions/2026-02-05.md) diff --git a/popdocs/sessions/2026-02-05.md b/popdocs/sessions/2026-02-05.md index 0e60325b..d565c8bc 100644 --- a/popdocs/sessions/2026-02-05.md +++ b/popdocs/sessions/2026-02-05.md @@ -1,12 +1,21 @@ # 2026-02-05 작업 기록 ## 요약 -v5 그리드 시스템 통합 완료, popdocs 문서 구조 재정비 +v5 그리드 시스템 통합 완료, 그리드 가이드 재설계, **드래그앤드롭 좌표 버그 수정**, popdocs 문서 구조 재정비 --- ## 완료 +### 드래그앤드롭 완전 수정 (저녁) +- [x] 스케일 보정 누락 문제 해결 +- [x] calcGridPosition 함수 추가 +- [x] DND 타입 상수 통합 (constants/dnd.ts) +- [x] 불필요한 toast 메시지 제거 +- [x] 컴포넌트 이동/리사이즈 정상 작동 확인 +- [x] **컴포넌트 중첩(겹침) 문제 해결** - toast import 누락 수정 +- [x] **리사이즈 핸들 작동 문제 해결** - useDrop 훅 통합 + ### v5 통합 작업 - [x] 레거시 파일 삭제 (PopCanvasV4, PopFlexRenderer, PopLayoutRenderer 등) - [x] 파일명 정규화 (V5 접미사 제거) @@ -22,31 +31,64 @@ v5 그리드 시스템 통합 완료, popdocs 문서 구조 재정비 - [x] INDEX.md 생성 (기능별 색인) - [x] sessions/ 폴더 구조 도입 +### 디자이너 완성 작업 +- [x] 컴포넌트 팔레트 UI 추가 (ComponentPalette.tsx) +- [x] PopCanvas.tsx 타입 오류 수정 +- [x] 드래그앤드롭 연결 + +### 그리드 가이드 재설계 +- [x] GridGuide.tsx 삭제 (SVG 기반 → 좌표 불일치 문제) +- [x] PopRenderer.tsx 격자 셀 렌더링 (CSS Grid 기반, 동일 좌표계) +- [x] PopCanvas.tsx 행/열 라벨 추가 (캔버스 바깥) +- [x] 컴포넌트 타입 단순화 (pop-sample 1개) + +### 기반 정리 작업 +- [x] pop-layout.ts: PopComponentType을 pop-sample 1개로 단순화 +- [x] ComponentPalette.tsx: 샘플 박스 1개만 표시 +- [x] PopRenderer.tsx: 샘플 박스 렌더링으로 단순화 + --- ## 미완료 -- [ ] 컴포넌트 팔레트 UI 추가 (PopDesigner.tsx 좌측) -- [ ] PopCanvas.tsx 타입 오류 수정 (line 76) -- [ ] ARCHITECTURE.md v5 기준 업데이트 -- [ ] CHANGELOG.md 오늘 작업 추가 +- [x] 실제 화면 테스트 (디자이너 페이지) → 완료, 정상 작동 +- [ ] 간격 조정 규칙 결정 (전역 고정 vs 화면별 vs 컴포넌트별) --- -## 중단점 +## 그리드 가이드 재설계 상세 -> **다음 작업자 참고**: -> -> 1. **타입 오류**: PopCanvas.tsx line 76 -> - `}: PopCanvasV5Props)` → `}: PopCanvasProps)`로 변경 -> - 인터페이스는 이미 `PopCanvasProps`로 정의됨 (line 48) -> -> 2. **팔레트 UI**: PopDesigner.tsx에 컴포넌트 팔레트 추가 필요 -> - 위치: 좌측 ResizablePanel (현재 비어있음) -> - 참고: 이전 ComponentPaletteV4.tsx (삭제됨, archive에서 참고 가능) -> - DnD 타입: PopCanvas.tsx에 `DND_ITEM_TYPES` 인라인 정의됨 -> -> 3. **문서**: ARCHITECTURE.md가 아직 v3/v4 기준임 +### 문제 원인 +1. GridGuide.tsx가 SVG로 별도 렌더링 → CSS Grid 기반 컴포넌트와 좌표계 불일치 +2. PopRenderer의 그리드 배경이 희미 (rgba 0.2) +3. 행/열 번호 라벨 없음 + +### 해결 방안 (Option C 하이브리드) +``` +역할 분담: +- PopRenderer: 격자선 + 컴포넌트 (같은 좌표계) +- PopCanvas: 라벨 + 줌/패닝 + 드롭존 +- GridGuide: 삭제 +``` + +### 핵심 설계 +``` +SVG 격자 (별도 좌표) → CSS Grid 셀 (동일 좌표) +- gridCells: 12열 × 20행 = 240개 실제 DOM 셀 +- border-dashed border-blue-300/40 스타일 +- 컴포넌트는 z-index:10으로 위에 표시 +``` + +### 라벨 구조 +``` + [1] [2] [3] [4] [5] [6] [7] [8] [9] [10][11][12] ← 열 라벨 (캔버스 상단) + ┌───────────────────────────────────────────┐ +[1] │ │ │ │ │ │ │ │ │ │ │ │ +[2] │ │ │ │ │ │ │ │ │ │ │ │ +[3] │ │ │ │ ■ │ │ │ │ │ │ │ │ ← 5열 3행 + └───────────────────────────────────────────┘ + ↑ 행 라벨 (캔버스 좌측) +``` --- @@ -58,28 +100,78 @@ v5 그리드 시스템 통합 완료, popdocs 문서 구조 재정비 - **연구**: Softr, Ant Design, Material Design 분석 - **결정**: CSS Grid 기반 그리드 시스템 채택 +### 그리드 가이드 재설계 배경 +- **문제**: SVG GridGuide와 CSS Grid PopRenderer가 좌표계 불일치 +- **원칙**: "격자선은 컴포넌트와 같은 좌표계에서 태어나야 한다" +- **결정**: CSS Grid 기반 실제 DOM 셀로 격자 렌더링 + ### popdocs 재정비 배경 - **문제**: 문서 구조가 AI 에이전트 진입점 역할 못함 - **해결**: Progressive Disclosure 적용, 저장/조회 규칙 명시화 - **참고**: 2025-2026 AI 컨텍스트 엔지니어링 최신 기법 -### 핵심 결정 -- Layer 1 (진입점): README, STATUS, SAVE_RULES -- Layer 2 (상세): CHANGELOG, PROBLEMS, INDEX 등 -- Layer 3 (심화): decisions/, sessions/, archive/ +--- + +## 빌드 결과 + +``` +exit_code: 0 +popScreenMngList: 29.4 kB (311 KB First Load) +총 변경: 8,453줄 삭제, 1,819줄 추가 (순감 6,634줄) +``` --- ## 관련 링크 - ADR: [decisions/003-v5-grid-system.md](../decisions/003-v5-grid-system.md) -- CHANGELOG: 오늘 작업 추가 필요 - 삭제된 파일 목록: FILES.md 하단 "삭제된 파일" 섹션 --- -## 메모 +## 드래그앤드롭 좌표 버그 수정 상세 -- POPUPDATE.md (루트)는 별도로 유지 (전체 프로젝트 기록용) -- popdocs/는 POP 디자이너 개발 전용 -- rangraph 연동 고려 (장기 기억 검색용) +### 문제 현상 +- 컴포넌트를 아래로 드래그해도 위로 올라감 +- Row 92 같은 비정상적인 좌표로 배치됨 +- 드래그 이동/리사이즈가 전혀 작동하지 않음 + +### 핵심 원인 +캔버스에 `transform: scale(0.8)` 적용 시 좌표 계산 불일치: +``` +getBoundingClientRect() → 스케일 적용된 크기 (1024px → 819px) +getClientOffset() → 뷰포트 기준 실제 마우스 좌표 +이 둘을 그대로 계산하면 좌표가 완전히 틀림 +``` + +### 해결 방법 +단순한 상대 좌표 + 스케일 보정: +```typescript +// 캔버스 내 상대 좌표 (스케일 보정) +const relX = (offset.x - canvasRect.left) / canvasScale; +const relY = (offset.y - canvasRect.top) / canvasScale; + +// 그리드 좌표 계산 (실제 캔버스 크기 사용) +calcGridPosition(relX, relY, customWidth, breakpoint.columns, ...); +``` + +### 추가 수정 +- DND 타입 상수를 3개 파일에서 중복 정의 → `constants/dnd.ts`로 통합 +- 불필요한 "컴포넌트가 이동되었습니다" toast 메시지 제거 + +--- + +## 다음 작업자 참고 + +1. **테스트 완료** + - 디자이너 페이지에서 그리드 가이드 확인 ✅ + - 컴포넌트 드래그앤드롭 테스트 ✅ + - 4가지 모드 전환 테스트 (추가 확인 필요) + +2. **향후 결정 필요** + - 간격 조정: 전역 고정 vs 화면별 vs 컴포넌트별 + - 행 수: 현재 20행 고정, 동적 변경 여부 + +3. **Phase 4 준비** + - 실제 컴포넌트 구현 (pop-label, pop-button 등) + - 데이터 바인딩 연결