섹션 레이아웃 및 템플릿 스타일 개선
This commit is contained in:
+261
-38
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user