에디터 인라인/속성 패널 리팩터링 및 폼 전송 기본 기능 추가
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,97 @@
import type { ChangeEvent } from "react";
import { PropertySliderField } from "@/features/editor/components/PropertySliderField";
export type NumericPresetOption = {
// 프리셋 식별자 (예: "tight", "normal")
id: string;
// UI 에 표시할 라벨
label: string;
// UI 기준 값 (px, 배율 등)
value: number;
};
export type NumericPropertyControlProps = {
// 필드 라벨 (예: 글자 크기, 줄 간격)
label: string;
// 단위 설명 (예: px, 배율 등)
unitLabel?: string;
// 현재 UI 값
value: number;
min: number;
max: number;
step: number;
// 프리셋 목록 (없으면 select 를 렌더링하지 않음)
presets?: NumericPresetOption[];
// 값 변경 시 호출되는 콜백 (UI 값 기준)
onChangeValue: (value: number) => void;
};
export function NumericPropertyControl({
label,
unitLabel,
value,
min,
max,
step,
presets,
onChangeValue,
}: NumericPropertyControlProps) {
// 현재 값에 가장 가까운 프리셋 ID 를 계산한다.
const currentPresetId = (() => {
if (!presets || presets.length === 0) return "";
let bestId = presets[0].id;
let bestDist = Math.abs(presets[0].value - value);
for (let i = 1; i < presets.length; i += 1) {
const dist = Math.abs(presets[i].value - value);
if (dist < bestDist) {
bestDist = dist;
bestId = presets[i].id;
}
}
return bestId;
})();
const handlePresetChange = (event: ChangeEvent<HTMLSelectElement>) => {
if (!presets || presets.length === 0) return;
const id = event.target.value;
const found = presets.find((p) => p.id === id);
if (!found) return;
onChangeValue(found.value);
};
return (
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span>
{label}
{unitLabel ? ` ${unitLabel}` : ""}
</span>
<div className="flex items-center gap-2">
{presets && presets.length > 0 ? (
<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={`${label} 프리셋`}
value={currentPresetId}
onChange={handlePresetChange}
>
{presets.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.label}
</option>
))}
</select>
) : null}
<PropertySliderField
label=""
ariaLabelSlider={`${label} 슬라이더`}
ariaLabelInput={`${label} 커스텀${unitLabel ? ` ${unitLabel}` : ""}`}
value={Number.isFinite(value) ? value : 0}
min={min}
max={max}
step={step}
onChange={onChangeValue}
/>
</div>
</label>
);
}