2051 lines
62 KiB
TypeScript
2051 lines
62 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";
|
|
import { getEditorTemplatesMessages } from "@/features/i18n/messages/editorTemplates";
|
|
import { getEditorBlockDefaultsMessages } from "@/features/i18n/messages/editorBlockDefaults";
|
|
import { DEFAULT_LOCALE, type AppLocale } from "@/features/i18n/locale";
|
|
|
|
// 블록 타입 정의: 텍스트/버튼/이미지/비디오/섹션/구분선/리스트/폼 블록/폼 요소 블록
|
|
export type BlockType =
|
|
| "text"
|
|
| "button"
|
|
| "image"
|
|
| "video"
|
|
| "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;
|
|
|
|
// 블록 배경색 커스텀 값 (예: "#123456")
|
|
backgroundColorCustom?: 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;
|
|
// 폼 컨트롤러에서 Submit 버튼 매핑 시 사용할 수 있는 전송 키 (name 역할)
|
|
formFieldName?: string;
|
|
imageSrc?: string;
|
|
imageAlt?: string;
|
|
imageSourceType?: "asset" | "externalUrl";
|
|
imageAssetId?: string | null;
|
|
imagePlacement?: "left" | "right" | "top" | "bottom";
|
|
}
|
|
|
|
// 이미지 블록 속성
|
|
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";
|
|
// 모서리 둥글기 px 값 (이미지에 한해서는 연속적인 px 단위 조정을 지원한다)
|
|
borderRadiusPx?: number;
|
|
// 이미지 카드를 감싸는 배경색 커스텀 값 (예: "#123456")
|
|
backgroundColorCustom?: string;
|
|
// 이미지 소스 타입: 업로드된 에셋 또는 외부 URL
|
|
sourceType?: "asset" | "externalUrl";
|
|
// 업로드된 에셋인 경우 Asset.id 를 저장해둔다.
|
|
assetId?: string | null;
|
|
}
|
|
|
|
export interface VideoBlockProps {
|
|
sourceUrl: string;
|
|
sourceType?: "asset" | "externalUrl";
|
|
assetId?: string | null;
|
|
posterImageSrc?: string;
|
|
posterSourceType?: "asset" | "externalUrl";
|
|
posterAssetId?: string | null;
|
|
platform?: "auto" | "youtube" | "vimeo" | "html5";
|
|
align?: "left" | "center" | "right";
|
|
widthMode?: "auto" | "full" | "fixed";
|
|
widthPx?: number;
|
|
aspectRatio?: "16:9" | "4:3" | "1:1";
|
|
autoplay?: boolean;
|
|
loop?: boolean;
|
|
muted?: boolean;
|
|
controls?: boolean;
|
|
titleText?: string;
|
|
ariaLabel?: string;
|
|
captionText?: string;
|
|
// HTML5 비디오 재생 범위(초 단위)
|
|
startTimeSec?: number;
|
|
endTimeSec?: number;
|
|
// 카드 스타일: 배경색/패딩/모서리 둥글기
|
|
backgroundColorCustom?: string;
|
|
cardPaddingPx?: number;
|
|
borderRadiusPx?: number;
|
|
}
|
|
|
|
// 구분선 블록 속성
|
|
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;
|
|
// 리스트 전체를 감싸는 배경색 커스텀 값
|
|
backgroundColorCustom?: 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;
|
|
// 섹션 배경 이미지
|
|
backgroundImageSrc?: string;
|
|
backgroundImageSourceType?: "asset" | "externalUrl";
|
|
backgroundImageAssetId?: string | null;
|
|
backgroundImageSize?: "auto" | "cover" | "contain";
|
|
backgroundImagePosition?: "center" | "top" | "bottom" | "left" | "right";
|
|
backgroundImageRepeat?: "no-repeat" | "repeat" | "repeat-x" | "repeat-y";
|
|
backgroundImagePositionMode?: "preset" | "custom";
|
|
backgroundImagePositionXPercent?: number;
|
|
backgroundImagePositionYPercent?: number;
|
|
// 섹션 배경 비디오
|
|
backgroundVideoSrc?: string;
|
|
backgroundVideoSourceType?: "asset" | "externalUrl";
|
|
backgroundVideoAssetId?: string | null;
|
|
}
|
|
|
|
// 폼 입력/셀렉트/체크박스/라디오 공통 스타일 속성
|
|
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";
|
|
optionLayout?: "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;
|
|
labelDisplay?: "visible" | "hidden" | "floating";
|
|
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;
|
|
labelDisplay?: "visible" | "hidden";
|
|
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;
|
|
groupLabelDisplay?: "visible" | "hidden";
|
|
groupLabelImageUrl?: string;
|
|
groupLabelImageSource?: "url" | "upload";
|
|
optionImageSource?: "url" | "upload";
|
|
}
|
|
|
|
// 폼 라디오 그룹 옵션/블록 속성
|
|
export interface FormRadioOption {
|
|
label: string;
|
|
value: string;
|
|
// 각 라디오 옵션도 개별 이미지 라벨을 가질 수 있도록 확장한다.
|
|
labelImageUrl?: string;
|
|
}
|
|
|
|
export interface FormRadioBlockProps extends FormFieldStyleProps {
|
|
groupLabel: string;
|
|
formFieldName: string;
|
|
options: FormRadioOption[];
|
|
required?: boolean;
|
|
// 라디오 그룹 타이틀의 텍스트/이미지 모드
|
|
groupLabelMode?: FormLabelMode;
|
|
groupLabelDisplay?: "visible" | "hidden";
|
|
groupLabelImageUrl?: string;
|
|
groupLabelImageSource?: "url" | "upload";
|
|
optionImageSource?: "url" | "upload";
|
|
}
|
|
|
|
// 폼 필드 설정
|
|
export interface FormFieldConfig {
|
|
id: string;
|
|
name: string; // 실제 전송 키 (예: "email")
|
|
label: string; // UI 라벨 (예: "이메일")
|
|
type: "text" | "email" | "textarea";
|
|
required?: boolean;
|
|
}
|
|
|
|
// 폼 블록 속성
|
|
export type FormSubmitTarget = "internal" | "webhook" | "both";
|
|
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[];
|
|
// v2 컨트롤러: fieldIds 로 연결된 필드 중 어떤 필드를 필수로 처리할지 관리하는 ID 목록
|
|
requiredFieldIds?: string[];
|
|
submitButtonId?: string | null;
|
|
// 폼 레이아웃
|
|
formWidthMode?: "auto" | "full" | "fixed";
|
|
formWidthPx?: number;
|
|
marginYPx?: number;
|
|
// 폼 전체를 감싸는 배경색 커스텀 값
|
|
backgroundColorCustom?: string;
|
|
}
|
|
|
|
export type CanvasPreset = "mobile" | "tablet" | "desktop" | "full" | "custom";
|
|
|
|
export interface ProjectConfig {
|
|
title: string;
|
|
slug: string;
|
|
canvasPreset: CanvasPreset;
|
|
canvasWidthPx?: number;
|
|
canvasBgColorHex?: string;
|
|
bodyBgColorHex?: string;
|
|
headHtml?: string;
|
|
trackingScript?: string;
|
|
// SEO / 메타 설정 (15.1)
|
|
seoTitle?: string;
|
|
seoDescription?: string;
|
|
seoOgImageUrl?: string;
|
|
seoCanonicalUrl?: string;
|
|
seoNoIndex?: boolean;
|
|
}
|
|
|
|
// 공통 블록 모델
|
|
export interface Block {
|
|
id: string;
|
|
type: BlockType;
|
|
props:
|
|
| TextBlockProps
|
|
| ButtonBlockProps
|
|
| ImageBlockProps
|
|
| VideoBlockProps
|
|
| 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[][];
|
|
projectConfig: ProjectConfig;
|
|
updateProjectConfig: (partial: Partial<ProjectConfig>) => void;
|
|
addTextBlock: (locale?: AppLocale | null) => void;
|
|
addButtonBlock: (locale?: AppLocale | null) => void;
|
|
addImageBlock: () => void;
|
|
addVideoBlock: () => void;
|
|
addDividerBlock: () => void;
|
|
addListBlock: (locale?: AppLocale | null) => void;
|
|
addSectionBlock: () => void;
|
|
addFormBlock: (locale?: AppLocale | null) => void;
|
|
addFormInputBlock: (locale?: AppLocale | null) => void;
|
|
addFormSelectBlock: (locale?: AppLocale | null) => void;
|
|
addFormCheckboxBlock: (locale?: AppLocale | null) => void;
|
|
addFormRadioBlock: (locale?: AppLocale | null) => void;
|
|
addHeroTemplateSection: (locale?: AppLocale | null) => void;
|
|
addFeaturesTemplateSection: (locale?: AppLocale | null) => void;
|
|
addCtaTemplateSection: (locale?: AppLocale | null) => void;
|
|
addFaqTemplateSection: (locale?: AppLocale | null) => void;
|
|
addPricingTemplateSection: (locale?: AppLocale | null) => void;
|
|
addTestimonialsTemplateSection: (locale?: AppLocale | null) => void;
|
|
addBlogTemplateSection: (locale?: AppLocale | null) => void;
|
|
addTeamTemplateSection: (locale?: AppLocale | null) => void;
|
|
addFooterTemplateSection: (locale?: AppLocale | null) => 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;
|
|
resetHistory: () => void;
|
|
}
|
|
|
|
// 간단한 ID 생성기 (추후 uuid 라이브러리로 교체 가능)
|
|
let idCounter = 0;
|
|
const createId = () => `blk_${Date.now()}_${idCounter++}`;
|
|
|
|
// undo/redo 를 위한 히스토리 유틸리티: 상태 변경 전에 현재 blocks 스냅샷을 history 에 쌓고 future 는 비운다.
|
|
const pushHistoryImpl = (set: any, get: any) => {
|
|
const { blocks } = get() as EditorState;
|
|
set((state: EditorState) => ({
|
|
history: [...state.history, blocks],
|
|
future: [],
|
|
}));
|
|
};
|
|
|
|
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}`;
|
|
};
|
|
|
|
const isKoLocale = (locale?: AppLocale | null): boolean => (locale ?? DEFAULT_LOCALE) === "ko";
|
|
|
|
// 에디터 스토어 생성 함수 (테스트 및 앱에서 공유 사용)
|
|
// set/get 은 zustand 내부 구현에 의해 주입되므로, 여기서는 any 로 완화해 사용한다.
|
|
const createEditorState = (set: any, get: any): EditorState => {
|
|
const pushHistory = () => {
|
|
const { blocks } = get() as EditorState;
|
|
set((state: EditorState) => ({
|
|
history: [...state.history, blocks],
|
|
future: [],
|
|
}));
|
|
};
|
|
|
|
return {
|
|
blocks: [],
|
|
selectedBlockId: null,
|
|
selectedListItemId: null,
|
|
history: [],
|
|
future: [],
|
|
projectConfig: {
|
|
title: "New page",
|
|
slug: "my-landing",
|
|
canvasPreset: "full",
|
|
canvasBgColorHex: "#020617",
|
|
bodyBgColorHex: "#020617",
|
|
headHtml: "",
|
|
trackingScript: "",
|
|
// SEO 기본값: 필요할 때만 값을 채우고, 없으면 title 등을 사용한다.
|
|
seoTitle: "",
|
|
seoDescription: "",
|
|
seoOgImageUrl: "",
|
|
seoCanonicalUrl: "",
|
|
seoNoIndex: false,
|
|
},
|
|
|
|
updateProjectConfig: (partial: Partial<ProjectConfig>) => {
|
|
set((state: EditorState) => ({
|
|
projectConfig: {
|
|
...state.projectConfig,
|
|
...partial,
|
|
},
|
|
}));
|
|
},
|
|
|
|
// 텍스트 블록 추가: 기본 텍스트와 함께 생성 후 선택 상태로 만든다
|
|
addTextBlock: (locale?: AppLocale | null) => {
|
|
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 blockDefaults = getEditorBlockDefaultsMessages(locale);
|
|
const newBlock: Block = {
|
|
id,
|
|
type: "text",
|
|
props: {
|
|
text: blockDefaults.text.defaultText,
|
|
align: "left",
|
|
size: "base",
|
|
},
|
|
sectionId,
|
|
columnId,
|
|
};
|
|
|
|
pushHistoryImpl(set, get);
|
|
set((state: EditorState) => ({
|
|
blocks: [...state.blocks, newBlock],
|
|
selectedBlockId: id,
|
|
}));
|
|
},
|
|
|
|
// Hero 템플릿 섹션 추가: 섹션 1개와 기본 텍스트/버튼 블록들을 첫 컬럼에 배치한다
|
|
addHeroTemplateSection: (locale?: AppLocale | null) => {
|
|
const { selectedBlockId, blocks } = get();
|
|
let sectionId = createId();
|
|
let replacingSectionId: string | null = null;
|
|
|
|
if (selectedBlockId) {
|
|
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
|
|
if (target) {
|
|
sectionId = target.id;
|
|
replacingSectionId = target.id;
|
|
}
|
|
}
|
|
|
|
const tpl = getEditorTemplatesMessages(locale);
|
|
const { blocks: templateBlocks, lastSelectedId } = createHeroTemplateBlocks({
|
|
sectionId,
|
|
createId,
|
|
messages: tpl.hero,
|
|
});
|
|
|
|
pushHistoryImpl(set, get);
|
|
set((state: EditorState) => {
|
|
let baseBlocks = state.blocks as Block[];
|
|
if (replacingSectionId) {
|
|
baseBlocks = baseBlocks.filter(
|
|
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
|
|
);
|
|
}
|
|
|
|
const newBlocks = [...baseBlocks, ...templateBlocks];
|
|
|
|
return {
|
|
blocks: newBlocks,
|
|
selectedBlockId: lastSelectedId,
|
|
};
|
|
});
|
|
},
|
|
|
|
// Features 템플릿 섹션 추가: 3컬럼(4/4/4) 섹션과 각 컬럼의 제목/설명 텍스트를 생성한다
|
|
addFeaturesTemplateSection: (locale?: AppLocale | null) => {
|
|
const { selectedBlockId, blocks } = get();
|
|
let sectionId = createId();
|
|
let replacingSectionId: string | null = null;
|
|
|
|
if (selectedBlockId) {
|
|
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
|
|
if (target) {
|
|
sectionId = target.id;
|
|
replacingSectionId = target.id;
|
|
}
|
|
}
|
|
|
|
const tpl = getEditorTemplatesMessages(locale);
|
|
const { blocks: templateBlocks, lastSelectedId } = createFeaturesTemplateBlocks({
|
|
sectionId,
|
|
createId,
|
|
messages: tpl.features,
|
|
});
|
|
|
|
pushHistoryImpl(set, get);
|
|
set((state: EditorState) => {
|
|
let baseBlocks = state.blocks as Block[];
|
|
if (replacingSectionId) {
|
|
baseBlocks = baseBlocks.filter(
|
|
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
|
|
);
|
|
}
|
|
|
|
const newBlocks = [...baseBlocks, ...templateBlocks];
|
|
|
|
return {
|
|
blocks: newBlocks,
|
|
selectedBlockId: lastSelectedId,
|
|
};
|
|
});
|
|
},
|
|
|
|
// Blog 템플릿 섹션 추가: 3컬럼 포스트 카드(제목/요약)를 생성한다
|
|
addBlogTemplateSection: (locale?: AppLocale | null) => {
|
|
const { selectedBlockId, blocks } = get();
|
|
let sectionId = createId();
|
|
let replacingSectionId: string | null = null;
|
|
|
|
if (selectedBlockId) {
|
|
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
|
|
if (target) {
|
|
sectionId = target.id;
|
|
replacingSectionId = target.id;
|
|
}
|
|
}
|
|
|
|
const tpl = getEditorTemplatesMessages(locale);
|
|
const { blocks: templateBlocks, lastSelectedId } = createBlogTemplateBlocks({
|
|
sectionId,
|
|
createId,
|
|
messages: tpl.blog,
|
|
});
|
|
|
|
pushHistoryImpl(set, get);
|
|
set((state: EditorState) => {
|
|
let baseBlocks = state.blocks as Block[];
|
|
if (replacingSectionId) {
|
|
baseBlocks = baseBlocks.filter(
|
|
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
|
|
);
|
|
}
|
|
|
|
const newBlocks = [...baseBlocks, ...templateBlocks];
|
|
|
|
return {
|
|
blocks: newBlocks,
|
|
selectedBlockId: lastSelectedId,
|
|
};
|
|
});
|
|
},
|
|
|
|
// Team 템플릿 섹션 추가: 3컬럼 팀 카드(이름/역할/소개)를 생성한다
|
|
addTeamTemplateSection: (locale?: AppLocale | null) => {
|
|
const { selectedBlockId, blocks } = get();
|
|
let sectionId = createId();
|
|
let replacingSectionId: string | null = null;
|
|
|
|
if (selectedBlockId) {
|
|
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
|
|
if (target) {
|
|
sectionId = target.id;
|
|
replacingSectionId = target.id;
|
|
}
|
|
}
|
|
|
|
const tpl = getEditorTemplatesMessages(locale);
|
|
const { blocks: templateBlocks, lastSelectedId } = createTeamTemplateBlocks({
|
|
sectionId,
|
|
createId,
|
|
messages: tpl.team,
|
|
});
|
|
|
|
pushHistoryImpl(set, get);
|
|
set((state: EditorState) => {
|
|
let baseBlocks = state.blocks as Block[];
|
|
if (replacingSectionId) {
|
|
baseBlocks = baseBlocks.filter(
|
|
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
|
|
);
|
|
}
|
|
|
|
const newBlocks = [...baseBlocks, ...templateBlocks];
|
|
|
|
return {
|
|
blocks: newBlocks,
|
|
selectedBlockId: lastSelectedId,
|
|
};
|
|
});
|
|
},
|
|
|
|
// Footer 템플릿 섹션 추가: 링크/카피라이트 텍스트가 포함된 1컬럼 섹션을 생성한다
|
|
addFooterTemplateSection: (locale?: AppLocale | null) => {
|
|
const { selectedBlockId, blocks } = get();
|
|
let sectionId = createId();
|
|
let replacingSectionId: string | null = null;
|
|
|
|
if (selectedBlockId) {
|
|
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
|
|
if (target) {
|
|
sectionId = target.id;
|
|
replacingSectionId = target.id;
|
|
}
|
|
}
|
|
|
|
const tpl = getEditorTemplatesMessages(locale);
|
|
const { blocks: templateBlocks, lastSelectedId } = createFooterTemplateBlocks({
|
|
sectionId,
|
|
createId,
|
|
messages: tpl.footer,
|
|
});
|
|
|
|
pushHistoryImpl(set, get);
|
|
set((state: EditorState) => {
|
|
let baseBlocks = state.blocks as Block[];
|
|
if (replacingSectionId) {
|
|
baseBlocks = baseBlocks.filter(
|
|
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
|
|
);
|
|
}
|
|
|
|
const newBlocks = [...baseBlocks, ...templateBlocks];
|
|
|
|
return {
|
|
blocks: newBlocks,
|
|
selectedBlockId: lastSelectedId,
|
|
};
|
|
});
|
|
},
|
|
|
|
// CTA 템플릿 섹션 추가: 텍스트와 버튼이 포함된 1컬럼 섹션을 생성한다
|
|
addCtaTemplateSection: (locale?: AppLocale | null) => {
|
|
const { selectedBlockId, blocks } = get();
|
|
let sectionId = createId();
|
|
let replacingSectionId: string | null = null;
|
|
|
|
if (selectedBlockId) {
|
|
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
|
|
if (target) {
|
|
sectionId = target.id;
|
|
replacingSectionId = target.id;
|
|
}
|
|
}
|
|
|
|
const tpl = getEditorTemplatesMessages(locale);
|
|
const { blocks: templateBlocks, lastSelectedId } = createCtaTemplateBlocks({
|
|
sectionId,
|
|
createId,
|
|
messages: tpl.cta,
|
|
});
|
|
|
|
pushHistoryImpl(set, get);
|
|
set((state: EditorState) => {
|
|
let baseBlocks = state.blocks as Block[];
|
|
if (replacingSectionId) {
|
|
baseBlocks = baseBlocks.filter(
|
|
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
|
|
);
|
|
}
|
|
|
|
const newBlocks = [...baseBlocks, ...templateBlocks];
|
|
|
|
return {
|
|
blocks: newBlocks,
|
|
selectedBlockId: lastSelectedId,
|
|
};
|
|
});
|
|
},
|
|
|
|
// FAQ 템플릿 섹션 추가: 질문/답변 쌍이 수직으로 나열된 섹션을 생성한다
|
|
addFaqTemplateSection: (locale?: AppLocale | null) => {
|
|
const { selectedBlockId, blocks } = get();
|
|
let sectionId = createId();
|
|
let replacingSectionId: string | null = null;
|
|
|
|
if (selectedBlockId) {
|
|
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
|
|
if (target) {
|
|
sectionId = target.id;
|
|
replacingSectionId = target.id;
|
|
}
|
|
}
|
|
|
|
const tpl = getEditorTemplatesMessages(locale);
|
|
const { blocks: templateBlocks, lastSelectedId } = createFaqTemplateBlocks({
|
|
sectionId,
|
|
createId,
|
|
messages: tpl.faq,
|
|
});
|
|
|
|
pushHistoryImpl(set, get);
|
|
set((state: EditorState) => {
|
|
let baseBlocks = state.blocks as Block[];
|
|
if (replacingSectionId) {
|
|
baseBlocks = baseBlocks.filter(
|
|
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
|
|
);
|
|
}
|
|
|
|
const newBlocks = [...baseBlocks, ...templateBlocks];
|
|
|
|
return {
|
|
blocks: newBlocks,
|
|
selectedBlockId: lastSelectedId,
|
|
};
|
|
});
|
|
},
|
|
|
|
// Pricing 템플릿 섹션 추가: 3컬럼 요금제 카드(플랜 이름/가격/설명)를 생성한다
|
|
addPricingTemplateSection: (locale?: AppLocale | null) => {
|
|
const { selectedBlockId, blocks } = get();
|
|
let sectionId = createId();
|
|
let replacingSectionId: string | null = null;
|
|
|
|
if (selectedBlockId) {
|
|
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
|
|
if (target) {
|
|
sectionId = target.id;
|
|
replacingSectionId = target.id;
|
|
}
|
|
}
|
|
|
|
const tpl = getEditorTemplatesMessages(locale);
|
|
const { blocks: templateBlocks, lastSelectedId } = createPricingTemplateBlocks({
|
|
sectionId,
|
|
createId,
|
|
messages: tpl.pricing,
|
|
});
|
|
|
|
pushHistoryImpl(set, get);
|
|
set((state: EditorState) => {
|
|
let baseBlocks = state.blocks as Block[];
|
|
if (replacingSectionId) {
|
|
baseBlocks = baseBlocks.filter(
|
|
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
|
|
);
|
|
}
|
|
|
|
const newBlocks = [...baseBlocks, ...templateBlocks];
|
|
|
|
return {
|
|
blocks: newBlocks,
|
|
selectedBlockId: lastSelectedId,
|
|
};
|
|
});
|
|
},
|
|
|
|
// Testimonials 템플릿 섹션 추가: 3컬럼 후기 카드(본문/작성자)를 생성한다
|
|
addTestimonialsTemplateSection: (locale?: AppLocale | null) => {
|
|
const { selectedBlockId, blocks } = get();
|
|
let sectionId = createId();
|
|
let replacingSectionId: string | null = null;
|
|
|
|
if (selectedBlockId) {
|
|
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
|
|
if (target) {
|
|
sectionId = target.id;
|
|
replacingSectionId = target.id;
|
|
}
|
|
}
|
|
|
|
const tpl = getEditorTemplatesMessages(locale);
|
|
const { blocks: templateBlocks, lastSelectedId } = createTestimonialsTemplateBlocks({
|
|
sectionId,
|
|
createId,
|
|
messages: tpl.testimonials,
|
|
});
|
|
|
|
pushHistoryImpl(set, get);
|
|
set((state: EditorState) => {
|
|
let baseBlocks = state.blocks as Block[];
|
|
if (replacingSectionId) {
|
|
baseBlocks = baseBlocks.filter(
|
|
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
|
|
);
|
|
}
|
|
|
|
const newBlocks = [...baseBlocks, ...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: "Image description",
|
|
align: "center",
|
|
widthMode: "auto",
|
|
borderRadius: "md",
|
|
},
|
|
sectionId,
|
|
columnId,
|
|
};
|
|
|
|
pushHistoryImpl(set, get);
|
|
set((state: EditorState) => ({
|
|
blocks: [...state.blocks, newBlock],
|
|
selectedBlockId: id,
|
|
}));
|
|
},
|
|
|
|
// 비디오 블록 추가: 기본 소스/정렬/비율 설정과 함께 생성 후 선택 상태로 만든다
|
|
addVideoBlock: () => {
|
|
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: "video",
|
|
props: {
|
|
sourceUrl: "",
|
|
platform: "auto",
|
|
align: "center",
|
|
widthMode: "auto",
|
|
aspectRatio: "16:9",
|
|
controls: true,
|
|
autoplay: false,
|
|
loop: false,
|
|
muted: false,
|
|
},
|
|
sectionId,
|
|
columnId,
|
|
};
|
|
|
|
pushHistoryImpl(set, get);
|
|
set((state: EditorState) => ({
|
|
blocks: [...state.blocks, newBlock],
|
|
selectedBlockId: id,
|
|
}));
|
|
},
|
|
|
|
// 폼 입력 블록 추가: 루트에 기본 label/formFieldName 과 함께 생성 후 선택 상태로 만든다
|
|
addFormInputBlock: (locale?: AppLocale | null) => {
|
|
const id = createId();
|
|
const { blocks } = get();
|
|
|
|
const blockDefaults = getEditorBlockDefaultsMessages(locale);
|
|
const label = blockDefaults.formInput.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,
|
|
};
|
|
|
|
pushHistoryImpl(set, get);
|
|
set((state: EditorState) => ({
|
|
blocks: [...state.blocks, newBlock],
|
|
selectedBlockId: id,
|
|
}));
|
|
},
|
|
|
|
// 폼 셀렉트 블록 추가: 루트에 기본 label/formFieldName/options 와 함께 생성 후 선택 상태로 만든다
|
|
addFormSelectBlock: (locale?: AppLocale | null) => {
|
|
const id = createId();
|
|
const { blocks } = get();
|
|
|
|
const blockDefaults = getEditorBlockDefaultsMessages(locale);
|
|
const selectDefaults = blockDefaults.formSelect;
|
|
const label = selectDefaults.label;
|
|
const formFieldName = getNextFormFieldName(blocks, "select");
|
|
const options: FormSelectOption[] = [
|
|
{ label: selectDefaults.option1Label, value: "option_1" },
|
|
{ label: selectDefaults.option2Label, 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,
|
|
};
|
|
|
|
pushHistoryImpl(set, get);
|
|
set((state: EditorState) => ({
|
|
blocks: [...state.blocks, newBlock],
|
|
selectedBlockId: id,
|
|
}));
|
|
},
|
|
|
|
// 폼 체크박스 블록 추가: 루트에 기본 groupLabel/formFieldName/options 와 함께 생성 후 선택 상태로 만든다
|
|
addFormCheckboxBlock: (locale?: AppLocale | null) => {
|
|
const id = createId();
|
|
const { blocks } = get();
|
|
|
|
const blockDefaults = getEditorBlockDefaultsMessages(locale);
|
|
const checkboxDefaults = blockDefaults.formCheckbox;
|
|
const groupLabel = checkboxDefaults.groupLabel;
|
|
const formFieldName = getNextFormFieldName(blocks, "checkbox");
|
|
const options: FormCheckboxOption[] = [
|
|
{ label: checkboxDefaults.option1Label, value: "check_1" },
|
|
{ label: checkboxDefaults.option2Label, 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,
|
|
};
|
|
|
|
pushHistoryImpl(set, get);
|
|
set((state: EditorState) => ({
|
|
blocks: [...state.blocks, newBlock],
|
|
selectedBlockId: id,
|
|
}));
|
|
},
|
|
|
|
// 폼 라디오 그룹 블록 추가: 루트에 기본 groupLabel/formFieldName/options 와 함께 생성 후 선택 상태로 만든다
|
|
addFormRadioBlock: (locale?: AppLocale | null) => {
|
|
const id = createId();
|
|
const { blocks } = get();
|
|
|
|
const blockDefaults = getEditorBlockDefaultsMessages(locale);
|
|
const radioDefaults = blockDefaults.formRadio;
|
|
const groupLabel = radioDefaults.groupLabel;
|
|
const formFieldName = getNextFormFieldName(blocks, "radio");
|
|
const options: FormRadioOption[] = [
|
|
{ label: radioDefaults.optionALabel, value: "option_a" },
|
|
{ label: radioDefaults.optionBLabel, 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,
|
|
};
|
|
|
|
pushHistoryImpl(set, get);
|
|
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,
|
|
};
|
|
|
|
pushHistoryImpl(set, get);
|
|
set((state: EditorState) => ({
|
|
blocks: [...state.blocks, newBlock],
|
|
selectedBlockId: id,
|
|
}));
|
|
},
|
|
|
|
// 리스트 블록 추가: 기본 항목/정렬과 함께 생성 후 선택 상태로 만든다
|
|
addListBlock: (locale?: AppLocale | null) => {
|
|
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 blockDefaults = getEditorBlockDefaultsMessages(locale);
|
|
const itemText = blockDefaults.list.firstItemText;
|
|
|
|
const newBlock: Block = {
|
|
id,
|
|
type: "list",
|
|
props: {
|
|
items: [itemText],
|
|
itemsTree: [
|
|
{
|
|
id: `${id}_item_1`,
|
|
text: itemText,
|
|
children: [],
|
|
},
|
|
],
|
|
ordered: false,
|
|
align: "left",
|
|
},
|
|
sectionId,
|
|
columnId,
|
|
};
|
|
|
|
pushHistoryImpl(set, get);
|
|
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,
|
|
};
|
|
|
|
pushHistoryImpl(set, get);
|
|
set((state: EditorState) => ({
|
|
blocks: [...state.blocks, newBlock],
|
|
selectedBlockId: id,
|
|
}));
|
|
},
|
|
|
|
// 폼 블록 추가: 기본 contact 폼 설정과 함께 생성 후 선택 상태로 만든다
|
|
addFormBlock: (locale?: AppLocale | null) => {
|
|
const id = createId();
|
|
|
|
const blockDefaults = getEditorBlockDefaultsMessages(locale);
|
|
const formDefaults = blockDefaults.form;
|
|
|
|
const fields: FormFieldConfig[] = [
|
|
{
|
|
id: `${id}_field_name`,
|
|
name: "name",
|
|
label: formDefaults.nameLabel,
|
|
type: "text",
|
|
required: true,
|
|
},
|
|
{
|
|
id: `${id}_field_email`,
|
|
name: "email",
|
|
label: formDefaults.emailLabel,
|
|
type: "email",
|
|
required: true,
|
|
},
|
|
{
|
|
id: `${id}_field_message`,
|
|
name: "message",
|
|
label: formDefaults.messageLabel,
|
|
type: "textarea",
|
|
required: true,
|
|
},
|
|
];
|
|
|
|
const newBlock: Block = {
|
|
id,
|
|
type: "form",
|
|
props: {
|
|
kind: "contact",
|
|
submitTarget: "internal",
|
|
successMessage: formDefaults.successMessage,
|
|
errorMessage: formDefaults.errorMessage,
|
|
fields,
|
|
fieldIds: [],
|
|
submitButtonId: null,
|
|
},
|
|
sectionId: null,
|
|
columnId: null,
|
|
};
|
|
|
|
pushHistoryImpl(set, get);
|
|
set((state: EditorState) => ({
|
|
blocks: [...state.blocks, newBlock],
|
|
selectedBlockId: id,
|
|
}));
|
|
},
|
|
|
|
// 버튼 블록 추가: 기본 라벨/링크와 함께 생성 후 선택 상태로 만든다
|
|
addButtonBlock: (locale?: AppLocale | null) => {
|
|
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 blockDefaults = getEditorBlockDefaultsMessages(locale);
|
|
|
|
const newBlock: Block = {
|
|
id,
|
|
type: "button",
|
|
props: {
|
|
label: blockDefaults.button.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,
|
|
};
|
|
|
|
pushHistoryImpl(set, get);
|
|
set((state: EditorState) => ({
|
|
blocks: [...state.blocks, newBlock],
|
|
selectedBlockId: id,
|
|
}));
|
|
},
|
|
|
|
// 특정 블록의 속성을 부분 업데이트 (텍스트/버튼 공통 사용)
|
|
updateBlock: (id, partial) => {
|
|
pushHistoryImpl(set, get);
|
|
set((state: EditorState) => ({
|
|
blocks: state.blocks.map((block: Block) =>
|
|
block.id === id
|
|
? {
|
|
...block,
|
|
props: { ...(block.props as any), ...(partial as any) },
|
|
}
|
|
: block,
|
|
),
|
|
}));
|
|
},
|
|
|
|
// 현재 선택된 리스트 아이템 ID 를 상태에 저장한다.
|
|
selectListItem: (itemId) => {
|
|
set({ selectedListItemId: itemId });
|
|
},
|
|
|
|
// 선택된 리스트 아이템을 들여쓰기한다.
|
|
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) => {
|
|
pushHistoryImpl(set, get);
|
|
set({
|
|
blocks,
|
|
selectedBlockId: blocks.length > 0 ? blocks[0].id : null,
|
|
});
|
|
},
|
|
|
|
reorderBlocks: (activeId, overId) => {
|
|
pushHistoryImpl(set, get);
|
|
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) => {
|
|
pushHistoryImpl(set, get);
|
|
set((state: EditorState) => ({
|
|
blocks: state.blocks.map((block: Block) =>
|
|
block.id === id
|
|
? {
|
|
...block,
|
|
sectionId,
|
|
columnId,
|
|
}
|
|
: block,
|
|
),
|
|
}));
|
|
},
|
|
removeBlock: (id) => {
|
|
pushHistoryImpl(set, get);
|
|
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) => {
|
|
pushHistoryImpl(set, get);
|
|
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,
|
|
}));
|
|
},
|
|
resetHistory: () => {
|
|
set({ history: [], future: [] });
|
|
},
|
|
};
|
|
};
|
|
|
|
// React 컴포넌트에서 사용하는 전역 훅 스토어
|
|
export const useEditorStore = create<EditorState>()(createEditorState);
|
|
|
|
// 테스트 등에서 독립적인 스토어 인스턴스를 만들 때 사용하는 팩토리
|
|
export const createEditorStore = () => createStore<EditorState>(createEditorState);
|