From e196ed07104a3ab0b2b197f9db76f8543512f5e9 Mon Sep 17 00:00:00 2001 From: "DESKTOP-4N4R3LH\\jqk17" Date: Sun, 22 Feb 2026 01:07:50 +0900 Subject: [PATCH 01/18] =?UTF-8?q?=F0=9F=94=80=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=20=ED=8C=A8=EB=84=90=EC=9D=84=20=EC=97=90=EB=94=94?= =?UTF-8?q?=ED=84=B0=20=EA=B8=B0=EB=B3=B8=20=ED=83=AD=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/editor/src/widgets/left-sidebar/model/store.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/editor/src/widgets/left-sidebar/model/store.ts b/apps/editor/src/widgets/left-sidebar/model/store.ts index 1d998ec..7af89c6 100644 --- a/apps/editor/src/widgets/left-sidebar/model/store.ts +++ b/apps/editor/src/widgets/left-sidebar/model/store.ts @@ -2,9 +2,9 @@ import { create } from 'zustand'; import { NavigationState } from './types'; export const useNavigationStore = create((set) => ({ - activeTab: 'component', // 기본값 + activeTab: 'layer', // 기본값 isExpanded: true, - + setActiveTab: (tab) => set((state) => ({ activeTab: tab, // 이미 열려있는 탭을 다시 누르면 닫거나, 다른 탭을 누르면 패널 유지 From 376e6b3daa4c06f2607b62897c5cac83950ac295 Mon Sep 17 00:00:00 2001 From: "DESKTOP-4N4R3LH\\jqk17" Date: Sun, 22 Feb 2026 01:10:25 +0900 Subject: [PATCH 02/18] =?UTF-8?q?=F0=9F=97=91=EF=B8=8F=20deleteNode=20?= =?UTF-8?q?=EC=9E=90=EC=8B=9D=20=EB=85=B8=EB=93=9C=20=EC=9E=AC=EA=B7=80=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=B0=8F=20=EC=84=A0=ED=83=9D=20=ED=95=B4?= =?UTF-8?q?=EC=A0=9C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/editor/src/stores/useEditorStore.ts | 36 ++++++++++++++++++------ 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/apps/editor/src/stores/useEditorStore.ts b/apps/editor/src/stores/useEditorStore.ts index fac6140..975e385 100644 --- a/apps/editor/src/stores/useEditorStore.ts +++ b/apps/editor/src/stores/useEditorStore.ts @@ -25,15 +25,35 @@ const useEditorStore = create( state.nodes?.push(node); }); }, + // 자식 노드까지 재귀적으로 삭제 deleteNode(nodeId: string) { - set((state) => { - if (!state.nodes) return; - const targetNodeIdx = state.nodes?.findIndex( - (node) => node.id === nodeId, - ); - if (targetNodeIdx === -1) return; - state.nodes.splice(targetNodeIdx, 1); - }); + set( + (state) => { + if (!state.nodes) return; + + // 삭제 대상 ID 수집 (자기 자신 + 모든 후손) + const idsToDelete = new Set(); + function collect(id: string) { + idsToDelete.add(id); + state.nodes! + .filter((n) => n.parent_id === id) + .forEach((n) => collect(n.id)); + } + collect(nodeId); + + // 일괄 삭제 + state.nodes = state.nodes.filter( + (n) => !idsToDelete.has(n.id), + ); + + // 삭제된 노드가 선택 경로에 있으면 선택 해제 + if (state.selectedDepthPath.includes(nodeId)) { + state.selectedDepthPath = []; + } + }, + false, + "editStore/deleteNode", + ); }, getDescendantIds(nodeId: string): string[] { const nodes = get().nodes; From 431d78ef22a66c61a9e755dba512d883d6a0f46c Mon Sep 17 00:00:00 2001 From: "DESKTOP-4N4R3LH\\jqk17" Date: Sun, 22 Feb 2026 01:10:31 +0900 Subject: [PATCH 03/18] =?UTF-8?q?=E2=9C=A8=20=EB=85=B8=EB=93=9C=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=EB=B3=84=20=EC=9E=90=EC=8B=9D=20=EC=82=BD?= =?UTF-8?q?=EC=9E=85=20=EA=B7=9C=EC=B9=99=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../left-sidebar/model/nodeInsertRules.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 apps/editor/src/widgets/left-sidebar/model/nodeInsertRules.ts diff --git a/apps/editor/src/widgets/left-sidebar/model/nodeInsertRules.ts b/apps/editor/src/widgets/left-sidebar/model/nodeInsertRules.ts new file mode 100644 index 0000000..390692e --- /dev/null +++ b/apps/editor/src/widgets/left-sidebar/model/nodeInsertRules.ts @@ -0,0 +1,56 @@ +/** + * 노드 타입별 자식 삽입 규칙 + * + * insertableChildren: 해당 노드 타입 안에 삽입 가능한 자식 타입 목록 + * - 빈 배열이면 자식을 가질 수 없는 리프 노드 (Text, Image, Heading, Button 등) + * - 목록에 포함된 타입만 우클릭 메뉴에 표시 + */ +import { WcxNode } from "@repo/ui/types/nodes"; +import { LucideIcon, Type, Layout, Heading1, Square } from "lucide-react"; + +export interface InsertOption { + type: WcxNode["type"]; + label: string; + icon: LucideIcon; +} + +/** + * 각 노드 타입이 자식으로 받을 수 있는 타입 목록 + * 여기에 새 노드 타입을 추가하면 컨텍스트 메뉴에 자동 반영됩니다. + */ +const INSERT_OPTIONS_MAP: Record = { + // 리프 노드 (자식 불가) + Text: [], + Heading: [], + Image: [], + Button: [], + + // 컨테이너 노드 (자식 가능) + Stack: [ + { type: "Text", label: "텍스트", icon: Type }, + { type: "Heading", label: "헤딩", icon: Heading1 }, + { type: "Stack", label: "스택", icon: Layout }, + { type: "Button", label: "버튼", icon: Square }, + ], + Container: [ + { type: "Text", label: "텍스트", icon: Type }, + { type: "Stack", label: "스택", icon: Layout }, + ], + Group: [ + { type: "Text", label: "텍스트", icon: Type }, + { type: "Stack", label: "스택", icon: Layout }, + ], + Modal: [ + { type: "Text", label: "텍스트", icon: Type }, + { type: "Stack", label: "스택", icon: Layout }, + ], +}; + +/** + * 특정 노드 타입에 삽입 가능한 자식 옵션 배열을 반환합니다. + * @param nodeType 부모 노드의 type + * @returns InsertOption[] — 비어 있으면 삽입 불가 (리프 노드) + */ +export function getInsertOptions(nodeType: WcxNode["type"]): InsertOption[] { + return INSERT_OPTIONS_MAP[nodeType] ?? []; +} From eef3a1fd5e3c478d3bc44d32bcd29ce4ca0f2d17 Mon Sep 17 00:00:00 2001 From: "DESKTOP-4N4R3LH\\jqk17" Date: Sun, 22 Feb 2026 01:10:34 +0900 Subject: [PATCH 04/18] =?UTF-8?q?=E2=9C=A8=20=EB=A0=88=EC=9D=B4=EC=96=B4?= =?UTF-8?q?=20=EC=9A=B0=ED=81=B4=EB=A6=AD=20=EC=BB=A8=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=A9=94=EB=89=B4=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../left-sidebar/ui/LayerContextMenu.tsx | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 apps/editor/src/widgets/left-sidebar/ui/LayerContextMenu.tsx diff --git a/apps/editor/src/widgets/left-sidebar/ui/LayerContextMenu.tsx b/apps/editor/src/widgets/left-sidebar/ui/LayerContextMenu.tsx new file mode 100644 index 0000000..19a5e25 --- /dev/null +++ b/apps/editor/src/widgets/left-sidebar/ui/LayerContextMenu.tsx @@ -0,0 +1,87 @@ +/** + * 레이어 패널 우클릭 컨텍스트 메뉴 + * + * nodeInsertRules에 따라 삽입 가능한 자식 옵션을 동적으로 표시합니다. + */ +import { useRef, useEffect } from "react"; +import { Trash2, Plus } from "lucide-react"; +import { WcxNode } from "@repo/ui/types/nodes"; +import { getInsertOptions } from "../model/nodeInsertRules"; + +export interface LayerContextMenuProps { + x: number; + y: number; + nodeId: string; + nodeType: WcxNode["type"]; + onDelete: (id: string) => void; + onInsert: (parentId: string, type: WcxNode["type"]) => void; + onClose: () => void; +} + +export default function LayerContextMenu({ + x, + y, + nodeId, + nodeType, + onDelete, + onInsert, + onClose, +}: LayerContextMenuProps) { + const ref = useRef(null); + + // 외부 클릭 시 닫기 + useEffect(() => { + const handleClick = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + onClose(); + } + }; + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [onClose]); + + const insertOptions = getInsertOptions(nodeType); + + return ( +
+ {/* 삽입 옵션 (리프 노드면 표시 안 함) */} + {insertOptions.length > 0 && ( + <> + {insertOptions.map(({ type, label, icon: Icon }) => ( + + ))} + + {/* 구분선 */} +
+ + )} + + {/* 삭제 */} + +
+ ); +} From 6ad21eed1b0ba9a8297d3993bfb803bb85075f06 Mon Sep 17 00:00:00 2001 From: "DESKTOP-4N4R3LH\\jqk17" Date: Sun, 22 Feb 2026 01:10:37 +0900 Subject: [PATCH 05/18] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20LayerPanel=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20=EB=B0=8F=20=EC=9E=90?= =?UTF-8?q?=EC=8B=9D=20=EC=82=BD=EC=9E=85=20=EA=B8=B0=EB=8A=A5=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../left-sidebar/ui/sub-panels/LayerPanel.tsx | 123 ++++++++++++------ 1 file changed, 83 insertions(+), 40 deletions(-) diff --git a/apps/editor/src/widgets/left-sidebar/ui/sub-panels/LayerPanel.tsx b/apps/editor/src/widgets/left-sidebar/ui/sub-panels/LayerPanel.tsx index 7aa978a..c028c65 100644 --- a/apps/editor/src/widgets/left-sidebar/ui/sub-panels/LayerPanel.tsx +++ b/apps/editor/src/widgets/left-sidebar/ui/sub-panels/LayerPanel.tsx @@ -1,5 +1,6 @@ import { PanelBaseLayout } from './base/PanelBaseLayout'; -import { useCurNodes, useSelectNode, useSelectedNodeId } from '../../../../stores/useEditorStore'; +import { useCurNodes, useSelectNode, useSelectedNodeId, useDeleteNode, useAddNode } from '../../../../stores/useEditorStore'; +import { COMPONENT_DEFAULTS } from '../../../../shared/lib/component-defaults'; import { cn } from '@repo/utils'; import { Type, @@ -12,10 +13,11 @@ import { ChevronRight, ChevronDown, Layers, - LucideIcon + LucideIcon, } from 'lucide-react'; -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import { WcxNode } from '@repo/ui/types/nodes'; +import LayerContextMenu from '../LayerContextMenu'; /** * 노드 타입별 아이콘 매핑 @@ -31,22 +33,20 @@ const NODE_TYPE_ICONS: Record = { Modal: Layout, }; +/* ─────────────────────── Layer Item ─────────────────────── */ + interface LayerItemProps { - node: WcxNode; // 현재 렌더링할 노드 - nodes: WcxNode[]; // 전체 노드 배열 (자식 탐색용) - selectedId: string | null; // 현재 선택된 노드 ID - onSelect: (id: string) => void; // 노드 선택 핸들러 - depth: number; // 계층 깊이 (들여쓰기 계산용) + node: WcxNode; + nodes: WcxNode[]; + selectedId: string | null; + onSelect: (id: string) => void; + onContextMenu: (e: React.MouseEvent, nodeId: string) => void; + depth: number; } -/** - * 개별 레이어 아이템 컴포넌트 (재귀적으로 자식 노드 렌더링) - */ -const LayerItem = ({ node, nodes, selectedId, onSelect, depth }: LayerItemProps) => { - // 폴더 접기/펴기 상태 +const LayerItem = ({ node, nodes, selectedId, onSelect, onContextMenu, depth }: LayerItemProps) => { const [isExpanded, setIsExpanded] = useState(true); - // 현재 노드를 부모로 가지는 자식 노드들을 필터링하고 position 순으로 정렬 const children = nodes .filter((n) => n.parent_id === node.id) .sort((a, b) => a.position - b.position); @@ -55,12 +55,6 @@ const LayerItem = ({ node, nodes, selectedId, onSelect, depth }: LayerItemProps) const isSelected = selectedId === node.id; const Icon = NODE_TYPE_ICONS[node.type] || Box; - /** - * 레이어 리스트에 표시될 노드의 이름 결정 - * 1. 텍스트 컴포넌트면 해당 텍스트 내용 우선 - * 2. 이미지면 alt 텍스트 우선 - * 3. 위 조건에 해당 없으면 노드 타입 표시 - */ const getNodeName = () => { if ('props' in node) { const props = node.props as Record; @@ -72,23 +66,22 @@ const LayerItem = ({ node, nodes, selectedId, onSelect, depth }: LayerItemProps) return (
- {/* 레이어 행 (클릭 시 선택) */}
onSelect(node.id)} + onContextMenu={(e) => onContextMenu(e, node.id)} > - {/* 접기/펴기 버튼 (자식이 있을 때만 노출) */}
{hasChildren && (
- {/* 노드 타입 아이콘 */} - {/* 노드 이름 */}
- {/* 자식 노드 재귀 호출 */} {hasChildren && isExpanded && (
{children.map((child) => ( @@ -130,6 +120,7 @@ const LayerItem = ({ node, nodes, selectedId, onSelect, depth }: LayerItemProps) nodes={nodes} selectedId={selectedId} onSelect={onSelect} + onContextMenu={onContextMenu} depth={depth + 1} /> ))} @@ -139,28 +130,68 @@ const LayerItem = ({ node, nodes, selectedId, onSelect, depth }: LayerItemProps) ); }; -/** - * 레이어 패널 메인 컴포넌트 - */ +/* ─────────────────────── Layer Panel ─────────────────────── */ + export const LayerPanel = () => { - // 스토어에서 전체 노드와 선택 정보 가져오기 const nodes = useCurNodes() || []; const selectedId = useSelectedNodeId(); const selectNode = useSelectNode(); + const deleteNode = useDeleteNode(); + const addNode = useAddNode(); + + // 우클릭 컨텍스트 메뉴 상태 + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + nodeId: string; + nodeType: WcxNode["type"]; + } | null>(null); + + const handleContextMenu = (e: React.MouseEvent, nodeId: string) => { + e.preventDefault(); + e.stopPropagation(); + const targetNode = nodes.find((n) => n.id === nodeId); + if (!targetNode) return; + setContextMenu({ x: e.clientX, y: e.clientY, nodeId, nodeType: targetNode.type }); + }; + + // 자식 노드 삽입 핸들러 + const handleInsertChild = useCallback( + (parentId: string, type: WcxNode['type']) => { + const defaults = COMPONENT_DEFAULTS[type]; + if (!defaults) return; + + const siblingCount = nodes.filter((n) => n.parent_id === parentId).length; + const parentNode = nodes.find((n) => n.id === parentId); + + const newNode: WcxNode = { + id: `${type.toLowerCase()}-${Date.now()}`, + page_id: parentNode?.page_id ?? 1, + parent_id: parentId, + type, + position: siblingCount, + layout: { ...defaults.layout }, + props: { ...defaults.props }, + style: { + ...defaults.style, + position: 'relative', + }, + created_at: new Date().toISOString(), + } as WcxNode; + + addNode(newNode); + }, + [nodes, addNode], + ); - // 최상위 노드(부모가 없는 노드)들만 먼저 추출 const rootNodes = nodes .filter((node) => node.parent_id === null) .sort((a, b) => a.position - b.position); return ( - +
{rootNodes.length > 0 ? ( - // 최상위 노드부터 렌더링 시작 (이후 내부에서 자식들을 재귀적으로 그림) rootNodes.map((node) => ( { nodes={nodes} selectedId={selectedId} onSelect={selectNode} - depth={0} // 루트는 깊이 0 + onContextMenu={handleContextMenu} + depth={0} /> )) ) : ( - // 노드가 없을 때의 빈 화면

레이어가 없습니다.
컴포넌트를 추가해보세요.

)}
+ + {contextMenu && ( + setContextMenu(null)} + /> + )}
); }; From 57922be83d95ff52fa3798a99d22d7bb42c4f2f2 Mon Sep 17 00:00:00 2001 From: "DESKTOP-4N4R3LH\\jqk17" Date: Sun, 22 Feb 2026 10:47:45 +0900 Subject: [PATCH 06/18] =?UTF-8?q?=E2=9C=A8=20=EB=85=B8=EB=93=9C=20?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EC=A7=95=20=EB=AA=A8=EB=93=9C(widthMode/heig?= =?UTF-8?q?htMode)=20=EB=B0=8F=20Stack=20direction=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ui/src/types/componentProps.ts | 4 ++++ packages/ui/src/types/nodes.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/types/componentProps.ts b/packages/ui/src/types/componentProps.ts index 1e982d6..db7e8c9 100644 --- a/packages/ui/src/types/componentProps.ts +++ b/packages/ui/src/types/componentProps.ts @@ -44,3 +44,7 @@ export interface ModalProps { // 애니메이션 프리셋 animation?: "fade" | "slide-up" | "slide-left"; } + +export interface StackProps { + direction?: "row" | "column"; +} diff --git a/packages/ui/src/types/nodes.ts b/packages/ui/src/types/nodes.ts index 0aa0d11..4f4d635 100644 --- a/packages/ui/src/types/nodes.ts +++ b/packages/ui/src/types/nodes.ts @@ -69,7 +69,7 @@ export interface GroupNode extends BaseNode { export interface StackNode extends BaseNode { type: "Stack"; - props: {}; + props: componentProps.StackProps; } // 3. 통합 노드 타입 From cfacf24ee476c47a91da0a29c969dab639a8eb41 Mon Sep 17 00:00:00 2001 From: "DESKTOP-4N4R3LH\\jqk17" Date: Sun, 22 Feb 2026 10:47:54 +0900 Subject: [PATCH 07/18] =?UTF-8?q?=E2=9C=A8=20fill/fit/relative=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=EC=A7=95=20=EB=AA=A8=EB=93=9C=20CSS=20=EB=B3=80?= =?UTF-8?q?=ED=99=98=20=EC=9C=A0=ED=8B=B8=EB=A6=AC=ED=8B=B0=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ui/src/utils/resolveSizeStyle.ts | 100 ++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 packages/ui/src/utils/resolveSizeStyle.ts diff --git a/packages/ui/src/utils/resolveSizeStyle.ts b/packages/ui/src/utils/resolveSizeStyle.ts new file mode 100644 index 0000000..17e926c --- /dev/null +++ b/packages/ui/src/utils/resolveSizeStyle.ts @@ -0,0 +1,100 @@ +import { CSSProperties } from "react"; + +type SizingMode = "fixed" | "fill" | "fit" | "relative"; +type StackDirection = "row" | "column"; + +interface LayoutInput { + width: number | string; + height: number | string; + widthMode: SizingMode; + heightMode: SizingMode; +} + +/** + * widthMode/heightMode를 실제 CSS 속성으로 변환합니다. + * + * Stack 내부의 flow 아이템에서 사용됩니다. + * 주축(main axis)과 교차축(cross axis)에서 fill/fit의 동작이 다릅니다. + * + * @param layout 노드의 layout 데이터 + * @param parentDirection 부모 Stack의 방향 (row | column) + */ +export default function resolveSizeStyle( + layout: LayoutInput, + parentDirection: StackDirection = "column", +): CSSProperties { + const style: CSSProperties = {}; + + // width 처리 + const isWidthMainAxis = parentDirection === "row"; + Object.assign( + style, + resolveAxis(layout.widthMode, layout.width, isWidthMainAxis, "width"), + ); + + // height 처리 + const isHeightMainAxis = parentDirection === "column"; + Object.assign( + style, + resolveAxis(layout.heightMode, layout.height, isHeightMainAxis, "height"), + ); + + return style; +} + +function resolveAxis( + mode: SizingMode, + value: number | string, + isMainAxis: boolean, + dimension: "width" | "height", +): CSSProperties { + const numValue = typeof value === "number" ? value : parseFloat(value) || 0; + + switch (mode) { + case "fixed": + return { [dimension]: `${numValue}px` }; + + case "relative": + return { [dimension]: `${numValue}%` }; + + case "fill": + if (isMainAxis) { + // 주축: flex-grow로 남은 공간 분배 + // flexBasis: 0 → 콘텐츠 크기와 무관하게 균등 분배 + // minWidth/minHeight: 0 → 콘텐츠 오버플로우 방지 + return { + flexGrow: numValue || 1, + flexBasis: 0, + flexShrink: 1, + [`min${capitalize(dimension)}`]: 0, + }; + } else { + // 교차축: stretch로 부모 너비에 맞추기 + return { + alignSelf: "stretch" as const, + [`min${capitalize(dimension)}`]: 0, + }; + } + + case "fit": + if (isMainAxis) { + // 주축: 콘텐츠 크기 유지, 압축/확장 방지 + return { + flexGrow: 0, + flexShrink: 0, + }; + } else { + // 교차축: fit-content로 콘텐츠 크기 + return { + [dimension]: "fit-content", + }; + } + + default: + return { [dimension]: `${numValue}px` }; + } +} + +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} From 67827aab7a9a0e208899a3626aa79474dab444a7 Mon Sep 17 00:00:00 2001 From: "DESKTOP-4N4R3LH\\jqk17" Date: Sun, 22 Feb 2026 10:47:59 +0900 Subject: [PATCH 08/18] =?UTF-8?q?=E2=9C=A8=20Stack=20data-attribute=20?= =?UTF-8?q?=EB=B0=A9=ED=96=A5=20=EB=85=B8=EC=B6=9C=20=EB=B0=8F=20FlowNodeW?= =?UTF-8?q?rapper=20=EB=B6=84=EA=B8=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ui/src/components/Stack.tsx | 8 ++- packages/ui/src/core/EditorNodeWrapper.tsx | 18 ++++++ packages/ui/src/core/FlowNodeWrapper.tsx | 72 ++++++++++++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/core/FlowNodeWrapper.tsx diff --git a/packages/ui/src/components/Stack.tsx b/packages/ui/src/components/Stack.tsx index d38c418..008bcbd 100644 --- a/packages/ui/src/components/Stack.tsx +++ b/packages/ui/src/components/Stack.tsx @@ -10,12 +10,17 @@ export default function StackComponent({ const hoveredStackId = useDragStore((s) => s.hoveredStackId); const cssProps = processNodeStyles(node.style); + const direction = node.props.direction ?? "column"; return (
); } + diff --git a/packages/ui/src/core/EditorNodeWrapper.tsx b/packages/ui/src/core/EditorNodeWrapper.tsx index 6ac3398..cd01e5d 100644 --- a/packages/ui/src/core/EditorNodeWrapper.tsx +++ b/packages/ui/src/core/EditorNodeWrapper.tsx @@ -5,6 +5,7 @@ import { useState } from "react"; import { Rnd } from "react-rnd"; import { WcxNode } from "types"; import { CanvasState, Layer } from "types/rnd"; +import FlowNodeWrapper from "./FlowNodeWrapper"; interface WrapperProps { children: React.ReactNode; @@ -32,6 +33,23 @@ export default function EditorNodeWrapper({ const isStackItem = parentNode?.type === "Stack"; const hasRelativePosition = node.style.position === "relative"; + // Stack 내부 flow 아이템은 FlowNodeWrapper로 렌더링 + const isFlowItem = isStackItem && hasRelativePosition; + if (isFlowItem) { + return ( + + {children} + + ); + } + const isSwitchItems = isStackItem && hasRelativePosition; const isSelected = selectedId === node.id; diff --git a/packages/ui/src/core/FlowNodeWrapper.tsx b/packages/ui/src/core/FlowNodeWrapper.tsx new file mode 100644 index 0000000..a68e3cd --- /dev/null +++ b/packages/ui/src/core/FlowNodeWrapper.tsx @@ -0,0 +1,72 @@ +/** + * FlowNodeWrapper — Stack 내부 flow 아이템 전용 래퍼 (Rnd 미사용) + * + * resolveSizeStyle로 CSS 크기를 제어합니다. + * fill / fit / fixed / relative 모드를 지원합니다. + */ +import { WcxNode } from "types"; +import { CanvasState, Layer } from "types/rnd"; +import resolveSizeStyle from "utils/resolveSizeStyle"; + +interface FlowWrapperProps { + children: React.ReactNode; + node: WcxNode; + parentNode: WcxNode | undefined; + selectedId: string | null; + updateNode: (id: string, updates: Partial) => void; + selectNode: (id: string) => void; + canvas: CanvasState; +} + +type StackDirection = "row" | "column"; + +export default function FlowNodeWrapper({ + children, + node, + parentNode, + selectedId, + selectNode, + canvas, +}: FlowWrapperProps) { + const { id } = node; + + // 부모 Stack의 direction 결정 + const parentDirection: StackDirection = + parentNode?.type === "Stack" + ? (parentNode.props.direction ?? "column") + : "column"; + + // widthMode/heightMode → CSS로 변환 + const sizeStyle = resolveSizeStyle(node.layout, parentDirection); + + // 빈 fit 컨테이너 placeholder: 최소 크기 확보 + const isFitWidth = node.layout.widthMode === "fit"; + const isFitHeight = node.layout.heightMode === "fit"; + const placeholderStyle: React.CSSProperties = {}; + if (isFitWidth) placeholderStyle.minWidth = 20; + if (isFitHeight) placeholderStyle.minHeight = 20; + + return ( +
+
{ + e.stopPropagation(); + selectNode(id); + }} + style={{ cursor: "pointer" }} + className="relative h-full w-full" + > + {children} +
+
+ ); +} From 1ecbaa04dca31fc9a66cc847fb3bca333025d7f2 Mon Sep 17 00:00:00 2001 From: "DESKTOP-4N4R3LH\\jqk17" Date: Sun, 22 Feb 2026 10:48:05 +0900 Subject: [PATCH 09/18] =?UTF-8?q?=E2=9C=A8=20=EC=82=AC=EC=9D=B4=EC=A7=95?= =?UTF-8?q?=20=EB=AA=A8=EB=93=9C=20UI=20=EB=B0=8F=20fit=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EC=8B=9C=20fill=20=EC=9E=90=EC=8B=9D=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EB=B3=80=ED=99=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/shared/lib/component-defaults.ts | 2 +- .../ui/sections/LayoutSection.tsx | 4 +- .../right-sidebar/ui/sections/SizeSection.tsx | 133 ++++++++++++++---- 3 files changed, 110 insertions(+), 29 deletions(-) diff --git a/apps/editor/src/shared/lib/component-defaults.ts b/apps/editor/src/shared/lib/component-defaults.ts index 1544c27..e5bbc64 100644 --- a/apps/editor/src/shared/lib/component-defaults.ts +++ b/apps/editor/src/shared/lib/component-defaults.ts @@ -143,7 +143,7 @@ export const COMPONENT_DEFAULTS: Record = { }, }, Stack: { - props: {}, + props: { direction: 'column' }, style: { ...DEFAULT_FLEX_STYLE, gap: "10px", diff --git a/apps/editor/src/widgets/right-sidebar/ui/sections/LayoutSection.tsx b/apps/editor/src/widgets/right-sidebar/ui/sections/LayoutSection.tsx index e8ddc15..0cd4abd 100644 --- a/apps/editor/src/widgets/right-sidebar/ui/sections/LayoutSection.tsx +++ b/apps/editor/src/widgets/right-sidebar/ui/sections/LayoutSection.tsx @@ -20,7 +20,7 @@ export default function LayoutSection({ node }: LayoutSectionProps) { updateNode(node.id, { style: { ...node.style, [key]: value } }); }; - const direction = (node.style.flexDirection as "row" | "column") || "row"; + const direction = ((node.props as Record).direction as "row" | "column") || "column"; const wrap = node.style.flexWrap === "wrap"; // Padding 파싱 @@ -74,7 +74,7 @@ export default function LayoutSection({ node }: LayoutSectionProps) { handleStyleChange("flexDirection", v)} + onChange={(v) => updateNode(node.id, { props: { ...node.props, direction: v } })} /> diff --git a/apps/editor/src/widgets/right-sidebar/ui/sections/SizeSection.tsx b/apps/editor/src/widgets/right-sidebar/ui/sections/SizeSection.tsx index fa3286c..5ce18ab 100644 --- a/apps/editor/src/widgets/right-sidebar/ui/sections/SizeSection.tsx +++ b/apps/editor/src/widgets/right-sidebar/ui/sections/SizeSection.tsx @@ -1,7 +1,7 @@ "use client"; import { WcxNode } from "@repo/ui/types/nodes"; -import { useUpdateNode, useUpdateNodeLayout, useCurNodes } from "@/stores/useEditorStore"; +import { useUpdateNode, useUpdateNodeLayout, useCurNodes, useCanvas } from "@/stores/useEditorStore"; import FieldRow from "../atoms/FieldRow"; import NumberInput from "../atoms/NumberInput"; import SelectInput from "../atoms/SelectInput"; @@ -12,13 +12,45 @@ interface SizeSectionProps { type SizingMode = "fixed" | "fill" | "fit" | "relative"; +/** + * DOM에서 노드의 실제 렌더링 크기를 읽어옵니다 (canvas scale 보정 포함). + */ +function getComputedNodeSize(nodeId: string, scale: number) { + const el = document.querySelector(`[data-component-id="${nodeId}"]`); + if (!el) return null; + const rect = el.getBoundingClientRect(); + return { width: rect.width / scale, height: rect.height / scale }; +} + +/** + * 부모의 content-box 크기를 계산합니다 (padding 제외). + */ +function getParentContentSize(parentId: string | null, scale: number) { + if (!parentId) return null; + const el = document.querySelector(`[data-component-id="${parentId}"]`); + if (!el) return null; + const computed = getComputedStyle(el); + const rect = el.getBoundingClientRect(); + return { + width: + rect.width / scale - + parseFloat(computed.paddingLeft) - + parseFloat(computed.paddingRight), + height: + rect.height / scale - + parseFloat(computed.paddingTop) - + parseFloat(computed.paddingBottom), + }; +} + export default function SizeSection({ node }: SizeSectionProps) { const updateLayout = useUpdateNodeLayout(); const updateNode = useUpdateNode(); const nodes = useCurNodes(); + const canvas = useCanvas(); - const widthMode: SizingMode = (node.layout as { widthMode: SizingMode }).widthMode || "fixed"; - const heightMode: SizingMode = (node.layout as { heightMode: SizingMode }).heightMode || "fixed"; + const widthMode: SizingMode = node.layout.widthMode || "fixed"; + const heightMode: SizingMode = node.layout.heightMode || "fixed"; const widthValue = typeof node.layout.width === "number" ? node.layout.width @@ -28,31 +60,51 @@ export default function SizeSection({ node }: SizeSectionProps) { ? node.layout.height : parseInt(String(node.layout.height)) || 0; + // 부모 노드 정보 (금지 조합 판단용) + const parentNode = nodes?.find(n => n.id === node.parent_id); + const isInStack = parentNode?.type === "Stack"; + const parentWidthMode = parentNode?.layout.widthMode || "fixed"; + const parentHeightMode = parentNode?.layout.heightMode || "fixed"; + const handleStyleChange = (key: string, value: string | number) => { updateNode(node.id, { style: { ...node.style, [key]: value } }); }; - const convertValue = (oldValue: number, oldMode: SizingMode, newMode: SizingMode, isHeight: boolean) => { + /** + * 모드 전환 시 값 변환. + * DOM에서 현재 노드의 실제 렌더링 크기를 읽어와서 정확한 변환을 수행합니다. + */ + const convertValue = ( + oldValue: number, + oldMode: SizingMode, + newMode: SizingMode, + isHeight: boolean, + ) => { if (oldMode === newMode) return oldValue; - if (newMode === 'fill') return 1; - if (newMode === 'fit') return oldValue; - - // Fixed -> Relative - if (oldMode === 'fixed' && newMode === 'relative') { - const parent = nodes?.find(n => n.id === node.parent_id); - const parentSize = isHeight - ? (typeof parent?.layout.height === 'number' ? parent.layout.height : 1000) - : (typeof parent?.layout.width === 'number' ? parent.layout.width : 1000); - return Math.round((oldValue / parentSize) * 100); + if (newMode === "fill") return 1; + if (newMode === "fit") return oldValue; + + const scale = canvas.scale || 1; + + // → fixed로 전환: DOM에서 현재 렌더링 크기 읽기 + if (newMode === "fixed") { + const size = getComputedNodeSize(node.id, scale); + if (size) { + return Math.round(isHeight ? size.height : size.width); + } + return oldValue; // DOM 접근 실패 시 폴백 } - // Relative -> Fixed - if (oldMode === 'relative' && newMode === 'fixed') { - const parent = nodes?.find(n => n.id === node.parent_id); - const parentSize = isHeight - ? (typeof parent?.layout.height === 'number' ? parent.layout.height : 1000) - : (typeof parent?.layout.width === 'number' ? parent.layout.width : 1000); - return Math.round((oldValue / 100) * parentSize); + // → relative로 전환: DOM에서 현재 크기 + 부모 content-box 기준 계산 + if (newMode === "relative") { + const size = getComputedNodeSize(node.id, scale); + const parentContent = getParentContentSize(node.parent_id, scale); + if (size && parentContent) { + const childPx = isHeight ? size.height : size.width; + const parentPx = isHeight ? parentContent.height : parentContent.width; + return parentPx > 0 ? Math.round((childPx / parentPx) * 100) : 100; + } + return oldValue; // 폴백 } return oldValue; @@ -67,12 +119,40 @@ export default function SizeSection({ node }: SizeSectionProps) { const newValue = convertValue(oldValue, oldMode, newMode, isHeight); // eslint-disable-next-line @typescript-eslint/no-explicit-any updateLayout(node.id, { [key]: newMode, [sizeKey]: newValue } as any); + + // fit으로 전환 시: fill인 자식들을 자동으로 fixed로 변환 (현재 렌더링 크기 스냅) + if (newMode === "fit" && nodes) { + const scale = canvas.scale || 1; + const children = nodes.filter((n) => n.parent_id === node.id); + + children.forEach((child) => { + const childMode = isHeight ? child.layout.heightMode : child.layout.widthMode; + if (childMode !== "fill") return; + + // DOM에서 현재 렌더링 크기 스냅 + const size = getComputedNodeSize(child.id, scale); + const snappedValue = size + ? Math.round(isHeight ? size.height : size.width) + : (typeof child.layout[sizeKey] === "number" ? child.layout[sizeKey] : 100); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + updateLayout(child.id, { [key]: "fixed", [sizeKey]: snappedValue } as any); + }); + } }; - const modeOptions = [ + // 금지 조합: fill/relative는 부모가 fit이거나 Stack이 아닌 경우 비활성 + const widthModeOptions = [ + { label: "Fixed", value: "fixed" }, + { label: "Rel", value: "relative", disabled: parentWidthMode === "fit" }, + { label: "Fill", value: "fill", disabled: parentWidthMode === "fit" || !isInStack }, + { label: "Fit", value: "fit" }, + ]; + + const heightModeOptions = [ { label: "Fixed", value: "fixed" }, - { label: "Rel", value: "relative" }, - { label: "Fill", value: "fill" }, + { label: "Rel", value: "relative", disabled: parentHeightMode === "fit" }, + { label: "Fill", value: "fill", disabled: parentHeightMode === "fit" || !isInStack }, { label: "Fit", value: "fit" }, ]; @@ -89,7 +169,7 @@ export default function SizeSection({ node }: SizeSectionProps) { /> handleModeChange("widthMode", v as SizingMode)} size="small" /> @@ -106,7 +186,7 @@ export default function SizeSection({ node }: SizeSectionProps) { /> handleModeChange("heightMode", v as SizingMode)} size="small" /> @@ -146,3 +226,4 @@ export default function SizeSection({ node }: SizeSectionProps) { ); } + From f0852460ba2700a7ea27b8225ed74b809677be70 Mon Sep 17 00:00:00 2001 From: "DESKTOP-4N4R3LH\\jqk17" Date: Sun, 22 Feb 2026 10:48:29 +0900 Subject: [PATCH 10/18] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Canvas=20DragOverlay?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/editor/db.json | 415 +++++++++++++------ apps/editor/src/components/editor/Canvas.tsx | 7 +- 2 files changed, 298 insertions(+), 124 deletions(-) diff --git a/apps/editor/db.json b/apps/editor/db.json index efb6540..fa76399 100644 --- a/apps/editor/db.json +++ b/apps/editor/db.json @@ -1,218 +1,393 @@ { "nodes": [ { - "id": "1002", + "id": "page", "page_id": 201, "parent_id": null, "type": "Stack", - "position": 1, - "layout": { "x": 0, "y": 0, "width": 1440, "height": 800, "zIndex": 1 }, - "props": {}, + "position": 0, + "layout": { + "x": 100, + "y": 100, + "width": 800, + "height": 600, + "widthMode": "fixed", + "heightMode": "fixed", + "zIndex": 1 + }, + "props": { + "direction": "column" + }, "style": { + "position": "absolute", "display": "flex", "flexDirection": "column", - "alignItems": "center", - "justifyContent": "center", - "gap": "20px", - "padding": "50px 24px", - "maxWidth": "1200px", - "backgroundColor": "#FFFFFF", - "className": "content-section" + "gap": "16px", + "padding": "24px", + "backgroundColor": "#f3f4f6" }, - "created_at": "2025-11-13T06:20:00Z" + "created_at": "2025-01-01T00:00:00Z" }, { - "id": "1003", + "id": "child-fixed", "page_id": 201, - "parent_id": "1002", - "type": "Heading", + "parent_id": "page", + "type": "Stack", "position": 0, "layout": { "x": 0, "y": 0, - "width": 1200, - "height": 100, + "width": 200, + "height": 80, + "widthMode": "fixed", + "heightMode": "fixed", "zIndex": 2 }, "props": { - "text": "주요 기능 소개", - "level": "h2" + "direction": "column" }, "style": { "position": "relative", - "color": "#111827", - "fontSize": "36px", - "textAlign": "center", - "marginBottom": "30px" + "display": "flex", + "backgroundColor": "#3b82f6", + "borderRadius": "8px" }, - "created_at": "2025-11-13T06:21:00Z" + "created_at": "2025-01-01T00:01:00Z" }, { - "id": "1004", + "id": "label-fixed", "page_id": 201, - "parent_id": "1002", + "parent_id": "child-fixed", "type": "Text", + "position": 0, + "layout": { + "x": 0, + "y": 0, + "width": 200, + "height": 40, + "widthMode": "fixed", + "heightMode": "fixed", + "zIndex": 3 + }, + "props": { + "text": "Fixed 200×80", + "level": "p" + }, + "style": { + "position": "relative", + "color": "#ffffff", + "fontSize": "14px", + "fontWeight": "600", + "padding": "8px 16px" + }, + "created_at": "2025-01-01T00:01:30Z" + }, + { + "id": "child-fill", + "page_id": 201, + "parent_id": "page", + "type": "Stack", "position": 1, "layout": { "x": 0, "y": 0, - "width": 800, - "height": 200, + "width": 1, + "height": 80, + "widthMode": "fill", + "heightMode": "fixed", "zIndex": 2 }, "props": { - "text": "직관적인 드래그 앤 드롭 인터페이스로 코딩 없이 웹사이트를 완성하세요. 모든 컴포넌트는 사용자가 원하는 대로 커스터마이징할 수 있습니다.", + "direction": "column" + }, + "style": { + "position": "relative", + "display": "flex", + "backgroundColor": "#10b981", + "borderRadius": "8px" + }, + "created_at": "2025-01-01T00:02:00Z" + }, + { + "id": "label-fill", + "page_id": 201, + "parent_id": "child-fill", + "type": "Text", + "position": 0, + "layout": { + "x": 0, + "y": 0, + "width": 200, + "height": 40, + "widthMode": "fixed", + "heightMode": "fixed", + "zIndex": 3 + }, + "props": { + "text": "Fill (가로 꽉 참)", "level": "p" }, "style": { "position": "relative", - "color": "#374151", - "fontSize": "18px", - "lineHeight": 1.6, - "textAlign": "center" + "color": "#ffffff", + "fontSize": "14px", + "fontWeight": "600", + "padding": "8px 16px" }, - "created_at": "2025-11-13T06:22:00Z" + "created_at": "2025-01-01T00:02:30Z" }, { - "id": "1005", + "id": "child-fit", "page_id": 201, - "parent_id": null, + "parent_id": "page", "type": "Stack", "position": 2, "layout": { "x": 0, - "y": 800, - "width": 1440, - "height": 600, - "zIndex": 1 + "y": 0, + "width": 300, + "height": 300, + "widthMode": "fit", + "heightMode": "fit", + "zIndex": 2 + }, + "props": { + "direction": "column" }, - "props": { "id": "gallery" }, "style": { + "position": "relative", "display": "flex", - "flexDirection": "row", - "flexWrap": "wrap", - "justifyContent": "center", - "gap": "20px", - "padding": "50px 24px", - "maxWidth": "1200px", - "backgroundColor": "#F9FAFB", - "className": "gallery-section" + "backgroundColor": "#f59e0b", + "borderRadius": "8px", + "padding": "12px" }, - "created_at": "2025-11-13T06:23:00Z" + "created_at": "2025-01-01T00:03:00Z" }, { - "id": "1006", + "id": "label-fit", "page_id": 201, - "parent_id": "1005", - "type": "Image", + "parent_id": "child-fit", + "type": "Text", "position": 0, - "layout": { "x": 0, "y": 0, "width": 600, "height": 300, "zIndex": 2 }, + "layout": { + "x": 0, + "y": 0, + "width": 200, + "height": 40, + "widthMode": "fixed", + "heightMode": "fixed", + "zIndex": 3 + }, "props": { - "src": "data:image/jpeg;base64,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", - "alt": "갤러리 이미지 1" + "text": "Fit (콘텐츠에 맞춤)", + "level": "p" }, "style": { "position": "relative", - "width": "100%", - "height": "300px", - "objectFit": "cover", - "borderRadius": "8px" + "color": "#ffffff", + "fontSize": "14px", + "fontWeight": "600", + "padding": "8px 16px" }, - "created_at": "2025-11-13T06:24:00Z" + "created_at": "2025-01-01T00:03:30Z" }, { - "id": "1007", + "id": "row-stack", "page_id": 201, - "parent_id": "1005", - "type": "Image", - "position": 1, - "layout": { "x": 700, "y": 50, "width": 600, "height": 300, "zIndex": 2 }, + "parent_id": "page", + "type": "Stack", + "position": 3, + "layout": { + "x": 0, + "y": 0, + "width": 1, + "height": 120, + "widthMode": "fill", + "heightMode": "fixed", + "zIndex": 2 + }, "props": { - "src": "https://image.utoimage.com/preview/cp872722/2022/12/202212008462_500.jpg", - "alt": "갤러리 이미지 2" + "direction": "row" }, "style": { - "position": "absolute", - "top": "50px", - "left": "700px", - "width": "600px", - "height": "300px", - "objectFit": "cover", + "position": "relative", + "display": "flex", + "flexDirection": "row", + "gap": "12px", + "backgroundColor": "#e5e7eb", "borderRadius": "8px", - "zIndex": 10 + "padding": "12px" }, - "created_at": "2025-11-13T06:25:00Z" + "created_at": "2025-01-01T00:04:00Z" }, { - "id": "1008", + "id": "row-a", "page_id": 201, - "parent_id": "1005", + "parent_id": "row-stack", + "type": "Stack", + "position": 0, + "layout": { + "x": 0, + "y": 0, + "width": 100, + "height": 1, + "widthMode": "fixed", + "heightMode": "fill", + "zIndex": 3 + }, + "props": { + "direction": "column" + }, + "style": { + "position": "relative", + "display": "flex", + "alignItems": "center", + "justifyContent": "center", + "backgroundColor": "#8b5cf6", + "borderRadius": "6px" + }, + "created_at": "2025-01-01T00:05:00Z" + }, + { + "id": "row-a-label", + "page_id": 201, + "parent_id": "row-a", "type": "Text", - "position": 2, - "layout": { "x": 0, "y": 0, "width": 600, "height": 50, "zIndex": 2 }, + "position": 0, + "layout": { + "x": 0, + "y": 0, + "width": 80, + "height": 30, + "widthMode": "fixed", + "heightMode": "fixed", + "zIndex": 4 + }, "props": { - "text": "이미지 설명 1", + "text": "Fixed W", "level": "p" }, "style": { "position": "relative", - "textAlign": "center", - "fontSize": "14px", - "color": "#6B7280", - "marginTop": "10px" + "color": "#ffffff", + "fontSize": "12px", + "fontWeight": "600", + "textAlign": "center" + }, + "created_at": "2025-01-01T00:05:30Z" + }, + { + "id": "row-b", + "page_id": 201, + "parent_id": "row-stack", + "type": "Stack", + "position": 1, + "layout": { + "x": 0, + "y": 0, + "width": 1, + "height": 1, + "widthMode": "fill", + "heightMode": "fill", + "zIndex": 3 + }, + "props": { + "direction": "column" + }, + "style": { + "position": "relative", + "display": "flex", + "alignItems": "center", + "justifyContent": "center", + "backgroundColor": "#ec4899", + "borderRadius": "6px" }, - "created_at": "2025-11-13T06:26:00Z" + "created_at": "2025-01-01T00:06:00Z" }, { - "id": "1009", + "id": "row-b-label", "page_id": 201, - "parent_id": "1005", + "parent_id": "row-b", "type": "Text", - "position": 3, - "layout": { "x": 0, "y": 0, "width": 600, "height": 50, "zIndex": 2 }, + "position": 0, + "layout": { + "x": 0, + "y": 0, + "width": 80, + "height": 30, + "widthMode": "fixed", + "heightMode": "fixed", + "zIndex": 4 + }, "props": { - "text": "이미지 설명 2", + "text": "Fill Both", "level": "p" }, "style": { "position": "relative", - "textAlign": "center", - "fontSize": "14px", - "color": "#6B7280", - "marginTop": "10px" + "color": "#ffffff", + "fontSize": "12px", + "fontWeight": "600", + "textAlign": "center" }, - "created_at": "2025-11-13T06:27:00Z" + "created_at": "2025-01-01T00:06:30Z" }, { - "id": "1010", + "id": "row-c", "page_id": 201, - "parent_id": null, - "type": "Button", - "position": 3, + "parent_id": "row-stack", + "type": "Stack", + "position": 2, "layout": { - "x": 600, - "y": 1500, - "width": 200, - "height": 60, - "zIndex": 1 + "x": 0, + "y": 0, + "width": 150, + "height": 1, + "widthMode": "fit", + "heightMode": "fill", + "zIndex": 3 }, "props": { - "text": "더 알아보기", - "action": { "type": "navigate", "url": "/features" } + "direction": "column" }, "style": { - "display": "block", - "padding": "15px 30px", - "width": "200px", - "backgroundColor": "#3B82F6", - "color": "#FFFFFF", - "fontSize": "16px", - "fontWeight": "bold", - "textAlign": "center", - "borderRadius": "5px", - "cursor": "pointer" - }, - "created_at": "2025-11-13T06:28:00Z" + "position": "relative", + "display": "flex", + "alignItems": "center", + "justifyContent": "center", + "backgroundColor": "#06b6d4", + "borderRadius": "6px", + "padding": "8px" + }, + "created_at": "2025-01-01T00:07:00Z" + }, + { + "id": "row-c-label", + "page_id": 201, + "parent_id": "row-c", + "type": "Text", + "position": 0, + "layout": { + "x": 0, + "y": 0, + "width": 80, + "height": 30, + "widthMode": "fixed", + "heightMode": "fixed", + "zIndex": 4 + }, + "props": { + "text": "Fit W, Fill H", + "level": "p" + }, + "style": { + "position": "relative", + "color": "#ffffff", + "fontSize": "12px", + "fontWeight": "600", + "textAlign": "center" + }, + "created_at": "2025-01-01T00:07:30Z" } ] -} +} \ No newline at end of file diff --git a/apps/editor/src/components/editor/Canvas.tsx b/apps/editor/src/components/editor/Canvas.tsx index f461692..be9bdb6 100644 --- a/apps/editor/src/components/editor/Canvas.tsx +++ b/apps/editor/src/components/editor/Canvas.tsx @@ -55,9 +55,9 @@ export default function Canvas() { */ function renderTree(parentNode: WcxNode | { id: null }) { //parentNode의 자식 찾기 - const childrenObjArr = nodes?.filter( - ({ parent_id }) => parent_id === parentNode.id, - ); + const childrenObjArr = nodes + ?.filter(({ parent_id }) => parent_id === parentNode.id) + .sort((a, b) => a.position - b.position); //BaseCondition //FIXME-솔직히 !childrenArr만 있어도 될듯? 길이가 0일 수가 없다. @@ -79,7 +79,6 @@ export default function Canvas() { } // 1. 자식들의 렌더링 결과물 (JSX 배열) - //현재 parentNode에 대해 NodeRenderer를 사용하려면 children이 필요한데, 재귀로 구해준다. const children = childrenObjArr.map((node) => { return {renderTree(node)}; }); From 6157322ca37aa5e675bc0e75f1db1de4e3a6b342 Mon Sep 17 00:00:00 2001 From: "DESKTOP-4N4R3LH\\jqk17" Date: Sun, 22 Feb 2026 10:49:15 +0900 Subject: [PATCH 11/18] =?UTF-8?q?=E2=9C=A8=20SelectInput=20disabled=20?= =?UTF-8?q?=EC=98=B5=EC=85=98=20=EC=A7=80=EC=9B=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/src/widgets/right-sidebar/ui/atoms/SelectInput.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/editor/src/widgets/right-sidebar/ui/atoms/SelectInput.tsx b/apps/editor/src/widgets/right-sidebar/ui/atoms/SelectInput.tsx index fcf3622..5f9ce8a 100644 --- a/apps/editor/src/widgets/right-sidebar/ui/atoms/SelectInput.tsx +++ b/apps/editor/src/widgets/right-sidebar/ui/atoms/SelectInput.tsx @@ -4,7 +4,7 @@ interface SelectInputProps { id?: string; name?: string; value: string; - options: { label: string; value: string }[]; + options: { label: string; value: string; disabled?: boolean }[]; onChange: (val: string) => void; size?: "single" | "small"; } @@ -29,7 +29,7 @@ export default function SelectInput({ className="flex w-full h-[28px] appearance-none items-center px-2 py-0 bg-transparent border-transparent font-inter font-normal text-[13px] leading-none text-zinc-900 outline-none focus:border-zinc-400 transition-colors cursor-pointer" > {options.map((opt) => ( - ))} From 4bf45d76f7399036c8929d2827e91135949ecc85 Mon Sep 17 00:00:00 2001 From: "DESKTOP-4N4R3LH\\jqk17" Date: Sun, 22 Feb 2026 14:18:16 +0900 Subject: [PATCH 12/18] =?UTF-8?q?:bug:=20fix:=20=ED=8C=A8=EB=94=A9=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EC=8B=9C=20undefined=EA=B0=80=20=EA=B0=9D?= =?UTF-8?q?=EC=B2=B4=EC=97=90=20=EB=93=A4=EC=96=B4=EA=B0=80=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8F=84=EB=A1=9D=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ui/src/utils/processNodeStyles.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/utils/processNodeStyles.ts b/packages/ui/src/utils/processNodeStyles.ts index 4451b60..842254e 100644 --- a/packages/ui/src/utils/processNodeStyles.ts +++ b/packages/ui/src/utils/processNodeStyles.ts @@ -30,10 +30,21 @@ export default function processNodeStyles(style: NodeStyle): CSSProperties { // 레이아웃 속성 필터링 const result: Record = {}; for (const [key, value] of Object.entries(cssProps)) { - if (!LAYOUT_PROPERTIES.has(key)) { + if (!LAYOUT_PROPERTIES.has(key) && value !== undefined) { result[key] = value; } } + // shorthand(padding)와 longhand(paddingTop 등)가 동시에 존재하면 + // React가 경고를 띄우므로, longhand가 있으면 shorthand를 제거 + const hasLonghandPadding = + "paddingTop" in result || + "paddingRight" in result || + "paddingBottom" in result || + "paddingLeft" in result; + if (hasLonghandPadding && "padding" in result) { + delete result.padding; + } + return result as CSSProperties; } From 6658509bc27ba8b6efa83da8c89c1c36fe08b14a Mon Sep 17 00:00:00 2001 From: "DESKTOP-4N4R3LH\\jqk17" Date: Sun, 22 Feb 2026 14:33:51 +0900 Subject: [PATCH 13/18] =?UTF-8?q?:bug:=20fix:=20=EC=97=90=EB=94=94?= =?UTF-8?q?=ED=84=B0=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20overflow=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=82=AC=EC=9D=B4=EB=93=9C?= =?UTF-8?q?=EB=B0=94=20=EC=8A=A4=ED=81=AC=EB=A1=A4=EB=B0=94=20=EC=88=A8?= =?UTF-8?q?=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - processNodeStyles에서 padding shorthand/longhand 동시 사용 방지 - 에디터 페이지 최상위 컨테이너에 overflow-hidden 적용 - 좌우 사이드바 스크롤바 숨김 (scrollbarWidth: none) --- apps/editor/src/app/editor/page.tsx | 2 +- apps/editor/src/widgets/left-sidebar/ui/SubPanel.tsx | 5 ++++- apps/editor/src/widgets/right-sidebar/ui/RightSidebar.tsx | 5 ++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/editor/src/app/editor/page.tsx b/apps/editor/src/app/editor/page.tsx index e48fc65..02e34ef 100644 --- a/apps/editor/src/app/editor/page.tsx +++ b/apps/editor/src/app/editor/page.tsx @@ -11,7 +11,7 @@ export default async function EditorPage() { const nodes = await getNodesFromDB(pageId); return ( -
+

에디터 페이지 입니다.

{/* 다른 에디터 관련 컴포넌트들은 이곳에서 렌더링 됩니다!(사이드바,매니패스트 수정 컴포넌트 등등...) */} diff --git a/apps/editor/src/widgets/left-sidebar/ui/SubPanel.tsx b/apps/editor/src/widgets/left-sidebar/ui/SubPanel.tsx index 03b4e80..199c27f 100644 --- a/apps/editor/src/widgets/left-sidebar/ui/SubPanel.tsx +++ b/apps/editor/src/widgets/left-sidebar/ui/SubPanel.tsx @@ -26,7 +26,10 @@ export const SubPanel = () => { }; return ( -