450 lines
16 KiB
TypeScript
450 lines
16 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* TextProperties.tsx — 텍스트/레이블 컴포넌트 설정
|
|
*
|
|
* - section="data": TextLayoutTabs (데이터 바인딩 / 텍스트 서식 / 표시 조건)
|
|
* - section="style": StyleAccordion 패턴 (프리셋 + 폰트 + 색상 + 정렬 + 테두리)
|
|
*/
|
|
|
|
import { useState, useCallback } from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { ChevronRight, Bold, Italic, Underline, Strikethrough, AlignLeft, AlignCenter, AlignRight } from "lucide-react";
|
|
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
|
import { TextLayoutTabs } from "../modals/TextLayoutTabs";
|
|
import type { ComponentConfig } from "@/types/report";
|
|
|
|
const FONT_FAMILIES = [
|
|
"Malgun Gothic",
|
|
"NanumGothic",
|
|
"NanumMyeongjo",
|
|
"굴림",
|
|
"돋움",
|
|
"바탕",
|
|
"Times New Roman",
|
|
"Arial",
|
|
];
|
|
|
|
const TEXT_STYLE_PRESETS = {
|
|
title: {
|
|
label: "제목",
|
|
fontSize: 24,
|
|
fontWeight: "bold",
|
|
fontColor: "#111827",
|
|
lineHeight: 1.3,
|
|
},
|
|
subtitle: {
|
|
label: "부제목",
|
|
fontSize: 16,
|
|
fontWeight: "600",
|
|
fontColor: "#374151",
|
|
lineHeight: 1.4,
|
|
},
|
|
body: {
|
|
label: "본문",
|
|
fontSize: 12,
|
|
fontWeight: "normal",
|
|
fontColor: "#374151",
|
|
lineHeight: 1.6,
|
|
},
|
|
caption: {
|
|
label: "캡션",
|
|
fontSize: 10,
|
|
fontWeight: "normal",
|
|
fontColor: "#6b7280",
|
|
fontStyle: "italic" as const,
|
|
lineHeight: 1.4,
|
|
},
|
|
header: {
|
|
label: "헤더",
|
|
fontSize: 14,
|
|
fontWeight: "bold",
|
|
fontColor: "#1e40af",
|
|
lineHeight: 1.5,
|
|
backgroundColor: "#eff6ff",
|
|
borderWidth: 1,
|
|
borderColor: "#bfdbfe",
|
|
padding: 8,
|
|
},
|
|
footer: {
|
|
label: "푸터",
|
|
fontSize: 9,
|
|
fontWeight: "normal",
|
|
fontColor: "#9ca3af",
|
|
lineHeight: 1.4,
|
|
},
|
|
} as const;
|
|
|
|
interface Props {
|
|
component: ComponentConfig;
|
|
/** 우측 패널: "style" | 모달: "data" | 미전달: 전체 표시 (하위 호환) */
|
|
section?: "style" | "data";
|
|
}
|
|
|
|
function StyleAccordion({
|
|
label,
|
|
isOpen,
|
|
onToggle,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
isOpen: boolean;
|
|
onToggle: () => void;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="border-b border-gray-100">
|
|
<button
|
|
onClick={onToggle}
|
|
className={`flex h-10 w-full items-center justify-between px-3 transition-colors ${
|
|
isOpen
|
|
? "bg-linear-to-r from-blue-600 to-indigo-600 text-white shadow-sm"
|
|
: "bg-white text-gray-900 hover:bg-gray-50"
|
|
}`}
|
|
>
|
|
<span className="text-xs font-bold">{label}</span>
|
|
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${isOpen ? "rotate-90" : "text-gray-400"}`} />
|
|
</button>
|
|
{isOpen && (
|
|
<div className="border-t border-blue-100 bg-linear-to-b from-blue-50/30 to-white p-3">
|
|
{children}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ColorInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
|
return (
|
|
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-2 py-1.5">
|
|
<input
|
|
type="color"
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
className="h-7 w-7 shrink-0 cursor-pointer rounded border-0 p-0"
|
|
/>
|
|
<Input
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
className="h-7 border-0 bg-transparent px-1 font-mono text-xs shadow-none focus-visible:ring-0"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ToggleBtn({
|
|
active,
|
|
onClick,
|
|
children,
|
|
title,
|
|
}: {
|
|
active: boolean;
|
|
onClick: () => void;
|
|
children: React.ReactNode;
|
|
title?: string;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
title={title}
|
|
onClick={onClick}
|
|
className={`flex h-8 w-8 items-center justify-center rounded-md border transition-colors ${
|
|
active
|
|
? "border-blue-300 bg-blue-50 text-blue-700"
|
|
: "border-gray-200 bg-white text-gray-500 hover:bg-gray-50"
|
|
}`}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function TextProperties({ component, section }: Props) {
|
|
const { updateComponent } = useReportDesigner();
|
|
|
|
const showStyle = !section || section === "style";
|
|
const showData = !section || section === "data";
|
|
|
|
const [openSections, setOpenSections] = useState<Set<string>>(new Set(["preset"]));
|
|
const toggleSection = (id: string) => {
|
|
setOpenSections((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const update = useCallback(
|
|
(updates: Partial<ComponentConfig>) => updateComponent(component.id, updates),
|
|
[component.id, updateComponent],
|
|
);
|
|
|
|
const applyPreset = useCallback(
|
|
(key: keyof typeof TEXT_STYLE_PRESETS) => {
|
|
const { label, ...values } = TEXT_STYLE_PRESETS[key];
|
|
update({ ...values, textPreset: key });
|
|
},
|
|
[update],
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{/* 모달(section="data")에서 표시: TextLayoutTabs 3탭 구조 */}
|
|
{showData && <TextLayoutTabs component={component} />}
|
|
|
|
{/* 우측 패널(section="style")에서 표시: StyleAccordion 패턴 */}
|
|
{showStyle && (
|
|
<div className="mt-2 overflow-hidden rounded-lg border border-gray-200">
|
|
{/* 프리셋 */}
|
|
<StyleAccordion label="프리셋" isOpen={openSections.has("preset")} onToggle={() => toggleSection("preset")}>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{(Object.entries(TEXT_STYLE_PRESETS) as [keyof typeof TEXT_STYLE_PRESETS, typeof TEXT_STYLE_PRESETS[keyof typeof TEXT_STYLE_PRESETS]][]).map(
|
|
([key, preset]) => (
|
|
<button
|
|
key={key}
|
|
onClick={() => applyPreset(key)}
|
|
className={`flex flex-col items-center gap-1 rounded-lg border p-2 text-[10px] font-medium transition-all hover:border-blue-300 hover:bg-blue-50/50 ${
|
|
component.textPreset === key
|
|
? "border-blue-400 bg-blue-50 text-blue-700"
|
|
: "border-gray-200 bg-white text-gray-700"
|
|
}`}
|
|
>
|
|
<span
|
|
className="flex w-full items-center justify-center rounded px-1 py-0.5"
|
|
style={{
|
|
fontSize: `${Math.min(preset.fontSize, 16)}px`,
|
|
fontWeight: preset.fontWeight,
|
|
color: preset.fontColor,
|
|
lineHeight: "1.2",
|
|
backgroundColor: ("backgroundColor" in preset) ? (preset as Record<string, unknown>).backgroundColor as string : undefined,
|
|
}}
|
|
>
|
|
Aa
|
|
</span>
|
|
{preset.label}
|
|
</button>
|
|
),
|
|
)}
|
|
</div>
|
|
</StyleAccordion>
|
|
|
|
{/* 폰트 */}
|
|
<StyleAccordion label="폰트" isOpen={openSections.has("font")} onToggle={() => toggleSection("font")}>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs text-gray-500">폰트 패밀리</Label>
|
|
<Select
|
|
value={component.fontFamily || "Malgun Gothic"}
|
|
onValueChange={(v) => update({ fontFamily: v })}
|
|
>
|
|
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{FONT_FAMILIES.map((f) => (
|
|
<SelectItem key={f} value={f} style={{ fontFamily: f }}>{f}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs text-gray-500">크기</Label>
|
|
<Input
|
|
type="number"
|
|
min={6}
|
|
max={120}
|
|
value={component.fontSize || 12}
|
|
onChange={(e) => update({ fontSize: parseInt(e.target.value) || 12 })}
|
|
className="h-9 text-xs"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs text-gray-500">줄 간격</Label>
|
|
<Select
|
|
value={String(component.lineHeight || 1.5)}
|
|
onValueChange={(v) => update({ lineHeight: parseFloat(v) })}
|
|
>
|
|
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="1">1.0</SelectItem>
|
|
<SelectItem value="1.2">1.2</SelectItem>
|
|
<SelectItem value="1.5">1.5</SelectItem>
|
|
<SelectItem value="1.8">1.8</SelectItem>
|
|
<SelectItem value="2">2.0</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs text-gray-500">스타일</Label>
|
|
<div className="flex gap-1.5">
|
|
<ToggleBtn
|
|
active={component.fontWeight === "bold" || component.fontWeight === "700"}
|
|
onClick={() => update({ fontWeight: component.fontWeight === "bold" || component.fontWeight === "700" ? "normal" : "bold" })}
|
|
title="굵게"
|
|
>
|
|
<Bold className="h-3.5 w-3.5" />
|
|
</ToggleBtn>
|
|
<ToggleBtn
|
|
active={component.fontStyle === "italic"}
|
|
onClick={() => update({ fontStyle: component.fontStyle === "italic" ? "normal" : "italic" })}
|
|
title="기울임"
|
|
>
|
|
<Italic className="h-3.5 w-3.5" />
|
|
</ToggleBtn>
|
|
<ToggleBtn
|
|
active={component.textDecoration === "underline"}
|
|
onClick={() => update({ textDecoration: component.textDecoration === "underline" ? "none" : "underline" })}
|
|
title="밑줄"
|
|
>
|
|
<Underline className="h-3.5 w-3.5" />
|
|
</ToggleBtn>
|
|
<ToggleBtn
|
|
active={component.textDecoration === "line-through"}
|
|
onClick={() => update({ textDecoration: component.textDecoration === "line-through" ? "none" : "line-through" })}
|
|
title="취소선"
|
|
>
|
|
<Strikethrough className="h-3.5 w-3.5" />
|
|
</ToggleBtn>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</StyleAccordion>
|
|
|
|
{/* 색상 */}
|
|
<StyleAccordion label="색상" isOpen={openSections.has("color")} onToggle={() => toggleSection("color")}>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs text-gray-500">글자 색상</Label>
|
|
<ColorInput
|
|
value={component.fontColor || "#000000"}
|
|
onChange={(v) => update({ fontColor: v })}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs text-gray-500">배경색</Label>
|
|
<div className="flex items-center gap-2">
|
|
<ColorInput
|
|
value={component.backgroundColor || "#ffffff"}
|
|
onChange={(v) => update({ backgroundColor: v })}
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 shrink-0 text-xs text-gray-400"
|
|
onClick={() => update({ backgroundColor: undefined })}
|
|
>
|
|
초기화
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</StyleAccordion>
|
|
|
|
{/* 정렬 & 간격 */}
|
|
<StyleAccordion label="정렬 & 간격" isOpen={openSections.has("align")} onToggle={() => toggleSection("align")}>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs text-gray-500">수평 정렬</Label>
|
|
<div className="flex gap-1.5">
|
|
<ToggleBtn active={(component.textAlign || "left") === "left"} onClick={() => update({ textAlign: "left" })} title="왼쪽">
|
|
<AlignLeft className="h-3.5 w-3.5" />
|
|
</ToggleBtn>
|
|
<ToggleBtn active={component.textAlign === "center"} onClick={() => update({ textAlign: "center" })} title="가운데">
|
|
<AlignCenter className="h-3.5 w-3.5" />
|
|
</ToggleBtn>
|
|
<ToggleBtn active={component.textAlign === "right"} onClick={() => update({ textAlign: "right" })} title="오른쪽">
|
|
<AlignRight className="h-3.5 w-3.5" />
|
|
</ToggleBtn>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs text-gray-500">자간 (px)</Label>
|
|
<Input
|
|
type="number"
|
|
min={-5}
|
|
max={20}
|
|
step={0.5}
|
|
value={component.letterSpacing ?? 0}
|
|
onChange={(e) => update({ letterSpacing: parseFloat(e.target.value) || 0 })}
|
|
className="h-9 text-xs"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs text-gray-500">안쪽 여백</Label>
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
max={50}
|
|
value={component.padding ?? 0}
|
|
onChange={(e) => update({ padding: parseInt(e.target.value) || 0 })}
|
|
className="h-9 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</StyleAccordion>
|
|
|
|
{/* 테두리 */}
|
|
<StyleAccordion label="테두리" isOpen={openSections.has("border")} onToggle={() => toggleSection("border")}>
|
|
<div className="space-y-3">
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs text-gray-500">두께</Label>
|
|
<Select
|
|
value={String(component.borderWidth ?? 0)}
|
|
onValueChange={(v) => update({ borderWidth: parseInt(v) })}
|
|
>
|
|
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="0">없음</SelectItem>
|
|
<SelectItem value="1">1px</SelectItem>
|
|
<SelectItem value="2">2px</SelectItem>
|
|
<SelectItem value="3">3px</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs text-gray-500">모서리</Label>
|
|
<Select
|
|
value={String(component.borderRadius ?? 0)}
|
|
onValueChange={(v) => update({ borderRadius: parseInt(v) })}
|
|
>
|
|
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="0">없음</SelectItem>
|
|
<SelectItem value="4">4px</SelectItem>
|
|
<SelectItem value="8">8px</SelectItem>
|
|
<SelectItem value="12">12px</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
{(component.borderWidth ?? 0) > 0 && (
|
|
<div>
|
|
<Label className="mb-1.5 block text-xs text-gray-500">색상</Label>
|
|
<ColorInput
|
|
value={component.borderColor || "#d1d5db"}
|
|
onChange={(v) => update({ borderColor: v })}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</StyleAccordion>
|
|
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|