diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index bf153dd..618a488 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -119,7 +119,9 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): ? weightMap[fontWeightScale] : ""; - let colorClass = ""; + let colorClass = "pb-text-color-strong"; + const inlineStyles: string[] = []; + if (props.colorMode === "palette") { const palette = props.colorPalette ?? "default"; const paletteMap: Record, string> = { @@ -133,7 +135,18 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): info: "pb-text-color-info", neutral: "pb-text-color-neutral", }; - colorClass = paletteMap[palette] ?? ""; + colorClass = paletteMap[palette] ?? colorClass; + } else if ( + props.colorMode === "custom" && + typeof props.colorCustom === "string" && + props.colorCustom.trim() !== "" + ) { + inlineStyles.push(`color:${props.colorCustom.trim()}`); + } + + // 블록 배경색: backgroundColorCustom 이 설정된 경우에만 적용한다. + if (typeof (props as any).backgroundColorCustom === "string" && (props as any).backgroundColorCustom.trim() !== "") { + inlineStyles.push(`background-color:${(props as any).backgroundColorCustom.trim()}`); } let maxWidthClass = ""; @@ -159,12 +172,15 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): weightClass, colorClass, maxWidthClass, + "pb-whitespace-pre-wrap", ...decoClasses, ] .filter(Boolean) .join(" "); - return `

${escapeHtml(text)}

`; + const styleAttr = inlineStyles.length > 0 ? ` style="${inlineStyles.join(";")}"` : ""; + + return `

${escapeHtml(text)}

`; } if (block.type === "button") { @@ -238,7 +254,42 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): const props: any = block.props ?? {}; const src = typeof props.src === "string" ? props.src : ""; const alt = typeof props.alt === "string" ? props.alt : ""; - return `
${escapeAttr(alt)}
`; + + const wrapperStyleParts: string[] = []; + const imgStyleParts: string[] = []; + + if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") { + wrapperStyleParts.push(`background-color:${escapeAttr(props.backgroundColorCustom.trim())}`); + } + + const widthMode = props.widthMode ?? "auto"; + if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) { + imgStyleParts.push(`width:${props.widthPx}px`); + } + + const radiusToken = props.borderRadius ?? "md"; + const fallbackRadiusPx = + radiusToken === "none" + ? 0 + : radiusToken === "sm" + ? 4 + : radiusToken === "lg" + ? 16 + : radiusToken === "full" + ? 9999 + : 8; + const radiusPx = + typeof props.borderRadiusPx === "number" && props.borderRadiusPx >= 0 + ? props.borderRadiusPx + : fallbackRadiusPx; + if (typeof radiusPx === "number" && radiusPx >= 0) { + imgStyleParts.push(`border-radius:${radiusPx === 9999 ? "9999px" : `${radiusPx}px`}`); + } + + const wrapperStyleAttr = wrapperStyleParts.length > 0 ? ` style="${wrapperStyleParts.join(";")}"` : ""; + const imgStyleAttr = imgStyleParts.length > 0 ? ` style="${imgStyleParts.join(";")}"` : ""; + + return `${escapeAttr(alt)}`; } if (block.type === "form") { @@ -261,7 +312,13 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): const fields = controllerFields.length > 0 ? controllerFields : null; const formParts: string[] = []; - formParts.push(`
`); + const formStyleParts: string[] = []; + if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") { + formStyleParts.push(`background-color:${props.backgroundColorCustom.trim()}`); + } + const formStyleAttr = formStyleParts.length > 0 ? ` style="${formStyleParts.join(";")}"` : ""; + + formParts.push(``); if (fields) { for (const fieldBlock of fields) { @@ -272,8 +329,15 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): ? (anyProps.label ?? anyProps.groupLabel) : name; + const textColorRaw = + typeof anyProps.textColorCustom === "string" && anyProps.textColorCustom.trim() !== "" + ? anyProps.textColorCustom.trim() + : ""; + const labelStyleAttr = textColorRaw ? ` style="color:${escapeAttr(textColorRaw)}"` : ""; + const fieldTextStyleAttr = textColorRaw ? ` style="color:${escapeAttr(textColorRaw)}"` : ""; + formParts.push(`
`); - formParts.push(``); + formParts.push(``); if (fieldBlock.type === "formInput") { const type = anyProps.inputType === "email" ? "email" : "text"; @@ -281,12 +345,12 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): formParts.push( ``, + )}"${required}${fieldTextStyleAttr} />`, ); } else if (fieldBlock.type === "formSelect") { const required = anyProps.required ? " required" : ""; const options: FormSelectOption[] = Array.isArray(anyProps.options) ? anyProps.options : []; - formParts.push(``); for (const opt of options) { formParts.push( ``, @@ -297,7 +361,7 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): const options = Array.isArray(anyProps.options) ? anyProps.options : []; for (const opt of options) { formParts.push( - ``, ); @@ -306,7 +370,7 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): const options = Array.isArray(anyProps.options) ? anyProps.options : []; for (const opt of options) { formParts.push( - ``, ); @@ -386,7 +450,14 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): const align = props.align === "center" ? "center" : props.align === "right" ? "right" : "left"; const lis = items.map((text) => `
  • ${escapeHtml(text)}
  • `).join(""); - return `<${Tag} class="pb-list" style="text-align:${align};">${lis}`; + + const listStyleParts: string[] = [`text-align:${align}`]; + if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") { + listStyleParts.push(`background-color:${props.backgroundColorCustom.trim()}`); + } + const listStyleAttr = listStyleParts.join(";"); + + return `<${Tag} class="pb-list" style="${listStyleAttr}">${lis}`; } if (block.type === "formInput") { @@ -397,12 +468,69 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): const type = props.inputType === "email" ? "email" : "text"; const required = props.required ? " required" : ""; + const textColorRaw = + typeof props.textColorCustom === "string" && props.textColorCustom.trim() !== "" + ? props.textColorCustom.trim() + : ""; + const labelStyleAttr = textColorRaw ? ` style=\"color:${escapeAttr(textColorRaw)}\"` : ""; + + const inputStyleParts: string[] = []; + if (textColorRaw) { + inputStyleParts.push(`color:${escapeAttr(textColorRaw)}`); + } + + if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") { + inputStyleParts.push(`background-color:${escapeAttr(props.fillColorCustom.trim())}`); + } + + if (typeof props.strokeColorCustom === "string" && props.strokeColorCustom.trim() !== "") { + inputStyleParts.push(`border-color:${escapeAttr(props.strokeColorCustom.trim())}`); + } + + if (typeof props.paddingX === "number" && props.paddingX >= 0) { + const px = props.paddingX; + inputStyleParts.push(`padding-left:${px}px`); + inputStyleParts.push(`padding-right:${px}px`); + } + + if (typeof props.paddingY === "number" && props.paddingY >= 0) { + const py = props.paddingY; + inputStyleParts.push(`padding-top:${py}px`); + inputStyleParts.push(`padding-bottom:${py}px`); + } + + const widthMode = + typeof props.widthMode === "string" + ? props.widthMode + : props.fullWidth + ? "full" + : "auto"; + if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) { + inputStyleParts.push(`width:${props.widthPx}px`); + } + + const radiusToken = typeof props.borderRadius === "string" ? props.borderRadius : "md"; + const radiusPx = + radiusToken === "none" + ? 0 + : radiusToken === "sm" + ? 2 + : radiusToken === "lg" + ? 6 + : radiusToken === "full" + ? 9999 + : 4; + inputStyleParts.push(`border-radius:${radiusPx}px`); + + const inputStyleAttr = + inputStyleParts.length > 0 ? ` style=\"${inputStyleParts.join(";")}\"` : ""; + return [ '
    ', - ``, + ``, ``, + )}"${required}${inputStyleAttr} />`, "
    ", ].join(""); } @@ -415,10 +543,67 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): const required = props.required ? " required" : ""; const options: FormSelectOption[] = Array.isArray(props.options) ? props.options : []; + const textColorRaw = + typeof props.textColorCustom === "string" && props.textColorCustom.trim() !== "" + ? props.textColorCustom.trim() + : ""; + const labelStyleAttr = textColorRaw ? ` style=\"color:${escapeAttr(textColorRaw)}\"` : ""; + + const selectStyleParts: string[] = []; + if (textColorRaw) { + selectStyleParts.push(`color:${escapeAttr(textColorRaw)}`); + } + + const widthMode = + typeof props.widthMode === "string" + ? props.widthMode + : props.fullWidth + ? "full" + : "auto"; + if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) { + selectStyleParts.push(`width:${props.widthPx}px`); + } + + if (typeof props.paddingX === "number" && props.paddingX >= 0) { + const px = props.paddingX; + selectStyleParts.push(`padding-left:${px}px`); + selectStyleParts.push(`padding-right:${px}px`); + } + + if (typeof props.paddingY === "number" && props.paddingY >= 0) { + const py = props.paddingY; + selectStyleParts.push(`padding-top:${py}px`); + selectStyleParts.push(`padding-bottom:${py}px`); + } + + if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") { + selectStyleParts.push(`background-color:${escapeAttr(props.fillColorCustom.trim())}`); + } + + if (typeof props.strokeColorCustom === "string" && props.strokeColorCustom.trim() !== "") { + selectStyleParts.push(`border-color:${escapeAttr(props.strokeColorCustom.trim())}`); + } + + const radiusToken = typeof props.borderRadius === "string" ? props.borderRadius : "md"; + const radiusPx = + radiusToken === "none" + ? 0 + : radiusToken === "sm" + ? 2 + : radiusToken === "lg" + ? 6 + : radiusToken === "full" + ? 9999 + : 4; + selectStyleParts.push(`border-radius:${radiusPx}px`); + + const selectStyleAttr = + selectStyleParts.length > 0 ? ` style=\"${selectStyleParts.join(";")}\"` : ""; + const parts: string[] = []; parts.push('
    '); - parts.push(``); - parts.push(``); for (const opt of options) { parts.push( ``, @@ -439,12 +624,61 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): : name; const options = Array.isArray(props.options) ? props.options : []; + const textColorRaw = + typeof props.textColorCustom === "string" && props.textColorCustom.trim() !== "" + ? props.textColorCustom.trim() + : ""; + const labelStyleAttr = textColorRaw ? ` style=\"color:${escapeAttr(textColorRaw)}\"` : ""; + + const optionStyleParts: string[] = []; + if (textColorRaw) { + optionStyleParts.push(`color:${escapeAttr(textColorRaw)}`); + } + + if (typeof props.paddingX === "number" && props.paddingX >= 0) { + const px = props.paddingX; + optionStyleParts.push(`padding-left:${px}px`); + optionStyleParts.push(`padding-right:${px}px`); + } + + if (typeof props.paddingY === "number" && props.paddingY >= 0) { + const py = props.paddingY; + optionStyleParts.push(`padding-top:${py}px`); + optionStyleParts.push(`padding-bottom:${py}px`); + } + + if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") { + optionStyleParts.push(`background-color:${escapeAttr(props.fillColorCustom.trim())}`); + } + + if (typeof props.strokeColorCustom === "string" && props.strokeColorCustom.trim() !== "") { + optionStyleParts.push(`border-color:${escapeAttr(props.strokeColorCustom.trim())}`); + optionStyleParts.push("border-width:1px"); + optionStyleParts.push("border-style:solid"); + } + + const checkboxRadiusToken = typeof props.borderRadius === "string" ? props.borderRadius : "md"; + const checkboxRadiusPx = + checkboxRadiusToken === "none" + ? 0 + : checkboxRadiusToken === "sm" + ? 2 + : checkboxRadiusToken === "lg" + ? 6 + : checkboxRadiusToken === "full" + ? 9999 + : 4; + optionStyleParts.push(`border-radius:${checkboxRadiusPx}px`); + + const optionStyleAttr = + optionStyleParts.length > 0 ? ` style=\"${optionStyleParts.join(";")}\"` : ""; + const parts: string[] = []; parts.push('
    '); - parts.push(``); + parts.push(``); for (const opt of options) { parts.push( - ``, ); @@ -463,12 +697,61 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): : name; const options = Array.isArray(props.options) ? props.options : []; + const textColorRaw = + typeof props.textColorCustom === "string" && props.textColorCustom.trim() !== "" + ? props.textColorCustom.trim() + : ""; + const labelStyleAttr = textColorRaw ? ` style=\"color:${escapeAttr(textColorRaw)}\"` : ""; + + const optionStyleParts: string[] = []; + if (textColorRaw) { + optionStyleParts.push(`color:${escapeAttr(textColorRaw)}`); + } + + if (typeof props.paddingX === "number" && props.paddingX >= 0) { + const px = props.paddingX; + optionStyleParts.push(`padding-left:${px}px`); + optionStyleParts.push(`padding-right:${px}px`); + } + + if (typeof props.paddingY === "number" && props.paddingY >= 0) { + const py = props.paddingY; + optionStyleParts.push(`padding-top:${py}px`); + optionStyleParts.push(`padding-bottom:${py}px`); + } + + if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") { + optionStyleParts.push(`background-color:${escapeAttr(props.fillColorCustom.trim())}`); + } + + if (typeof props.strokeColorCustom === "string" && props.strokeColorCustom.trim() !== "") { + optionStyleParts.push(`border-color:${escapeAttr(props.strokeColorCustom.trim())}`); + optionStyleParts.push("border-width:1px"); + optionStyleParts.push("border-style:solid"); + } + + const radioRadiusToken = typeof props.borderRadius === "string" ? props.borderRadius : "md"; + const radioRadiusPx = + radioRadiusToken === "none" + ? 0 + : radioRadiusToken === "sm" + ? 2 + : radioRadiusToken === "lg" + ? 6 + : radioRadiusToken === "full" + ? 9999 + : 4; + optionStyleParts.push(`border-radius:${radioRadiusPx}px`); + + const optionStyleAttr = + optionStyleParts.length > 0 ? ` style=\"${optionStyleParts.join(";")}\"` : ""; + const parts: string[] = []; parts.push('
    '); - parts.push(``); + parts.push(``); for (const opt of options) { parts.push( - ``, ); @@ -515,8 +798,14 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): const pyClass = pyClassMap[pyToken] ?? "pb-section-py-md"; const sectionClasses = ["pb-section", bgClass, pyClass].filter(Boolean).join(" "); + const sectionStyleParts: string[] = []; + // 섹션 커스텀 배경색: backgroundColorCustom 이 설정된 경우에만 인라인 스타일로 적용하여 토큰 기반 배경 클래스를 오버라이드한다. + if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") { + sectionStyleParts.push(`background-color:${escapeAttr(props.backgroundColorCustom.trim())}`); + } + const sectionStyleAttr = sectionStyleParts.length > 0 ? ` style="${sectionStyleParts.join(";")}"` : ""; - bodyParts.push(`
    `); + bodyParts.push(`
    `); for (const col of columns) { const columnBlocks = blocks.filter((b) => b.sectionId === section.id && b.columnId === col.id); bodyParts.push('
    '); diff --git a/src/app/api/forms/submit/route.ts b/src/app/api/forms/submit/route.ts index 58d100b..e105738 100644 --- a/src/app/api/forms/submit/route.ts +++ b/src/app/api/forms/submit/route.ts @@ -21,19 +21,24 @@ export async function POST(req: Request) { } const submitTarget = config?.submitTarget ?? "internal"; + const successMessage = config?.successMessage; + const errorMessage = config?.errorMessage; // 1) internal: 우리 서버에서 직접 처리하는 기본 모드 if (submitTarget === "internal") { // TODO: 이곳에서 DB 저장이나 이메일 전송 등 실제 처리를 수행한다. console.log("[forms/submit][internal]", { name, email, message }); - return NextResponse.json({ ok: true }); + return NextResponse.json({ ok: true, message: successMessage }); } // 2) webhook: destinationUrl 로 서버에서 POST (Google Sheets 등 포함) if (submitTarget === "webhook") { const destinationUrl = config?.destinationUrl; if (!destinationUrl) { - return NextResponse.json({ ok: false, error: "destinationUrl_missing" }, { status: 400 }); + return NextResponse.json( + { ok: false, error: "destinationUrl_missing", message: errorMessage }, + { status: 400 }, + ); } const payloadFormat = config?.payloadFormat ?? "form"; @@ -87,19 +92,25 @@ export async function POST(req: Request) { if (!res.ok) { console.error("[forms/submit][webhook] remote error", res.status, await res.text().catch(() => "")); - return NextResponse.json({ ok: false, error: "webhook_failed" }, { status: 502 }); + return NextResponse.json( + { ok: false, error: "webhook_failed", message: errorMessage }, + { status: 502 }, + ); } } catch (error) { console.error("[forms/submit][webhook] fetch error", error); - return NextResponse.json({ ok: false, error: "webhook_exception" }, { status: 500 }); + return NextResponse.json( + { ok: false, error: "webhook_exception", message: errorMessage }, + { status: 500 }, + ); } console.log("[forms/submit][webhook] forwarded", { name, email, message, destinationUrl }); - return NextResponse.json({ ok: true }); + return NextResponse.json({ ok: true, message: successMessage }); } // 그 외 알 수 없는 모드는 internal 과 동일하게 처리 console.warn("[forms/submit] unknown submitTarget, fallback to internal", submitTarget); console.log("[forms/submit][internal]", { name, email, message }); - return NextResponse.json({ ok: true }); + return NextResponse.json({ ok: true, message: successMessage }); } diff --git a/src/app/editor/EditorCanvas.tsx b/src/app/editor/EditorCanvas.tsx index 35837c9..db10ac0 100644 --- a/src/app/editor/EditorCanvas.tsx +++ b/src/app/editor/EditorCanvas.tsx @@ -42,6 +42,7 @@ export function EditorCanvas(props: EditorCanvasProps) { projectConfig, } = props; + const canvasOuterStyle: CSSProperties = {}; const canvasInnerStyle: CSSProperties = {}; const preset = projectConfig.canvasPreset ?? "full"; const widthPx = @@ -59,14 +60,19 @@ export function EditorCanvas(props: EditorCanvasProps) { canvasInnerStyle.maxWidth = "1200px"; } + if (projectConfig.bodyBgColorHex && projectConfig.bodyBgColorHex.trim()) { + canvasOuterStyle.backgroundColor = projectConfig.bodyBgColorHex; + } + if (projectConfig.canvasBgColorHex && projectConfig.canvasBgColorHex.trim()) { canvasInnerStyle.backgroundColor = projectConfig.canvasBgColorHex; } return (
    { if (event.target === event.currentTarget) { onCanvasEmptyClick(); diff --git a/src/app/editor/forms/FormControllerPanel.tsx b/src/app/editor/forms/FormControllerPanel.tsx index 1f02b24..811a67b 100644 --- a/src/app/editor/forms/FormControllerPanel.tsx +++ b/src/app/editor/forms/FormControllerPanel.tsx @@ -2,6 +2,7 @@ import type { Block, ButtonBlockProps, FormBlockProps } from "@/features/editor/state/editorStore"; import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl"; +import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField"; interface FormControllerPanelProps { block: Block; // type === "form" @@ -188,6 +189,61 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc />
    +
    +

    폼 스타일

    + { + updateBlock(selectedBlockId, { + backgroundColorCustom: hex, + } as any); + }} + palette={TEXT_COLOR_PALETTE} + onPaletteSelect={(item) => { + updateBlock(selectedBlockId, { + backgroundColorCustom: item.color, + } as any); + }} + /> +
    + +
    +

    폼 메시지

    + + +
    +

    폼 컨트롤러

    diff --git a/src/app/editor/page.tsx b/src/app/editor/page.tsx index a26f7de..9c74b47 100644 --- a/src/app/editor/page.tsx +++ b/src/app/editor/page.tsx @@ -113,17 +113,14 @@ export default function EditorPage() { const [projectSlug, setProjectSlug] = useState(""); const [loadSlug, setLoadSlug] = useState(""); const [projectMessage, setProjectMessage] = useState(""); + // 멀티 선택(MVP)을 위한 로컬 선택 상태: 단일 선택(selectedBlockId)과 병행 사용한다. + const [selectedBlockIds, setSelectedBlockIds] = useState([]); const [menuOpen, setMenuOpen] = useState(false); const [activeModal, setActiveModal] = useState<"project" | "json" | null>(null); const sensors = useSensors(useSensor(PointerSensor)); - const editorMainStyle: CSSProperties = {}; - if (projectConfig.bodyBgColorHex && projectConfig.bodyBgColorHex.trim()) { - editorMainStyle.backgroundColor = projectConfig.bodyBgColorHex; - } - const startEditing = (id: string, initialText: string) => { selectBlock(id); setEditingBlockId(id); @@ -474,6 +471,20 @@ export default function EditorPage() { // ArrowUp/ArrowDown을 이용한 선택 블록 이동을 처리한다. useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null; + if (target) { + const tagName = target.tagName; + const isInputLike = + tagName === "INPUT" || + tagName === "TEXTAREA" || + (target as HTMLElement).isContentEditable; + if (isInputLike) { + // 입력 포커스 상태에서는 에디터 전역 단축키를 비활성화하고, + // 기본 브라우저 편집 동작(삭제/커서 이동 등)을 그대로 둔다. + return; + } + } + // 방향키로 선택된 블록을 위/아래로 이동 (순서 변경이 아니라 선택만 이동) if (event.key === "ArrowDown" || event.key === "ArrowUp") { const { blocks: currentBlocks, selectedBlockId: currentSelectedId, selectBlock: storeSelectBlock } = @@ -518,9 +529,20 @@ export default function EditorPage() { const isMac = navigator.platform.toLowerCase().includes("mac"); const metaKey = isMac ? event.metaKey : event.ctrlKey; - // Delete / Backspace 로 선택된 블록 삭제 + // Delete / Backspace 로 선택된 블록 삭제 (멀티 선택 우선) if (event.key === "Delete" || event.key === "Backspace") { - const { selectedBlockId: currentSelectedId } = (useEditorStore as any).getState(); + const { selectedBlockId: currentSelectedId, blocks: currentBlocks } = (useEditorStore as any).getState(); + + // 멀티 선택이 존재하면 멀티 삭제를 우선 처리한다. + if (selectedBlockIds.length > 1) { + event.preventDefault(); + const selectedSet = new Set(selectedBlockIds); + const nextBlocks = (currentBlocks as Block[]).filter((b) => !selectedSet.has(b.id)); + replaceBlocks(nextBlocks); + return; + } + + // 단일 선택만 있는 경우 기존 removeBlock 로직 사용 if (currentSelectedId) { event.preventDefault(); removeBlock(currentSelectedId); @@ -559,11 +581,12 @@ export default function EditorPage() { return () => { window.removeEventListener("keydown", handleKeyDown); }; - }, [undo, redo]); + }, [undo, redo, selectedBlockIds, replaceBlocks, removeBlock]); const renderBlocks = (targetBlocks: Block[]) => targetBlocks.map((block) => { - const isSelected = block.id === selectedBlockId; + const isSelected = + (selectedBlockId != null && block.id === selectedBlockId) || selectedBlockIds.includes(block.id); const isEditing = block.id === editingBlockId; return ( @@ -572,6 +595,9 @@ export default function EditorPage() { +

    Page Editor

    @@ -867,6 +893,9 @@ interface TextInlineToolbarProps { interface SortableEditorBlockProps { block: Block; isSelected: boolean; + selectedBlockId: string | null; + selectedBlockIds: string[]; + setSelectedBlockIds: React.Dispatch>; isEditing: boolean; editingText: string; startEditing: (id: string, initialText: string) => void; @@ -884,6 +913,9 @@ interface SortableEditorBlockProps { function SortableEditorBlock({ block, isSelected, + selectedBlockId, + selectedBlockIds, + setSelectedBlockIds, isEditing, editingText, startEditing, @@ -966,6 +998,11 @@ function SortableEditorBlock({ colorClass = `pb-text-color-${colorPalette}`; } + // 블록 배경색: backgroundColorCustom 이 설정된 경우 편집기 텍스트 컨테이너 배경에도 반영한다. + if (textProps.backgroundColorCustom && textProps.backgroundColorCustom.trim() !== "") { + textStyleOverrides.backgroundColor = textProps.backgroundColorCustom.trim(); + } + // 최대 너비: 커스텀 값이 있으면 우선 사용, 없으면 scale 프리셋 const maxWidthScale = textProps.maxWidthScale ?? "none"; if (textProps.maxWidthCustom && textProps.maxWidthCustom.trim() !== "") { @@ -1027,15 +1064,29 @@ function SortableEditorBlock({ ref={setNodeRef} style={{ ...baseStyle, ...textStyleOverrides }} data-testid="editor-block" + data-block-id={block.id} data-selected={isSelected ? "true" : "false"} aria-selected={isSelected ? "true" : "false"} className={`rounded border px-3 py-2 cursor-text transition-colors ${alignClass} ${sizeClass} ${leadingClass} ${weightClass} ${colorClass} ${maxWidthClass} ${decorationClass} ${ isSelected ? "border-sky-500 bg-slate-900/80" : "border-slate-700 bg-slate-900" }`} onClick={(event) => { - // 섹션 안 자식 블록을 클릭했을 때 섹션까지 선택되는 것을 막기 위해 - // 클릭 이벤트를 여기서 중단하고 현재 블록만 선택한다. + // 멀티 선택(MVP): Cmd/Ctrl+클릭 시 선택 토글, 그 외에는 단일 선택으로 전환한다. + const isMeta = event.metaKey || event.ctrlKey; + if (isMeta) { + event.stopPropagation(); + setSelectedBlockIds((prev) => { + if (prev.includes(block.id)) { + return prev.filter((id) => id !== block.id); + } + return [...prev, block.id]; + }); + return; + } + + // 일반 클릭: 현재 블록만 단일 선택으로 유지한다. event.stopPropagation(); + setSelectedBlockIds([block.id]); selectBlock(block.id); }} onDoubleClick={(event) => { @@ -1358,7 +1409,7 @@ function SortableEditorBlock({ else if (checkboxRadius === "lg" || checkboxRadius === "full") fieldStyle.borderRadius = 9999; return ( -
    +
    {/* 체크박스 그룹 타이틀도 텍스트/이미지 모드를 지원한다. */} {groupLabelMode === "image" && checkboxProps.groupLabelImageUrl ? ( // eslint-disable-next-line @next/next/no-img-element @@ -1402,7 +1453,9 @@ function SortableEditorBlock({ const variant = buttonProps.variant ?? "solid"; const colorPalette = buttonProps.colorPalette ?? "primary"; const radius = buttonProps.borderRadius ?? "md"; - const fullWidth = buttonProps.fullWidth ?? false; + const baseFullWidth = buttonProps.fullWidth ?? false; + const widthMode = buttonProps.widthMode ?? (baseFullWidth ? "full" : "auto"); + const isFullWidth = widthMode === "full" || baseFullWidth; const sizeClassBtn = size === "xs" @@ -1445,6 +1498,9 @@ function SortableEditorBlock({ if (buttonProps.textColorCustom && buttonProps.textColorCustom.trim() !== "") { buttonStyle.color = buttonProps.textColorCustom; } + if (widthMode === "fixed" && typeof buttonProps.widthPx === "number" && buttonProps.widthPx > 0) { + buttonStyle.width = `${buttonProps.widthPx}px`; + } if (typeof buttonProps.paddingX === "number" && buttonProps.paddingX >= 0) { buttonStyle.paddingInline = `${buttonProps.paddingX}px`; } @@ -1453,7 +1509,7 @@ function SortableEditorBlock({ } return ( -
    +
    -
    +

    템플릿

    - - - - - - - - - + +
    +

    히어로 · CTA

    +
    +
    +
    + +
    +
    +
    +
    +

    + 페이지 상단 Hero 섹션 (큰 제목 + 서브텍스트 + 버튼) +

    +
    + +
    +
    + +
    +
    +
    +
    +

    콜투액션(CTA) 섹션

    +
    +
    +
    + +
    +

    콘텐츠 섹션

    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +

    3컬럼 기능 소개 섹션

    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    +

    자주 묻는 질문(FAQ) 섹션

    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    +

    요금제/플랜 소개 섹션

    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    +

    블로그 포스트 목록 섹션

    +
    +
    +
    + +
    +

    신뢰/소개

    +
    +
    +
    + +
    +
    +
    +
    +
    +

    고객 후기(Testimonials) 섹션

    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    +

    팀 소개 섹션

    +
    +
    +
    + +
    +

    푸터/기타

    +
    +
    +
    + +
    +
    +
    +
    +
    +

    페이지 푸터 섹션

    +
    +
    +
    ); diff --git a/src/app/editor/panels/ButtonPropertiesPanel.tsx b/src/app/editor/panels/ButtonPropertiesPanel.tsx index b84b6f4..6244254 100644 --- a/src/app/editor/panels/ButtonPropertiesPanel.tsx +++ b/src/app/editor/panels/ButtonPropertiesPanel.tsx @@ -341,18 +341,6 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc }} />
    -
    - -
    ); } diff --git a/src/app/editor/panels/ImagePropertiesPanel.tsx b/src/app/editor/panels/ImagePropertiesPanel.tsx index f136d03..c04b6d8 100644 --- a/src/app/editor/panels/ImagePropertiesPanel.tsx +++ b/src/app/editor/panels/ImagePropertiesPanel.tsx @@ -2,6 +2,7 @@ import type { ImageBlockProps } from "@/features/editor/state/editorStore"; import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl"; +import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField"; export type ImagePropertiesPanelProps = { imageProps: ImageBlockProps; @@ -127,6 +128,21 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock

    이미지 스타일

    + {/* 카드 배경색 */} +
    + { + const next = hex && hex.trim().length > 0 ? hex : undefined; + updateBlock(selectedBlockId, { backgroundColorCustom: next } as any); + }} + palette={TEXT_COLOR_PALETTE} + /> +
    + {/* 정렬 */}
    +
    + { + updateBlock(selectedBlockId, { + backgroundColorCustom: hex, + } as any); + }} + palette={TEXT_COLOR_PALETTE} + /> +
    +