섹션 레이아웃 및 템플릿 스타일 개선
CI / test (push) Failing after 6m10s
CI / pr_and_merge (push) Has been skipped

This commit is contained in:
2025-11-21 16:25:55 +09:00
parent 2f59e3781e
commit 546c961a31
25 changed files with 2843 additions and 151 deletions
+261 -38
View File
@@ -37,6 +37,7 @@ import type {
FormSelectBlockProps,
FormCheckboxBlockProps,
FormRadioBlockProps,
ListItemNode,
} from "@/features/editor/state/editorStore";
import { ButtonPropertiesPanel } from "./panels/ButtonPropertiesPanel";
import { TextPropertiesPanel } from "./panels/TextPropertiesPanel";
@@ -51,6 +52,7 @@ 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);
@@ -73,6 +75,19 @@ export default function EditorPage() {
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);
@@ -519,6 +534,8 @@ export default function EditorPage() {
cancelEditing={cancelEditing}
setEditingText={setEditingText}
selectBlock={selectBlock}
selectedListItemId={selectedListItemId ?? null}
selectListItem={selectListItem}
allBlocks={blocks}
renderBlocks={renderBlocks}
updateBlock={updateBlock}
@@ -572,6 +589,18 @@ export default function EditorPage() {
>
JSON /
</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>
@@ -596,15 +625,18 @@ export default function EditorPage() {
<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>
</main>
);
{activeModal === "project" && (
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/60">
@@ -727,6 +759,8 @@ export default function EditorPage() {
</div>
</div>
)}
</main>
);
}
interface DragPreviewProps {
@@ -748,8 +782,6 @@ function DragPreview({ block }: DragPreviewProps) {
? "Image"
: block.type === "divider"
? "Divider"
: block.type === "list"
? "List"
: "Section"}
</div>
<div className="truncate">
@@ -786,6 +818,8 @@ interface SortableEditorBlockProps {
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;
@@ -801,6 +835,8 @@ function SortableEditorBlock({
cancelEditing,
setEditingText,
selectBlock,
selectedListItemId,
selectListItem,
allBlocks,
renderBlocks,
updateBlock,
@@ -1386,26 +1422,184 @@ function SortableEditorBlock({
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 py-2">
<div className={`border-slate-700 ${thicknessClass}`} />
<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 items = listProps.items && listProps.items.length > 0 ? listProps.items : ["리스트 아이템 1"];
const ListTag = (listProps.ordered ? "ol" : "ul") as "ol" | "ul";
const bulletStyleRaw = listProps.bulletStyle ?? (listProps.ordered ? "decimal" : "disc");
const alignClass =
listProps.align === "center"
? "text-center"
: listProps.align === "right"
? "text-right"
: "text-left";
return (
<div className="w-full py-1">
<ListTag className="list-inside list-disc space-y-1 text-xs">
{items.map((item, index) => (
<li key={index}>{item}</li>
// 줄 간 간격: 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>
</div>
);
);
};
return <div className="w-full py-1">{renderListNodes(itemsTree, 0)}</div>;
})()}
{block.type === "section" && (() => {
const sectionProps = block.props as SectionBlockProps;
@@ -1428,29 +1622,58 @@ function SortableEditorBlock({
? 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`}
<div
className={`w-full ${bgClass} ${pyClass} rounded border border-dashed border-slate-700`}
style={sectionStyle}
>
<div className="flex gap-3">
{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
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>
);
@@ -1533,13 +1756,13 @@ function ColumnDroppable({ id, sectionId, columnId, basis, blocks, renderBlocks,
ref={setNodeRef}
data-section-id={sectionId}
data-column-id={columnId}
className="flex-1"
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={{ flexBasis: basis }}
style={{}}
>
{blocks.length === 0 ? (
<span className="text-[11px] px-2 text-center text-slate-500">