243 lines
9.0 KiB
TypeScript
243 lines
9.0 KiB
TypeScript
"use client";
|
|
|
|
import type { ListBlockProps } from "@/features/editor/state/editorStore";
|
|
import {
|
|
buildItemsTreeFromItems,
|
|
itemsTreeToLines,
|
|
linesToItemsTree,
|
|
parseTextareaToLines,
|
|
stringifyLinesToTextarea,
|
|
} from "@/features/editor/state/editorStore";
|
|
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
|
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
|
|
|
|
export type ListPropertiesPanelProps = {
|
|
listProps: ListBlockProps;
|
|
selectedBlockId: string;
|
|
selectedListItemId: string | null;
|
|
updateBlock: (id: string, partial: Partial<ListBlockProps>) => void;
|
|
};
|
|
|
|
export function ListPropertiesPanel({
|
|
listProps,
|
|
selectedBlockId,
|
|
selectedListItemId,
|
|
updateBlock,
|
|
}: ListPropertiesPanelProps) {
|
|
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-[80px] rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
|
aria-label="리스트 아이템들"
|
|
value={(() => {
|
|
const tree = (listProps as any).itemsTree as any[] | undefined;
|
|
|
|
if (tree && tree.length > 0) {
|
|
// 중첩 리스트가 존재하는 경우, itemsTree → ListLine → textarea 문자열 순으로 직렬화한다.
|
|
const lines = itemsTreeToLines(tree as any);
|
|
return stringifyLinesToTextarea(lines as any);
|
|
}
|
|
|
|
// 기존 하위호환: items 배열이 있다면 그대로 줄바꿈으로 이어 붙여 사용한다.
|
|
return (listProps.items ?? []).join("\n");
|
|
})()}
|
|
onChange={(e) => {
|
|
const textValue = e.target.value;
|
|
|
|
// textarea 내용을 ListLine 배열로 파싱한다.
|
|
const parsedLines = parseTextareaToLines(textValue);
|
|
|
|
// 전체 입력이 완전히 비어 있는 경우에는 빈 리스트 상태를 유지한다.
|
|
const hasAnyContent = parsedLines.some((line) => line.text.length > 0);
|
|
|
|
if (!hasAnyContent) {
|
|
updateBlock(
|
|
selectedBlockId,
|
|
{
|
|
items: [],
|
|
itemsTree: [],
|
|
} as any,
|
|
);
|
|
return;
|
|
}
|
|
|
|
// depth 기반 라인 모델에서 중첩 리스트 트리를 생성한다.
|
|
const nextTree = linesToItemsTree(selectedBlockId, parsedLines as any);
|
|
|
|
// 플랫 items 배열은 기존 하위호환을 위해 text 만 뽑아서 유지한다.
|
|
const flatItems = parsedLines.map((line) => line.text);
|
|
|
|
updateBlock(
|
|
selectedBlockId,
|
|
{
|
|
items: flatItems,
|
|
itemsTree: nextTree,
|
|
} as any,
|
|
);
|
|
}}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
|
<label className="flex items-center gap-2">
|
|
<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={listProps.align}
|
|
onChange={(e) => {
|
|
const value = e.target.value as ListBlockProps["align"];
|
|
updateBlock(selectedBlockId, { align: value } as any);
|
|
}}
|
|
>
|
|
<option value="left">왼쪽</option>
|
|
<option value="center">가운데</option>
|
|
<option value="right">오른쪽</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3 text-xs text-slate-400">
|
|
<h4 className="text-[11px] font-semibold text-slate-200">리스트 스타일</h4>
|
|
|
|
{/* 글자 크기 */}
|
|
<NumericPropertyControl
|
|
label="글자 크기 (px)"
|
|
unitLabel="(px)"
|
|
value={(() => {
|
|
const raw = listProps.fontSizeCustom ?? "";
|
|
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
|
|
if (match) return Number(match[1]);
|
|
return 14;
|
|
})()}
|
|
min={10}
|
|
max={32}
|
|
step={1}
|
|
presets={[
|
|
{ id: "sm", label: "작게", value: 12 },
|
|
{ id: "md", label: "보통", value: 14 },
|
|
{ id: "lg", label: "크게", value: 18 },
|
|
]}
|
|
onChangeValue={(px) => {
|
|
updateBlock(selectedBlockId, { fontSizeCustom: `${px}px` } as any);
|
|
}}
|
|
/>
|
|
|
|
{/* 줄 간격 */}
|
|
<NumericPropertyControl
|
|
label="줄 간격"
|
|
value={(() => {
|
|
const raw = listProps.lineHeightCustom ?? "";
|
|
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
|
|
if (match) return Number(match[1]);
|
|
return 1.5;
|
|
})()}
|
|
min={1}
|
|
max={3}
|
|
step={0.05}
|
|
presets={[
|
|
{ id: "tight", label: "좁게", value: 1.2 },
|
|
{ id: "normal", label: "보통", value: 1.5 },
|
|
{ id: "relaxed", label: "넓게", value: 1.8 },
|
|
]}
|
|
onChangeValue={(v) => {
|
|
updateBlock(selectedBlockId, { lineHeightCustom: v.toString() } as any);
|
|
}}
|
|
/>
|
|
|
|
{/* 텍스트 색상 */}
|
|
<ColorPickerField
|
|
label="텍스트 색상"
|
|
ariaLabelColorInput="리스트 텍스트 색상 피커"
|
|
ariaLabelHexInput="리스트 텍스트 색상 HEX"
|
|
value={
|
|
listProps.textColorCustom && listProps.textColorCustom.trim() !== ""
|
|
? listProps.textColorCustom
|
|
: "#e5e7eb"
|
|
}
|
|
onChange={(hex) => {
|
|
updateBlock(selectedBlockId, { textColorCustom: hex } as any);
|
|
}}
|
|
palette={TEXT_COLOR_PALETTE}
|
|
onPaletteSelect={(item) => {
|
|
updateBlock(selectedBlockId, { textColorCustom: item.color } as any);
|
|
}}
|
|
/>
|
|
|
|
<ColorPickerField
|
|
label="블록 배경색"
|
|
ariaLabelColorInput="리스트 배경색 피커"
|
|
ariaLabelHexInput="리스트 배경색 HEX"
|
|
value={listProps.backgroundColorCustom ?? ""}
|
|
onChange={(hex) => {
|
|
updateBlock(selectedBlockId, { backgroundColorCustom: hex } as any);
|
|
}}
|
|
palette={TEXT_COLOR_PALETTE}
|
|
/>
|
|
|
|
{/* 불릿 스타일 (● / ○ / ■ / 숫자형 / 없음) */}
|
|
<label className="flex flex-col gap-1">
|
|
<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={listProps.bulletStyle ?? "disc"}
|
|
onChange={(e) => {
|
|
const value = e.target.value as NonNullable<ListBlockProps["bulletStyle"]>;
|
|
|
|
// 순서형 스타일(decimal / alpha / roman)은 ordered=true, 나머지는 false 로 설정한다.
|
|
const orderedStyles: Array<NonNullable<ListBlockProps["bulletStyle"]>> = [
|
|
"decimal",
|
|
"lower-alpha",
|
|
"upper-alpha",
|
|
"lower-roman",
|
|
"upper-roman",
|
|
];
|
|
const ordered = orderedStyles.includes(value);
|
|
|
|
updateBlock(selectedBlockId, { bulletStyle: value, ordered } as any);
|
|
}}
|
|
>
|
|
<option value="disc">기본 (●)</option>
|
|
<option value="circle">원 (○)</option>
|
|
<option value="square">사각 (■)</option>
|
|
<option value="decimal">숫자 (1.)</option>
|
|
<option value="lower-alpha">알파벳 소문자 (a.)</option>
|
|
<option value="upper-alpha">알파벳 대문자 (A.)</option>
|
|
<option value="lower-roman">로마 숫자 소문자 (i.)</option>
|
|
<option value="upper-roman">로마 숫자 대문자 (I.)</option>
|
|
<option value="none">없음</option>
|
|
</select>
|
|
</label>
|
|
|
|
{/* 아이템 간 여백 (슬라이더) */}
|
|
<NumericPropertyControl
|
|
label="아이템 간 여백 (px)"
|
|
unitLabel="(px)"
|
|
value={(() => {
|
|
if (typeof listProps.gapYPx === "number") {
|
|
return listProps.gapYPx;
|
|
}
|
|
const token = listProps.gapY ?? "md";
|
|
return token === "sm" ? 4 : token === "lg" ? 16 : 8;
|
|
})()}
|
|
min={0}
|
|
max={40}
|
|
step={2}
|
|
presets={[
|
|
{ id: "tight", label: "좁게", value: 4 },
|
|
{ id: "normal", label: "보통", value: 8 },
|
|
{ id: "relaxed", label: "넓게", value: 16 },
|
|
]}
|
|
onChangeValue={(v) => {
|
|
updateBlock(selectedBlockId, { gapYPx: v } as any);
|
|
}}
|
|
/>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|