1차 싱크 완료
This commit is contained in:
@@ -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 (
|
||||
<div
|
||||
className="flex-1 p-4 flex flex-col gap-2 text-sm text-slate-200 border-r border-slate-800 overflow-auto"
|
||||
className="flex-1 p-4 flex flex-col gap-2 text-sm text-slate-200 border-r border-slate-800 overflow-auto pb-scroll"
|
||||
data-testid="editor-canvas"
|
||||
style={canvasOuterStyle}
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onCanvasEmptyClick();
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t border-slate-800 pt-3 mt-4">
|
||||
<h3 className="text-[11px] font-semibold text-slate-200">폼 스타일</h3>
|
||||
<ColorPickerField
|
||||
label="폼 배경색"
|
||||
ariaLabelColorInput="폼 배경색 피커"
|
||||
ariaLabelHexInput="폼 배경색 HEX"
|
||||
value={
|
||||
formProps.backgroundColorCustom && formProps.backgroundColorCustom.trim() !== ""
|
||||
? formProps.backgroundColorCustom
|
||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#e5e7eb"
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundColorCustom: hex,
|
||||
} as any);
|
||||
}}
|
||||
palette={TEXT_COLOR_PALETTE}
|
||||
onPaletteSelect={(item) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundColorCustom: item.color,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t border-slate-800 pt-3 mt-4">
|
||||
<h3 className="text-[11px] font-semibold text-slate-200">폼 메시지</h3>
|
||||
<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-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
placeholder="예: 성공적으로 전송되었습니다."
|
||||
value={formProps.successMessage ?? ""}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
successMessage: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<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-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
placeholder="예: 전송 중 오류가 발생했습니다."
|
||||
value={formProps.errorMessage ?? ""}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
errorMessage: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 border-t border-slate-800 pt-3 mt-4">
|
||||
<h3 className="text-[11px] font-semibold text-slate-200">폼 컨트롤러</h3>
|
||||
|
||||
|
||||
+89
-17
@@ -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<string[]>([]);
|
||||
|
||||
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() {
|
||||
<SortableEditorBlock
|
||||
block={block}
|
||||
isSelected={isSelected}
|
||||
selectedBlockId={selectedBlockId ?? null}
|
||||
selectedBlockIds={selectedBlockIds}
|
||||
setSelectedBlockIds={setSelectedBlockIds}
|
||||
isEditing={isEditing}
|
||||
editingText={editingText}
|
||||
startEditing={startEditing}
|
||||
@@ -590,7 +616,7 @@ export default function EditorPage() {
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex flex-col" style={editorMainStyle}>
|
||||
<main className="h-screen flex flex-col overflow-hidden">
|
||||
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between relative z-20">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h1 className="text-xl font-semibold">Page Editor</h1>
|
||||
@@ -867,6 +893,9 @@ interface TextInlineToolbarProps {
|
||||
interface SortableEditorBlockProps {
|
||||
block: Block;
|
||||
isSelected: boolean;
|
||||
selectedBlockId: string | null;
|
||||
selectedBlockIds: string[];
|
||||
setSelectedBlockIds: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
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 (
|
||||
<div className="flex flex-col gap-1 text-xs text-slate-200">
|
||||
<div className="flex flex-col gap-1 text-xs text-slate-200" style={fieldStyle}>
|
||||
{/* 체크박스 그룹 타이틀도 텍스트/이미지 모드를 지원한다. */}
|
||||
{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 (
|
||||
<div className={fullWidth ? "w-full" : "inline-block"}>
|
||||
<div className={isFullWidth ? "w-full" : "inline-block"}>
|
||||
<button
|
||||
type="button"
|
||||
className={`pb-btn-base ${sizeClassBtn} ${radiusClassBtn} ${variantClassBtn} w-full`}
|
||||
@@ -1708,6 +1764,12 @@ function SortableEditorBlock({
|
||||
})()}
|
||||
{block.type === "section" && (() => {
|
||||
const sectionProps = block.props as SectionBlockProps;
|
||||
const isSectionSelected =
|
||||
isSelected ||
|
||||
(selectedBlockId != null &&
|
||||
allBlocks.some(
|
||||
(candidate) => candidate.id === selectedBlockId && candidate.sectionId === block.id,
|
||||
));
|
||||
|
||||
const bgClass =
|
||||
sectionProps.background === "muted"
|
||||
@@ -1723,6 +1785,13 @@ function SortableEditorBlock({
|
||||
? "py-10"
|
||||
: "py-6";
|
||||
|
||||
const alignItemsClass =
|
||||
sectionProps.alignItems === "center"
|
||||
? "items-center"
|
||||
: sectionProps.alignItems === "bottom"
|
||||
? "items-end"
|
||||
: "items-start";
|
||||
|
||||
const columns = sectionProps.columns && sectionProps.columns.length > 0
|
||||
? sectionProps.columns
|
||||
: [{ id: `${block.id}_col_fallback`, span: 12 }];
|
||||
@@ -1738,7 +1807,10 @@ function SortableEditorBlock({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full ${bgClass} ${pyClass} rounded border border-dashed border-slate-700`}
|
||||
data-testid="editor-section"
|
||||
className={`w-full ${bgClass} ${pyClass} rounded border ${
|
||||
isSectionSelected ? "border-sky-500" : "border-dashed border-slate-700"
|
||||
}`}
|
||||
style={sectionStyle}
|
||||
>
|
||||
<div
|
||||
@@ -1751,7 +1823,7 @@ function SortableEditorBlock({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex"
|
||||
className={`flex ${alignItemsClass}`}
|
||||
style={{
|
||||
columnGap:
|
||||
typeof sectionProps.gapXPx === "number" && sectionProps.gapXPx > 0
|
||||
|
||||
@@ -68,7 +68,7 @@ export function BlocksSidebar() {
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className="w-60 border-r border-slate-800 p-4 text-sm space-y-3">
|
||||
<aside className="w-60 border-r border-slate-800 p-4 text-sm space-y-3 overflow-y-auto pb-scroll">
|
||||
<h2 className="font-medium">블록</h2>
|
||||
<button
|
||||
type="button"
|
||||
@@ -152,71 +152,213 @@ export function BlocksSidebar() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-slate-800 mt-3 space-y-2">
|
||||
<div className="pt-3 border-t border-slate-800 mt-3 space-y-3">
|
||||
<h3 className="text-[11px] font-medium text-slate-300">템플릿</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddHeroTemplate}
|
||||
>
|
||||
Hero 템플릿 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddFeaturesTemplate}
|
||||
>
|
||||
Features 템플릿 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddCtaTemplate}
|
||||
>
|
||||
CTA 템플릿 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddFaqTemplate}
|
||||
>
|
||||
FAQ 템플릿 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddPricingTemplate}
|
||||
>
|
||||
Pricing 템플릿 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddTestimonialsTemplate}
|
||||
>
|
||||
Testimonials 템플릿 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddBlogTemplate}
|
||||
>
|
||||
Blog 템플릿 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddTeamTemplate}
|
||||
>
|
||||
Team 템플릿 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddFooterTemplate}
|
||||
>
|
||||
Footer 템플릿 추가
|
||||
</button>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-semibold text-slate-400">히어로 · CTA</p>
|
||||
<div className="space-y-2">
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddHeroTemplate}
|
||||
>
|
||||
Hero 템플릿 추가
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-hero"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||
>
|
||||
<div className="flex-1 bg-slate-700/70" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">
|
||||
페이지 상단 Hero 섹션 (큰 제목 + 서브텍스트 + 버튼)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddCtaTemplate}
|
||||
>
|
||||
CTA 템플릿 추가
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-cta"
|
||||
className="shrink-0 flex h-6 w-10 items-center justify-center gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||
>
|
||||
<div className="h-2 w-6 rounded bg-slate-700/70" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">콜투액션(CTA) 섹션</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-semibold text-slate-400">콘텐츠 섹션</p>
|
||||
<div className="space-y-2">
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddFeaturesTemplate}
|
||||
>
|
||||
Features 템플릿 추가
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-features"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||
>
|
||||
<div className="flex-1 bg-slate-700/70" />
|
||||
<div className="flex-1 bg-slate-700/50" />
|
||||
<div className="flex-1 bg-slate-700/70" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">3컬럼 기능 소개 섹션</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddFaqTemplate}
|
||||
>
|
||||
FAQ 템플릿 추가
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-faq"
|
||||
className="shrink-0 flex h-6 w-10 flex-col justify-center gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||
>
|
||||
<div className="h-[2px] w-full bg-slate-700/70" />
|
||||
<div className="h-[2px] w-full bg-slate-700/40" />
|
||||
<div className="h-[2px] w-full bg-slate-700/70" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">자주 묻는 질문(FAQ) 섹션</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddPricingTemplate}
|
||||
>
|
||||
Pricing 템플릿 추가
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-pricing"
|
||||
className="shrink-0 flex h-6 w-10 items-end gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||
>
|
||||
<div className="h-2 flex-1 bg-slate-700/40" />
|
||||
<div className="h-4 flex-1 bg-slate-700/70" />
|
||||
<div className="h-3 flex-1 bg-slate-700/50" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">요금제/플랜 소개 섹션</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddBlogTemplate}
|
||||
>
|
||||
Blog 템플릿 추가
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-blog"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||
>
|
||||
<div className="flex-1 bg-slate-700/70" />
|
||||
<div className="flex-1 bg-slate-700/50" />
|
||||
<div className="flex-1 bg-slate-700/70" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">블로그 포스트 목록 섹션</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-semibold text-slate-400">신뢰/소개</p>
|
||||
<div className="space-y-2">
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddTestimonialsTemplate}
|
||||
>
|
||||
Testimonials 템플릿 추가
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-testimonials"
|
||||
className="shrink-0 flex h-6 w-10 flex-col justify-center gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||
>
|
||||
<div className="h-[2px] w-full bg-slate-700/70" />
|
||||
<div className="h-[2px] w-3/4 bg-slate-700/50" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">고객 후기(Testimonials) 섹션</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddTeamTemplate}
|
||||
>
|
||||
Team 템플릿 추가
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-team"
|
||||
className="shrink-0 flex h-6 w-10 items-end gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||
>
|
||||
<div className="h-3 flex-1 bg-slate-700/60" />
|
||||
<div className="h-4 flex-1 bg-slate-700/80" />
|
||||
<div className="h-2 flex-1 bg-slate-700/50" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">팀 소개 섹션</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-semibold text-slate-400">푸터/기타</p>
|
||||
<div className="space-y-2">
|
||||
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddFooterTemplate}
|
||||
>
|
||||
Footer 템플릿 추가
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-footer"
|
||||
className="shrink-0 flex h-6 w-10 flex-col justify-end gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||
>
|
||||
<div className="h-[2px] w-full bg-slate-700/40" />
|
||||
<div className="h-[2px] w-3/4 bg-slate-700/70" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">페이지 푸터 섹션</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -341,18 +341,6 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="flex items-center justify-between text-xs text-slate-400">
|
||||
<span>가로 전체 사용</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={buttonProps.fullWidth ?? false}
|
||||
onChange={(e) => {
|
||||
updateBlock(selectedBlockId, { fullWidth: e.target.checked } as any);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
<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>
|
||||
|
||||
{/* 카드 배경색 */}
|
||||
<div className="space-y-1">
|
||||
<ColorPickerField
|
||||
label="카드 배경색"
|
||||
ariaLabelColorInput="이미지 카드 배경색 피커"
|
||||
ariaLabelHexInput="이미지 카드 배경색 HEX"
|
||||
value={imageProps.backgroundColorCustom ?? ""}
|
||||
onChange={(hex) => {
|
||||
const next = hex && hex.trim().length > 0 ? hex : undefined;
|
||||
updateBlock(selectedBlockId, { backgroundColorCustom: next } as any);
|
||||
}}
|
||||
palette={TEXT_COLOR_PALETTE}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정렬 */}
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>정렬</span>
|
||||
|
||||
@@ -167,6 +167,17 @@ export function ListPropertiesPanel({
|
||||
}}
|
||||
/>
|
||||
|
||||
<ColorPickerField
|
||||
label="블록 배경색"
|
||||
ariaLabelColorInput="리스트 배경색 피커"
|
||||
ariaLabelHexInput="리스트 배경색 HEX"
|
||||
value={listProps.backgroundColorCustom ?? ""}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, { backgroundColorCustom: hex } as any);
|
||||
}}
|
||||
palette={TEXT_COLOR_PALETTE}
|
||||
/>
|
||||
|
||||
{/* 불릿 스타일 (● / ○ / ■ / 숫자형 / 없음) */}
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>불릿 스타일</span>
|
||||
|
||||
@@ -50,7 +50,7 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
|
||||
return (
|
||||
<aside
|
||||
data-testid="properties-sidebar"
|
||||
className="w-80 p-4 text-sm border-l border-slate-800 flex flex-col gap-4 overflow-auto"
|
||||
className="w-80 p-4 text-sm border-l border-slate-800 flex flex-col gap-4 overflow-auto pb-scroll"
|
||||
onKeyDownCapture={(e) => {
|
||||
// 속성 패널 안에서 발생한 키 입력은 에디터 단축키/텍스트 블록 편집으로 전달되지 않도록 막는다.
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -181,7 +181,7 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
|
||||
|
||||
// 1컬럼인 경우: 12 고정 표시
|
||||
if (colCount === 1) {
|
||||
const only = columns[0];
|
||||
const only = columns[0] ?? { id: `${selectedBlockId}_col_1`, span: 12 };
|
||||
return (
|
||||
<label key={only.id} className="flex flex-col gap-1">
|
||||
<span>{`1열 폭 (고정 12/12)`}</span>
|
||||
|
||||
@@ -437,6 +437,21 @@ export function TextPropertiesPanel({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<ColorPickerField
|
||||
label="블록 배경색"
|
||||
ariaLabelColorInput="텍스트 블록 배경색 피커"
|
||||
ariaLabelHexInput="텍스트 블록 배경색 HEX"
|
||||
value={textProps.backgroundColorCustom ?? ""}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundColorCustom: hex,
|
||||
} 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>
|
||||
|
||||
Reference in New Issue
Block a user