에디터 정리 및 버그 수정
This commit is contained in:
@@ -250,9 +250,19 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
|
let innerHtml = escapeHtml(label);
|
||||||
|
|
||||||
|
if (typeof (props as any).imageSrc === "string" && (props as any).imageSrc.trim() !== "") {
|
||||||
|
const imgSrc = escapeAttr((props as any).imageSrc.trim());
|
||||||
|
const altRaw = (props as any).imageAlt as string | undefined;
|
||||||
|
const altValue = altRaw && altRaw.trim() !== "" ? altRaw.trim() : label;
|
||||||
|
const imgAlt = escapeAttr(altValue);
|
||||||
|
innerHtml = `<img src="${imgSrc}" alt="${imgAlt}" />${innerHtml}`;
|
||||||
|
}
|
||||||
|
|
||||||
return `<div class="${tokens.alignClass}"><a href="${escapeAttr(
|
return `<div class="${tokens.alignClass}"><a href="${escapeAttr(
|
||||||
href,
|
href,
|
||||||
)}" class="${btnClasses}"${styleAttr}>${escapeHtml(label)}</a></div>`;
|
)}" class="${btnClasses}"${styleAttr}>${innerHtml}</a></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (block.type === "video") {
|
if (block.type === "video") {
|
||||||
|
|||||||
@@ -233,10 +233,16 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
|||||||
)
|
)
|
||||||
.map((fieldBlock) => {
|
.map((fieldBlock) => {
|
||||||
const fieldId = fieldBlock.id;
|
const fieldId = fieldBlock.id;
|
||||||
const fieldLabel = (fieldBlock.props as any).label ??
|
const anyProps: any = fieldBlock.props ?? {};
|
||||||
(fieldBlock.props as any).groupLabel ??
|
|
||||||
(fieldBlock.props as any).formFieldName ??
|
const transmissionKey: string | undefined = anyProps.formFieldName;
|
||||||
fieldId;
|
const labelText: string | undefined = anyProps.label ?? anyProps.groupLabel;
|
||||||
|
|
||||||
|
const displayLabel = transmissionKey
|
||||||
|
? labelText && labelText !== transmissionKey
|
||||||
|
? `${transmissionKey} (${labelText})`
|
||||||
|
: transmissionKey
|
||||||
|
: labelText || fieldId;
|
||||||
|
|
||||||
const checked = (formProps.fieldIds ?? []).includes(fieldId);
|
const checked = (formProps.fieldIds ?? []).includes(fieldId);
|
||||||
|
|
||||||
@@ -260,7 +266,7 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
|||||||
} as any);
|
} as any);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span>{fieldLabel}</span>
|
<span>{displayLabel}</span>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -283,9 +289,17 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
|||||||
.filter((b) => b.type === "button")
|
.filter((b) => b.type === "button")
|
||||||
.map((buttonBlock) => {
|
.map((buttonBlock) => {
|
||||||
const btnProps = buttonBlock.props as ButtonBlockProps;
|
const btnProps = buttonBlock.props as ButtonBlockProps;
|
||||||
|
const transmissionKey = btnProps.formFieldName;
|
||||||
|
const labelText = btnProps.label;
|
||||||
|
const displayLabel = transmissionKey
|
||||||
|
? labelText && labelText !== transmissionKey
|
||||||
|
? `${transmissionKey} (${labelText})`
|
||||||
|
: transmissionKey
|
||||||
|
: labelText || buttonBlock.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<option key={buttonBlock.id} value={buttonBlock.id}>
|
<option key={buttonBlock.id} value={buttonBlock.id}>
|
||||||
{btnProps.label || buttonBlock.id}
|
{displayLabel}
|
||||||
</option>
|
</option>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
+152
-6
@@ -3,7 +3,7 @@
|
|||||||
import type { CSSProperties, ReactNode } from "react";
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
import { Fragment, useEffect, useState } from "react";
|
import { Fragment, useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { FilePlus2, Trash2, Eye, Pencil, ListChecks, Undo2, Redo2 } from "lucide-react";
|
import { FilePlus2, Trash2, Eye, Pencil, ListChecks, Undo2, Redo2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
@@ -72,6 +72,7 @@ import { EditorCanvas } from "./EditorCanvas";
|
|||||||
|
|
||||||
export default function EditorPage() {
|
export default function EditorPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const blocks = useEditorStore((state) => state.blocks);
|
const blocks = useEditorStore((state) => state.blocks);
|
||||||
const selectedBlockId = useEditorStore((state) => state.selectedBlockId);
|
const selectedBlockId = useEditorStore((state) => state.selectedBlockId);
|
||||||
const selectedListItemId = useEditorStore((state) => (state as any).selectedListItemId as string | null | undefined);
|
const selectedListItemId = useEditorStore((state) => (state as any).selectedListItemId as string | null | undefined);
|
||||||
@@ -121,6 +122,13 @@ export default function EditorPage() {
|
|||||||
(state) => (state as any).projectConfig as ProjectConfig,
|
(state) => (state as any).projectConfig as ProjectConfig,
|
||||||
);
|
);
|
||||||
const updateProjectConfig = useEditorStore((state) => state.updateProjectConfig);
|
const updateProjectConfig = useEditorStore((state) => state.updateProjectConfig);
|
||||||
|
const resetHistory = useEditorStore((state) => (state as any).resetHistory as () => void);
|
||||||
|
|
||||||
|
const [authUserId, setAuthUserId] = useState<string | null>(null);
|
||||||
|
const [authChecked, setAuthChecked] = useState(false);
|
||||||
|
|
||||||
|
const [initialSlugFromQuery, setInitialSlugFromQuery] = useState<string | null>(null);
|
||||||
|
const [hasLoadedInitialProjectFromSlug, setHasLoadedInitialProjectFromSlug] = useState(false);
|
||||||
|
|
||||||
const [editingBlockId, setEditingBlockId] = useState<string | null>(null);
|
const [editingBlockId, setEditingBlockId] = useState<string | null>(null);
|
||||||
const [editingText, setEditingText] = useState("");
|
const [editingText, setEditingText] = useState("");
|
||||||
@@ -140,17 +148,105 @@ export default function EditorPage() {
|
|||||||
const canUndo = historyLength > 0;
|
const canUndo = historyLength > 0;
|
||||||
const canRedo = futureLength > 0;
|
const canRedo = futureLength > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchParams) return;
|
||||||
|
|
||||||
|
const slugParam = searchParams.get("slug");
|
||||||
|
if (slugParam && !initialSlugFromQuery) {
|
||||||
|
setInitialSlugFromQuery(slugParam);
|
||||||
|
}
|
||||||
|
}, [searchParams, initialSlugFromQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialSlugFromQuery) return;
|
||||||
|
const trimmed = initialSlugFromQuery.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
|
||||||
|
const currentSlug = (projectConfig?.slug ?? "").trim();
|
||||||
|
if (currentSlug !== trimmed) {
|
||||||
|
updateProjectConfig({ slug: trimmed });
|
||||||
|
}
|
||||||
|
}, [initialSlugFromQuery, projectConfig?.slug, updateProjectConfig]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authChecked) return;
|
||||||
|
if (!initialSlugFromQuery) return;
|
||||||
|
if (hasLoadedInitialProjectFromSlug) return;
|
||||||
|
|
||||||
|
const slug = initialSlugFromQuery.trim();
|
||||||
|
if (!slug) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const loadFromServer = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}`);
|
||||||
|
if (!res.ok || cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (data && Array.isArray(data.contentJson)) {
|
||||||
|
replaceBlocks(data.contentJson as any);
|
||||||
|
if (data.title && typeof data.title === "string") {
|
||||||
|
updateProjectConfig({ title: data.title, slug });
|
||||||
|
} else {
|
||||||
|
updateProjectConfig({ slug });
|
||||||
|
}
|
||||||
|
// 다른 slug/사용자에서 불러온 뒤에는 이전 프로젝트의 undo/redo 히스토리를 끊어준다.
|
||||||
|
resetHistory();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 서버 로드 실패는 조용히 무시하고, 사용자가 수동으로 불러오기 할 수 있도록 둔다.
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setHasLoadedInitialProjectFromSlug(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadFromServer();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [authChecked, initialSlugFromQuery, hasLoadedInitialProjectFromSlug, replaceBlocks]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const checkAuth = async () => {
|
const checkAuth = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/auth/me");
|
const res = await fetch("/api/auth/me");
|
||||||
if (!res.ok && res.status === 401 && !cancelled) {
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
let data: any = null;
|
||||||
|
try {
|
||||||
|
data = await res.json();
|
||||||
|
} catch {
|
||||||
|
data = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data && typeof data.id === "string") {
|
||||||
|
setAuthUserId(data.id);
|
||||||
|
} else {
|
||||||
|
setAuthUserId(null);
|
||||||
|
}
|
||||||
|
} else if (res.status === 401) {
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
|
setAuthUserId(null);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 네트워크 오류 등은 일단 무시하고, 페이지 접근을 막지 않는다.
|
if (!cancelled) {
|
||||||
|
setAuthUserId(null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setAuthChecked(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -353,6 +449,15 @@ export default function EditorPage() {
|
|||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
if (parsed && Array.isArray(parsed.contentJson)) {
|
if (parsed && Array.isArray(parsed.contentJson)) {
|
||||||
replaceBlocks(parsed.contentJson as any);
|
replaceBlocks(parsed.contentJson as any);
|
||||||
|
if (parsed.title && typeof parsed.title === "string") {
|
||||||
|
updateProjectConfig({ title: parsed.title, slug });
|
||||||
|
} else {
|
||||||
|
updateProjectConfig({ slug });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다른 프로젝트(slug)를 불러왔으므로 이전 history/future 를 초기화한다.
|
||||||
|
resetHistory();
|
||||||
|
|
||||||
setProjectMessage(`로컬에서 프로젝트를 불러왔습니다: ${slug}`);
|
setProjectMessage(`로컬에서 프로젝트를 불러왔습니다: ${slug}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -372,6 +477,15 @@ export default function EditorPage() {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data && Array.isArray(data.contentJson)) {
|
if (data && Array.isArray(data.contentJson)) {
|
||||||
replaceBlocks(data.contentJson as any);
|
replaceBlocks(data.contentJson as any);
|
||||||
|
if (data.title && typeof data.title === "string") {
|
||||||
|
updateProjectConfig({ title: data.title, slug });
|
||||||
|
} else {
|
||||||
|
updateProjectConfig({ slug });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서버에서 다른 프로젝트를 불러온 뒤에는 이전 history/future 를 모두 비운다.
|
||||||
|
resetHistory();
|
||||||
|
|
||||||
setProjectMessage(`프로젝트를 불러왔습니다: ${slug}`);
|
setProjectMessage(`프로젝트를 불러왔습니다: ${slug}`);
|
||||||
} else {
|
} else {
|
||||||
setProjectMessage("프로젝트 데이터 형식이 올바르지 않습니다.");
|
setProjectMessage("프로젝트 데이터 형식이 올바르지 않습니다.");
|
||||||
@@ -586,6 +700,7 @@ export default function EditorPage() {
|
|||||||
const rootBlocks = blocks.filter((block) => !block.sectionId);
|
const rootBlocks = blocks.filter((block) => !block.sectionId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!authChecked) return;
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
const slug = projectConfig?.slug?.trim();
|
const slug = projectConfig?.slug?.trim();
|
||||||
@@ -596,17 +711,35 @@ export default function EditorPage() {
|
|||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw) as { blocks?: Block[]; projectConfig?: ProjectConfig };
|
const parsed = JSON.parse(raw) as {
|
||||||
|
blocks?: Block[];
|
||||||
|
projectConfig?: ProjectConfig;
|
||||||
|
savedByUserId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const savedByUserId = parsed.savedByUserId ?? null;
|
||||||
|
|
||||||
|
if (authUserId) {
|
||||||
|
if (savedByUserId && savedByUserId !== authUserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (savedByUserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (Array.isArray(parsed.blocks)) {
|
if (Array.isArray(parsed.blocks)) {
|
||||||
replaceBlocks(parsed.blocks as any);
|
replaceBlocks(parsed.blocks as any);
|
||||||
}
|
}
|
||||||
if (parsed.projectConfig) {
|
if (parsed.projectConfig) {
|
||||||
updateProjectConfig(parsed.projectConfig as ProjectConfig);
|
updateProjectConfig(parsed.projectConfig as ProjectConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// autosave 로 전체 프로젝트를 복원한 경우에도 이전 history/future 는 끊어준다.
|
||||||
|
resetHistory();
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}, [projectConfig?.slug, replaceBlocks, updateProjectConfig]);
|
}, [authChecked, authUserId, projectConfig?.slug, replaceBlocks, updateProjectConfig]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
@@ -615,9 +748,22 @@ export default function EditorPage() {
|
|||||||
if (!slug) return;
|
if (!slug) return;
|
||||||
|
|
||||||
const key = `pb:autosave:${slug}`;
|
const key = `pb:autosave:${slug}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = window.localStorage.getItem(key);
|
||||||
|
if (existing && !authChecked) {
|
||||||
|
// 초기 렌더 시 이미 autosave 스냅샷이 있는 경우,
|
||||||
|
// 인증 체크 및 복원(useEffect)이 끝나기 전까지는 기존 스냅샷을 덮어쓰지 않는다.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 기존 값 조회 에러는 무시하고, 아래 저장 시도는 계속 진행한다.
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
blocks,
|
blocks,
|
||||||
projectConfig,
|
projectConfig,
|
||||||
|
savedByUserId: authUserId ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -625,7 +771,7 @@ export default function EditorPage() {
|
|||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}, [blocks, projectConfig]);
|
}, [blocks, projectConfig, authUserId, authChecked]);
|
||||||
|
|
||||||
// 에디터 전역에서 Undo/Redo 단축키(Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z),
|
// 에디터 전역에서 Undo/Redo 단축키(Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z),
|
||||||
// Delete/Backspace 기반 삭제, Cmd/Ctrl+D 기반 복제,
|
// Delete/Backspace 기반 삭제, Cmd/Ctrl+D 기반 복제,
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ export type ButtonPropertiesPanelProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBlock }: ButtonPropertiesPanelProps) {
|
export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBlock }: ButtonPropertiesPanelProps) {
|
||||||
|
const imageSource: "none" | "url" | "upload" = (() => {
|
||||||
|
const hasSrc = (buttonProps.imageSrc ?? "").trim() !== "";
|
||||||
|
if (!hasSrc) return "none";
|
||||||
|
if (buttonProps.imageSourceType === "asset") return "upload";
|
||||||
|
return "url";
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -26,6 +33,148 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3 text-xs text-slate-400">
|
||||||
|
<h4 className="text-[11px] font-semibold text-slate-200">버튼 이미지</h4>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span>버튼 이미지 소스</span>
|
||||||
|
<select
|
||||||
|
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||||
|
aria-label="버튼 이미지 소스"
|
||||||
|
value={imageSource}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.value as "none" | "url" | "upload";
|
||||||
|
if (next === "none") {
|
||||||
|
updateBlock(selectedBlockId, {
|
||||||
|
imageSrc: "",
|
||||||
|
imageAssetId: null,
|
||||||
|
} as any);
|
||||||
|
} else if (next === "url") {
|
||||||
|
updateBlock(selectedBlockId, {
|
||||||
|
imageSourceType: "externalUrl",
|
||||||
|
imageAssetId: null,
|
||||||
|
} as any);
|
||||||
|
} else {
|
||||||
|
updateBlock(selectedBlockId, {
|
||||||
|
imageSourceType: "asset",
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="none">사용 안 함</option>
|
||||||
|
<option value="url">URL</option>
|
||||||
|
<option value="upload">파일 업로드</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{imageSource === "url" && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span>버튼 이미지 URL</span>
|
||||||
|
<input
|
||||||
|
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||||
|
aria-label="버튼 이미지 URL"
|
||||||
|
value={buttonProps.imageSrc ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
updateBlock(selectedBlockId, {
|
||||||
|
imageSrc: value,
|
||||||
|
imageSourceType: "externalUrl",
|
||||||
|
imageAssetId: null,
|
||||||
|
} as any);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{imageSource === "upload" && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span>버튼 이미지 파일 업로드</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
|
||||||
|
aria-label="버튼 이미지 파일 업로드"
|
||||||
|
onChange={async (event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
const response = await fetch("/api/image", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error("버튼 이미지 업로드 실패", await response.text());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { id: string; servedUrl?: string | null };
|
||||||
|
const servedUrl = data.servedUrl ?? `/api/image/${data.id}`;
|
||||||
|
|
||||||
|
updateBlock(selectedBlockId, {
|
||||||
|
imageSrc: servedUrl,
|
||||||
|
imageSourceType: "asset",
|
||||||
|
imageAssetId: data.id,
|
||||||
|
} as any);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("버튼 이미지 업로드 중 오류", error);
|
||||||
|
} finally {
|
||||||
|
event.target.value = "";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{imageSource !== "none" && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span>버튼 이미지 대체 텍스트</span>
|
||||||
|
<input
|
||||||
|
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||||
|
aria-label="버튼 이미지 대체 텍스트"
|
||||||
|
value={buttonProps.imageAlt ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateBlock(selectedBlockId, { imageAlt: e.target.value } as any);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span>버튼 이미지 위치</span>
|
||||||
|
<select
|
||||||
|
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||||
|
aria-label="버튼 이미지 위치"
|
||||||
|
value={buttonProps.imagePlacement ?? "left"}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateBlock(selectedBlockId, {
|
||||||
|
imagePlacement: e.target.value as any,
|
||||||
|
} as any);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="left">텍스트 왼쪽</option>
|
||||||
|
<option value="right">텍스트 오른쪽</option>
|
||||||
|
<option value="top">텍스트 위쪽</option>
|
||||||
|
<option value="bottom">텍스트 아래쪽</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<NumericPropertyControl
|
<NumericPropertyControl
|
||||||
label="가로 패딩 (px)"
|
label="가로 패딩 (px)"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export default function PreviewPage() {
|
|||||||
const projectConfig = useEditorStore((state) => (state as any).projectConfig);
|
const projectConfig = useEditorStore((state) => (state as any).projectConfig);
|
||||||
const replaceBlocks = useEditorStore((state) => state.replaceBlocks);
|
const replaceBlocks = useEditorStore((state) => state.replaceBlocks);
|
||||||
const updateProjectConfig = useEditorStore((state) => state.updateProjectConfig);
|
const updateProjectConfig = useEditorStore((state) => state.updateProjectConfig);
|
||||||
|
const resetHistory = useEditorStore((state) => (state as any).resetHistory as () => void);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -53,6 +54,9 @@ export default function PreviewPage() {
|
|||||||
if (parsed.projectConfig) {
|
if (parsed.projectConfig) {
|
||||||
updateProjectConfig(parsed.projectConfig as any);
|
updateProjectConfig(parsed.projectConfig as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 프리뷰 진입 시 autosave 프로젝트를 복원하는 경우에도 에디터 히스토리는 새 프로젝트 기준으로만 유지되도록 초기화한다.
|
||||||
|
resetHistory();
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -337,10 +337,48 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
|
const hasImage = typeof (props as any).imageSrc === "string" && (props as any).imageSrc.trim() !== "";
|
||||||
|
const placement = ((props as any).imagePlacement as string | undefined) ?? "left";
|
||||||
|
|
||||||
|
let innerContent: React.ReactNode;
|
||||||
|
if (!hasImage) {
|
||||||
|
innerContent = <span className="whitespace-pre-wrap">{props.label}</span>;
|
||||||
|
} else {
|
||||||
|
const src = (props as any).imageSrc as string;
|
||||||
|
const altRaw = (props as any).imageAlt as string | undefined;
|
||||||
|
const alt = altRaw && altRaw.trim() !== "" ? altRaw.trim() : props.label;
|
||||||
|
|
||||||
|
const imgEl = (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={src} alt={alt} className="inline-block max-w-full h-auto" />
|
||||||
|
);
|
||||||
|
|
||||||
|
const labelSpan = <span className="whitespace-pre-wrap">{props.label}</span>;
|
||||||
|
const isVertical = placement === "top" || placement === "bottom";
|
||||||
|
const wrapperClassNames = [
|
||||||
|
"inline-flex",
|
||||||
|
"items-center",
|
||||||
|
"gap-2",
|
||||||
|
isVertical ? "flex-col" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
const first = placement === "left" || placement === "top" ? imgEl : labelSpan;
|
||||||
|
const second = placement === "left" || placement === "top" ? labelSpan : imgEl;
|
||||||
|
|
||||||
|
innerContent = (
|
||||||
|
<span className={wrapperClassNames}>
|
||||||
|
{first}
|
||||||
|
{second}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={block.id} className={wrapperClass}>
|
<div key={block.id} className={wrapperClass}>
|
||||||
<a href={props.href} className={buttonClassName} style={buttonStyle}>
|
<a href={props.href} className={buttonClassName} style={buttonStyle}>
|
||||||
<span className="whitespace-pre-wrap">{props.label}</span>
|
{innerContent}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -116,6 +116,13 @@ export interface ButtonBlockProps {
|
|||||||
strokeColorCustom?: string;
|
strokeColorCustom?: string;
|
||||||
// 버튼 텍스트 색상 커스텀 값
|
// 버튼 텍스트 색상 커스텀 값
|
||||||
textColorCustom?: string;
|
textColorCustom?: string;
|
||||||
|
// 폼 컨트롤러에서 Submit 버튼 매핑 시 사용할 수 있는 전송 키 (name 역할)
|
||||||
|
formFieldName?: string;
|
||||||
|
imageSrc?: string;
|
||||||
|
imageAlt?: string;
|
||||||
|
imageSourceType?: "asset" | "externalUrl";
|
||||||
|
imageAssetId?: string | null;
|
||||||
|
imagePlacement?: "left" | "right" | "top" | "bottom";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이미지 블록 속성
|
// 이미지 블록 속성
|
||||||
@@ -768,12 +775,22 @@ export interface EditorState {
|
|||||||
redo: () => void;
|
redo: () => void;
|
||||||
removeBlock: (id: string) => void;
|
removeBlock: (id: string) => void;
|
||||||
duplicateBlock: (id: string) => void;
|
duplicateBlock: (id: string) => void;
|
||||||
|
resetHistory: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 간단한 ID 생성기 (추후 uuid 라이브러리로 교체 가능)
|
// 간단한 ID 생성기 (추후 uuid 라이브러리로 교체 가능)
|
||||||
let idCounter = 0;
|
let idCounter = 0;
|
||||||
const createId = () => `blk_${Date.now()}_${idCounter++}`;
|
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 getNextFormFieldName = (blocks: Block[], prefix: string): string => {
|
||||||
const regex = new RegExp(`^${prefix}-(\\d+)$`);
|
const regex = new RegExp(`^${prefix}-(\\d+)$`);
|
||||||
let max = 0;
|
let max = 0;
|
||||||
@@ -795,7 +812,16 @@ const getNextFormFieldName = (blocks: Block[], prefix: string): string => {
|
|||||||
|
|
||||||
// 에디터 스토어 생성 함수 (테스트 및 앱에서 공유 사용)
|
// 에디터 스토어 생성 함수 (테스트 및 앱에서 공유 사용)
|
||||||
// set/get 은 zustand 내부 구현에 의해 주입되므로, 여기서는 any 로 완화해 사용한다.
|
// set/get 은 zustand 내부 구현에 의해 주입되므로, 여기서는 any 로 완화해 사용한다.
|
||||||
const createEditorState = (set: any, get: any): EditorState => ({
|
const createEditorState = (set: any, get: any): EditorState => {
|
||||||
|
const pushHistory = () => {
|
||||||
|
const { blocks } = get() as EditorState;
|
||||||
|
set((state: EditorState) => ({
|
||||||
|
history: [...state.history, blocks],
|
||||||
|
future: [],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
blocks: [],
|
blocks: [],
|
||||||
selectedBlockId: null,
|
selectedBlockId: null,
|
||||||
selectedListItemId: null,
|
selectedListItemId: null,
|
||||||
@@ -860,11 +886,10 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
columnId,
|
columnId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
blocks: [...state.blocks, newBlock],
|
blocks: [...state.blocks, newBlock],
|
||||||
selectedBlockId: id,
|
selectedBlockId: id,
|
||||||
history: [...state.history, blocks],
|
|
||||||
future: [],
|
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -887,6 +912,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
createId,
|
createId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
let baseBlocks = state.blocks as Block[];
|
let baseBlocks = state.blocks as Block[];
|
||||||
if (replacingSectionId) {
|
if (replacingSectionId) {
|
||||||
@@ -928,6 +954,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
createId,
|
createId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
let baseBlocks = state.blocks as Block[];
|
let baseBlocks = state.blocks as Block[];
|
||||||
if (replacingSectionId) {
|
if (replacingSectionId) {
|
||||||
@@ -964,6 +991,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
createId,
|
createId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
let baseBlocks = state.blocks as Block[];
|
let baseBlocks = state.blocks as Block[];
|
||||||
if (replacingSectionId) {
|
if (replacingSectionId) {
|
||||||
@@ -1000,6 +1028,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
createId,
|
createId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
let baseBlocks = state.blocks as Block[];
|
let baseBlocks = state.blocks as Block[];
|
||||||
if (replacingSectionId) {
|
if (replacingSectionId) {
|
||||||
@@ -1036,6 +1065,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
createId,
|
createId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
let baseBlocks = state.blocks as Block[];
|
let baseBlocks = state.blocks as Block[];
|
||||||
if (replacingSectionId) {
|
if (replacingSectionId) {
|
||||||
@@ -1072,6 +1102,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
createId,
|
createId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
let baseBlocks = state.blocks as Block[];
|
let baseBlocks = state.blocks as Block[];
|
||||||
if (replacingSectionId) {
|
if (replacingSectionId) {
|
||||||
@@ -1108,6 +1139,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
createId,
|
createId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
let baseBlocks = state.blocks as Block[];
|
let baseBlocks = state.blocks as Block[];
|
||||||
if (replacingSectionId) {
|
if (replacingSectionId) {
|
||||||
@@ -1144,6 +1176,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
createId,
|
createId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
let baseBlocks = state.blocks as Block[];
|
let baseBlocks = state.blocks as Block[];
|
||||||
if (replacingSectionId) {
|
if (replacingSectionId) {
|
||||||
@@ -1180,6 +1213,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
createId,
|
createId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
let baseBlocks = state.blocks as Block[];
|
let baseBlocks = state.blocks as Block[];
|
||||||
if (replacingSectionId) {
|
if (replacingSectionId) {
|
||||||
@@ -1233,6 +1267,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
columnId,
|
columnId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
blocks: [...state.blocks, newBlock],
|
blocks: [...state.blocks, newBlock],
|
||||||
selectedBlockId: id,
|
selectedBlockId: id,
|
||||||
@@ -1279,6 +1314,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
columnId,
|
columnId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
blocks: [...state.blocks, newBlock],
|
blocks: [...state.blocks, newBlock],
|
||||||
selectedBlockId: id,
|
selectedBlockId: id,
|
||||||
@@ -1318,6 +1354,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
columnId: null,
|
columnId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
blocks: [...state.blocks, newBlock],
|
blocks: [...state.blocks, newBlock],
|
||||||
selectedBlockId: id,
|
selectedBlockId: id,
|
||||||
@@ -1359,6 +1396,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
columnId: null,
|
columnId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
blocks: [...state.blocks, newBlock],
|
blocks: [...state.blocks, newBlock],
|
||||||
selectedBlockId: id,
|
selectedBlockId: id,
|
||||||
@@ -1401,6 +1439,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
columnId: null,
|
columnId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
blocks: [...state.blocks, newBlock],
|
blocks: [...state.blocks, newBlock],
|
||||||
selectedBlockId: id,
|
selectedBlockId: id,
|
||||||
@@ -1443,6 +1482,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
columnId: null,
|
columnId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
blocks: [...state.blocks, newBlock],
|
blocks: [...state.blocks, newBlock],
|
||||||
selectedBlockId: id,
|
selectedBlockId: id,
|
||||||
@@ -1483,6 +1523,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
columnId,
|
columnId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
blocks: [...state.blocks, newBlock],
|
blocks: [...state.blocks, newBlock],
|
||||||
selectedBlockId: id,
|
selectedBlockId: id,
|
||||||
@@ -1530,6 +1571,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
columnId,
|
columnId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
blocks: [...state.blocks, newBlock],
|
blocks: [...state.blocks, newBlock],
|
||||||
selectedBlockId: id,
|
selectedBlockId: id,
|
||||||
@@ -1556,6 +1598,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
columnId: null,
|
columnId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
blocks: [...state.blocks, newBlock],
|
blocks: [...state.blocks, newBlock],
|
||||||
selectedBlockId: id,
|
selectedBlockId: id,
|
||||||
@@ -1606,6 +1649,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
columnId: null,
|
columnId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
blocks: [...state.blocks, newBlock],
|
blocks: [...state.blocks, newBlock],
|
||||||
selectedBlockId: id,
|
selectedBlockId: id,
|
||||||
@@ -1660,6 +1704,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
columnId,
|
columnId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
blocks: [...state.blocks, newBlock],
|
blocks: [...state.blocks, newBlock],
|
||||||
selectedBlockId: id,
|
selectedBlockId: id,
|
||||||
@@ -1668,6 +1713,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
|
|
||||||
// 특정 블록의 속성을 부분 업데이트 (텍스트/버튼 공통 사용)
|
// 특정 블록의 속성을 부분 업데이트 (텍스트/버튼 공통 사용)
|
||||||
updateBlock: (id, partial) => {
|
updateBlock: (id, partial) => {
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
blocks: state.blocks.map((block: Block) =>
|
blocks: state.blocks.map((block: Block) =>
|
||||||
block.id === id
|
block.id === id
|
||||||
@@ -1816,6 +1862,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
replaceBlocks: (blocks) => {
|
replaceBlocks: (blocks) => {
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set({
|
set({
|
||||||
blocks,
|
blocks,
|
||||||
selectedBlockId: blocks.length > 0 ? blocks[0].id : null,
|
selectedBlockId: blocks.length > 0 ? blocks[0].id : null,
|
||||||
@@ -1823,6 +1870,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
reorderBlocks: (activeId, overId) => {
|
reorderBlocks: (activeId, overId) => {
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
const current = state.blocks;
|
const current = state.blocks;
|
||||||
const oldIndex = current.findIndex((b: Block) => b.id === activeId);
|
const oldIndex = current.findIndex((b: Block) => b.id === activeId);
|
||||||
@@ -1840,6 +1888,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
moveBlock: (id, sectionId, columnId) => {
|
moveBlock: (id, sectionId, columnId) => {
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => ({
|
set((state: EditorState) => ({
|
||||||
blocks: state.blocks.map((block: Block) =>
|
blocks: state.blocks.map((block: Block) =>
|
||||||
block.id === id
|
block.id === id
|
||||||
@@ -1853,6 +1902,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
removeBlock: (id) => {
|
removeBlock: (id) => {
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
const current = state.blocks;
|
const current = state.blocks;
|
||||||
const index = current.findIndex((b) => b.id === id);
|
const index = current.findIndex((b) => b.id === id);
|
||||||
@@ -1884,6 +1934,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
duplicateBlock: (id) => {
|
duplicateBlock: (id) => {
|
||||||
|
pushHistoryImpl(set, get);
|
||||||
set((state: EditorState) => {
|
set((state: EditorState) => {
|
||||||
const current = state.blocks;
|
const current = state.blocks;
|
||||||
const index = current.findIndex((b) => b.id === id);
|
const index = current.findIndex((b) => b.id === id);
|
||||||
@@ -1939,7 +1990,11 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
|||||||
future: nextFuture,
|
future: nextFuture,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
});
|
resetHistory: () => {
|
||||||
|
set({ history: [], future: [] });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// React 컴포넌트에서 사용하는 전역 훅 스토어
|
// React 컴포넌트에서 사용하는 전역 훅 스토어
|
||||||
export const useEditorStore = create<EditorState>()(createEditorState);
|
export const useEditorStore = create<EditorState>()(createEditorState);
|
||||||
|
|||||||
@@ -259,16 +259,16 @@ describe("/api/export", () => {
|
|||||||
expect(htmlColors).toContain('style="background-color:#222222;margin:0;padding:0;"');
|
expect(htmlColors).toContain('style="background-color:#222222;margin:0;padding:0;"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("projectConfig.headHtml 과 trackingScript 는 index.html 의 head/body 에 그대로 포함되어야 한다", async () => {
|
it("projectConfig.headHtml / trackingScript 가 head 와 body 에 반영되어야 한다", async () => {
|
||||||
const blocks: Block[] = [];
|
const blocks: Block[] = [];
|
||||||
|
|
||||||
const projectConfig: ProjectConfig = {
|
const projectConfig: ProjectConfig = {
|
||||||
title: "헤드/트래킹 테스트",
|
title: "헤드/트래킹 스크립트 테스트",
|
||||||
slug: "head-tracking-test",
|
slug: "head-tracking-test",
|
||||||
canvasPreset: "full",
|
canvasPreset: "full",
|
||||||
headHtml: '<meta name="robots" content="noindex" />',
|
headHtml: '<meta name="robots" content="noindex" />',
|
||||||
trackingScript: '<script>window.__TEST_TRACKING__ = true;<\/script>',
|
trackingScript: '<script>window.__TEST_TRACKING__ = true;<\\/script>',
|
||||||
};
|
} as ProjectConfig;
|
||||||
|
|
||||||
const payload = { blocks, projectConfig };
|
const payload = { blocks, projectConfig };
|
||||||
|
|
||||||
@@ -294,7 +294,35 @@ describe("/api/export", () => {
|
|||||||
// head 커스텀 HTML
|
// head 커스텀 HTML
|
||||||
expect(html).toContain('<meta name="robots" content="noindex" />');
|
expect(html).toContain('<meta name="robots" content="noindex" />');
|
||||||
// body 하단 추적 스크립트
|
// body 하단 추적 스크립트
|
||||||
expect(html).toContain('<script>window.__TEST_TRACKING__ = true;<\/script>');
|
expect(html).toContain('<script>window.__TEST_TRACKING__ = true;<\\/script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("버튼 블록에 imageSrc 가 있으면 내보내기 HTML 의 버튼 안에 img 요소가 포함되어야 한다", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "btn_img_export",
|
||||||
|
type: "button",
|
||||||
|
props: {
|
||||||
|
label: "이미지 버튼",
|
||||||
|
href: "#",
|
||||||
|
imageSrc: "https://example.com/button.png",
|
||||||
|
imageAlt: "버튼 이미지",
|
||||||
|
imagePlacement: "left",
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const projectConfig: ProjectConfig = {
|
||||||
|
title: "버튼 이미지 내보내기 테스트",
|
||||||
|
slug: "btn-img-export-test",
|
||||||
|
canvasPreset: "full",
|
||||||
|
} as ProjectConfig;
|
||||||
|
|
||||||
|
const html = buildStaticHtml(blocks, projectConfig);
|
||||||
|
|
||||||
|
expect(html).toContain('<a href="#"');
|
||||||
|
expect(html).toContain('<img src="https://example.com/button.png"');
|
||||||
|
expect(html).toContain('alt="버튼 이미지"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("index.html head 에는 기본 viewport 메타 태그가 포함되어야 한다", async () => {
|
it("index.html head 에는 기본 viewport 메타 태그가 포함되어야 한다", async () => {
|
||||||
|
|||||||
@@ -99,4 +99,84 @@ describe("ButtonPropertiesPanel", () => {
|
|||||||
expect.objectContaining({ widthMode: "fixed" }),
|
expect.objectContaining({ widthMode: "fixed" }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("버튼 이미지 URL 인풋 변경 시 updateBlock 이 imageSrc 와 imageSourceType(externalUrl) 으로 호출되어야 한다", () => {
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ButtonPropertiesPanel
|
||||||
|
buttonProps={{
|
||||||
|
...baseProps,
|
||||||
|
imageSrc: "https://example.com/old.png",
|
||||||
|
imageSourceType: "externalUrl",
|
||||||
|
} as any}
|
||||||
|
selectedBlockId="btn-img-url"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const urlInput = screen.getByLabelText("버튼 이미지 URL");
|
||||||
|
fireEvent.change(urlInput, { target: { value: "https://example.com/new.png" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"btn-img-url",
|
||||||
|
expect.objectContaining({
|
||||||
|
imageSrc: "https://example.com/new.png",
|
||||||
|
imageSourceType: "externalUrl",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("버튼 이미지 위치 셀렉트 변경 시 updateBlock 이 imagePlacement 로 호출되어야 한다", () => {
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ButtonPropertiesPanel
|
||||||
|
buttonProps={{
|
||||||
|
...baseProps,
|
||||||
|
imagePlacement: "left",
|
||||||
|
imageSrc: "https://example.com/icon.png",
|
||||||
|
imageSourceType: "externalUrl",
|
||||||
|
} as any}
|
||||||
|
selectedBlockId="btn-img-pos"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const select = screen.getByLabelText("버튼 이미지 위치");
|
||||||
|
fireEvent.change(select, { target: { value: "right" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"btn-img-pos",
|
||||||
|
expect.objectContaining({ imagePlacement: "right" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("버튼 이미지 소스 셀렉트에서 URL/업로드를 전환할 수 있어야 한다", () => {
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ButtonPropertiesPanel
|
||||||
|
buttonProps={{
|
||||||
|
...baseProps,
|
||||||
|
imageSrc: "/api/image/test-id",
|
||||||
|
imageSourceType: "asset",
|
||||||
|
} as any}
|
||||||
|
selectedBlockId="btn-img-source"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const select = screen.getByLabelText("버튼 이미지 소스");
|
||||||
|
// 업로드 → URL 로 전환
|
||||||
|
fireEvent.change(select, { target: { value: "url" } });
|
||||||
|
|
||||||
|
expect(updateBlock).toHaveBeenCalledWith(
|
||||||
|
"btn-img-source",
|
||||||
|
expect.objectContaining({
|
||||||
|
imageSourceType: "externalUrl",
|
||||||
|
imageAssetId: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
import { render, cleanup } from "@testing-library/react";
|
import { render, cleanup, waitFor } from "@testing-library/react";
|
||||||
import EditorPage from "@/app/editor/page";
|
import EditorPage from "@/app/editor/page";
|
||||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||||
|
|
||||||
@@ -63,6 +63,7 @@ beforeEach(() => {
|
|||||||
addFooterTemplateSection: vi.fn(),
|
addFooterTemplateSection: vi.fn(),
|
||||||
updateBlock: vi.fn(),
|
updateBlock: vi.fn(),
|
||||||
updateProjectConfig: vi.fn(),
|
updateProjectConfig: vi.fn(),
|
||||||
|
resetHistory: vi.fn(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,6 +95,9 @@ vi.mock("next/navigation", () => {
|
|||||||
useRouter: () => ({
|
useRouter: () => ({
|
||||||
push: vi.fn(),
|
push: vi.fn(),
|
||||||
}),
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -114,7 +118,7 @@ describe("EditorPage - 자동저장", () => {
|
|||||||
expect(parsed.projectConfig.slug).toBe("autosave-test");
|
expect(parsed.projectConfig.slug).toBe("autosave-test");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("localStorage 에 autosave 스냅샷이 있으면 초기 렌더에서 replaceBlocks 와 updateProjectConfig 로 복원해야 한다", () => {
|
it("localStorage 에 autosave 스냅샷이 있으면 초기 렌더에서 replaceBlocks 와 updateProjectConfig 로 복원해야 한다", async () => {
|
||||||
const savedBlocks: Block[] = [
|
const savedBlocks: Block[] = [
|
||||||
{
|
{
|
||||||
id: "saved_1",
|
id: "saved_1",
|
||||||
@@ -142,10 +146,75 @@ describe("EditorPage - 자동저장", () => {
|
|||||||
|
|
||||||
render(<EditorPage />);
|
render(<EditorPage />);
|
||||||
|
|
||||||
expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1);
|
await waitFor(() => {
|
||||||
|
expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
expect(mockState.replaceBlocks).toHaveBeenCalledWith(savedBlocks);
|
expect(mockState.replaceBlocks).toHaveBeenCalledWith(savedBlocks);
|
||||||
|
|
||||||
expect(mockState.updateProjectConfig).toHaveBeenCalledTimes(1);
|
expect(mockState.updateProjectConfig).toHaveBeenCalledTimes(1);
|
||||||
expect(mockState.updateProjectConfig).toHaveBeenCalledWith(savedConfig);
|
expect(mockState.updateProjectConfig).toHaveBeenCalledWith(savedConfig);
|
||||||
|
|
||||||
|
// autosave 로 전체 프로젝트를 복원한 경우 history/future 도 초기화해야 한다.
|
||||||
|
expect(mockState.resetHistory).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("savedByUserId 가 다른 autosave 스냅샷은 현재 로그인 유저가 불러오지 않아야 한다", async () => {
|
||||||
|
const savedBlocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "saved_1",
|
||||||
|
type: "text",
|
||||||
|
props: {
|
||||||
|
text: "다른 유저가 저장한 블록",
|
||||||
|
align: "left",
|
||||||
|
size: "base",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
const savedConfig: ProjectConfig = {
|
||||||
|
title: "다른 유저 프로젝트",
|
||||||
|
slug: "autosave-test",
|
||||||
|
canvasPreset: "full",
|
||||||
|
} as ProjectConfig;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
blocks: savedBlocks,
|
||||||
|
projectConfig: savedConfig,
|
||||||
|
savedByUserId: "user-a",
|
||||||
|
};
|
||||||
|
|
||||||
|
window.localStorage.setItem("pb:autosave:autosave-test", JSON.stringify(payload));
|
||||||
|
|
||||||
|
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||||
|
const url = typeof input === "string" ? input : input.url;
|
||||||
|
const method = init?.method ?? "GET";
|
||||||
|
|
||||||
|
if (url === "/api/auth/me" && method === "GET") {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ id: "user-b", email: "user-b@example.com", tokenVersion: 1 }),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(new Response(null, { status: 200 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock as any);
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockState.replaceBlocks).not.toHaveBeenCalled();
|
||||||
|
expect(mockState.updateProjectConfig).not.toHaveBeenCalled();
|
||||||
|
// 다른 유저의 autosave 는 무시되므로 history 초기화도 호출되지 않아야 한다.
|
||||||
|
expect(mockState.resetHistory).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ vi.mock("next/navigation", () => {
|
|||||||
useRouter: () => ({
|
useRouter: () => ({
|
||||||
push: vi.fn(),
|
push: vi.fn(),
|
||||||
}),
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ vi.mock("next/navigation", () => {
|
|||||||
useRouter: () => ({
|
useRouter: () => ({
|
||||||
push: vi.fn(),
|
push: vi.fn(),
|
||||||
}),
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ vi.mock("next/navigation", () => {
|
|||||||
useRouter: () => ({
|
useRouter: () => ({
|
||||||
push: vi.fn(),
|
push: vi.fn(),
|
||||||
}),
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ vi.mock("next/navigation", () => {
|
|||||||
useRouter: () => ({
|
useRouter: () => ({
|
||||||
push: vi.fn(),
|
push: vi.fn(),
|
||||||
}),
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ vi.mock("next/navigation", () => {
|
|||||||
useRouter: () => ({
|
useRouter: () => ({
|
||||||
push: vi.fn(),
|
push: vi.fn(),
|
||||||
}),
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ vi.mock("next/navigation", () => {
|
|||||||
useRouter: () => ({
|
useRouter: () => ({
|
||||||
push: vi.fn(),
|
push: vi.fn(),
|
||||||
}),
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ vi.mock("next/navigation", () => {
|
|||||||
useRouter: () => ({
|
useRouter: () => ({
|
||||||
push: vi.fn(),
|
push: vi.fn(),
|
||||||
}),
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ vi.mock("next/navigation", () => {
|
|||||||
useRouter: () => ({
|
useRouter: () => ({
|
||||||
push: vi.fn(),
|
push: vi.fn(),
|
||||||
}),
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ vi.mock("next/navigation", () => {
|
|||||||
useRouter: () => ({
|
useRouter: () => ({
|
||||||
push: vi.fn(),
|
push: vi.fn(),
|
||||||
}),
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ vi.mock("next/navigation", () => {
|
|||||||
return {
|
return {
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
useRouter: () => ({ push: pushMock }),
|
useRouter: () => ({ push: pushMock }),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ vi.mock("next/navigation", () => {
|
|||||||
useRouter: () => ({
|
useRouter: () => ({
|
||||||
push: vi.fn(),
|
push: vi.fn(),
|
||||||
}),
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ vi.mock("next/navigation", () => {
|
|||||||
useRouter: () => ({
|
useRouter: () => ({
|
||||||
push: vi.fn(),
|
push: vi.fn(),
|
||||||
}),
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
|||||||
// - 로컬스토리지에 없으면 /api/projects/[slug] 를 호출해 서버에서 프로젝트를 불러와 replaceBlocks 해야 한다.
|
// - 로컬스토리지에 없으면 /api/projects/[slug] 를 호출해 서버에서 프로젝트를 불러와 replaceBlocks 해야 한다.
|
||||||
|
|
||||||
let mockState: any;
|
let mockState: any;
|
||||||
|
let searchParamsSlug: string | null = null;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const baseProjectConfig: ProjectConfig = {
|
const baseProjectConfig: ProjectConfig = {
|
||||||
@@ -32,14 +33,17 @@ beforeEach(() => {
|
|||||||
projectConfig: baseProjectConfig,
|
projectConfig: baseProjectConfig,
|
||||||
selectedBlockId: null as string | null,
|
selectedBlockId: null as string | null,
|
||||||
selectedListItemId: null as string | null,
|
selectedListItemId: null as string | null,
|
||||||
// EditorPage 가 참조하는 액션들: 기본적으로 no-op mock 으로 채운다.
|
// EditorPage 가 참조하는 액션들: 기본적으로 mock 으로 채우되,
|
||||||
|
// replaceBlocks 는 실제 store.blocks 도 업데이트하도록 구현한다.
|
||||||
undo: vi.fn(),
|
undo: vi.fn(),
|
||||||
redo: vi.fn(),
|
redo: vi.fn(),
|
||||||
removeBlock: vi.fn(),
|
removeBlock: vi.fn(),
|
||||||
duplicateBlock: vi.fn(),
|
duplicateBlock: vi.fn(),
|
||||||
selectBlock: vi.fn(),
|
selectBlock: vi.fn(),
|
||||||
selectListItem: vi.fn(),
|
selectListItem: vi.fn(),
|
||||||
replaceBlocks: vi.fn(),
|
replaceBlocks: vi.fn((nextBlocks: Block[]) => {
|
||||||
|
mockState.blocks = nextBlocks;
|
||||||
|
}),
|
||||||
reorderBlocks: vi.fn(),
|
reorderBlocks: vi.fn(),
|
||||||
moveBlock: vi.fn(),
|
moveBlock: vi.fn(),
|
||||||
addTextBlock: vi.fn(),
|
addTextBlock: vi.fn(),
|
||||||
@@ -69,9 +73,11 @@ beforeEach(() => {
|
|||||||
...partial,
|
...partial,
|
||||||
} as ProjectConfig;
|
} as ProjectConfig;
|
||||||
}),
|
}),
|
||||||
|
resetHistory: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
|
searchParamsSlug = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -102,6 +108,9 @@ vi.mock("next/navigation", () => {
|
|||||||
useRouter: () => ({
|
useRouter: () => ({
|
||||||
push: vi.fn(),
|
push: vi.fn(),
|
||||||
}),
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: (key: string) => (key === "slug" ? searchParamsSlug : null),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -277,6 +286,12 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
|||||||
|
|
||||||
expect(mockState.replaceBlocks).toHaveBeenCalledWith(savedBlocks as any);
|
expect(mockState.replaceBlocks).toHaveBeenCalledWith(savedBlocks as any);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(mockState.updateProjectConfig as any).mock.calls.some(
|
||||||
|
([arg]: any[]) => arg && arg.title === "로컬 프로젝트" && arg.slug === slug,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
const serverCalls = fetchMock.mock.calls.filter(
|
const serverCalls = fetchMock.mock.calls.filter(
|
||||||
([url]: any[]) => typeof url === "string" && url.startsWith("/api/projects"),
|
([url]: any[]) => typeof url === "string" && url.startsWith("/api/projects"),
|
||||||
);
|
);
|
||||||
@@ -304,7 +319,7 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
|||||||
const fetchMock = vi.fn().mockResolvedValue({
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
json: async () => ({ slug, contentJson: serverBlocks }),
|
json: async () => ({ slug, title: "서버 프로젝트", contentJson: serverBlocks }),
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
@@ -341,10 +356,145 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
|||||||
expect(mockState.replaceBlocks).toHaveBeenCalledWith(serverBlocks as any);
|
expect(mockState.replaceBlocks).toHaveBeenCalledWith(serverBlocks as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(mockState.updateProjectConfig as any).mock.calls.some(
|
||||||
|
([arg]: any[]) => arg && arg.title === "서버 프로젝트" && arg.slug === slug,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
const message = await screen.findByText(`프로젝트를 불러왔습니다: ${slug}`);
|
const message = await screen.findByText(`프로젝트를 불러왔습니다: ${slug}`);
|
||||||
expect(message).toBeTruthy();
|
expect(message).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("서버에서 복잡한 프로젝트 contentJson 을 불러오면 모든 블록 속성과 스타일이 그대로 복원되어야 한다", async () => {
|
||||||
|
const slug = "roundtrip-slug";
|
||||||
|
|
||||||
|
const complexBlocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "text_1",
|
||||||
|
type: "text",
|
||||||
|
props: {
|
||||||
|
text: "복잡한 텍스트",
|
||||||
|
align: "center",
|
||||||
|
size: "xl",
|
||||||
|
colorCustom: "#ff0000",
|
||||||
|
backgroundColorCustom: "#123456",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
id: "button_1",
|
||||||
|
type: "button",
|
||||||
|
props: {
|
||||||
|
label: "CTA 버튼",
|
||||||
|
href: "/pricing",
|
||||||
|
variant: "outline",
|
||||||
|
size: "lg",
|
||||||
|
align: "right",
|
||||||
|
widthMode: "fixed",
|
||||||
|
widthPx: 320,
|
||||||
|
borderRadius: "full",
|
||||||
|
colorPalette: "secondary",
|
||||||
|
textColorCustom: "#111111",
|
||||||
|
fillColorCustom: "#222222",
|
||||||
|
strokeColorCustom: "#333333",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
id: "section_1",
|
||||||
|
type: "section",
|
||||||
|
props: {
|
||||||
|
paddingY: "lg",
|
||||||
|
background: "image",
|
||||||
|
backgroundImageUrl: "https://example.com/bg.png",
|
||||||
|
columns: [
|
||||||
|
{ id: "col_1", span: 8 },
|
||||||
|
{ id: "col_2", span: 4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
{
|
||||||
|
id: "form_input_1",
|
||||||
|
type: "formInput",
|
||||||
|
props: {
|
||||||
|
label: "이메일",
|
||||||
|
inputType: "email",
|
||||||
|
required: true,
|
||||||
|
labelMode: "text",
|
||||||
|
labelLayout: "stacked",
|
||||||
|
labelGapPx: 12,
|
||||||
|
widthMode: "custom",
|
||||||
|
widthPx: 360,
|
||||||
|
borderRadius: "lg",
|
||||||
|
paddingX: 16,
|
||||||
|
paddingY: 12,
|
||||||
|
textColorCustom: "#fafafa",
|
||||||
|
fillColorCustom: "#0f172a",
|
||||||
|
strokeColorCustom: "#1e293b",
|
||||||
|
formFieldName: "email-1",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||||
|
const url = typeof input === "string" ? input : input.url;
|
||||||
|
const method = init?.method ?? "GET";
|
||||||
|
|
||||||
|
if (url === "/api/auth/me" && method === "GET") {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ id: "user-1", email: "user@example.com", tokenVersion: 1 }),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === `/api/projects/${slug}` && method === "GET") {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ slug, title: "복잡한 프로젝트", contentJson: complexBlocks }),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(new Response(null, { status: 200 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock as any);
|
||||||
|
|
||||||
|
mockState.projectConfig.slug = slug;
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
const menuButton = screen.getByText("메뉴");
|
||||||
|
fireEvent.click(menuButton);
|
||||||
|
|
||||||
|
const projectMenuItem = screen.getByText("프로젝트 저장/불러오기");
|
||||||
|
fireEvent.click(projectMenuItem);
|
||||||
|
|
||||||
|
const loadButton = screen.getByText("불러오기");
|
||||||
|
fireEvent.click(loadButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockState.replaceBlocks).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [loadedBlocks] = (mockState.replaceBlocks as any).mock.calls[0];
|
||||||
|
expect(loadedBlocks).toEqual(complexBlocks as any);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(mockState.updateProjectConfig as any).mock.calls.some(
|
||||||
|
([arg]: any[]) => arg && arg.title === "복잡한 프로젝트" && arg.slug === slug,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("에디터 메뉴에서 프로젝트 삭제를 선택하면 서버에 DELETE 요청을 보내고 로컬 저장/자동저장 상태를 정리한 뒤 목록으로 이동해야 한다", async () => {
|
it("에디터 메뉴에서 프로젝트 삭제를 선택하면 서버에 DELETE 요청을 보내고 로컬 저장/자동저장 상태를 정리한 뒤 목록으로 이동해야 한다", async () => {
|
||||||
const slug = "delete-test-slug";
|
const slug = "delete-test-slug";
|
||||||
mockState.projectConfig.slug = slug;
|
mockState.projectConfig.slug = slug;
|
||||||
@@ -391,4 +541,75 @@ describe("EditorPage - 프로젝트 저장/불러오기", () => {
|
|||||||
|
|
||||||
confirmSpy.mockRestore();
|
confirmSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("URL 쿼리의 slug 가 있으면 해당 slug 프로젝트를 자동으로 서버에서 불러와야 한다", async () => {
|
||||||
|
const slug = "auto-load-slug";
|
||||||
|
|
||||||
|
const serverBlocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "server_1",
|
||||||
|
type: "text",
|
||||||
|
props: {
|
||||||
|
text: "쿼리 로드 블록",
|
||||||
|
align: "left",
|
||||||
|
size: "base",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
|
||||||
|
const url = typeof input === "string" ? input : input.url;
|
||||||
|
const method = init?.method ?? "GET";
|
||||||
|
|
||||||
|
if (url === "/api/auth/me" && method === "GET") {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ id: "user-1", email: "user@example.com", tokenVersion: 1 }),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === `/api/projects/${slug}` && method === "GET") {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ slug, title: "쿼리 로드 프로젝트", contentJson: serverBlocks }),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(new Response(null, { status: 200 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fetchMock as any);
|
||||||
|
|
||||||
|
searchParamsSlug = slug;
|
||||||
|
|
||||||
|
render(<EditorPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
fetchMock.mock.calls.some(
|
||||||
|
([url, options]: any[]) => url === `/api/projects/${slug}` && (options?.method ?? "GET") === "GET",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockState.replaceBlocks).toHaveBeenCalledWith(serverBlocks as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(mockState.updateProjectConfig as any).mock.calls.some(
|
||||||
|
([arg]: any[]) => arg && arg.title === "쿼리 로드 프로젝트" && arg.slug === slug,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ vi.mock("next/navigation", () => {
|
|||||||
useRouter: () => ({
|
useRouter: () => ({
|
||||||
push: vi.fn(),
|
push: vi.fn(),
|
||||||
}),
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ vi.mock("next/navigation", () => {
|
|||||||
useRouter: () => ({
|
useRouter: () => ({
|
||||||
push: vi.fn(),
|
push: vi.fn(),
|
||||||
}),
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ vi.mock("next/navigation", () => {
|
|||||||
useRouter: () => ({
|
useRouter: () => ({
|
||||||
push: vi.fn(),
|
push: vi.fn(),
|
||||||
}),
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ vi.mock("next/navigation", () => {
|
|||||||
useRouter: () => ({
|
useRouter: () => ({
|
||||||
push: vi.fn(),
|
push: vi.fn(),
|
||||||
}),
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ vi.mock("next/navigation", () => {
|
|||||||
useRouter: () => ({
|
useRouter: () => ({
|
||||||
push: vi.fn(),
|
push: vi.fn(),
|
||||||
}),
|
}),
|
||||||
|
useSearchParams: () => ({
|
||||||
|
get: () => null,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -75,4 +75,80 @@ describe("FormControllerPanel", () => {
|
|||||||
expect.objectContaining({ errorMessage: "새 에러 메시지" }),
|
expect.objectContaining({ errorMessage: "새 에러 메시지" }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("폼 필드 매핑 UI 에서 필드 라벨 대신 전송 키(formFieldName)를 우선적으로 표시해야 한다", () => {
|
||||||
|
const formBlock = makeFormBlock("form-controller", {
|
||||||
|
fieldIds: ["input-1", "checkbox-1"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputBlock: Block = {
|
||||||
|
id: "input-1",
|
||||||
|
type: "formInput",
|
||||||
|
props: {
|
||||||
|
label: "이름",
|
||||||
|
formFieldName: "name",
|
||||||
|
} as any,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const checkboxBlock: Block = {
|
||||||
|
id: "checkbox-1",
|
||||||
|
type: "formCheckbox",
|
||||||
|
props: {
|
||||||
|
groupLabel: "옵션",
|
||||||
|
formFieldName: "options",
|
||||||
|
options: [],
|
||||||
|
} as any,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormControllerPanel
|
||||||
|
block={formBlock}
|
||||||
|
blocks={[formBlock, inputBlock, checkboxBlock]}
|
||||||
|
selectedBlockId="form-controller"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const nameLabel = screen.getByText("name (이름)");
|
||||||
|
const optionsLabel = screen.getByText("options (옵션)");
|
||||||
|
|
||||||
|
expect(nameLabel).toBeTruthy();
|
||||||
|
expect(optionsLabel).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Submit 버튼 셀렉트에서 버튼 블록의 전송 키(formFieldName)를 기준으로 표시해야 한다", () => {
|
||||||
|
const formBlock = makeFormBlock("form-controller", {
|
||||||
|
submitButtonId: "btn-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const buttonBlock: Block = {
|
||||||
|
id: "btn-1",
|
||||||
|
type: "button",
|
||||||
|
props: {
|
||||||
|
label: "제출하기",
|
||||||
|
formFieldName: "submit-main",
|
||||||
|
} as any,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const updateBlock = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormControllerPanel
|
||||||
|
block={formBlock}
|
||||||
|
blocks={[formBlock, buttonBlock]}
|
||||||
|
selectedBlockId="form-controller"
|
||||||
|
updateBlock={updateBlock}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const select = screen.getByLabelText("Submit 버튼") as HTMLSelectElement;
|
||||||
|
|
||||||
|
const submitMainOption = screen.getByText("submit-main (제출하기)");
|
||||||
|
|
||||||
|
expect(submitMainOption).toBeTruthy();
|
||||||
|
// 기본 선택 값은 FormBlock.submitButtonId 이어야 한다.
|
||||||
|
expect(select.value).toBe("btn-1");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -102,4 +102,63 @@ describe("PublicPageRenderer - 버튼 블록 스타일", () => {
|
|||||||
// lg → 12px → 12 / 16 = 0.75em
|
// lg → 12px → 12 / 16 = 0.75em
|
||||||
expect(link!.style.borderRadius).toBe("0.75em");
|
expect(link!.style.borderRadius).toBe("0.75em");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("버튼에 imageSrc 가 있으면 이미지와 텍스트를 함께 렌더링해야 한다 (좌측 배치)", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "btn_img_left",
|
||||||
|
type: "button",
|
||||||
|
props: {
|
||||||
|
label: "이미지 버튼",
|
||||||
|
href: "#",
|
||||||
|
imageSrc: "https://example.com/icon.png",
|
||||||
|
imageAlt: "아이콘",
|
||||||
|
imagePlacement: "left",
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||||
|
|
||||||
|
const link = container.querySelector("a") as HTMLAnchorElement | null;
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
|
||||||
|
const img = link!.querySelector("img") as HTMLImageElement | null;
|
||||||
|
expect(img).not.toBeNull();
|
||||||
|
expect(img!.src).toContain("https://example.com/icon.png");
|
||||||
|
expect(img!.alt).toBe("아이콘");
|
||||||
|
|
||||||
|
const labelNode = Array.from(link!.querySelectorAll("span")).find((el) =>
|
||||||
|
el.textContent?.includes("이미지 버튼"),
|
||||||
|
);
|
||||||
|
expect(labelNode).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("imagePlacement 가 top 인 경우 이미지가 텍스트 위쪽에 렌더링되어야 한다", () => {
|
||||||
|
const blocks: Block[] = [
|
||||||
|
{
|
||||||
|
id: "btn_img_top",
|
||||||
|
type: "button",
|
||||||
|
props: {
|
||||||
|
label: "위쪽 이미지 버튼",
|
||||||
|
href: "#",
|
||||||
|
imageSrc: "https://example.com/icon-top.png",
|
||||||
|
imageAlt: "위쪽 아이콘",
|
||||||
|
imagePlacement: "top",
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||||
|
|
||||||
|
const link = container.querySelector("a") as HTMLAnchorElement | null;
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
|
||||||
|
const wrapper = link!.querySelector("span") as HTMLSpanElement | null;
|
||||||
|
expect(wrapper).not.toBeNull();
|
||||||
|
expect(wrapper!.className).toContain("flex-col");
|
||||||
|
|
||||||
|
const firstChild = wrapper!.firstElementChild as HTMLElement | null;
|
||||||
|
expect(firstChild?.tagName.toLowerCase()).toBe("img");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1164,4 +1164,278 @@ describe("editorStore", () => {
|
|||||||
expect(custom.projectConfig.canvasPreset).toBe("custom");
|
expect(custom.projectConfig.canvasPreset).toBe("custom");
|
||||||
expect(custom.projectConfig.canvasWidthPx).toBe(1024);
|
expect(custom.projectConfig.canvasWidthPx).toBe(1024);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("여러 블록/템플릿 추가 액션은 history 에 스냅샷을 기록하고 undo/redo 로 되돌릴 수 있어야 한다", () => {
|
||||||
|
const actionNames = [
|
||||||
|
"addTextBlock",
|
||||||
|
"addButtonBlock",
|
||||||
|
"addImageBlock",
|
||||||
|
"addVideoBlock",
|
||||||
|
"addDividerBlock",
|
||||||
|
"addListBlock",
|
||||||
|
"addSectionBlock",
|
||||||
|
"addFormBlock",
|
||||||
|
"addFormInputBlock",
|
||||||
|
"addFormSelectBlock",
|
||||||
|
"addFormCheckboxBlock",
|
||||||
|
"addFormRadioBlock",
|
||||||
|
"addHeroTemplateSection",
|
||||||
|
"addFeaturesTemplateSection",
|
||||||
|
"addCtaTemplateSection",
|
||||||
|
"addFaqTemplateSection",
|
||||||
|
"addPricingTemplateSection",
|
||||||
|
"addTestimonialsTemplateSection",
|
||||||
|
"addBlogTemplateSection",
|
||||||
|
"addTeamTemplateSection",
|
||||||
|
"addFooterTemplateSection",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
actionNames.forEach((name) => {
|
||||||
|
const store = createEditorStore();
|
||||||
|
const stateAny = store.getState() as any;
|
||||||
|
|
||||||
|
expect(stateAny.blocks).toHaveLength(0);
|
||||||
|
expect(stateAny.history).toHaveLength(0);
|
||||||
|
|
||||||
|
// 각 액션을 한 번 호출하면 직전 blocks 스냅샷이 history 에 쌓여야 한다.
|
||||||
|
stateAny[name]();
|
||||||
|
|
||||||
|
const after = store.getState() as any;
|
||||||
|
|
||||||
|
// 최소 한 개 이상의 블록이 생성되어야 한다.
|
||||||
|
expect(after.blocks.length).toBeGreaterThan(0);
|
||||||
|
// 직전 상태 스냅샷이 history 에 1개 쌓여야 한다.
|
||||||
|
if (after.history.length !== 1) {
|
||||||
|
throw new Error(`history length mismatch for action ${name}: ${after.history.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocksAfterAdd = after.blocks;
|
||||||
|
const expectedLength = blocksAfterAdd.length;
|
||||||
|
|
||||||
|
stateAny.undo();
|
||||||
|
const afterUndo = store.getState() as any;
|
||||||
|
// 처음 상태가 빈 배열이었으므로 undo 후에는 다시 0개가 되어야 한다.
|
||||||
|
expect(afterUndo.blocks.length).toBe(0);
|
||||||
|
|
||||||
|
stateAny.redo();
|
||||||
|
const afterRedo = store.getState() as any;
|
||||||
|
expect(afterRedo.blocks.length).toBe(expectedLength);
|
||||||
|
// redo 이후 블록 타입 배열도 대략 동일해야 한다.
|
||||||
|
expect(afterRedo.blocks.map((b: any) => b.type)).toEqual(
|
||||||
|
blocksAfterAdd.map((b: any) => b.type),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateBlock 으로 블록 속성을 변경한 뒤 undo 하면 이전 속성으로 되돌아가야 한다", () => {
|
||||||
|
const store = createEditorStore();
|
||||||
|
|
||||||
|
store.getState().addTextBlock();
|
||||||
|
|
||||||
|
let { blocks, history } = store.getState();
|
||||||
|
expect(blocks).toHaveLength(1);
|
||||||
|
|
||||||
|
const blockId = blocks[0].id;
|
||||||
|
const initialText = (blocks[0].props as TextBlockProps).text;
|
||||||
|
expect(history.length).toBe(1);
|
||||||
|
|
||||||
|
store.getState().updateBlock(blockId, { text: "변경된 텍스트" } as any);
|
||||||
|
|
||||||
|
({ blocks, history } = store.getState());
|
||||||
|
expect((blocks[0].props as TextBlockProps).text).toBe("변경된 텍스트");
|
||||||
|
// updateBlock 이 호출되면서 history 스택이 1개 늘어나야 한다.
|
||||||
|
expect(history.length).toBe(2);
|
||||||
|
|
||||||
|
store.getState().undo();
|
||||||
|
|
||||||
|
({ blocks, history } = store.getState());
|
||||||
|
expect((blocks[0].props as TextBlockProps).text).toBe(initialText);
|
||||||
|
expect(history.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removeBlock 호출 후 undo 하면 삭제된 블록이 복원되어야 한다", () => {
|
||||||
|
const store = createEditorStore();
|
||||||
|
|
||||||
|
store.getState().addTextBlock();
|
||||||
|
store.getState().addTextBlock();
|
||||||
|
|
||||||
|
let { blocks, history } = store.getState();
|
||||||
|
expect(blocks).toHaveLength(2);
|
||||||
|
|
||||||
|
const firstId = blocks[0].id;
|
||||||
|
const secondId = blocks[1].id;
|
||||||
|
|
||||||
|
(store.getState() as any).removeBlock(secondId);
|
||||||
|
|
||||||
|
({ blocks, history } = store.getState());
|
||||||
|
expect(blocks.map((b) => b.id)).toEqual([firstId]);
|
||||||
|
// 텍스트 블록 2개 추가(2번) + removeBlock(1번) 이므로 history 스택 길이는 3이어야 한다.
|
||||||
|
expect(history.length).toBe(3);
|
||||||
|
|
||||||
|
(store.getState() as any).undo();
|
||||||
|
|
||||||
|
({ blocks } = store.getState());
|
||||||
|
expect(blocks.map((b) => b.id)).toEqual([firstId, secondId]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("duplicateBlock 호출 후 undo 하면 복제된 블록이 제거되어야 한다", () => {
|
||||||
|
const store = createEditorStore();
|
||||||
|
|
||||||
|
store.getState().addTextBlock();
|
||||||
|
|
||||||
|
let { blocks } = store.getState();
|
||||||
|
expect(blocks).toHaveLength(1);
|
||||||
|
|
||||||
|
const originalId = blocks[0].id;
|
||||||
|
|
||||||
|
(store.getState() as any).duplicateBlock(originalId);
|
||||||
|
|
||||||
|
({ blocks } = store.getState());
|
||||||
|
expect(blocks).toHaveLength(2);
|
||||||
|
|
||||||
|
(store.getState() as any).undo();
|
||||||
|
|
||||||
|
({ blocks } = store.getState());
|
||||||
|
expect(blocks).toHaveLength(1);
|
||||||
|
expect(blocks[0].id).toBe(originalId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reorderBlocks 호출 후 undo/redo 로 블록 순서를 되돌릴 수 있어야 한다", () => {
|
||||||
|
const store = createEditorStore();
|
||||||
|
|
||||||
|
store.getState().addTextBlock();
|
||||||
|
store.getState().addTextBlock();
|
||||||
|
store.getState().addTextBlock();
|
||||||
|
|
||||||
|
let { blocks } = store.getState();
|
||||||
|
const [aId, bId, cId] = blocks.map((b) => b.id);
|
||||||
|
|
||||||
|
// B 를 맨 앞으로 이동시킨다.
|
||||||
|
store.getState().reorderBlocks(bId, aId);
|
||||||
|
|
||||||
|
({ blocks } = store.getState());
|
||||||
|
expect(blocks.map((b) => b.id)).toEqual([bId, aId, cId]);
|
||||||
|
|
||||||
|
store.getState().undo();
|
||||||
|
|
||||||
|
({ blocks } = store.getState());
|
||||||
|
expect(blocks.map((b) => b.id)).toEqual([aId, bId, cId]);
|
||||||
|
|
||||||
|
store.getState().redo();
|
||||||
|
|
||||||
|
({ blocks } = store.getState());
|
||||||
|
expect(blocks.map((b) => b.id)).toEqual([bId, aId, cId]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moveBlock 호출 후 undo/redo 로 블록 위치(sectionId/columnId)를 되돌릴 수 있어야 한다", () => {
|
||||||
|
const store = createEditorStore();
|
||||||
|
|
||||||
|
// 섹션 블록과 텍스트 블록을 준비한다.
|
||||||
|
store.getState().addSectionBlock();
|
||||||
|
let { blocks } = store.getState();
|
||||||
|
const sectionBlock = blocks.find((b) => b.type === "section")!;
|
||||||
|
const sectionProps = sectionBlock.props as any;
|
||||||
|
|
||||||
|
// 섹션을 2컬럼 레이아웃으로 변경한다.
|
||||||
|
const updatedColumns = [
|
||||||
|
{ id: `${sectionBlock.id}_col_1`, span: 6 },
|
||||||
|
{ id: `${sectionBlock.id}_col_2`, span: 6 },
|
||||||
|
];
|
||||||
|
|
||||||
|
store.getState().updateBlock(sectionBlock.id, { columns: updatedColumns } as any);
|
||||||
|
|
||||||
|
store.getState().selectBlock(sectionBlock.id);
|
||||||
|
store.getState().addTextBlock();
|
||||||
|
|
||||||
|
({ blocks } = store.getState());
|
||||||
|
const textBlock = blocks.find((b) => b.type === "text") as any;
|
||||||
|
expect(textBlock.sectionId).toBe(sectionBlock.id);
|
||||||
|
expect(textBlock.columnId).toBe(updatedColumns[0].id ?? sectionProps.columns[0].id);
|
||||||
|
|
||||||
|
// 두 번째 컬럼으로 이동
|
||||||
|
store.getState().moveBlock(textBlock.id, sectionBlock.id, updatedColumns[1].id);
|
||||||
|
|
||||||
|
({ blocks } = store.getState());
|
||||||
|
let moved = blocks.find((b) => b.id === textBlock.id) as any;
|
||||||
|
expect(moved.columnId).toBe(updatedColumns[1].id);
|
||||||
|
|
||||||
|
store.getState().undo();
|
||||||
|
|
||||||
|
({ blocks } = store.getState());
|
||||||
|
moved = blocks.find((b) => b.id === textBlock.id) as any;
|
||||||
|
expect(moved.columnId).toBe(updatedColumns[0].id ?? sectionProps.columns[0].id);
|
||||||
|
|
||||||
|
store.getState().redo();
|
||||||
|
|
||||||
|
({ blocks } = store.getState());
|
||||||
|
moved = blocks.find((b) => b.id === textBlock.id) as any;
|
||||||
|
expect(moved.columnId).toBe(updatedColumns[1].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaceBlocks 호출 후 undo/redo 로 이전 블록 배열을 복원할 수 있어야 한다", () => {
|
||||||
|
const store = createEditorStore();
|
||||||
|
|
||||||
|
store.getState().addTextBlock();
|
||||||
|
|
||||||
|
let { blocks } = store.getState();
|
||||||
|
expect(blocks).toHaveLength(1);
|
||||||
|
|
||||||
|
const originalIds = blocks.map((b) => b.id);
|
||||||
|
|
||||||
|
const replacedBlocks = blocks.map((b) => ({
|
||||||
|
...b,
|
||||||
|
id: `${b.id}_replaced`,
|
||||||
|
}));
|
||||||
|
const replacedIds = replacedBlocks.map((b) => b.id);
|
||||||
|
|
||||||
|
(store.getState() as any).replaceBlocks(replacedBlocks as any);
|
||||||
|
|
||||||
|
({ blocks } = store.getState());
|
||||||
|
expect(blocks.map((b) => b.id)).toEqual(replacedIds);
|
||||||
|
|
||||||
|
(store.getState() as any).undo();
|
||||||
|
|
||||||
|
({ blocks } = store.getState());
|
||||||
|
expect(blocks.map((b) => b.id)).toEqual(originalIds);
|
||||||
|
|
||||||
|
(store.getState() as any).redo();
|
||||||
|
|
||||||
|
({ blocks } = store.getState());
|
||||||
|
expect(blocks.map((b) => b.id)).toEqual(replacedIds);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resetHistory 를 호출하면 history/future 가 비워지고 undo/redo 가 이전 스냅샷으로 돌아가지 않아야 한다", () => {
|
||||||
|
const store = createEditorStore();
|
||||||
|
|
||||||
|
// 블록을 여러 개 추가해 history 스택을 만든다.
|
||||||
|
store.getState().addTextBlock();
|
||||||
|
store.getState().addTextBlock();
|
||||||
|
|
||||||
|
let { blocks, history, future } = store.getState();
|
||||||
|
expect(blocks.length).toBe(2);
|
||||||
|
expect(history.length).toBeGreaterThan(0);
|
||||||
|
expect(future.length).toBe(0);
|
||||||
|
|
||||||
|
const snapshotAfterEdits = blocks;
|
||||||
|
|
||||||
|
// 히스토리 초기화
|
||||||
|
(store.getState() as any).resetHistory();
|
||||||
|
|
||||||
|
({ blocks, history, future } = store.getState());
|
||||||
|
expect(history).toHaveLength(0);
|
||||||
|
expect(future).toHaveLength(0);
|
||||||
|
|
||||||
|
// undo/redo 를 호출해도 더 이상 이전 스냅샷으로는 돌아가지 않아야 한다.
|
||||||
|
store.getState().undo();
|
||||||
|
({ blocks, history, future } = store.getState());
|
||||||
|
expect(blocks).toEqual(snapshotAfterEdits);
|
||||||
|
expect(history).toHaveLength(0);
|
||||||
|
expect(future).toHaveLength(0);
|
||||||
|
|
||||||
|
store.getState().redo();
|
||||||
|
({ blocks, history, future } = store.getState());
|
||||||
|
expect(blocks).toEqual(snapshotAfterEdits);
|
||||||
|
expect(history).toHaveLength(0);
|
||||||
|
expect(future).toHaveLength(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
PNG TEST FIXTURE
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
PNG TEST FIXTURE
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
PNG TEST FIXTURE
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
PNG TEST FIXTURE
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
PNG TEST FIXTURE
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-image
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-section-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-section-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-section-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-section-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-section-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-section-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-section-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-video
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-poster
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-poster
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-poster
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-poster
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-poster
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-poster
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dummy-poster
|
||||||
Reference in New Issue
Block a user