에디터 인라인/속성 패널 리팩터링 및 폼 전송 기본 기능 추가
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user