Files
page-builder/src/features/editor/state/editorStore.ts
T
jaybe 4e4c9cd37a
CI / test (push) Successful in 11m2s
CI / pr_and_merge (push) Successful in 1m19s
CI / test (pull_request) Successful in 51m33s
CI / pr_and_merge (pull_request) Has been skipped
섹션 삭제 후 텍스트 블록 추가 버그 수정 및 버튼/구분선 스타일-속성 패널 동기화
2025-11-22 15:18:32 +09:00

1635 lines
48 KiB
TypeScript

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<string, string>; // Authorization 등
extraParams?: Record<string, string>; // 항상 함께 보내는 추가 파라미터(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<TextBlockProps>
| Partial<ButtonBlockProps>
| Partial<FormBlockProps>
| Partial<FormInputBlockProps>
| Partial<FormSelectBlockProps>
| Partial<FormCheckboxBlockProps>
| Partial<FormRadioBlockProps>,
) => 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<EditorState>()(createEditorState);
// 테스트 등에서 독립적인 스토어 인스턴스를 만들 때 사용하는 팩토리
export const createEditorStore = () => createStore<EditorState>(createEditorState);