import { createStore } from "zustand"; import { create } from "zustand"; // 블록 타입 정의: 텍스트/버튼/이미지/섹션/구분선/리스트 블록 export type BlockType = "text" | "button" | "image" | "section" | "divider" | "list"; // 텍스트 블록 속성 export interface TextBlockProps { text: string; // 텍스트 정렬: 기본은 left align: "left" | "center" | "right"; // 텍스트 크기: 기본은 base size: "sm" | "base" | "lg"; } // 버튼 블록 속성 export interface ButtonBlockProps { label: string; href: string; // TODO: variant, size 등은 추후 확장 } // 이미지 블록 속성 export interface ImageBlockProps { src: string; alt: string; } // 구분선 블록 속성 export interface DividerBlockProps { align: "left" | "center" | "right"; thickness: "thin" | "medium"; } // 리스트 블록 속성 export interface ListBlockProps { items: string[]; ordered: boolean; align: "left" | "center" | "right"; } // 섹션 블록 속성 export interface SectionBlockProps { background: "default" | "muted" | "primary"; paddingY: "sm" | "md" | "lg"; // 레이아웃 컬럼 정의 (12 그리드 기준 span) columns: Array<{ id: string; span: number; }>; } // 공통 블록 모델 export interface Block { id: string; type: BlockType; props: | TextBlockProps | ButtonBlockProps | ImageBlockProps | SectionBlockProps | DividerBlockProps | ListBlockProps; // 레이아웃 트리 상 위치 정보 (루트 텍스트/버튼/이미지 블록은 null) sectionId?: string | null; columnId?: string | null; } // 에디터 상태 인터페이스 export interface EditorState { blocks: Block[]; selectedBlockId: string | null; history: Block[][]; future: Block[][]; addTextBlock: () => void; addButtonBlock: () => void; addImageBlock: () => void; addDividerBlock: () => void; addListBlock: () => void; addSectionBlock: () => void; addHeroTemplateSection: () => void; addFeaturesTemplateSection: () => void; addCtaTemplateSection: () => void; addFaqTemplateSection: () => void; addPricingTemplateSection: () => void; addTestimonialsTemplateSection: () => void; addBlogTemplateSection: () => void; addTeamTemplateSection: () => void; addFooterTemplateSection: () => void; updateBlock: (id: string, partial: Partial) => void; selectBlock: (id: string | null) => void; replaceBlocks: (blocks: Block[]) => void; reorderBlocks: (activeId: string, overId: string) => void; moveBlock: (id: string, sectionId: string | null, columnId: string | null) => void; undo: () => void; redo: () => void; removeBlock: (id: string) => void; duplicateBlock: (id: string) => void; } // 간단한 ID 생성기 (추후 uuid 라이브러리로 교체 가능) let idCounter = 0; const createId = () => `blk_${Date.now()}_${idCounter++}`; // 에디터 스토어 생성 함수 (테스트 및 앱에서 공유 사용) // set/get 은 zustand 내부 구현에 의해 주입되므로, 여기서는 any 로 완화해 사용한다. const createEditorState = (set: any, get: any): EditorState => ({ blocks: [], selectedBlockId: null, history: [], future: [], // 텍스트 블록 추가: 기본 텍스트와 함께 생성 후 선택 상태로 만든다 addTextBlock: () => { const id = createId(); const { selectedBlockId, blocks } = get(); let sectionId: string | null = null; let columnId: string | null = null; if (selectedBlockId) { const target = blocks.find((b: Block) => b.id === selectedBlockId); if (target) { if (target.type === "section") { const sProps = target.props as SectionBlockProps; sectionId = target.id; columnId = sProps.columns?.[0]?.id ?? null; } else { sectionId = (target as any).sectionId ?? null; columnId = (target as any).columnId ?? null; } } } const newBlock: Block = { id, type: "text", props: { text: "새 텍스트", align: "left", size: "base", }, sectionId, columnId, }; set((state: EditorState) => ({ blocks: [...state.blocks, newBlock], selectedBlockId: id, history: [...state.history, blocks], future: [], })); }, // Hero 템플릿 섹션 추가: 섹션 1개와 기본 텍스트/버튼 블록들을 첫 컬럼에 배치한다 addHeroTemplateSection: () => { const sectionId = createId(); // Hero 템플릿용 섹션: 기본 배경/패딩 + 1컬럼(12) const sectionBlock: Block = { id: sectionId, type: "section", props: { background: "default", paddingY: "lg", columns: [ { id: `${sectionId}_col_1`, span: 12, }, ], }, sectionId: null, columnId: null, }; const firstColumnId = `${sectionId}_col_1`; // Hero 텍스트 블록 (예: 헤드라인) const heroHeadlineId = createId(); const heroHeadline: Block = { id: heroHeadlineId, type: "text", props: { text: "Hero 제목을 여기에 입력하세요", align: "center", size: "lg", }, sectionId, columnId: firstColumnId, }; // Hero 서브텍스트 블록 const heroSubId = createId(); const heroSub: Block = { id: heroSubId, type: "text", props: { text: "제품이나 서비스를 한 문장으로 설명하는 서브텍스트입니다.", align: "center", size: "base", }, sectionId, columnId: firstColumnId, }; // CTA 버튼 블록 const heroButtonId = createId(); const heroButton: Block = { id: heroButtonId, type: "button", props: { label: "지금 시작하기", href: "#", }, sectionId, columnId: firstColumnId, }; set((state: EditorState) => { const newBlocks = [...state.blocks, sectionBlock, heroHeadline, heroSub, heroButton]; return { blocks: newBlocks, selectedBlockId: heroButtonId, }; }); }, // Features 템플릿 섹션 추가: 3컬럼(4/4/4) 섹션과 각 컬럼의 제목/설명 텍스트를 생성한다 addFeaturesTemplateSection: () => { const sectionId = createId(); const columns = [ { id: `${sectionId}_col_1`, span: 4 }, { id: `${sectionId}_col_2`, span: 4 }, { id: `${sectionId}_col_3`, span: 4 }, ]; const sectionBlock: Block = { id: sectionId, type: "section", props: { background: "muted", paddingY: "md", columns, }, sectionId: null, columnId: null, }; const featureBlocks: Block[] = columns.flatMap((col, index) => { const titleId = createId(); const descId = createId(); const title: Block = { id: titleId, type: "text", props: { text: `Feature ${index + 1} 제목`, align: "left", size: "lg", }, sectionId, columnId: col.id, }; const description: Block = { id: descId, type: "text", props: { text: "해당 기능을 간단히 설명하는 텍스트입니다.", align: "left", size: "sm", }, sectionId, columnId: col.id, }; return [title, description]; }); const lastBlockId = featureBlocks[featureBlocks.length - 1].id; set((state: EditorState) => { const newBlocks = [...state.blocks, sectionBlock, ...featureBlocks]; return { blocks: newBlocks, selectedBlockId: lastBlockId, }; }); }, // Blog 템플릿 섹션 추가: 3컬럼 포스트 카드(제목/요약)를 생성한다 addBlogTemplateSection: () => { const sectionId = createId(); const columns = [ { id: `${sectionId}_col_1`, span: 4 }, { id: `${sectionId}_col_2`, span: 4 }, { id: `${sectionId}_col_3`, span: 4 }, ]; const sectionBlock: Block = { id: sectionId, type: "section", props: { background: "default", paddingY: "lg", columns, }, sectionId: null, columnId: null, }; const postDefinitions = [ { title: "블로그 포스트 1", summary: "첫 번째 포스트 요약을 여기에 입력하세요." }, { title: "블로그 포스트 2", summary: "두 번째 포스트 요약을 여기에 입력하세요." }, { title: "블로그 포스트 3", summary: "세 번째 포스트 요약을 여기에 입력하세요." }, ]; const blogBlocks: Block[] = columns.flatMap((col, index) => { const post = postDefinitions[index] ?? postDefinitions[0]; const titleId = createId(); const summaryId = createId(); const titleBlock: Block = { id: titleId, type: "text", props: { text: post.title, align: "left", size: "lg", }, sectionId, columnId: col.id, }; const summaryBlock: Block = { id: summaryId, type: "text", props: { text: post.summary, align: "left", size: "sm", }, sectionId, columnId: col.id, }; return [titleBlock, summaryBlock]; }); const lastBlockId = blogBlocks[blogBlocks.length - 1].id; set((state: EditorState) => { const newBlocks = [...state.blocks, sectionBlock, ...blogBlocks]; return { blocks: newBlocks, selectedBlockId: lastBlockId, }; }); }, // Team 템플릿 섹션 추가: 3컬럼 팀 카드(이름/역할/소개)를 생성한다 addTeamTemplateSection: () => { const sectionId = createId(); const columns = [ { id: `${sectionId}_col_1`, span: 4 }, { id: `${sectionId}_col_2`, span: 4 }, { id: `${sectionId}_col_3`, span: 4 }, ]; const sectionBlock: Block = { id: sectionId, type: "section", props: { background: "muted", paddingY: "lg", columns, }, sectionId: null, columnId: null, }; const memberDefinitions = [ { name: "홍길동", role: "Product Designer", bio: "사용자 경험을 설계합니다." }, { name: "김영희", role: "Frontend Engineer", bio: "깔끔한 UI를 구현합니다." }, { name: "이철수", role: "Backend Engineer", bio: "안정적인 인프라를 책임집니다." }, ]; const teamBlocks: Block[] = columns.flatMap((col, index) => { const m = memberDefinitions[index] ?? memberDefinitions[0]; const nameId = createId(); const roleId = createId(); const bioId = createId(); const nameBlock: Block = { id: nameId, type: "text", props: { text: m.name, align: "center", size: "lg", }, sectionId, columnId: col.id, }; const roleBlock: Block = { id: roleId, type: "text", props: { text: m.role, align: "center", size: "base", }, sectionId, columnId: col.id, }; const bioBlock: Block = { id: bioId, type: "text", props: { text: m.bio, align: "center", size: "sm", }, sectionId, columnId: col.id, }; return [nameBlock, roleBlock, bioBlock]; }); const lastBlockId = teamBlocks[teamBlocks.length - 1].id; set((state: EditorState) => { const newBlocks = [...state.blocks, sectionBlock, ...teamBlocks]; return { blocks: newBlocks, selectedBlockId: lastBlockId, }; }); }, // Footer 템플릿 섹션 추가: 링크/카피라이트 텍스트가 포함된 1컬럼 섹션을 생성한다 addFooterTemplateSection: () => { const sectionId = createId(); const sectionBlock: Block = { id: sectionId, type: "section", props: { background: "muted", paddingY: "md", columns: [ { id: `${sectionId}_col_1`, span: 12, }, ], }, sectionId: null, columnId: null, }; const firstColumnId = `${sectionId}_col_1`; const linksId = createId(); const copyrightId = createId(); const linksBlock: Block = { id: linksId, type: "text", props: { text: "이용약관 · 개인정보처리방침", align: "center", size: "sm", }, sectionId, columnId: firstColumnId, }; const copyrightBlock: Block = { id: copyrightId, type: "text", props: { text: "© 2025 MyLanding. All rights reserved.", align: "center", size: "sm", }, sectionId, columnId: firstColumnId, }; set((state: EditorState) => { const newBlocks = [...state.blocks, sectionBlock, linksBlock, copyrightBlock]; return { blocks: newBlocks, selectedBlockId: copyrightId, }; }); }, // CTA 템플릿 섹션 추가: 텍스트와 버튼이 포함된 1컬럼 섹션을 생성한다 addCtaTemplateSection: () => { const sectionId = createId(); const sectionBlock: Block = { id: sectionId, type: "section", props: { background: "primary", paddingY: "md", columns: [ { id: `${sectionId}_col_1`, span: 12, }, ], }, sectionId: null, columnId: null, }; const firstColumnId = `${sectionId}_col_1`; const textId = createId(); const textBlock: Block = { id: textId, type: "text", props: { text: "지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.", align: "center", size: "base", }, sectionId, columnId: firstColumnId, }; const buttonId = createId(); const buttonBlock: Block = { id: buttonId, type: "button", props: { label: "CTA 버튼", href: "#", }, sectionId, columnId: firstColumnId, }; set((state: EditorState) => { const newBlocks = [...state.blocks, sectionBlock, textBlock, buttonBlock]; return { blocks: newBlocks, selectedBlockId: buttonId, }; }); }, // FAQ 템플릿 섹션 추가: 질문/답변 쌍이 수직으로 나열된 섹션을 생성한다 addFaqTemplateSection: () => { const sectionId = createId(); const sectionBlock: Block = { id: sectionId, type: "section", props: { background: "default", paddingY: "md", columns: [ { id: `${sectionId}_col_1`, span: 12, }, ], }, sectionId: null, columnId: null, }; const firstColumnId = `${sectionId}_col_1`; const faqPairs = [ { question: "자주 묻는 질문 1", answer: "첫 번째 질문에 대한 답변을 여기에 입력하세요.", }, { question: "자주 묻는 질문 2", answer: "두 번째 질문에 대한 답변을 여기에 입력하세요.", }, { question: "자주 묻는 질문 3", answer: "세 번째 질문에 대한 답변을 여기에 입력하세요.", }, ]; const faqBlocks: Block[] = faqPairs.flatMap((pair) => { const qId = createId(); const aId = createId(); const questionBlock: Block = { id: qId, type: "text", props: { text: pair.question, align: "left", size: "lg", }, sectionId, columnId: firstColumnId, }; const answerBlock: Block = { id: aId, type: "text", props: { text: pair.answer, align: "left", size: "sm", }, sectionId, columnId: firstColumnId, }; return [questionBlock, answerBlock]; }); const lastBlockId = faqBlocks[faqBlocks.length - 1].id; set((state: EditorState) => { const newBlocks = [...state.blocks, sectionBlock, ...faqBlocks]; return { blocks: newBlocks, selectedBlockId: lastBlockId, }; }); }, // Pricing 템플릿 섹션 추가: 3컬럼 요금제 카드(플랜 이름/가격/설명)를 생성한다 addPricingTemplateSection: () => { const sectionId = createId(); const columns = [ { id: `${sectionId}_col_1`, span: 4 }, { id: `${sectionId}_col_2`, span: 4 }, { id: `${sectionId}_col_3`, span: 4 }, ]; const sectionBlock: Block = { id: sectionId, type: "section", props: { background: "muted", paddingY: "lg", columns, }, sectionId: null, columnId: null, }; const planDefinitions = [ { name: "Basic", price: "₩9,900/월" }, { name: "Pro", price: "₩29,900/월" }, { name: "Enterprise", price: "문의" }, ]; const pricingBlocks: Block[] = columns.flatMap((col, index) => { const plan = planDefinitions[index] ?? planDefinitions[0]; const nameId = createId(); const priceId = createId(); const nameBlock: Block = { id: nameId, type: "text", props: { text: plan.name, align: "center", size: "lg", }, sectionId, columnId: col.id, }; const priceBlock: Block = { id: priceId, type: "text", props: { text: plan.price, align: "center", size: "base", }, sectionId, columnId: col.id, }; return [nameBlock, priceBlock]; }); const lastBlockId = pricingBlocks[pricingBlocks.length - 1].id; set((state: EditorState) => { const newBlocks = [...state.blocks, sectionBlock, ...pricingBlocks]; return { blocks: newBlocks, selectedBlockId: lastBlockId, }; }); }, // Testimonials 템플릿 섹션 추가: 3컬럼 후기 카드(본문/작성자)를 생성한다 addTestimonialsTemplateSection: () => { const sectionId = createId(); const columns = [ { id: `${sectionId}_col_1`, span: 4 }, { id: `${sectionId}_col_2`, span: 4 }, { id: `${sectionId}_col_3`, span: 4 }, ]; const sectionBlock: Block = { id: sectionId, type: "section", props: { background: "default", paddingY: "lg", columns, }, sectionId: null, columnId: null, }; const testimonialDefinitions = [ { body: "이 서비스 덕분에 랜딩 페이지 제작 시간이 크게 줄었습니다.", author: "홍길동" }, { body: "디자인을 잘 못해도 깔끔한 페이지를 만들 수 있어요.", author: "김영희" }, { body: "팀 전체가 만족하는 빌더입니다.", author: "이철수" }, ]; const testimonialBlocks: Block[] = columns.flatMap((col, index) => { const t = testimonialDefinitions[index] ?? testimonialDefinitions[0]; const bodyId = createId(); const authorId = createId(); const bodyBlock: Block = { id: bodyId, type: "text", props: { text: t.body, align: "left", size: "base", }, sectionId, columnId: col.id, }; const authorBlock: Block = { id: authorId, type: "text", props: { text: `- ${t.author}`, align: "left", size: "sm", }, sectionId, columnId: col.id, }; return [bodyBlock, authorBlock]; }); const lastBlockId = testimonialBlocks[testimonialBlocks.length - 1].id; set((state: EditorState) => { const newBlocks = [...state.blocks, sectionBlock, ...testimonialBlocks]; return { blocks: newBlocks, selectedBlockId: lastBlockId, }; }); }, // 이미지 블록 추가: 기본 플레이스홀더와 함께 생성 후 선택 상태로 만든다 addImageBlock: () => { const id = createId(); const { selectedBlockId, blocks } = get(); let sectionId: string | null = null; let columnId: string | null = null; if (selectedBlockId) { const target = blocks.find((b: Block) => b.id === selectedBlockId); if (target) { if (target.type === "section") { const sProps = target.props as SectionBlockProps; sectionId = target.id; columnId = sProps.columns?.[0]?.id ?? null; } else { sectionId = (target as any).sectionId ?? null; columnId = (target as any).columnId ?? null; } } } const newBlock: Block = { id, type: "image", props: { src: "", alt: "이미지 설명", }, sectionId, columnId, }; set((state: EditorState) => ({ blocks: [...state.blocks, newBlock], selectedBlockId: id, })); }, // 구분선 블록 추가: 기본 정렬/두께와 함께 생성 후 선택 상태로 만든다 addDividerBlock: () => { const id = createId(); const { selectedBlockId, blocks } = get(); let sectionId: string | null = null; let columnId: string | null = null; if (selectedBlockId) { const target = blocks.find((b: Block) => b.id === selectedBlockId); if (target) { if (target.type === "section") { const sProps = target.props as SectionBlockProps; sectionId = target.id; columnId = sProps.columns?.[0]?.id ?? null; } else { sectionId = (target as any).sectionId ?? null; columnId = (target as any).columnId ?? null; } } } const newBlock: Block = { id, type: "divider", props: { align: "center", thickness: "thin", }, sectionId, columnId, }; set((state: EditorState) => ({ blocks: [...state.blocks, newBlock], selectedBlockId: id, })); }, // 리스트 블록 추가: 기본 항목/정렬과 함께 생성 후 선택 상태로 만든다 addListBlock: () => { const id = createId(); const { selectedBlockId, blocks } = get(); let sectionId: string | null = null; let columnId: string | null = null; if (selectedBlockId) { const target = blocks.find((b: Block) => b.id === selectedBlockId); if (target) { if (target.type === "section") { const sProps = target.props as SectionBlockProps; sectionId = target.id; columnId = sProps.columns?.[0]?.id ?? null; } else { sectionId = (target as any).sectionId ?? null; columnId = (target as any).columnId ?? null; } } } const newBlock: Block = { id, type: "list", props: { items: ["리스트 아이템 1"], ordered: false, align: "left", }, sectionId, columnId, }; set((state: EditorState) => ({ blocks: [...state.blocks, newBlock], selectedBlockId: id, })); }, // 섹션 블록 추가: 배경/패딩 기본값과 함께 생성 후 선택 상태로 만든다 addSectionBlock: () => { const id = createId(); const newBlock: Block = { id, type: "section", props: { background: "default", paddingY: "md", columns: [ { id: `${id}_col_1`, span: 12, }, ], }, sectionId: null, columnId: null, }; set((state: EditorState) => ({ blocks: [...state.blocks, newBlock], selectedBlockId: id, })); }, // 버튼 블록 추가: 기본 라벨/링크와 함께 생성 후 선택 상태로 만든다 addButtonBlock: () => { const id = createId(); const { selectedBlockId, blocks } = get(); let sectionId: string | null = null; let columnId: string | null = null; if (selectedBlockId) { const target = blocks.find((b: Block) => b.id === selectedBlockId); if (target) { if (target.type === "section") { const sProps = target.props as SectionBlockProps; sectionId = target.id; columnId = sProps.columns?.[0]?.id ?? null; } else { sectionId = (target as any).sectionId ?? null; columnId = (target as any).columnId ?? null; } } } const newBlock: Block = { id, type: "button", props: { label: "버튼", href: "#", }, sectionId, columnId, }; set((state: EditorState) => ({ blocks: [...state.blocks, newBlock], selectedBlockId: id, })); }, // 특정 블록의 속성을 부분 업데이트 (텍스트/버튼 공통 사용) updateBlock: (id, partial) => { set((state: EditorState) => ({ blocks: state.blocks.map((block: Block) => block.id === id ? { ...block, props: { ...block.props, ...partial }, } : block, ), })); }, // 선택된 블록 ID를 변경 selectBlock: (id) => { const { blocks } = get(); // 존재하지 않는 ID를 선택하려고 할 경우, 그냥 null 로 초기화 if (id && !blocks.some((b: Block) => b.id === id)) { set({ selectedBlockId: null }); return; } set({ selectedBlockId: id }); }, replaceBlocks: (blocks) => { set({ blocks, selectedBlockId: blocks.length > 0 ? blocks[0].id : null, }); }, reorderBlocks: (activeId, overId) => { set((state: EditorState) => { const current = state.blocks; const oldIndex = current.findIndex((b: Block) => b.id === activeId); const newIndex = current.findIndex((b: Block) => b.id === overId); if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) { return { blocks: current }; } const updated = [...current]; const [moved] = updated.splice(oldIndex, 1); updated.splice(newIndex, 0, moved); return { blocks: updated }; }); }, moveBlock: (id, sectionId, columnId) => { set((state: EditorState) => ({ blocks: state.blocks.map((block: Block) => block.id === id ? { ...block, sectionId, columnId, } : block, ), })); }, removeBlock: (id) => { set((state: EditorState) => { const current = state.blocks; const index = current.findIndex((b) => b.id === id); if (index === -1) { return state; } const nextBlocks = current.filter((b) => b.id !== id); // 선택 상태는 삭제된 블록의 이전 블록 또는 다음 블록으로 이동한다. let nextSelected: string | null = null; if (nextBlocks.length > 0) { const prevIndex = index - 1; if (prevIndex >= 0) { nextSelected = nextBlocks[prevIndex].id; } else { // 첫 블록이 삭제된 경우, 새 첫 블록을 선택한다. nextSelected = nextBlocks[0].id; } } return { ...state, blocks: nextBlocks, selectedBlockId: nextSelected, }; }); }, duplicateBlock: (id) => { set((state: EditorState) => { const current = state.blocks; const index = current.findIndex((b) => b.id === id); if (index === -1) { return state; } const original = current[index]; const newId = createId(); const cloned: Block = { ...original, id: newId, // props 는 얕은 복사로 충분 (현재 props 는 모두 평면 구조) props: { ...(original.props as any) }, }; const nextBlocks = [...current]; nextBlocks.splice(index + 1, 0, cloned); return { ...state, blocks: nextBlocks, selectedBlockId: newId, }; }); }, undo: () => { const { history, blocks } = get(); if (history.length === 0) return; const previous = history[history.length - 1]; const nextHistory = history.slice(0, -1); set((state: EditorState) => ({ blocks: previous, selectedBlockId: previous.length > 0 ? previous[previous.length - 1].id : null, history: nextHistory, future: [...state.future, blocks], })); }, redo: () => { const { future, blocks } = get(); if (future.length === 0) return; const next = future[future.length - 1]; const nextFuture = future.slice(0, -1); set((state: EditorState) => ({ blocks: next, selectedBlockId: next.length > 0 ? next[next.length - 1].id : null, history: [...state.history, blocks], future: nextFuture, })); }, }); // React 컴포넌트에서 사용하는 전역 훅 스토어 export const useEditorStore = create()(createEditorState); // 테스트 등에서 독립적인 스토어 인스턴스를 만들 때 사용하는 팩토리 export const createEditorStore = () => createStore(createEditorState);