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