에디터 인라인/속성 패널 리팩터링 및 폼 전송 기본 기능 추가
CI / pr_and_merge (push) Blocked by required conditions
CI / test (push) Has been cancelled

This commit is contained in:
2025-11-20 00:53:39 +09:00
parent 211b0f8230
commit a6ef5f01cd
29 changed files with 3371 additions and 1052 deletions
@@ -0,0 +1,505 @@
"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-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
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-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
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-700 bg-slate-900 text-slate-300"
}`}
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-700 bg-slate-900 text-slate-300"
}`}
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-700 bg-slate-900 text-slate-300"
}`}
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-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
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">
<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-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
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-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="최대 너비 커스텀"
placeholder="예: 600px, 40rem, 80%, 60ch"
value={textProps.maxWidthCustom ?? ""}
onChange={(e) => {
updateBlock(selectedBlockId, {
maxWidthCustom: e.target.value,
maxWidthMode: "custom",
} as any);
}}
/>
</label>
</div>
</>
);
}