diff --git a/.gitignore b/.gitignore
index b185941..7196e88 100644
--- a/.gitignore
+++ b/.gitignore
@@ -48,4 +48,5 @@ apps/*/out
apps/*/.next
apps/*/dist
-docs
\ No newline at end of file
+docs
+.agent
\ No newline at end of file
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/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/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)};
});
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/stores/useEditorStore.ts b/apps/editor/src/stores/useEditorStore.ts
index fac6140..1140941 100644
--- a/apps/editor/src/stores/useEditorStore.ts
+++ b/apps/editor/src/stores/useEditorStore.ts
@@ -4,6 +4,39 @@ import { create } from "zustand";
import { combine, devtools } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
+// ── 인접 리스트 기반 노드 인덱스 구축 함수 ──
+function buildNodeMaps(nodes: WcxNode[] | null) {
+ const nodeMap: Record = {};
+ const childrenMap: Record = {};
+
+ if (!nodes) return { nodeMap, childrenMap };
+
+ // 1패스: 모든 노드를 순회하며 두 인덱스를 동시에 구축
+ for (const node of nodes) {
+ nodeMap[node.id] = node;
+
+ const parentKey = node.parent_id ?? "__root__";
+ if (!childrenMap[parentKey]) {
+ childrenMap[parentKey] = [];
+ }
+ childrenMap[parentKey].push(node);
+ }
+
+ // 2패스: 각 자식 배열을 position 기준 정렬
+ for (const key in childrenMap) {
+ childrenMap[key].sort((a, b) => a.position - b.position);
+ }
+
+ return { nodeMap, childrenMap };
+}
+
+// immer draft state에 인덱스를 적용하는 헬퍼
+function rebuildIndex(state: { nodes: WcxNode[] | null; nodeMap: Record; childrenMap: Record }) {
+ const maps = buildNodeMaps(state.nodes);
+ state.nodeMap = maps.nodeMap;
+ state.childrenMap = maps.childrenMap;
+}
+
const useEditorStore = create(
devtools(
immer(
@@ -12,28 +45,56 @@ const useEditorStore = create(
nodes: null as null | WcxNode[], //TODO- 추후에 현재 페이지에 해당하는 노드들을 받아오는 로직을 통해 해당 상태가 업데이트 되야 한다. -> EditorStoreInitializer컴포넌트에서 담당
canvas: { dx: 0, dy: 0, scale: 1 },
selectedDepthPath: [] as string[],
+ // ── 노드 인덱스 (인접 리스트) ──
+ nodeMap: {} as Record,
+ childrenMap: {} as Record,
},
(set, get) => ({
setNode(nodes: WcxNode[]) {
set((state) => {
state.nodes = nodes;
+ rebuildIndex(state);
});
},
//TODO-노드를 추가/삭제 하는 기능 필요(에디터 섹션에서 노드 추가, 삭제하는 경우 ) -> addNode & deleteNode(자식 노드까지 재귀적으로 삭제 필요!)
addNode(node: WcxNode) {
set((state) => {
state.nodes?.push(node);
+ rebuildIndex(state);
});
},
+ // 자식 노드까지 재귀적으로 삭제 (childrenMap 활용)
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;
+
+ // childrenMap을 활용한 후손 ID 수집 (O(1) 자식 조회)
+ const idsToDelete = new Set();
+ function collect(id: string) {
+ idsToDelete.add(id);
+ const children = state.childrenMap[id];
+ if (children) {
+ children.forEach((child) => collect(child.id));
+ }
+ }
+ collect(nodeId);
+
+ // 일괄 삭제
+ state.nodes = state.nodes.filter(
+ (n) => !idsToDelete.has(n.id),
+ );
+
+ // 삭제된 노드가 선택 경로에 있으면 선택 해제
+ if (state.selectedDepthPath.includes(nodeId)) {
+ state.selectedDepthPath = [];
+ }
+
+ rebuildIndex(state);
+ },
+ false,
+ "editStore/deleteNode",
+ );
},
getDescendantIds(nodeId: string): string[] {
const nodes = get().nodes;
@@ -151,7 +212,7 @@ const useEditorStore = create(
},
//TODO-'Node참조값 전달' vs nodeId 전달후 스코프 안에서 파싱 고민해보기
- addItemToStack: (nodeId: string, stackId: string) =>
+ addItemToStack(nodeId: string, stackId: string) {
set(
(state) => {
if (!state.nodes) return state;
@@ -173,10 +234,13 @@ const useEditorStore = create(
node.position = insertIndex;
node.parent_id = stackId;
node.style.position = "relative";
+
+ rebuildIndex(state);
},
false,
"editStore/addItemToStack",
- ),
+ );
+ },
}),
),
),
@@ -274,13 +338,22 @@ export const useAddItemToStack = () =>
/**
* [Selector] ID를 기준으로 특정 노드 객체를 반환합니다.
- * 해당 ID의 노드가 업데이트되면 이를 사용하는 컴포넌트만 리렌더링됩니다.
+ * nodeMap을 활용한 O(1) 조회.
* @param nodeId - 찾고자 하는 노드의 ID
*/
export const useGetNodeById = (nodeId: string) => {
- return useEditorStore((store) =>
- store.nodes?.find(({ id }) => id === nodeId),
- );
+ return useEditorStore((store) => store.nodeMap[nodeId]);
};
+/**
+ * [Selector] 노드 인덱스 맵을 반환합니다. (id → 노드 O(1) 조회)
+ */
+export const useNodeMap = () => useEditorStore((store) => store.nodeMap);
+
+/**
+ * [Selector] 자식 인덱스 맵을 반환합니다. (parentId → 정렬된 자식 배열 O(1) 조회)
+ * 루트 노드는 key "__root__"로 접근합니다.
+ */
+export const useChildrenMap = () => useEditorStore((store) => store.childrenMap);
+
//노드 순서 바꾸는 훅 고민하기, 트리에서도 노드의 순서 바꿀 수 있도록 고려하기.
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] ?? [];
+}
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,
// 이미 열려있는 탭을 다시 누르면 닫거나, 다른 탭을 누르면 패널 유지
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 }) => (
+
+ ))}
+
+ {/* 구분선 */}
+
+ >
+ )}
+
+ {/* 삭제 */}
+
+
+ );
+}
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 (
-