import { createStore } from "zustand"; import { create } from "zustand"; import { createHeroTemplateBlocks } from "@/app/editor/templates/heroTemplate"; import { createFeaturesTemplateBlocks } from "@/app/editor/templates/featuresTemplate"; import { createBlogTemplateBlocks } from "@/app/editor/templates/blogTemplate"; import { createTeamTemplateBlocks } from "@/app/editor/templates/teamTemplate"; import { createFooterTemplateBlocks } from "@/app/editor/templates/footerTemplate"; import { createCtaTemplateBlocks } from "@/app/editor/templates/ctaTemplate"; import { createFaqTemplateBlocks } from "@/app/editor/templates/faqTemplate"; import { createPricingTemplateBlocks } from "@/app/editor/templates/pricingTemplate"; import { createTestimonialsTemplateBlocks } from "@/app/editor/templates/testimonialsTemplate"; // 블록 타입 정의: 텍스트/버튼/이미지/섹션/구분선/리스트/폼 블록/폼 요소 블록 export type BlockType = | "text" | "button" | "image" | "section" | "divider" | "list" | "form" | "formInput" | "formSelect" | "formCheckbox" | "formRadio"; // 텍스트 블록 속성 export interface TextBlockProps { text: string; // 텍스트 정렬: 기본은 left align: "left" | "center" | "right"; // 텍스트 크기: 기본은 base (기존 필드, 하위호환 유지) size: "sm" | "base" | "lg"; // 폰트 크기 모드: 프리셋 스케일 또는 커스텀 값 fontSizeMode?: "scale" | "custom"; // 폰트 크기 스케일 (scale 모드일 때 사용) fontSizeScale?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl" | "3xl"; // 폰트 크기 커스텀 값 (예: "18px", "1.125rem") fontSizeCustom?: string; // 줄 간격 모드: 프리셋 스케일 또는 커스텀 값 lineHeightMode?: "scale" | "custom"; // 줄 간격 스케일 lineHeightScale?: "tight" | "snug" | "normal" | "relaxed" | "loose"; // 줄 간격 커스텀 값 (예: "1.4", "24px") lineHeightCustom?: string; // 폰트 굵기 모드: 프리셋 스케일 또는 커스텀 값 fontWeightMode?: "scale" | "custom"; // 폰트 굵기 스케일 fontWeightScale?: "normal" | "medium" | "semibold" | "bold"; // 폰트 굵기 커스텀 값 (예: "500", "650") fontWeightCustom?: string; // 글자 간격 커스텀 값 (em 단위, 예: "0.05em", "-0.02em") letterSpacingCustom?: string; // 텍스트 색상 모드: 팔레트 또는 커스텀 웹색상 colorMode?: "palette" | "custom"; // 텍스트 색상 팔레트 (디자인 토큰) colorPalette?: | "default" | "muted" | "strong" | "accent" | "danger" | "success" | "warning" | "info" | "neutral"; // 커스텀 색상 값 (예: "#ff0000", "rgb(…)" 등) colorCustom?: string; // 최대 너비 모드: 프리셋 또는 커스텀 값 maxWidthMode?: "scale" | "custom"; // 최대 너비 스케일 maxWidthScale?: "none" | "prose" | "narrow"; // 최대 너비 커스텀 값 (예: "600px", "40rem") maxWidthCustom?: string; // 텍스트 장식 underline?: boolean; strike?: boolean; italic?: boolean; } // 버튼 블록 속성 export interface ButtonBlockProps { label: string; href: string; align?: "left" | "center" | "right"; // 버튼 크기: 더 세분화된 스케일(xs~xl) size?: "xs" | "sm" | "md" | "lg" | "xl"; variant?: "solid" | "outline" | "ghost"; colorPalette?: "primary" | "muted" | "danger" | "success" | "neutral"; fullWidth?: boolean; widthMode?: "auto" | "full" | "fixed"; widthPx?: number; borderRadius?: "none" | "sm" | "md" | "lg" | "full"; paddingX?: number; paddingY?: number; // 버튼 텍스트 크기 (예: "14px") fontSizeCustom?: string; // 버튼 텍스트 줄 간격 (예: "1.4") lineHeightCustom?: string; // 버튼 텍스트 글자 간격 (em 단위, 예: "0.05em") letterSpacingCustom?: string; // 채움(배경) 색상 커스텀 값 fillColorCustom?: string; // 외곽선(보더) 색상 커스텀 값 strokeColorCustom?: string; // 버튼 텍스트 색상 커스텀 값 textColorCustom?: string; } // 이미지 블록 속성 export interface ImageBlockProps { src: string; alt: string; // 이미지 정렬: 기본은 center align?: "left" | "center" | "right"; // 너비 모드: auto(콘텐츠에 맞춤) 또는 fixed(px 지정) widthMode?: "auto" | "fixed"; widthPx?: number; // 모서리 둥글기 borderRadius?: "none" | "sm" | "md" | "lg" | "full"; } // 구분선 블록 속성 export interface DividerBlockProps { align: "left" | "center" | "right"; thickness: "thin" | "medium"; // 길이/너비 widthMode?: "auto" | "full" | "fixed"; widthPx?: number; // 색상 colorHex?: string; // 상하 여백 (토큰, 호환용) marginY?: "sm" | "md" | "lg"; // 상하 여백(px) 숫자 값 marginYPx?: number; } // 리스트 아이템 트리(중첩 리스트) 노드 정의 export interface ListItemNode { id: string; text: string; children?: ListItemNode[]; } export interface ListLine { depth: number; text: string; } // 리스트 아이템 트리 조작 유틸 (pure helper) type ListTreeOpResult = { tree: ListItemNode[]; changed: boolean; }; const cloneNode = (node: ListItemNode): ListItemNode => ({ id: node.id, text: node.text, children: node.children ? node.children.map(cloneNode) : [], }); export const buildItemsTreeFromItems = (blockId: string, items: string[]): ListItemNode[] => items.map((text, index) => ({ id: `${blockId}_item_${index + 1}`, text, children: [], })); export const flattenItemsTreeToItems = (tree: ListItemNode[]): string[] => { const result: string[] = []; const walk = (nodes: ListItemNode[]) => { for (const node of nodes) { result.push(node.text); if (node.children && node.children.length > 0) { walk(node.children); } } }; walk(tree); return result; }; export const linesToItemsTree = (blockId: string, lines: ListLine[]): ListItemNode[] => { const roots: ListItemNode[] = []; const stack: ListItemNode[] = []; lines.forEach((line, index) => { const depth = Number.isFinite(line.depth) && line.depth > 0 ? Math.floor(line.depth) : 0; const node: ListItemNode = { id: `${blockId}_item_${index + 1}`, text: line.text, children: [], }; if (depth === 0 || stack.length === 0) { roots.push(node); stack.length = 1; stack[0] = node; return; } // depth 가 현재 스택 깊이보다 크면, 스택 마지막을 부모로 사용한다. const parentDepth = Math.min(depth - 1, stack.length - 1); const parent = stack[parentDepth]; if (!parent.children) { parent.children = []; } parent.children.push(node); // 현재 노드를 해당 depth 위치에 두고, 그 이후 스택은 잘라낸다. stack.length = parentDepth + 2; stack[parentDepth + 1] = node; }); return roots; }; export const itemsTreeToLines = (tree: ListItemNode[]): ListLine[] => { const lines: ListLine[] = []; const walk = (nodes: ListItemNode[], depth: number) => { for (const node of nodes) { lines.push({ depth, text: node.text }); if (node.children && node.children.length > 0) { walk(node.children, depth + 1); } } }; walk(tree, 0); return lines; }; export const parseTextareaToLines = (text: string): ListLine[] => { const rawLines = text.split(/\r?\n/); return rawLines.map((raw) => { let depth = 0; let i = 0; while (i < raw.length) { const ch = raw[i]; if (ch === "\t") { depth += 1; i += 1; } else if (ch === " ") { let spaceCount = 0; while (i < raw.length && raw[i] === " ") { spaceCount += 1; i += 1; } depth += Math.floor(spaceCount / 2); } else { break; } } const textWithoutIndent = raw.slice(i); return { depth, text: textWithoutIndent, }; }); }; export const stringifyLinesToTextarea = (lines: ListLine[]): string => { return lines .map((line) => { const indent = " ".repeat(Math.max(0, Math.floor(line.depth))); return `${indent}${line.text}`; }) .join("\n"); }; // 내부 재귀 유틸: siblings 배열과 targetId 를 받아 조작을 수행한다. const withSiblings = ( siblings: ListItemNode[], targetId: string, op: (args: { siblings: ListItemNode[]; index: number; parentPath: ListItemNode[]; }) => ListTreeOpResult, parentPath: ListItemNode[] = [], ): ListTreeOpResult => { for (let i = 0; i < siblings.length; i += 1) { const node = siblings[i]; if (node.id === targetId) { return op({ siblings, index: i, parentPath }); } if (node.children && node.children.length > 0) { const childResult = withSiblings(node.children, targetId, op, [...parentPath, node]); if (childResult.changed) { const cloned = siblings.map(cloneNode); const idx = cloned.findIndex((n) => n.id === node.id); if (idx >= 0) { cloned[idx].children = childResult.tree; } return { tree: cloned, changed: true }; } } } return { tree: siblings, changed: false }; }; export const indentListItem = (tree: ListItemNode[], targetId: string): ListItemNode[] => { const { tree: next } = withSiblings( tree, targetId, ({ siblings, index }) => { // 첫 번째 아이템은 들여쓰기 불가 if (index === 0) { return { tree: siblings, changed: false }; } const prev = siblings[index - 1]; const target = siblings[index]; const newPrev: ListItemNode = cloneNode(prev); const newTarget: ListItemNode = cloneNode(target); const newSiblings = siblings.map(cloneNode); // siblings 에서 target 제거 newSiblings.splice(index, 1); // prev 위치의 노드를 newPrev 로 교체 const prevIndex = index - 1; const prevNode = newSiblings[prevIndex]; const prevChildren = prevNode.children ? [...prevNode.children] : []; prevChildren.push(newTarget); newSiblings[prevIndex] = { ...newPrev, children: prevChildren }; return { tree: newSiblings, changed: true }; }, ); return next; }; export const outdentListItem = (tree: ListItemNode[], targetId: string): ListItemNode[] => { // 부모의 children 에서 targetId 를 찾아 제거하고, // 부모가 속한 siblings 배열에서 부모 바로 다음 위치에 target 을 삽입한다. const liftFromChildren = (nodes: ListItemNode[]): ListTreeOpResult => { const clonedSiblings = nodes.map(cloneNode); for (let i = 0; i < clonedSiblings.length; i += 1) { const node = clonedSiblings[i]; const children = node.children ?? []; // 1단계: 현재 node 의 children 에 target 이 있는지 확인한다. const childIndex = children.findIndex((c) => c.id === targetId); if (childIndex >= 0) { const child = children[childIndex]; const newChildren = [...children]; newChildren.splice(childIndex, 1); // 부모의 children 에서 target 제거 clonedSiblings[i] = { ...node, children: newChildren, }; // 부모 바로 뒤에 target 을 siblings 레벨로 삽입 clonedSiblings.splice(i + 1, 0, cloneNode(child)); return { tree: clonedSiblings, changed: true }; } // 2단계: 더 깊은 레벨에서 검색한다. if (children.length > 0) { const childResult = liftFromChildren(children); if (childResult.changed) { clonedSiblings[i] = { ...node, children: childResult.tree, }; return { tree: clonedSiblings, changed: true }; } } } return { tree: nodes, changed: false }; }; const { tree: next } = liftFromChildren(tree); return next; }; export const moveListItemUp = (tree: ListItemNode[], targetId: string): ListItemNode[] => { const { tree: next } = withSiblings( tree, targetId, ({ siblings, index }) => { if (index === 0) { return { tree: siblings, changed: false }; } const cloned = siblings.map(cloneNode); const tmp = cloned[index - 1]; cloned[index - 1] = cloned[index]; cloned[index] = tmp; return { tree: cloned, changed: true }; }, ); return next; }; export const moveListItemDown = (tree: ListItemNode[], targetId: string): ListItemNode[] => { const { tree: next } = withSiblings( tree, targetId, ({ siblings, index }) => { if (index >= siblings.length - 1) { return { tree: siblings, changed: false }; } const cloned = siblings.map(cloneNode); const tmp = cloned[index + 1]; cloned[index + 1] = cloned[index]; cloned[index] = tmp; return { tree: cloned, changed: true }; }, ); return next; }; // 리스트 블록 속성 export interface ListBlockProps { items: string[]; // 1단계 하위호환용 단순 배열 itemsTree?: ListItemNode[]; // 중첩 리스트용 트리 구조 ordered: boolean; align: "left" | "center" | "right"; // 타이포/색상 fontSizeCustom?: string; lineHeightCustom?: string; textColorCustom?: string; // 불릿/간격 bulletStyle?: | "disc" | "circle" | "square" | "decimal" | "none" | "lower-alpha" | "upper-alpha" | "lower-roman" | "upper-roman"; // 토큰 기반 간격(호환용) gapY?: "sm" | "md" | "lg"; // 아이템 간 여백(px) 숫자 값 gapYPx?: number; } // 섹션 블록 속성 export interface SectionBlockProps { background: "default" | "muted" | "primary"; paddingY: "sm" | "md" | "lg"; // px 단위 세로 패딩(있으면 토큰 대신 우선 적용) paddingYPx?: number; // 레이아웃 컬럼 정의 (12 그리드 기준 span) columns: Array<{ id: string; span: number; }>; // 섹션 레이아웃 스타일 maxWidthMode?: "narrow" | "normal" | "wide"; // px 단위 최대 폭(있으면 토큰 기반 maxWidth 대신 우선 적용) maxWidthPx?: number; gapX?: "sm" | "md" | "lg"; // px 단위 컬럼 간 간격(있으면 토큰 기반 gap 대신 우선 적용) gapXPx?: number; alignItems?: "top" | "center" | "bottom"; backgroundColorCustom?: string; } // 폼 입력/셀렉트/체크박스/라디오 공통 스타일 속성 export interface FormFieldStyleProps { align?: "left" | "center" | "right"; size?: "xs" | "sm" | "md" | "lg" | "xl"; fullWidth?: boolean; // 필드 너비 및 레이아웃 widthMode?: "auto" | "full" | "fixed"; widthPx?: number; paddingX?: number; paddingY?: number; optionGapPx?: number; labelGapPx?: number; labelLayout?: "stacked" | "inline"; borderRadius?: "none" | "sm" | "md" | "lg" | "full"; // 필드 텍스트 색상/타이포그라피 fontSizeCustom?: string; lineHeightCustom?: string; letterSpacingCustom?: string; textColorCustom?: string; // 필드 배경/테두리 색상 fillColorCustom?: string; strokeColorCustom?: string; } // 폼 입력 블록 속성 export type FormLabelMode = "text" | "image"; export interface FormInputBlockProps extends FormFieldStyleProps { label: string; formFieldName: string; required?: boolean; inputType?: "text" | "email" | "textarea"; placeholder?: string; labelMode?: FormLabelMode; labelImageUrl?: string; labelImageAlt?: string; } // 폼 셀렉트 옵션/블록 속성 export interface FormSelectOption { label: string; value: string; } export interface FormSelectBlockProps extends FormFieldStyleProps { label: string; formFieldName: string; options: FormSelectOption[]; required?: boolean; labelMode?: FormLabelMode; labelImageUrl?: string; labelImageAlt?: string; } // 폼 체크박스 옵션/블록 속성 (여러 체크박스를 한 그룹으로 표현) export interface FormCheckboxOption { label: string; value: string; // 각 체크박스 옵션도 개별 이미지 라벨을 가질 수 있도록 확장한다. labelImageUrl?: string; } export interface FormCheckboxBlockProps extends FormFieldStyleProps { groupLabel: string; formFieldName: string; options: FormCheckboxOption[]; required?: boolean; // 그룹 타이틀 자체도 텍스트/이미지 모드를 가질 수 있도록 분리한다. groupLabelMode?: FormLabelMode; groupLabelImageUrl?: string; } // 폼 라디오 그룹 옵션/블록 속성 export interface FormRadioOption { label: string; value: string; // 각 라디오 옵션도 개별 이미지 라벨을 가질 수 있도록 확장한다. labelImageUrl?: string; } export interface FormRadioBlockProps extends FormFieldStyleProps { groupLabel: string; formFieldName: string; options: FormRadioOption[]; required?: boolean; // 라디오 그룹 타이틀의 텍스트/이미지 모드 groupLabelMode?: FormLabelMode; groupLabelImageUrl?: string; } // 폼 필드 설정 export interface FormFieldConfig { id: string; name: string; // 실제 전송 키 (예: "email") label: string; // UI 라벨 (예: "이메일") type: "text" | "email" | "textarea"; required?: boolean; } // 폼 블록 속성 export type FormSubmitTarget = "internal" | "webhook"; export type FormPayloadFormat = "form" | "json"; export interface FormBlockProps { kind: "contact"; // 전송 대상: internal 또는 webhook(외부 URL, Google Sheets 포함) submitTarget: FormSubmitTarget; // 성공/실패 메시지 successMessage?: string; errorMessage?: string; // webhook 설정 (Google Sheets 포함) destinationUrl?: string; // POST 보낼 URL method?: "POST" | "GET"; // 기본 POST // webhook 전송 포맷: 기본은 x-www-form-urlencoded(form). payloadFormat?: FormPayloadFormat; headers?: Record; // Authorization 등 extraParams?: Record; // 항상 함께 보내는 추가 파라미터(formId 등) // 폼 필드 목록 fields?: FormFieldConfig[]; // v2 컨트롤러: 개별 폼 요소 블록과 제출 버튼을 연결하기 위한 ID 목록 fieldIds?: string[]; submitButtonId?: string | null; // 폼 레이아웃 formWidthMode?: "auto" | "full" | "fixed"; formWidthPx?: number; marginYPx?: number; } // 공통 블록 모델 export interface Block { id: string; type: BlockType; props: | TextBlockProps | ButtonBlockProps | ImageBlockProps | SectionBlockProps | DividerBlockProps | ListBlockProps | FormBlockProps | FormInputBlockProps | FormSelectBlockProps | FormCheckboxBlockProps | FormRadioBlockProps; // 레이아웃 트리 상 위치 정보 (루트 텍스트/버튼/이미지 블록은 null) sectionId?: string | null; columnId?: string | null; } // 에디터 상태 인터페이스 export interface EditorState { blocks: Block[]; selectedBlockId: string | null; selectedListItemId?: string | null; history: Block[][]; future: Block[][]; addTextBlock: () => void; addButtonBlock: () => void; addImageBlock: () => void; addDividerBlock: () => void; addListBlock: () => void; addSectionBlock: () => void; addFormBlock: () => void; addFormInputBlock: () => void; addFormSelectBlock: () => void; addFormCheckboxBlock: () => void; addFormRadioBlock: () => void; addHeroTemplateSection: () => void; addFeaturesTemplateSection: () => void; addCtaTemplateSection: () => void; addFaqTemplateSection: () => void; addPricingTemplateSection: () => void; addTestimonialsTemplateSection: () => void; addBlogTemplateSection: () => void; addTeamTemplateSection: () => void; addFooterTemplateSection: () => void; selectListItem: (itemId: string | null) => void; indentSelectedListItem: (blockId: string) => void; outdentSelectedListItem: (blockId: string) => void; moveSelectedListItemUp: (blockId: string) => void; moveSelectedListItemDown: (blockId: string) => void; updateBlock: ( id: string, partial: | Partial | Partial | Partial | Partial | Partial | 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++}`; const getNextFormFieldName = (blocks: Block[], prefix: string): string => { const regex = new RegExp(`^${prefix}-(\\d+)$`); let max = 0; for (const b of blocks) { const anyProps: any = b.props; const name = anyProps?.formFieldName; if (typeof name !== "string") continue; const match = name.match(regex); if (!match) continue; const n = Number(match[1] ?? 0); if (!Number.isNaN(n) && n > max) { max = n; } } return `${prefix}-${max + 1}`; }; // 에디터 스토어 생성 함수 (테스트 및 앱에서 공유 사용) // set/get 은 zustand 내부 구현에 의해 주입되므로, 여기서는 any 로 완화해 사용한다. const createEditorState = (set: any, get: any): EditorState => ({ blocks: [], selectedBlockId: null, selectedListItemId: 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(); const { blocks: templateBlocks, lastSelectedId } = createHeroTemplateBlocks({ sectionId, createId, }); set((state: EditorState) => { const newBlocks = [...state.blocks, ...templateBlocks]; return { blocks: newBlocks, selectedBlockId: lastSelectedId, }; }); }, // 리스트 아이템 선택 상태를 업데이트한다. selectListItem: (itemId) => { set({ selectedListItemId: itemId ?? null }); }, // Features 템플릿 섹션 추가: 3컬럼(4/4/4) 섹션과 각 컬럼의 제목/설명 텍스트를 생성한다 addFeaturesTemplateSection: () => { const sectionId = createId(); const { blocks: templateBlocks, lastSelectedId } = createFeaturesTemplateBlocks({ sectionId, createId, }); set((state: EditorState) => { const newBlocks = [...state.blocks, ...templateBlocks]; return { blocks: newBlocks, selectedBlockId: lastSelectedId, }; }); }, // Blog 템플릿 섹션 추가: 3컬럼 포스트 카드(제목/요약)를 생성한다 addBlogTemplateSection: () => { const sectionId = createId(); const { blocks: templateBlocks, lastSelectedId } = createBlogTemplateBlocks({ sectionId, createId, }); set((state: EditorState) => { const newBlocks = [...state.blocks, ...templateBlocks]; return { blocks: newBlocks, selectedBlockId: lastSelectedId, }; }); }, // Team 템플릿 섹션 추가: 3컬럼 팀 카드(이름/역할/소개)를 생성한다 addTeamTemplateSection: () => { const sectionId = createId(); const { blocks: templateBlocks, lastSelectedId } = createTeamTemplateBlocks({ sectionId, createId, }); set((state: EditorState) => { const newBlocks = [...state.blocks, ...templateBlocks]; return { blocks: newBlocks, selectedBlockId: lastSelectedId, }; }); }, // Footer 템플릿 섹션 추가: 링크/카피라이트 텍스트가 포함된 1컬럼 섹션을 생성한다 addFooterTemplateSection: () => { const sectionId = createId(); const { blocks: templateBlocks, lastSelectedId } = createFooterTemplateBlocks({ sectionId, createId, }); set((state: EditorState) => { const newBlocks = [...state.blocks, ...templateBlocks]; return { blocks: newBlocks, selectedBlockId: lastSelectedId, }; }); }, // CTA 템플릿 섹션 추가: 텍스트와 버튼이 포함된 1컬럼 섹션을 생성한다 addCtaTemplateSection: () => { const sectionId = createId(); const { blocks: templateBlocks, lastSelectedId } = createCtaTemplateBlocks({ sectionId, createId, }); set((state: EditorState) => { const newBlocks = [...state.blocks, ...templateBlocks]; return { blocks: newBlocks, selectedBlockId: lastSelectedId, }; }); }, // FAQ 템플릿 섹션 추가: 질문/답변 쌍이 수직으로 나열된 섹션을 생성한다 addFaqTemplateSection: () => { const sectionId = createId(); const { blocks: templateBlocks, lastSelectedId } = createFaqTemplateBlocks({ sectionId, createId, }); set((state: EditorState) => { const newBlocks = [...state.blocks, ...templateBlocks]; return { blocks: newBlocks, selectedBlockId: lastSelectedId, }; }); }, // Pricing 템플릿 섹션 추가: 3컬럼 요금제 카드(플랜 이름/가격/설명)를 생성한다 addPricingTemplateSection: () => { const sectionId = createId(); const { blocks: templateBlocks, lastSelectedId } = createPricingTemplateBlocks({ sectionId, createId, }); set((state: EditorState) => { const newBlocks = [...state.blocks, ...templateBlocks]; return { blocks: newBlocks, selectedBlockId: lastSelectedId, }; }); }, // Testimonials 템플릿 섹션 추가: 3컬럼 후기 카드(본문/작성자)를 생성한다 addTestimonialsTemplateSection: () => { const sectionId = createId(); const { blocks: templateBlocks, lastSelectedId } = createTestimonialsTemplateBlocks({ sectionId, createId, }); set((state: EditorState) => { const newBlocks = [...state.blocks, ...templateBlocks]; return { blocks: newBlocks, selectedBlockId: lastSelectedId, }; }); }, // 이미지 블록 추가: 기본 플레이스홀더와 스타일 기본값과 함께 생성 후 선택 상태로 만든다 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: "이미지 설명", align: "center", widthMode: "auto", borderRadius: "md", }, sectionId, columnId, }; set((state: EditorState) => ({ blocks: [...state.blocks, newBlock], selectedBlockId: id, })); }, // 폼 입력 블록 추가: 루트에 기본 label/formFieldName 과 함께 생성 후 선택 상태로 만든다 addFormInputBlock: () => { const id = createId(); const { blocks } = get(); const label = "입력 필드"; const formFieldName = getNextFormFieldName(blocks, "text"); const newBlock: Block = { id, type: "formInput", props: { label, formFieldName, required: false, inputType: "text", placeholder: "", labelMode: "text", borderRadius: "md", fillColorCustom: "", strokeColorCustom: "", textColorCustom: "", widthMode: "full", paddingX: 12, paddingY: 10, optionGapPx: 4, labelLayout: "stacked", labelGapPx: 8, }, sectionId: null, columnId: null, }; set((state: EditorState) => ({ blocks: [...state.blocks, newBlock], selectedBlockId: id, })); }, // 폼 셀렉트 블록 추가: 루트에 기본 label/formFieldName/options 와 함께 생성 후 선택 상태로 만든다 addFormSelectBlock: () => { const id = createId(); const { blocks } = get(); const label = "선택 필드"; const formFieldName = getNextFormFieldName(blocks, "select"); const options: FormSelectOption[] = [ { label: "옵션 1", value: "option_1" }, { label: "옵션 2", value: "option_2" }, ]; const newBlock: Block = { id, type: "formSelect", props: { label, formFieldName, options, required: false, labelMode: "text", borderRadius: "md", fillColorCustom: "", strokeColorCustom: "", textColorCustom: "", widthMode: "full", paddingX: 12, paddingY: 10, labelLayout: "stacked", labelGapPx: 8, }, sectionId: null, columnId: null, }; set((state: EditorState) => ({ blocks: [...state.blocks, newBlock], selectedBlockId: id, })); }, // 폼 체크박스 블록 추가: 루트에 기본 groupLabel/formFieldName/options 와 함께 생성 후 선택 상태로 만든다 addFormCheckboxBlock: () => { const id = createId(); const { blocks } = get(); const groupLabel = "체크박스"; const formFieldName = getNextFormFieldName(blocks, "checkbox"); const options: FormCheckboxOption[] = [ { label: "체크 1", value: "check_1" }, { label: "체크 2", value: "check_2" }, ]; const newBlock: Block = { id, type: "formCheckbox", props: { groupLabel, formFieldName, options, required: false, groupLabelMode: "text", borderRadius: "md", fillColorCustom: "", strokeColorCustom: "", textColorCustom: "", widthMode: "full", paddingX: 12, paddingY: 10, optionGapPx: 4, labelLayout: "stacked", labelGapPx: 8, }, sectionId: null, columnId: null, }; set((state: EditorState) => ({ blocks: [...state.blocks, newBlock], selectedBlockId: id, })); }, // 폼 라디오 그룹 블록 추가: 루트에 기본 groupLabel/formFieldName/options 와 함께 생성 후 선택 상태로 만든다 addFormRadioBlock: () => { const id = createId(); const { blocks } = get(); const groupLabel = "라디오 그룹"; const formFieldName = getNextFormFieldName(blocks, "radio"); const options: FormRadioOption[] = [ { label: "옵션 A", value: "option_a" }, { label: "옵션 B", value: "option_b" }, ]; const newBlock: Block = { id, type: "formRadio", props: { groupLabel, formFieldName, options, required: false, groupLabelMode: "text", borderRadius: "md", fillColorCustom: "", strokeColorCustom: "", textColorCustom: "", widthMode: "full", paddingX: 12, paddingY: 10, optionGapPx: 4, labelLayout: "stacked", labelGapPx: 8, }, sectionId: null, columnId: null, }; 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", colorHex: "#475569", }, 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"], itemsTree: [ { id: `${id}_item_1`, text: "리스트 아이템 1", children: [], }, ], 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, })); }, // 폼 블록 추가: 기본 contact 폼 설정과 함께 생성 후 선택 상태로 만든다 addFormBlock: () => { const id = createId(); const fields: FormFieldConfig[] = [ { id: `${id}_field_name`, name: "name", label: "이름", type: "text", required: true, }, { id: `${id}_field_email`, name: "email", label: "이메일", type: "email", required: true, }, { id: `${id}_field_message`, name: "message", label: "메시지", type: "textarea", required: true, }, ]; const newBlock: Block = { id, type: "form", props: { kind: "contact", submitTarget: "internal", successMessage: "성공적으로 전송되었습니다.", errorMessage: "전송 중 오류가 발생했습니다.", fields, fieldIds: [], submitButtonId: null, }, 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: "#", align: "left", size: "md", variant: "solid", colorPalette: "primary", fullWidth: false, widthMode: "auto", widthPx: 240, borderRadius: "md", paddingX: 16, paddingY: 10, fontSizeCustom: "14px", // 기본 primary/solid 버튼 색상과 동일한 커스텀 색상 값을 사용해 // 에디터/프리뷰/속성 패널이 모두 같은 색상을 공유하도록 한다. textColorCustom: "#0b1120", fillColorCustom: "#0ea5e9", strokeColorCustom: "#0284c7", }, 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 as any), ...(partial as any) }, } : block, ), })); }, // 선택된 리스트 아이템을 들여쓰기한다. indentSelectedListItem: (blockId) => { const { blocks, selectedListItemId } = get(); if (!selectedListItemId) return; const target = blocks.find((b: Block) => b.id === blockId && b.type === "list"); if (!target) return; const listProps = target.props as ListBlockProps & { itemsTree?: ListItemNode[] }; const currentTree: ListItemNode[] = (listProps.itemsTree as ListItemNode[] | undefined) && listProps.itemsTree!.length > 0 ? (listProps.itemsTree as ListItemNode[]) : []; const nextTree = indentListItem(currentTree, selectedListItemId); set((state: EditorState) => ({ blocks: state.blocks.map((block: Block) => block.id === blockId ? { ...block, props: { ...(block.props as any), itemsTree: nextTree, }, } : block, ), })); }, // 선택된 리스트 아이템을 부모의 다음 형제로 내어쓰기한다. outdentSelectedListItem: (blockId) => { const { blocks, selectedListItemId } = get(); if (!selectedListItemId) return; const target = blocks.find((b: Block) => b.id === blockId && b.type === "list"); if (!target) return; const listProps = target.props as ListBlockProps & { itemsTree?: ListItemNode[] }; const currentTree: ListItemNode[] = (listProps.itemsTree as ListItemNode[] | undefined) && listProps.itemsTree!.length > 0 ? (listProps.itemsTree as ListItemNode[]) : []; const nextTree = outdentListItem(currentTree, selectedListItemId); set((state: EditorState) => ({ blocks: state.blocks.map((block: Block) => block.id === blockId ? { ...block, props: { ...(block.props as any), itemsTree: nextTree, }, } : block, ), })); }, // 선택된 리스트 아이템을 같은 레벨에서 한 칸 위로 이동시킨다. moveSelectedListItemUp: (blockId) => { const { blocks, selectedListItemId } = get(); if (!selectedListItemId) return; const target = blocks.find((b: Block) => b.id === blockId && b.type === "list"); if (!target) return; const listProps = target.props as ListBlockProps & { itemsTree?: ListItemNode[] }; const currentTree: ListItemNode[] = (listProps.itemsTree as ListItemNode[] | undefined) && listProps.itemsTree!.length > 0 ? (listProps.itemsTree as ListItemNode[]) : []; const nextTree = moveListItemUp(currentTree, selectedListItemId); set((state: EditorState) => ({ blocks: state.blocks.map((block: Block) => block.id === blockId ? { ...block, props: { ...(block.props as any), itemsTree: nextTree, }, } : block, ), })); }, // 선택된 리스트 아이템을 같은 레벨에서 한 칸 아래로 이동시킨다. moveSelectedListItemDown: (blockId) => { const { blocks, selectedListItemId } = get(); if (!selectedListItemId) return; const target = blocks.find((b: Block) => b.id === blockId && b.type === "list"); if (!target) return; const listProps = target.props as ListBlockProps & { itemsTree?: ListItemNode[] }; const currentTree: ListItemNode[] = (listProps.itemsTree as ListItemNode[] | undefined) && listProps.itemsTree!.length > 0 ? (listProps.itemsTree as ListItemNode[]) : []; const nextTree = moveListItemDown(currentTree, selectedListItemId); set((state: EditorState) => ({ blocks: state.blocks.map((block: Block) => block.id === blockId ? { ...block, props: { ...(block.props as any), itemsTree: nextTree, }, } : 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 target = current[index]; let nextBlocks: Block[]; if (target.type === "section") { // 섹션 블록이 삭제될 때는 해당 섹션 안에 속한 자식 블록들도 함께 제거한다. nextBlocks = current.filter((b) => b.id !== id && b.sectionId !== id); } else { nextBlocks = current.filter((b) => b.id !== id); } // 선택 상태는 삭제된 블록의 이전 블록 또는 다음 블록으로 이동한다. let nextSelected: string | null = null; if (nextBlocks.length > 0) { const candidateIndex = Math.min(Math.max(index - 1, 0), nextBlocks.length - 1); nextSelected = nextBlocks[candidateIndex].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);