섹션 레이아웃 및 템플릿 스타일 개선
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">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import type { DividerBlockProps } from "@/features/editor/state/editorStore";
|
||||
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
||||
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
|
||||
|
||||
export type DividerPropertiesPanelProps = {
|
||||
dividerProps: DividerBlockProps;
|
||||
@@ -46,6 +48,90 @@ export function DividerPropertiesPanel({ dividerProps, selectedBlockId, updateBl
|
||||
</select>
|
||||
</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>
|
||||
|
||||
{/* 길이/너비 모드 */}
|
||||
<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={dividerProps.widthMode ?? "full"}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value as NonNullable<DividerBlockProps["widthMode"]>;
|
||||
updateBlock(selectedBlockId, { widthMode: value } as any);
|
||||
}}
|
||||
>
|
||||
<option value="auto">내용에 맞춤</option>
|
||||
<option value="full">전체 폭</option>
|
||||
<option value="fixed">고정 길이 (px)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{(dividerProps.widthMode ?? "full") === "fixed" && (
|
||||
<NumericPropertyControl
|
||||
label="고정 길이 (px)"
|
||||
unitLabel="(px)"
|
||||
value={dividerProps.widthPx ?? 320}
|
||||
min={40}
|
||||
max={1200}
|
||||
step={10}
|
||||
presets={[
|
||||
{ id: "sm", label: "짧게", value: 240 },
|
||||
{ id: "md", label: "보통", value: 320 },
|
||||
{ id: "lg", label: "길게", value: 480 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, { widthPx: v } as any);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 색상 */}
|
||||
<ColorPickerField
|
||||
label="선 색상"
|
||||
ariaLabelColorInput="구분선 색상 피커"
|
||||
ariaLabelHexInput="구분선 색상 HEX"
|
||||
value={
|
||||
dividerProps.colorHex && dividerProps.colorHex.trim() !== ""
|
||||
? dividerProps.colorHex
|
||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#64748b"
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, { colorHex: hex } as any);
|
||||
}}
|
||||
palette={TEXT_COLOR_PALETTE}
|
||||
onPaletteSelect={(item) => {
|
||||
updateBlock(selectedBlockId, { colorHex: item.color } as any);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 상하 여백 (슬라이더) */}
|
||||
<NumericPropertyControl
|
||||
label="위/아래 여백"
|
||||
unitLabel="(px)"
|
||||
value={(() => {
|
||||
if (typeof dividerProps.marginYPx === "number") {
|
||||
return dividerProps.marginYPx;
|
||||
}
|
||||
const y = dividerProps.marginY ?? "md";
|
||||
return y === "sm" ? 8 : y === "lg" ? 24 : 16;
|
||||
})()}
|
||||
min={0}
|
||||
max={50}
|
||||
step={2}
|
||||
presets={[
|
||||
{ id: "sm", label: "작게", value: 8 },
|
||||
{ id: "md", label: "보통", value: 16 },
|
||||
{ id: "lg", label: "크게", value: 24 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, { marginYPx: v } as any);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { ImageBlockProps } from "@/features/editor/state/editorStore";
|
||||
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
||||
|
||||
export type ImagePropertiesPanelProps = {
|
||||
imageProps: ImageBlockProps;
|
||||
@@ -37,6 +38,94 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* 정렬 */}
|
||||
<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={imageProps.align ?? "center"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
align: e.target.value as ImageBlockProps["align"],
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="left">왼쪽</option>
|
||||
<option value="center">가운데</option>
|
||||
<option value="right">오른쪽</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* 너비 모드 */}
|
||||
<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={imageProps.widthMode ?? "auto"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
widthMode: e.target.value as ImageBlockProps["widthMode"],
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="auto">내용에 맞춤</option>
|
||||
<option value="fixed">고정 너비 (px)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{(imageProps.widthMode ?? "auto") === "fixed" && (
|
||||
<NumericPropertyControl
|
||||
label="고정 너비 (px)"
|
||||
unitLabel="(px)"
|
||||
value={imageProps.widthPx ?? 320}
|
||||
min={40}
|
||||
max={1200}
|
||||
step={10}
|
||||
presets={[
|
||||
{ id: "sm", label: "작게", value: 240 },
|
||||
{ id: "md", label: "보통", value: 320 },
|
||||
{ id: "lg", label: "넓게", value: 480 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
widthPx: v,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 모서리 둥글기 */}
|
||||
<NumericPropertyControl
|
||||
label="모서리 둥글기"
|
||||
value={(() => {
|
||||
const r = imageProps.borderRadius ?? "md";
|
||||
return r === "none" ? 0 : r === "sm" ? 2 : r === "lg" ? 6 : r === "full" ? 8 : 4;
|
||||
})()}
|
||||
min={0}
|
||||
max={8}
|
||||
step={1}
|
||||
presets={[
|
||||
{ id: "none", label: "없음", value: 0 },
|
||||
{ id: "sm", label: "작게", value: 2 },
|
||||
{ id: "md", label: "보통", value: 4 },
|
||||
{ id: "lg", label: "크게", value: 6 },
|
||||
{ id: "full", label: "완전 둥글게", value: 8 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
const next =
|
||||
v <= 0 ? "none" : v <= 2 ? "sm" : v <= 5 ? "md" : v <= 7 ? "lg" : "full";
|
||||
updateBlock(selectedBlockId, {
|
||||
borderRadius: next,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import type { ListBlockProps } from "@/features/editor/state/editorStore";
|
||||
import {
|
||||
buildItemsTreeFromItems,
|
||||
itemsTreeToLines,
|
||||
linesToItemsTree,
|
||||
parseTextareaToLines,
|
||||
stringifyLinesToTextarea,
|
||||
} from "@/features/editor/state/editorStore";
|
||||
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
||||
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
|
||||
|
||||
export type ListPropertiesPanelProps = {
|
||||
listProps: ListBlockProps;
|
||||
selectedBlockId: string;
|
||||
selectedListItemId: string | null;
|
||||
updateBlock: (id: string, partial: Partial<ListBlockProps>) => void;
|
||||
};
|
||||
|
||||
export function ListPropertiesPanel({ listProps, selectedBlockId, updateBlock }: ListPropertiesPanelProps) {
|
||||
const firstItem = listProps.items && listProps.items.length > 0 ? listProps.items[0] : "";
|
||||
|
||||
export function ListPropertiesPanel({
|
||||
listProps,
|
||||
selectedBlockId,
|
||||
selectedListItemId,
|
||||
updateBlock,
|
||||
}: ListPropertiesPanelProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<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={firstItem}
|
||||
onChange={(e) => {
|
||||
const nextItems = [...(listProps.items ?? [])];
|
||||
if (nextItems.length === 0) {
|
||||
nextItems.push(e.target.value);
|
||||
} else {
|
||||
nextItems[0] = e.target.value;
|
||||
<span>리스트 아이템 (줄바꿈으로 구분)</span>
|
||||
<textarea
|
||||
className="w-full min-h-[80px] rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="리스트 아이템들"
|
||||
value={(() => {
|
||||
const tree = (listProps as any).itemsTree as any[] | undefined;
|
||||
|
||||
if (tree && tree.length > 0) {
|
||||
// 중첩 리스트가 존재하는 경우, itemsTree → ListLine → textarea 문자열 순으로 직렬화한다.
|
||||
const lines = itemsTreeToLines(tree as any);
|
||||
return stringifyLinesToTextarea(lines as any);
|
||||
}
|
||||
updateBlock(selectedBlockId, { items: nextItems } as any);
|
||||
|
||||
// 기존 하위호환: items 배열이 있다면 그대로 줄바꿈으로 이어 붙여 사용한다.
|
||||
return (listProps.items ?? []).join("\n");
|
||||
})()}
|
||||
onChange={(e) => {
|
||||
const textValue = e.target.value;
|
||||
|
||||
// textarea 내용을 ListLine 배열로 파싱한다.
|
||||
const parsedLines = parseTextareaToLines(textValue);
|
||||
|
||||
// 전체 입력이 완전히 비어 있는 경우에는 빈 리스트 상태를 유지한다.
|
||||
const hasAnyContent = parsedLines.some((line) => line.text.length > 0);
|
||||
|
||||
if (!hasAnyContent) {
|
||||
updateBlock(
|
||||
selectedBlockId,
|
||||
{
|
||||
items: [],
|
||||
itemsTree: [],
|
||||
} as any,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// depth 기반 라인 모델에서 중첩 리스트 트리를 생성한다.
|
||||
const nextTree = linesToItemsTree(selectedBlockId, parsedLines as any);
|
||||
|
||||
// 플랫 items 배열은 기존 하위호환을 위해 text 만 뽑아서 유지한다.
|
||||
const flatItems = parsedLines.map((line) => line.text);
|
||||
|
||||
updateBlock(
|
||||
selectedBlockId,
|
||||
{
|
||||
items: flatItems,
|
||||
itemsTree: nextTree,
|
||||
} as any,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={listProps.ordered}
|
||||
onChange={(e) => {
|
||||
updateBlock(selectedBlockId, { ordered: e.target.checked } as any);
|
||||
}}
|
||||
/>
|
||||
<span>번호 매기기</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<span>정렬</span>
|
||||
<select
|
||||
@@ -60,6 +99,133 @@ export function ListPropertiesPanel({ listProps, selectedBlockId, updateBlock }:
|
||||
</select>
|
||||
</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>
|
||||
|
||||
{/* 글자 크기 */}
|
||||
<NumericPropertyControl
|
||||
label="글자 크기"
|
||||
unitLabel="(px)"
|
||||
value={(() => {
|
||||
const raw = listProps.fontSizeCustom ?? "";
|
||||
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
|
||||
if (match) return Number(match[1]);
|
||||
return 14;
|
||||
})()}
|
||||
min={10}
|
||||
max={32}
|
||||
step={1}
|
||||
presets={[
|
||||
{ id: "sm", label: "작게", value: 12 },
|
||||
{ id: "md", label: "보통", value: 14 },
|
||||
{ id: "lg", label: "크게", value: 18 },
|
||||
]}
|
||||
onChangeValue={(px) => {
|
||||
updateBlock(selectedBlockId, { fontSizeCustom: `${px}px` } as any);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 줄 간격 */}
|
||||
<NumericPropertyControl
|
||||
label="줄 간격"
|
||||
value={(() => {
|
||||
const raw = listProps.lineHeightCustom ?? "";
|
||||
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
|
||||
if (match) return Number(match[1]);
|
||||
return 1.5;
|
||||
})()}
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.05}
|
||||
presets={[
|
||||
{ id: "tight", label: "좁게", value: 1.2 },
|
||||
{ id: "normal", label: "보통", value: 1.5 },
|
||||
{ id: "relaxed", label: "넓게", value: 1.8 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, { lineHeightCustom: v.toString() } as any);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 텍스트 색상 */}
|
||||
<ColorPickerField
|
||||
label="텍스트 색상"
|
||||
ariaLabelColorInput="리스트 텍스트 색상 피커"
|
||||
ariaLabelHexInput="리스트 텍스트 색상 HEX"
|
||||
value={
|
||||
listProps.textColorCustom && listProps.textColorCustom.trim() !== ""
|
||||
? listProps.textColorCustom
|
||||
: "#e5e7eb"
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, { textColorCustom: hex } as any);
|
||||
}}
|
||||
palette={TEXT_COLOR_PALETTE}
|
||||
onPaletteSelect={(item) => {
|
||||
updateBlock(selectedBlockId, { textColorCustom: item.color } as any);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 불릿 스타일 (● / ○ / ■ / 숫자형 / 없음) */}
|
||||
<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={listProps.bulletStyle ?? "disc"}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value as NonNullable<ListBlockProps["bulletStyle"]>;
|
||||
|
||||
// 순서형 스타일(decimal / alpha / roman)은 ordered=true, 나머지는 false 로 설정한다.
|
||||
const orderedStyles: Array<NonNullable<ListBlockProps["bulletStyle"]>> = [
|
||||
"decimal",
|
||||
"lower-alpha",
|
||||
"upper-alpha",
|
||||
"lower-roman",
|
||||
"upper-roman",
|
||||
];
|
||||
const ordered = orderedStyles.includes(value);
|
||||
|
||||
updateBlock(selectedBlockId, { bulletStyle: value, ordered } as any);
|
||||
}}
|
||||
>
|
||||
<option value="disc">기본 (●)</option>
|
||||
<option value="circle">원 (○)</option>
|
||||
<option value="square">사각 (■)</option>
|
||||
<option value="decimal">숫자 (1.)</option>
|
||||
<option value="lower-alpha">알파벳 소문자 (a.)</option>
|
||||
<option value="upper-alpha">알파벳 대문자 (A.)</option>
|
||||
<option value="lower-roman">로마 숫자 소문자 (i.)</option>
|
||||
<option value="upper-roman">로마 숫자 대문자 (I.)</option>
|
||||
<option value="none">없음</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* 아이템 간 여백 (슬라이더) */}
|
||||
<NumericPropertyControl
|
||||
label="아이템 간 여백"
|
||||
unitLabel="(px)"
|
||||
value={(() => {
|
||||
if (typeof listProps.gapYPx === "number") {
|
||||
return listProps.gapYPx;
|
||||
}
|
||||
const token = listProps.gapY ?? "md";
|
||||
return token === "sm" ? 4 : token === "lg" ? 16 : 8;
|
||||
})()}
|
||||
min={0}
|
||||
max={40}
|
||||
step={2}
|
||||
presets={[
|
||||
{ id: "tight", label: "좁게", value: 4 },
|
||||
{ id: "normal", label: "보통", value: 8 },
|
||||
{ id: "relaxed", label: "넓게", value: 16 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, { gapYPx: v } as any);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,12 +16,17 @@ import { FormControllerPanel } from "../forms/FormControllerPanel";
|
||||
interface PropertiesSidebarProps {
|
||||
blocks: Block[];
|
||||
selectedBlockId: string | null;
|
||||
selectedListItemId: string | null;
|
||||
updateBlock: (id: string, partial: any) => void;
|
||||
removeBlock: (id: string) => void;
|
||||
duplicateBlock: (id: string) => void;
|
||||
// 텍스트 속성 패널에서 사용하는 인라인 편집 상태를 그대로 전달한다.
|
||||
editingBlockId: string | null;
|
||||
setEditingText: (value: string) => void;
|
||||
onMoveSelectedItemUp: (blockId: string) => void;
|
||||
onMoveSelectedItemDown: (blockId: string) => void;
|
||||
onIndentSelectedItem: (blockId: string) => void;
|
||||
onOutdentSelectedItem: (blockId: string) => void;
|
||||
}
|
||||
|
||||
// 우측 속성 패널을 분리한 컴포넌트
|
||||
@@ -29,11 +34,16 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
|
||||
const {
|
||||
blocks,
|
||||
selectedBlockId,
|
||||
selectedListItemId,
|
||||
updateBlock,
|
||||
removeBlock,
|
||||
duplicateBlock,
|
||||
editingBlockId,
|
||||
setEditingText,
|
||||
onMoveSelectedItemUp,
|
||||
onMoveSelectedItemDown,
|
||||
onIndentSelectedItem,
|
||||
onOutdentSelectedItem,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
@@ -100,6 +110,7 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
|
||||
<ListPropertiesPanel
|
||||
listProps={listProps}
|
||||
selectedBlockId={selectedBlockId}
|
||||
selectedListItemId={selectedListItemId}
|
||||
updateBlock={(id, partial) => updateBlock(id, partial as any)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import type { SectionBlockProps } from "@/features/editor/state/editorStore";
|
||||
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
|
||||
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
||||
|
||||
export type SectionPropertiesPanelProps = {
|
||||
sectionProps: SectionBlockProps;
|
||||
@@ -11,42 +13,374 @@ export type SectionPropertiesPanelProps = {
|
||||
export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBlock }: SectionPropertiesPanelProps) {
|
||||
return (
|
||||
<>
|
||||
{/* 섹션 배경 */}
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<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={sectionProps.background ?? "default"}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value as NonNullable<SectionBlockProps["background"]>;
|
||||
updateBlock(selectedBlockId, { background: value } as any);
|
||||
}}
|
||||
>
|
||||
<option value="default">기본</option>
|
||||
<option value="muted">Muted</option>
|
||||
<option value="primary">Primary</option>
|
||||
</select>
|
||||
</label>
|
||||
{/* 섹션 배경: 커스텀 색상 피커만 사용 */}
|
||||
<div className="mt-1">
|
||||
<ColorPickerField
|
||||
label="배경 색상"
|
||||
ariaLabelColorInput="섹션 배경 색상 선택"
|
||||
ariaLabelHexInput="섹션 배경 색상 HEX 입력"
|
||||
value={sectionProps.backgroundColorCustom ?? ""}
|
||||
onChange={(hex) => {
|
||||
const next = hex && hex.trim().length > 0 ? hex : undefined;
|
||||
updateBlock(selectedBlockId, { backgroundColorCustom: next } as any);
|
||||
}}
|
||||
palette={TEXT_COLOR_PALETTE}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 상하 패딩 */}
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>세로 패딩</span>
|
||||
{/* 세로 패딩 슬라이더 (px) - 프리셋 + 자유 슬라이더 */}
|
||||
<div className="mt-3 space-y-1">
|
||||
<NumericPropertyControl
|
||||
label="세로 패딩"
|
||||
unitLabel="(px)"
|
||||
value={typeof sectionProps.paddingYPx === "number" ? sectionProps.paddingYPx : 48}
|
||||
min={16}
|
||||
max={80}
|
||||
step={2}
|
||||
presets={[
|
||||
{ id: "x-tight", label: "매우 좁게", value: 16 },
|
||||
{ id: "tight", label: "좁게", value: 32 },
|
||||
{ id: "normal", label: "보통", value: 48 },
|
||||
{ id: "relaxed", label: "넓게", value: 64 },
|
||||
{ id: "x-relaxed", label: "아주 넓게", value: 80 },
|
||||
]}
|
||||
onChangeValue={(next) => {
|
||||
const safe = Number.isFinite(next) ? next : 48;
|
||||
updateBlock(selectedBlockId, { paddingYPx: safe } as any);
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* 컬럼 레이아웃 프리셋 */}
|
||||
<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={sectionProps.paddingY ?? "md"}
|
||||
aria-label="섹션 컬럼 레이아웃"
|
||||
value={(() => {
|
||||
const cols = sectionProps.columns ?? [];
|
||||
const spans = cols.map((c) => c.span).join("-");
|
||||
if (spans === "12") return "one-full";
|
||||
if (spans === "6-6") return "two-equal";
|
||||
if (spans === "4-8") return "two-1-2";
|
||||
if (spans === "8-4") return "two-2-1";
|
||||
if (spans === "4-4-4") return "three-equal";
|
||||
return "custom";
|
||||
})()}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value as NonNullable<SectionBlockProps["paddingY"]>;
|
||||
updateBlock(selectedBlockId, { paddingY: value } as any);
|
||||
const v = e.target.value as
|
||||
| "one-full"
|
||||
| "two-equal"
|
||||
| "two-1-2"
|
||||
| "two-2-1"
|
||||
| "three-equal"
|
||||
| "custom";
|
||||
|
||||
if (v === "custom") return;
|
||||
const current = sectionProps.columns ?? [];
|
||||
|
||||
const ensureId = (index: number) => {
|
||||
if (current[index]?.id) return current[index].id;
|
||||
return `${selectedBlockId}_col_${index + 1}`;
|
||||
};
|
||||
|
||||
const nextColumns =
|
||||
v === "one-full"
|
||||
? [
|
||||
{ id: ensureId(0), span: 12 },
|
||||
]
|
||||
: v === "two-equal"
|
||||
? [
|
||||
{ id: ensureId(0), span: 6 },
|
||||
{ id: ensureId(1), span: 6 },
|
||||
]
|
||||
: v === "two-1-2"
|
||||
? [
|
||||
{ id: ensureId(0), span: 4 },
|
||||
{ id: ensureId(1), span: 8 },
|
||||
]
|
||||
: v === "two-2-1"
|
||||
? [
|
||||
{ id: ensureId(0), span: 8 },
|
||||
{ id: ensureId(1), span: 4 },
|
||||
]
|
||||
: [
|
||||
{ id: ensureId(0), span: 4 },
|
||||
{ id: ensureId(1), span: 4 },
|
||||
{ id: ensureId(2), span: 4 },
|
||||
];
|
||||
|
||||
updateBlock(selectedBlockId, { columns: nextColumns } as any);
|
||||
}}
|
||||
>
|
||||
<option value="sm">작게</option>
|
||||
<option value="md">보통</option>
|
||||
<option value="lg">크게</option>
|
||||
<option value="one-full">1열 (1/1)</option>
|
||||
<option value="two-equal">2열 (1/2 - 1/2)</option>
|
||||
<option value="two-1-2">2열 (1/3 - 2/3)</option>
|
||||
<option value="two-2-1">2열 (2/3 - 1/3)</option>
|
||||
<option value="three-equal">3열 (1/3 - 1/3 - 1/3)</option>
|
||||
<option value="custom">직접 조정</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* 컬럼 개수 직접 조정 (1~5열) */}
|
||||
{(() => {
|
||||
const current = sectionProps.columns ?? [];
|
||||
const count = current.length || 1;
|
||||
|
||||
const handleChangeCount = (nextCount: number) => {
|
||||
const clamped = Math.max(1, Math.min(5, Math.floor(nextCount)));
|
||||
if (clamped === count) return;
|
||||
|
||||
const base = Math.floor(12 / clamped) || 1;
|
||||
const rest = 12 - base * clamped;
|
||||
|
||||
const nextColumns = Array.from({ length: clamped }).map((_, index) => {
|
||||
const existing = current[index];
|
||||
const id = existing?.id ?? `${selectedBlockId}_col_${index + 1}`;
|
||||
const span = base + (index === clamped - 1 ? rest : 0);
|
||||
return { id, span };
|
||||
});
|
||||
|
||||
updateBlock(selectedBlockId, { columns: nextColumns } as any);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-slate-300">
|
||||
<span>{`컬럼 개수: ${count}열 (최대 5열)`}</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-slate-700 px-2 py-0.5 text-[11px] disabled:opacity-40"
|
||||
onClick={() => handleChangeCount(count - 1)}
|
||||
disabled={count <= 1}
|
||||
>
|
||||
- 열 제거
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-slate-700 px-2 py-0.5 text-[11px] disabled:opacity-40"
|
||||
onClick={() => handleChangeCount(count + 1)}
|
||||
disabled={count >= 5}
|
||||
>
|
||||
+ 열 추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 컬럼별 span 조정
|
||||
- 컬럼이 1개일 때: 12 고정, 편집 불가능
|
||||
- 컬럼이 2개 이상일 때: 앞의 N-1개 컬럼만 직접 조정, 마지막 컬럼은 합이 항상 12가 되도록 자동 계산 */}
|
||||
{(() => {
|
||||
const columns = sectionProps.columns ?? [];
|
||||
const colCount = columns.length || 1;
|
||||
|
||||
// 1컬럼인 경우: 12 고정 표시
|
||||
if (colCount === 1) {
|
||||
const only = columns[0];
|
||||
return (
|
||||
<label key={only.id} className="flex flex-col gap-1">
|
||||
<span>{`1열 폭 (고정 12/12)`}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={12}
|
||||
max={12}
|
||||
step={1}
|
||||
className="flex-1 opacity-60"
|
||||
aria-label="1열 폭 슬라이더 (고정)"
|
||||
value={12}
|
||||
disabled
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={12}
|
||||
max={12}
|
||||
className="w-16 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none text-slate-400"
|
||||
aria-label="1열 폭 (고정 12/12)"
|
||||
value={12}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const lastIndex = colCount - 1;
|
||||
|
||||
return columns.map((col, index) => {
|
||||
const maxSpanForEach = Math.max(1, 12 - (colCount - 1));
|
||||
|
||||
// 마지막 컬럼: 자동 계산, 편집 불가
|
||||
if (index === lastIndex) {
|
||||
const nonLast = columns.slice(0, lastIndex);
|
||||
const nonLastSum = nonLast.reduce(
|
||||
(sum, c) => sum + (Number.isFinite(c.span) ? c.span : 0),
|
||||
0,
|
||||
);
|
||||
let autoSpan = 12 - nonLastSum;
|
||||
if (!Number.isFinite(autoSpan) || autoSpan < 1) autoSpan = 1;
|
||||
|
||||
return (
|
||||
<label key={col.id} className="flex flex-col gap-1">
|
||||
<span>{`${index + 1}열 폭 (자동, 합계 12 유지)`}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={maxSpanForEach}
|
||||
step={1}
|
||||
className="flex-1 opacity-60"
|
||||
aria-label={`${index + 1}열 폭 슬라이더 (자동)`}
|
||||
value={Number.isFinite(col.span) ? col.span : autoSpan}
|
||||
disabled
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={maxSpanForEach}
|
||||
className="w-16 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none text-slate-400"
|
||||
aria-label={`${index + 1}열 폭 (자동)`}
|
||||
value={Number.isFinite(col.span) ? col.span : autoSpan}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// 앞의 N-1개 컬럼: 직접 조정하면 마지막 컬럼을 자동으로 보정해서 합계 12 유지
|
||||
const otherNonLastSum = columns.reduce((sum, c, idx) => {
|
||||
if (idx === index || idx === lastIndex) return sum;
|
||||
return sum + (Number.isFinite(c.span) ? c.span : 0);
|
||||
}, 0);
|
||||
|
||||
const maxForCurrent = Math.max(
|
||||
1,
|
||||
Math.min(maxSpanForEach, 11 - otherNonLastSum),
|
||||
);
|
||||
|
||||
const handleChange = (raw: number) => {
|
||||
if (Number.isNaN(raw)) return;
|
||||
const clampedCurrent = Math.min(
|
||||
maxForCurrent,
|
||||
Math.max(1, Math.floor(raw)),
|
||||
);
|
||||
|
||||
const newNonLastTotal = otherNonLastSum + clampedCurrent;
|
||||
let lastSpan = 12 - newNonLastTotal;
|
||||
if (!Number.isFinite(lastSpan) || lastSpan < 1) {
|
||||
lastSpan = 1;
|
||||
}
|
||||
|
||||
const nextColumns = columns.map((c, idx) => {
|
||||
if (idx === index) {
|
||||
return { ...c, span: clampedCurrent };
|
||||
}
|
||||
if (idx === lastIndex) {
|
||||
return { ...c, span: lastSpan };
|
||||
}
|
||||
return c;
|
||||
});
|
||||
|
||||
updateBlock(selectedBlockId, { columns: nextColumns } as any);
|
||||
};
|
||||
|
||||
return (
|
||||
<label key={col.id} className="flex flex-col gap-1">
|
||||
<span>{`${index + 1}열 폭 (1~${maxForCurrent})`}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={maxForCurrent}
|
||||
step={1}
|
||||
className="flex-1"
|
||||
aria-label={`${index + 1}열 폭 슬라이더`}
|
||||
value={Number.isFinite(col.span) ? col.span : 12}
|
||||
onChange={(e) => {
|
||||
handleChange(Number(e.target.value));
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={maxForCurrent}
|
||||
className="w-16 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label={`${index + 1}열 폭 (1~${maxForCurrent})`}
|
||||
value={Number.isFinite(col.span) ? col.span : 12}
|
||||
onChange={(e) => {
|
||||
handleChange(Number(e.target.value));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
|
||||
{/* 최대 폭 슬라이더 (px) - 프리셋 + 자유 슬라이더 */}
|
||||
<NumericPropertyControl
|
||||
label="최대 폭"
|
||||
unitLabel="(px)"
|
||||
value={typeof sectionProps.maxWidthPx === "number" ? sectionProps.maxWidthPx : 960}
|
||||
min={640}
|
||||
max={1440}
|
||||
step={16}
|
||||
presets={[
|
||||
{ id: "x-narrow", label: "매우 좁게", value: 640 },
|
||||
{ id: "narrow", label: "좁게", value: 800 },
|
||||
{ id: "normal", label: "보통", value: 960 },
|
||||
{ id: "wide", label: "넓게", value: 1200 },
|
||||
{ id: "x-wide", label: "아주 넓게", value: 1440 },
|
||||
]}
|
||||
onChangeValue={(next) => {
|
||||
const safe = Number.isFinite(next) ? next : 960;
|
||||
updateBlock(selectedBlockId, { maxWidthPx: safe } as any);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 컬럼 간 간격 슬라이더 (px) - 프리셋 + 자유 슬라이더 */}
|
||||
<NumericPropertyControl
|
||||
label="컬럼 간 간격"
|
||||
unitLabel="(px)"
|
||||
value={typeof sectionProps.gapXPx === "number" ? sectionProps.gapXPx : 24}
|
||||
min={0}
|
||||
max={64}
|
||||
step={2}
|
||||
presets={[
|
||||
{ id: "zero", label: "0", value: 0 },
|
||||
{ id: "tight", label: "좁게", value: 16 },
|
||||
{ id: "normal", label: "보통", value: 24 },
|
||||
{ id: "relaxed", label: "넓게", value: 32 },
|
||||
{ id: "x-relaxed", label: "아주 넓게", value: 48 },
|
||||
{ id: "max", label: "최대", value: 64 },
|
||||
]}
|
||||
onChangeValue={(next) => {
|
||||
const safe = Number.isFinite(next) ? next : 24;
|
||||
updateBlock(selectedBlockId, { gapXPx: safe } as any);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 세로 정렬 */}
|
||||
<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={sectionProps.alignItems ?? "top"}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value as NonNullable<SectionBlockProps["alignItems"]>;
|
||||
updateBlock(selectedBlockId, { alignItems: value } as any);
|
||||
}}
|
||||
>
|
||||
<option value="top">위</option>
|
||||
<option value="center">가운데</option>
|
||||
<option value="bottom">아래</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,11 @@ export function createBlogTemplateBlocks(opts: {
|
||||
const sectionProps: SectionBlockProps = {
|
||||
background: "default",
|
||||
paddingY: "lg",
|
||||
// 블로그 리스트는 넓은 폭과 충분한 카드 간격을 사용한다.
|
||||
paddingYPx: 72,
|
||||
maxWidthPx: 1120,
|
||||
gapXPx: 32,
|
||||
backgroundColorCustom: "#020617",
|
||||
columns,
|
||||
} as any;
|
||||
|
||||
|
||||
@@ -15,6 +15,11 @@ export function createCtaTemplateBlocks(opts: {
|
||||
const sectionProps: SectionBlockProps = {
|
||||
background: "primary",
|
||||
paddingY: "md",
|
||||
// CTA 는 강한 색상과 적당한 여백, 비교적 좁은 폭을 사용한다.
|
||||
paddingYPx: 64,
|
||||
maxWidthPx: 960,
|
||||
gapXPx: 24,
|
||||
backgroundColorCustom: "#0c4a6e",
|
||||
columns,
|
||||
} as any;
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ export function createFaqTemplateBlocks(opts: {
|
||||
const sectionProps: SectionBlockProps = {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
// FAQ 는 비교적 좁은 폭과 보통 여백을 사용한다.
|
||||
paddingYPx: 56,
|
||||
maxWidthPx: 720,
|
||||
backgroundColorCustom: "#020617",
|
||||
columns,
|
||||
} as any;
|
||||
|
||||
|
||||
@@ -17,6 +17,11 @@ export function createFeaturesTemplateBlocks(opts: {
|
||||
const sectionProps: SectionBlockProps = {
|
||||
background: "muted",
|
||||
paddingY: "md",
|
||||
// 기능 리스트는 비교적 넓은 폭과 보통 수준의 여백을 사용한다.
|
||||
paddingYPx: 72,
|
||||
maxWidthPx: 1040,
|
||||
gapXPx: 32,
|
||||
backgroundColorCustom: "#0f172a",
|
||||
columns,
|
||||
} as any;
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ export function createFooterTemplateBlocks(opts: {
|
||||
const sectionProps: SectionBlockProps = {
|
||||
background: "muted",
|
||||
paddingY: "md",
|
||||
// 푸터는 상대적으로 낮은 높이와 좁은 최대 폭을 사용한다.
|
||||
paddingYPx: 40,
|
||||
maxWidthPx: 800,
|
||||
backgroundColorCustom: "#020617",
|
||||
columns,
|
||||
} as any;
|
||||
|
||||
|
||||
@@ -16,6 +16,11 @@ export function createHeroTemplateBlocks(opts: {
|
||||
const sectionProps: SectionBlockProps = {
|
||||
background: "default",
|
||||
paddingY: "lg",
|
||||
// 히어로는 가장 여백이 넓고 화면 중앙에 집중되도록 설정한다.
|
||||
paddingYPx: 96,
|
||||
maxWidthPx: 960,
|
||||
gapXPx: 40,
|
||||
backgroundColorCustom: "#020617", // 다크 히어로 섹션
|
||||
columns: [
|
||||
{
|
||||
id: `${sectionId}_col_1`,
|
||||
|
||||
@@ -17,6 +17,11 @@ export function createPricingTemplateBlocks(opts: {
|
||||
const sectionProps: SectionBlockProps = {
|
||||
background: "muted",
|
||||
paddingY: "lg",
|
||||
// 요금제는 꽤 넓은 여백과 넓은 최대 폭, 넉넉한 컬럼 간격을 사용한다.
|
||||
paddingYPx: 88,
|
||||
maxWidthPx: 1120,
|
||||
gapXPx: 40,
|
||||
backgroundColorCustom: "#0f172a",
|
||||
columns,
|
||||
} as any;
|
||||
|
||||
|
||||
@@ -17,6 +17,11 @@ export function createTeamTemplateBlocks(opts: {
|
||||
const sectionProps: SectionBlockProps = {
|
||||
background: "muted",
|
||||
paddingY: "lg",
|
||||
// 팀 섹션은 보라 계열 배경과 넓은 폭, 보통 간격을 사용한다.
|
||||
paddingYPx: 80,
|
||||
maxWidthPx: 1120,
|
||||
gapXPx: 32,
|
||||
backgroundColorCustom: "#1e1b4b", // 보라/인디고 계열
|
||||
columns,
|
||||
} as any;
|
||||
|
||||
|
||||
@@ -17,6 +17,11 @@ export function createTestimonialsTemplateBlocks(opts: {
|
||||
const sectionProps: SectionBlockProps = {
|
||||
background: "default",
|
||||
paddingY: "lg",
|
||||
// 후기 섹션은 중간 폭과 적당한 카드 간격을 사용한다.
|
||||
paddingYPx: 80,
|
||||
maxWidthPx: 1040,
|
||||
gapXPx: 36,
|
||||
backgroundColorCustom: "#020617",
|
||||
columns,
|
||||
} as any;
|
||||
|
||||
|
||||
@@ -31,10 +31,10 @@ export type ColorPickerFieldProps = {
|
||||
|
||||
// 텍스트/버튼 등에서 공통으로 사용하는 기본 색상 팔레트
|
||||
export const TEXT_COLOR_PALETTE: ColorPaletteItem[] = [
|
||||
// 투명
|
||||
{ id: "transparent", label: "투명", color: "transparent" },
|
||||
// 기본/중립 계열
|
||||
{ id: "default", label: "기본", color: "#e5e7eb" },
|
||||
// 투명
|
||||
{ id: "transparent", label: "투명", color: "transparent" },
|
||||
{ id: "muted", label: "연한", color: "#9ca3af" },
|
||||
{ id: "strong", label: "강조", color: "#f9fafb" },
|
||||
{ id: "neutral", label: "중립", color: "#94a3b8" },
|
||||
|
||||
@@ -7,12 +7,58 @@ import type {
|
||||
ButtonBlockProps,
|
||||
ImageBlockProps,
|
||||
SectionBlockProps,
|
||||
DividerBlockProps,
|
||||
ListBlockProps,
|
||||
ListItemNode,
|
||||
FormBlockProps,
|
||||
FormInputBlockProps,
|
||||
FormSelectBlockProps,
|
||||
FormCheckboxBlockProps,
|
||||
FormRadioBlockProps,
|
||||
FormSelectOption,
|
||||
FormRadioOption,
|
||||
FormCheckboxOption,
|
||||
} from "@/features/editor/state/editorStore";
|
||||
|
||||
// 섹션 레이아웃/스타일 토큰을 실제 Tailwind 클래스 조합으로 변환하는 유틸
|
||||
// (에디터/퍼블릭 렌더러에서 동일한 규칙을 재사용하기 위해 export 한다.)
|
||||
export function getSectionLayoutConfig(props: SectionBlockProps) {
|
||||
const backgroundClass =
|
||||
props.background === "muted"
|
||||
? "bg-slate-900"
|
||||
: props.background === "primary"
|
||||
? "bg-sky-900"
|
||||
: "bg-slate-950";
|
||||
|
||||
const paddingYClass =
|
||||
props.paddingY === "sm" ? "py-8" : props.paddingY === "lg" ? "py-20" : "py-12";
|
||||
|
||||
const maxWidthClass =
|
||||
props.maxWidthMode === "narrow"
|
||||
? "max-w-3xl"
|
||||
: props.maxWidthMode === "wide"
|
||||
? "max-w-6xl"
|
||||
: "max-w-5xl";
|
||||
|
||||
const gapXClass =
|
||||
props.gapX === "sm" ? "gap-4" : props.gapX === "lg" ? "gap-10" : "gap-8";
|
||||
|
||||
const alignItemsClass =
|
||||
props.alignItems === "center"
|
||||
? "items-center"
|
||||
: props.alignItems === "bottom"
|
||||
? "items-end"
|
||||
: "items-start";
|
||||
|
||||
return {
|
||||
backgroundClass,
|
||||
paddingYClass,
|
||||
maxWidthClass,
|
||||
gapXClass,
|
||||
alignItemsClass,
|
||||
} as const;
|
||||
}
|
||||
|
||||
// 에디터 크롬 없이 실제 랜딩 페이지처럼 블록들을 렌더링하는 컴포넌트
|
||||
interface PublicPageRendererProps {
|
||||
blocks: Block[];
|
||||
@@ -77,6 +123,151 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (block.type === "formInput") {
|
||||
const props = block.props as FormInputBlockProps;
|
||||
|
||||
// 정렬: 입력 필드의 textAlign 으로 매핑한다.
|
||||
const align = props.align ?? "left";
|
||||
const labelLayout = props.labelLayout ?? "stacked";
|
||||
const widthMode = props.widthMode ?? (props.fullWidth ? "full" : "auto");
|
||||
|
||||
const wrapperLayoutClass =
|
||||
labelLayout === "inline" ? "flex flex-row items-center gap-2" : "flex flex-col gap-1";
|
||||
|
||||
const baseInputClass =
|
||||
"rounded border px-3 py-2 text-xs outline-none focus:border-sky-500";
|
||||
|
||||
const widthClass = widthMode === "full" ? "w-full" : "";
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
textAlign: align,
|
||||
};
|
||||
|
||||
// 너비: fixed 모드일 때 widthPx 를 직접 사용한다.
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
inputStyle.width = `${props.widthPx}px`;
|
||||
}
|
||||
|
||||
// 타이포 관련 커스텀 값
|
||||
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
|
||||
inputStyle.fontSize = props.fontSizeCustom;
|
||||
}
|
||||
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
|
||||
inputStyle.lineHeight = props.lineHeightCustom;
|
||||
}
|
||||
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
|
||||
inputStyle.letterSpacing = props.letterSpacingCustom;
|
||||
}
|
||||
|
||||
// 색상 관련 커스텀 값
|
||||
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
||||
inputStyle.color = props.textColorCustom;
|
||||
} else {
|
||||
inputStyle.color = "#f9fafb";
|
||||
}
|
||||
|
||||
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
|
||||
inputStyle.backgroundColor = props.fillColorCustom;
|
||||
} else {
|
||||
inputStyle.backgroundColor = "#020617"; // 기본 다크 배경과 유사한 색
|
||||
}
|
||||
|
||||
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
|
||||
inputStyle.borderColor = props.strokeColorCustom;
|
||||
} else {
|
||||
inputStyle.borderColor = "#334155";
|
||||
}
|
||||
|
||||
// 모서리 둥글기: borderRadius 토큰을 px 값으로 변환한다.
|
||||
const radiusToken = props.borderRadius ?? "md";
|
||||
const radiusPx =
|
||||
radiusToken === "none" ? 0 : radiusToken === "sm" ? 2 : radiusToken === "lg" ? 6 : radiusToken === "full" ? 9999 : 4;
|
||||
inputStyle.borderRadius = `${radiusPx}px`;
|
||||
|
||||
return (
|
||||
<label key={block.id} className={`${wrapperLayoutClass} text-xs text-slate-200`}>
|
||||
<span>{props.label}</span>
|
||||
{props.inputType === "textarea" ? (
|
||||
<textarea
|
||||
className={`${baseInputClass} ${widthClass}`}
|
||||
style={inputStyle}
|
||||
placeholder={props.placeholder || props.label}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className={`${baseInputClass} ${widthClass}`}
|
||||
style={inputStyle}
|
||||
type={props.inputType === "email" ? "email" : "text"}
|
||||
placeholder={props.placeholder || props.label}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
if (block.type === "formSelect") {
|
||||
const props = block.props as FormSelectBlockProps;
|
||||
|
||||
return (
|
||||
<label key={block.id} className="flex flex-col gap-1 text-xs text-slate-200">
|
||||
<span>{props.label}</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
|
||||
defaultValue={props.options[0]?.value ?? ""}
|
||||
>
|
||||
{props.options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
if (block.type === "formCheckbox") {
|
||||
const props = block.props as FormCheckboxBlockProps;
|
||||
|
||||
return (
|
||||
<div key={block.id} className="flex flex-col gap-1 text-xs text-slate-200">
|
||||
<span>{props.groupLabel}</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
{props.options.map((opt) => (
|
||||
<label key={opt.value} className="inline-flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-slate-700 bg-slate-900 text-sky-500"
|
||||
/>
|
||||
<span>{opt.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (block.type === "formRadio") {
|
||||
const props = block.props as FormRadioBlockProps;
|
||||
|
||||
return (
|
||||
<div key={block.id} className="flex flex-col gap-1 text-xs text-slate-200">
|
||||
<span>{props.groupLabel}</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
{props.options.map((opt) => (
|
||||
<label key={opt.value} className="inline-flex items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
className="h-4 w-4 border-slate-700 bg-slate-900 text-sky-500"
|
||||
name={props.formFieldName}
|
||||
/>
|
||||
<span>{opt.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (block.type === "button") {
|
||||
const props = block.props as ButtonBlockProps;
|
||||
|
||||
@@ -91,6 +282,138 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (block.type === "divider") {
|
||||
const props = block.props as DividerBlockProps;
|
||||
|
||||
const alignClass =
|
||||
props.align === "center" ? "items-center" : props.align === "right" ? "items-end" : "items-start";
|
||||
|
||||
const thicknessClass = props.thickness === "medium" ? "h-[2px]" : "h-px";
|
||||
|
||||
const margin =
|
||||
typeof props.marginYPx === "number"
|
||||
? props.marginYPx
|
||||
: props.marginY === "sm"
|
||||
? 8
|
||||
: props.marginY === "lg"
|
||||
? 24
|
||||
: 16;
|
||||
|
||||
const dividerStyle: CSSProperties = {
|
||||
backgroundColor:
|
||||
props.colorHex && props.colorHex.trim() !== ""
|
||||
? props.colorHex
|
||||
: "#475569", // 기본 slate 계열 색상
|
||||
};
|
||||
|
||||
// 길이/너비: widthMode/widthPx 에 따라 조정한다.
|
||||
const widthMode = props.widthMode ?? "full";
|
||||
const innerStyle: CSSProperties = {};
|
||||
let innerWidthClass = "w-full";
|
||||
|
||||
if (widthMode === "auto") {
|
||||
innerWidthClass = "w-1/2";
|
||||
} else if (widthMode === "fixed") {
|
||||
innerWidthClass = "";
|
||||
if (typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
innerStyle.width = `${props.widthPx}px`;
|
||||
} else {
|
||||
innerStyle.width = "320px";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={block.id}
|
||||
className={`flex ${alignClass}`}
|
||||
style={{ marginTop: margin, marginBottom: margin }}
|
||||
>
|
||||
<div className="max-w-xs" style={innerStyle}>
|
||||
<div className={`${thicknessClass} ${innerWidthClass}`} style={dividerStyle} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (block.type === "list") {
|
||||
const props = block.props as ListBlockProps;
|
||||
|
||||
const alignClass =
|
||||
props.align === "center" ? "text-center" : props.align === "right" ? "text-right" : "text-left";
|
||||
|
||||
const bulletStyleRaw = props.bulletStyle ?? (props.ordered ? "decimal" : "disc");
|
||||
|
||||
const gapPx =
|
||||
typeof props.gapYPx === "number"
|
||||
? props.gapYPx
|
||||
: props.gapY === "sm"
|
||||
? 4
|
||||
: props.gapY === "lg"
|
||||
? 16
|
||||
: 8;
|
||||
|
||||
const listStyle: CSSProperties = {};
|
||||
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
|
||||
listStyle.fontSize = props.fontSizeCustom;
|
||||
}
|
||||
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
|
||||
listStyle.lineHeight = props.lineHeightCustom;
|
||||
}
|
||||
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
||||
listStyle.color = props.textColorCustom;
|
||||
}
|
||||
|
||||
const bulletStyle = bulletStyleRaw;
|
||||
|
||||
const itemsTree: ListItemNode[] =
|
||||
(props as any).itemsTree && (props as any).itemsTree.length > 0
|
||||
? ((props as any).itemsTree as ListItemNode[])
|
||||
: props.items && props.items.length > 0
|
||||
? props.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 = bulletStyle === "decimal"; // 1단계에서는 전체 레벨 동일 스타일 사용
|
||||
const ListTag = isOrderedLevel ? "ol" : "ul";
|
||||
|
||||
return (
|
||||
<ListTag
|
||||
className={alignClass}
|
||||
style={{
|
||||
...listStyle,
|
||||
listStyleType:
|
||||
bulletStyle === "none"
|
||||
? "none"
|
||||
: bulletStyle,
|
||||
paddingLeft: 12 + level * 8,
|
||||
}}
|
||||
>
|
||||
{nodes.map((node, index) => (
|
||||
<li
|
||||
key={node.id}
|
||||
style={index < nodes.length - 1 ? { marginBottom: gapPx } : undefined}
|
||||
>
|
||||
{node.text}
|
||||
{node.children && node.children.length > 0 && renderListNodes(node.children, level + 1)}
|
||||
</li>
|
||||
))}
|
||||
</ListTag>
|
||||
);
|
||||
};
|
||||
|
||||
return renderListNodes(itemsTree, 0);
|
||||
}
|
||||
|
||||
if (block.type === "form") {
|
||||
const props = block.props as FormBlockProps;
|
||||
|
||||
@@ -364,17 +687,30 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
|
||||
const renderSection = (section: Block) => {
|
||||
const props = section.props as SectionBlockProps;
|
||||
|
||||
const bgClass =
|
||||
props.background === "muted" ? "bg-slate-900" : props.background === "primary" ? "bg-sky-900" : "bg-slate-950";
|
||||
|
||||
const pyClass = props.paddingY === "sm" ? "py-8" : props.paddingY === "lg" ? "py-20" : "py-12";
|
||||
const { backgroundClass, paddingYClass, maxWidthClass, gapXClass, alignItemsClass } =
|
||||
getSectionLayoutConfig(props);
|
||||
|
||||
const columns = props.columns && props.columns.length > 0 ? props.columns : [{ id: `${section.id}_col`, span: 12 }];
|
||||
|
||||
const sectionStyle: CSSProperties = {};
|
||||
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
|
||||
sectionStyle.backgroundColor = props.backgroundColorCustom;
|
||||
}
|
||||
if (typeof props.paddingYPx === "number" && props.paddingYPx > 0) {
|
||||
sectionStyle.paddingTop = `${props.paddingYPx}px`;
|
||||
sectionStyle.paddingBottom = `${props.paddingYPx}px`;
|
||||
}
|
||||
|
||||
return (
|
||||
<section key={section.id} className={`${bgClass} ${pyClass}`}>
|
||||
<div className="mx-auto max-w-5xl px-4">
|
||||
<div className="flex gap-8">
|
||||
<section key={section.id} className={`${backgroundClass} ${paddingYClass}`} style={sectionStyle}>
|
||||
<div
|
||||
className={`mx-auto ${maxWidthClass} px-4`}
|
||||
style={{ maxWidth: typeof props.maxWidthPx === "number" && props.maxWidthPx > 0 ? `${props.maxWidthPx}px` : undefined }}
|
||||
>
|
||||
<div
|
||||
className={`flex ${gapXClass} ${alignItemsClass}`}
|
||||
style={{ columnGap: typeof props.gapXPx === "number" && props.gapXPx > 0 ? `${props.gapXPx}px` : undefined }}
|
||||
>
|
||||
{columns.map((col) => {
|
||||
const basis = `${(col.span / 12) * 100}%`;
|
||||
const columnBlocks = blocks.filter((b) => b.sectionId === section.id && b.columnId === col.id);
|
||||
|
||||
@@ -114,30 +114,369 @@ export interface ButtonBlockProps {
|
||||
export interface ImageBlockProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
// 이미지 정렬: 기본은 center
|
||||
align?: "left" | "center" | "right";
|
||||
// 너비 모드: auto(콘텐츠에 맞춤) 또는 fixed(px 지정)
|
||||
widthMode?: "auto" | "fixed";
|
||||
widthPx?: number;
|
||||
// 모서리 둥글기
|
||||
borderRadius?: "none" | "sm" | "md" | "lg" | "full";
|
||||
}
|
||||
|
||||
// 구분선 블록 속성
|
||||
export interface DividerBlockProps {
|
||||
align: "left" | "center" | "right";
|
||||
thickness: "thin" | "medium";
|
||||
// 길이/너비
|
||||
widthMode?: "auto" | "full" | "fixed";
|
||||
widthPx?: number;
|
||||
// 색상
|
||||
colorHex?: string;
|
||||
// 상하 여백 (토큰, 호환용)
|
||||
marginY?: "sm" | "md" | "lg";
|
||||
// 상하 여백(px) 숫자 값
|
||||
marginYPx?: number;
|
||||
}
|
||||
|
||||
// 리스트 아이템 트리(중첩 리스트) 노드 정의
|
||||
export interface ListItemNode {
|
||||
id: string;
|
||||
text: string;
|
||||
children?: ListItemNode[];
|
||||
}
|
||||
|
||||
export interface ListLine {
|
||||
depth: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
// 리스트 아이템 트리 조작 유틸 (pure helper)
|
||||
|
||||
type ListTreeOpResult = {
|
||||
tree: ListItemNode[];
|
||||
changed: boolean;
|
||||
};
|
||||
|
||||
const cloneNode = (node: ListItemNode): ListItemNode => ({
|
||||
id: node.id,
|
||||
text: node.text,
|
||||
children: node.children ? node.children.map(cloneNode) : [],
|
||||
});
|
||||
|
||||
export const buildItemsTreeFromItems = (blockId: string, items: string[]): ListItemNode[] =>
|
||||
items.map((text, index) => ({
|
||||
id: `${blockId}_item_${index + 1}`,
|
||||
text,
|
||||
children: [],
|
||||
}));
|
||||
|
||||
export const flattenItemsTreeToItems = (tree: ListItemNode[]): string[] => {
|
||||
const result: string[] = [];
|
||||
|
||||
const walk = (nodes: ListItemNode[]) => {
|
||||
for (const node of nodes) {
|
||||
result.push(node.text);
|
||||
if (node.children && node.children.length > 0) {
|
||||
walk(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(tree);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const linesToItemsTree = (blockId: string, lines: ListLine[]): ListItemNode[] => {
|
||||
const roots: ListItemNode[] = [];
|
||||
const stack: ListItemNode[] = [];
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const depth = Number.isFinite(line.depth) && line.depth > 0 ? Math.floor(line.depth) : 0;
|
||||
|
||||
const node: ListItemNode = {
|
||||
id: `${blockId}_item_${index + 1}`,
|
||||
text: line.text,
|
||||
children: [],
|
||||
};
|
||||
|
||||
if (depth === 0 || stack.length === 0) {
|
||||
roots.push(node);
|
||||
stack.length = 1;
|
||||
stack[0] = node;
|
||||
return;
|
||||
}
|
||||
|
||||
// depth 가 현재 스택 깊이보다 크면, 스택 마지막을 부모로 사용한다.
|
||||
const parentDepth = Math.min(depth - 1, stack.length - 1);
|
||||
const parent = stack[parentDepth];
|
||||
|
||||
if (!parent.children) {
|
||||
parent.children = [];
|
||||
}
|
||||
parent.children.push(node);
|
||||
|
||||
// 현재 노드를 해당 depth 위치에 두고, 그 이후 스택은 잘라낸다.
|
||||
stack.length = parentDepth + 2;
|
||||
stack[parentDepth + 1] = node;
|
||||
});
|
||||
|
||||
return roots;
|
||||
};
|
||||
|
||||
export const itemsTreeToLines = (tree: ListItemNode[]): ListLine[] => {
|
||||
const lines: ListLine[] = [];
|
||||
|
||||
const walk = (nodes: ListItemNode[], depth: number) => {
|
||||
for (const node of nodes) {
|
||||
lines.push({ depth, text: node.text });
|
||||
if (node.children && node.children.length > 0) {
|
||||
walk(node.children, depth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(tree, 0);
|
||||
return lines;
|
||||
};
|
||||
|
||||
export const parseTextareaToLines = (text: string): ListLine[] => {
|
||||
const rawLines = text.split(/\r?\n/);
|
||||
|
||||
return rawLines.map((raw) => {
|
||||
let depth = 0;
|
||||
let i = 0;
|
||||
|
||||
while (i < raw.length) {
|
||||
const ch = raw[i];
|
||||
if (ch === "\t") {
|
||||
depth += 1;
|
||||
i += 1;
|
||||
} else if (ch === " ") {
|
||||
let spaceCount = 0;
|
||||
while (i < raw.length && raw[i] === " ") {
|
||||
spaceCount += 1;
|
||||
i += 1;
|
||||
}
|
||||
depth += Math.floor(spaceCount / 2);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const textWithoutIndent = raw.slice(i);
|
||||
return {
|
||||
depth,
|
||||
text: textWithoutIndent,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const stringifyLinesToTextarea = (lines: ListLine[]): string => {
|
||||
return lines
|
||||
.map((line) => {
|
||||
const indent = " ".repeat(Math.max(0, Math.floor(line.depth)));
|
||||
return `${indent}${line.text}`;
|
||||
})
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
// 내부 재귀 유틸: siblings 배열과 targetId 를 받아 조작을 수행한다.
|
||||
const withSiblings = (
|
||||
siblings: ListItemNode[],
|
||||
targetId: string,
|
||||
op: (args: {
|
||||
siblings: ListItemNode[];
|
||||
index: number;
|
||||
parentPath: ListItemNode[];
|
||||
}) => ListTreeOpResult,
|
||||
parentPath: ListItemNode[] = [],
|
||||
): ListTreeOpResult => {
|
||||
for (let i = 0; i < siblings.length; i += 1) {
|
||||
const node = siblings[i];
|
||||
if (node.id === targetId) {
|
||||
return op({ siblings, index: i, parentPath });
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
const childResult = withSiblings(node.children, targetId, op, [...parentPath, node]);
|
||||
if (childResult.changed) {
|
||||
const cloned = siblings.map(cloneNode);
|
||||
const idx = cloned.findIndex((n) => n.id === node.id);
|
||||
if (idx >= 0) {
|
||||
cloned[idx].children = childResult.tree;
|
||||
}
|
||||
return { tree: cloned, changed: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { tree: siblings, changed: false };
|
||||
};
|
||||
|
||||
export const indentListItem = (tree: ListItemNode[], targetId: string): ListItemNode[] => {
|
||||
const { tree: next } = withSiblings(
|
||||
tree,
|
||||
targetId,
|
||||
({ siblings, index }) => {
|
||||
// 첫 번째 아이템은 들여쓰기 불가
|
||||
if (index === 0) {
|
||||
return { tree: siblings, changed: false };
|
||||
}
|
||||
|
||||
const prev = siblings[index - 1];
|
||||
const target = siblings[index];
|
||||
|
||||
const newPrev: ListItemNode = cloneNode(prev);
|
||||
const newTarget: ListItemNode = cloneNode(target);
|
||||
const newSiblings = siblings.map(cloneNode);
|
||||
|
||||
// siblings 에서 target 제거
|
||||
newSiblings.splice(index, 1);
|
||||
|
||||
// prev 위치의 노드를 newPrev 로 교체
|
||||
const prevIndex = index - 1;
|
||||
const prevNode = newSiblings[prevIndex];
|
||||
const prevChildren = prevNode.children ? [...prevNode.children] : [];
|
||||
prevChildren.push(newTarget);
|
||||
newSiblings[prevIndex] = { ...newPrev, children: prevChildren };
|
||||
|
||||
return { tree: newSiblings, changed: true };
|
||||
},
|
||||
);
|
||||
return next;
|
||||
};
|
||||
|
||||
export const outdentListItem = (tree: ListItemNode[], targetId: string): ListItemNode[] => {
|
||||
// 부모의 children 에서 targetId 를 찾아 제거하고,
|
||||
// 부모가 속한 siblings 배열에서 부모 바로 다음 위치에 target 을 삽입한다.
|
||||
|
||||
const liftFromChildren = (nodes: ListItemNode[]): ListTreeOpResult => {
|
||||
const clonedSiblings = nodes.map(cloneNode);
|
||||
|
||||
for (let i = 0; i < clonedSiblings.length; i += 1) {
|
||||
const node = clonedSiblings[i];
|
||||
const children = node.children ?? [];
|
||||
|
||||
// 1단계: 현재 node 의 children 에 target 이 있는지 확인한다.
|
||||
const childIndex = children.findIndex((c) => c.id === targetId);
|
||||
if (childIndex >= 0) {
|
||||
const child = children[childIndex];
|
||||
const newChildren = [...children];
|
||||
newChildren.splice(childIndex, 1);
|
||||
|
||||
// 부모의 children 에서 target 제거
|
||||
clonedSiblings[i] = {
|
||||
...node,
|
||||
children: newChildren,
|
||||
};
|
||||
|
||||
// 부모 바로 뒤에 target 을 siblings 레벨로 삽입
|
||||
clonedSiblings.splice(i + 1, 0, cloneNode(child));
|
||||
|
||||
return { tree: clonedSiblings, changed: true };
|
||||
}
|
||||
|
||||
// 2단계: 더 깊은 레벨에서 검색한다.
|
||||
if (children.length > 0) {
|
||||
const childResult = liftFromChildren(children);
|
||||
if (childResult.changed) {
|
||||
clonedSiblings[i] = {
|
||||
...node,
|
||||
children: childResult.tree,
|
||||
};
|
||||
return { tree: clonedSiblings, changed: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { tree: nodes, changed: false };
|
||||
};
|
||||
|
||||
const { tree: next } = liftFromChildren(tree);
|
||||
return next;
|
||||
};
|
||||
|
||||
export const moveListItemUp = (tree: ListItemNode[], targetId: string): ListItemNode[] => {
|
||||
const { tree: next } = withSiblings(
|
||||
tree,
|
||||
targetId,
|
||||
({ siblings, index }) => {
|
||||
if (index === 0) {
|
||||
return { tree: siblings, changed: false };
|
||||
}
|
||||
const cloned = siblings.map(cloneNode);
|
||||
const tmp = cloned[index - 1];
|
||||
cloned[index - 1] = cloned[index];
|
||||
cloned[index] = tmp;
|
||||
return { tree: cloned, changed: true };
|
||||
},
|
||||
);
|
||||
return next;
|
||||
};
|
||||
|
||||
export const moveListItemDown = (tree: ListItemNode[], targetId: string): ListItemNode[] => {
|
||||
const { tree: next } = withSiblings(
|
||||
tree,
|
||||
targetId,
|
||||
({ siblings, index }) => {
|
||||
if (index >= siblings.length - 1) {
|
||||
return { tree: siblings, changed: false };
|
||||
}
|
||||
const cloned = siblings.map(cloneNode);
|
||||
const tmp = cloned[index + 1];
|
||||
cloned[index + 1] = cloned[index];
|
||||
cloned[index] = tmp;
|
||||
return { tree: cloned, changed: true };
|
||||
},
|
||||
);
|
||||
return next;
|
||||
};
|
||||
|
||||
// 리스트 블록 속성
|
||||
export interface ListBlockProps {
|
||||
items: string[];
|
||||
items: string[]; // 1단계 하위호환용 단순 배열
|
||||
itemsTree?: ListItemNode[]; // 중첩 리스트용 트리 구조
|
||||
ordered: boolean;
|
||||
align: "left" | "center" | "right";
|
||||
// 타이포/색상
|
||||
fontSizeCustom?: string;
|
||||
lineHeightCustom?: string;
|
||||
textColorCustom?: string;
|
||||
// 불릿/간격
|
||||
bulletStyle?:
|
||||
| "disc"
|
||||
| "circle"
|
||||
| "square"
|
||||
| "decimal"
|
||||
| "none"
|
||||
| "lower-alpha"
|
||||
| "upper-alpha"
|
||||
| "lower-roman"
|
||||
| "upper-roman";
|
||||
// 토큰 기반 간격(호환용)
|
||||
gapY?: "sm" | "md" | "lg";
|
||||
// 아이템 간 여백(px) 숫자 값
|
||||
gapYPx?: number;
|
||||
}
|
||||
|
||||
// 섹션 블록 속성
|
||||
export interface SectionBlockProps {
|
||||
background: "default" | "muted" | "primary";
|
||||
paddingY: "sm" | "md" | "lg";
|
||||
// px 단위 세로 패딩(있으면 토큰 대신 우선 적용)
|
||||
paddingYPx?: number;
|
||||
// 레이아웃 컬럼 정의 (12 그리드 기준 span)
|
||||
columns: Array<{
|
||||
id: string;
|
||||
span: number;
|
||||
}>;
|
||||
// 섹션 레이아웃 스타일
|
||||
maxWidthMode?: "narrow" | "normal" | "wide";
|
||||
// px 단위 최대 폭(있으면 토큰 기반 maxWidth 대신 우선 적용)
|
||||
maxWidthPx?: number;
|
||||
gapX?: "sm" | "md" | "lg";
|
||||
// px 단위 컬럼 간 간격(있으면 토큰 기반 gap 대신 우선 적용)
|
||||
gapXPx?: number;
|
||||
alignItems?: "top" | "center" | "bottom";
|
||||
backgroundColorCustom?: string;
|
||||
}
|
||||
|
||||
// 폼 입력/셀렉트/체크박스/라디오 공통 스타일 속성
|
||||
@@ -285,6 +624,7 @@ export interface Block {
|
||||
export interface EditorState {
|
||||
blocks: Block[];
|
||||
selectedBlockId: string | null;
|
||||
selectedListItemId?: string | null;
|
||||
history: Block[][];
|
||||
future: Block[][];
|
||||
addTextBlock: () => void;
|
||||
@@ -307,6 +647,11 @@ export interface EditorState {
|
||||
addBlogTemplateSection: () => void;
|
||||
addTeamTemplateSection: () => void;
|
||||
addFooterTemplateSection: () => void;
|
||||
selectListItem: (itemId: string | null) => void;
|
||||
indentSelectedListItem: (blockId: string) => void;
|
||||
outdentSelectedListItem: (blockId: string) => void;
|
||||
moveSelectedListItemUp: (blockId: string) => void;
|
||||
moveSelectedListItemDown: (blockId: string) => void;
|
||||
updateBlock: (
|
||||
id: string,
|
||||
partial:
|
||||
@@ -354,52 +699,53 @@ const getNextFormFieldName = (blocks: Block[], prefix: string): string => {
|
||||
// 에디터 스토어 생성 함수 (테스트 및 앱에서 공유 사용)
|
||||
// set/get 은 zustand 내부 구현에 의해 주입되므로, 여기서는 any 로 완화해 사용한다.
|
||||
const createEditorState = (set: any, get: any): EditorState => ({
|
||||
blocks: [],
|
||||
selectedBlockId: null,
|
||||
history: [],
|
||||
future: [],
|
||||
blocks: [],
|
||||
selectedBlockId: null,
|
||||
selectedListItemId: null,
|
||||
history: [],
|
||||
future: [],
|
||||
|
||||
// 텍스트 블록 추가: 기본 텍스트와 함께 생성 후 선택 상태로 만든다
|
||||
addTextBlock: () => {
|
||||
const id = createId();
|
||||
// 텍스트 블록 추가: 기본 텍스트와 함께 생성 후 선택 상태로 만든다
|
||||
addTextBlock: () => {
|
||||
const id = createId();
|
||||
|
||||
const { selectedBlockId, blocks } = get();
|
||||
let sectionId: string | null = null;
|
||||
let columnId: string | null = null;
|
||||
const { selectedBlockId, blocks } = get();
|
||||
let sectionId: string | null = null;
|
||||
let columnId: string | null = null;
|
||||
|
||||
if (selectedBlockId) {
|
||||
const target = blocks.find((b: Block) => b.id === selectedBlockId);
|
||||
if (target) {
|
||||
if (target.type === "section") {
|
||||
const sProps = target.props as SectionBlockProps;
|
||||
sectionId = target.id;
|
||||
columnId = sProps.columns?.[0]?.id ?? null;
|
||||
} else {
|
||||
sectionId = (target as any).sectionId ?? null;
|
||||
columnId = (target as any).columnId ?? null;
|
||||
}
|
||||
if (selectedBlockId) {
|
||||
const target = blocks.find((b: Block) => b.id === selectedBlockId);
|
||||
if (target) {
|
||||
if (target.type === "section") {
|
||||
const sProps = target.props as SectionBlockProps;
|
||||
sectionId = target.id;
|
||||
columnId = sProps.columns?.[0]?.id ?? null;
|
||||
} else {
|
||||
sectionId = (target as any).sectionId ?? null;
|
||||
columnId = (target as any).columnId ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newBlock: Block = {
|
||||
id,
|
||||
type: "text",
|
||||
props: {
|
||||
text: "새 텍스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
sectionId,
|
||||
columnId,
|
||||
};
|
||||
const newBlock: Block = {
|
||||
id,
|
||||
type: "text",
|
||||
props: {
|
||||
text: "새 텍스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
sectionId,
|
||||
columnId,
|
||||
};
|
||||
|
||||
set((state: EditorState) => ({
|
||||
blocks: [...state.blocks, newBlock],
|
||||
selectedBlockId: id,
|
||||
history: [...state.history, blocks],
|
||||
future: [],
|
||||
}));
|
||||
},
|
||||
set((state: EditorState) => ({
|
||||
blocks: [...state.blocks, newBlock],
|
||||
selectedBlockId: id,
|
||||
history: [...state.history, blocks],
|
||||
future: [],
|
||||
}));
|
||||
},
|
||||
|
||||
// Hero 템플릿 섹션 추가: 섹션 1개와 기본 텍스트/버튼 블록들을 첫 컬럼에 배치한다
|
||||
addHeroTemplateSection: () => {
|
||||
@@ -419,6 +765,11 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
||||
});
|
||||
},
|
||||
|
||||
// 리스트 아이템 선택 상태를 업데이트한다.
|
||||
selectListItem: (itemId) => {
|
||||
set({ selectedListItemId: itemId ?? null });
|
||||
},
|
||||
|
||||
// Features 템플릿 섹션 추가: 3컬럼(4/4/4) 섹션과 각 컬럼의 제목/설명 텍스트를 생성한다
|
||||
addFeaturesTemplateSection: () => {
|
||||
const sectionId = createId();
|
||||
@@ -566,7 +917,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
||||
});
|
||||
},
|
||||
|
||||
// 이미지 블록 추가: 기본 플레이스홀더와 함께 생성 후 선택 상태로 만든다
|
||||
// 이미지 블록 추가: 기본 플레이스홀더와 스타일 기본값과 함께 생성 후 선택 상태로 만든다
|
||||
addImageBlock: () => {
|
||||
const id = createId();
|
||||
|
||||
@@ -594,6 +945,9 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
||||
props: {
|
||||
src: "",
|
||||
alt: "이미지 설명",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
borderRadius: "md",
|
||||
},
|
||||
sectionId,
|
||||
columnId,
|
||||
@@ -820,6 +1174,13 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
||||
type: "list",
|
||||
props: {
|
||||
items: ["리스트 아이템 1"],
|
||||
itemsTree: [
|
||||
{
|
||||
id: `${id}_item_1`,
|
||||
text: "리스트 아이템 1",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
ordered: false,
|
||||
align: "left",
|
||||
},
|
||||
@@ -970,6 +1331,130 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
||||
}));
|
||||
},
|
||||
|
||||
// 선택된 리스트 아이템을 들여쓰기한다.
|
||||
indentSelectedListItem: (blockId) => {
|
||||
const { blocks, selectedListItemId } = get();
|
||||
if (!selectedListItemId) return;
|
||||
|
||||
const target = blocks.find((b: Block) => b.id === blockId && b.type === "list");
|
||||
if (!target) return;
|
||||
|
||||
const listProps = target.props as ListBlockProps & { itemsTree?: ListItemNode[] };
|
||||
const currentTree: ListItemNode[] =
|
||||
(listProps.itemsTree as ListItemNode[] | undefined) && listProps.itemsTree!.length > 0
|
||||
? (listProps.itemsTree as ListItemNode[])
|
||||
: [];
|
||||
|
||||
const nextTree = indentListItem(currentTree, selectedListItemId);
|
||||
|
||||
set((state: EditorState) => ({
|
||||
blocks: state.blocks.map((block: Block) =>
|
||||
block.id === blockId
|
||||
? {
|
||||
...block,
|
||||
props: {
|
||||
...(block.props as any),
|
||||
itemsTree: nextTree,
|
||||
},
|
||||
}
|
||||
: block,
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
// 선택된 리스트 아이템을 부모의 다음 형제로 내어쓰기한다.
|
||||
outdentSelectedListItem: (blockId) => {
|
||||
const { blocks, selectedListItemId } = get();
|
||||
if (!selectedListItemId) return;
|
||||
|
||||
const target = blocks.find((b: Block) => b.id === blockId && b.type === "list");
|
||||
if (!target) return;
|
||||
|
||||
const listProps = target.props as ListBlockProps & { itemsTree?: ListItemNode[] };
|
||||
const currentTree: ListItemNode[] =
|
||||
(listProps.itemsTree as ListItemNode[] | undefined) && listProps.itemsTree!.length > 0
|
||||
? (listProps.itemsTree as ListItemNode[])
|
||||
: [];
|
||||
|
||||
const nextTree = outdentListItem(currentTree, selectedListItemId);
|
||||
|
||||
set((state: EditorState) => ({
|
||||
blocks: state.blocks.map((block: Block) =>
|
||||
block.id === blockId
|
||||
? {
|
||||
...block,
|
||||
props: {
|
||||
...(block.props as any),
|
||||
itemsTree: nextTree,
|
||||
},
|
||||
}
|
||||
: block,
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
// 선택된 리스트 아이템을 같은 레벨에서 한 칸 위로 이동시킨다.
|
||||
moveSelectedListItemUp: (blockId) => {
|
||||
const { blocks, selectedListItemId } = get();
|
||||
if (!selectedListItemId) return;
|
||||
|
||||
const target = blocks.find((b: Block) => b.id === blockId && b.type === "list");
|
||||
if (!target) return;
|
||||
|
||||
const listProps = target.props as ListBlockProps & { itemsTree?: ListItemNode[] };
|
||||
const currentTree: ListItemNode[] =
|
||||
(listProps.itemsTree as ListItemNode[] | undefined) && listProps.itemsTree!.length > 0
|
||||
? (listProps.itemsTree as ListItemNode[])
|
||||
: [];
|
||||
|
||||
const nextTree = moveListItemUp(currentTree, selectedListItemId);
|
||||
|
||||
set((state: EditorState) => ({
|
||||
blocks: state.blocks.map((block: Block) =>
|
||||
block.id === blockId
|
||||
? {
|
||||
...block,
|
||||
props: {
|
||||
...(block.props as any),
|
||||
itemsTree: nextTree,
|
||||
},
|
||||
}
|
||||
: block,
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
// 선택된 리스트 아이템을 같은 레벨에서 한 칸 아래로 이동시킨다.
|
||||
moveSelectedListItemDown: (blockId) => {
|
||||
const { blocks, selectedListItemId } = get();
|
||||
if (!selectedListItemId) return;
|
||||
|
||||
const target = blocks.find((b: Block) => b.id === blockId && b.type === "list");
|
||||
if (!target) return;
|
||||
|
||||
const listProps = target.props as ListBlockProps & { itemsTree?: ListItemNode[] };
|
||||
const currentTree: ListItemNode[] =
|
||||
(listProps.itemsTree as ListItemNode[] | undefined) && listProps.itemsTree!.length > 0
|
||||
? (listProps.itemsTree as ListItemNode[])
|
||||
: [];
|
||||
|
||||
const nextTree = moveListItemDown(currentTree, selectedListItemId);
|
||||
|
||||
set((state: EditorState) => ({
|
||||
blocks: state.blocks.map((block: Block) =>
|
||||
block.id === blockId
|
||||
? {
|
||||
...block,
|
||||
props: {
|
||||
...(block.props as any),
|
||||
itemsTree: nextTree,
|
||||
},
|
||||
}
|
||||
: block,
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
// 선택된 블록 ID를 변경
|
||||
selectBlock: (id) => {
|
||||
const { blocks } = get();
|
||||
|
||||
Reference in New Issue
Block a user