feat(pop-designer): 반응형 레이아웃 시스템 구현
모드별(4/6/8/12칸) 컴포넌트 위치/크기 오버라이드 저장 화면 밖 컴포넌트 오른쪽 패널 표시 및 드래그 재배치 컴포넌트 숨김 기능 (드래그/H키/클릭, 드래그로 해제) 리사이즈 겹침 검사 추가 드롭 위치 그리드 범위 초과 시 자동 조정 숨김 컴포넌트 드래그 안됨 버그 수정 (상태 업데이트 통합)
This commit is contained in:
parent
9ebc8c4219
commit
726f6ac395
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from "./dnd";
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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로 전환했는가)
|
||||
|
|
|
|||
|
|
@ -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 통합) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 | 좌표 계산, 겹침 감지, 자동 배치 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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()`는 스케일 적용된 값을 반환하지만
|
||||
> 마우스 좌표는 뷰포트 기준이므로 **반드시 스케일 보정 필요**
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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)*
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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 등)
|
||||
- 데이터 바인딩 연결
|
||||
|
|
|
|||
Loading…
Reference in New Issue