296 lines
11 KiB
TypeScript
296 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import type { ButtonBlockProps } from "@/features/editor/state/editorStore";
|
|
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
|
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
|
|
|
|
export type ButtonPropertiesPanelProps = {
|
|
buttonProps: ButtonBlockProps;
|
|
selectedBlockId: string;
|
|
updateBlock: (id: string, partial: Partial<ButtonBlockProps>) => void;
|
|
};
|
|
|
|
export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBlock }: ButtonPropertiesPanelProps) {
|
|
return (
|
|
<>
|
|
<div className="space-y-1">
|
|
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
<span>버튼 텍스트</span>
|
|
<textarea
|
|
className="w-full min-h-[60px] rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
aria-label="버튼 텍스트"
|
|
value={buttonProps.label}
|
|
onChange={(e) => {
|
|
updateBlock(selectedBlockId, { label: e.target.value } as any);
|
|
}}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<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={buttonProps.href}
|
|
onChange={(e) => {
|
|
updateBlock(selectedBlockId, { href: e.target.value } as any);
|
|
}}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<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={buttonProps.align ?? "left"}
|
|
onChange={(e) => {
|
|
const value = e.target.value as NonNullable<ButtonBlockProps["align"]>;
|
|
updateBlock(selectedBlockId, { align: value } as any);
|
|
}}
|
|
>
|
|
<option value="left">왼쪽</option>
|
|
<option value="center">가운데</option>
|
|
<option value="right">오른쪽</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<NumericPropertyControl
|
|
label="버튼 크기"
|
|
value={(() => {
|
|
const s = buttonProps.size ?? "md";
|
|
return s === "xs" ? 1 : s === "sm" ? 2 : s === "lg" ? 4 : s === "xl" ? 5 : 3;
|
|
})()}
|
|
min={1}
|
|
max={5}
|
|
step={1}
|
|
presets={[
|
|
{ id: "xs", label: "아주 작게", value: 1 },
|
|
{ id: "sm", label: "작게", value: 2 },
|
|
{ id: "md", label: "보통", value: 3 },
|
|
{ id: "lg", label: "크게", value: 4 },
|
|
{ id: "xl", label: "아주 크게", value: 5 },
|
|
]}
|
|
onChangeValue={(v) => {
|
|
const next = v <= 1 ? "xs" : v === 2 ? "sm" : v === 4 ? "lg" : v >= 5 ? "xl" : "md";
|
|
updateBlock(selectedBlockId, { size: next } as any);
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<ColorPickerField
|
|
label="텍스트 색상"
|
|
ariaLabelColorInput="버튼 텍스트 색상 피커"
|
|
ariaLabelHexInput="버튼 텍스트 색상 HEX"
|
|
value={
|
|
buttonProps.textColorCustom && buttonProps.textColorCustom.startsWith("#")
|
|
? buttonProps.textColorCustom
|
|
: "#f9fafb"
|
|
}
|
|
onChange={(hex) => {
|
|
updateBlock(selectedBlockId, {
|
|
textColorCustom: hex,
|
|
} as any);
|
|
}}
|
|
palette={TEXT_COLOR_PALETTE}
|
|
onPaletteSelect={(item) => {
|
|
updateBlock(selectedBlockId, {
|
|
textColorCustom: item.color,
|
|
} as any);
|
|
}}
|
|
/>
|
|
</div>
|
|
<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={buttonProps.variant ?? "solid"}
|
|
onChange={(e) => {
|
|
const value = e.target.value as NonNullable<ButtonBlockProps["variant"]>;
|
|
updateBlock(selectedBlockId, { variant: value } as any);
|
|
}}
|
|
>
|
|
<option value="solid">채움</option>
|
|
<option value="outline">외곽선</option>
|
|
<option value="ghost">고스트</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<ColorPickerField
|
|
label="채움 색상"
|
|
ariaLabelColorInput="버튼 채움 색상 피커"
|
|
ariaLabelHexInput="버튼 채움 색상 HEX"
|
|
value={
|
|
buttonProps.fillColorCustom && buttonProps.fillColorCustom.startsWith("#")
|
|
? buttonProps.fillColorCustom
|
|
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
|
}
|
|
onChange={(hex) => {
|
|
updateBlock(selectedBlockId, {
|
|
fillColorCustom: hex,
|
|
} as any);
|
|
}}
|
|
palette={TEXT_COLOR_PALETTE}
|
|
onPaletteSelect={(item) => {
|
|
updateBlock(selectedBlockId, {
|
|
fillColorCustom: item.color,
|
|
} as any);
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<ColorPickerField
|
|
label="외곽선 색상"
|
|
ariaLabelColorInput="버튼 외곽선 색상 피커"
|
|
ariaLabelHexInput="버튼 외곽선 색상 HEX"
|
|
value={
|
|
buttonProps.strokeColorCustom && buttonProps.strokeColorCustom.startsWith("#")
|
|
? buttonProps.strokeColorCustom
|
|
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
|
}
|
|
onChange={(hex) => {
|
|
updateBlock(selectedBlockId, {
|
|
strokeColorCustom: hex,
|
|
} as any);
|
|
}}
|
|
palette={TEXT_COLOR_PALETTE}
|
|
onPaletteSelect={(item) => {
|
|
updateBlock(selectedBlockId, {
|
|
strokeColorCustom: item.color,
|
|
} as any);
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<NumericPropertyControl
|
|
label="모서리 둥글기"
|
|
value={(() => {
|
|
const r = buttonProps.borderRadius ?? "md";
|
|
return r === "none" ? 0 : r === "sm" ? 1 : r === "lg" ? 3 : r === "full" ? 4 : 2;
|
|
})()}
|
|
min={0}
|
|
max={4}
|
|
step={1}
|
|
presets={[
|
|
{ id: "none", label: "없음", value: 0 },
|
|
{ id: "sm", label: "작게", value: 1 },
|
|
{ id: "md", label: "보통", value: 2 },
|
|
{ id: "lg", label: "크게", value: 3 },
|
|
{ id: "full", label: "완전 둥글게", value: 4 },
|
|
]}
|
|
onChangeValue={(v) => {
|
|
const next =
|
|
v <= 0 ? "none" : v === 1 ? "sm" : v === 3 ? "lg" : v >= 4 ? "full" : "md";
|
|
updateBlock(selectedBlockId, { borderRadius: next } as any);
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<NumericPropertyControl
|
|
label="글자 크기"
|
|
unitLabel="(px)"
|
|
value={(() => {
|
|
const raw = buttonProps.fontSizeCustom ?? "";
|
|
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
|
|
if (match) return Number(match[1]);
|
|
// 기본 버튼 텍스트 크기: md 스케일(16px) 기준
|
|
return 16;
|
|
})()}
|
|
min={10}
|
|
max={32}
|
|
step={1}
|
|
presets={[
|
|
{ id: "xs", label: "XS", value: 12 },
|
|
{ id: "sm", label: "S", value: 14 },
|
|
{ id: "base", label: "M", value: 16 },
|
|
{ id: "lg", label: "L", value: 18 },
|
|
{ id: "xl", label: "XL", value: 20 },
|
|
{ id: "2xl", label: "2XL", value: 24 },
|
|
{ id: "3xl", label: "3XL", value: 30 },
|
|
]}
|
|
onChangeValue={(px) => {
|
|
// 버튼은 텍스트 스케일 상태 없이 px만 저장하지만,
|
|
// 프리셋 값은 TextPropertiesPanel과 동일한 테이블을 사용한다.
|
|
updateBlock(selectedBlockId, {
|
|
fontSizeCustom: `${px}px`,
|
|
} as any);
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<NumericPropertyControl
|
|
label="줄 간격"
|
|
value={(() => {
|
|
const raw = buttonProps.lineHeightCustom ?? "";
|
|
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
|
|
if (match) return Number(match[1]);
|
|
return 1.4;
|
|
})()}
|
|
min={0.8}
|
|
max={3}
|
|
step={0.05}
|
|
presets={[
|
|
{ id: "tight", label: "좁게", value: 1.1 },
|
|
{ id: "normal", label: "보통", value: 1.4 },
|
|
{ id: "relaxed", label: "넓게", value: 1.8 },
|
|
]}
|
|
onChangeValue={(v) => {
|
|
updateBlock(selectedBlockId, {
|
|
lineHeightCustom: v.toString(),
|
|
} as any);
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<NumericPropertyControl
|
|
label="글자 간격"
|
|
unitLabel="(px)"
|
|
value={(() => {
|
|
const raw = buttonProps.letterSpacingCustom ?? "";
|
|
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
|
|
if (match) {
|
|
const em = Number(match[1]);
|
|
if (Number.isFinite(em)) return em * 16;
|
|
}
|
|
return 0;
|
|
})()}
|
|
min={-2}
|
|
max={10}
|
|
step={0.1}
|
|
presets={[
|
|
{ id: "tighter", label: "아주 좁게", value: -1.5 },
|
|
{ id: "tight", label: "좁게", value: -0.5 },
|
|
{ id: "normal", label: "보통", value: 0 },
|
|
{ id: "wide", label: "넓게", value: 1 },
|
|
{ id: "wider", label: "아주 넓게", value: 2 },
|
|
]}
|
|
onChangeValue={(px) => {
|
|
const em = px / 16;
|
|
updateBlock(selectedBlockId, {
|
|
letterSpacingCustom: `${em}em`,
|
|
} as any);
|
|
}}
|
|
/>
|
|
</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>
|
|
</>
|
|
);
|
|
}
|