diff --git a/apps/editor/WORK_DIVISION.md b/apps/editor/WORK_DIVISION.md new file mode 100644 index 0000000..f0662b1 --- /dev/null +++ b/apps/editor/WORK_DIVISION.md @@ -0,0 +1,119 @@ +# WebCreatorX - 팀 업무 분담 가이드 + +이 문서는 프로젝트의 효율적인 개발을 위해 팀원 간의 업무 영역과 협업 포인트를 정의합니다. +서로의 작업 영역을 침범하지 않으면서, 공통 상태(Store)와 데이터 구조를 기반으로 유기적으로 연결되도록 설계되었습니다. + +--- + +## 0. 들어가기에 앞서 +- 프로젝트 구조가 모노레포로 변경되었습니다! monorepoGuide.md 를 꼭 먼저 읽어주세요! + +## 1. 프로젝트 개요 및 아키텍처 +**WebCreatorX**는 노드 기반의 웹사이트 빌더입니다. 사용자는 좌측 사이드바에서 컴포넌트를 추가하고, 중앙 캔버스에서 확인하며, 우측 사이드바에서 속성을 편집합니다. + +### 핵심 데이터 구조 (`WcxNode`) +모든 컴포넌트(Hero, Image, Text 등)는 `WcxNode`라는 공통 타입을 가집니다. +- **정의 위치**: `packages/ui/src/types/nodes.ts` +- **핵심 필드**: + - `id`: 고유 식별자 (UUID) + - `type`: 컴포넌트 종류 (Hero, Image, Button 등) + - `props`: 해당 컴포넌트의 데이터 (텍스트 내용, 이미지 URL 등) + - `style`: 스타일 정보 + +--- + +## 2. 업무 분담 (R&R) + +### 🧑‍💻 Milo (Core & Renderer) +- **책임 영역**: 에디터 중앙 캔버스, 노드 렌더링, 전역 상태(Store) 설계, 서버 통신 +- **주요 작업**: + 1. **Node Renderer**: 서버에서 받아온 `nodes` 데이터를 순회하며 실제 화면에 그립니다. + 2. **Interaction**: 캔버스 상의 요소를 클릭했을 때, 해당 노드의 `id`를 `selectedId` 상태로 업데이트합니다. + 3. **Drag & Drop (추후)**: 캔버스 내에서의 위치 이동을 담당합니다. (DND 라이브러리 사용 예정) + +### 🧑‍💻 Nago (Sidebars & Controls) +- **책임 영역**: 좌측 사이드바(생성), 우측 사이드바(편집), UI 컨트롤 +- **주요 작업**: + 1. **좌측 사이드바 (Node Creator)**: 사용 가능한 컴포넌트 목록을 보여주고, 클릭 시 새로운 노드를 생성하여 캔버스에 추가합니다. + 2. **우측 사이드바 (Property Editor)**: 현재 선택된(`selectedId`) 노드의 정보를 보여주고, 사용자가 값을 수정하면 이를 실시간으로 반영합니다. + +--- + +## 3. 협업 포인트: 전역 상태 (Zustand Store) + +두 팀원의 작업은 **Zustand Store**를 통해 만납니다. 팀원은 아래 Store Hook이 존재한다고 가정하고 개발을 진행하면 됩니다. + +```typescript +// apps/editor/src/store/useEditorStore.ts (예시 구조) + +interface EditorState { + nodes: Record; // 전체 노드 데이터 (ID를 키로 사용) + selectedId: string | null; // 현재 선택된 노드 ID + + // Actions + addNode: (node: WcxNode) => void; + updateNode: (id: string, partialNode: Partial) => void; + selectNode: (id: string | null) => void; +} +``` + +--- + +## 4. Nago 합류 상세 가이드 + +### ✅ Task 1: 좌측 사이드바 - 노드 생성 +**목표**: 사용자가 새로운 컴포넌트를 추가할 수 있는 패널을 만듭니다. + +**구현 내용**: +1. `packages/ui/src/types/nodes.ts`에 정의된 모든 노드 타입(Hero, Text, Image 등)을 버튼 형태로 나열합니다.(완자가 만든 피그마 기획서 참고) +2. 버튼 클릭 시, 해당 타입에 맞는 **기본 노드 객체**를 생성해야 합니다. + - 예: Text 노드 생성 시 `id: uuid()`, `type: 'Text'`, `props: { text: 'New Text', level: 'h1' }` 등의 초기값을 가진 객체 생성. +3. 생성된 객체를 `useEditorStore`의 `addNode` 함수를 통해 상태에 추가합니다. + +**참고**: UI 디자인은 완자가 만든 피그마 기획서를 참고하여 구현해주세요. + +### ✅ Task 2: 우측 사이드바 - 속성 편집 (Manifest) +**목표**: 선택된 노드의 세부 내용을 수정하는 패널을 만듭니다. + +**구현 내용**: +1. `useEditorStore`에서 `selectedId`와 `nodes`를 구독합니다. +2. **조건부 렌더링**: + - `selectedId`가 `null`이면: "요소를 선택해주세요" 같은 안내 메시지 표시. + - `selectedId`가 있으면: `nodes[selectedId]`를 가져와서 편집 UI 표시. +3. **Switch Case 분기**: + - 선택된 노드의 `type`에 따라 다른 컴포넌트를 렌더링해야 합니다. + - `Hero` 타입 -> `HeroControl` 컴포넌트 (Heading 입력, 이미지 업로드, 버튼 텍스트 입력창 등) + - `Image` 타입 -> `ImageControl` 컴포넌트 (URL 입력, Alt 입력창 등) +4. **데이터 업데이트**: + - 입력창의 값이 변할 때마다 `updateNode(selectedId, { props: { ...newProps } })`를 호출하여 실시간으로 반영합니다. + +### 💡 팁 +- **컴포넌트 재사용**: 각 속성 편집기(Input, Select, Toggle 등)는 `RightSidebar` 내부에서만 쓰이지 않고 나중에도 쓰일 수 있으니, 작고 재사용 가능한 'Control Component'로 쪼개서 개발하는 것이 좋습니다. +- **타입 안전성**: `packages/ui`에 있는 `WcxNode`, `HeroNode` 등의 타입을 적극 활용하여, 잘못된 속성을 수정하는 일을 방지하세요. + +### 중요 사항! +- 위에서 언급한 기획들은 대략적으로 서술한 것입니다. 실제로 구현에 필요한 자세한 정보들은 피그마의 기획서를 절대적으로 따라야합니다. +- 추가로 궁금한 점이 있으면 디스코드로 언제든지 질문해주세요! +--- + +## 5. 폴더 구조 제안 (apps/editor/src) +``` +src/ +├── components/ +│ ├── editor/ # (Milo) 캔버스, 렌더러 +│ │ ├── Canvas.tsx +│ │ └── NodeRenderer.tsx +│ │ +│ ├── sidebar/ # (Nago) 사이드바 +│ │ ├── LeftSidebar.tsx +│ │ ├── RightSidebar.tsx +│ │ └── controls/ # (Shared) 속성 편집용 작은 컴포넌트들 +│ │ ├── TextInput.tsx +│ │ └── ColorPicker.tsx +│ │ +│ └── layout/ # 전체 레이아웃 (Header, Main, Sidebars 배치) +│ └── EditorLayout.tsx +│ +└── store/ + └── useEditorStore.ts # 전역 상태 +``` diff --git a/monorepoGuide.md b/monorepoGuide.md new file mode 100644 index 0000000..1fc47b9 --- /dev/null +++ b/monorepoGuide.md @@ -0,0 +1,126 @@ +# WebCreatorX Monorepo 가이드라인 + +이 프로젝트는 확장성과 코드 재사용성을 높이기 위해 **Monorepo(모노레포)** 구조로 전환되었습니다. +**Turborepo**와 **pnpm workspaces**를 기반으로 구성되어 있으며, 여러 애플리케이션과 공유 패키지를 하나의 저장소에서 효율적으로 관리합니다. + +팀원들이 새로운 구조에 빠르게 적응할 수 있도록 가이드를 제공하겠습니다. + +--- + +## 🏗️ 기술 스택 및 도구 + +- **Monorepo Tool**: [Turborepo](https://turbo.build/) (빌드 시스템 및 태스크 러너) +- **Package Manager**: [pnpm](https://pnpm.io/) (Workspaces 기능 사용) +- **Framework**: Next.js 15 (App Router) +- **Language**: TypeScript + +--- + +## 📂 디렉토리 구조 (Directory Structure) + +기존 단일 레포지토리 구조와 달리, 프로젝트는 크게 `apps`와 `packages`로 나뉩니다. + +``` +web-creator-x/ +├── apps/ # ⭐️ 배포 가능한 애플리케이션들이 위치합니다. +│ ├── editor/ # 메인 에디터 애플리케이션 (Next.js) +│ └── user-template/ # 사용자 템플릿 관련 애플리케이션 +├── packages/ # 🛠️ 여러 앱에서 공유하는 라이브러리/패키지들이 위치합니다. +│ └── ui/ # 공통 UI 컴포넌트 (@repo/ui) +├── package.json # 루트 설정 (Workspaces 정의) +├── pnpm-workspace.yaml # pnpm 워크스페이스 설정 +└── turbo.json # Turborepo 파이프라인 설정 +``` + +### 💡 주요 변경 사항 (Migration Notes) + +| ⚠️ 기존 (Single Repo) | ♻️ 변경 후 (Monorepo) | 설명 | +| --------------------------------------- | ----------------------------------- | --------------------------------------------------------------------- | +| `/src` | `/apps/editor/src` | 메인 앱 코드는 `apps/editor`로 이동했습니다. | +| `/src/components` | `/packages/ui/src` | 재사용 가능한 컴포넌트는 `packages/ui`로 분리되었습니다. | +| `import { Button } from '@/components'` | `import { Button } from '@repo/ui'` | 공통 컴포넌트는 패키지명(`@repo/ui`)으로 import 합니다. | +| `npm run dev` | `pnpm dev` | 루트에서 실행하면 모든 앱을 동시에 실행하거나, 필터링하여 실행합니다. | + +--- + +## 🚀 시작하기 (Getting Started) + +### 1. 필수 요구사항 + +- Node.js (LTS 버전 권장) +- **pnpm** (필수): `npm install -g pnpm` + +### 2. 설치 + +프로젝트 루트에서 의존성을 설치합니다. 모든 앱과 패키지의 의존성이 한 번에 설치됩니다. + +```bash +pnpm install +``` + +### 3. 개발 서버 실행 + +루트에서 다음 명령어를 실행하면 `apps` 내의 모든 애플리케이션이 동시에 실행됩니다. + +```bash +pnpm dev +``` + +특정 앱만 실행하고 싶다면 `--filter` 옵션을 사용하세요. + +```bash +# editor 앱만 실행 +pnpm --filter editor dev +``` + +### 4. 빌드 (Build) + +전체 프로젝트를 빌드합니다. Turborepo의 캐싱 기능을 통해 변경된 부분만 빌드되어 속도가 빠릅니다. + +```bash +pnpm build +``` + +--- + +## 🛠️ 개발 워크플로우 (Development Workflow) + +### 패키지 추가하기 (Adding Dependencies) + +🚨 특정 앱이나 패키지에 라이브러리를 설치할 때는 `--filter`를 사용해야 합니다. + +**예시: `editor` 앱에 `axios` 설치** + +```bash +pnpm add axios --filter editor +``` + +**예시: `ui` 패키지에 `clsx` 설치** + +```bash +pnpm add clsx --filter @repo/ui +``` + +### 공통 컴포넌트 작업 (Working with Shared UI) + +`packages/ui`에 있는 컴포넌트를 수정하면, 이를 사용하는 모든 앱(`editor` 등)에 즉시 반영됩니다. +새로운 공통 컴포넌트를 만들 때는 다음 규칙을 따라주세요: + +1. `packages/ui/src/components`에 컴포넌트 파일 생성 +2. `packages/ui/package.json`의 `exports` 설정 확인 (필요 시) +3. 앱에서 `import { ... } from '@repo/ui'`로 사용 + +### 새로운 앱 추가하기 + +`apps/` 폴더 안에 새로운 Next.js 프로젝트 등을 생성하면 자동으로 워크스페이스에 포함됩니다. +`package.json`의 이름을 고유하게 설정해주세요. + +--- + +## ❓ 자주 묻는 질문 (FAQ) + +**Q. 왜 `npm` 대신 `pnpm`을 쓰나요?** +A. `pnpm`은 디스크 공간을 효율적으로 사용하고 설치 속도가 매우 빠릅니다. 또한 모노레포의 워크스페이스 기능을 완벽하게 지원합니다. + +**Q. `turbo.json`은 무엇인가요?** +A. `build`, `dev`, `lint` 등의 스크립트 실행 순서와 캐싱 정책을 정의하는 파일입니다. 예를 들어, `build` 작업은 의존성 패키지가 먼저 빌드되어야 함을 설정할 수 있습니다. diff --git a/packages/ui/src/components/Button.tsx b/packages/ui/src/components/Button.tsx new file mode 100644 index 0000000..e87446e --- /dev/null +++ b/packages/ui/src/components/Button.tsx @@ -0,0 +1,43 @@ +import { useBuilderMode } from "context/builderMode"; +import { useActionHandler } from "hooks/useActionHandler"; +import { ButtonNode, NodeComponentProps } from "types"; +import processNodeStyles from "utils/processNodeStyles"; + +export default function Button({ + node, + props, + style, +}: NodeComponentProps) { + const { mode } = useBuilderMode(); + const { text, action } = props; + + //스타일 변환(className은 제외, 오직 CSS속성만) + const nodeStyleObj = processNodeStyles(style); + + //액션 함수 생성 + const excuteAction = useActionHandler(action); + + //클릭 핸들러 + function clickHandler(e: React.MouseEvent) { + //에디터 모드에서는 액션 실행 x + if (mode === "editor") { + e.preventDefault(); + return; + } + + //라이브 모드: 실제 액션 실행 + excuteAction(); + } + + return ( + + ); +} diff --git a/packages/ui/src/components/Heading.tsx b/packages/ui/src/components/Heading.tsx index b77c1e3..37099d3 100644 --- a/packages/ui/src/components/Heading.tsx +++ b/packages/ui/src/components/Heading.tsx @@ -1,6 +1,6 @@ +import processNodeStyles from "utils/processNodeStyles"; import { NodeComponentProps } from "../types"; import { HeadingNode } from "../types/nodes"; -import applyStyles from "../utils/applyStyles"; export default function HeadingComponent({ node, @@ -9,12 +9,17 @@ export default function HeadingComponent({ children, }: NodeComponentProps) { const { text, level = "h2" } = props; - const inlineStyle = applyStyles(style); + const nodeStyleObj = processNodeStyles(style); + const Tag = level; return ( -
- {text} +
+ {text} {/* 컨테이너일 경우 이곳에 children이 렌더링 되야 한다. */} {children}
diff --git a/packages/ui/src/components/Hero.tsx b/packages/ui/src/components/Hero.tsx index 54b38df..e5a81ef 100644 --- a/packages/ui/src/components/Hero.tsx +++ b/packages/ui/src/components/Hero.tsx @@ -1,7 +1,7 @@ -import applyStyles from "../utils/applyStyles"; -import { NodeComponentProps } from "../types/component"; -import { HeroNode } from "../types/nodes"; import { useBuilderMode } from "context/builderMode"; +import Image from "next/image"; +import processNodeStyles from "../utils/processNodeStyles"; +import { HeroNode, NodeComponentProps } from "types"; export default function HeroComponent({ node, @@ -9,26 +9,81 @@ export default function HeroComponent({ style, }: NodeComponentProps) { const { mode } = useBuilderMode(); //현재 모드 확인 - const { heading, subHeading, button, backgroundImage } = props; - const inlineStyles = applyStyles(style); - const bgImageUrl = backgroundImage ? backgroundImage : null; + //이 props의 내부 Key에 따라서 하위에 렌더링될 요소들이 결정된다. + const { heading, subHeading, button, image } = props; + + //TODO - 🚨 style이나 node가 변경될 때만 재연산되게 useMemo로 메모이제이션 사용해야할 필요가 있다. + const nodeStyleObj = processNodeStyles(style); + + /** + * + * image태그가 z-0에 깔려있고 컨텐츠를 감싸는 컨테이너 div가 image태그 위에 존재합니다. + */ const handleLinkClick = (e: React.MouseEvent) => { if (mode === "editor") e.preventDefault(); }; return ( -
-

{heading}

- {subHeading &&

{subHeading}

} - {button && ( - + //TODO - 노드들이 드래그 앤 드롭될때 위치가 자유롭게 변하게 할 수 있어야한다. +
+ {/* 배경 이미지 영역 */} + {image?.url && ( +
+ {image.alt +
+
)} -
+ + {/* --- 콘텐츠 영역 (z-index를 높여서 이미지 위에 표시) --- */} +
+

+ {heading} +

+ + {subHeading && ( +

+ {subHeading} +

+ )} + {button && ( + + {button?.text} + + )} +
+
); } diff --git a/packages/ui/src/components/Image.tsx b/packages/ui/src/components/Image.tsx index f9b7a18..47fa24c 100644 --- a/packages/ui/src/components/Image.tsx +++ b/packages/ui/src/components/Image.tsx @@ -1,7 +1,7 @@ import Image from "next/image"; -import applyStyles from "../utils/applyStyles"; import { NodeComponentProps } from "../types/component"; import { ImageNode } from "../types/nodes"; +import processNodeStyles from "utils/processNodeStyles"; export default function ImageComponent({ node, @@ -9,14 +9,15 @@ export default function ImageComponent({ style, }: NodeComponentProps) { const { src, alt = "사용자의 이미지", caption } = props; - const inlineStyles = applyStyles(style); + const nodeStyleObj = processNodeStyles(style); return (
- {alt} + {alt} {caption &&
{caption}
}
); diff --git a/packages/ui/src/components/Modal.tsx b/packages/ui/src/components/Modal.tsx new file mode 100644 index 0000000..0c87419 --- /dev/null +++ b/packages/ui/src/components/Modal.tsx @@ -0,0 +1,89 @@ +import { useBuilderMode } from "context/builderMode"; +import { useRuntimeState } from "context/runtimeContext"; +import { useRef } from "react"; +import { ModalNode, NodeComponentProps } from "types"; +import processNodeStyles from "utils/processNodeStyles"; + +/** + * + * @param param0 + * @returns + * + * 해당 모달 렌더러를 에디터에서 보여주고 싶다면 기본 상태는 isOpen:flase 이므로 에디터에 한해서만 강제로 updateNodeState(id, { isOpen: true })를 호출해서 보여줘야합니다. + */ +export default function Modal({ + node, + props, + style, + children, //모달안에 들어갈 버튼, 텍스트 등이 children으로 올 수 있습니다. +}: NodeComponentProps) { + const dialogRef = useRef(null); + + const curNodeId = node.id; + + const { mode } = useBuilderMode(); + + //Context를 통한 상태 구독 + //TODO-만약 context의 형태가 context API에서 zustand로 바뀐다면 해당 로직도 수정되야 합니다. + const { state, updateNodeState } = useRuntimeState(); + + const isOpen = state[curNodeId]?.isOpen ?? false; + + const { title, showCloseButton, closeOnOverlayClick } = props; + + const nodeStyleObj = processNodeStyles(style); + + //닫기 핸들러 + function closeHandler(e?: React.MouseEvent) { + if (mode === "editor") return; + + e?.stopPropagation(); + + updateNodeState(curNodeId, { isOpen: false }); + } + + function overlayClickHandler(e?: React.MouseEvent) { + if (props.closeOnOverlayClick === false) return; + if (e?.target !== dialogRef.current) return; + //오직 ::backdrop에 이벤트가 발생했을 경우에만 발생합니다. + closeHandler(e); + } + + if (!isOpen) return null; + + //굳이 div로 오버레이가 필요 없을듯..? ::backdrop과 dialog에서 제공하는 .showModal() 사용해보기 + // (closeOnOverlayClick 존재 유무에 따라 div태그의 핸들러 동작을 결정하자.) + + //기본적인 모달 태그는 dialog로 결정 + //이때 배경 클릭시 + return ( + + {/* 실제 모달 컨테이너 */} +
e.stopPropagation()} + className={`${style.root?.className || ""} relative min-h-[200px] min-w-[300px] rounded-lg bg-white shadow-xl`} + style={nodeStyleObj.root} + > + {/* 모달 헤더 */} + {(props.title || props.showCloseButton) && ( +
+ {props.title &&

{props.title}

} + + {/* TODO-svg 공통 모듈 작업 필요 */} + {props.showCloseButton && } +
+ )} + + {/* [Body] 자식 노드들(버튼, 이미지, 텍스트)이 여기에 렌더링 됨 */} +
{children}
+
+
+ ); +} diff --git a/packages/ui/src/components/Text.tsx b/packages/ui/src/components/Text.tsx new file mode 100644 index 0000000..ca949f1 --- /dev/null +++ b/packages/ui/src/components/Text.tsx @@ -0,0 +1,24 @@ +import { NodeComponentProps, TextNode } from "types"; +import processNodeStyles from "utils/processNodeStyles"; + +export default function TextComponent({ + node, + props, + style, +}: NodeComponentProps) { + const { text, level = "h2" } = props; + const Tag = level; + + const nodeStyleObj = processNodeStyles(style); + + return ( +
+ {text} + {/* TODO-만약 텍스트 컴포넌트가 사진같은 정적 파일도 렌더링 해야한다면? */} +
+ ); +} diff --git a/packages/ui/src/context/runtimeContext.tsx b/packages/ui/src/context/runtimeContext.tsx new file mode 100644 index 0000000..b8d4246 --- /dev/null +++ b/packages/ui/src/context/runtimeContext.tsx @@ -0,0 +1,49 @@ +"use client"; + +/* +노드 렌더러들이 만든 노드 컴포넌트가 정적이지만, 이를 동적으로 변경해주기위해 필요한 장치입니다. +버튼 컴포넌트를 설계하다 필요를 느껴 해당 context를 고안했습니다. +버튼을 클릭하면 동적으로 웹사이트가 변경되야하면 어떡하지? 에서 해당 컨텍스트에 타겟 id와 행동을 전달해 타겟 노드에 동작을 전달하기 위한 중간 장치입니다. +*/ + +import { createContext, ReactNode, useContext, useState } from "react"; + +// 런타임에 변경된 노드들의 상태를 저장하는 객체 +// 예: { "node-123": { text: "11" }, "modal-456": { isOpen: true } } +type RuntimeState = Record; + +interface RuntimeContextType { + state: RuntimeState; + updateNodeState: (id: string, newState: any) => void; +} + +const RuntimeContext = createContext(null); + +//TODO-빠른 구현을 위해 Context를 사용했지만 하나의 상태가 바뀌더라도 해당 context를 구독하는 다른 노드들도 리렌더링이 발생하는 위험이 존재합니다. 꼭 추후에 상태 관리 방식을 리팩토링 해야합니다. +export function RuntimeProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState({}); + + function updateNodeState(id: string, partial: any) { + setState((prev) => ({ + ...prev, + [id]: { ...prev[id], ...partial }, + })); + } + + const contextValue = { + state, + updateNodeState, + }; + + return ( + + {children} + + ); +} + +export function useRuntimeState() { + const context = useContext(RuntimeContext); + if (!context) throw new Error("RuntimeProvider is not Here"); + return context; +} diff --git a/packages/ui/src/core/DraggableNodeWrapper.tsx b/packages/ui/src/core/DraggableNodeWrapper.tsx new file mode 100644 index 0000000..9b149b7 --- /dev/null +++ b/packages/ui/src/core/DraggableNodeWrapper.tsx @@ -0,0 +1,51 @@ +import { useBuilderMode } from "context/builderMode"; +import { CSSProperties } from "react"; +import { BaseNode } from "types"; + +interface WrapperProps { + children: React.ReactNode; + node: BaseNode; + isSelected: boolean; + onDragStart: (e: React.MouseEvent, id: string) => void; +} + +export default function DraggableNodeWrapper({ + children, + node, + isSelected, + onDragStart, +}: WrapperProps) { + const { mode } = useBuilderMode(); + const wrapperStyle: CSSProperties = { + position: "absolute", // 핵심: 캔버스 내에서 자유 배치 + left: node["layout"].x, + top: node["layout"].y, + width: node["layout"].width, + height: node["layout"].height, + zIndex: node["layout"].zIndex, + + // 선택되었을 때 시각적 피드백 (테두리 등) + outline: mode === "editor" && isSelected ? "2px solid blue" : "none", + cursor: mode === "editor" ? "move" : "default", + }; + + return ( +
mode === "editor" && onDragStart(e, node.id)} + className="" //TODO - 필요시 클래스네임 추가(dnd상황에서 스타일 강조 등) + > + {/* 실제 컴포넌트(Hero 등)는 이 안에 렌더링됨 */} + {children} + + {/* 리사이즈 핸들 등은 에디터 모드에서만 오버레이로 표시 */} + {/* TODO-추후 호버시 리사이즈 핸들 렌더링 하도록 수정 필요! */} + {isSelected && mode === "editor" && ( + <> +
+ {/* TODO-기타 리사이즈 핸들들 렌더링은 이곳에서 ... */} + + )} +
+ ); +} diff --git a/packages/ui/src/renderer/NodeRenderer.tsx b/packages/ui/src/core/NodeRenderer.tsx similarity index 100% rename from packages/ui/src/renderer/NodeRenderer.tsx rename to packages/ui/src/core/NodeRenderer.tsx diff --git a/packages/ui/src/hooks/useActionHandler.tsx b/packages/ui/src/hooks/useActionHandler.tsx new file mode 100644 index 0000000..0b97778 --- /dev/null +++ b/packages/ui/src/hooks/useActionHandler.tsx @@ -0,0 +1,50 @@ +"use Rounter"; + +import { useRuntimeState } from "context/runtimeContext"; +import { useRouter } from "next/router"; +import { NodeAction } from "types/nodeAction"; + +export function useActionHandler(action?: NodeAction) { + const router = useRouter(); + const { state, updateNodeState } = useRuntimeState(); + + function excute() { + if (!action) return; + + switch (action.type) { + // Alert 띄우기 + case "alert": + alert(action.payload || "알림"); + break; + + //TODO-스크롤할때의 액션 옵션도 추후에 입력에 따라서 사용자가 커스텀 할 수 있도록 개션해야합니다. + case "scroll": + const element = document.querySelector( + `[data-component-id=${action.payload}]`, + ); + element?.scrollIntoView({ + block: "center", + behavior: "smooth", + inline: "nearest", + }); + + break; + + //모달 띄우기 (모달 노드의 isOpen 상태를 변경) + case "modal": + if (action.targetId) { + updateNodeState(action.targetId, { isOpen: true }); + } + break; + + //링크이동 + case "link": + if (action.payload) router.push(action.payload); + break; + + //TODO-추후 필요한 기능 추가 예정(API 호출 등등...) + } + } + + return excute; +} diff --git a/packages/ui/src/types/componentProps.ts b/packages/ui/src/types/componentProps.ts index c775e8e..2c37701 100644 --- a/packages/ui/src/types/componentProps.ts +++ b/packages/ui/src/types/componentProps.ts @@ -1,5 +1,7 @@ //노드의 타입별로 달라지는 props의 타입을 정의. +import { NodeAction } from "./nodeAction"; + // 1. Hero 컴포넌트 Props export interface HeroProps { heading: string; @@ -8,7 +10,9 @@ export interface HeroProps { text: string; link: string; }; - backgroundImage?: { + + //만약 사진이 없다면 null을 입력 해야합니다. + image?: { url: string; alt?: string; }; @@ -30,8 +34,12 @@ export interface HeadingProps { // 클릭 이벤트도 들어가야 하는거 아닌가? export interface ButtonProps { text: string; - link: string; - target?: "_blank" | "_self"; + action?: NodeAction; +} + +export interface TextProps { + text: string; + level: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; } // 5. Container 컴포넌트 Props (보통 비어있거나 ID 정도만 가짐) @@ -39,3 +47,14 @@ export interface ContainerProps { id?: string; tagName?: "div" | "section" | "article"; } + +export interface ModalProps { + //모달의 제목 + title?: string; + + //닫기 버튼 렌더링 여부 + showCloseButton?: boolean; + + //백그라운드 클릭시 닫기 여부 + closeOnOverlayClick?: boolean; +} diff --git a/packages/ui/src/types/nodeAction.ts b/packages/ui/src/types/nodeAction.ts new file mode 100644 index 0000000..f8ed6e3 --- /dev/null +++ b/packages/ui/src/types/nodeAction.ts @@ -0,0 +1,18 @@ +export type ActionType = + | "link" // 5. 링크 이동 + | "scroll" // 3. 스크롤 이동 + | "alert" // 2. 알림창 + | "modal" // 4. 모달 열기 + | "mutation"; // 1. 데이터 변경 (핵심!) + +export interface NodeAction { + type: ActionType; + + // 공통 페이로드 (링크 URL, 알림 메시지, 타겟 ID 등) + payload?: string; + + // mutation용 설정 + targetId?: string; // 변경할 노드의 ID (ex: "text-node-123") + mutationType?: "increment" | "setText" | "toggle"; // 구체적인 변경 방식 + value?: any; // 변경할 값 +} diff --git a/packages/ui/src/types/nodes.ts b/packages/ui/src/types/nodes.ts index 2963d09..88d8e6b 100644 --- a/packages/ui/src/types/nodes.ts +++ b/packages/ui/src/types/nodes.ts @@ -5,13 +5,22 @@ import { NodeStyle } from "./styles"; //노드의 타입 별로 달라지는 node타입을 선언 // 1. 공통 필드 (모든 노드가 무조건 가지는 것) -interface BaseNode { +export interface BaseNode { id: string; //UUID page_id: string | number; // 소속 페이지 parent_id: string | null; // 부모 노드 position: number; // 정렬 순서 style: NodeStyle; // 스타일 (공통) created_at?: string; + + //wrapper에서 사용될 데이터 + layout: { + x: number; + y: number; + width: number; // 혹은 string ('100%') + height: number; // 혹은 string ('auto') + zIndex: number; + }; } //type에 따라 props가 동적으로 정해져서 모두 다르게 타입 선언 해야함. @@ -30,6 +39,10 @@ export interface HeadingNode extends BaseNode { type: "Heading"; props: componentProps.HeadingProps; } +export interface TextNode extends BaseNode { + type: "Text"; + props: componentProps.TextProps; +} export interface ButtonNode extends BaseNode { type: "Button"; @@ -41,6 +54,11 @@ export interface ContainerNode extends BaseNode { props: componentProps.ContainerProps; } +export interface ModalNode extends BaseNode { + type: "Modal"; + props: componentProps.ModalProps; +} + // 3. 통합 노드 타입 // 이제 WcxNode 타입을 쓰면 type 체크 시 props가 자동 추론. export type WcxNode = @@ -48,4 +66,6 @@ export type WcxNode = | ImageNode | HeadingNode | ButtonNode - | ContainerNode; + | ContainerNode + | TextNode + | ModalNode; diff --git a/packages/ui/src/types/styles.ts b/packages/ui/src/types/styles.ts index 682f53e..173d883 100644 --- a/packages/ui/src/types/styles.ts +++ b/packages/ui/src/types/styles.ts @@ -1,13 +1,17 @@ import { CSSProperties } from "react"; //노드의 스타일 타입은 반드시 카테고리 별로 구분되어 저장되야 한다. -export interface NodeStyle { - layout?: CSSProperties; //전체적인 레이아웃 - dimensions?: CSSProperties; //노드의 크기 - typography?: CSSProperties; //폰트 관련 CSS - background?: CSSProperties; //배경 - effects?: CSSProperties; // boxShadow, opacity, borderRadius 등 +//최소 단위. 하나의 요소에 적용될 스타일 그룹 +type ElementStyleKey = + | `layout` //전체적인 레이아웃 + | "dimensions" //노드의 크기 + | "typography" //폰트 관련 CSS + | "background" //배경 + | "effects"; // boxShadow, opacity, borderRadius 등 +export interface ElementStyle + //FIXME - root 스타일은 크기(dimensions),레이아웃(layout)을 제외한 나머지(배경, 테두리, 패딩)만 담당하도록 수정해야한다. + extends Partial> { // 2. HTML 클래스 (Tailwind 유틸리티 등) -> 추후 바이브 코딩의 결과물을 받았을때 사용될 속성입니다. className?: string; @@ -15,3 +19,17 @@ export interface NodeStyle { // 나중에 'animation'이나 'hover' 같은 그룹이 추가 예정 [key: string]: CSSProperties | string | undefined; } + +export type NodeStyleKey = + | "root" //필수: 최상위 컨테이너 -> 항상 최상위 컨테이너 스타일은 root로 지정 + | "button" // 옵션: 버튼 + | "heading" // 옵션: 제목 + | "subHeading" // 옵션: 부제목 + | "image" // 옵션: 이미지 + | "text"; // 옵션: 텍스트 + +// 2. 컴포넌트 전체 스타일: 부위별(Key)로 ElementStyle을 가짐 + +export interface NodeStyle extends Partial> { + [key: string]: ElementStyle | undefined; +} diff --git a/packages/ui/src/utils/applyStyles.ts b/packages/ui/src/utils/applyStyles.ts index 7f750df..b4d9c0c 100644 --- a/packages/ui/src/utils/applyStyles.ts +++ b/packages/ui/src/utils/applyStyles.ts @@ -1,7 +1,7 @@ //참고 자료->https://www.notion.so/Object-assign-2b175c9287fa8077b766de146b87a7e2?source=copy_link -//추후 성능 개선 필요(메모이제이션) +//TODO-추후 성능 개선 필요(메모이제이션) import { CSSProperties } from "react"; -import { NodeStyle } from "../types/styles"; +import { ElementStyle } from "../types/styles"; /** * @@ -12,13 +12,14 @@ import { NodeStyle } from "../types/styles"; * */ export default function applyStyles( - styleData: NodeStyle, + styleData: ElementStyle, ): CSSProperties | undefined { if (!styleData) return; const combinedStyles = {}; for (const key in styleData) { //카테고리별로 중첩된 스타일 데이터를 평탄화 시킴. + //⭐️ className은 평탄화 작업에서 안전하게 제외합니다. if (key !== "className" && typeof styleData[key] === "object") { Object.assign(combinedStyles, styleData[key]); //스프레드 연산자 오버헤드 위험 diff --git a/packages/ui/src/utils/processNodeStyles.ts b/packages/ui/src/utils/processNodeStyles.ts new file mode 100644 index 0000000..b252d2c --- /dev/null +++ b/packages/ui/src/utils/processNodeStyles.ts @@ -0,0 +1,23 @@ +import { CSSProperties } from "react"; +import { NodeStyle, NodeStyleKey } from "types"; +import applyStyles from "./applyStyles"; + +/** + * + * @param style 평탄화 되지 않은 노드의 스타일 객체 + * @returns 노드의 하위 요소들에 대한 스타일이 css객체로 변환된 객체 + * + * 상위 노드객체를 받아서 각 키(노드의 내부 요소들)에 해당하는 스타일을 적용하고 CSSProperties 객체로 변환하는 역할을 수행. + * 최종적으로 각 하위 요소별 스타일이 CSSProperties 객체로 매핑된 객체를 반환. + * 이때 하위 요소별 스타일객체는 평탄화 되어있습니다. + */ +export default function processNodeStyles(style: NodeStyle) { + const nodeStyleObj: Partial> = {}; + Object.entries(style).forEach(([key, value]) => { + if (!value) return; + const styleKey = key as NodeStyleKey; + nodeStyleObj[styleKey] = applyStyles(value); + }); + + return nodeStyleObj; +}