Files
page-builder/src/app/editor/page.tsx
T
jaybe 7a8ad7c057
CI / test (push) Failing after 10m34s
CI / pr_and_merge (push) Has been cancelled
이미지 파일 업로드 기능
2025-11-23 19:07:41 +09:00

1889 lines
76 KiB
TypeScript

"use client";
import type { CSSProperties, ReactNode } from "react";
import { Fragment, useEffect, useState } from "react";
import Link from "next/link";
import {
DndContext,
PointerSensor,
closestCenter,
rectIntersection,
useSensor,
useSensors,
DragEndEvent,
DragStartEvent,
DragOverlay,
useDroppable,
useDndContext,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useEditorStore } from "@/features/editor/state/editorStore";
import type {
Block,
TextBlockProps,
ButtonBlockProps,
ImageBlockProps,
SectionBlockProps,
DividerBlockProps,
ListBlockProps,
FormBlockProps,
FormFieldConfig,
FormInputBlockProps,
FormSelectBlockProps,
FormCheckboxBlockProps,
FormRadioBlockProps,
ListItemNode,
ProjectConfig,
} from "@/features/editor/state/editorStore";
import { ButtonPropertiesPanel } from "./panels/ButtonPropertiesPanel";
import { TextPropertiesPanel } from "./panels/TextPropertiesPanel";
import { ListPropertiesPanel } from "./panels/ListPropertiesPanel";
import { DividerPropertiesPanel } from "./panels/DividerPropertiesPanel";
import { ImagePropertiesPanel } from "./panels/ImagePropertiesPanel";
import { SectionPropertiesPanel } from "./panels/SectionPropertiesPanel";
import { BlocksSidebar } from "./panels/BlocksSidebar";
import { PropertiesSidebar } from "./panels/PropertiesSidebar";
import { EditorCanvas } from "./EditorCanvas";
export default function EditorPage() {
const blocks = useEditorStore((state) => state.blocks);
const selectedBlockId = useEditorStore((state) => state.selectedBlockId);
const selectedListItemId = useEditorStore((state) => (state as any).selectedListItemId as string | null | undefined);
const addTextBlock = useEditorStore((state) => state.addTextBlock);
const addButtonBlock = useEditorStore((state) => state.addButtonBlock);
const addImageBlock = useEditorStore((state) => state.addImageBlock);
const addDividerBlock = useEditorStore((state) => state.addDividerBlock);
const addListBlock = useEditorStore((state) => state.addListBlock);
const addSectionBlock = useEditorStore((state) => state.addSectionBlock);
const addFormBlock = useEditorStore((state) => state.addFormBlock);
const addFormInputBlock = useEditorStore((state) => (state as any).addFormInputBlock);
const addFormSelectBlock = useEditorStore((state) => (state as any).addFormSelectBlock);
const addFormCheckboxBlock = useEditorStore((state) => (state as any).addFormCheckboxBlock);
const addFormRadioBlock = useEditorStore((state) => (state as any).addFormRadioBlock);
const addHeroTemplateSection = useEditorStore((state) => state.addHeroTemplateSection);
const addFeaturesTemplateSection = useEditorStore((state) => state.addFeaturesTemplateSection);
const addCtaTemplateSection = useEditorStore((state) => state.addCtaTemplateSection);
const addFaqTemplateSection = useEditorStore((state) => state.addFaqTemplateSection);
const addPricingTemplateSection = useEditorStore((state) => state.addPricingTemplateSection);
const addTestimonialsTemplateSection = useEditorStore((state) => state.addTestimonialsTemplateSection);
const addBlogTemplateSection = useEditorStore((state) => state.addBlogTemplateSection);
const addTeamTemplateSection = useEditorStore((state) => state.addTeamTemplateSection);
const addFooterTemplateSection = useEditorStore((state) => state.addFooterTemplateSection);
const updateBlock = useEditorStore((state) => state.updateBlock);
const selectBlock = useEditorStore((state) => state.selectBlock);
const selectListItem = useEditorStore((state) => (state as any).selectListItem as (id: string | null) => void);
const indentSelectedListItem = useEditorStore(
(state) => (state as any).indentSelectedListItem as (blockId: string) => void,
);
const outdentSelectedListItem = useEditorStore(
(state) => (state as any).outdentSelectedListItem as (blockId: string) => void,
);
const moveSelectedListItemUp = useEditorStore(
(state) => (state as any).moveSelectedListItemUp as (blockId: string) => void,
);
const moveSelectedListItemDown = useEditorStore(
(state) => (state as any).moveSelectedListItemDown as (blockId: string) => void,
);
const replaceBlocks = useEditorStore((state) => state.replaceBlocks);
const reorderBlocks = useEditorStore((state) => state.reorderBlocks);
const moveBlock = useEditorStore((state) => state.moveBlock);
const undo = useEditorStore((state) => state.undo);
const redo = useEditorStore((state) => state.redo);
const removeBlock = useEditorStore((state) => state.removeBlock);
const duplicateBlock = useEditorStore((state) => state.duplicateBlock);
const projectConfig = useEditorStore(
(state) => (state as any).projectConfig as ProjectConfig,
);
const [editingBlockId, setEditingBlockId] = useState<string | null>(null);
const [editingText, setEditingText] = useState("");
// 드래그 중인 블록 ID를 추적하여 DragOverlay에 일관된 고스트 카드를 렌더링한다.
const [activeDragId, setActiveDragId] = useState<string | null>(null);
const [exportJson, setExportJson] = useState("");
const [importJson, setImportJson] = useState("");
const [projectTitle, setProjectTitle] = useState("");
const [projectSlug, setProjectSlug] = useState("");
const [loadSlug, setLoadSlug] = useState("");
const [projectMessage, setProjectMessage] = useState("");
const [menuOpen, setMenuOpen] = useState(false);
const [activeModal, setActiveModal] = useState<"project" | "json" | null>(null);
const sensors = useSensors(useSensor(PointerSensor));
const editorMainStyle: CSSProperties = {};
if (projectConfig.bodyBgColorHex && projectConfig.bodyBgColorHex.trim()) {
editorMainStyle.backgroundColor = projectConfig.bodyBgColorHex;
}
const startEditing = (id: string, initialText: string) => {
selectBlock(id);
setEditingBlockId(id);
setEditingText(initialText);
};
const handleExportZip = async () => {
try {
const payload = {
blocks,
projectConfig,
};
const response = await fetch("/api/export", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
console.error("정적 파일 내보내기 실패", await response.text().catch(() => ""));
return;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const slug = (projectConfig.slug ?? "").trim() || "page-builder-export";
const link = document.createElement("a");
link.href = url;
link.download = `${slug}.zip`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
console.error("정적 파일 내보내기 중 오류", error);
}
};
const commitEditing = () => {
if (!editingBlockId) return;
updateBlock(editingBlockId, { text: editingText });
setEditingBlockId(null);
};
const cancelEditing = () => {
if (!editingBlockId) return;
const current = blocks.find((b) => b.id === editingBlockId);
if (current && current.type === "text") {
const textProps = current.props as TextBlockProps;
setEditingText(textProps.text);
}
setEditingBlockId(null);
};
const handleExportJson = () => {
try {
const payload = JSON.stringify(blocks, null, 2);
setExportJson(payload);
setImportJson(payload);
} catch {
// JSON 직렬화 실패 시는 조용히 무시 (순수 클라이언트 상태라 치명적이지 않음)
}
};
const handleClearCanvas = () => {
replaceBlocks([]);
setExportJson("");
setImportJson("");
};
const handleApplyImportJson = () => {
if (!importJson.trim()) return;
try {
const parsed = JSON.parse(importJson);
if (!Array.isArray(parsed)) return;
// 최소한의 구조 검증: id, type, props.text 가 있는 블록만 사용
const safeBlocks = parsed.filter((b) => b && typeof b.id === "string" && b.type === "text" && b.props && typeof b.props.text === "string");
replaceBlocks(safeBlocks);
// 내보내기 영역도 최신 상태로 업데이트
setExportJson(JSON.stringify(safeBlocks, null, 2));
} catch {
// 파싱 에러는 무시 (추후에는 토스트 등으로 피드백 가능)
}
};
const handleSaveProject = async () => {
const slug = projectSlug.trim();
if (!slug) {
setProjectMessage("프로젝트 주소를 입력해 주세요.");
return;
}
try {
// 1) 로컬스토리지에 현재 상태 저장 (슬러그 기준)
if (typeof window !== "undefined") {
const localKey = `pb:project:${slug}`;
const payload = {
title: projectTitle.trim() || "제목 없음",
contentJson: blocks,
};
window.localStorage.setItem(localKey, JSON.stringify(payload));
}
// 2) 서버에도 저장 (백업/공유용)
const response = await fetch("/api/projects", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: projectTitle.trim() || "제목 없음",
slug,
contentJson: blocks,
}),
});
if (!response.ok) {
setProjectMessage("프로젝트 저장에 실패했습니다.");
return;
}
const data = await response.json();
setProjectMessage(`프로젝트가 저장되었습니다: ${data.slug}`);
} catch (error) {
console.error(error);
setProjectMessage("프로젝트 저장 중 오류가 발생했습니다.");
}
};
const handleLoadProject = async () => {
const slug = loadSlug.trim() || projectSlug.trim();
if (!slug) {
setProjectMessage("불러올 주소를 입력해 주세요.");
return;
}
try {
// 1) 로컬스토리지에서 먼저 조회
if (typeof window !== "undefined") {
const localKey = `pb:project:${slug}`;
const raw = window.localStorage.getItem(localKey);
if (raw) {
try {
const parsed = JSON.parse(raw);
if (parsed && Array.isArray(parsed.contentJson)) {
replaceBlocks(parsed.contentJson as any);
setProjectMessage(`로컬에서 프로젝트를 불러왔습니다: ${slug}`);
return;
}
} catch {
// 로컬 파싱 에러는 무시하고 서버 시도
}
}
}
// 2) 로컬에 없으면 서버에서 조회
const response = await fetch(`/api/projects/${slug}`);
if (!response.ok) {
setProjectMessage("프로젝트를 불러오지 못했습니다.");
return;
}
const data = await response.json();
if (data && Array.isArray(data.contentJson)) {
replaceBlocks(data.contentJson as any);
setProjectMessage(`프로젝트를 불러왔습니다: ${slug}`);
} else {
setProjectMessage("프로젝트 데이터 형식이 올바르지 않습니다.");
}
} catch (error) {
console.error(error);
setProjectMessage("프로젝트 불러오기 중 오류가 발생했습니다.");
}
};
const handleDragStart = (event: DragStartEvent) => {
const { active } = event;
setActiveDragId(String(active.id));
};
const handleDragEnd = (event: DragEndEvent) => {
// 드래그가 끝나면 고스트 카드를 숨긴다.
setActiveDragId(null);
const { active, over } = event;
if (!over || active.id === over.id) return;
const activeId = String(active.id);
const overId = String(over.id);
// 드롭존(블록 사이/최하단 가이드 영역)에 드롭한 경우: 컨테이너 기준으로 위치를 조정한다.
if (overId.startsWith("dropzone-before:")) {
const [, targetId] = overId.split(":");
if (targetId) {
const activeBlock = blocks.find((b) => b.id === activeId);
const targetBlock = blocks.find((b) => b.id === targetId);
if (!activeBlock || !targetBlock) {
reorderBlocks(activeId, targetId);
return;
}
const activeSectionId = activeBlock.sectionId ?? null;
const activeColumnId = activeBlock.columnId ?? null;
const targetSectionId = targetBlock.sectionId ?? null;
const targetColumnId = targetBlock.columnId ?? null;
// 섹션 안 블록을 루트 드롭존으로 끌어낸 경우: 루트로 이동 후 순서 재배치
if (activeSectionId && !targetSectionId && !targetColumnId && targetBlock.type !== "section") {
moveBlock(activeId, null, null);
reorderBlocks(activeId, targetId);
return;
}
// 같은 컨테이너(루트 또는 동일 섹션/컬럼) 내에서는 순서만 변경
if (
(!activeSectionId && !activeColumnId && !targetSectionId && !targetColumnId) ||
(activeSectionId === targetSectionId && activeColumnId === targetColumnId)
) {
reorderBlocks(activeId, targetId);
return;
}
// 다른 섹션/컬럼으로의 이동: 컨테이너를 변경한 뒤 순서 재배치
moveBlock(activeId, targetSectionId, targetColumnId);
reorderBlocks(activeId, targetId);
}
return;
}
// 최하단 드롭존에 드롭한 경우: 컨테이너의 맨 아래로 이동한다.
if (overId === "dropzone-after-root" || overId.startsWith("dropzone-after:")) {
const activeBlock = blocks.find((b) => b.id === activeId);
if (!activeBlock) return;
// 루트 최하단 드롭존
if (overId === "dropzone-after-root") {
const rootBlocksForDrop = blocks.filter((b) => !b.sectionId);
const lastRoot = rootBlocksForDrop[rootBlocksForDrop.length - 1];
if (!lastRoot) {
// 아직 루트 블록이 없다면, 섹션 안 블록을 루트로만 이동시킨다.
if (activeBlock.sectionId || activeBlock.columnId) {
moveBlock(activeId, null, null);
}
return;
}
// 섹션 안에서 루트 최하단으로 끌어낸 경우: 먼저 루트로 이동 후 마지막 루트 블록 뒤로 재배치
if (activeBlock.sectionId || activeBlock.columnId) {
moveBlock(activeId, null, null);
}
if (lastRoot.id !== activeId) {
reorderBlocks(activeId, lastRoot.id);
}
return;
}
// 섹션 컬럼 최하단 드롭존: id 형식은 dropzone-after:sectionId:columnId
const [, sectionId, columnId] = overId.split(":");
const columnBlocks = blocks.filter(
(b) => b.sectionId === sectionId && b.columnId === columnId,
);
const lastInColumn = columnBlocks[columnBlocks.length - 1];
// 다른 컨테이너에서 이 컬럼으로 이동하는 경우: 먼저 컨테이너 이동
if (activeBlock.sectionId !== sectionId || activeBlock.columnId !== columnId) {
moveBlock(activeId, sectionId || null, columnId || null);
}
if (lastInColumn && lastInColumn.id !== activeId) {
reorderBlocks(activeId, lastInColumn.id);
}
return;
}
// 1) 컬럼 droppable에 드롭한 경우: 해당 섹션/컬럼으로 이동만 수행한다.
if (overId.startsWith("column:")) {
const [, sectionId, columnId] = overId.split(":");
moveBlock(activeId, sectionId || null, columnId || null);
return;
}
const activeBlock = blocks.find((b) => b.id === activeId);
const overBlock = blocks.find((b) => b.id === overId);
// 2) 드롭 대상 블록을 찾지 못한 경우: 단순 순서 변경만 시도한다.
if (!activeBlock || !overBlock) {
reorderBlocks(activeId, overId);
return;
}
const activeSectionId = activeBlock.sectionId ?? null;
const activeColumnId = activeBlock.columnId ?? null;
const overSectionId = overBlock.sectionId ?? null;
const overColumnId = overBlock.columnId ?? null;
// B-1) 루트 블록을 섹션(박스) 안으로 집어넣기:
// 섹션 블록 위로 드랍했고, 드래그 중인 블록은 루트(섹션/컬럼이 없음)인 경우
if (overBlock.type === "section" && !activeSectionId && !activeColumnId) {
const sectionProps = overBlock.props as SectionBlockProps;
const targetColumnId = sectionProps.columns?.[0]?.id ?? null;
moveBlock(activeId, overBlock.id, targetColumnId);
// 섹션 블록 주변에서 순서가 자연스럽게 보이도록 배열 순서도 함께 재배치
reorderBlocks(activeId, overId);
return;
}
// B-2) 섹션(박스) 안 블록을 루트로 꺼내기:
// 드래그 중인 블록은 섹션 안에 있고, 드랍 대상은 루트 블록(섹션/컬럼이 없음)인 경우
if (activeSectionId && !overSectionId && !overColumnId && overBlock.type !== "section") {
moveBlock(activeId, null, null);
reorderBlocks(activeId, overId);
return;
}
// 3) 루트 블록들끼리 드래그하는 경우: 순서만 변경한다.
if (!activeSectionId && !activeColumnId && !overSectionId && !overColumnId) {
reorderBlocks(activeId, overId);
return;
}
// 4) 같은 섹션/컬럼 내에서는 순서만 변경한다.
if (activeSectionId === overSectionId && activeColumnId === overColumnId) {
reorderBlocks(activeId, overId);
return;
}
// 5) 다른 컬럼/섹션으로 이동: 위치를 변경한 뒤, 블록 기준 드롭이면 순서 재배치까지 진행한다.
moveBlock(activeId, overSectionId, overColumnId);
reorderBlocks(activeId, overId);
};
const handleDragCancel = () => {
// 드래그가 취소된 경우에도 고스트 카드를 정리한다.
setActiveDragId(null);
};
const rootBlocks = blocks.filter((block) => !block.sectionId);
// 에디터 전역에서 Undo/Redo 단축키(Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z),
// Delete/Backspace 기반 삭제, Cmd/Ctrl+D 기반 복제,
// ArrowUp/ArrowDown을 이용한 선택 블록 이동을 처리한다.
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// 방향키로 선택된 블록을 위/아래로 이동 (순서 변경이 아니라 선택만 이동)
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
const { blocks: currentBlocks, selectedBlockId: currentSelectedId, selectBlock: storeSelectBlock } =
(useEditorStore as any).getState();
if (!currentBlocks.length) return;
const currentIndex = currentSelectedId
? currentBlocks.findIndex((b: Block) => b.id === currentSelectedId)
: -1;
let nextIndex = currentIndex;
if (event.key === "ArrowDown") {
// 선택이 없거나(자동 선택 상태를 무시) 0보다 큰 인덱스에서 시작하면 첫 번째 블록으로 이동한다.
if (currentIndex === -1 || currentIndex > 0) {
nextIndex = 0;
} else {
// 이미 0번을 선택 중이면 1번으로 한 칸 내려간다.
nextIndex = currentBlocks.length > 1 ? 1 : 0;
}
} else if (event.key === "ArrowUp") {
// 선택이 없을 때는 마지막 블록을 선택한다.
if (currentIndex === -1) {
nextIndex = currentBlocks.length - 1;
} else {
nextIndex = currentIndex > 0 ? currentIndex - 1 : currentIndex;
}
}
if (nextIndex !== -1 && nextIndex !== currentIndex) {
event.preventDefault();
const nextId = currentBlocks[nextIndex]?.id;
if (nextId) {
storeSelectBlock(nextId);
}
}
return;
}
const isMac = navigator.platform.toLowerCase().includes("mac");
const metaKey = isMac ? event.metaKey : event.ctrlKey;
// Delete / Backspace 로 선택된 블록 삭제
if (event.key === "Delete" || event.key === "Backspace") {
const { selectedBlockId: currentSelectedId } = (useEditorStore as any).getState();
if (currentSelectedId) {
event.preventDefault();
removeBlock(currentSelectedId);
}
return;
}
// Cmd/Ctrl 없는 경우에는 여기서 종료한다.
if (!metaKey) return;
// Cmd/Ctrl + D → 현재 선택된 블록 복제
if (event.key.toLowerCase() === "d") {
const { selectedBlockId: currentSelectedId } = (useEditorStore as any).getState();
if (currentSelectedId) {
event.preventDefault();
duplicateBlock(currentSelectedId);
}
return;
}
// Cmd/Ctrl + Shift + Z → Redo
if (event.key.toLowerCase() === "z" && event.shiftKey) {
event.preventDefault();
redo();
return;
}
// Cmd/Ctrl + Z → Undo
if (event.key.toLowerCase() === "z") {
event.preventDefault();
undo();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [undo, redo]);
const renderBlocks = (targetBlocks: Block[]) =>
targetBlocks.map((block) => {
const isSelected = block.id === selectedBlockId;
const isEditing = block.id === editingBlockId;
return (
<Fragment key={block.id}>
<BlockDropzone id={`dropzone-before:${block.id}`} />
<SortableEditorBlock
block={block}
isSelected={isSelected}
isEditing={isEditing}
editingText={editingText}
startEditing={startEditing}
commitEditing={commitEditing}
cancelEditing={cancelEditing}
setEditingText={setEditingText}
selectBlock={selectBlock}
selectedListItemId={selectedListItemId ?? null}
selectListItem={selectListItem}
allBlocks={blocks}
renderBlocks={renderBlocks}
updateBlock={updateBlock}
/>
</Fragment>
);
});
return (
<main className="min-h-screen flex flex-col" style={editorMainStyle}>
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between relative z-20">
<div className="flex items-baseline gap-3">
<h1 className="text-xl font-semibold">Page Editor</h1>
<p className="text-xs text-slate-400">빌더 MVP용 에디터 페이지 골격</p>
</div>
<div className="flex items-center gap-2 text-xs">
<Link
href="/preview"
className="inline-flex items-center rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800"
>
프리뷰 열기
</Link>
<div className="relative">
<button
type="button"
className="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800"
onClick={() => setMenuOpen((prev) => !prev)}
>
메뉴
<span className="text-[9px]"></span>
</button>
{menuOpen && (
<div className="absolute right-0 mt-1 w-48 rounded border border-slate-700 bg-slate-900 shadow-lg text-[11px] z-30">
<button
type="button"
className="w-full px-3 py-2 text-left hover:bg-slate-800 text-slate-100"
onClick={() => {
setMenuOpen(false);
setActiveModal("project");
}}
>
프로젝트 저장/불러오기
</button>
<button
type="button"
className="w-full px-3 py-2 text-left hover:bg-slate-800 text-slate-100 border-t border-slate-800"
onClick={() => {
setMenuOpen(false);
setActiveModal("json");
}}
>
JSON 내보내기/불러오기
</button>
<button
type="button"
className="w-full px-3 py-2 text-left hover:bg-slate-800 text-slate-100 border-t border-slate-800"
onClick={() => {
setMenuOpen(false);
void handleExportZip();
}}
>
페이지 파일로 내보내기 (ZIP)
</button>
<button
type="button"
className="w-full px-3 py-2 text-left hover:bg-red-900/60 text-red-100 border-t border-slate-800"
onClick={() => {
setMenuOpen(false);
if (window.confirm("캔버스를 모두 초기화할까요? 이 작업은 실행 취소로만 되돌릴 수 있습니다.")) {
handleClearCanvas();
}
}}
>
캔버스 초기화
</button>
</div>
)}
</div>
</div>
</header>
<section className="flex flex-1 overflow-hidden">
<BlocksSidebar />
<EditorCanvas
blocks={blocks}
rootBlocks={rootBlocks}
selectedBlockId={selectedBlockId}
editingBlockId={editingBlockId}
editingText={editingText}
activeDragId={activeDragId}
sensors={sensors}
onCanvasEmptyClick={() => selectBlock(null)}
renderBlocks={renderBlocks}
handleDragStart={handleDragStart}
handleDragEnd={handleDragEnd}
handleDragCancel={handleDragCancel}
projectConfig={projectConfig}
/>
<PropertiesSidebar
blocks={blocks}
selectedBlockId={selectedBlockId}
selectedListItemId={selectedListItemId ?? null}
updateBlock={(id, partial) => updateBlock(id, partial as any)}
removeBlock={removeBlock}
duplicateBlock={duplicateBlock}
editingBlockId={editingBlockId}
setEditingText={setEditingText}
onMoveSelectedItemUp={(blockId) => moveSelectedListItemUp(blockId)}
onMoveSelectedItemDown={(blockId) => moveSelectedListItemDown(blockId)}
onIndentSelectedItem={(blockId) => indentSelectedListItem(blockId)}
onOutdentSelectedItem={(blockId) => outdentSelectedListItem(blockId)}
/>
</section>
{activeModal === "project" && (
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/60">
<div className="w-full max-w-md rounded-lg border border-slate-700 bg-slate-900 p-4 text-xs text-slate-100 shadow-xl">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium">프로젝트 저장 / 불러오기</h3>
<button
type="button"
className="text-slate-400 hover:text-slate-100 text-sm"
onClick={() => setActiveModal(null)}
>
</button>
</div>
<div className="space-y-2">
<label className="flex flex-col gap-1">
<span className="text-slate-400">프로젝트 제목</span>
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
value={projectTitle}
onChange={(e) => setProjectTitle(e.target.value)}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-400">프로젝트 주소 (: my-landing)</span>
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
value={projectSlug}
onChange={(e) => setProjectSlug(e.target.value)}
/>
</label>
<div className="flex gap-2">
<button
type="button"
className="flex-1 rounded border border-sky-700 bg-sky-950 px-2 py-1 text-sky-100 hover:bg-sky-900"
onClick={handleSaveProject}
>
저장 (로컬 + 서버)
</button>
<button
type="button"
className="flex-1 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-slate-100 hover:bg-slate-800"
onClick={handleLoadProject}
>
불러오기
</button>
</div>
<label className="flex flex-col gap-1">
<span className="text-slate-400">불러올 주소 (비우면 주소 사용)</span>
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
value={loadSlug}
onChange={(e) => setLoadSlug(e.target.value)}
/>
</label>
{projectMessage && (
<p className="text-[11px] text-slate-300 mt-1">{projectMessage}</p>
)}
</div>
</div>
</div>
)}
{activeModal === "json" && (
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/60">
<div className="w-full max-w-2xl rounded-lg border border-slate-700 bg-slate-900 p-4 text-xs text-slate-100 shadow-xl">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium">JSON Export / Import</h3>
<button
type="button"
className="text-slate-400 hover:text-slate-100 text-sm"
onClick={() => setActiveModal(null)}
>
</button>
</div>
<div className="space-y-3">
<div className="flex gap-2 mb-1">
<button
type="button"
className="flex-1 rounded border border-slate-700 bg-slate-950 px-2 py-1 hover:bg-slate-800"
onClick={handleExportJson}
>
JSON 내보내기
</button>
<button
type="button"
className="rounded border border-red-700 bg-red-950 px-2 py-1 text-red-100 hover:bg-red-900"
onClick={handleClearCanvas}
>
캔버스 초기화
</button>
</div>
<div className="grid grid-cols-2 gap-3">
<label className="flex flex-col gap-1">
<span className="text-slate-400">에디터 상태 JSON</span>
<textarea
className="w-full h-48 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[10px] font-mono outline-none focus:border-sky-500"
value={exportJson}
readOnly
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-400">JSON에서 불러오기</span>
<textarea
className="w-full h-48 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[10px] font-mono outline-none focus:border-sky-500"
value={importJson}
onChange={(e) => setImportJson(e.target.value)}
/>
<button
type="button"
className="mt-1 w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 hover:bg-slate-800"
onClick={handleApplyImportJson}
>
JSON 적용하기
</button>
</label>
</div>
</div>
</div>
</div>
)}
</main>
);
}
interface DragPreviewProps {
block: Block | null;
}
function DragPreview({ block }: DragPreviewProps) {
if (!block) return null;
// 블록 타입별로 간단한 고스트 카드 UI를 렌더링한다.
return (
<div className="pointer-events-none rounded border border-sky-500 bg-slate-900/80 px-3 py-2 text-xs text-slate-100 shadow-lg opacity-90 min-w-[160px] max-w-[260px]">
<div className="text-[10px] uppercase tracking-wide text-sky-300 mb-1">
{block.type === "text"
? "Text"
: block.type === "button"
? "Button"
: block.type === "image"
? "Image"
: block.type === "divider"
? "Divider"
: "Section"}
</div>
<div className="truncate">
{block.type === "text"
? (block.props as TextBlockProps).text || "텍스트 블록"
: block.type === "button"
? (block.props as ButtonBlockProps).label || "버튼 블록"
: block.type === "image"
? (block.props as ImageBlockProps).alt || "이미지 블록"
: block.type === "list"
? "리스트 블록"
: block.type === "divider"
? "구분선 블록"
: "섹션 블록"}
</div>
</div>
);
}
interface TextInlineToolbarProps {
align: TextBlockProps["align"];
size: TextBlockProps["size"];
onChangeAlign: (value: TextBlockProps["align"]) => void;
onChangeSize: (value: TextBlockProps["size"]) => void;
}
interface SortableEditorBlockProps {
block: Block;
isSelected: boolean;
isEditing: boolean;
editingText: string;
startEditing: (id: string, initialText: string) => void;
commitEditing: () => void;
cancelEditing: () => void;
setEditingText: (value: string) => void;
selectBlock: (id: string) => void;
selectedListItemId: string | null;
selectListItem: (id: string | null) => void;
allBlocks: Block[];
renderBlocks: (targetBlocks: Block[]) => ReactNode;
updateBlock: (id: string, partial: Partial<TextBlockProps & ButtonBlockProps & FormBlockProps>) => void;
}
function SortableEditorBlock({
block,
isSelected,
isEditing,
editingText,
startEditing,
commitEditing,
cancelEditing,
setEditingText,
selectBlock,
selectedListItemId,
selectListItem,
allBlocks,
renderBlocks,
updateBlock,
}: SortableEditorBlockProps) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
id: block.id,
});
const baseStyle: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
};
let alignClass = "";
let sizeClass = "";
let leadingClass = "";
let weightClass = "";
let colorClass = "";
let maxWidthClass = "";
let decorationClass = "";
const textStyleOverrides: CSSProperties = {};
if (block.type === "text") {
const textProps = block.props as TextBlockProps;
// 정렬: pb-text-*
alignClass =
textProps.align === "center"
? "pb-text-center"
: textProps.align === "right"
? "pb-text-right"
: "pb-text-left";
// 폰트 크기: scale/custom + 기존 size 값 fallback
const fontSizeMode = textProps.fontSizeMode ?? "scale";
const fallbackScale =
textProps.size === "sm" ? "sm" : textProps.size === "lg" ? "lg" : "base";
const fontSizeScale = textProps.fontSizeScale ?? fallbackScale;
// 스케일/커스텀 모드와 무관하게 pb-text-* 스케일 클래스는 항상 유지한다.
sizeClass = `pb-text-${fontSizeScale}`;
// custom 모드에서는 inline 스타일로 실제 폰트 크기를 덮어쓴다.
if (fontSizeMode === "custom" && textProps.fontSizeCustom) {
textStyleOverrides.fontSize = textProps.fontSizeCustom;
}
// 줄 간격: scale/custom
const lineHeightMode = textProps.lineHeightMode ?? "scale";
const lineHeightScale = textProps.lineHeightScale ?? "normal";
if (lineHeightMode === "scale") {
leadingClass = `pb-leading-${lineHeightScale}`;
} else if (lineHeightMode === "custom" && textProps.lineHeightCustom) {
textStyleOverrides.lineHeight = textProps.lineHeightCustom;
}
// 굵기: scale/custom
const fontWeightMode = textProps.fontWeightMode ?? "scale";
const fontWeightScale = textProps.fontWeightScale ?? "normal";
if (fontWeightMode === "scale") {
weightClass = `pb-font-${fontWeightScale}`;
} else if (fontWeightMode === "custom" && textProps.fontWeightCustom) {
textStyleOverrides.fontWeight = textProps.fontWeightCustom as CSSProperties["fontWeight"];
}
// 색상: colorCustom이 있으면 항상 우선 사용, 없으면 팔레트
const colorPalette = textProps.colorPalette ?? "default";
if (textProps.colorCustom && textProps.colorCustom.trim() !== "") {
textStyleOverrides.color = textProps.colorCustom;
} else {
colorClass = `pb-text-color-${colorPalette}`;
}
// 최대 너비: 커스텀 값이 있으면 우선 사용, 없으면 scale 프리셋
const maxWidthScale = textProps.maxWidthScale ?? "none";
if (textProps.maxWidthCustom && textProps.maxWidthCustom.trim() !== "") {
textStyleOverrides.maxWidth = textProps.maxWidthCustom;
} else if (maxWidthScale === "prose") {
maxWidthClass = "pb-text-maxw-prose";
} else if (maxWidthScale === "narrow") {
maxWidthClass = "pb-text-maxw-narrow";
}
// 글자 간격: 커스텀 값이 있으면 em 단위 그대로 사용
if (textProps.letterSpacingCustom && textProps.letterSpacingCustom.trim() !== "") {
textStyleOverrides.letterSpacing = textProps.letterSpacingCustom;
}
// 텍스트 장식: 밑줄/가운데줄/이탤릭
if (textProps.underline) {
decorationClass += " pb-underline";
}
if (textProps.strike) {
decorationClass += " pb-line-through";
}
if (textProps.italic) {
decorationClass += " pb-italic";
}
} else if (block.type === "button") {
const buttonProps = block.props as ButtonBlockProps;
const align = buttonProps.align ?? "left";
alignClass =
align === "center" ? "pb-text-center" : align === "right" ? "pb-text-right" : "pb-text-left";
} else if (block.type === "image") {
alignClass = "pb-text-left";
} else if (block.type === "divider") {
const dividerProps = block.props as DividerBlockProps;
alignClass =
dividerProps.align === "center"
? "pb-text-center"
: dividerProps.align === "right"
? "pb-text-right"
: "pb-text-left";
} else if (block.type === "list") {
const listProps = block.props as ListBlockProps;
alignClass =
listProps.align === "center"
? "pb-text-center"
: listProps.align === "right"
? "pb-text-right"
: "pb-text-left";
} else if (block.type === "section") {
// 섹션 블록: 텍스트 크기 대신 섹션 자체에 패딩/배경을 입히므로 텍스트 클래스는 기본값만
alignClass = "pb-text-left";
} else if (block.type === "form") {
// 폼 블록은 텍스트 클래스 대신 기본 정렬만 사용한다.
alignClass = "pb-text-left";
}
return (
<div
ref={setNodeRef}
style={{ ...baseStyle, ...textStyleOverrides }}
data-testid="editor-block"
data-selected={isSelected ? "true" : "false"}
aria-selected={isSelected ? "true" : "false"}
className={`rounded border px-3 py-2 cursor-text transition-colors ${alignClass} ${sizeClass} ${leadingClass} ${weightClass} ${colorClass} ${maxWidthClass} ${decorationClass} ${
isSelected ? "border-sky-500 bg-slate-900/80" : "border-slate-700 bg-slate-900"
}`}
onClick={(event) => {
// 섹션 안 자식 블록을 클릭했을 때 섹션까지 선택되는 것을 막기 위해
// 클릭 이벤트를 여기서 중단하고 현재 블록만 선택한다.
event.stopPropagation();
selectBlock(block.id);
}}
onDoubleClick={(event) => {
if (block.type === "text") {
// 더블클릭 시 텍스트 블록 인라인 편집 모드로 진입한다.
event.stopPropagation();
startEditing(block.id, (block.props as TextBlockProps).text);
}
}}
>
<div className="flex items-start gap-2">
<button
type="button"
className="mt-0.5 h-4 w-4 rounded border border-slate-700 bg-slate-900 text-[10px] flex items-center justify-center text-slate-400 hover:bg-slate-800"
aria-label="블록 드래그 핸들"
{...listeners}
{...attributes}
>
</button>
<div className="flex-1">
{block.type === "text" && (
isEditing ? (
<textarea
className="w-full bg-transparent outline-none resize-none text-sm"
aria-label="텍스트 블록 내용 편집"
value={editingText}
onChange={(e) => setEditingText(e.target.value)}
onBlur={commitEditing}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
commitEditing();
}
if (e.key === "Escape") {
e.preventDefault();
cancelEditing();
}
}}
rows={1}
autoFocus
/>
) : (
<div
className={`whitespace-pre-wrap ${alignClass} ${sizeClass} ${leadingClass} ${weightClass} ${colorClass} ${maxWidthClass} ${decorationClass}`}
style={textStyleOverrides}
>
{(block.props as TextBlockProps).text}
</div>
)
)}
{block.type === "formInput" && (() => {
const inputProps = block.props as FormInputBlockProps;
const inputType = inputProps.inputType ?? "text";
// 필드 스타일: 텍스트 색상/배경/테두리/모서리 등은 커스텀 props 를 통해 제어한다.
const fieldStyle: CSSProperties = {};
if (inputProps.textColorCustom && inputProps.textColorCustom.trim() !== "") {
fieldStyle.color = inputProps.textColorCustom;
}
if (inputProps.fillColorCustom && inputProps.fillColorCustom.trim() !== "") {
fieldStyle.backgroundColor = inputProps.fillColorCustom;
}
if (inputProps.strokeColorCustom && inputProps.strokeColorCustom.trim() !== "") {
fieldStyle.borderColor = inputProps.strokeColorCustom;
}
const widthMode = inputProps.widthMode ?? "full";
if (widthMode === "fixed" && typeof inputProps.widthPx === "number" && inputProps.widthPx > 0) {
fieldStyle.width = `${inputProps.widthPx}px`;
}
const radius = inputProps.borderRadius ?? "md";
if (radius === "none") fieldStyle.borderRadius = 0;
else if (radius === "sm") fieldStyle.borderRadius = 4;
else if (radius === "lg") fieldStyle.borderRadius = 9999;
else if (radius === "full") fieldStyle.borderRadius = 9999;
// md 는 기본 tailwind rounded 와 비슷한 값으로 둔다 (별도 스타일 없음이면 클래스에 맡긴다).
const labelLayout = inputProps.labelLayout ?? "stacked";
const isInline = labelLayout === "inline";
const align = inputProps.align ?? "left";
const inputAlignClass =
align === "center" ? "text-center" : align === "right" ? "text-right" : "text-left";
// 에디터에서는 폼 입력 블록이 실제 입력 UI처럼 보이도록 라벨 + 인풋(또는 textarea)을 함께 렌더링한다.
return (
<div className={`text-xs ${isInline ? "flex items-center gap-2" : "flex flex-col gap-1"}`}>
{inputProps.labelMode === "image" && inputProps.labelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={inputProps.labelImageUrl}
alt={inputProps.labelImageAlt || inputProps.label || "폼 라벨"}
className="h-4 w-auto"
/>
) : (
<span className="text-slate-200 shrink-0">{inputProps.label}</span>
)}
{(() => {
// widthMode 와 labelLayout 에 따라 wrapper 의 Tailwind width/flex 클래스를 결정한다.
let widthClass = "w-full";
if (isInline) {
if (widthMode === "fixed") {
// 고정 너비일 때는 flex 레이아웃의 너비 확장을 막기 위해 flex-none 으로 둔다.
widthClass = "flex-none";
} else if (widthMode === "full") {
widthClass = "flex-1";
} else {
// auto: 인라인 레이아웃에서 내용 기반 너비 사용
widthClass = "";
}
} else {
if (widthMode === "fixed") {
widthClass = ""; // 고정 너비는 style.width 로만 제어
} else {
widthClass = "w-full";
}
}
return (
<div
data-testid="form-input-field"
style={fieldStyle}
className={`${widthClass} rounded border border-slate-700 bg-slate-950`}
>
{inputType === "textarea" ? (
<textarea
className={`w-full min-h-[60px] bg-transparent px-2 py-1 text-xs outline-none ${inputAlignClass}`}
placeholder={inputProps.placeholder || inputProps.label}
aria-label={inputProps.label}
readOnly
/>
) : (
<input
className={`w-full bg-transparent px-2 py-1 text-xs outline-none ${inputAlignClass}`}
type={inputType === "email" ? "email" : "text"}
placeholder={inputProps.placeholder || inputProps.label}
aria-label={inputProps.label}
readOnly
/>
)}
</div>
);
})()}
</div>
);
})()}
{block.type === "formSelect" && (() => {
const selectProps = block.props as FormSelectBlockProps;
const options = Array.isArray(selectProps.options) && selectProps.options.length > 0
? selectProps.options
: [
{ label: "옵션 1", value: "option_1" },
{ label: "옵션 2", value: "option_2" },
];
const fieldStyle: CSSProperties = {};
if (selectProps.textColorCustom && selectProps.textColorCustom.trim() !== "") {
fieldStyle.color = selectProps.textColorCustom;
}
if (selectProps.fillColorCustom && selectProps.fillColorCustom.trim() !== "") {
fieldStyle.backgroundColor = selectProps.fillColorCustom;
}
if (selectProps.strokeColorCustom && selectProps.strokeColorCustom.trim() !== "") {
fieldStyle.borderColor = selectProps.strokeColorCustom;
}
const selectWidthMode = selectProps.widthMode ?? "full";
if (
selectWidthMode === "fixed" &&
typeof selectProps.widthPx === "number" &&
selectProps.widthPx > 0
) {
fieldStyle.width = `${selectProps.widthPx}px`;
}
const selectRadius = selectProps.borderRadius ?? "md";
if (selectRadius === "none") fieldStyle.borderRadius = 0;
else if (selectRadius === "sm") fieldStyle.borderRadius = 4;
else if (selectRadius === "lg" || selectRadius === "full") fieldStyle.borderRadius = 9999;
// 에디터에서는 폼 셀렉트 블록이 라벨 + 셀렉트 박스로 보이도록 렌더링한다.
return (
<div className="flex flex-col gap-1 text-xs">
{selectProps.labelMode === "image" && selectProps.labelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={selectProps.labelImageUrl}
alt={selectProps.labelImageAlt || selectProps.label || "폼 라벨"}
className="h-4 w-auto"
/>
) : (
<span className="text-slate-200">{selectProps.label}</span>
)}
<div
style={fieldStyle}
className="w-full rounded border border-slate-700 bg-slate-950"
>
<select
className="w-full bg-transparent px-2 py-1 text-xs outline-none"
aria-label={selectProps.label}
defaultValue={options[0]?.value}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
);
})()}
{block.type === "formRadio" && (() => {
const radioProps = block.props as FormRadioBlockProps;
const options = Array.isArray(radioProps.options) && radioProps.options.length > 0
? radioProps.options
: [
{ label: "옵션 A", value: "option_a" },
{ label: "옵션 B", value: "option_b" },
];
const groupLabelMode = radioProps.groupLabelMode ?? "text";
const fieldStyle: CSSProperties = {};
if (radioProps.textColorCustom && radioProps.textColorCustom.trim() !== "") {
fieldStyle.color = radioProps.textColorCustom;
}
if (radioProps.fillColorCustom && radioProps.fillColorCustom.trim() !== "") {
fieldStyle.backgroundColor = radioProps.fillColorCustom;
}
if (radioProps.strokeColorCustom && radioProps.strokeColorCustom.trim() !== "") {
fieldStyle.borderColor = radioProps.strokeColorCustom;
}
const radioWidthMode = radioProps.widthMode ?? "full";
if (
radioWidthMode === "fixed" &&
typeof radioProps.widthPx === "number" &&
radioProps.widthPx > 0
) {
fieldStyle.width = `${radioProps.widthPx}px`;
}
const radioRadius = radioProps.borderRadius ?? "md";
if (radioRadius === "none") fieldStyle.borderRadius = 0;
else if (radioRadius === "sm") fieldStyle.borderRadius = 4;
else if (radioRadius === "lg" || radioRadius === "full") fieldStyle.borderRadius = 9999;
return (
<div className="flex flex-col gap-1 text-xs">
{/* 그룹 타이틀은 텍스트/이미지 모드를 지원한다. */}
{groupLabelMode === "image" && radioProps.groupLabelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={radioProps.groupLabelImageUrl}
alt={radioProps.groupLabel || "폼 그룹 타이틀"}
className="inline-block max-w-full h-auto"
/>
) : (
<span className="text-slate-200">{radioProps.groupLabel}</span>
)}
<div
style={fieldStyle}
className="flex flex-col gap-1 rounded border border-slate-700 bg-slate-950"
>
{options.map((opt) => (
<label key={opt.value} className="inline-flex items-center gap-2 text-slate-200">
<input
type="radio"
className="h-3 w-3 rounded border border-slate-700 bg-slate-950"
name={radioProps.formFieldName}
value={opt.value}
readOnly
/>
{/* 옵션 옆 라벨은 옵션에 개별 이미지 URL 이 있는 경우 이미지로 렌더링한다. */}
{opt.labelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={opt.labelImageUrl}
alt={opt.label || radioProps.groupLabel || "폼 라벨"}
className="h-4 w-auto"
/>
) : (
<span>{opt.label}</span>
)}
</label>
))}
</div>
</div>
);
})()}
{block.type === "formCheckbox" && (() => {
const checkboxProps = block.props as FormCheckboxBlockProps;
const options = Array.isArray(checkboxProps.options) && checkboxProps.options.length > 0
? checkboxProps.options
: [
{ label: "체크 1", value: "check_1" },
{ label: "체크 2", value: "check_2" },
];
const groupLabelMode = checkboxProps.groupLabelMode ?? "text";
const fieldStyle: CSSProperties = {};
if (checkboxProps.textColorCustom && checkboxProps.textColorCustom.trim() !== "") {
fieldStyle.color = checkboxProps.textColorCustom;
}
if (checkboxProps.fillColorCustom && checkboxProps.fillColorCustom.trim() !== "") {
fieldStyle.backgroundColor = checkboxProps.fillColorCustom;
}
if (checkboxProps.strokeColorCustom && checkboxProps.strokeColorCustom.trim() !== "") {
fieldStyle.borderColor = checkboxProps.strokeColorCustom;
}
const checkboxWidthMode = checkboxProps.widthMode ?? "full";
if (
checkboxWidthMode === "fixed" &&
typeof checkboxProps.widthPx === "number" &&
checkboxProps.widthPx > 0
) {
fieldStyle.width = `${checkboxProps.widthPx}px`;
}
const checkboxRadius = checkboxProps.borderRadius ?? "md";
if (checkboxRadius === "none") fieldStyle.borderRadius = 0;
else if (checkboxRadius === "sm") fieldStyle.borderRadius = 4;
else if (checkboxRadius === "lg" || checkboxRadius === "full") fieldStyle.borderRadius = 9999;
return (
<div className="flex flex-col gap-1 text-xs text-slate-200">
{/* 체크박스 그룹 타이틀도 텍스트/이미지 모드를 지원한다. */}
{groupLabelMode === "image" && checkboxProps.groupLabelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={checkboxProps.groupLabelImageUrl}
alt={checkboxProps.groupLabel || "폼 그룹 타이틀"}
className="inline-block max-w-full h-auto"
/>
) : (
<span className="text-slate-200">{checkboxProps.groupLabel}</span>
)}
<div className="flex flex-col gap-1">
{options.map((opt) => (
<label key={opt.value} className="inline-flex items-center gap-2">
<input
type="checkbox"
className="h-3 w-3 rounded border border-slate-700 bg-slate-950"
name={checkboxProps.formFieldName}
value={opt.value}
readOnly
/>
{opt.labelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={opt.labelImageUrl}
alt={opt.label || checkboxProps.groupLabel || "폼 라벨"}
className="h-4 w-auto"
/>
) : (
<span>{opt.label}</span>
)}
</label>
))}
</div>
</div>
);
})()}
{block.type === "button" && (() => {
const buttonProps = block.props as ButtonBlockProps;
const size = buttonProps.size ?? "md";
const variant = buttonProps.variant ?? "solid";
const colorPalette = buttonProps.colorPalette ?? "primary";
const radius = buttonProps.borderRadius ?? "md";
const fullWidth = buttonProps.fullWidth ?? false;
const sizeClassBtn =
size === "xs"
? "pb-btn-size-xs"
: size === "sm"
? "pb-btn-size-sm"
: size === "lg"
? "pb-btn-size-lg"
: size === "xl"
? "pb-btn-size-xl"
: "pb-btn-size-md";
const radiusClassBtn =
radius === "none"
? "pb-btn-radius-none"
: radius === "sm"
? "pb-btn-radius-sm"
: radius === "lg"
? "pb-btn-radius-lg"
: radius === "full"
? "pb-btn-radius-full"
: "pb-btn-radius-md";
const variantClassBtn = `pb-btn-variant-${variant}-${colorPalette}`;
const buttonStyle: CSSProperties = {};
if (buttonProps.fontSizeCustom && buttonProps.fontSizeCustom.trim() !== "") {
buttonStyle.fontSize = buttonProps.fontSizeCustom;
}
if (buttonProps.lineHeightCustom && buttonProps.lineHeightCustom.trim() !== "") {
buttonStyle.lineHeight = buttonProps.lineHeightCustom;
}
if (buttonProps.letterSpacingCustom && buttonProps.letterSpacingCustom.trim() !== "") {
buttonStyle.letterSpacing = buttonProps.letterSpacingCustom;
}
if (buttonProps.fillColorCustom && buttonProps.fillColorCustom.trim() !== "") {
buttonStyle.backgroundColor = buttonProps.fillColorCustom;
}
if (buttonProps.strokeColorCustom && buttonProps.strokeColorCustom.trim() !== "") {
buttonStyle.borderColor = buttonProps.strokeColorCustom;
}
if (buttonProps.textColorCustom && buttonProps.textColorCustom.trim() !== "") {
buttonStyle.color = buttonProps.textColorCustom;
}
if (typeof buttonProps.paddingX === "number" && buttonProps.paddingX >= 0) {
buttonStyle.paddingInline = `${buttonProps.paddingX}px`;
}
if (typeof buttonProps.paddingY === "number" && buttonProps.paddingY >= 0) {
buttonStyle.paddingBlock = `${buttonProps.paddingY}px`;
}
return (
<div className={fullWidth ? "w-full" : "inline-block"}>
<button
type="button"
className={`pb-btn-base ${sizeClassBtn} ${radiusClassBtn} ${variantClassBtn} w-full`}
style={buttonStyle}
aria-label={buttonProps.label}
onClick={(e) => {
// 에디터 내 버튼 클릭은 선택만 유지하고 실제 이동은 막는다.
e.stopPropagation();
}}
>
<span className="whitespace-pre-wrap">{buttonProps.label}</span>
</button>
</div>
);
})()}
{block.type === "image" && (() => {
const imageProps = block.props as ImageBlockProps;
const hasSrc = imageProps.src.trim().length > 0;
const align = imageProps.align ?? "center";
const alignClass =
align === "left" ? "justify-start" : align === "right" ? "justify-end" : "justify-center";
const containerStyle: React.CSSProperties = {};
const widthMode = imageProps.widthMode ?? "auto";
if (widthMode === "fixed" && typeof imageProps.widthPx === "number" && imageProps.widthPx > 0) {
containerStyle.width = `${imageProps.widthPx}px`;
}
const imageStyle: React.CSSProperties = {
maxWidth: "100%",
height: "auto",
};
const radiusToken = imageProps.borderRadius ?? "md";
const fallbackRadiusPx =
radiusToken === "none"
? 0
: radiusToken === "sm"
? 4
: radiusToken === "lg"
? 16
: radiusToken === "full"
? 9999
: 8;
const radiusPx =
typeof imageProps.borderRadiusPx === "number" && imageProps.borderRadiusPx >= 0
? imageProps.borderRadiusPx
: fallbackRadiusPx;
imageStyle.borderRadius = `${radiusPx}px`;
return (
<div
className={`w-full border border-dashed border-slate-700 bg-slate-900/40 rounded flex items-center overflow-hidden ${alignClass}`}
>
{hasSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={imageProps.src}
alt={imageProps.alt}
className="object-contain"
style={{ ...containerStyle, ...imageStyle }}
/>
) : (
<span className="text-[10px] text-slate-500 px-2 py-4">
이미지 URL 입력하거나 파일을 업로드하면 여기에서 미리보기가 표시됩니다.
</span>
)}
</div>
);
})()}
{block.type === "divider" && (() => {
const dividerProps = block.props as DividerBlockProps;
const thicknessClass = dividerProps.thickness === "medium" ? "border-t-2" : "border-t";
// 색상: colorHex 가 있으면 사용, 없으면 기본 슬레이트 색상
const borderColor =
dividerProps.colorHex && dividerProps.colorHex.trim() !== ""
? dividerProps.colorHex
: "#475569";
// 길이/너비: widthMode/widthPx 에 따라 가로 길이를 조정
const widthMode = dividerProps.widthMode ?? "full";
const innerStyle: React.CSSProperties = {
borderColor,
};
let innerWidthClass = "w-full";
if (widthMode === "auto") {
innerWidthClass = "w-1/2";
} else if (widthMode === "fixed") {
innerWidthClass = "";
if (typeof dividerProps.widthPx === "number" && dividerProps.widthPx > 0) {
innerStyle.width = `${dividerProps.widthPx}px`;
} else {
innerStyle.width = "320px";
}
}
// 위/아래 여백: marginYPx 가 있으면 px 값 그대로 사용, 없으면 토큰 기반 기본값
const margin =
typeof dividerProps.marginYPx === "number"
? dividerProps.marginYPx
: dividerProps.marginY === "sm"
? 8
: dividerProps.marginY === "lg"
? 24
: 16;
return (
<div className="w-full" style={{ marginTop: margin, marginBottom: margin }}>
<div className={`${thicknessClass} ${innerWidthClass} border-t`} style={innerStyle} />
</div>
);
})()}
{block.type === "list" && (() => {
const listProps = block.props as ListBlockProps;
const bulletStyleRaw = listProps.bulletStyle ?? (listProps.ordered ? "decimal" : "disc");
const alignClass =
listProps.align === "center"
? "text-center"
: listProps.align === "right"
? "text-right"
: "text-left";
// 줄 간 간격: gapYPx 숫자 값을 우선 사용하고, 없으면 gapY 토큰을 space-y 클래스로 매핑
const gapPx =
typeof listProps.gapYPx === "number"
? listProps.gapYPx
: listProps.gapY === "sm"
? 4
: listProps.gapY === "lg"
? 16
: 8;
// 타이포/색상 스타일
const listStyle: React.CSSProperties = {};
if (listProps.fontSizeCustom && listProps.fontSizeCustom.trim() !== "") {
listStyle.fontSize = listProps.fontSizeCustom;
}
if (listProps.lineHeightCustom && listProps.lineHeightCustom.trim() !== "") {
listStyle.lineHeight = listProps.lineHeightCustom;
}
if (listProps.textColorCustom && listProps.textColorCustom.trim() !== "") {
listStyle.color = listProps.textColorCustom;
}
// 불릿 스타일 (none 인 경우만 제거, decimal 은 숫자형으로 매핑)
const bulletStyle = bulletStyleRaw;
const itemsTree: ListItemNode[] =
(listProps.itemsTree as ListItemNode[] | undefined) && listProps.itemsTree!.length > 0
? (listProps.itemsTree as ListItemNode[])
: listProps.items && listProps.items.length > 0
? listProps.items.map((text, index) => ({
id: `${block.id}_item_${index + 1}`,
text,
children: [],
}))
: [
{
id: `${block.id}_item_1`,
text: "리스트 아이템 1",
children: [],
},
];
const renderListNodes = (nodes: ListItemNode[], level: number): React.ReactNode => {
const isOrderedLevel = listProps.ordered;
const ListTag = (isOrderedLevel ? "ol" : "ul") as "ol" | "ul";
return (
<ListTag
className={`text-xs ${alignClass}`}
style={{
...listStyle,
listStyleType:
bulletStyle === "none"
? "none"
: bulletStyle,
paddingLeft: 12 + level * 8,
}}
>
{nodes.map((node, index) => (
<li
key={node.id}
data-list-item-id={node.id}
className={
selectedListItemId === node.id
? "rounded bg-sky-900/40 text-sky-100"
: undefined
}
style={index < nodes.length - 1 ? { marginBottom: gapPx } : undefined}
>
<span className="break-words align-middle">{node.text}</span>
<span className="ml-2 inline-flex items-center gap-1 text-[10px] text-slate-300 align-middle">
<button
type="button"
className="rounded border border-slate-700 px-1 py-0.5 hover:bg-slate-800"
onClick={(event) => {
event.stopPropagation();
selectBlock(block.id);
selectListItem(node.id);
(useEditorStore.getState() as any).moveSelectedListItemUp(block.id);
}}
>
</button>
<button
type="button"
className="rounded border border-slate-700 px-1 py-0.5 hover:bg-slate-800"
onClick={(event) => {
event.stopPropagation();
selectBlock(block.id);
selectListItem(node.id);
(useEditorStore.getState() as any).moveSelectedListItemDown(block.id);
}}
>
아래
</button>
<button
type="button"
className="rounded border border-slate-700 px-1 py-0.5 hover:bg-slate-800"
onClick={(event) => {
event.stopPropagation();
selectBlock(block.id);
selectListItem(node.id);
(useEditorStore.getState() as any).indentSelectedListItem(block.id);
}}
>
들여쓰기
</button>
<button
type="button"
className="rounded border border-slate-700 px-1 py-0.5 hover:bg-slate-800"
onClick={(event) => {
event.stopPropagation();
selectBlock(block.id);
selectListItem(node.id);
(useEditorStore.getState() as any).outdentSelectedListItem(block.id);
}}
>
내어쓰기
</button>
</span>
{node.children && node.children.length > 0 && renderListNodes(node.children, level + 1)}
</li>
))}
</ListTag>
);
};
return <div className="w-full py-1">{renderListNodes(itemsTree, 0)}</div>;
})()}
{block.type === "section" && (() => {
const sectionProps = block.props as SectionBlockProps;
const bgClass =
sectionProps.background === "muted"
? "bg-slate-950/40"
: sectionProps.background === "primary"
? "bg-sky-950/40 border-sky-900/60"
: "bg-slate-900/60";
const pyClass =
sectionProps.paddingY === "sm"
? "py-4"
: sectionProps.paddingY === "lg"
? "py-10"
: "py-6";
const columns = sectionProps.columns && sectionProps.columns.length > 0
? sectionProps.columns
: [{ id: `${block.id}_col_fallback`, span: 12 }];
const sectionStyle: CSSProperties = {};
if (sectionProps.backgroundColorCustom && sectionProps.backgroundColorCustom.trim() !== "") {
sectionStyle.backgroundColor = sectionProps.backgroundColorCustom;
}
if (typeof sectionProps.paddingYPx === "number" && sectionProps.paddingYPx > 0) {
sectionStyle.paddingTop = `${sectionProps.paddingYPx}px`;
sectionStyle.paddingBottom = `${sectionProps.paddingYPx}px`;
}
return (
<div
className={`w-full ${bgClass} ${pyClass} rounded border border-dashed border-slate-700`}
style={sectionStyle}
>
<div
className="mx-auto px-4"
style={{
maxWidth:
typeof sectionProps.maxWidthPx === "number" && sectionProps.maxWidthPx > 0
? `${sectionProps.maxWidthPx}px`
: undefined,
}}
>
<div
className="flex"
style={{
columnGap:
typeof sectionProps.gapXPx === "number" && sectionProps.gapXPx > 0
? `${sectionProps.gapXPx}px`
: undefined,
}}
>
{columns.map((col) => {
const basis = `${(col.span / 12) * 100}%`;
const columnBlocks = allBlocks.filter(
(b) => b.sectionId === block.id && b.columnId === col.id,
);
const droppableId = `column:${block.id}:${col.id}`;
return (
<ColumnDroppable
key={col.id}
id={droppableId}
sectionId={block.id}
columnId={col.id}
basis={basis}
blocks={columnBlocks}
renderBlocks={renderBlocks}
span={col.span}
/>
);
})}
</div>
</div>
</div>
);
})()}
</div>
</div>
</div>
);
}
interface BlockDropzoneProps {
id: string;
testId?: string;
}
function BlockDropzone({ id, testId }: BlockDropzoneProps) {
const { setNodeRef, isOver } = useDroppable({ id });
return (
<div
ref={setNodeRef}
data-testid={testId}
className={`h-2 w-full rounded-full transition-colors ${
isOver ? "bg-sky-500" : "bg-sky-500/40"
}`}
/>
);
}
interface ColumnDroppableProps {
id: string;
sectionId: string;
columnId: string;
basis: string;
blocks: Block[];
renderBlocks: (targetBlocks: Block[]) => ReactNode;
span: number;
}
function ColumnDroppable({ id, sectionId, columnId, basis, blocks, renderBlocks, span }: ColumnDroppableProps) {
const { setNodeRef } = useDroppable({ id });
const { over } = useDndContext();
// 현재 드롭 대상이 이 컬럼이거나, 이 컬럼 안 블록들의 dropzone-before / dropzone-after 인 경우
// 컬럼 박스를 강조해서 사용자가 어느 컬럼으로 이동 중인지 명확히 보여준다.
const overId = over?.id ? String(over.id) : null;
const isActiveColumn = (() => {
if (!overId) return false;
// 0) 섹션 블록 자체 위에 있는 경우: 해당 섹션의 컬럼 전체를 하이라이트한다.
if (overId === sectionId) return true;
// 1) 이 컬럼 droppable 자체 위에 있는 경우
if (overId === id) return true;
// 2) 이 컬럼 안 블록들 앞 드롭존 또는 섹션 바로 위 드롭존: dropzone-before:*
if (overId.startsWith("dropzone-before:")) {
const [, targetId] = overId.split(":");
// 섹션 바로 위 루트 드롭존(dropzone-before:sectionId)도 이 섹션의 컬럼을 활성화한다.
if (targetId === sectionId) {
return true;
}
if (targetId && blocks.some((b) => b.id === targetId)) {
return true;
}
}
// 3) 이 컬럼 맨 아래 드롭존: dropzone-after:sectionId:columnId
if (overId === `dropzone-after:${sectionId}:${columnId}`) {
return true;
}
return false;
})();
return (
<div
ref={setNodeRef}
data-section-id={sectionId}
data-column-id={columnId}
style={{ flexBasis: basis }}
>
<div
className={`border rounded flex flex-col gap-2 items-stretch justify-start px-2 py-2 bg-slate-950/40 ${
isActiveColumn ? "border-sky-500" : "border-slate-800/80"
}`}
style={{}}
>
{blocks.length === 0 ? (
<span className="text-[11px] px-2 text-center text-slate-500">
{`컬럼 영역 (span ${span}/12)`}
</span>
) : (
<SortableContext items={blocks.map((b) => b.id)} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-2">
{renderBlocks(blocks)}
{/* 컬럼 맨 아래 드롭존: 블록을 이 컬럼의 최하단으로 이동 */}
<BlockDropzone id={`dropzone-after:${sectionId}:${columnId}`} />
</div>
</SortableContext>
)}
</div>
</div>
);
}