섹션 레이아웃 및 템플릿 스타일 개선
CI / test (push) Failing after 6m10s
CI / pr_and_merge (push) Has been skipped

This commit is contained in:
2025-11-21 16:25:55 +09:00
parent 2f59e3781e
commit 546c961a31
25 changed files with 2843 additions and 151 deletions
@@ -31,10 +31,10 @@ export type ColorPickerFieldProps = {
// 텍스트/버튼 등에서 공통으로 사용하는 기본 색상 팔레트
export const TEXT_COLOR_PALETTE: ColorPaletteItem[] = [
// 투명
{ id: "transparent", label: "투명", color: "transparent" },
// 기본/중립 계열
{ id: "default", label: "기본", color: "#e5e7eb" },
// 투명
{ id: "transparent", label: "투명", color: "transparent" },
{ id: "muted", label: "연한", color: "#9ca3af" },
{ id: "strong", label: "강조", color: "#f9fafb" },
{ id: "neutral", label: "중립", color: "#94a3b8" },
@@ -7,12 +7,58 @@ import type {
ButtonBlockProps,
ImageBlockProps,
SectionBlockProps,
DividerBlockProps,
ListBlockProps,
ListItemNode,
FormBlockProps,
FormInputBlockProps,
FormSelectBlockProps,
FormCheckboxBlockProps,
FormRadioBlockProps,
FormSelectOption,
FormRadioOption,
FormCheckboxOption,
} from "@/features/editor/state/editorStore";
// 섹션 레이아웃/스타일 토큰을 실제 Tailwind 클래스 조합으로 변환하는 유틸
// (에디터/퍼블릭 렌더러에서 동일한 규칙을 재사용하기 위해 export 한다.)
export function getSectionLayoutConfig(props: SectionBlockProps) {
const backgroundClass =
props.background === "muted"
? "bg-slate-900"
: props.background === "primary"
? "bg-sky-900"
: "bg-slate-950";
const paddingYClass =
props.paddingY === "sm" ? "py-8" : props.paddingY === "lg" ? "py-20" : "py-12";
const maxWidthClass =
props.maxWidthMode === "narrow"
? "max-w-3xl"
: props.maxWidthMode === "wide"
? "max-w-6xl"
: "max-w-5xl";
const gapXClass =
props.gapX === "sm" ? "gap-4" : props.gapX === "lg" ? "gap-10" : "gap-8";
const alignItemsClass =
props.alignItems === "center"
? "items-center"
: props.alignItems === "bottom"
? "items-end"
: "items-start";
return {
backgroundClass,
paddingYClass,
maxWidthClass,
gapXClass,
alignItemsClass,
} as const;
}
// 에디터 크롬 없이 실제 랜딩 페이지처럼 블록들을 렌더링하는 컴포넌트
interface PublicPageRendererProps {
blocks: Block[];
@@ -77,6 +123,151 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
);
}
if (block.type === "formInput") {
const props = block.props as FormInputBlockProps;
// 정렬: 입력 필드의 textAlign 으로 매핑한다.
const align = props.align ?? "left";
const labelLayout = props.labelLayout ?? "stacked";
const widthMode = props.widthMode ?? (props.fullWidth ? "full" : "auto");
const wrapperLayoutClass =
labelLayout === "inline" ? "flex flex-row items-center gap-2" : "flex flex-col gap-1";
const baseInputClass =
"rounded border px-3 py-2 text-xs outline-none focus:border-sky-500";
const widthClass = widthMode === "full" ? "w-full" : "";
const inputStyle: CSSProperties = {
textAlign: align,
};
// 너비: fixed 모드일 때 widthPx 를 직접 사용한다.
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
inputStyle.width = `${props.widthPx}px`;
}
// 타이포 관련 커스텀 값
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
inputStyle.fontSize = props.fontSizeCustom;
}
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
inputStyle.lineHeight = props.lineHeightCustom;
}
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
inputStyle.letterSpacing = props.letterSpacingCustom;
}
// 색상 관련 커스텀 값
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
inputStyle.color = props.textColorCustom;
} else {
inputStyle.color = "#f9fafb";
}
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
inputStyle.backgroundColor = props.fillColorCustom;
} else {
inputStyle.backgroundColor = "#020617"; // 기본 다크 배경과 유사한 색
}
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
inputStyle.borderColor = props.strokeColorCustom;
} else {
inputStyle.borderColor = "#334155";
}
// 모서리 둥글기: borderRadius 토큰을 px 값으로 변환한다.
const radiusToken = props.borderRadius ?? "md";
const radiusPx =
radiusToken === "none" ? 0 : radiusToken === "sm" ? 2 : radiusToken === "lg" ? 6 : radiusToken === "full" ? 9999 : 4;
inputStyle.borderRadius = `${radiusPx}px`;
return (
<label key={block.id} className={`${wrapperLayoutClass} text-xs text-slate-200`}>
<span>{props.label}</span>
{props.inputType === "textarea" ? (
<textarea
className={`${baseInputClass} ${widthClass}`}
style={inputStyle}
placeholder={props.placeholder || props.label}
/>
) : (
<input
className={`${baseInputClass} ${widthClass}`}
style={inputStyle}
type={props.inputType === "email" ? "email" : "text"}
placeholder={props.placeholder || props.label}
/>
)}
</label>
);
}
if (block.type === "formSelect") {
const props = block.props as FormSelectBlockProps;
return (
<label key={block.id} className="flex flex-col gap-1 text-xs text-slate-200">
<span>{props.label}</span>
<select
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
defaultValue={props.options[0]?.value ?? ""}
>
{props.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
);
}
if (block.type === "formCheckbox") {
const props = block.props as FormCheckboxBlockProps;
return (
<div key={block.id} className="flex flex-col gap-1 text-xs text-slate-200">
<span>{props.groupLabel}</span>
<div className="flex flex-col gap-1">
{props.options.map((opt) => (
<label key={opt.value} className="inline-flex items-center gap-1">
<input
type="checkbox"
className="h-4 w-4 rounded border-slate-700 bg-slate-900 text-sky-500"
/>
<span>{opt.label}</span>
</label>
))}
</div>
</div>
);
}
if (block.type === "formRadio") {
const props = block.props as FormRadioBlockProps;
return (
<div key={block.id} className="flex flex-col gap-1 text-xs text-slate-200">
<span>{props.groupLabel}</span>
<div className="flex flex-col gap-1">
{props.options.map((opt) => (
<label key={opt.value} className="inline-flex items-center gap-1">
<input
type="radio"
className="h-4 w-4 border-slate-700 bg-slate-900 text-sky-500"
name={props.formFieldName}
/>
<span>{opt.label}</span>
</label>
))}
</div>
</div>
);
}
if (block.type === "button") {
const props = block.props as ButtonBlockProps;
@@ -91,6 +282,138 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
);
}
if (block.type === "divider") {
const props = block.props as DividerBlockProps;
const alignClass =
props.align === "center" ? "items-center" : props.align === "right" ? "items-end" : "items-start";
const thicknessClass = props.thickness === "medium" ? "h-[2px]" : "h-px";
const margin =
typeof props.marginYPx === "number"
? props.marginYPx
: props.marginY === "sm"
? 8
: props.marginY === "lg"
? 24
: 16;
const dividerStyle: CSSProperties = {
backgroundColor:
props.colorHex && props.colorHex.trim() !== ""
? props.colorHex
: "#475569", // 기본 slate 계열 색상
};
// 길이/너비: widthMode/widthPx 에 따라 조정한다.
const widthMode = props.widthMode ?? "full";
const innerStyle: CSSProperties = {};
let innerWidthClass = "w-full";
if (widthMode === "auto") {
innerWidthClass = "w-1/2";
} else if (widthMode === "fixed") {
innerWidthClass = "";
if (typeof props.widthPx === "number" && props.widthPx > 0) {
innerStyle.width = `${props.widthPx}px`;
} else {
innerStyle.width = "320px";
}
}
return (
<div
key={block.id}
className={`flex ${alignClass}`}
style={{ marginTop: margin, marginBottom: margin }}
>
<div className="max-w-xs" style={innerStyle}>
<div className={`${thicknessClass} ${innerWidthClass}`} style={dividerStyle} />
</div>
</div>
);
}
if (block.type === "list") {
const props = block.props as ListBlockProps;
const alignClass =
props.align === "center" ? "text-center" : props.align === "right" ? "text-right" : "text-left";
const bulletStyleRaw = props.bulletStyle ?? (props.ordered ? "decimal" : "disc");
const gapPx =
typeof props.gapYPx === "number"
? props.gapYPx
: props.gapY === "sm"
? 4
: props.gapY === "lg"
? 16
: 8;
const listStyle: CSSProperties = {};
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
listStyle.fontSize = props.fontSizeCustom;
}
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
listStyle.lineHeight = props.lineHeightCustom;
}
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
listStyle.color = props.textColorCustom;
}
const bulletStyle = bulletStyleRaw;
const itemsTree: ListItemNode[] =
(props as any).itemsTree && (props as any).itemsTree.length > 0
? ((props as any).itemsTree as ListItemNode[])
: props.items && props.items.length > 0
? props.items.map((text, index) => ({
id: `${block.id}_item_${index + 1}`,
text,
children: [],
}))
: [
{
id: `${block.id}_item_1`,
text: "리스트 아이템 1",
children: [],
},
];
const renderListNodes = (nodes: ListItemNode[], level: number): React.ReactNode => {
const isOrderedLevel = bulletStyle === "decimal"; // 1단계에서는 전체 레벨 동일 스타일 사용
const ListTag = isOrderedLevel ? "ol" : "ul";
return (
<ListTag
className={alignClass}
style={{
...listStyle,
listStyleType:
bulletStyle === "none"
? "none"
: bulletStyle,
paddingLeft: 12 + level * 8,
}}
>
{nodes.map((node, index) => (
<li
key={node.id}
style={index < nodes.length - 1 ? { marginBottom: gapPx } : undefined}
>
{node.text}
{node.children && node.children.length > 0 && renderListNodes(node.children, level + 1)}
</li>
))}
</ListTag>
);
};
return renderListNodes(itemsTree, 0);
}
if (block.type === "form") {
const props = block.props as FormBlockProps;
@@ -364,17 +687,30 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
const renderSection = (section: Block) => {
const props = section.props as SectionBlockProps;
const bgClass =
props.background === "muted" ? "bg-slate-900" : props.background === "primary" ? "bg-sky-900" : "bg-slate-950";
const pyClass = props.paddingY === "sm" ? "py-8" : props.paddingY === "lg" ? "py-20" : "py-12";
const { backgroundClass, paddingYClass, maxWidthClass, gapXClass, alignItemsClass } =
getSectionLayoutConfig(props);
const columns = props.columns && props.columns.length > 0 ? props.columns : [{ id: `${section.id}_col`, span: 12 }];
const sectionStyle: CSSProperties = {};
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
sectionStyle.backgroundColor = props.backgroundColorCustom;
}
if (typeof props.paddingYPx === "number" && props.paddingYPx > 0) {
sectionStyle.paddingTop = `${props.paddingYPx}px`;
sectionStyle.paddingBottom = `${props.paddingYPx}px`;
}
return (
<section key={section.id} className={`${bgClass} ${pyClass}`}>
<div className="mx-auto max-w-5xl px-4">
<div className="flex gap-8">
<section key={section.id} className={`${backgroundClass} ${paddingYClass}`} style={sectionStyle}>
<div
className={`mx-auto ${maxWidthClass} px-4`}
style={{ maxWidth: typeof props.maxWidthPx === "number" && props.maxWidthPx > 0 ? `${props.maxWidthPx}px` : undefined }}
>
<div
className={`flex ${gapXClass} ${alignItemsClass}`}
style={{ columnGap: typeof props.gapXPx === "number" && props.gapXPx > 0 ? `${props.gapXPx}px` : undefined }}
>
{columns.map((col) => {
const basis = `${(col.span / 12) * 100}%`;
const columnBlocks = blocks.filter((b) => b.sectionId === section.id && b.columnId === col.id);
+526 -41
View File
@@ -114,30 +114,369 @@ export interface ButtonBlockProps {
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[];
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;
}
// 폼 입력/셀렉트/체크박스/라디오 공통 스타일 속성
@@ -285,6 +624,7 @@ export interface Block {
export interface EditorState {
blocks: Block[];
selectedBlockId: string | null;
selectedListItemId?: string | null;
history: Block[][];
future: Block[][];
addTextBlock: () => void;
@@ -307,6 +647,11 @@ export interface EditorState {
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:
@@ -354,52 +699,53 @@ const getNextFormFieldName = (blocks: Block[], prefix: string): string => {
// 에디터 스토어 생성 함수 (테스트 및 앱에서 공유 사용)
// set/get 은 zustand 내부 구현에 의해 주입되므로, 여기서는 any 로 완화해 사용한다.
const createEditorState = (set: any, get: any): EditorState => ({
blocks: [],
selectedBlockId: null,
history: [],
future: [],
blocks: [],
selectedBlockId: null,
selectedListItemId: null,
history: [],
future: [],
// 텍스트 블록 추가: 기본 텍스트와 함께 생성 후 선택 상태로 만든다
addTextBlock: () => {
const id = createId();
// 텍스트 블록 추가: 기본 텍스트와 함께 생성 후 선택 상태로 만든다
addTextBlock: () => {
const id = createId();
const { selectedBlockId, blocks } = get();
let sectionId: string | null = null;
let columnId: string | null = null;
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;
}
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,
};
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: [],
}));
},
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
history: [...state.history, blocks],
future: [],
}));
},
// Hero 템플릿 섹션 추가: 섹션 1개와 기본 텍스트/버튼 블록들을 첫 컬럼에 배치한다
addHeroTemplateSection: () => {
@@ -419,6 +765,11 @@ const createEditorState = (set: any, get: any): EditorState => ({
});
},
// 리스트 아이템 선택 상태를 업데이트한다.
selectListItem: (itemId) => {
set({ selectedListItemId: itemId ?? null });
},
// Features 템플릿 섹션 추가: 3컬럼(4/4/4) 섹션과 각 컬럼의 제목/설명 텍스트를 생성한다
addFeaturesTemplateSection: () => {
const sectionId = createId();
@@ -566,7 +917,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
});
},
// 이미지 블록 추가: 기본 플레이스홀더와 함께 생성 후 선택 상태로 만든다
// 이미지 블록 추가: 기본 플레이스홀더와 스타일 기본값과 함께 생성 후 선택 상태로 만든다
addImageBlock: () => {
const id = createId();
@@ -594,6 +945,9 @@ const createEditorState = (set: any, get: any): EditorState => ({
props: {
src: "",
alt: "이미지 설명",
align: "center",
widthMode: "auto",
borderRadius: "md",
},
sectionId,
columnId,
@@ -820,6 +1174,13 @@ const createEditorState = (set: any, get: any): EditorState => ({
type: "list",
props: {
items: ["리스트 아이템 1"],
itemsTree: [
{
id: `${id}_item_1`,
text: "리스트 아이템 1",
children: [],
},
],
ordered: false,
align: "left",
},
@@ -970,6 +1331,130 @@ const createEditorState = (set: any, get: any): EditorState => ({
}));
},
// 선택된 리스트 아이템을 들여쓰기한다.
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();