feat(pop-designer): 반응형 레이아웃 시스템 구현

모드별(4/6/8/12칸) 컴포넌트 위치/크기 오버라이드 저장
화면 밖 컴포넌트 오른쪽 패널 표시 및 드래그 재배치
컴포넌트 숨김 기능 (드래그/H키/클릭, 드래그로 해제)
리사이즈 겹침 검사 추가
드롭 위치 그리드 범위 초과 시 자동 조정
숨김 컴포넌트 드래그 안됨 버그 수정 (상태 업데이트 통합)
This commit is contained in:
SeongHyun Kim 2026-02-05 19:16:23 +09:00
parent 9ebc8c4219
commit 726f6ac395
15 changed files with 2088 additions and 257 deletions

View File

@ -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<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);
// 드래그 상태
const [isDraggingComponent, setIsDraggingComponent] = useState(false);
const [draggedComponentId, setDraggedComponentId] = useState<string | null>(null);
const [dragStartPos, setDragStartPos] = useState<{ x: number; y: number } | null>(null);
const [dragPreviewPos, setDragPreviewPos] = useState<PopGridPosition | null>(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 (
<div className="flex h-full flex-col bg-gray-50">
@ -250,6 +426,35 @@ export default function PopCanvas({
<div className="h-4 w-px bg-gray-300" />
{/* 고정/되돌리기 버튼 (기본 모드 아닐 때만 표시) */}
{currentMode !== DEFAULT_PRESET && (
<>
<Button
variant="outline"
size="sm"
onClick={onLockLayout}
className="h-8 gap-1 text-xs"
>
<Lock className="h-3 w-3" />
</Button>
{layout.overrides?.[currentMode] && (
<Button
variant="ghost"
size="sm"
onClick={() => onResetOverride?.(currentMode)}
className="h-8 gap-1 text-xs"
>
<RotateCcw className="h-3 w-3" />
</Button>
)}
</>
)}
<div className="h-4 w-px bg-gray-300" />
{/* 해상도 표시 */}
<div className="text-xs text-muted-foreground">
{customWidth} × {customHeight}
@ -318,102 +523,138 @@ export default function PopCanvas({
onWheel={handleWheel}
>
<div
className="relative mx-auto my-8 origin-top"
className="relative mx-auto my-8 origin-top overflow-visible flex gap-4"
style={{
width: `${customWidth + 32}px`, // 라벨 공간 추가
width: showRightPanel
? `${customWidth + 32 + 220}px` // 오른쪽 패널 공간 추가
: `${customWidth + 32}px`,
minHeight: `${customHeight + 32}px`,
transform: `scale(${canvasScale})`,
}}
>
{/* 그리드 라벨 영역 */}
{showGridGuide && (
<>
{/* 열 라벨 (상단) */}
<div
className="flex absolute top-0 left-8"
style={{
gap: `${breakpoint.gap}px`,
paddingLeft: `${breakpoint.padding}px`,
}}
>
{gridLabels.columnLabels.map((num) => (
<div
key={`col-${num}`}
className="flex items-center justify-center text-xs font-semibold text-blue-500"
style={{
width: `calc((${customWidth}px - ${breakpoint.padding * 2}px - ${breakpoint.gap * (breakpoint.columns - 1)}px) / ${breakpoint.columns})`,
height: "24px",
}}
>
{num}
</div>
))}
</div>
{/* 행 라벨 (좌측) */}
<div
className="flex flex-col absolute top-8 left-0"
style={{
gap: `${breakpoint.gap}px`,
paddingTop: `${breakpoint.padding}px`,
}}
>
{gridLabels.rowLabels.map((num) => (
<div
key={`row-${num}`}
className="flex items-center justify-center text-xs font-semibold text-blue-500"
style={{
width: "24px",
height: `${breakpoint.rowHeight}px`,
}}
>
{num}
</div>
))}
</div>
</>
)}
{/* 디바이스 스크린 */}
<div
ref={canvasRef}
className={cn(
"relative rounded-lg border-2 bg-white shadow-xl overflow-hidden",
canDrop && isOver && "ring-4 ring-primary/20"
{/* 그리드 + 라벨 영역 */}
<div className="relative">
{/* 그리드 라벨 영역 */}
{showGridGuide && (
<>
{/* 열 라벨 (상단) */}
<div
className="flex absolute top-0 left-8"
style={{
gap: `${breakpoint.gap}px`,
paddingLeft: `${breakpoint.padding}px`,
}}
>
{gridLabels.columnLabels.map((num) => (
<div
key={`col-${num}`}
className="flex items-center justify-center text-xs font-semibold text-blue-500"
style={{
width: `calc((${customWidth}px - ${breakpoint.padding * 2}px - ${breakpoint.gap * (breakpoint.columns - 1)}px) / ${breakpoint.columns})`,
height: "24px",
}}
>
{num}
</div>
))}
</div>
{/* 행 라벨 (좌측) */}
<div
className="flex flex-col absolute top-8 left-0"
style={{
gap: `${breakpoint.gap}px`,
paddingTop: `${breakpoint.padding}px`,
}}
>
{gridLabels.rowLabels.map((num) => (
<div
key={`row-${num}`}
className="flex items-center justify-center text-xs font-semibold text-blue-500"
style={{
width: "24px",
height: `${breakpoint.rowHeight}px`,
}}
>
{num}
</div>
))}
</div>
</>
)}
style={{
width: `${customWidth}px`,
minHeight: `${customHeight}px`,
marginLeft: "32px",
marginTop: "32px",
}}
>
{isEmpty ? (
// 빈 상태
<div className="flex h-full items-center justify-center p-8">
<div className="text-center">
<div className="mb-2 text-sm font-medium text-gray-500">
</div>
<div className="text-xs text-gray-400">
{breakpoint.label} - {breakpoint.columns}
{/* 디바이스 스크린 */}
<div
ref={canvasRef}
className={cn(
"relative rounded-lg border-2 bg-white shadow-xl overflow-visible",
canDrop && isOver && "ring-4 ring-primary/20"
)}
style={{
width: `${customWidth}px`,
minHeight: `${customHeight}px`,
marginLeft: "32px",
marginTop: "32px",
}}
>
{isEmpty ? (
// 빈 상태
<div className="flex h-full items-center justify-center p-8">
<div className="text-center">
<div className="mb-2 text-sm font-medium text-gray-500">
</div>
<div className="text-xs text-gray-400">
{breakpoint.label} - {breakpoint.columns}
</div>
</div>
</div>
</div>
) : (
// 그리드 렌더러
<PopRenderer
layout={layout}
viewportWidth={customWidth}
currentMode={currentMode}
isDesignMode={true}
showGridGuide={showGridGuide}
selectedComponentId={selectedComponentId}
onComponentClick={onSelectComponent}
onBackgroundClick={() => onSelectComponent(null)}
/>
)}
) : (
// 그리드 렌더러
<PopRenderer
layout={layout}
viewportWidth={customWidth}
currentMode={currentMode}
isDesignMode={true}
showGridGuide={showGridGuide}
selectedComponentId={selectedComponentId}
onComponentClick={onSelectComponent}
onBackgroundClick={() => onSelectComponent(null)}
onComponentMove={onMoveComponent}
onComponentResize={onResizeComponent}
onComponentResizeEnd={onResizeEnd}
/>
)}
</div>
</div>
{/* 오른쪽 패널 영역 (초과 컴포넌트 + 숨김 컴포넌트) */}
{showRightPanel && (
<div
className="flex flex-col gap-3"
style={{ marginTop: "32px" }}
>
{/* 초과 컴포넌트 패널 */}
{showOutOfBoundsPanel && (
<OutOfBoundsPanel
components={outOfBoundsComponents}
selectedComponentId={selectedComponentId}
onSelectComponent={onSelectComponent}
onHideComponent={onHideComponent}
/>
)}
{/* 숨김 컴포넌트 패널 */}
{showHiddenPanel && (
<HiddenPanel
components={hiddenComponents}
selectedComponentId={selectedComponentId}
onSelectComponent={onSelectComponent}
onHideComponent={onHideComponent}
/>
)}
</div>
)}
</div>
</div>
@ -429,3 +670,263 @@ export default function PopCanvas({
</div>
);
}
// ========================================
// 초과 컴포넌트 영역 (오른쪽 패널)
// ========================================
interface OutOfBoundsPanelProps {
components: PopComponentDefinitionV5[];
selectedComponentId: string | null;
onSelectComponent: (id: string | null) => void;
onHideComponent?: (componentId: string) => void;
}
function OutOfBoundsPanel({
components,
selectedComponentId,
onSelectComponent,
onHideComponent,
}: OutOfBoundsPanelProps) {
return (
<div
className="flex flex-col rounded-lg border-2 border-dashed border-orange-300 bg-orange-50/50"
style={{
width: "200px",
maxHeight: "300px",
}}
>
{/* 헤더 */}
<div className="flex items-center gap-2 border-b border-orange-200 bg-orange-100/50 px-3 py-2 rounded-t-lg">
<AlertTriangle className="h-4 w-4 text-orange-600" />
<span className="text-xs font-semibold text-orange-700">
({components.length})
</span>
</div>
{/* 컴포넌트 목록 */}
<div className="flex-1 overflow-auto p-2 space-y-2">
{components.map((comp) => (
<OutOfBoundsItem
key={comp.id}
component={comp}
isSelected={selectedComponentId === comp.id}
onSelect={() => onSelectComponent(comp.id)}
onHide={() => onHideComponent?.(comp.id)}
/>
))}
</div>
{/* 안내 문구 */}
<div className="border-t border-orange-200 px-3 py-2 bg-orange-50/80 rounded-b-lg">
<p className="text-[10px] text-orange-600 leading-tight">
/
</p>
</div>
</div>
);
}
// ========================================
// 숨김 컴포넌트 영역 (오른쪽 패널)
// ========================================
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 (
<div
ref={drop}
className={cn(
"flex flex-col rounded-lg border-2 border-dashed bg-gray-100/50 transition-colors",
isOver && canDrop
? "border-gray-600 bg-gray-200/70"
: "border-gray-400"
)}
style={{
width: "200px",
maxHeight: "300px",
}}
>
{/* 헤더 */}
<div className="flex items-center gap-2 border-b border-gray-300 bg-gray-200/50 px-3 py-2 rounded-t-lg">
<EyeOff className="h-4 w-4 text-gray-600" />
<span className="text-xs font-semibold text-gray-700">
({components.length})
</span>
</div>
{/* 컴포넌트 목록 */}
<div className="flex-1 overflow-auto p-2 space-y-2">
{components.map((comp) => (
<HiddenItem
key={comp.id}
component={comp}
isSelected={selectedComponentId === comp.id}
onSelect={() => onSelectComponent(comp.id)}
/>
))}
</div>
{/* 안내 문구 */}
<div className="border-t border-gray-300 px-3 py-2 bg-gray-100/80 rounded-b-lg">
<p className="text-[10px] text-gray-600 leading-tight">
</p>
</div>
</div>
);
}
// ========================================
// 초과 컴포넌트 아이템 (드래그 가능)
// ========================================
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 (
<div
ref={drag}
className={cn(
"rounded-md border-2 bg-white p-2 cursor-move transition-all",
isSelected
? "border-primary ring-2 ring-primary/30"
: "border-gray-300 hover:border-gray-400",
isDragging && "opacity-50"
)}
onClick={handleClick}
>
{/* 컴포넌트 이름 */}
<div className="text-xs font-medium text-gray-700 truncate">
{component.label || component.type}
</div>
{/* 원본 위치 정보 */}
<div className="text-[10px] text-gray-500 mt-1">
: {component.position.col}, {component.position.row}
({component.position.colSpan}×{component.position.rowSpan})
</div>
</div>
);
}
// ========================================
// 숨김 컴포넌트 아이템 (드래그 가능)
// ========================================
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 (
<div
ref={drag}
className={cn(
"rounded-md border-2 bg-white p-2 cursor-move transition-all opacity-60",
isSelected
? "border-primary ring-2 ring-primary/30"
: "border-gray-400 hover:border-gray-500",
isDragging && "opacity-30"
)}
onClick={onSelect}
>
{/* 컴포넌트 이름 */}
<div className="flex items-center gap-1 text-xs font-medium text-gray-600 truncate">
<EyeOff className="h-3 w-3" />
{component.label || component.type}
</div>
{/* 원본 위치 정보 */}
<div className="text-[10px] text-gray-500 mt-1">
: {component.position.col}, {component.position.row}
</div>
</div>
);
}

View File

@ -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<string, PopGridPosition> = {};
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}
/>
</ResizablePanel>

View File

@ -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];

View File

@ -0,0 +1 @@
export * from "./dnd";

View File

@ -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 {

View File

@ -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 (
<div
className={cn("relative min-h-full w-full", className)}
@ -175,6 +192,7 @@ export default function PopRenderer({
))}
{/* 컴포넌트 렌더링 (z-index로 위에 표시) */}
{/* 디자인 모드에서는 초과 컴포넌트를 그리드에서 제외 (오른쪽 별도 영역에 표시) */}
{Object.values(components).map((comp) => {
// 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 (
<div
<DraggableComponent
key={comp.id}
className={cn(
"relative rounded-lg border-2 bg-white transition-all overflow-hidden z-10",
isSelected
? "border-primary ring-2 ring-primary/30"
: "border-gray-200",
isDesignMode && "cursor-pointer hover:border-gray-300 hover:shadow-sm"
)}
style={positionStyle}
onClick={(e) => {
e.stopPropagation();
onComponentClick?.(comp.id);
}}
>
<ComponentContent
component={comp}
isDesignMode={isDesignMode}
isSelected={isSelected}
/>
</div>
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}
/>
);
})}
</div>
);
}
// ========================================
// 드래그 가능한 컴포넌트 래퍼
// ========================================
interface DraggableComponentProps {
component: PopComponentDefinitionV5;
position: PopGridPosition;
positionStyle: React.CSSProperties;
isSelected: boolean;
isDesignMode: boolean;
isOutOfBounds: boolean;
breakpoint: GridBreakpoint;
viewportWidth: number;
allEffectivePositions: Map<string, PopGridPosition>;
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 (
<div
ref={isDesignMode ? drag : null}
className={cn(
"relative rounded-lg border-2 transition-all overflow-visible z-10",
// 초과 컴포넌트 스타일 (디자인 모드에서만)
isDesignMode && isOutOfBounds && "opacity-40 bg-gray-400/30",
!isOutOfBounds && "bg-white",
isSelected
? "border-primary ring-2 ring-primary/30"
: "border-gray-200",
isDesignMode && "cursor-move hover:border-gray-300 hover:shadow-sm",
isDragging && "opacity-50"
)}
style={positionStyle}
onClick={(e) => {
e.stopPropagation();
onComponentClick?.(component.id);
}}
title={
isDesignMode && isOutOfBounds
? `이 컴포넌트는 ${breakpoint.label}에서 표시되지 않습니다`
: undefined
}
>
<ComponentContent
component={component}
effectivePosition={position}
isDesignMode={isDesignMode}
isSelected={isSelected}
isOutOfBounds={isOutOfBounds}
/>
{/* 리사이즈 핸들 (선택된 컴포넌트만, 초과 아닐 때만) */}
{isDesignMode && isSelected && !isOutOfBounds && onComponentResize && (
<ResizeHandles
component={component}
position={position}
breakpoint={breakpoint}
viewportWidth={viewportWidth}
allEffectivePositions={allEffectivePositions}
onResize={onComponentResize}
onResizeEnd={onComponentResizeEnd}
/>
)}
</div>
);
}
// ========================================
// 리사이즈 핸들
// ========================================
interface ResizeHandlesProps {
component: PopComponentDefinitionV5;
position: PopGridPosition;
breakpoint: GridBreakpoint;
viewportWidth: number;
allEffectivePositions: Map<string, PopGridPosition>;
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 (
<>
{/* 오른쪽 핸들 (가로 크기) */}
<div
className="absolute top-0 bottom-0 w-2 cursor-ew-resize bg-primary/20 hover:bg-primary/50 transition-colors"
onMouseDown={handleMouseDown('right')}
style={{ right: '-4px' }}
/>
{/* 아래쪽 핸들 (세로 크기) */}
<div
className="absolute left-0 right-0 h-2 cursor-ns-resize bg-primary/20 hover:bg-primary/50 transition-colors"
onMouseDown={handleMouseDown('bottom')}
style={{ bottom: '-4px' }}
/>
{/* 오른쪽 아래 모서리 (가로+세로) */}
<div
className="absolute h-3 w-3 cursor-nwse-resize bg-primary hover:bg-primary/80 transition-colors rounded-sm"
onMouseDown={handleMouseDown('corner')}
style={{ right: '-6px', bottom: '-6px' }}
/>
</>
);
}
// ========================================
// 컴포넌트 내용 렌더링
// ========================================
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}
</span>
{/* 초과 표시 */}
{isOutOfBounds && (
<span className="ml-1 text-[9px] text-orange-600 font-semibold">
</span>
)}
</div>
{/* 내용 */}
<div className="flex flex-1 items-center justify-center p-2">
<span className="text-xs text-gray-400">{typeLabel}</span>
<span className={cn(
"text-xs",
isOutOfBounds ? "text-gray-500" : "text-gray-400"
)}>
{typeLabel}
</span>
</div>
{/* 위치 정보 표시 */}
{/* 위치 정보 표시 (유효 위치 사용) */}
<div className="absolute bottom-1 right-1 text-[9px] text-gray-400 bg-white/80 px-1 rounded">
{component.position.col},{component.position.row}
({component.position.colSpan}×{component.position.rowSpan})
{effectivePosition.col},{effectivePosition.row}
({effectivePosition.colSpan}×{effectivePosition.rowSpan})
</div>
</div>
);

View File

@ -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<string, PopGridPosition> {
const result = new Map<string, PopGridPosition>();
// 숨김 처리된 컴포넌트 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;
}

View File

@ -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<string, Partial<PopGridPosition>>; // 위치 오버라이드
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로 전환했는가)

View File

@ -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 => (
<div
key={cell.id}
className="border border-dashed border-blue-300/40"
style={{ gridColumn: cell.col, gridRow: cell.row }}
/>
))}
```
**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 통합) |
---

View File

@ -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 | 좌표 계산, 겹침 감지, 자동 배치 |

View File

@ -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()`는 스케일 적용된 값을 반환하지만
> 마우스 좌표는 뷰포트 기준이므로 **반드시 스케일 보정 필요**
---

View File

@ -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)*

View File

@ -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 |

View File

@ -0,0 +1,143 @@
# ADR-004: 그리드 가이드 CSS Grid 통합
**상태**: 승인됨
**날짜**: 2026-02-05
**결정자**: 개발팀
---
## 컨텍스트
그리드 가이드는 다음 목적을 가짐:
1. **시각적 기준**: 어디에 배치할지 눈으로 확인 가능
2. **정렬 도움**: 칸에 맞춰 배치하기 쉬움
3. **디자인 일관성**: 규칙적인 배치 유도
기존 구현:
- `GridGuide.tsx`: SVG `<line>` 요소로 격자선 렌더링
- `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 => (
<div
key={cell.id}
className="border border-dashed border-blue-300/40"
style={{
gridColumn: cell.col,
gridRow: cell.row,
}}
/>
))}
```
### 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)

View File

@ -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 등)
- 데이터 바인딩 연결