diff --git a/apps/editor/src/app/editor/page.tsx b/apps/editor/src/app/editor/page.tsx index 389f4b4..e48fc65 100644 --- a/apps/editor/src/app/editor/page.tsx +++ b/apps/editor/src/app/editor/page.tsx @@ -2,6 +2,7 @@ import getNodesFromDB from "@/actions/editor/getNodesFromDB"; import Canvas from "@/components/editor/Canvas"; import EditorStoreInitializer from "@/components/editor/EditorStoreInitializer"; import { LeftSidebar } from "@/widgets/left-sidebar"; +import { RightSidebar } from "@/widgets/right-sidebar"; import { RuntimeProvider } from "@repo/ui/context/runtimeContext"; export default async function EditorPage() { @@ -18,6 +19,7 @@ export default async function EditorPage() {
+
diff --git a/apps/editor/src/entities/section/model/store.ts b/apps/editor/src/entities/section/model/store.ts index b2045cb..cd03dc9 100644 --- a/apps/editor/src/entities/section/model/store.ts +++ b/apps/editor/src/entities/section/model/store.ts @@ -10,7 +10,7 @@ interface SectionState { selectSection: (id: string) => void; } -export const useSectionStore = create((set) => ({ +export const useSectionStore = create(() => ({ sections: [ { id: 's1', name: '히어로 섹션' }, { id: 's2', name: '특징 소개' }, diff --git a/apps/editor/src/shared/lib/component-defaults.ts b/apps/editor/src/shared/lib/component-defaults.ts index a2bce1f..1544c27 100644 --- a/apps/editor/src/shared/lib/component-defaults.ts +++ b/apps/editor/src/shared/lib/component-defaults.ts @@ -9,6 +9,21 @@ export interface ComponentDefaults { layout: WcxNode['layout']; // x, y, width, height, zIndex } +// 모든 컴포넌트의 기본 레이아웃 스타일 (Flex Row 중앙 정렬) +const DEFAULT_FLEX_STYLE: NodeStyle = { + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', +}; + +const DEFAULT_LAYOUT_MODE = { + widthMode: 'fixed' as const, + heightMode: 'fixed' as const, + widthUnit: 'px' as const, + heightUnit: 'px' as const, +}; + export const COMPONENT_DEFAULTS: Record = { Image: { props: { @@ -16,13 +31,16 @@ export const COMPONENT_DEFAULTS: Record = { alt: "Image", caption: "Image Caption", }, - style: {}, + style: { + ...DEFAULT_FLEX_STYLE, + }, layout: { x: 0, y: 0, width: 400, height: 300, zIndex: 0, + ...DEFAULT_LAYOUT_MODE, }, }, Heading: { @@ -31,9 +49,10 @@ export const COMPONENT_DEFAULTS: Record = { level: "h2", }, style: { + ...DEFAULT_FLEX_STYLE, color: "#000000", fontSize: "24px", - fontWeight: "bold", + fontWeight: "700", }, layout: { x: 0, @@ -41,6 +60,7 @@ export const COMPONENT_DEFAULTS: Record = { width: 200, height: 50, zIndex: 1, + ...DEFAULT_LAYOUT_MODE, }, }, Text: { @@ -49,8 +69,11 @@ export const COMPONENT_DEFAULTS: Record = { level: "h5", }, style: { + ...DEFAULT_FLEX_STYLE, color: "#333333", fontSize: "16px", + background: 'white', + border: '1px solid #ccc', }, layout: { x: 0, @@ -58,6 +81,7 @@ export const COMPONENT_DEFAULTS: Record = { width: 300, height: 100, zIndex: 1, + ...DEFAULT_LAYOUT_MODE, }, }, Button: { @@ -65,12 +89,10 @@ export const COMPONENT_DEFAULTS: Record = { text: "Button", }, style: { + ...DEFAULT_FLEX_STYLE, backgroundColor: "#007bff", color: "#ffffff", borderRadius: "4px", - display: "flex", - alignItems: "center", - justifyContent: "center", }, layout: { x: 0, @@ -78,6 +100,7 @@ export const COMPONENT_DEFAULTS: Record = { width: 120, height: 40, zIndex: 2, + ...DEFAULT_LAYOUT_MODE, }, }, Container: { @@ -85,6 +108,7 @@ export const COMPONENT_DEFAULTS: Record = { tagName: "div", }, style: { + ...DEFAULT_FLEX_STYLE, border: "1px dashed #ccc", backgroundColor: "#ffffff", }, @@ -94,6 +118,7 @@ export const COMPONENT_DEFAULTS: Record = { width: 500, height: 200, zIndex: 0, + ...DEFAULT_LAYOUT_MODE, }, }, Modal: { @@ -114,14 +139,13 @@ export const COMPONENT_DEFAULTS: Record = { width: 400, height: 300, zIndex: 100, + ...DEFAULT_LAYOUT_MODE, }, }, - // Stack 추가 Stack: { props: {}, style: { - display: "flex", - flexDirection: "column", + ...DEFAULT_FLEX_STYLE, gap: "10px", padding: "20px", backgroundColor: "#f9fafb", @@ -133,18 +157,21 @@ export const COMPONENT_DEFAULTS: Record = { width: 300, height: 300, zIndex: 0, + ...DEFAULT_LAYOUT_MODE, }, }, - // Group 추가 Group: { props: {}, - style: {}, + style: { + ...DEFAULT_FLEX_STYLE, + }, layout: { x: 0, y: 0, width: 200, height: 200, zIndex: 0, + ...DEFAULT_LAYOUT_MODE, }, }, }; diff --git a/apps/editor/src/stores/useEditorStore.ts b/apps/editor/src/stores/useEditorStore.ts index 848d58c..f034a32 100644 --- a/apps/editor/src/stores/useEditorStore.ts +++ b/apps/editor/src/stores/useEditorStore.ts @@ -128,7 +128,8 @@ const useEditorStore = create( // 1. 최상위 속성 업데이트 (type 등) // (주의: 객체 타입인 style, props, layout을 통째로 덮어쓰지 않도록 별도 처리) - const { style, props, layout, ...rest } = updates; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { style, props, layout: _layout, ...rest } = updates; Object.assign(targetNode, rest); // 2. 하위 객체 병합 업데이트 diff --git a/apps/editor/src/widgets/left-sidebar/model/constants.ts b/apps/editor/src/widgets/left-sidebar/model/constants.ts index 8bd93d4..23f83c0 100644 --- a/apps/editor/src/widgets/left-sidebar/model/constants.ts +++ b/apps/editor/src/widgets/left-sidebar/model/constants.ts @@ -3,7 +3,8 @@ import { File, PanelsTopLeft, PlusCircle, - AppWindow + AppWindow, + Plus } from 'lucide-react'; export const STATIC_PANEL_DATA = { @@ -32,7 +33,8 @@ export const STATIC_PANEL_DATA = { }; export const NAV_ITEMS = [ - { id: 'component', label: '컴포넌트', icon: Layers }, + { id: 'layer', label: '레이어', icon: Layers }, + { id: 'component', label: '컴포넌트', icon: Plus }, { id: 'page', label: '페이지', icon: File }, { id: 'section', label: '섹션', icon: PanelsTopLeft }, { id: 'widget', label: '위젯', icon: PlusCircle }, diff --git a/apps/editor/src/widgets/left-sidebar/model/types.ts b/apps/editor/src/widgets/left-sidebar/model/types.ts index 3ed797d..5b634d8 100644 --- a/apps/editor/src/widgets/left-sidebar/model/types.ts +++ b/apps/editor/src/widgets/left-sidebar/model/types.ts @@ -1,4 +1,4 @@ -export type NavTabId = 'component' | 'page' | 'section' | 'widget' | 'modal'; +export type NavTabId = 'layer' | 'component' | 'page' | 'section' | 'widget' | 'modal'; export interface NavigationState { activeTab: NavTabId | null; diff --git a/apps/editor/src/widgets/left-sidebar/ui/SubPanel.tsx b/apps/editor/src/widgets/left-sidebar/ui/SubPanel.tsx index da0521a..03b4e80 100644 --- a/apps/editor/src/widgets/left-sidebar/ui/SubPanel.tsx +++ b/apps/editor/src/widgets/left-sidebar/ui/SubPanel.tsx @@ -1,10 +1,11 @@ import { useNavigationStore } from '../model/store'; -import { - ComponentPanel, - PagePanel, - SectionPanel, - WidgetPanel, - ModalPanel +import { + ComponentPanel, + LayerPanel, + PagePanel, + SectionPanel, + WidgetPanel, + ModalPanel } from './sub-panels'; export const SubPanel = () => { @@ -15,11 +16,12 @@ export const SubPanel = () => { const renderContent = () => { switch (activeTab) { case 'component': return ; - case 'page': return ; - case 'section': return ; - case 'widget': return ; - case 'modal': return ; - default: return null; + case 'layer': return ; + case 'page': return ; + case 'section': return ; + case 'widget': return ; + case 'modal': return ; + default: return null; } }; 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 new file mode 100644 index 0000000..7aa978a --- /dev/null +++ b/apps/editor/src/widgets/left-sidebar/ui/sub-panels/LayerPanel.tsx @@ -0,0 +1,184 @@ +import { PanelBaseLayout } from './base/PanelBaseLayout'; +import { useCurNodes, useSelectNode, useSelectedNodeId } from '../../../../stores/useEditorStore'; +import { cn } from '@repo/utils'; +import { + Type, + Image as ImageIcon, + Heading1, + Square, + Box, + Layout, + Component, + ChevronRight, + ChevronDown, + Layers, + LucideIcon +} from 'lucide-react'; +import { useState } from 'react'; +import { WcxNode } from '@repo/ui/types/nodes'; + +/** + * 노드 타입별 아이콘 매핑 + */ +const NODE_TYPE_ICONS: Record = { + Text: Type, + Image: ImageIcon, + Heading: Heading1, + Button: Square, + Container: Box, + Stack: Layout, + Group: Component, + Modal: Layout, +}; + +interface LayerItemProps { + node: WcxNode; // 현재 렌더링할 노드 + nodes: WcxNode[]; // 전체 노드 배열 (자식 탐색용) + selectedId: string | null; // 현재 선택된 노드 ID + onSelect: (id: string) => void; // 노드 선택 핸들러 + depth: number; // 계층 깊이 (들여쓰기 계산용) +} + +/** + * 개별 레이어 아이템 컴포넌트 (재귀적으로 자식 노드 렌더링) + */ +const LayerItem = ({ node, nodes, selectedId, onSelect, 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); + + const hasChildren = children.length > 0; + 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; + if (props.text) return props.text as string; + if (props.alt) return props.alt as string; + } + return node.type; + }; + + return ( +
+ {/* 레이어 행 (클릭 시 선택) */} +
onSelect(node.id)} + > + {/* 접기/펴기 버튼 (자식이 있을 때만 노출) */} +
+ {hasChildren && ( + + )} +
+ + {/* 노드 타입 아이콘 */} + + + {/* 노드 이름 */} + + {getNodeName()} + +
+ + {/* 자식 노드 재귀 호출 */} + {hasChildren && isExpanded && ( +
+ {children.map((child) => ( + + ))} +
+ )} +
+ ); +}; + +/** + * 레이어 패널 메인 컴포넌트 + */ +export const LayerPanel = () => { + // 스토어에서 전체 노드와 선택 정보 가져오기 + const nodes = useCurNodes() || []; + const selectedId = useSelectedNodeId(); + const selectNode = useSelectNode(); + + // 최상위 노드(부모가 없는 노드)들만 먼저 추출 + const rootNodes = nodes + .filter((node) => node.parent_id === null) + .sort((a, b) => a.position - b.position); + + return ( + +
+ {rootNodes.length > 0 ? ( + // 최상위 노드부터 렌더링 시작 (이후 내부에서 자식들을 재귀적으로 그림) + rootNodes.map((node) => ( + + )) + ) : ( + // 노드가 없을 때의 빈 화면 +
+ +

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

+
+ )} +
+
+ ); +}; diff --git a/apps/editor/src/widgets/left-sidebar/ui/sub-panels/index.ts b/apps/editor/src/widgets/left-sidebar/ui/sub-panels/index.ts index e3d03fb..ebd9d5e 100644 --- a/apps/editor/src/widgets/left-sidebar/ui/sub-panels/index.ts +++ b/apps/editor/src/widgets/left-sidebar/ui/sub-panels/index.ts @@ -2,4 +2,5 @@ export * from './ComponentPanel'; export * from './PagePanel'; export * from './SectionPanel'; export * from './WidgetPanel'; -export * from './ModalPanel'; \ No newline at end of file +export * from './ModalPanel'; +export * from './LayerPanel'; diff --git a/apps/editor/src/widgets/right-sidebar/index.ts b/apps/editor/src/widgets/right-sidebar/index.ts new file mode 100644 index 0000000..ef87bf0 --- /dev/null +++ b/apps/editor/src/widgets/right-sidebar/index.ts @@ -0,0 +1 @@ +export { default as RightSidebar } from "./ui/RightSidebar"; diff --git a/apps/editor/src/widgets/right-sidebar/ui/RightSidebar.tsx b/apps/editor/src/widgets/right-sidebar/ui/RightSidebar.tsx new file mode 100644 index 0000000..d4840fc --- /dev/null +++ b/apps/editor/src/widgets/right-sidebar/ui/RightSidebar.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useCurNodes, useSelectedNodeId } from "@/stores/useEditorStore"; +import SettingsTitle from "./SettingsTitle"; +import TextPanel from "./panels/TextPanel"; +import ButtonPanel from "./panels/ButtonPanel"; +import ImagePanel from "./panels/ImagePanel"; +import PositionSection from "./sections/PositionSection"; +import SizeSection from "./sections/SizeSection"; +import LayoutSection from "./sections/LayoutSection"; +import PropertySection from "./sections/PropertySection"; +import { WcxNode } from "@repo/ui/types/nodes"; + +// 노드 타입별 Content 패널 매핑 +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const CONTENT_PANEL: Partial>> = { + Text: TextPanel, + Heading: TextPanel, + Button: ButtonPanel, + Image: ImagePanel, +}; + +// Layout 패널을 표시할 노드 타입 +const LAYOUT_TYPES: WcxNode["type"][] = ["Container", "Stack", "Modal", "Group"]; + +export default function RightSidebar() { + const selectedNodeId = useSelectedNodeId(); + const nodes = useCurNodes(); + + const selectedNode = nodes?.find((n) => n.id === selectedNodeId); + const ContentPanel = selectedNode ? CONTENT_PANEL[selectedNode.type] : undefined; + + return ( +
+ + + {selectedNode ? ( +
+ + + + + {/* ─── Size ─── */} + + + + + {/* ─── Layout (컨테이너 계열만) ─── */} + {LAYOUT_TYPES.includes(selectedNode.type) && ( + + + + )} + + {/* ─── Content (노드 타입 고유 속성) ─── */} + {ContentPanel && ( + + + + )} +
+ ) : ( +
+ Select a node to edit its properties +
+ )} +
+ ); +} diff --git a/apps/editor/src/widgets/right-sidebar/ui/SettingsTitle.tsx b/apps/editor/src/widgets/right-sidebar/ui/SettingsTitle.tsx new file mode 100644 index 0000000..aff264a --- /dev/null +++ b/apps/editor/src/widgets/right-sidebar/ui/SettingsTitle.tsx @@ -0,0 +1,13 @@ +interface SettingsTitleProps { + type?: string; +} + +export default function SettingsTitle({ type }: SettingsTitleProps) { + const title = type ? `${type.toUpperCase()} SETTINGS` : "NO SELECTION"; + + return ( +

+ {title} +

+ ); +} diff --git a/apps/editor/src/widgets/right-sidebar/ui/atoms/AlignPicker.tsx b/apps/editor/src/widgets/right-sidebar/ui/atoms/AlignPicker.tsx new file mode 100644 index 0000000..33866fd --- /dev/null +++ b/apps/editor/src/widgets/right-sidebar/ui/atoms/AlignPicker.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { cn } from "@repo/utils"; +import { + // 수평 정렬용 (교차축이 수평일 때 - Column 모드) + AlignStartVertical, + AlignCenterVertical, + AlignEndVertical, + // 수직 정렬용 (교차축이 수직일 때 - Row 모드) + AlignStartHorizontal, + AlignCenterHorizontal, + AlignEndHorizontal +} from "lucide-react"; + +interface AlignPickerProps { + direction: "row" | "column"; + value: string; + onChange: (val: string) => void; +} + +export default function AlignPicker({ direction, value, onChange }: AlignPickerProps) { + const isDirectionRow = direction === "row"; + + const options = isDirectionRow + ? [ + { icon: AlignStartHorizontal, value: "flex-start", label: "Top" }, + { icon: AlignCenterHorizontal, value: "center", label: "Middle" }, + { icon: AlignEndHorizontal, value: "flex-end", label: "Bottom" }, + ] + : [ + { icon: AlignStartVertical, value: "flex-start", label: "Left" }, + { icon: AlignCenterVertical, value: "center", label: "Center" }, + { icon: AlignEndVertical, value: "flex-end", label: "Right" }, + ]; + + return ( +
+ {options.map((opt) => { + const Icon = opt.icon; + const isActive = value === opt.value; + + return ( + + ); + })} +
+ ); +} diff --git a/apps/editor/src/widgets/right-sidebar/ui/atoms/ColorInput.tsx b/apps/editor/src/widgets/right-sidebar/ui/atoms/ColorInput.tsx new file mode 100644 index 0000000..2147e34 --- /dev/null +++ b/apps/editor/src/widgets/right-sidebar/ui/atoms/ColorInput.tsx @@ -0,0 +1,46 @@ +"use client"; + +interface ColorInputProps { + id?: string; + name?: string; + value: string; + onChange: (val: string) => void; +} + +export default function ColorInput({ id, name, value, onChange }: ColorInputProps) { + return ( +
+ {/* Color Swatch / Picker Trigger */} +
+ onChange(e.target.value)} + className="absolute inset-0 opacity-0 cursor-pointer w-full h-full" + /> +
+
+ + {/* Hex Text */} + # + { + const hex = e.target.value.replace(/[^0-9A-Fa-f]/g, ""); + if (hex.length <= 6) { + onChange(`#${hex}`); + } + }} + className="w-full bg-transparent font-inter font-normal text-[14px] leading-none text-zinc-900 outline-none uppercase" + maxLength={6} + /> +
+ ); +} diff --git a/apps/editor/src/widgets/right-sidebar/ui/atoms/ConstraintPicker.tsx b/apps/editor/src/widgets/right-sidebar/ui/atoms/ConstraintPicker.tsx new file mode 100644 index 0000000..b2e4647 --- /dev/null +++ b/apps/editor/src/widgets/right-sidebar/ui/atoms/ConstraintPicker.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { cn } from "@repo/utils"; + +interface ConstraintPickerProps { + top: boolean; + right: boolean; + bottom: boolean; + left: boolean; + onChange: (constraints: { top: boolean; right: boolean; bottom: boolean; left: boolean }) => void; +} + +/** + * Framer의 시각적 Position Constraint Picker + * 중앙 점 + 상/하/좌/우 연결선을 클릭하여 핀 활성화/비활성화 + */ +export default function ConstraintPicker({ + top, + right, + bottom, + left, + onChange, +}: ConstraintPickerProps) { + const toggle = (key: "top" | "right" | "bottom" | "left") => { + onChange({ top, right, bottom, left, [key]: !{ top, right, bottom, left }[key] }); + }; + + return ( +
+ {/* 중앙 점 */} +
+ + {/* Top 연결선 */} + + + {/* Bottom 연결선 */} + + + {/* Left 연결선 */} + + + {/* Right 연결선 */} + +
+ ); +} diff --git a/apps/editor/src/widgets/right-sidebar/ui/atoms/ControlRow.tsx b/apps/editor/src/widgets/right-sidebar/ui/atoms/ControlRow.tsx new file mode 100644 index 0000000..2443cc1 --- /dev/null +++ b/apps/editor/src/widgets/right-sidebar/ui/atoms/ControlRow.tsx @@ -0,0 +1,20 @@ +"use client"; + +import React from "react"; + +interface ControlRowProps { + children: React.ReactNode; +} + +/** + * 두 개 이상의 컨트롤을 한 줄에 배치하기 위한 그리드 레이아웃 헬퍼 + */ +export default function ControlRow({ children }: ControlRowProps) { + return ( +
+ {React.Children.map(children, (child) => ( +
{child}
+ ))} +
+ ); +} diff --git a/apps/editor/src/widgets/right-sidebar/ui/atoms/DirectionToggle.tsx b/apps/editor/src/widgets/right-sidebar/ui/atoms/DirectionToggle.tsx new file mode 100644 index 0000000..4adfb22 --- /dev/null +++ b/apps/editor/src/widgets/right-sidebar/ui/atoms/DirectionToggle.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { cn } from "@repo/utils"; +import { MoveHorizontal, MoveVertical } from "lucide-react"; + +interface DirectionToggleProps { + value: "row" | "column"; + onChange: (val: "row" | "column") => void; +} + +export default function DirectionToggle({ value, onChange }: DirectionToggleProps) { + return ( +
+ + +
+ ); +} diff --git a/apps/editor/src/widgets/right-sidebar/ui/atoms/FieldRow.tsx b/apps/editor/src/widgets/right-sidebar/ui/atoms/FieldRow.tsx new file mode 100644 index 0000000..fe497ce --- /dev/null +++ b/apps/editor/src/widgets/right-sidebar/ui/atoms/FieldRow.tsx @@ -0,0 +1,23 @@ +"use client"; + +interface FieldRowProps { + label: string; + children: React.ReactNode; +} + +/** + * 사이드바 필드 행 — 가로 한 줄 + * [라벨] [컨트롤] + */ +export default function FieldRow({ label, children }: FieldRowProps) { + return ( +
+ + {label} + +
+ {children} +
+
+ ); +} diff --git a/apps/editor/src/widgets/right-sidebar/ui/atoms/NumberInput.tsx b/apps/editor/src/widgets/right-sidebar/ui/atoms/NumberInput.tsx new file mode 100644 index 0000000..9d3a284 --- /dev/null +++ b/apps/editor/src/widgets/right-sidebar/ui/atoms/NumberInput.tsx @@ -0,0 +1,44 @@ +"use client"; + +interface NumberInputProps { + id?: string; + name?: string; + value: number; + onChange: (val: number) => void; + size?: "single" | "small"; + suffix?: string; + disabled?: boolean; + blurred?: boolean; +} + +export default function NumberInput({ + id, + name, + value, + onChange, + size = "single", + suffix, + disabled, + blurred, +}: NumberInputProps) { + const widthClass = size === "small" ? "w-[65px]" : "w-[140px]"; + + return ( +
+ onChange(Number(e.target.value))} + disabled={disabled || blurred} + className={`flex w-full h-[28px] items-center pl-3 ${suffix ? "pr-8" : "pr-3"} py-0 rounded-[8px] border-transparent bg-[#F4F4F5] font-inter font-normal text-[13px] leading-none text-zinc-900 outline-none focus:border-zinc-400 transition-colors [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${blurred ? "pointer-events-none" : ""}`} + /> + {suffix && ( + + {suffix} + + )} +
+ ); +} diff --git a/apps/editor/src/widgets/right-sidebar/ui/atoms/PaddingInput.tsx b/apps/editor/src/widgets/right-sidebar/ui/atoms/PaddingInput.tsx new file mode 100644 index 0000000..e14cdb3 --- /dev/null +++ b/apps/editor/src/widgets/right-sidebar/ui/atoms/PaddingInput.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useState } from "react"; +import NumberInput from "./NumberInput"; +import ToggleButtonGroup from "./ToggleButtonGroup"; + +type PaddingMode = "uniform" | "individual"; + +interface PaddingInputProps { + top: number; + right: number; + bottom: number; + left: number; + onChange: (padding: { top: number; right: number; bottom: number; left: number }) => void; +} + +export default function PaddingInput({ + top, + right, + bottom, + left, + onChange, +}: PaddingInputProps) { + const [mode, setMode] = useState( + top === right && right === bottom && bottom === left ? "uniform" : "individual" + ); + + const handleUniformChange = (value: number) => { + onChange({ top: value, right: value, bottom: value, left: value }); + }; + + const handleIndividualChange = (key: "top" | "right" | "bottom" | "left", value: number) => { + onChange({ top, right, bottom, left, [key]: value }); + }; + + return ( +
+
+ + setMode(val as PaddingMode)} + options={[ + { label: "U", value: "uniform" }, + { label: "I", value: "individual" }, + ]} + /> +
+ + {mode === "individual" && ( +
+
+ {(["top", "right", "bottom", "left"] as const).map((key) => ( +
+ handleIndividualChange(key, Number(e.target.value))} + className="w-full h-[26px] text-center bg-[#F4F4F5] rounded-[4px] text-[10px] font-medium outline-none border-none focus:ring-1 focus:ring-blue-500 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> + {key[0]} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/apps/editor/src/widgets/right-sidebar/ui/atoms/SelectInput.tsx b/apps/editor/src/widgets/right-sidebar/ui/atoms/SelectInput.tsx new file mode 100644 index 0000000..fcf3622 --- /dev/null +++ b/apps/editor/src/widgets/right-sidebar/ui/atoms/SelectInput.tsx @@ -0,0 +1,47 @@ +"use client"; + +interface SelectInputProps { + id?: string; + name?: string; + value: string; + options: { label: string; value: string }[]; + onChange: (val: string) => void; + size?: "single" | "small"; +} + +export default function SelectInput({ + id, + name, + value, + options, + onChange, + size = "single", +}: SelectInputProps) { + const widthClass = size === "small" ? "w-[65px]" : "w-[140px]"; + + return ( +
+ + {/* 커스텀 화살표 아이콘 */} +
+ + + +
+
+ ); +} + + diff --git a/apps/editor/src/widgets/right-sidebar/ui/atoms/SidebarItem.tsx b/apps/editor/src/widgets/right-sidebar/ui/atoms/SidebarItem.tsx new file mode 100644 index 0000000..725aa1e --- /dev/null +++ b/apps/editor/src/widgets/right-sidebar/ui/atoms/SidebarItem.tsx @@ -0,0 +1,20 @@ +"use client"; + +interface SidebarItemProps { + label: string; + children: React.ReactNode; +} + +/** + * 모든 사이드바 입력 요소의 공통 래퍼 + */ +export default function SidebarItem({ label, children }: SidebarItemProps) { + return ( +
+ + {label} + + {children} +
+ ); +} diff --git a/apps/editor/src/widgets/right-sidebar/ui/atoms/SizingModeInput.tsx b/apps/editor/src/widgets/right-sidebar/ui/atoms/SizingModeInput.tsx new file mode 100644 index 0000000..87a28c0 --- /dev/null +++ b/apps/editor/src/widgets/right-sidebar/ui/atoms/SizingModeInput.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { cn } from "@repo/utils"; +import { ChevronDown } from "lucide-react"; +import { useState, useRef, useEffect } from "react"; + +type SizingMode = "fixed" | "fill" | "fit" | "relative"; +type Unit = "px" | "%"; + +interface SizingModeInputProps { + mode: SizingMode; + value: number | string; + unit: Unit; + label: string; // "W" 또는 "H" + onModeChange: (mode: SizingMode) => void; + onValueChange: (value: number) => void; + onUnitChange: (unit: Unit) => void; +} + +const MODE_LABELS: Record = { + fixed: "Fixed", + fill: "Fill", + fit: "Fit", + relative: "Relative", +}; + +/** + * Framer의 Size 입력 컴포넌트 + * 모드 선택 + 값 입력 + 단위 토글 + */ +export default function SizingModeInput({ + mode, + value, + unit, + label, + onModeChange, + onValueChange, + onUnitChange, +}: SizingModeInputProps) { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + // 모드에 따라 입력 비활성화 + const isDisabled = mode === "fill" || mode === "fit"; + const displayValue = isDisabled ? (mode === "fill" ? "1fr" : "auto") : value; + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + return ( +
+ {/* 라벨 */} + {label} + + {/* 모드 드롭다운 */} +
+ + {isOpen && ( +
+ {(Object.keys(MODE_LABELS) as SizingMode[]).map((m) => ( + + ))} +
+ )} +
+ + {/* 값 입력 */} + onValueChange(Number(e.target.value))} + className={cn( + "w-16 px-2 py-1.5 text-xs border border-zinc-200 rounded-md text-center", + isDisabled && "bg-zinc-100 text-zinc-400 cursor-not-allowed" + )} + /> + + {/* 단위 토글 */} + {!isDisabled && ( + + )} +
+ ); +} diff --git a/apps/editor/src/widgets/right-sidebar/ui/atoms/SliderInput.tsx b/apps/editor/src/widgets/right-sidebar/ui/atoms/SliderInput.tsx new file mode 100644 index 0000000..f728f94 --- /dev/null +++ b/apps/editor/src/widgets/right-sidebar/ui/atoms/SliderInput.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { cn } from "@repo/utils"; + +interface SliderInputProps { + value: number; + min?: number; + max?: number; + onChange: (value: number) => void; + size?: "single" | "small"; +} + +export default function SliderInput({ + value, + min = 0, + max = 100, + onChange, + size = "small", +}: SliderInputProps) { + const widthClass = size === "small" ? "w-[65px]" : "w-[140px]"; + + return ( +
+ onChange(Number(e.target.value))} + className={cn( + "w-full h-1 bg-zinc-200 rounded-full appearance-none cursor-pointer accent-[#2563EB]", + "[&::-webkit-slider-thumb]:appearance-none", + "[&::-webkit-slider-thumb]:w-3.5 [&::-webkit-slider-thumb]:h-3.5", + "[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white", + "[&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-[#2563EB]", + "[&::-webkit-slider-thumb]:shadow-md [&::-webkit-slider-thumb]:transition-transform", + "hover:[&::-webkit-slider-thumb]:scale-110" + )} + /> +
+ ); +} diff --git a/apps/editor/src/widgets/right-sidebar/ui/atoms/TextInput.tsx b/apps/editor/src/widgets/right-sidebar/ui/atoms/TextInput.tsx new file mode 100644 index 0000000..6c30e80 --- /dev/null +++ b/apps/editor/src/widgets/right-sidebar/ui/atoms/TextInput.tsx @@ -0,0 +1,23 @@ +"use client"; + +interface TextInputProps { + id?: string; + name?: string; + value: string; + onChange: (val: string) => void; + placeholder?: string; +} + +export default function TextInput({ id, name, value, onChange, placeholder }: TextInputProps) { + return ( + onChange(e.target.value)} + placeholder={placeholder} + className="flex w-full h-[33px] items-center px-3 py-2 rounded-[6px] border border-[#E4E4E7] bg-white font-inter font-normal text-[14px] leading-none text-zinc-900 placeholder:text-[#A1A1AA] outline-none focus:border-zinc-400 transition-colors" + /> + ); +} diff --git a/apps/editor/src/widgets/right-sidebar/ui/atoms/ToggleButtonGroup.tsx b/apps/editor/src/widgets/right-sidebar/ui/atoms/ToggleButtonGroup.tsx new file mode 100644 index 0000000..786cf18 --- /dev/null +++ b/apps/editor/src/widgets/right-sidebar/ui/atoms/ToggleButtonGroup.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { cn } from "@repo/utils"; + +interface ToggleOption { + label: string; + value: string; +} + +interface ToggleButtonGroupProps { + options: ToggleOption[]; + value: string | string[]; + onChange: (val: string) => void; + size?: "single" | "small"; +} + +export default function ToggleButtonGroup({ + options, + value, + onChange, + size = "single", +}: ToggleButtonGroupProps) { + const widthClass = size === "small" ? "w-[65px]" : "w-[140px]"; + + return ( +
+ {options.map((opt) => { + const isActive = Array.isArray(value) + ? value.includes(opt.value) + : value === opt.value; + + return ( + + ); + })} +
+ ); +} diff --git a/apps/editor/src/widgets/right-sidebar/ui/panels/ButtonPanel.tsx b/apps/editor/src/widgets/right-sidebar/ui/panels/ButtonPanel.tsx new file mode 100644 index 0000000..ad77e53 --- /dev/null +++ b/apps/editor/src/widgets/right-sidebar/ui/panels/ButtonPanel.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { ButtonNode } from "@repo/ui/types/nodes"; +import { useUpdateNode } from "@/stores/useEditorStore"; +import SidebarItem from "../atoms/SidebarItem"; +import TextInput from "../atoms/TextInput"; +import LayoutSection from "../sections/LayoutSection"; + +interface ButtonPanelProps { + node: ButtonNode; +} + +export default function ButtonPanel({ node }: ButtonPanelProps) { + const updateNode = useUpdateNode(); + + const handlePropChange = (key: string, value: string | number | boolean | object) => { + updateNode(node.id, { props: { ...node.props, [key]: value } }); + }; + + return ( +
+ {/* Label Section */} + + handlePropChange("text", val)} + placeholder="Enter button text..." + /> + + + {/* Common Layout Section */} + + + {/* Button Specific Sections (State, Hover, Trigger etc.) */} +
+ Button specialized settings (Hover, Trigger) will be added here. +
+
+ ); +} diff --git a/apps/editor/src/widgets/right-sidebar/ui/panels/ImagePanel.tsx b/apps/editor/src/widgets/right-sidebar/ui/panels/ImagePanel.tsx new file mode 100644 index 0000000..ebf5e73 --- /dev/null +++ b/apps/editor/src/widgets/right-sidebar/ui/panels/ImagePanel.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { WcxNode } from "@repo/ui/types/nodes"; +import { useUpdateNode } from "@/stores/useEditorStore"; +import SidebarItem from "../atoms/SidebarItem"; +import TextInput from "../atoms/TextInput"; +import LayoutSection from "../sections/LayoutSection"; + +interface ImagePanelProps { + node: WcxNode; +} + +export default function ImagePanel({ node }: ImagePanelProps) { + const updateNode = useUpdateNode(); + + return ( +
+ + + updateNode(node.id, { props: { ...node.props, alt: val } }) + } + placeholder="Image description..." + /> + + +
+ ); +} diff --git a/apps/editor/src/widgets/right-sidebar/ui/panels/TextPanel.tsx b/apps/editor/src/widgets/right-sidebar/ui/panels/TextPanel.tsx new file mode 100644 index 0000000..f66f55a --- /dev/null +++ b/apps/editor/src/widgets/right-sidebar/ui/panels/TextPanel.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { TextNode } from "@repo/ui/types/nodes"; +import { useUpdateNode } from "@/stores/useEditorStore"; +import SidebarItem from "../atoms/SidebarItem"; +import TextInput from "../atoms/TextInput"; +import SelectInput from "../atoms/SelectInput"; +import NumberInput from "../atoms/NumberInput"; +import ColorInput from "../atoms/ColorInput"; +import ToggleButtonGroup from "../atoms/ToggleButtonGroup"; +import ControlRow from "../atoms/ControlRow"; +import AlignPicker from "../atoms/AlignPicker"; + +interface TextPanelProps { + node: TextNode; +} + +export default function TextPanel({ node }: TextPanelProps) { + const updateNode = useUpdateNode(); + + const handlePropChange = (key: string, value: string | number | boolean | object) => { + updateNode(node.id, { props: { ...node.props, [key]: value } }); + }; + + const handleStyleChange = (key: string, value: string | number | boolean | object) => { + updateNode(node.id, { style: { ...node.style, [key]: value } }); + }; + + return ( +
+ {/* 1. Text Content - Added as per request */} + + handlePropChange("text", val)} + placeholder="Enter text..." + /> + + + {/* 2. Font Family */} + + handleStyleChange("fontFamily", val)} + /> + + + {/* 3. Size & Weight */} + + + handleStyleChange("fontSize", `${val}px`)} + /> + + + handleStyleChange("fontWeight", val)} + /> + + + + {/* 4. Color */} + + handleStyleChange("color", val)} + /> + + + {/* 5. Style (Italic, Underline, Strike) */} + + v !== "" && v !== "none")} + options={[ + { label: "I", value: "italic" }, + { label: "U", value: "underline" }, + { label: "S", value: "line-through" }, + ]} + onChange={(val) => { + if (val === "italic") { + handleStyleChange("fontStyle", node.style.fontStyle === "italic" ? "normal" : "italic"); + } else { + handleStyleChange("textDecoration", node.style.textDecoration === val ? "none" : val); + } + }} + /> + + + {/* 6. Alignment (Horizontal & Vertical) */} + + handleStyleChange("justifyContent", val)} + /> + + + + handleStyleChange("alignItems", val)} + /> + + + {/* 7. Line Height & Letter Spacing */} + + + handleStyleChange("lineHeight", `${val}%`)} + /> + + + handleStyleChange("letterSpacing", `${val}px`)} + /> + + + + {/* 8. Heading Level */} + + handlePropChange("level", val)} + /> + +
+ ); +} diff --git a/apps/editor/src/widgets/right-sidebar/ui/sections/LayoutSection.tsx b/apps/editor/src/widgets/right-sidebar/ui/sections/LayoutSection.tsx new file mode 100644 index 0000000..e8ddc15 --- /dev/null +++ b/apps/editor/src/widgets/right-sidebar/ui/sections/LayoutSection.tsx @@ -0,0 +1,146 @@ +import { WcxNode } from "@repo/ui/types/nodes"; +import { useUpdateNode } from "@/stores/useEditorStore"; +import FieldRow from "../atoms/FieldRow"; +import DirectionToggle from "../atoms/DirectionToggle"; +import AlignPicker from "../atoms/AlignPicker"; +import SelectInput from "../atoms/SelectInput"; +import ToggleButtonGroup from "../atoms/ToggleButtonGroup"; +import SliderInput from "../atoms/SliderInput"; +import PaddingInput from "../atoms/PaddingInput"; +import NumberInput from "../atoms/NumberInput"; + +interface LayoutSectionProps { + node: WcxNode; +} + +export default function LayoutSection({ node }: LayoutSectionProps) { + const updateNode = useUpdateNode(); + + const handleStyleChange = (key: string, value: string | number) => { + updateNode(node.id, { style: { ...node.style, [key]: value } }); + }; + + const direction = (node.style.flexDirection as "row" | "column") || "row"; + const wrap = node.style.flexWrap === "wrap"; + + // Padding 파싱 + const paddingValue = parseInt(String(node.style.padding)) || 0; + const paddingTop = parseInt(String(node.style.paddingTop)) || paddingValue; + const paddingRight = parseInt(String(node.style.paddingRight)) || paddingValue; + const paddingBottom = parseInt(String(node.style.paddingBottom)) || paddingValue; + const paddingLeft = parseInt(String(node.style.paddingLeft)) || paddingValue; + + const handlePaddingChange = (padding: { top: number; right: number; bottom: number; left: number }) => { + if (padding.top === padding.right && padding.right === padding.bottom && padding.bottom === padding.left) { + updateNode(node.id, { + style: { + ...node.style, + padding: `${padding.top}px`, + paddingTop: undefined, + paddingRight: undefined, + paddingBottom: undefined, + paddingLeft: undefined, + }, + }); + } else { + updateNode(node.id, { + style: { + ...node.style, + padding: undefined, + paddingTop: `${padding.top}px`, + paddingRight: `${padding.right}px`, + paddingBottom: `${padding.bottom}px`, + paddingLeft: `${padding.left}px`, + }, + }); + } + }; + + return ( + <> + {/* Type */} + + { }} + /> + + + {/* Direction */} + + handleStyleChange("flexDirection", v)} + /> + + + {/* Distribute */} + + handleStyleChange("justifyContent", v)} + /> + + + {/* Align */} + + handleStyleChange("alignItems", v)} + /> + + + {/* Wrap */} + + handleStyleChange("flexWrap", v === "Yes" ? "wrap" : "nowrap")} + /> + + + {/* Gap */} + + handleStyleChange("gap", `${v}px`)} + size="small" + /> + handleStyleChange("gap", `${v}px`)} + size="small" + /> + + + {/* Padding */} + + + + + ); +} diff --git a/apps/editor/src/widgets/right-sidebar/ui/sections/PositionSection.tsx b/apps/editor/src/widgets/right-sidebar/ui/sections/PositionSection.tsx new file mode 100644 index 0000000..04ed4ba --- /dev/null +++ b/apps/editor/src/widgets/right-sidebar/ui/sections/PositionSection.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { WcxNode } from "@repo/ui/types/nodes"; +import { useUpdateNode, useUpdateNodeLayout } from "@/stores/useEditorStore"; +import FieldRow from "../atoms/FieldRow"; +import NumberInput from "../atoms/NumberInput"; +import SelectInput from "../atoms/SelectInput"; + +interface PositionSectionProps { + node: WcxNode; +} + +type PositionType = "relative" | "absolute" | "fixed" | "sticky"; + +export default function PositionSection({ node }: PositionSectionProps) { + const updateNode = useUpdateNode(); + const updateNodeLayout = useUpdateNodeLayout(); + const positionType = (node.style.position as PositionType) || "relative"; + + const handleStyleChange = (key: string, value: string | number | undefined) => { + updateNode(node.id, { style: { ...node.style, [key]: value } }); + }; + + const handleLayoutChange = (key: string, value: string | number) => { + updateNodeLayout(node.id, { [key]: value }); + }; + + const handlePositionTypeChange = (type: PositionType) => { + if (type === "relative") { + // relative 전환 시 스타일에서 위치 정보 제거 + updateNode(node.id, { + style: { + ...node.style, + position: type, + top: undefined, + right: undefined, + bottom: undefined, + left: undefined, + }, + }); + // x, y 좌표는 유지하거나 필요에 따라 초기화할 수 있지만 일단 유지 + } else { + handleStyleChange("position", type); + } + }; + + const showOffsets = positionType === "absolute" || positionType === "fixed"; + const showStickyTop = positionType === "sticky"; + + return ( + <> + {/* Absolute / Fixed → Top(y) / Left(x) (Layout 필드 사용) */} + {showOffsets && ( + <> + + handleLayoutChange("y", v)} + /> + + + handleLayoutChange("x", v)} + /> + + + )} + + {/* Sticky → Top only (y 사용) */} + {showStickyTop && ( + + handleLayoutChange("y", v)} + /> + + )} + + {/* Type 드롭다운 — 항상 표시 */} + + handlePositionTypeChange(v as PositionType)} + /> + + + ); +} diff --git a/apps/editor/src/widgets/right-sidebar/ui/sections/PropertySection.tsx b/apps/editor/src/widgets/right-sidebar/ui/sections/PropertySection.tsx new file mode 100644 index 0000000..87e92d7 --- /dev/null +++ b/apps/editor/src/widgets/right-sidebar/ui/sections/PropertySection.tsx @@ -0,0 +1,46 @@ +"use client"; + + +import { Minus, Plus } from "lucide-react"; +import { useState } from "react"; + +interface PropertySectionProps { + title: string; + defaultOpen?: boolean; + children: React.ReactNode; +} + +/** + * 사이드바 레이어 섹션 + * --- 구분선 --- + * Position [-] + * (fields...) + */ +export default function PropertySection({ title, defaultOpen = true, children }: PropertySectionProps) { + const [isOpen, setIsOpen] = useState(defaultOpen); + + return ( +
+ {/* 헤더 */} + + + {/* 본문 */} + {isOpen && ( +
+ {children} +
+ )} +
+ ); +} diff --git a/apps/editor/src/widgets/right-sidebar/ui/sections/SizeSection.tsx b/apps/editor/src/widgets/right-sidebar/ui/sections/SizeSection.tsx new file mode 100644 index 0000000..fa3286c --- /dev/null +++ b/apps/editor/src/widgets/right-sidebar/ui/sections/SizeSection.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { WcxNode } from "@repo/ui/types/nodes"; +import { useUpdateNode, useUpdateNodeLayout, useCurNodes } from "@/stores/useEditorStore"; +import FieldRow from "../atoms/FieldRow"; +import NumberInput from "../atoms/NumberInput"; +import SelectInput from "../atoms/SelectInput"; + +interface SizeSectionProps { + node: WcxNode; +} + +type SizingMode = "fixed" | "fill" | "fit" | "relative"; + +export default function SizeSection({ node }: SizeSectionProps) { + const updateLayout = useUpdateNodeLayout(); + const updateNode = useUpdateNode(); + const nodes = useCurNodes(); + + const widthMode: SizingMode = (node.layout as { widthMode: SizingMode }).widthMode || "fixed"; + const heightMode: SizingMode = (node.layout as { heightMode: SizingMode }).heightMode || "fixed"; + + const widthValue = typeof node.layout.width === "number" + ? node.layout.width + : parseInt(String(node.layout.width)) || 0; + + const heightValue = typeof node.layout.height === "number" + ? node.layout.height + : parseInt(String(node.layout.height)) || 0; + + 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) => { + 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); + } + + // 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); + } + + return oldValue; + }; + + const handleModeChange = (key: 'widthMode' | 'heightMode', newMode: SizingMode) => { + const isHeight = key === 'heightMode'; + const sizeKey = isHeight ? 'height' : 'width'; + const oldMode = isHeight ? heightMode : widthMode; + const oldValue = isHeight ? heightValue : widthValue; + + 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); + }; + + const modeOptions = [ + { label: "Fixed", value: "fixed" }, + { label: "Rel", value: "relative" }, + { label: "Fill", value: "fill" }, + { label: "Fit", value: "fit" }, + ]; + + return ( + <> + {/* Width: 값 / 모드 */} + + updateLayout(node.id, { width: v })} + size="small" + suffix={widthMode === "relative" ? "%" : widthMode === "fill" ? "fr" : undefined} + blurred={widthMode === "fit"} + /> + handleModeChange("widthMode", v as SizingMode)} + size="small" + /> + + + {/* Height: 값 / 모드 */} + + updateLayout(node.id, { height: v })} + size="small" + suffix={heightMode === "relative" ? "%" : heightMode === "fill" ? "fr" : undefined} + blurred={heightMode === "fit"} + /> + handleModeChange("heightMode", v as SizingMode)} + size="small" + /> + + + {/* Min Width */} + + handleStyleChange("minWidth", v)} + /> + + + {/* Max Width */} + + handleStyleChange("maxWidth", v)} + /> + + + {/* Min Height */} + + handleStyleChange("minHeight", v)} + /> + + + {/* Max Height */} + + handleStyleChange("maxHeight", v)} + /> + + + ); +} diff --git a/packages/ui/src/components/Heading.tsx b/packages/ui/src/components/Heading.tsx index 07ca899..33acd6b 100644 --- a/packages/ui/src/components/Heading.tsx +++ b/packages/ui/src/components/Heading.tsx @@ -15,7 +15,7 @@ export default function HeadingComponent({ {text} diff --git a/packages/ui/src/components/Text.tsx b/packages/ui/src/components/Text.tsx index 8ca9a55..2d829e1 100644 --- a/packages/ui/src/components/Text.tsx +++ b/packages/ui/src/components/Text.tsx @@ -14,7 +14,7 @@ export default function TextComponent({ {text} diff --git a/packages/ui/src/core/EditorNodeWrapper.tsx b/packages/ui/src/core/EditorNodeWrapper.tsx index ac27061..e197804 100644 --- a/packages/ui/src/core/EditorNodeWrapper.tsx +++ b/packages/ui/src/core/EditorNodeWrapper.tsx @@ -38,7 +38,7 @@ export default function EditorNodeWrapper({ }; const { id } = node; - const { width, height, x, y } = node.layout; + const { width, height, x, y, zIndex } = node.layout; const selectedNodeGuideClasses = { handle: "bg-white border-2 rounded-full border-rnd-handle !w-2 !h-2 ", outline: "ring ring-2 ring-rnd-handle", @@ -50,6 +50,11 @@ export default function EditorNodeWrapper({ e.stopPropagation()} //TODO-일단 이동중에 스토어 업데이트는 미루기 -> 성능 이슈 diff --git a/packages/ui/src/types/nodes.ts b/packages/ui/src/types/nodes.ts index 04d977f..0aa0d11 100644 --- a/packages/ui/src/types/nodes.ts +++ b/packages/ui/src/types/nodes.ts @@ -19,8 +19,12 @@ export interface BaseNode { layout: { x: number; y: number; - width: number; // 혹은 string ('100%') - height: number; // 혹은 string ('auto') + width: number | string; // 숫자(px) 또는 문자열('100%', 'auto') + height: number | string; // 숫자(px) 또는 문자열('100%', 'auto') + widthMode: 'fixed' | 'fill' | 'fit' | 'relative'; // Framer sizing 모드 + heightMode: 'fixed' | 'fill' | 'fit' | 'relative'; // Framer sizing 모드 + widthUnit: 'px' | '%'; + heightUnit: 'px' | '%'; zIndex: number; }; }