From 6ad731b6e28dd4b57c951dc2b0476e8f93732a25 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Mon, 1 Dec 2025 13:34:16 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=EC=97=90=EB=94=94=ED=84=B0=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/export/route.ts | 12 +- src/app/editor/forms/FormControllerPanel.tsx | 26 +- src/app/editor/page.tsx | 158 +++++++++- .../editor/panels/ButtonPropertiesPanel.tsx | 149 ++++++++++ src/app/preview/page.tsx | 4 + .../editor/components/PublicPageRenderer.tsx | 40 ++- src/features/editor/state/editorStore.ts | 63 +++- tests/api/export.spec.ts | 38 ++- tests/unit/ButtonPropertiesPanel.spec.tsx | 80 +++++ tests/unit/EditorAutosave.spec.tsx | 75 ++++- tests/unit/EditorButtonBlockStyle.spec.tsx | 3 + tests/unit/EditorDividerBlockStyle.spec.tsx | 3 + tests/unit/EditorExportPreview.spec.tsx | 3 + tests/unit/EditorFormBlocksStyle.spec.tsx | 3 + tests/unit/EditorImageBlockStyle.spec.tsx | 3 + tests/unit/EditorJsonExportImport.spec.tsx | 3 + tests/unit/EditorLayoutScroll.spec.tsx | 3 + tests/unit/EditorListBlockStyle.spec.tsx | 3 + tests/unit/EditorMultiSelect.spec.tsx | 3 + tests/unit/EditorPageAuth.spec.tsx | 3 + tests/unit/EditorPageBackground.spec.tsx | 3 + tests/unit/EditorPageShortcuts.spec.tsx | 3 + tests/unit/EditorProjectSaveLoad.spec.tsx | 227 ++++++++++++++- .../EditorSectionBackgroundImage.spec.tsx | 3 + tests/unit/EditorSectionLayout.spec.tsx | 3 + tests/unit/EditorSelectionFocus.spec.tsx | 3 + tests/unit/EditorTextBlockStyle.spec.tsx | 3 + tests/unit/EditorVideoBlockStyle.spec.tsx | 3 + tests/unit/FormControllerPanel.spec.tsx | 76 +++++ .../PublicPageRendererButtonStyles.spec.tsx | 59 ++++ tests/unit/editorStore.spec.ts | 274 ++++++++++++++++++ uploads/04d04b93-e16c-4996-885a-481b066a07eb | 1 + uploads/18a242fe-647b-4e5c-9952-bcfa3f1abea5 | 1 + uploads/1975a3a8-95a0-44cf-95ce-2d62e48ec69a | 1 + uploads/1b0a3ea3-f65b-4064-8a21-f86d6096df99 | 1 + uploads/49d6d744-86c9-4464-85ad-881c5f13024e | 1 + uploads/57b807eb-b546-4ead-aa12-7ce24b6d145b | 1 + uploads/8358a06e-71e9-4fb4-9cdf-b5a650176cc4 | 1 + uploads/9e89d8ba-2035-4641-a132-4ccb0db80e86 | 1 + uploads/c1453fb7-0a84-4a4c-93c3-71b945cc8d55 | 1 + uploads/d5d911c3-4f30-4383-8d6f-28da4ab8df7e | 1 + uploads/test-image-1764561587800 | 1 + uploads/test-image-1764561612085 | 1 + uploads/test-image-1764561667437 | 1 + uploads/test-image-1764561692955 | 1 + uploads/test-image-1764562655052 | 1 + uploads/test-image-1764563210423 | 1 + uploads/test-image-1764563311736 | 1 + uploads/test-section-bg-1764561587810 | 1 + uploads/test-section-bg-1764561612090 | 1 + uploads/test-section-bg-1764561667442 | 1 + uploads/test-section-bg-1764561692978 | 1 + uploads/test-section-bg-1764562655065 | 1 + uploads/test-section-bg-1764563210438 | 1 + uploads/test-section-bg-1764563311752 | 1 + uploads/test-section-bg-video-1764561587832 | 1 + uploads/test-section-bg-video-1764561612110 | 1 + uploads/test-section-bg-video-1764561667457 | 1 + uploads/test-section-bg-video-1764561692998 | 1 + uploads/test-section-bg-video-1764562655085 | 1 + uploads/test-section-bg-video-1764563210477 | 1 + uploads/test-section-bg-video-1764563311815 | 1 + uploads/test-video-1764561587827 | 1 + uploads/test-video-1764561612103 | 1 + uploads/test-video-1764561667455 | 1 + uploads/test-video-1764561692992 | 1 + uploads/test-video-1764562655081 | 1 + uploads/test-video-1764563210467 | 1 + uploads/test-video-1764563311781 | 1 + uploads/test-video-poster-1764561587805 | 1 + uploads/test-video-poster-1764561612088 | 1 + uploads/test-video-poster-1764561667439 | 1 + uploads/test-video-poster-1764561692967 | 1 + uploads/test-video-poster-1764562655058 | 1 + uploads/test-video-poster-1764563210428 | 1 + uploads/test-video-poster-1764563311741 | 1 + 76 files changed, 1348 insertions(+), 29 deletions(-) create mode 100644 uploads/04d04b93-e16c-4996-885a-481b066a07eb create mode 100644 uploads/18a242fe-647b-4e5c-9952-bcfa3f1abea5 create mode 100644 uploads/1975a3a8-95a0-44cf-95ce-2d62e48ec69a create mode 100644 uploads/1b0a3ea3-f65b-4064-8a21-f86d6096df99 create mode 100644 uploads/49d6d744-86c9-4464-85ad-881c5f13024e create mode 100644 uploads/57b807eb-b546-4ead-aa12-7ce24b6d145b create mode 100644 uploads/8358a06e-71e9-4fb4-9cdf-b5a650176cc4 create mode 100644 uploads/9e89d8ba-2035-4641-a132-4ccb0db80e86 create mode 100644 uploads/c1453fb7-0a84-4a4c-93c3-71b945cc8d55 create mode 100644 uploads/d5d911c3-4f30-4383-8d6f-28da4ab8df7e create mode 100644 uploads/test-image-1764561587800 create mode 100644 uploads/test-image-1764561612085 create mode 100644 uploads/test-image-1764561667437 create mode 100644 uploads/test-image-1764561692955 create mode 100644 uploads/test-image-1764562655052 create mode 100644 uploads/test-image-1764563210423 create mode 100644 uploads/test-image-1764563311736 create mode 100644 uploads/test-section-bg-1764561587810 create mode 100644 uploads/test-section-bg-1764561612090 create mode 100644 uploads/test-section-bg-1764561667442 create mode 100644 uploads/test-section-bg-1764561692978 create mode 100644 uploads/test-section-bg-1764562655065 create mode 100644 uploads/test-section-bg-1764563210438 create mode 100644 uploads/test-section-bg-1764563311752 create mode 100644 uploads/test-section-bg-video-1764561587832 create mode 100644 uploads/test-section-bg-video-1764561612110 create mode 100644 uploads/test-section-bg-video-1764561667457 create mode 100644 uploads/test-section-bg-video-1764561692998 create mode 100644 uploads/test-section-bg-video-1764562655085 create mode 100644 uploads/test-section-bg-video-1764563210477 create mode 100644 uploads/test-section-bg-video-1764563311815 create mode 100644 uploads/test-video-1764561587827 create mode 100644 uploads/test-video-1764561612103 create mode 100644 uploads/test-video-1764561667455 create mode 100644 uploads/test-video-1764561692992 create mode 100644 uploads/test-video-1764562655081 create mode 100644 uploads/test-video-1764563210467 create mode 100644 uploads/test-video-1764563311781 create mode 100644 uploads/test-video-poster-1764561587805 create mode 100644 uploads/test-video-poster-1764561612088 create mode 100644 uploads/test-video-poster-1764561667439 create mode 100644 uploads/test-video-poster-1764561692967 create mode 100644 uploads/test-video-poster-1764562655058 create mode 100644 uploads/test-video-poster-1764563210428 create mode 100644 uploads/test-video-poster-1764563311741 diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index cefdcca..7c4b7df 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -250,9 +250,19 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): .filter(Boolean) .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 = `${imgAlt}${innerHtml}`; + } + return `
${escapeHtml(label)}
`; + )}" class="${btnClasses}"${styleAttr}>${innerHtml}`; } if (block.type === "video") { diff --git a/src/app/editor/forms/FormControllerPanel.tsx b/src/app/editor/forms/FormControllerPanel.tsx index 970c058..2f6f216 100644 --- a/src/app/editor/forms/FormControllerPanel.tsx +++ b/src/app/editor/forms/FormControllerPanel.tsx @@ -233,10 +233,16 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc ) .map((fieldBlock) => { const fieldId = fieldBlock.id; - const fieldLabel = (fieldBlock.props as any).label ?? - (fieldBlock.props as any).groupLabel ?? - (fieldBlock.props as any).formFieldName ?? - fieldId; + const anyProps: any = fieldBlock.props ?? {}; + + const transmissionKey: string | undefined = anyProps.formFieldName; + 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); @@ -260,7 +266,7 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc } as any); }} /> - {fieldLabel} + {displayLabel} ); })} @@ -283,9 +289,17 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc .filter((b) => b.type === "button") .map((buttonBlock) => { 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 ( ); })} diff --git a/src/app/editor/page.tsx b/src/app/editor/page.tsx index d2d4977..5ddf261 100644 --- a/src/app/editor/page.tsx +++ b/src/app/editor/page.tsx @@ -3,7 +3,7 @@ import type { CSSProperties, ReactNode } from "react"; import { Fragment, useEffect, useState } from "react"; 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 { DndContext, @@ -72,6 +72,7 @@ import { EditorCanvas } from "./EditorCanvas"; export default function EditorPage() { const router = useRouter(); + const searchParams = useSearchParams(); const blocks = useEditorStore((state) => state.blocks); const selectedBlockId = useEditorStore((state) => state.selectedBlockId); 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, ); const updateProjectConfig = useEditorStore((state) => state.updateProjectConfig); + const resetHistory = useEditorStore((state) => (state as any).resetHistory as () => void); + + const [authUserId, setAuthUserId] = useState(null); + const [authChecked, setAuthChecked] = useState(false); + + const [initialSlugFromQuery, setInitialSlugFromQuery] = useState(null); + const [hasLoadedInitialProjectFromSlug, setHasLoadedInitialProjectFromSlug] = useState(false); const [editingBlockId, setEditingBlockId] = useState(null); const [editingText, setEditingText] = useState(""); @@ -140,17 +148,105 @@ export default function EditorPage() { const canUndo = historyLength > 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(() => { let cancelled = false; const checkAuth = async () => { try { 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"); + setAuthUserId(null); } } catch { - // 네트워크 오류 등은 일단 무시하고, 페이지 접근을 막지 않는다. + if (!cancelled) { + setAuthUserId(null); + } + } finally { + if (!cancelled) { + setAuthChecked(true); + } } }; @@ -353,6 +449,15 @@ export default function EditorPage() { const parsed = JSON.parse(raw); if (parsed && Array.isArray(parsed.contentJson)) { 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}`); return; } @@ -372,6 +477,15 @@ export default function EditorPage() { const data = await response.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 }); + } + + // 서버에서 다른 프로젝트를 불러온 뒤에는 이전 history/future 를 모두 비운다. + resetHistory(); + setProjectMessage(`프로젝트를 불러왔습니다: ${slug}`); } else { setProjectMessage("프로젝트 데이터 형식이 올바르지 않습니다."); @@ -586,6 +700,7 @@ export default function EditorPage() { const rootBlocks = blocks.filter((block) => !block.sectionId); useEffect(() => { + if (!authChecked) return; if (typeof window === "undefined") return; const slug = projectConfig?.slug?.trim(); @@ -596,17 +711,35 @@ export default function EditorPage() { if (!raw) return; 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)) { replaceBlocks(parsed.blocks as any); } if (parsed.projectConfig) { updateProjectConfig(parsed.projectConfig as ProjectConfig); } + + // autosave 로 전체 프로젝트를 복원한 경우에도 이전 history/future 는 끊어준다. + resetHistory(); } catch { return; } - }, [projectConfig?.slug, replaceBlocks, updateProjectConfig]); + }, [authChecked, authUserId, projectConfig?.slug, replaceBlocks, updateProjectConfig]); useEffect(() => { if (typeof window === "undefined") return; @@ -615,9 +748,22 @@ export default function EditorPage() { if (!slug) return; const key = `pb:autosave:${slug}`; + + try { + const existing = window.localStorage.getItem(key); + if (existing && !authChecked) { + // 초기 렌더 시 이미 autosave 스냅샷이 있는 경우, + // 인증 체크 및 복원(useEffect)이 끝나기 전까지는 기존 스냅샷을 덮어쓰지 않는다. + return; + } + } catch { + // 기존 값 조회 에러는 무시하고, 아래 저장 시도는 계속 진행한다. + } + const payload = { blocks, projectConfig, + savedByUserId: authUserId ?? null, }; try { @@ -625,7 +771,7 @@ export default function EditorPage() { } catch { return; } - }, [blocks, projectConfig]); + }, [blocks, projectConfig, authUserId, authChecked]); // 에디터 전역에서 Undo/Redo 단축키(Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z), // Delete/Backspace 기반 삭제, Cmd/Ctrl+D 기반 복제, diff --git a/src/app/editor/panels/ButtonPropertiesPanel.tsx b/src/app/editor/panels/ButtonPropertiesPanel.tsx index 6244254..775453d 100644 --- a/src/app/editor/panels/ButtonPropertiesPanel.tsx +++ b/src/app/editor/panels/ButtonPropertiesPanel.tsx @@ -11,6 +11,13 @@ export type 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 ( <>
@@ -26,6 +33,148 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc />
+
+

버튼 이미지

+ +
+ +
+ + {imageSource === "url" && ( +
+ +
+ )} + + {imageSource === "upload" && ( +
+ +
+ )} + + {imageSource !== "none" && ( + <> +
+ +
+ +
+ +
+ + )} +
(state as any).projectConfig); const replaceBlocks = useEditorStore((state) => state.replaceBlocks); const updateProjectConfig = useEditorStore((state) => state.updateProjectConfig); + const resetHistory = useEditorStore((state) => (state as any).resetHistory as () => void); useEffect(() => { let cancelled = false; @@ -53,6 +54,9 @@ export default function PreviewPage() { if (parsed.projectConfig) { updateProjectConfig(parsed.projectConfig as any); } + + // 프리뷰 진입 시 autosave 프로젝트를 복원하는 경우에도 에디터 히스토리는 새 프로젝트 기준으로만 유지되도록 초기화한다. + resetHistory(); } catch { return; } diff --git a/src/features/editor/components/PublicPageRenderer.tsx b/src/features/editor/components/PublicPageRenderer.tsx index 550896e..5d03460 100644 --- a/src/features/editor/components/PublicPageRenderer.tsx +++ b/src/features/editor/components/PublicPageRenderer.tsx @@ -337,10 +337,48 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) { .filter(Boolean) .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 = {props.label}; + } 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 + {alt} + ); + + const labelSpan = {props.label}; + 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 = ( + + {first} + {second} + + ); + } + return ( ); diff --git a/src/features/editor/state/editorStore.ts b/src/features/editor/state/editorStore.ts index c6c39a2..14ebfa4 100644 --- a/src/features/editor/state/editorStore.ts +++ b/src/features/editor/state/editorStore.ts @@ -116,6 +116,13 @@ export interface ButtonBlockProps { strokeColorCustom?: 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; removeBlock: (id: string) => void; duplicateBlock: (id: string) => void; + resetHistory: () => void; } // 간단한 ID 생성기 (추후 uuid 라이브러리로 교체 가능) let idCounter = 0; const createId = () => `blk_${Date.now()}_${idCounter++}`; +// undo/redo 를 위한 히스토리 유틸리티: 상태 변경 전에 현재 blocks 스냅샷을 history 에 쌓고 future 는 비운다. +const pushHistoryImpl = (set: any, get: any) => { + const { blocks } = get() as EditorState; + set((state: EditorState) => ({ + history: [...state.history, blocks], + future: [], + })); +}; + const getNextFormFieldName = (blocks: Block[], prefix: string): string => { const regex = new RegExp(`^${prefix}-(\\d+)$`); let max = 0; @@ -795,7 +812,16 @@ const getNextFormFieldName = (blocks: Block[], prefix: string): string => { // 에디터 스토어 생성 함수 (테스트 및 앱에서 공유 사용) // 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: [], selectedBlockId: null, selectedListItemId: null, @@ -860,11 +886,10 @@ const createEditorState = (set: any, get: any): EditorState => ({ columnId, }; + pushHistoryImpl(set, get); set((state: EditorState) => ({ blocks: [...state.blocks, newBlock], selectedBlockId: id, - history: [...state.history, blocks], - future: [], })); }, @@ -887,6 +912,7 @@ const createEditorState = (set: any, get: any): EditorState => ({ createId, }); + pushHistoryImpl(set, get); set((state: EditorState) => { let baseBlocks = state.blocks as Block[]; if (replacingSectionId) { @@ -928,6 +954,7 @@ const createEditorState = (set: any, get: any): EditorState => ({ createId, }); + pushHistoryImpl(set, get); set((state: EditorState) => { let baseBlocks = state.blocks as Block[]; if (replacingSectionId) { @@ -964,6 +991,7 @@ const createEditorState = (set: any, get: any): EditorState => ({ createId, }); + pushHistoryImpl(set, get); set((state: EditorState) => { let baseBlocks = state.blocks as Block[]; if (replacingSectionId) { @@ -1000,6 +1028,7 @@ const createEditorState = (set: any, get: any): EditorState => ({ createId, }); + pushHistoryImpl(set, get); set((state: EditorState) => { let baseBlocks = state.blocks as Block[]; if (replacingSectionId) { @@ -1036,6 +1065,7 @@ const createEditorState = (set: any, get: any): EditorState => ({ createId, }); + pushHistoryImpl(set, get); set((state: EditorState) => { let baseBlocks = state.blocks as Block[]; if (replacingSectionId) { @@ -1072,6 +1102,7 @@ const createEditorState = (set: any, get: any): EditorState => ({ createId, }); + pushHistoryImpl(set, get); set((state: EditorState) => { let baseBlocks = state.blocks as Block[]; if (replacingSectionId) { @@ -1108,6 +1139,7 @@ const createEditorState = (set: any, get: any): EditorState => ({ createId, }); + pushHistoryImpl(set, get); set((state: EditorState) => { let baseBlocks = state.blocks as Block[]; if (replacingSectionId) { @@ -1144,6 +1176,7 @@ const createEditorState = (set: any, get: any): EditorState => ({ createId, }); + pushHistoryImpl(set, get); set((state: EditorState) => { let baseBlocks = state.blocks as Block[]; if (replacingSectionId) { @@ -1180,6 +1213,7 @@ const createEditorState = (set: any, get: any): EditorState => ({ createId, }); + pushHistoryImpl(set, get); set((state: EditorState) => { let baseBlocks = state.blocks as Block[]; if (replacingSectionId) { @@ -1233,6 +1267,7 @@ const createEditorState = (set: any, get: any): EditorState => ({ columnId, }; + pushHistoryImpl(set, get); set((state: EditorState) => ({ blocks: [...state.blocks, newBlock], selectedBlockId: id, @@ -1279,6 +1314,7 @@ const createEditorState = (set: any, get: any): EditorState => ({ columnId, }; + pushHistoryImpl(set, get); set((state: EditorState) => ({ blocks: [...state.blocks, newBlock], selectedBlockId: id, @@ -1318,6 +1354,7 @@ const createEditorState = (set: any, get: any): EditorState => ({ columnId: null, }; + pushHistoryImpl(set, get); set((state: EditorState) => ({ blocks: [...state.blocks, newBlock], selectedBlockId: id, @@ -1359,6 +1396,7 @@ const createEditorState = (set: any, get: any): EditorState => ({ columnId: null, }; + pushHistoryImpl(set, get); set((state: EditorState) => ({ blocks: [...state.blocks, newBlock], selectedBlockId: id, @@ -1401,6 +1439,7 @@ const createEditorState = (set: any, get: any): EditorState => ({ columnId: null, }; + pushHistoryImpl(set, get); set((state: EditorState) => ({ blocks: [...state.blocks, newBlock], selectedBlockId: id, @@ -1443,6 +1482,7 @@ const createEditorState = (set: any, get: any): EditorState => ({ columnId: null, }; + pushHistoryImpl(set, get); set((state: EditorState) => ({ blocks: [...state.blocks, newBlock], selectedBlockId: id, @@ -1483,6 +1523,7 @@ const createEditorState = (set: any, get: any): EditorState => ({ columnId, }; + pushHistoryImpl(set, get); set((state: EditorState) => ({ blocks: [...state.blocks, newBlock], selectedBlockId: id, @@ -1530,6 +1571,7 @@ const createEditorState = (set: any, get: any): EditorState => ({ columnId, }; + pushHistoryImpl(set, get); set((state: EditorState) => ({ blocks: [...state.blocks, newBlock], selectedBlockId: id, @@ -1556,6 +1598,7 @@ const createEditorState = (set: any, get: any): EditorState => ({ columnId: null, }; + pushHistoryImpl(set, get); set((state: EditorState) => ({ blocks: [...state.blocks, newBlock], selectedBlockId: id, @@ -1606,6 +1649,7 @@ const createEditorState = (set: any, get: any): EditorState => ({ columnId: null, }; + pushHistoryImpl(set, get); set((state: EditorState) => ({ blocks: [...state.blocks, newBlock], selectedBlockId: id, @@ -1660,6 +1704,7 @@ const createEditorState = (set: any, get: any): EditorState => ({ columnId, }; + pushHistoryImpl(set, get); set((state: EditorState) => ({ blocks: [...state.blocks, newBlock], selectedBlockId: id, @@ -1668,6 +1713,7 @@ const createEditorState = (set: any, get: any): EditorState => ({ // 특정 블록의 속성을 부분 업데이트 (텍스트/버튼 공통 사용) updateBlock: (id, partial) => { + pushHistoryImpl(set, get); set((state: EditorState) => ({ blocks: state.blocks.map((block: Block) => block.id === id @@ -1816,6 +1862,7 @@ const createEditorState = (set: any, get: any): EditorState => ({ }, replaceBlocks: (blocks) => { + pushHistoryImpl(set, get); set({ blocks, selectedBlockId: blocks.length > 0 ? blocks[0].id : null, @@ -1823,6 +1870,7 @@ const createEditorState = (set: any, get: any): EditorState => ({ }, reorderBlocks: (activeId, overId) => { + pushHistoryImpl(set, get); set((state: EditorState) => { const current = state.blocks; const oldIndex = current.findIndex((b: Block) => b.id === activeId); @@ -1840,6 +1888,7 @@ const createEditorState = (set: any, get: any): EditorState => ({ }); }, moveBlock: (id, sectionId, columnId) => { + pushHistoryImpl(set, get); set((state: EditorState) => ({ blocks: state.blocks.map((block: Block) => block.id === id @@ -1853,6 +1902,7 @@ const createEditorState = (set: any, get: any): EditorState => ({ })); }, removeBlock: (id) => { + pushHistoryImpl(set, get); set((state: EditorState) => { const current = state.blocks; const index = current.findIndex((b) => b.id === id); @@ -1884,6 +1934,7 @@ const createEditorState = (set: any, get: any): EditorState => ({ }); }, duplicateBlock: (id) => { + pushHistoryImpl(set, get); set((state: EditorState) => { const current = state.blocks; const index = current.findIndex((b) => b.id === id); @@ -1939,7 +1990,11 @@ const createEditorState = (set: any, get: any): EditorState => ({ future: nextFuture, })); }, - }); + resetHistory: () => { + set({ history: [], future: [] }); + }, + }; +}; // React 컴포넌트에서 사용하는 전역 훅 스토어 export const useEditorStore = create()(createEditorState); diff --git a/tests/api/export.spec.ts b/tests/api/export.spec.ts index 272edd4..9dd8435 100644 --- a/tests/api/export.spec.ts +++ b/tests/api/export.spec.ts @@ -259,16 +259,16 @@ describe("/api/export", () => { 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 projectConfig: ProjectConfig = { - title: "헤드/트래킹 테스트", + title: "헤드/트래킹 스크립트 테스트", slug: "head-tracking-test", canvasPreset: "full", headHtml: '', - trackingScript: '