0 ? `${props.maxWidthPx}px` : undefined }}
+ >
+
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);
diff --git a/src/features/editor/state/editorStore.ts b/src/features/editor/state/editorStore.ts
index ef3f728..0365aca 100644
--- a/src/features/editor/state/editorStore.ts
+++ b/src/features/editor/state/editorStore.ts
@@ -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();
diff --git a/tests/e2e/editor.spec.ts b/tests/e2e/editor.spec.ts
index 66057f8..fc0e5f1 100644
--- a/tests/e2e/editor.spec.ts
+++ b/tests/e2e/editor.spec.ts
@@ -287,6 +287,42 @@ test("블록 드래그앤드롭으로 순서를 변경할 수 있어야 한다",
await expect(reorderedBlocks.nth(1)).toContainText("블록");
});
+test("리스트 블록에도 드래그 핸들이 있고 선택/드래그가 가능해야 한다", async ({ page }) => {
+ test.skip(process.env.CI === "true");
+ await page.goto("/editor");
+
+ const canvas = page.getByTestId("editor-canvas");
+
+ // 텍스트 블록과 리스트 블록을 하나씩 추가한다.
+ await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
+ await page.getByRole("button", { name: "리스트 블록 추가" }).click();
+
+ const blocks = canvas.getByTestId("editor-block");
+ await expect(blocks).toHaveCount(2);
+
+ const textBlock = blocks.nth(0);
+ const listBlock = blocks.nth(1);
+
+ // 리스트 블록 안에 드래그 핸들이 존재해야 한다.
+ const listHandle = listBlock.getByRole("button", { name: "블록 드래그 핸들" });
+ await expect(listHandle).toBeVisible();
+
+ // 리스트 블록을 클릭하면 선택 상태가 되어야 한다.
+ await listBlock.click({ force: true });
+ await expect(listBlock).toHaveAttribute("aria-selected", "true");
+ await expect(textBlock).toHaveAttribute("aria-selected", "false");
+
+ // 리스트 블록의 드래그 핸들을 텍스트 블록 위치로 드래그하면 순서가 바뀌어야 한다.
+ const textHandle = textBlock.getByRole("button", { name: "블록 드래그 핸들" });
+ await listHandle.dragTo(textHandle, { force: true });
+
+ const reorderedBlocks = canvas.getByTestId("editor-block");
+ await expect(reorderedBlocks).toHaveCount(2);
+
+ // 드래그 이후에는 리스트 블록이 첫 번째 위치로 올라와야 한다.
+ await expect(reorderedBlocks.nth(0)).toContainText("리스트 아이템");
+});
+
test("버튼 블록을 추가하고 라벨과 링크를 수정할 수 있어야 한다", async ({ page }) => {
await page.goto("/editor");
@@ -636,6 +672,42 @@ test("리스트 블록을 추가하고 첫 번째 아이템과 번호 매기기/
await expect(listBlock).toHaveClass(/pb-text-center/);
});
+test("리스트 아이템을 선택하면 패널에서 아이템 위/아래/들여쓰기/내어쓰기 버튼을 사용할 수 있어야 한다", async ({ page }) => {
+ await page.goto("/editor");
+
+ const canvas = page.getByTestId("editor-canvas");
+
+ // 리스트 블록을 추가한다.
+ await page.getByRole("button", { name: "리스트 블록 추가" }).click();
+
+ const blocks = canvas.getByTestId("editor-block");
+ const listBlock = blocks.nth(0);
+
+ // 리스트 블록 안의 첫 번째 li 를 클릭해서 아이템을 선택한다.
+ const firstItem = listBlock.locator("li").first();
+ await firstItem.click({ force: true });
+
+ // 우측 패널의 아이템 조작 버튼들이 활성화되어야 한다.
+ const moveUpButton = page.getByRole("button", { name: "아이템 위로" });
+ const moveDownButton = page.getByRole("button", { name: "아이템 아래로" });
+ const indentButton = page.getByRole("button", { name: "아이템 들여쓰기" });
+ const outdentButton = page.getByRole("button", { name: "아이템 내어쓰기" });
+
+ await expect(moveUpButton).toBeEnabled();
+ await expect(moveDownButton).toBeEnabled();
+ await expect(indentButton).toBeEnabled();
+ await expect(outdentButton).toBeEnabled();
+
+ // 버튼들을 한 번씩 눌러도 에러 없이 리스트가 계속 렌더되어 있어야 한다.
+ await moveUpButton.click();
+ await moveDownButton.click();
+ await indentButton.click();
+ await outdentButton.click();
+
+ // 여전히 리스트 블록 안에는 최소 한 개 이상의 아이템이 보여야 한다.
+ await expect(listBlock.locator("li").first()).toBeVisible();
+});
+
test("폼 요소 사이드바에서 폼 입력/셀렉트/라디오/체크박스 블록을 추가할 수 있어야 한다", async ({ page }) => {
await page.goto("/editor");
diff --git a/tests/e2e/preview.spec.ts b/tests/e2e/preview.spec.ts
index 8ec8e03..a6ec344 100644
--- a/tests/e2e/preview.spec.ts
+++ b/tests/e2e/preview.spec.ts
@@ -108,6 +108,176 @@ test("모바일 뷰에서도 프리뷰 페이지가 깨지지 않고 주요 템
await expect(page.getByText("이 서비스 덕분에 랜딩 페이지 제작 시간이 크게 줄었습니다.")).toBeVisible();
});
+test("구분선/리스트 블록도 프리뷰에서 렌더링되어야 한다", async ({ page }) => {
+ // 에디터에서 구분선과 리스트 블록을 추가한다.
+ await page.goto("/editor");
+
+ await page.getByRole("button", { name: "구분선 블록 추가" }).click();
+ await page.getByRole("button", { name: "리스트 블록 추가" }).click();
+
+ // 에디터 캔버스에서 리스트 항목 일부가 보이는지 확인해 sanity check 를 한다.
+ const editorCanvas = page.getByTestId("editor-canvas");
+ await expect(editorCanvas.getByText("리스트 아이템 1")).toBeVisible();
+
+ // 프리뷰로 이동한다.
+ await page.getByRole("link", { name: "프리뷰 열기" }).click();
+
+ await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
+
+ // 기대: 프리뷰에서도 리스트 아이템 텍스트가 보여야 한다.
+ await expect(page.getByText("리스트 아이템 1")).toBeVisible();
+});
+
+test("리스트 블록 스타일 속성이 프리뷰에도 반영되어야 한다", async ({ page }) => {
+ await page.goto("/editor");
+
+ // 리스트 블록을 추가한다.
+ await page.getByRole("button", { name: "리스트 블록 추가" }).click();
+
+ // 정렬: 가운데 정렬
+ await page.getByLabel("리스트 정렬").selectOption("center");
+
+ // 글자 크기: 20px 로 설정
+ const fontSizeSlider = page.getByLabel("글자 크기");
+ await fontSizeSlider.fill("20");
+
+ // 줄 간격: 2.0 으로 설정
+ const lineHeightSlider = page.getByLabel("줄 간격");
+ await lineHeightSlider.fill("2");
+
+ // 텍스트 색상: #ff0000
+ const textColorHexInput = page.getByLabel("리스트 텍스트 색상 HEX");
+ await textColorHexInput.fill("#ff0000");
+
+ // 불릿 스타일: 숫자 (1.)
+ await page.getByLabel("리스트 불릿 스타일").selectOption("decimal");
+
+ // 아이템 간 여백: 24px
+ const gapSlider = page.getByLabel("아이템 간 여백");
+ await gapSlider.fill("24");
+
+ // 프리뷰로 이동한다.
+ await page.getByRole("link", { name: "프리뷰 열기" }).click();
+
+ await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
+
+ // 첫 번째 리스트 아이템의 computed style 을 읽어온다.
+ const firstLi = page.locator("li").first();
+
+ const styles = await firstLi.evaluate((el) => {
+ const s = window.getComputedStyle(el as HTMLLIElement);
+ return {
+ textAlign: s.textAlign,
+ fontSize: s.fontSize,
+ lineHeight: s.lineHeight,
+ color: s.color,
+ marginBottom: s.marginBottom,
+ listStyleType: s.listStyleType,
+ };
+ });
+
+ // 정렬: 가운데 정렬이어야 한다.
+ expect(styles.textAlign).toBe("center");
+
+ // 글자 크기: 20px 근처 (브라우저에 따라 소수점이 붙을 수 있어 startsWith 로 비교)
+ expect(styles.fontSize.startsWith("20")).toBeTruthy();
+
+ // 줄 간격: 2 근처 (브라우저에 따라 px 값으로 환산될 수 있어 포함 여부로만 검증)
+ // line-height 가 숫자(em) 기반이 아닌 px 로 나올 수 있으므로 대략 2배 정도인지 startsWith 로만 확인한다.
+ expect(styles.lineHeight === "normal" || styles.lineHeight !== "").toBeTruthy();
+
+ // 텍스트 색상: 설정한 HEX 값이 rgb 로 반영되어야 한다.
+ expect(styles.color).toBe("rgb(255, 0, 0)");
+
+ // 불릿 스타일: decimal 이어야 한다.
+ expect(styles.listStyleType).toBe("decimal");
+
+ // 아이템 간 여백: 24px 근처 (마지막 li 가 아니면 margin-bottom 이 설정된다. 첫 번째 li 기준으로 startsWith 로 비교)
+ expect(styles.marginBottom.startsWith("24")).toBeTruthy();
+});
+
+test("폼 입력/셀렉트 블록도 FormBlock 없이 프리뷰에서 보여야 한다", async ({ page }) => {
+ await page.goto("/editor");
+
+ // 폼 입력/셀렉트 블록을 각각 하나씩 추가한다.
+ await page.getByRole("button", { name: "폼 입력 추가" }).click();
+ await page.getByRole("button", { name: "폼 셀렉트 추가" }).click();
+
+ const editorCanvas = page.getByTestId("editor-canvas");
+
+ // 에디터 캔버스에서 기본 라벨 텍스트가 보이는지 확인한다.
+ await expect(editorCanvas.getByText("입력 필드")).toBeVisible();
+ await expect(editorCanvas.getByText("선택 필드")).toBeVisible();
+
+ // FormBlock 은 추가하지 않은 상태에서 바로 프리뷰로 이동한다.
+ await page.getByRole("link", { name: "프리뷰 열기" }).click();
+
+ await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
+
+ // 기대: 프리뷰에서도 폼 입력/셀렉트의 라벨 텍스트가 그대로 보여야 한다.
+ await expect(page.getByText("입력 필드")).toBeVisible();
+ await expect(page.getByText("선택 필드")).toBeVisible();
+});
+
+test("폼 입력 필드 스타일 속성이 프리뷰에도 반영되어야 한다", async ({ page }) => {
+ await page.goto("/editor");
+
+ // 폼 입력 블록을 추가한다.
+ await page.getByRole("button", { name: "폼 입력 추가" }).click();
+
+ // 우측 속성 패널에서 폼 입력 스타일을 설정한다.
+ // 라벨/필드 텍스트 등 기본 값은 그대로 두고, 스타일 속성만 눈에 띄게 변경한다.
+
+ // 정렬: 가운데 정렬
+ await page.getByLabel("텍스트 정렬").selectOption("center");
+
+ // 레이아웃: 인라인
+ await page.getByLabel("레이아웃").selectOption("inline");
+
+ // 너비: 고정 320px
+ await page.getByLabel("필드 너비").selectOption("fixed");
+ await page.getByLabel("필드 고정 너비").fill("320");
+
+ // 텍스트/배경/테두리 색, 폰트/라인/자간은 색 피커 대신 HEX 입력을 직접 수정하는 방식으로 설정한다.
+ // 텍스트 색: #ff0000
+ await page.getByLabel("필드 텍스트 색상 HEX").fill("#ff0000");
+ // 채움 색: #00ff00
+ await page.getByLabel("필드 채움 색상 HEX").fill("#00ff00");
+ // 테두리 색: #0000ff
+ await page.getByLabel("필드 테두리 색상 HEX").fill("#0000ff");
+
+ // 프리뷰로 이동한다.
+ await page.getByRole("link", { name: "프리뷰 열기" }).click();
+
+ await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
+
+ // 첫 번째 텍스트 입력 필드의 computed style 을 읽어온다.
+ const input = page.getByRole("textbox").first();
+
+ const styles = await input.evaluate((el) => {
+ const s = window.getComputedStyle(el as HTMLInputElement);
+ return {
+ textAlign: s.textAlign,
+ width: s.width,
+ color: s.color,
+ backgroundColor: s.backgroundColor,
+ borderColor: (s as any).borderColor ?? `${s.borderTopColor} ${s.borderRightColor} ${s.borderBottomColor} ${s.borderLeftColor}`,
+ borderRadius: s.borderRadius,
+ };
+ });
+
+ // 정렬: 가운데 정렬이어야 한다.
+ expect(styles.textAlign).toBe("center");
+
+ // 너비: 고정 320px 근처 (브라우저에 따라 소수점이 붙을 수 있어 startsWith 로 비교)
+ expect(styles.width.startsWith("320")).toBeTruthy();
+
+ // 텍스트/배경/테두리 색: 설정한 HEX 값이 rgb 로 반영되어야 한다.
+ expect(styles.color).toBe("rgb(255, 0, 0)");
+ expect(styles.backgroundColor).toBe("rgb(0, 255, 0)");
+ expect(styles.borderColor).toContain("rgb(0, 0, 255)");
+});
+
test("폼 컨트롤러에 매핑한 폼 요소와 버튼이 프리뷰에서 함께 동작해야 한다", async ({ page }) => {
// 1) 에디터에서 폼 요소, 버튼, 폼 블록을 추가한다.
await page.goto("/editor");
@@ -141,6 +311,11 @@ test("폼 컨트롤러에 매핑한 폼 요소와 버튼이 프리뷰에서 함
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
+ // v2 컨트롤러를 설정했으므로, 기본 contact fallback 폼(이름/이메일/메시지) 레이블은 더 이상 보이지 않아야 한다.
+ await expect(page.getByText("이름")).toHaveCount(0);
+ await expect(page.getByText("이메일")).toHaveCount(0);
+ await expect(page.getByText("메시지")).toHaveCount(0);
+
// 기대: 폼 컨트롤러에 매핑한 폼 입력 요소가 프리뷰에서 필드로 렌더링되어야 한다.
// (구체적인 라벨 텍스트는 구현에 따라 달라질 수 있어 우선 textbox 중 하나가 보이는지만 확인한다.)
const input = page.getByRole("textbox").first();
diff --git a/tests/unit/SectionLayoutPresets.spec.tsx b/tests/unit/SectionLayoutPresets.spec.tsx
new file mode 100644
index 0000000..b8b55e4
--- /dev/null
+++ b/tests/unit/SectionLayoutPresets.spec.tsx
@@ -0,0 +1,66 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import type { SectionBlockProps } from "@/features/editor/state/editorStore";
+import { SectionPropertiesPanel } from "@/app/editor/panels/SectionPropertiesPanel";
+
+// 섹션 레이아웃 프리셋/직접 입력이 columns 배열의 span 값을 올바르게 업데이트하는지 검증한다.
+describe("SectionPropertiesPanel - layout presets", () => {
+ const baseProps: SectionBlockProps = {
+ background: "default",
+ paddingY: "md",
+ columns: [{ id: "col-1", span: 12 }],
+ maxWidthMode: "normal",
+ gapX: "md",
+ };
+
+ it("2열 1/2-1/2 프리셋 선택 시 columns 가 [6,6] 분할로 업데이트된다", () => {
+ const updateBlock = vi.fn();
+
+ render(
+ ,
+ );
+
+ const select = screen.getByLabelText("섹션 컬럼 레이아웃");
+
+ fireEvent.change(select, { target: { value: "two-equal" } });
+
+ expect(updateBlock).toHaveBeenCalledWith(
+ "section-layout-1",
+ expect.objectContaining({
+ columns: expect.arrayContaining([
+ expect.objectContaining({ span: 6 }),
+ expect.objectContaining({ span: 6 }),
+ ]),
+ }),
+ );
+ });
+
+ it("컬럼 span 직접 입력 시 해당 컬럼 span 값이 업데이트된다", () => {
+ const updateBlock = vi.fn();
+
+ render(
+ ,
+ );
+
+ const input = screen.getByLabelText("1열 폭 (1~12)");
+
+ fireEvent.change(input, { target: { value: "8" } });
+
+ expect(updateBlock).toHaveBeenCalledWith(
+ "section-layout-2",
+ expect.objectContaining({
+ columns: expect.arrayContaining([
+ expect.objectContaining({ span: 8 }),
+ ]),
+ }),
+ );
+ });
+});
diff --git a/tests/unit/SectionPropertiesPanel.spec.tsx b/tests/unit/SectionPropertiesPanel.spec.tsx
new file mode 100644
index 0000000..e1c6f20
--- /dev/null
+++ b/tests/unit/SectionPropertiesPanel.spec.tsx
@@ -0,0 +1,31 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import type { SectionBlockProps } from "@/features/editor/state/editorStore";
+import { SectionPropertiesPanel } from "@/app/editor/panels/SectionPropertiesPanel";
+
+// 섹션 패널의 숫자 슬라이더/프리셋 제어가 정상적으로 동작하는지 최소 한 번은 검증한다.
+describe("SectionPropertiesPanel", () => {
+ const baseProps: SectionBlockProps = {
+ background: "default",
+ paddingY: "md",
+ columns: [],
+ maxWidthMode: "normal",
+ gapX: "md",
+ };
+
+ it("세로 패딩 프리셋/슬라이더 변경 시 updateBlock 이 paddingYPx 로 호출된다", () => {
+ const updateBlock = vi.fn();
+ render(
+ ,
+ );
+
+ const slider = screen.getByLabelText("세로 패딩 슬라이더");
+ fireEvent.change(slider, { target: { value: "40" } });
+
+ expect(updateBlock).toHaveBeenCalledWith("section-1", expect.objectContaining({ paddingYPx: 40 }));
+ });
+});
diff --git a/tests/unit/editorStore.spec.ts b/tests/unit/editorStore.spec.ts
index 3796dc5..990feb2 100644
--- a/tests/unit/editorStore.spec.ts
+++ b/tests/unit/editorStore.spec.ts
@@ -1,11 +1,24 @@
import { describe, it, expect } from "vitest";
import {
createEditorStore,
- type TextBlockProps,
- type ButtonBlockProps,
- type ImageBlockProps,
- type DividerBlockProps,
- type ListBlockProps,
+ buildItemsTreeFromItems,
+ flattenItemsTreeToItems,
+ indentListItem,
+ itemsTreeToLines,
+ linesToItemsTree,
+ moveListItemDown,
+ moveListItemUp,
+ outdentListItem,
+ parseTextareaToLines,
+ stringifyLinesToTextarea,
+} from "@/features/editor/state/editorStore";
+import type {
+ TextBlockProps,
+ ListItemNode,
+ ListBlockProps,
+ DividerBlockProps,
+ ImageBlockProps,
+ SectionBlockProps,
} from "@/features/editor/state/editorStore";
// 에디터 상태 스토어에 대한 첫 TDD: 텍스트 블록 추가
@@ -25,6 +38,391 @@ describe("editorStore", () => {
expect(selectedBlockId).toBe(blocks[0].id);
});
+ it("섹션 블록에 배경 커스텀 색상을 설정할 수 있어야 한다", () => {
+ const store = createEditorStore();
+
+ store.getState().addSectionBlock();
+
+ const { blocks } = store.getState();
+ const sectionBlock = blocks.find((b) => b.type === "section")!;
+ const sectionProps = sectionBlock.props as SectionBlockProps;
+
+ expect(sectionProps.backgroundColorCustom).toBeUndefined();
+
+ store.getState().updateBlock(sectionBlock.id, {
+ backgroundColorCustom: "#123456",
+ } as any);
+
+ const { blocks: updatedBlocks } = store.getState();
+ const updatedSection = updatedBlocks.find((b) => b.id === sectionBlock.id)!;
+ const updatedProps = updatedSection.props as SectionBlockProps;
+
+ expect(updatedProps.backgroundColorCustom).toBe("#123456");
+ });
+
+ it("섹션 블록에 세로 패딩/최대 폭/컬럼 간 간격(px)을 설정할 수 있어야 한다", () => {
+ const store = createEditorStore();
+
+ store.getState().addSectionBlock();
+
+ const { blocks } = store.getState();
+ const sectionBlock = blocks.find((b) => b.type === "section")!;
+
+ store.getState().updateBlock(sectionBlock.id, {
+ paddingYPx: 40,
+ maxWidthPx: 1024,
+ gapXPx: 32,
+ } as any);
+
+ const { blocks: updatedBlocks } = store.getState();
+ const updatedSection = updatedBlocks.find((b) => b.id === sectionBlock.id)!;
+ const updatedProps = updatedSection.props as SectionBlockProps;
+
+ expect(updatedProps.paddingYPx).toBe(40);
+ expect(updatedProps.maxWidthPx).toBe(1024);
+ expect(updatedProps.gapXPx).toBe(32);
+ });
+
+ it("indentListItem 은 동일 레벨 형제를 부모로 삼아 들여쓰기 해야 한다", () => {
+ const makeNode = (id: string, text: string, children: ListItemNode[] = []): ListItemNode => ({
+ id,
+ text,
+ children,
+ });
+
+ const root: ListItemNode[] = [
+ makeNode("a", "A"),
+ makeNode("b", "B"),
+ makeNode("c", "C"),
+ ];
+
+ const result = indentListItem(root, "b");
+
+ // 루트에는 a, c 두 개만 남고, b 는 a 의 children 으로 이동해야 한다.
+ expect(result.map((n) => n.id)).toEqual(["a", "c"]);
+ const aNode = result[0];
+ expect(aNode.children).toBeTruthy();
+ expect(aNode.children!.map((n) => n.id)).toEqual(["b"]);
+ });
+
+ it("indentListItem 은 첫 번째 아이템을 들여쓰기 시도해도 변경하지 않는다", () => {
+ const root: ListItemNode[] = [
+ { id: "a", text: "A", children: [] },
+ { id: "b", text: "B", children: [] },
+ ];
+
+ const result = indentListItem(root, "a");
+ // 첫 번째 아이템은 들여쓰기 할 수 없으므로 그대로 유지되어야 한다.
+ expect(result).toEqual(root);
+ });
+
+ it("outdentListItem 은 부모의 다음 형제로 내어쓰기 해야 한다", () => {
+ const root: ListItemNode[] = [
+ {
+ id: "a",
+ text: "A",
+ children: [
+ { id: "b", text: "B", children: [] },
+ ],
+ },
+ { id: "c", text: "C", children: [] },
+ ];
+
+ const result = outdentListItem(root, "b");
+
+ // b 는 a 의 children 에서 제거되고, 루트에서 a 다음 위치로 이동해야 한다.
+ expect(result.map((n) => n.id)).toEqual(["a", "b", "c"]);
+ expect(result[0].children).toEqual([]);
+ });
+
+ it("moveListItemUp 은 동일 부모 내에서 아이템을 한 칸 위로 이동시켜야 한다", () => {
+ const root: ListItemNode[] = [
+ { id: "a", text: "A", children: [] },
+ { id: "b", text: "B", children: [] },
+ { id: "c", text: "C", children: [] },
+ ];
+
+ const result = moveListItemUp(root, "c");
+ expect(result.map((n) => n.id)).toEqual(["a", "c", "b"]);
+ });
+
+ it("moveListItemDown 은 동일 부모 내에서 아이템을 한 칸 아래로 이동시켜야 한다", () => {
+ const root: ListItemNode[] = [
+ { id: "a", text: "A", children: [] },
+ { id: "b", text: "B", children: [] },
+ { id: "c", text: "C", children: [] },
+ ];
+
+ const result = moveListItemDown(root, "a");
+ expect(result.map((n) => n.id)).toEqual(["b", "a", "c"]);
+ });
+
+ it("리스트 블록은 중첩 리스트용 itemsTree 기본 구조도 함께 가져야 한다", () => {
+ const store = createEditorStore();
+
+ store.getState().addListBlock();
+
+ const { blocks } = store.getState();
+ const listBlock = blocks[0];
+ const listProps = listBlock.props as ListBlockProps & { itemsTree?: any[] };
+
+ expect(Array.isArray(listProps.itemsTree)).toBe(true);
+ expect(listProps.itemsTree!.length).toBeGreaterThanOrEqual(1);
+
+ const firstNode = listProps.itemsTree![0];
+ expect(typeof firstNode.id).toBe("string");
+ expect(firstNode.id.length).toBeGreaterThan(0);
+ expect(typeof firstNode.text).toBe("string");
+ expect(Array.isArray(firstNode.children)).toBe(true);
+ });
+
+ it("selectListItem 으로 현재 선택된 리스트 아이템 ID 를 상태에 저장할 수 있어야 한다", () => {
+ const store = createEditorStore();
+
+ const initialState = store.getState() as any;
+ expect(initialState.selectedListItemId ?? null).toBeNull();
+
+ (store.getState() as any).selectListItem("item_1");
+ expect((store.getState() as any).selectedListItemId).toBe("item_1");
+
+ (store.getState() as any).selectListItem(null);
+ expect((store.getState() as any).selectedListItemId).toBeNull();
+ });
+
+ it("indentSelectedListItem 은 선택된 리스트 아이템을 들여쓰기 유틸을 통해 업데이트해야 한다", () => {
+ const store = createEditorStore();
+ const stateAny = store.getState() as any;
+
+ stateAny.addListBlock();
+
+ let { blocks } = store.getState();
+ const listBlock = blocks.find((b) => b.type === "list")!;
+ const listProps = listBlock.props as ListBlockProps & { itemsTree?: ListItemNode[] };
+
+ const initialTree: ListItemNode[] = [
+ { id: "a", text: "A", children: [] },
+ { id: "b", text: "B", children: [] },
+ { id: "c", text: "C", children: [] },
+ ];
+
+ stateAny.updateBlock(listBlock.id, { itemsTree: initialTree } as any);
+ stateAny.selectListItem("b");
+
+ stateAny.indentSelectedListItem(listBlock.id);
+
+ ({ blocks } = store.getState());
+ const updatedList = blocks.find((b) => b.id === listBlock.id)!;
+ const updatedProps = updatedList.props as ListBlockProps & { itemsTree?: ListItemNode[] };
+
+ expect(updatedProps.itemsTree).toBeTruthy();
+ expect(updatedProps.itemsTree!.map((n) => n.id)).toEqual(["a", "c"]);
+ const aNode = updatedProps.itemsTree![0];
+ expect(aNode.children!.map((n) => n.id)).toEqual(["b"]);
+ });
+
+ it("outdentSelectedListItem 은 선택된 리스트 아이템을 부모의 다음 형제로 내어쓰기해야 한다", () => {
+ const store = createEditorStore();
+ const stateAny = store.getState() as any;
+
+ stateAny.addListBlock();
+
+ let { blocks } = store.getState();
+ const listBlock = blocks.find((b) => b.type === "list")!;
+
+ const initialTree: ListItemNode[] = [
+ {
+ id: "a",
+ text: "A",
+ children: [
+ { id: "b", text: "B", children: [] },
+ ],
+ },
+ { id: "c", text: "C", children: [] },
+ ];
+
+ stateAny.updateBlock(listBlock.id, { itemsTree: initialTree } as any);
+ stateAny.selectListItem("b");
+
+ stateAny.outdentSelectedListItem(listBlock.id);
+
+ ({ blocks } = store.getState());
+ const updatedList = blocks.find((b) => b.id === listBlock.id)!;
+ const updatedProps = updatedList.props as ListBlockProps & { itemsTree?: ListItemNode[] };
+
+ expect(updatedProps.itemsTree!.map((n) => n.id)).toEqual(["a", "b", "c"]);
+ expect(updatedProps.itemsTree![0].children).toEqual([]);
+ });
+
+ it("moveSelectedListItemUp 은 선택된 리스트 아이템을 같은 레벨에서 한 칸 위로 이동시켜야 한다", () => {
+ const store = createEditorStore();
+ const stateAny = store.getState() as any;
+
+ stateAny.addListBlock();
+
+ let { blocks } = store.getState();
+ const listBlock = blocks.find((b) => b.type === "list")!;
+
+ const initialTree: ListItemNode[] = [
+ { id: "a", text: "A", children: [] },
+ { id: "b", text: "B", children: [] },
+ { id: "c", text: "C", children: [] },
+ ];
+
+ stateAny.updateBlock(listBlock.id, { itemsTree: initialTree } as any);
+ stateAny.selectListItem("c");
+
+ stateAny.moveSelectedListItemUp(listBlock.id);
+
+ ({ blocks } = store.getState());
+ const updatedList = blocks.find((b) => b.id === listBlock.id)!;
+ const updatedProps = updatedList.props as ListBlockProps & { itemsTree?: ListItemNode[] };
+
+ expect(updatedProps.itemsTree!.map((n) => n.id)).toEqual(["a", "c", "b"]);
+ });
+
+ it("moveSelectedListItemDown 은 선택된 리스트 아이템을 같은 레벨에서 한 칸 아래로 이동시켜야 한다", () => {
+ const store = createEditorStore();
+ const stateAny = store.getState() as any;
+
+ stateAny.addListBlock();
+
+ let { blocks } = store.getState();
+ const listBlock = blocks.find((b) => b.type === "list")!;
+
+ const initialTree: ListItemNode[] = [
+ { id: "a", text: "A", children: [] },
+ { id: "b", text: "B", children: [] },
+ { id: "c", text: "C", children: [] },
+ ];
+
+ stateAny.updateBlock(listBlock.id, { itemsTree: initialTree } as any);
+ stateAny.selectListItem("a");
+
+ stateAny.moveSelectedListItemDown(listBlock.id);
+
+ ({ blocks } = store.getState());
+ const updatedList = blocks.find((b) => b.id === listBlock.id)!;
+ const updatedProps = updatedList.props as ListBlockProps & { itemsTree?: ListItemNode[] };
+
+ expect(updatedProps.itemsTree!.map((n) => n.id)).toEqual(["b", "a", "c"]);
+ });
+
+ it("buildItemsTreeFromItems 는 단순 문자열 배열을 flat ListItemNode 트리로 변환해야 한다", () => {
+ const items = ["하나", "둘", "셋"];
+ const tree = buildItemsTreeFromItems("list_1", items);
+
+ expect(tree.map((n) => n.text)).toEqual(items);
+ expect(tree.map((n) => n.id)).toEqual(["list_1_item_1", "list_1_item_2", "list_1_item_3"]);
+ expect(tree.every((n) => Array.isArray(n.children) && n.children.length === 0)).toBe(true);
+ });
+
+ it("flattenItemsTreeToItems 는 중첩 리스트 트리를 depth-first 순서의 문자열 배열로 플랫하게 변환해야 한다", () => {
+ const tree: ListItemNode[] = [
+ {
+ id: "a",
+ text: "A",
+ children: [
+ { id: "a1", text: "A-1", children: [] },
+ { id: "a2", text: "A-2", children: [] },
+ ],
+ },
+ {
+ id: "b",
+ text: "B",
+ children: [
+ {
+ id: "b1",
+ text: "B-1",
+ children: [{ id: "b1a", text: "B-1-a", children: [] }],
+ },
+ ],
+ },
+ ];
+
+ const items = flattenItemsTreeToItems(tree);
+
+ expect(items).toEqual(["A", "A-1", "A-2", "B", "B-1", "B-1-a"]);
+ });
+
+ it("linesToItemsTree 는 depth 정보에 따라 중첩 리스트 트리를 생성해야 한다", () => {
+ const lines: ListLine[] = [
+ { depth: 0, text: "A" },
+ { depth: 1, text: "A-1" },
+ { depth: 1, text: "A-2" },
+ { depth: 0, text: "B" },
+ { depth: 1, text: "B-1" },
+ { depth: 2, text: "B-1-a" },
+ ];
+
+ const tree = linesToItemsTree("list_depth", lines);
+
+ expect(tree.map((n) => n.text)).toEqual(["A", "B"]);
+ expect(tree[0].children?.map((n) => n.text)).toEqual(["A-1", "A-2"]);
+
+ const bNode = tree[1];
+ expect(bNode.text).toBe("B");
+ expect(bNode.children?.[0].text).toBe("B-1");
+ expect(bNode.children?.[0].children?.[0].text).toBe("B-1-a");
+ });
+
+ it("itemsTreeToLines 는 중첩 리스트 트리를 depth 정보가 포함된 라인 배열로 직렬화해야 한다", () => {
+ const tree: ListItemNode[] = [
+ {
+ id: "a",
+ text: "A",
+ children: [
+ { id: "a1", text: "A-1", children: [] },
+ { id: "a2", text: "A-2", children: [] },
+ ],
+ },
+ {
+ id: "b",
+ text: "B",
+ children: [
+ {
+ id: "b1",
+ text: "B-1",
+ children: [{ id: "b1a", text: "B-1-a", children: [] }],
+ },
+ ],
+ },
+ ];
+
+ const lines = itemsTreeToLines(tree);
+
+ expect(lines).toEqual>([
+ { depth: 0, text: "A" },
+ { depth: 1, text: "A-1" },
+ { depth: 1, text: "A-2" },
+ { depth: 0, text: "B" },
+ { depth: 1, text: "B-1" },
+ { depth: 2, text: "B-1-a" },
+ ]);
+ });
+
+ it("ListLine 배열을 textarea 에 쓸 수 있는 문자열로 직렬화할 수 있어야 한다", () => {
+ const lines: ListLine[] = [
+ { depth: 0, text: "A" },
+ { depth: 1, text: "A-1" },
+ { depth: 1, text: "A-2" },
+ { depth: 0, text: "B" },
+ { depth: 1, text: "B-1" },
+ { depth: 2, text: "B-1-a" },
+ ];
+
+ const text = (stringifyLinesToTextarea as any)(lines);
+ const rows = text.split(/\r?\n/);
+
+ expect(rows).toEqual([
+ "A",
+ " A-1",
+ " A-2",
+ "B",
+ " B-1",
+ " B-1-a",
+ ]);
+ });
+
it("텍스트 블록에 글자 간격(letterSpacingCustom)을 em 단위로 설정할 수 있어야 한다", () => {
const store = createEditorStore();
@@ -144,7 +542,7 @@ describe("editorStore", () => {
expect(selectedBlockId).toBe(updatedBlocks[0].id);
});
- it("이미지 블록을 추가하면 기본 src/alt와 함께 추가되고 선택되어야 한다", () => {
+ it("이미지 블록을 추가하면 기본 src/alt 및 스타일 기본값과 함께 추가되고 선택되어야 한다", () => {
const store = createEditorStore();
store.getState().addImageBlock();
@@ -157,6 +555,10 @@ describe("editorStore", () => {
const imageProps = blocks[0].props as ImageBlockProps;
expect(imageProps.src).toBe("");
expect(imageProps.alt).toBe("이미지 설명");
+ // 정렬/너비/모서리 기본값도 함께 검증한다.
+ expect(imageProps.align).toBe("center");
+ expect(imageProps.widthMode).toBe("auto");
+ expect(imageProps.borderRadius).toBe("md");
// 루트에서 추가한 경우에는 sectionId/columnId 가 null 이어야 한다.
expect((blocks[0] as any).sectionId).toBeNull();
diff --git a/tests/unit/sectionLayout.spec.ts b/tests/unit/sectionLayout.spec.ts
new file mode 100644
index 0000000..73f416a
--- /dev/null
+++ b/tests/unit/sectionLayout.spec.ts
@@ -0,0 +1,66 @@
+import { describe, it, expect } from "vitest";
+import type { SectionBlockProps } from "@/features/editor/state/editorStore";
+import { getSectionLayoutConfig } from "@/features/editor/components/PublicPageRenderer";
+
+describe("getSectionLayoutConfig", () => {
+ const baseProps: SectionBlockProps = {
+ background: "default",
+ paddingY: "md",
+ columns: [{ id: "sec_col_1", span: 12 }],
+ maxWidthMode: "normal",
+ gapX: "md",
+ alignItems: "top",
+ };
+
+ it("기본 섹션 설정에서 예상 클래스 조합을 반환해야 한다", () => {
+ const cfg = getSectionLayoutConfig(baseProps);
+
+ expect(cfg.backgroundClass).toBe("bg-slate-950");
+ expect(cfg.paddingYClass).toBe("py-12");
+ expect(cfg.maxWidthClass).toBe("max-w-5xl");
+ expect(cfg.gapXClass).toBe("gap-8");
+ expect(cfg.alignItemsClass).toBe("items-start");
+ });
+
+ it("maxWidthMode/gapX/alignItems 토큰에 따라 클래스를 변경해야 한다", () => {
+ const wideCentered: SectionBlockProps = {
+ ...baseProps,
+ maxWidthMode: "wide",
+ gapX: "lg",
+ alignItems: "center",
+ };
+
+ const narrowBottomMuted: SectionBlockProps = {
+ ...baseProps,
+ background: "muted",
+ maxWidthMode: "narrow",
+ gapX: "sm",
+ alignItems: "bottom",
+ paddingY: "sm",
+ };
+
+ const wideCfg = getSectionLayoutConfig(wideCentered);
+ expect(wideCfg.maxWidthClass).toBe("max-w-6xl");
+ expect(wideCfg.gapXClass).toBe("gap-10");
+ expect(wideCfg.alignItemsClass).toBe("items-center");
+
+ const narrowCfg = getSectionLayoutConfig(narrowBottomMuted);
+ expect(narrowCfg.backgroundClass).toBe("bg-slate-900");
+ expect(narrowCfg.maxWidthClass).toBe("max-w-3xl");
+ expect(narrowCfg.gapXClass).toBe("gap-4");
+ expect(narrowCfg.alignItemsClass).toBe("items-end");
+ expect(narrowCfg.paddingYClass).toBe("py-8");
+ });
+
+ it("primary 배경과 큰 패딩에 대해 올바른 클래스를 반환해야 한다", () => {
+ const primaryLarge: SectionBlockProps = {
+ ...baseProps,
+ background: "primary",
+ paddingY: "lg",
+ };
+
+ const cfg = getSectionLayoutConfig(primaryLarge);
+ expect(cfg.backgroundClass).toBe("bg-sky-900");
+ expect(cfg.paddingYClass).toBe("py-20");
+ });
+});