214 lines
8.6 KiB
TypeScript
214 lines
8.6 KiB
TypeScript
import React from "react";
|
|
import DOMPurify from "isomorphic-dompurify";
|
|
import {
|
|
Check, Save, CheckCircle, CircleCheck, FileCheck, ShieldCheck,
|
|
Trash2, Trash, XCircle, X, Eraser, CircleX,
|
|
Pencil, PenLine, Pen, SquarePen, FilePen, PenTool,
|
|
ArrowRight, ExternalLink, MoveRight, Navigation, CornerUpRight, Link,
|
|
Maximize2, PanelTop, AppWindow, LayoutGrid, Layers, FolderOpen,
|
|
SendHorizontal, ArrowRightLeft, Repeat, PackageCheck, Upload, Share2,
|
|
Download, FileDown, FileSpreadsheet, Sheet, Table, FileOutput,
|
|
FileUp, FileInput,
|
|
Zap, Plus, PlusCircle, SquarePlus, FilePlus, BadgePlus,
|
|
Settings, SlidersHorizontal, ToggleLeft, Workflow, GitBranch, Settings2,
|
|
ScanLine, QrCode, Camera, Scan, ScanBarcode, Focus,
|
|
Truck, Car, MapPin, Navigation2, Route, Bell,
|
|
Send, Radio, Megaphone, Podcast, BellRing,
|
|
Copy, ClipboardCopy, Files, CopyPlus, ClipboardList, Clipboard,
|
|
SquareMousePointer,
|
|
type LucideIcon,
|
|
} from "lucide-react";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 아이콘 이름 → 컴포넌트 매핑 (추천 아이콘만 명시적 import)
|
|
// ---------------------------------------------------------------------------
|
|
export const iconMap: Record<string, LucideIcon> = {
|
|
Check, Save, CheckCircle, CircleCheck, FileCheck, ShieldCheck,
|
|
Trash2, Trash, XCircle, X, Eraser, CircleX,
|
|
Pencil, PenLine, Pen, SquarePen, FilePen, PenTool,
|
|
ArrowRight, ExternalLink, MoveRight, Navigation, CornerUpRight, Link,
|
|
Maximize2, PanelTop, AppWindow, LayoutGrid, Layers, FolderOpen,
|
|
SendHorizontal, ArrowRightLeft, Repeat, PackageCheck, Upload, Share2,
|
|
Download, FileDown, FileSpreadsheet, Sheet, Table, FileOutput,
|
|
FileUp, FileInput,
|
|
Zap, Plus, PlusCircle, SquarePlus, FilePlus, BadgePlus,
|
|
Settings, SlidersHorizontal, ToggleLeft, Workflow, GitBranch, Settings2,
|
|
ScanLine, QrCode, Camera, Scan, ScanBarcode, Focus,
|
|
Truck, Car, MapPin, Navigation2, Route, Bell,
|
|
Send, Radio, Megaphone, Podcast, BellRing,
|
|
Copy, ClipboardCopy, Files, CopyPlus, ClipboardList, Clipboard,
|
|
SquareMousePointer,
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 버튼 액션 → 추천 아이콘 이름 매핑
|
|
// ---------------------------------------------------------------------------
|
|
export const actionIconMap: Record<string, string[]> = {
|
|
save: ["Check", "Save", "CheckCircle", "CircleCheck", "FileCheck", "ShieldCheck"],
|
|
delete: ["Trash2", "Trash", "XCircle", "X", "Eraser", "CircleX"],
|
|
edit: ["Pencil", "PenLine", "Pen", "SquarePen", "FilePen", "PenTool"],
|
|
navigate: ["ArrowRight", "ExternalLink", "MoveRight", "Navigation", "CornerUpRight", "Link"],
|
|
modal: ["Maximize2", "PanelTop", "AppWindow", "LayoutGrid", "Layers", "FolderOpen"],
|
|
transferData: ["SendHorizontal", "ArrowRightLeft", "Repeat", "PackageCheck", "Upload", "Share2"],
|
|
excel_download: ["Download", "FileDown", "FileSpreadsheet", "Sheet", "Table", "FileOutput"],
|
|
excel_upload: ["Upload", "FileUp", "FileSpreadsheet", "Sheet", "FileInput", "FileOutput"],
|
|
quickInsert: ["Zap", "Plus", "PlusCircle", "SquarePlus", "FilePlus", "BadgePlus"],
|
|
control: ["Settings", "SlidersHorizontal", "ToggleLeft", "Workflow", "GitBranch", "Settings2"],
|
|
barcode_scan: ["ScanLine", "QrCode", "Camera", "Scan", "ScanBarcode", "Focus"],
|
|
operation_control: ["Truck", "Car", "MapPin", "Navigation2", "Route", "Bell"],
|
|
event: ["Send", "Bell", "Radio", "Megaphone", "Podcast", "BellRing"],
|
|
copy: ["Copy", "ClipboardCopy", "Files", "CopyPlus", "ClipboardList", "Clipboard"],
|
|
};
|
|
|
|
// 아이콘 추천이 불가능한 deprecated/숨김 액션
|
|
export const noIconActions = new Set([
|
|
"openRelatedModal",
|
|
"openModalWithData",
|
|
"view_table_history",
|
|
"code_merge",
|
|
"empty_vehicle",
|
|
]);
|
|
|
|
export const NO_ICON_MESSAGE = "적절한 추천 아이콘이 없습니다. 텍스트 모드를 사용하거나 아래에서 아이콘을 직접 추가하세요.";
|
|
|
|
// 범용 폴백 아이콘 (추천 아이콘이 없는 액션용)
|
|
export const FALLBACK_ICON_NAME = "SquareMousePointer";
|
|
|
|
/** 액션 타입에 대한 디폴트 아이콘(첫 번째 추천)을 반환. 없으면 범용 폴백. */
|
|
export function getDefaultIconForAction(actionType?: string): { name: string; type: "lucide" } {
|
|
if (actionType && actionIconMap[actionType]?.length) {
|
|
return { name: actionIconMap[actionType][0], type: "lucide" };
|
|
}
|
|
return { name: FALLBACK_ICON_NAME, type: "lucide" };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 아이콘 크기 (버튼 높이 대비 비율)
|
|
// ---------------------------------------------------------------------------
|
|
export const iconSizePresets: Record<string, number> = {
|
|
"작게": 40,
|
|
"보통": 55,
|
|
"크게": 70,
|
|
"매우 크게": 85,
|
|
};
|
|
|
|
/** 프리셋 문자열 → 비율(%) 반환. 레거시 값은 55(보통)로 폴백 */
|
|
export function getIconPercent(size: string | number): number {
|
|
if (typeof size === "number") return size;
|
|
return iconSizePresets[size] ?? 55;
|
|
}
|
|
|
|
/** 아이콘 크기를 CSS로 변환 (버튼 높이 대비 비율, 정사각형 유지) */
|
|
export function getIconSizeStyle(size: string | number): React.CSSProperties {
|
|
const pct = getIconPercent(size);
|
|
return { height: `${pct}%`, width: "auto", aspectRatio: "1 / 1" };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 아이콘 조회 / 동적 등록
|
|
// ---------------------------------------------------------------------------
|
|
export function getLucideIcon(name: string): LucideIcon | undefined {
|
|
return iconMap[name];
|
|
}
|
|
|
|
export function addToIconMap(name: string, component: LucideIcon): void {
|
|
iconMap[name] = component;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SVG 정화
|
|
// ---------------------------------------------------------------------------
|
|
export function sanitizeSvg(svgString: string): string {
|
|
return DOMPurify.sanitize(svgString, { USE_PROFILES: { svg: true } });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 버튼 아이콘 렌더러 컴포넌트 (모든 뷰어/위젯에서 공용)
|
|
// ---------------------------------------------------------------------------
|
|
export function ButtonIconRenderer({
|
|
componentConfig,
|
|
fallbackLabel,
|
|
}: {
|
|
componentConfig: any;
|
|
fallbackLabel: string;
|
|
}) {
|
|
const cfg = componentConfig || {};
|
|
const displayMode = cfg.displayMode || "text";
|
|
|
|
if (displayMode === "text" || !cfg.icon?.name) {
|
|
return <>{cfg.text || fallbackLabel}</>;
|
|
}
|
|
|
|
return <>{getButtonDisplayContent(cfg)}</>;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 버튼 표시 콘텐츠 계산 (모든 렌더러 공용)
|
|
// ---------------------------------------------------------------------------
|
|
export function getButtonDisplayContent(componentConfig: any): React.ReactNode {
|
|
const displayMode = componentConfig?.displayMode || "text";
|
|
const text = componentConfig?.text || componentConfig?.label || "버튼";
|
|
const icon = componentConfig?.icon;
|
|
|
|
if (displayMode === "text" || !icon?.name) {
|
|
return text;
|
|
}
|
|
|
|
// 아이콘 노드 생성
|
|
const sizeStyle = getIconSizeStyle(icon.size || "보통");
|
|
const colorStyle: React.CSSProperties = icon.color ? { color: icon.color } : {};
|
|
let iconNode: React.ReactNode = null;
|
|
|
|
if (icon.type === "svg") {
|
|
const svgIcon = componentConfig?.customSvgIcons?.find(
|
|
(s: { name: string; svg: string }) => s.name === icon.name,
|
|
);
|
|
if (svgIcon) {
|
|
const clean = sanitizeSvg(svgIcon.svg);
|
|
iconNode = (
|
|
<span
|
|
className="inline-flex items-center justify-center [&>svg]:h-full [&>svg]:w-full"
|
|
style={{ ...sizeStyle, ...colorStyle }}
|
|
dangerouslySetInnerHTML={{ __html: clean }}
|
|
/>
|
|
);
|
|
}
|
|
} else {
|
|
const IconComponent = getLucideIcon(icon.name);
|
|
if (IconComponent) {
|
|
iconNode = (
|
|
<span className="inline-flex items-center justify-center" style={sizeStyle}>
|
|
<IconComponent className="h-full w-full" style={colorStyle} />
|
|
</span>
|
|
);
|
|
}
|
|
}
|
|
|
|
if (!iconNode) {
|
|
return text;
|
|
}
|
|
|
|
if (displayMode === "icon") {
|
|
return iconNode;
|
|
}
|
|
|
|
// icon-text 모드
|
|
const gap = componentConfig?.iconGap ?? 6;
|
|
const textPos = componentConfig?.iconTextPosition || "right";
|
|
const isVertical = textPos === "top" || textPos === "bottom";
|
|
const textFirst = textPos === "left" || textPos === "top";
|
|
|
|
return (
|
|
<span
|
|
className="inline-flex items-center justify-center"
|
|
style={{
|
|
gap: `${gap}px`,
|
|
flexDirection: isVertical ? "column" : "row",
|
|
}}
|
|
>
|
|
{textFirst ? <span>{text}</span> : iconNode}
|
|
{textFirst ? iconNode : <span>{text}</span>}
|
|
</span>
|
|
);
|
|
}
|