1135 lines
30 KiB
TypeScript
1135 lines
30 KiB
TypeScript
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<TextBlockProps & ButtonBlockProps>) => 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<EditorState>()(createEditorState);
|
|
|
|
// 테스트 등에서 독립적인 스토어 인스턴스를 만들 때 사용하는 팩토리
|
|
export const createEditorStore = () => createStore<EditorState>(createEditorState);
|