From 546c961a31e0c795326f23a0a25b1546bcb9a668 Mon Sep 17 00:00:00 2001 From: Jaybe Date: Fri, 21 Nov 2025 16:25:55 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=EC=84=B9=EC=85=98=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EB=B0=8F=20=ED=85=9C=ED=94=8C=EB=A6=BF=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MAIN_PLAN.md | 97 ++- src/app/editor/page.tsx | 299 +++++++-- .../editor/panels/DividerPropertiesPanel.tsx | 86 +++ .../editor/panels/ImagePropertiesPanel.tsx | 89 +++ src/app/editor/panels/ListPropertiesPanel.tsx | 216 ++++++- src/app/editor/panels/PropertiesSidebar.tsx | 11 + .../editor/panels/SectionPropertiesPanel.tsx | 392 +++++++++++- src/app/editor/templates/blogTemplate.ts | 5 + src/app/editor/templates/ctaTemplate.ts | 5 + src/app/editor/templates/faqTemplate.ts | 4 + src/app/editor/templates/featuresTemplate.ts | 5 + src/app/editor/templates/footerTemplate.ts | 4 + src/app/editor/templates/heroTemplate.ts | 5 + src/app/editor/templates/pricingTemplate.ts | 5 + src/app/editor/templates/teamTemplate.ts | 5 + .../editor/templates/testimonialsTemplate.ts | 5 + .../editor/components/ColorPickerField.tsx | 4 +- .../editor/components/PublicPageRenderer.tsx | 350 ++++++++++- src/features/editor/state/editorStore.ts | 567 ++++++++++++++++-- tests/e2e/editor.spec.ts | 72 +++ tests/e2e/preview.spec.ts | 175 ++++++ tests/unit/SectionLayoutPresets.spec.tsx | 66 ++ tests/unit/SectionPropertiesPanel.spec.tsx | 31 + tests/unit/editorStore.spec.ts | 430 ++++++++++++- tests/unit/sectionLayout.spec.ts | 66 ++ 25 files changed, 2843 insertions(+), 151 deletions(-) create mode 100644 tests/unit/SectionLayoutPresets.spec.tsx create mode 100644 tests/unit/SectionPropertiesPanel.spec.tsx create mode 100644 tests/unit/sectionLayout.spec.ts diff --git a/MAIN_PLAN.md b/MAIN_PLAN.md index 95ca2ff..49b9a86 100644 --- a/MAIN_PLAN.md +++ b/MAIN_PLAN.md @@ -212,11 +212,45 @@ 폼 기능 역시 다른 단계와 동일하게 **실패하는 테스트 → 최소 구현 → 리팩터링** 순서를 유지하며, Webhook/Google Sheets 연동 시 CORS 문제를 피하기 위해 항상 `/api/forms/submit` 경유 구조를 사용한다. -### 3) 진행 현황 (2025-11-20) +### 3) 폼 컨트롤러 + 프리뷰 레이아웃 동기화 (브랜치: feature/builder-13-forms-style-sync) + +- **목표** + - 에디터에서 배치한 폼 요소 블록(`formInput`/`formSelect`/`formRadio`/`formCheckbox`)이 프리뷰에서도 가능한 한 **같은 레이아웃**으로 보이도록 정리한다. + - `FormBlock`(폼 블록)은 레이아웃을 직접 렌더링하지 않고, **전송 설정 + 어떤 요소/버튼을 묶어 전송할지 제어하는 컨트롤러** 역할만 수행한다. + - 기존 v1 fallback(contact 폼: 이름/이메일/메시지 3필드)은 가능하면 제거하거나, v2 컨트롤러가 전혀 설정되지 않았을 때에만 선택적으로 사용한다. + +- **현재 문제 인식** + - 에디터 캔버스에서는 `formInput`/`formSelect`/`formRadio`/`formCheckbox` 블록이 각각 파란 박스 형태로 자유 레이아웃에 배치되어 보이지만, + - 프리뷰에서는 `FormBlockProps.fields`/`fieldIds`/fallback 에 따라 **별도의 수직 폼 레이아웃(name/email/message)** 이 생성되어, 에디터에 없는 블록이 새로 생긴 것처럼 보인다. + - 사용 의도는 "폼 요소 블록 = 레이아웃, 폼 블록 = 전송 컨트롤러" 인데, 현재는 폼 블록이 레이아웃까지 재구성하는 상태. + +- **작업 항목 (TDD)** + 1. **프리뷰 E2E 보강 (`tests/e2e/preview.spec.ts`)** + - 단순한 시나리오부터 추가: + - 에디터에서 `formInput`/`button`/`form` 블록을 추가하고, 폼 컨트롤러에서 해당 입력/버튼을 매핑한 뒤 `/preview` 로 이동했을 때, + - 프리뷰 화면에 기본 name/email/message 폼이 아니라, 에디터에서 만든 입력 필드(레이아웃 기반)가 보이는지 검증. + - fallback contact 폼은 fieldIds/fields 가 완전히 비어 있을 때에만 보이는지, 또는 사용하지 않을 경우 어떤 동작을 기대할지 테스트로 먼저 정의. + + 2. **PublicPageRenderer 리팩터링 (폼 컨트롤러 역할 명확화)** + - `PublicPageRenderer` 에서 `form` 블록을 렌더링할 때: + - 가능하면 `
` 요소 안에서 레이아웃을 새로 만들지 않고, 섹션/컬럼/루트 레이아웃 안에 이미 렌더된 폼 요소 블록들을 기준으로 전송만 담당하도록 구조 재검토. + - v2 컨트롤러(fieldIds/submitButtonId) 우선 사용, v1 `fields`/기본 name/email/message 폼은 옵션/백워드 호환 용도로만 유지하거나 점진적 제거. + - submit 시에는 여전히 `/api/forms/submit` 로 `FormData` 를 전송하되, 수집 대상 필드를 v2 컨트롤러 기준으로 한정. + + 3. **에디터/프리뷰 시각적 차이 최소화** + - 텍스트/버튼과 동일하게, 폼 요소 블록의 타이포/색상/레이아웃 스타일이 에디터와 프리뷰에서 크게 다르지 않도록 정리. + - 필요시 `PublicPageRenderer` 에서 폼 요소 렌더링에 재사용 가능한 프레젠테이션 컴포넌트 도입(에디터용 렌더링과 일정 부분 공유). + + 4. **회귀 방지** + - 기존 폼 관련 E2E(`preview.spec.ts`의 v2 컨트롤러 시나리오)와 API 테스트(`formsSubmit.spec.ts`)가 모두 통과하는지 수시로 확인. + - fallback contact 폼 동작을 유지할지/제거할지 결정되면, 해당 경로도 테스트로 명시해 회귀 방지. + + +### 3) 진행 현황 (2025-11-20 ~ 2025-11-21) - **v2 TDD 1단계(editorStore)** - `tests/unit/editorStore.spec.ts` - `formInput` 블록 추가 액션(`addFormInputBlock`)에 대한 유닛 테스트 작성 - - FormBlock 컨트롤러 기본 속성(`fieldIds`, `submitButtonId`) 초기값 유닛 테스트 작성 + - FormBlock 의 `fieldIds`/`submitButtonId` 기본값 및 업데이트 동작 테스트 - `src/features/editor/state/editorStore.ts` - `BlockType`에 `"formInput"` 타입 추가 - `FormInputBlockProps` 정의 및 공통 `Block.props` 유니온에 포함 @@ -225,6 +259,17 @@ - `addFormInputBlock` 액션 구현 (루트에 기본 label/formFieldName 으로 추가, 선택 상태 업데이트) - 결과: `editorStore.spec.ts` 전체 25개 테스트 모두 통과 +- **텍스트 스타일/프리뷰 1차 정리 (별도 브랜치에서 수행 후 병합 예정)** + - 에디터 텍스트 블록(`TextBlockProps`)의 정렬/폰트 크기/줄 간격/자간/색상/최대 너비/장식 속성을 `SortableEditorBlock` 렌더링에 전부 반영 + - 에디터 캔버스에서 텍스트 요소에 `pb-text-*`/`pb-leading-*`/`pb-font-*`/`pb-text-color-*` 및 커스텀 스타일이 적용되도록 정리 + - 퍼블릭 렌더러(`PublicPageRenderer`)에서 텍스트 블록의 정렬/크기/색상 일부를 에디터와 의미적으로 일치하도록 개선 + - 관련 E2E(`editor.spec.ts`) 기대값을 `text-*` → `pb-text-*` 네이밍에 맞게 조정 + +- **폼 컨트롤러 + 프리뷰 레이아웃 동기화 브랜치 시작 (`feature/builder-13-forms-style-sync`)** + - MAIN_PLAN 에 "3) 폼 컨트롤러 + 프리뷰 레이아웃 동기화" 섹션 추가 + - v2 폼 컨트롤러 시나리오에서 프리뷰에 기본 contact fallback 폼(name/email/message)이 보이지 않아야 한다는 E2E 기대를 명시 + - 다음 단계에서 `PublicPageRenderer` 의 FormBlock 렌더링을 컨트롤러 중심으로 리팩터링하고, 컨트롤러가 없는 FormBlock 의 fallback 정책을 정리할 예정 + - **v2 TDD 2단계(editorStore + Editor UI)** - `tests/unit/editorStore.spec.ts` - `formSelect`/`formRadio`/`formCheckbox` 블록 추가 액션 및 FormBlock `fieldIds`/`submitButtonId` 업데이트 테스트 추가 @@ -286,4 +331,50 @@ - divider/list 블록용 E2E는 블록 추가 직후 자동 선택 상태와 속성 패널 조작만 검증하도록 작성해 부분적으로 우회 - 기존 테스트들(블록 삭제, 복제, 키보드 이동 등)은 동일한 클릭 간섭 문제 영향 범위에 있어 후속 단계에서 레이아웃/테스트 전략 리팩터링 필요 -이후 단계(`builder-11-editor-ux-advanced`, `builder-12-theme-output`)에서도 모든 기능 추가는 TDD(실패 테스트 → 구현 → 리팩터링) 순서를 유지한다. +- 리스트 블록 선택/드래그 버그 (2025-11-21) + - 증상: 에디터에서 리스트 블록만 회색 박스/드래그 핸들이 보이지 않고, 클릭/드래그가 전혀 되지 않음. 다른 텍스트/버튼 블록은 정상 동작. + - 원인: `src/app/editor/page.tsx` 의 `SortableEditorBlock` 내부에서 `block.type === "list"` 인 경우, 리스트 렌더링 JSX 를 `return` 으로 즉시 반환하면서 공통 래퍼(테두리 + "블록 드래그 핸들" 버튼 + 선택 onClick)를 건너뜀. 결과적으로 리스트 블록은 DnD 컨텍스트에는 포함되지만, 실제 화면에는 핸들이 없는 `ul/li` 만 노출되어 선택/드래그 불가 상태가 됨. + - 조치: + - 리스트 분기에서 JSX 를 직접 반환하는 코드를 제거하고, `alignClass` 만 설정하도록 수정하여 리스트도 다른 블록과 동일하게 `SortableEditorBlock` 공통 래퍼를 타도록 변경. + - `tests/e2e/editor.spec.ts` 에 "리스트 블록에도 드래그 핸들이 있고 선택/드래그가 가능해야 한다" E2E 테스트 추가. + - 텍스트/리스트 블록을 추가 → 리스트 블록 안의 "블록 드래그 핸들" 존재 여부, 클릭 시 `aria-selected` 전환, 드래그 후 리스트 블록이 첫 번째 위치로 이동하는지 검증. + +- 리스트 중첩 편집 1차 뼈대 정리 (현재 브랜치) + - 구현 완료 범위 + - 리스트 블록 추가/선택/드래그 + - 리스트 스타일(정렬/폰트/색상/불릿/간격) 에디터 ↔ 프리뷰 동기화 + - `itemsTree` 기반 중첩 리스트 데이터 구조 및 유틸(`indent`/`outdent`/`moveUp`/`moveDown`) + - `selectedListItemId` 상태 및 `li` 클릭 하이라이트 + - 패널 버튼으로 기본적인 들여쓰기/내어쓰기/위/아래 이동 1차 동작 구현 + - 리스트 관련 유닛 테스트 + 기본 흐름 E2E 테스트 통과 + - 한계/제약 + - textarea 내용 ↔ `itemsTree` 동기화가 단순해서, 여러 번 섞어가며 편집할 때 완전한 “리치 리스트 편집기” 수준의 UX 는 아님 + - Tab/Shift+Tab 조작이 선택/상태 조합에 따라 체감상 불안정한 구간이 존재 + +- 리스트 에디터 v2 설계 개요 (향후 별도 TDD 태스크) + - 핵심 컨셉 + - 단일 소스 = `textarea` + - 각 줄의 Tab/스페이스 수를 `depth` 로 해석하여 중첩 구조를 표현 + - `depth + text` → `itemsTree` 로 변환하는 유틸 도입 + - `itemsTree` → `depth + text` → `textarea` 내용을 재구성하는 유틸 도입 + - 공통 조작 모델 (키보드/패널 공통) + - Enter: 현재 줄 기준 새 줄 추가 + - Tab/Shift+Tab: 현재 줄의 `depth` ±1 (Tab = +1, Shift+Tab = -1, 최소 0) + - 패널 버튼(들여쓰기/내어쓰기)도 동일한 depth 조정 로직을 재사용 + - v2 작업 단위(TDD 단계 제안) + - **v2-1: depth + text → itemsTree 유틸** + - 입력: 줄 단위 텍스트 배열 + 각 줄의 depth(정수) + - 출력: 현재 리스트 블록에서 사용하는 `itemsTree` 구조 + - TDD: 단일 레벨/간단 중첩/잘못된 depth(건너뛴 depth 등) 케이스를 포함한 유닛 테스트부터 작성 + - **v2-2: itemsTree → depth + text → textarea 유틸** + - 입력: `itemsTree` + - 출력: 각 아이템의 depth + text 로 구성된 라인 목록 (textarea 직렬화 가능 형태) + - TDD: v2-1 에서 사용한 입력/기대 구조를 역으로 검증하는 round-trip 유닛 테스트 작성 + - **v2-3: textarea + 키보드/패널 통합 조작 모델** + - 내용: textarea 내 현재 커서가 위치한 줄 기준으로 Enter/Tab/Shift+Tab/패널 버튼이 동일한 depth 조정 규칙을 사용하도록 통합 + - TDD: 키 이벤트에 따른 라인/depth 변화에 대한 작은 단위 유틸 테스트 → 이후 Playwright E2E 로 실제 textarea ↔ 프리뷰 동기화 시나리오 추가 + - 기대 효과 + - textarea 에서 보이는 탭/들여쓰기가 곧 중첩 구조가 되어 UX 가 직관적 + - 라인 + depth 기반 단일 모델 위에서 키보드/버튼 조작 로직을 통합해 유지보수성 향상 + + - 이후 단계(`builder-11-editor-ux-advanced`, `builder-12-theme-output`)에서도 모든 기능 추가는 TDD(실패 테스트 → 구현 → 리팩터링) 순서를 유지한다. diff --git a/src/app/editor/page.tsx b/src/app/editor/page.tsx index 415bfaf..b2555a9 100644 --- a/src/app/editor/page.tsx +++ b/src/app/editor/page.tsx @@ -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 내보내기/불러오기 + )} @@ -596,15 +625,18 @@ export default function EditorPage() { 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)} /> - - ); {activeModal === "project" && (
@@ -727,6 +759,8 @@ export default function EditorPage() {
)} + + ); } interface DragPreviewProps { @@ -748,8 +782,6 @@ function DragPreview({ block }: DragPreviewProps) { ? "Image" : block.type === "divider" ? "Divider" - : block.type === "list" - ? "List" : "Section"}
@@ -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) => 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 ( -
-
+
+
); })()} {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 ( -
- - {items.map((item, index) => ( -
  • {item}
  • + // 줄 간 간격: 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 ( + + {nodes.map((node, index) => ( +
  • + {node.text} + + + + + + + {node.children && node.children.length > 0 && renderListNodes(node.children, level + 1)} +
  • ))}
    -
    - ); + ); + }; + + return
    {renderListNodes(itemsTree, 0)}
    ; })()} {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 ( -
    -
    - {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 ( - - ); - })} +
    0 + ? `${sectionProps.maxWidthPx}px` + : undefined, + }} + > +
    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 ( + + ); + })} +
    ); @@ -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 }} >
    {blocks.length === 0 ? ( diff --git a/src/app/editor/panels/DividerPropertiesPanel.tsx b/src/app/editor/panels/DividerPropertiesPanel.tsx index a400045..15a34b3 100644 --- a/src/app/editor/panels/DividerPropertiesPanel.tsx +++ b/src/app/editor/panels/DividerPropertiesPanel.tsx @@ -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
    + +
    +

    구분선 스타일

    + + {/* 길이/너비 모드 */} + + + {(dividerProps.widthMode ?? "full") === "fixed" && ( + { + updateBlock(selectedBlockId, { widthPx: v } as any); + }} + /> + )} + + {/* 색상 */} + { + updateBlock(selectedBlockId, { colorHex: hex } as any); + }} + palette={TEXT_COLOR_PALETTE} + onPaletteSelect={(item) => { + updateBlock(selectedBlockId, { colorHex: item.color } as any); + }} + /> + + {/* 상하 여백 (슬라이더) */} + { + 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); + }} + /> +
    ); } diff --git a/src/app/editor/panels/ImagePropertiesPanel.tsx b/src/app/editor/panels/ImagePropertiesPanel.tsx index 3bf638b..9962f5a 100644 --- a/src/app/editor/panels/ImagePropertiesPanel.tsx +++ b/src/app/editor/panels/ImagePropertiesPanel.tsx @@ -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 />
    + +
    +

    이미지 스타일

    + + {/* 정렬 */} + + + {/* 너비 모드 */} + + + {(imageProps.widthMode ?? "auto") === "fixed" && ( + { + updateBlock(selectedBlockId, { + widthPx: v, + } as any); + }} + /> + )} + + {/* 모서리 둥글기 */} + { + 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); + }} + /> +
    ); } diff --git a/src/app/editor/panels/ListPropertiesPanel.tsx b/src/app/editor/panels/ListPropertiesPanel.tsx index 6218d7a..fb083ce 100644 --- a/src/app/editor/panels/ListPropertiesPanel.tsx +++ b/src/app/editor/panels/ListPropertiesPanel.tsx @@ -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) => 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 ( <>