에디터 정리 및 버그 수정
This commit is contained in:
@@ -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 = `<img src="${imgSrc}" alt="${imgAlt}" />${innerHtml}`;
|
||||
}
|
||||
|
||||
return `<div class="${tokens.alignClass}"><a href="${escapeAttr(
|
||||
href,
|
||||
)}" class="${btnClasses}"${styleAttr}>${escapeHtml(label)}</a></div>`;
|
||||
)}" class="${btnClasses}"${styleAttr}>${innerHtml}</a></div>`;
|
||||
}
|
||||
|
||||
if (block.type === "video") {
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
<span>{fieldLabel}</span>
|
||||
<span>{displayLabel}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
@@ -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 (
|
||||
<option key={buttonBlock.id} value={buttonBlock.id}>
|
||||
{btnProps.label || buttonBlock.id}
|
||||
{displayLabel}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
|
||||
+152
-6
@@ -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<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 [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 기반 복제,
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
@@ -26,6 +33,148 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
|
||||
/>
|
||||
</label>
|
||||
</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">
|
||||
<NumericPropertyControl
|
||||
label="가로 패딩 (px)"
|
||||
|
||||
@@ -13,6 +13,7 @@ export default function PreviewPage() {
|
||||
const projectConfig = useEditorStore((state) => (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;
|
||||
}
|
||||
|
||||
@@ -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 = <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 (
|
||||
<div key={block.id} className={wrapperClass}>
|
||||
<a href={props.href} className={buttonClassName} style={buttonStyle}>
|
||||
<span className="whitespace-pre-wrap">{props.label}</span>
|
||||
{innerContent}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<EditorState>()(createEditorState);
|
||||
|
||||
Reference in New Issue
Block a user