521 lines
18 KiB
TypeScript
521 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import type { TextBlockProps } from "@/features/editor/state/editorStore";
|
|
import { PropertySliderField } from "@/features/editor/components/PropertySliderField";
|
|
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
|
|
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
|
|
|
export type TextPropertiesPanelProps = {
|
|
textProps: TextBlockProps;
|
|
selectedBlockId: string;
|
|
updateBlock: (id: string, partial: Partial<TextBlockProps>) => void;
|
|
editingBlockId: string | null;
|
|
setEditingText: (value: string) => void;
|
|
};
|
|
|
|
export function TextPropertiesPanel({
|
|
textProps,
|
|
selectedBlockId,
|
|
updateBlock,
|
|
editingBlockId,
|
|
setEditingText,
|
|
}: TextPropertiesPanelProps) {
|
|
const fontSizeMode = textProps.fontSizeMode ?? "scale";
|
|
const fallbackScale =
|
|
textProps.size === "sm" ? "sm" : textProps.size === "lg" ? "lg" : "base";
|
|
const fontSizeScale = textProps.fontSizeScale ?? fallbackScale;
|
|
const lineHeightMode = textProps.lineHeightMode ?? "scale";
|
|
const lineHeightScale = textProps.lineHeightScale ?? "normal";
|
|
const fontWeightMode = textProps.fontWeightMode ?? "scale";
|
|
const fontWeightScale = textProps.fontWeightScale ?? "normal";
|
|
const colorPalette = textProps.colorPalette ?? "default";
|
|
const maxWidthMode = textProps.maxWidthMode ?? "scale";
|
|
const maxWidthScale = textProps.maxWidthScale ?? "none";
|
|
|
|
const fontSizeScaleToPx: Record<NonNullable<TextBlockProps["fontSizeScale"]>, number> = {
|
|
xs: 12,
|
|
sm: 14,
|
|
base: 16,
|
|
lg: 18,
|
|
xl: 20,
|
|
"2xl": 24,
|
|
"3xl": 30,
|
|
};
|
|
|
|
const parsedFontSize = (() => {
|
|
const raw = textProps.fontSizeCustom ?? "";
|
|
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
|
|
if (match) return Number(match[1]);
|
|
return fontSizeScaleToPx[fontSizeScale];
|
|
})();
|
|
|
|
const parsedLineHeight = (() => {
|
|
const raw = textProps.lineHeightCustom ?? "";
|
|
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
|
|
if (match) return Number(match[1]);
|
|
return 1.5;
|
|
})();
|
|
|
|
// 글자 간격: 내부 저장은 em 이지만, UI 에서는 px 단위로 보여준다.
|
|
const parsedLetterSpacingPx = (() => {
|
|
const raw = textProps.letterSpacingCustom ?? "";
|
|
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
|
|
if (match) {
|
|
const em = Number(match[1]);
|
|
if (Number.isFinite(em)) {
|
|
return em * 16; // 1em = 16px 기준으로 환산
|
|
}
|
|
}
|
|
return 0;
|
|
})();
|
|
|
|
const letterSpacingPreset = (() => {
|
|
const px = parsedLetterSpacingPx;
|
|
if (px <= -1) return "tighter" as const;
|
|
if (px < 0) return "tight" as const;
|
|
if (px < 1) return "normal" as const;
|
|
if (px < 3) return "wide" as const;
|
|
return "wider" as const;
|
|
})();
|
|
|
|
const parsedFontWeight = (() => {
|
|
const raw = textProps.fontWeightCustom ?? "";
|
|
const match = raw.match(/([0-9]{3})/);
|
|
if (match) return Number(match[1]);
|
|
switch (fontWeightScale) {
|
|
case "medium":
|
|
return 500;
|
|
case "semibold":
|
|
return 600;
|
|
case "bold":
|
|
return 700;
|
|
default:
|
|
return 400;
|
|
}
|
|
})();
|
|
|
|
return (
|
|
<>
|
|
<div className="space-y-1">
|
|
<p className="text-xs text-slate-400">선택한 텍스트 블록 내용</p>
|
|
<textarea
|
|
className="w-full min-h-[80px] rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
|
aria-label="선택한 텍스트 블록 내용"
|
|
value={textProps.text}
|
|
onChange={(e) => {
|
|
const value = e.target.value;
|
|
updateBlock(selectedBlockId, { text: value });
|
|
if (editingBlockId === selectedBlockId) {
|
|
setEditingText(value);
|
|
}
|
|
}}
|
|
/>
|
|
</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-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
|
aria-label="정렬"
|
|
value={textProps.align}
|
|
onChange={(e) => {
|
|
const value = e.target.value as "left" | "center" | "right";
|
|
updateBlock(selectedBlockId, { align: value });
|
|
}}
|
|
>
|
|
<option value="left">왼쪽</option>
|
|
<option value="center">가운데</option>
|
|
<option value="right">오른쪽</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<div className="flex items-center justify-between text-[11px] text-slate-400">
|
|
<span>텍스트 스타일</span>
|
|
<div className="flex gap-1">
|
|
<button
|
|
type="button"
|
|
className={`px-2 py-0.5 rounded border text-[11px] ${
|
|
textProps.underline
|
|
? "border-sky-500 bg-sky-900/40 text-sky-100"
|
|
: "border-slate-300 bg-white text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
|
}`}
|
|
onClick={() => {
|
|
updateBlock(selectedBlockId, {
|
|
underline: !textProps.underline,
|
|
} as any);
|
|
}}
|
|
>
|
|
밑줄
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`px-2 py-0.5 rounded border text-[11px] ${
|
|
textProps.strike
|
|
? "border-sky-500 bg-sky-900/40 text-sky-100"
|
|
: "border-slate-300 bg-white text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
|
}`}
|
|
onClick={() => {
|
|
updateBlock(selectedBlockId, {
|
|
strike: !textProps.strike,
|
|
} as any);
|
|
}}
|
|
>
|
|
가운데줄
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`px-2 py-0.5 rounded border text-[11px] ${
|
|
textProps.italic
|
|
? "border-sky-500 bg-sky-900/40 text-sky-100"
|
|
: "border-slate-300 bg-white text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
|
}`}
|
|
onClick={() => {
|
|
updateBlock(selectedBlockId, {
|
|
italic: !textProps.italic,
|
|
} as any);
|
|
}}
|
|
>
|
|
이탤릭
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<NumericPropertyControl
|
|
label="글자 크기"
|
|
unitLabel="(px)"
|
|
value={Number.isFinite(parsedFontSize) ? parsedFontSize : 16}
|
|
min={10}
|
|
max={72}
|
|
step={1}
|
|
presets={(
|
|
["xs", "sm", "base", "lg", "xl", "2xl", "3xl"] as NonNullable<
|
|
TextBlockProps["fontSizeScale"]
|
|
>[]
|
|
).map((scale) => ({
|
|
id: scale,
|
|
label:
|
|
scale === "xs"
|
|
? "XS"
|
|
: scale === "sm"
|
|
? "S"
|
|
: scale === "base"
|
|
? "M"
|
|
: scale === "lg"
|
|
? "L"
|
|
: scale === "xl"
|
|
? "XL"
|
|
: scale === "2xl"
|
|
? "2XL"
|
|
: "3XL",
|
|
value: fontSizeScaleToPx[scale],
|
|
}))}
|
|
onChangeValue={(px) => {
|
|
const raw = textProps.fontSizeCustom ?? "";
|
|
const suffixMatch = raw.match(/px|rem|em|%/);
|
|
const suffix = suffixMatch ? suffixMatch[0] : "px";
|
|
|
|
const entries = Object.entries(fontSizeScaleToPx) as [
|
|
NonNullable<TextBlockProps["fontSizeScale"]>,
|
|
number,
|
|
][];
|
|
const closest = entries.reduce(
|
|
(best, [scale, value]) => {
|
|
const dist = Math.abs(value - px);
|
|
if (dist < best.dist) return { scale, dist };
|
|
return best;
|
|
},
|
|
{ scale: fontSizeScale as NonNullable<TextBlockProps["fontSizeScale"]>, dist: Infinity },
|
|
).scale;
|
|
|
|
updateBlock(selectedBlockId, {
|
|
fontSizeScale: closest,
|
|
fontSizeCustom: `${px}${suffix}`,
|
|
fontSizeMode: "custom",
|
|
} as any);
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
|
<span>글자 간격</span>
|
|
<div className="flex items-center gap-2">
|
|
<select
|
|
className="w-32 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
|
aria-label="글자 간격 프리셋"
|
|
value={letterSpacingPreset}
|
|
onChange={(e) => {
|
|
const preset = e.target.value as
|
|
| "tighter"
|
|
| "tight"
|
|
| "normal"
|
|
| "wide"
|
|
| "wider";
|
|
|
|
const presetPxMap: Record<
|
|
"tighter" | "tight" | "normal" | "wide" | "wider",
|
|
number
|
|
> = {
|
|
tighter: -1.5,
|
|
tight: -0.5,
|
|
normal: 0,
|
|
wide: 1,
|
|
wider: 2,
|
|
};
|
|
|
|
const px = presetPxMap[preset];
|
|
const em = px / 16;
|
|
|
|
updateBlock(selectedBlockId, {
|
|
letterSpacingCustom: `${em}em`,
|
|
} as any);
|
|
}}
|
|
>
|
|
<option value="tighter">아주 좁게</option>
|
|
<option value="tight">좁게</option>
|
|
<option value="normal">보통</option>
|
|
<option value="wide">넓게</option>
|
|
<option value="wider">아주 넓게</option>
|
|
</select>
|
|
|
|
<PropertySliderField
|
|
label=""
|
|
ariaLabelSlider="글자 간격 슬라이더"
|
|
ariaLabelInput="글자 간격 커스텀 (px)"
|
|
value={Number.isFinite(parsedLetterSpacingPx) ? parsedLetterSpacingPx : 0}
|
|
min={-2}
|
|
max={10}
|
|
step={0.1}
|
|
onChange={(px) => {
|
|
const em = px / 16;
|
|
updateBlock(selectedBlockId, {
|
|
letterSpacingCustom: `${em}em`,
|
|
} as any);
|
|
}}
|
|
/>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<NumericPropertyControl
|
|
label="줄 간격"
|
|
unitLabel=""
|
|
value={Number.isFinite(parsedLineHeight) ? parsedLineHeight : 1.5}
|
|
min={-1}
|
|
max={3}
|
|
step={0.05}
|
|
presets={[
|
|
{ id: "tight", label: "좁게", value: 1.25 },
|
|
{ id: "snug", label: "약간 좁게", value: 1.35 },
|
|
{ id: "normal", label: "보통", value: 1.5 },
|
|
{ id: "relaxed", label: "넓게", value: 1.7 },
|
|
{ id: "loose", label: "아주 넓게", value: 1.9 },
|
|
]}
|
|
onChangeValue={(v) => {
|
|
const preset: Record<NonNullable<TextBlockProps["lineHeightScale"]>, number> = {
|
|
tight: 1.25,
|
|
snug: 1.35,
|
|
normal: 1.5,
|
|
relaxed: 1.7,
|
|
loose: 1.9,
|
|
};
|
|
|
|
const entries = Object.entries(preset) as [
|
|
NonNullable<TextBlockProps["lineHeightScale"]>,
|
|
number,
|
|
][];
|
|
const closest = entries.reduce(
|
|
(best, [scale, value]) => {
|
|
const dist = Math.abs(value - v);
|
|
if (dist < best.dist) return { scale, dist };
|
|
return best;
|
|
},
|
|
{ scale: lineHeightScale as NonNullable<TextBlockProps["lineHeightScale"]>, dist: Infinity },
|
|
).scale;
|
|
|
|
updateBlock(selectedBlockId, {
|
|
lineHeightScale: closest,
|
|
lineHeightCustom: v.toString(),
|
|
lineHeightMode: "custom",
|
|
} as any);
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<NumericPropertyControl
|
|
label="굵기"
|
|
value={Number.isFinite(parsedFontWeight) ? parsedFontWeight : 400}
|
|
min={100}
|
|
max={900}
|
|
step={100}
|
|
presets={(
|
|
["normal", "medium", "semibold", "bold"] as NonNullable<
|
|
TextBlockProps["fontWeightScale"]
|
|
>[]
|
|
).map((scale) => ({
|
|
id: scale,
|
|
label:
|
|
scale === "normal"
|
|
? "보통"
|
|
: scale === "medium"
|
|
? "중간"
|
|
: scale === "semibold"
|
|
? "세미볼드"
|
|
: "볼드",
|
|
value:
|
|
scale === "normal"
|
|
? 400
|
|
: scale === "medium"
|
|
? 500
|
|
: scale === "semibold"
|
|
? 600
|
|
: 700,
|
|
}))}
|
|
onChangeValue={(v) => {
|
|
const preset: Record<NonNullable<TextBlockProps["fontWeightScale"]>, number> = {
|
|
normal: 400,
|
|
medium: 500,
|
|
semibold: 600,
|
|
bold: 700,
|
|
};
|
|
|
|
const entries = Object.entries(preset) as [
|
|
NonNullable<TextBlockProps["fontWeightScale"]>,
|
|
number,
|
|
][];
|
|
const closest = entries.reduce(
|
|
(best, [scale, value]) => {
|
|
const dist = Math.abs(value - v);
|
|
if (dist < best.dist) return { scale, dist };
|
|
return best;
|
|
},
|
|
{ scale: fontWeightScale as NonNullable<TextBlockProps["fontWeightScale"]>, dist: Infinity },
|
|
).scale;
|
|
|
|
updateBlock(selectedBlockId, {
|
|
fontWeightScale: closest,
|
|
fontWeightCustom: v.toString(),
|
|
fontWeightMode: "custom",
|
|
} as any);
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<ColorPickerField
|
|
label="텍스트 색상"
|
|
ariaLabelColorInput="텍스트 색상 피커"
|
|
ariaLabelHexInput="텍스트 색상 HEX"
|
|
value={
|
|
(textProps.colorCustom && textProps.colorCustom.startsWith("#")
|
|
? textProps.colorCustom
|
|
: TEXT_COLOR_PALETTE.find((p) => p.id === colorPalette)?.color ?? "#ffffff")
|
|
}
|
|
onChange={(hex) => {
|
|
updateBlock(selectedBlockId, {
|
|
colorCustom: hex,
|
|
colorMode: "custom",
|
|
} as any);
|
|
}}
|
|
palette={TEXT_COLOR_PALETTE}
|
|
selectedPaletteId={colorPalette}
|
|
onPaletteSelect={(item) => {
|
|
const paletteId = item.id as NonNullable<TextBlockProps["colorPalette"]>;
|
|
updateBlock(selectedBlockId, {
|
|
colorPalette: paletteId,
|
|
colorCustom: item.color,
|
|
colorMode: "custom",
|
|
} as any);
|
|
}}
|
|
/>
|
|
</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>
|
|
<div className="flex items-center gap-2">
|
|
<select
|
|
className="w-32 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
|
aria-label="최대 너비 프리셋"
|
|
value={maxWidthScale}
|
|
onChange={(e) => {
|
|
const value = e.target.value as NonNullable<TextBlockProps["maxWidthScale"]>;
|
|
let custom = textProps.maxWidthCustom ?? "";
|
|
if (value === "prose") custom = "60ch";
|
|
else if (value === "narrow") custom = "40ch";
|
|
else custom = "";
|
|
updateBlock(selectedBlockId, {
|
|
maxWidthScale: value,
|
|
maxWidthMode: custom ? "custom" : "scale",
|
|
maxWidthCustom: custom,
|
|
} as any);
|
|
}}
|
|
>
|
|
<option value="none">제한 없음</option>
|
|
<option value="prose">본문 폭 (60ch)</option>
|
|
<option value="narrow">좁게 (40ch)</option>
|
|
</select>
|
|
<input
|
|
type="range"
|
|
min={20}
|
|
max={120}
|
|
step={5}
|
|
aria-label="최대 너비 슬라이더 (ch 단위)"
|
|
value={(() => {
|
|
const raw = textProps.maxWidthCustom ?? "";
|
|
const match = raw.match(/([0-9]+)ch/);
|
|
if (match) return Number(match[1]);
|
|
if (maxWidthScale === "prose") return 60;
|
|
if (maxWidthScale === "narrow") return 40;
|
|
return 120;
|
|
})()}
|
|
onChange={(e) => {
|
|
const ch = Number(e.target.value || 60);
|
|
updateBlock(selectedBlockId, {
|
|
maxWidthCustom: `${ch}ch`,
|
|
maxWidthMode: "custom",
|
|
} as any);
|
|
}}
|
|
className="flex-1"
|
|
/>
|
|
</div>
|
|
<input
|
|
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
|
aria-label="최대 너비 커스텀"
|
|
placeholder="예: 600px, 40rem, 80%, 60ch"
|
|
value={textProps.maxWidthCustom ?? ""}
|
|
onChange={(e) => {
|
|
updateBlock(selectedBlockId, {
|
|
maxWidthCustom: e.target.value,
|
|
maxWidthMode: "custom",
|
|
} as any);
|
|
}}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|