feat(pop-text): POP 텍스트 컴포넌트 추가
- pop-text 컴포넌트 구현 (텍스트/시간/이미지/제목 타입) - PopComponentRegistry에 preview 속성 추가 - ComponentEditorPanel에서 configPanel 동적 렌더링 - PopRenderer에서 preview 컴포넌트 렌더링 지원 - ComponentPalette에 텍스트 컴포넌트 추가 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
40219fed08
commit
2dfc3cc681
|
|
@ -12,6 +12,9 @@ import {
|
|||
} from "@/components/ui/resizable";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// POP 컴포넌트 자동 등록 (반드시 다른 import보다 먼저)
|
||||
import "@/lib/registry/pop-components";
|
||||
|
||||
import PopCanvas from "./PopCanvas";
|
||||
import ComponentEditorPanel from "./panels/ComponentEditorPanel";
|
||||
import ComponentPalette from "./panels/ComponentPalette";
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
|
||||
|
||||
// ========================================
|
||||
// Props
|
||||
|
|
@ -315,6 +316,15 @@ interface ComponentSettingsFormProps {
|
|||
}
|
||||
|
||||
function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormProps) {
|
||||
// PopComponentRegistry에서 configPanel 가져오기
|
||||
const registeredComp = PopComponentRegistry.getComponent(component.type);
|
||||
const ConfigPanel = registeredComp?.configPanel;
|
||||
|
||||
// config 업데이트 핸들러
|
||||
const handleConfigUpdate = (newConfig: any) => {
|
||||
onUpdate?.({ config: newConfig });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 라벨 */}
|
||||
|
|
@ -329,12 +339,19 @@ function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormPro
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* 컴포넌트 타입별 설정 (추후 구현) */}
|
||||
<div className="rounded-lg bg-gray-50 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{component.type} 전용 설정은 Phase 4에서 구현 예정
|
||||
</p>
|
||||
</div>
|
||||
{/* 컴포넌트 타입별 설정 패널 */}
|
||||
{ConfigPanel ? (
|
||||
<ConfigPanel
|
||||
config={component.config || {}}
|
||||
onUpdate={handleConfigUpdate}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg bg-gray-50 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{component.type} 전용 설정이 없습니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useDrag } from "react-dnd";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PopComponentType } from "../types/pop-layout";
|
||||
import { Square } from "lucide-react";
|
||||
import { Square, FileText } from "lucide-react";
|
||||
import { DND_ITEM_TYPES } from "../constants";
|
||||
|
||||
// 컴포넌트 정의
|
||||
|
|
@ -21,6 +21,12 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||
icon: Square,
|
||||
description: "크기 조정 테스트용",
|
||||
},
|
||||
{
|
||||
type: "pop-text",
|
||||
label: "텍스트",
|
||||
icon: FileText,
|
||||
description: "텍스트, 시간, 이미지 표시",
|
||||
},
|
||||
];
|
||||
|
||||
// 드래그 가능한 컴포넌트 아이템
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
isOverlapping,
|
||||
getAllEffectivePositions,
|
||||
} from "../utils/gridUtils";
|
||||
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
|
||||
|
||||
// ========================================
|
||||
// Props
|
||||
|
|
@ -500,7 +501,11 @@ interface ComponentContentProps {
|
|||
function ComponentContent({ component, effectivePosition, isDesignMode, isSelected }: ComponentContentProps) {
|
||||
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
|
||||
|
||||
// 디자인 모드: 플레이스홀더 표시
|
||||
// PopComponentRegistry에서 등록된 컴포넌트 가져오기
|
||||
const registeredComp = PopComponentRegistry.getComponent(component.type);
|
||||
const PreviewComponent = registeredComp?.preview;
|
||||
|
||||
// 디자인 모드: 미리보기 컴포넌트 또는 플레이스홀더 표시
|
||||
if (isDesignMode) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
|
|
@ -519,11 +524,15 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect
|
|||
</span>
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="flex flex-1 items-center justify-center p-2">
|
||||
<span className="text-xs text-gray-400">
|
||||
{typeLabel}
|
||||
</span>
|
||||
{/* 내용: 등록된 preview 컴포넌트 또는 기본 플레이스홀더 */}
|
||||
<div className="flex flex-1 items-center justify-center overflow-hidden">
|
||||
{PreviewComponent ? (
|
||||
<PreviewComponent config={component.config} />
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 p-2">
|
||||
{typeLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 위치 정보 표시 (유효 위치 사용) */}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
/**
|
||||
* POP 컴포넌트 타입
|
||||
*/
|
||||
export type PopComponentType = "pop-sample"; // 테스트용 샘플 박스
|
||||
export type PopComponentType = "pop-sample" | "pop-text"; // 테스트용 샘플 박스, 텍스트 컴포넌트
|
||||
|
||||
/**
|
||||
* 데이터 흐름 정의
|
||||
|
|
@ -341,6 +341,7 @@ export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => {
|
|||
*/
|
||||
export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: number; rowSpan: number }> = {
|
||||
"pop-sample": { colSpan: 2, rowSpan: 1 },
|
||||
"pop-text": { colSpan: 3, rowSpan: 1 },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export interface PopComponentDefinition {
|
|||
icon?: string;
|
||||
component: React.ComponentType<any>;
|
||||
configPanel?: React.ComponentType<any>;
|
||||
preview?: React.ComponentType<{ config?: any }>; // 디자이너 미리보기용
|
||||
defaultProps?: Record<string, any>;
|
||||
// POP 전용 속성
|
||||
touchOptimized?: boolean;
|
||||
|
|
|
|||
|
|
@ -1,24 +1,20 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* POP 컴포넌트 인덱스
|
||||
* POP 컴포넌트 자동 등록 진입점
|
||||
*
|
||||
* POP(모바일/태블릿) 전용 컴포넌트를 export합니다.
|
||||
* 새로운 POP 컴포넌트 추가 시 여기에 export를 추가하세요.
|
||||
* [역할]
|
||||
* - 각 컴포넌트 파일을 import하면 해당 파일 끝의 registerComponent()가 실행되어 자동 등록됨
|
||||
* - 예: import "./pop-text" → pop-text.tsx 실행 → PopComponentRegistry.registerComponent() 호출
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// POP 컴포넌트 목록
|
||||
// ============================================
|
||||
// 4단계에서 추가될 컴포넌트들:
|
||||
// - pop-card-list: 카드형 리스트
|
||||
// - pop-touch-button: 터치 버튼
|
||||
// - pop-scanner-input: 스캐너 입력
|
||||
// - pop-status-badge: 상태 배지
|
||||
// 공통 타입 re-export (외부에서 필요 시 사용 가능)
|
||||
export * from "./types";
|
||||
|
||||
// 예시: 컴포넌트가 추가되면 다음과 같이 export
|
||||
// export * from "./pop-card-list";
|
||||
// export * from "./pop-touch-button";
|
||||
// export * from "./pop-scanner-input";
|
||||
// export * from "./pop-status-badge";
|
||||
// POP 컴포넌트 등록
|
||||
import "./pop-text";
|
||||
|
||||
// 현재는 빈 export (컴포넌트 개발 전)
|
||||
export { };
|
||||
// 향후 추가될 컴포넌트들:
|
||||
// import "./pop-field";
|
||||
// import "./pop-button";
|
||||
// import "./pop-list";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,831 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
|
||||
import {
|
||||
FontSize,
|
||||
FontWeight,
|
||||
TextAlign,
|
||||
ObjectFit,
|
||||
VerticalAlign,
|
||||
FONT_SIZE_LABELS,
|
||||
FONT_WEIGHT_LABELS,
|
||||
OBJECT_FIT_LABELS,
|
||||
FONT_SIZE_CLASSES,
|
||||
FONT_WEIGHT_CLASSES,
|
||||
TEXT_ALIGN_CLASSES,
|
||||
VERTICAL_ALIGN_LABELS,
|
||||
VERTICAL_ALIGN_CLASSES,
|
||||
JUSTIFY_CLASSES,
|
||||
} from "./types";
|
||||
|
||||
// ========================================
|
||||
// 타입 정의
|
||||
// ========================================
|
||||
export type PopTextType = "text" | "datetime" | "image" | "title";
|
||||
|
||||
// datetime 빌더 설정 타입
|
||||
export interface DateTimeBuilderConfig {
|
||||
// 날짜 요소
|
||||
showYear?: boolean;
|
||||
showMonth?: boolean;
|
||||
showDay?: boolean;
|
||||
showWeekday?: boolean;
|
||||
// 시간 요소
|
||||
showHour?: boolean;
|
||||
showMinute?: boolean;
|
||||
showSecond?: boolean;
|
||||
// 표기 방식
|
||||
useKorean?: boolean; // true: 한글 (02월 04일), false: 숫자 (02/04)
|
||||
// 구분자
|
||||
dateSeparator?: string; // "-", "/", "."
|
||||
}
|
||||
|
||||
export interface PopTextConfig {
|
||||
textType: PopTextType;
|
||||
content?: string;
|
||||
dateFormat?: string; // 기존 호환용 (deprecated)
|
||||
dateTimeConfig?: DateTimeBuilderConfig; // 새로운 빌더 설정
|
||||
isRealtime?: boolean;
|
||||
imageUrl?: string;
|
||||
objectFit?: ObjectFit;
|
||||
imageScale?: number; // 이미지 크기 조정 (10-100%)
|
||||
fontSize?: FontSize;
|
||||
fontWeight?: FontWeight;
|
||||
textAlign?: TextAlign;
|
||||
verticalAlign?: VerticalAlign; // 상하 정렬
|
||||
}
|
||||
|
||||
const TEXT_TYPE_LABELS: Record<PopTextType, string> = {
|
||||
text: "일반 텍스트",
|
||||
datetime: "시간/날짜",
|
||||
image: "이미지",
|
||||
title: "제목",
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// datetime 포맷 빌드 함수
|
||||
// ========================================
|
||||
function buildDateTimeFormat(config?: DateTimeBuilderConfig): string {
|
||||
// 설정이 없으면 기본값 (시:분:초)
|
||||
if (!config) return "HH:mm:ss";
|
||||
|
||||
const sep = config.dateSeparator || "-";
|
||||
const parts: string[] = [];
|
||||
|
||||
// 날짜 부분 조합
|
||||
const hasDateParts = config.showYear || config.showMonth || config.showDay;
|
||||
if (hasDateParts) {
|
||||
const dateParts: string[] = [];
|
||||
if (config.showYear) dateParts.push(config.useKorean ? "yyyy년" : "yyyy");
|
||||
if (config.showMonth) dateParts.push(config.useKorean ? "MM월" : "MM");
|
||||
if (config.showDay) dateParts.push(config.useKorean ? "dd일" : "dd");
|
||||
|
||||
// 한글 모드: 공백으로 연결, 숫자 모드: 구분자로 연결
|
||||
parts.push(config.useKorean ? dateParts.join(" ") : dateParts.join(sep));
|
||||
}
|
||||
|
||||
// 요일
|
||||
if (config.showWeekday) {
|
||||
parts.push(config.useKorean ? "(EEEE)" : "(EEE)");
|
||||
}
|
||||
|
||||
// 시간 부분 조합
|
||||
const timeParts: string[] = [];
|
||||
if (config.showHour) timeParts.push(config.useKorean ? "HH시" : "HH");
|
||||
if (config.showMinute) timeParts.push(config.useKorean ? "mm분" : "mm");
|
||||
if (config.showSecond) timeParts.push(config.useKorean ? "ss초" : "ss");
|
||||
|
||||
if (timeParts.length > 0) {
|
||||
// 한글 모드: 공백으로 연결, 숫자 모드: 콜론으로 연결
|
||||
parts.push(config.useKorean ? timeParts.join(" ") : timeParts.join(":"));
|
||||
}
|
||||
|
||||
// 아무것도 선택 안 했으면 기본값
|
||||
return parts.join(" ") || "HH:mm:ss";
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 메인 컴포넌트
|
||||
// ========================================
|
||||
interface PopTextComponentProps {
|
||||
config?: PopTextConfig;
|
||||
label?: string;
|
||||
isDesignMode?: boolean;
|
||||
}
|
||||
|
||||
export function PopTextComponent({
|
||||
config,
|
||||
label,
|
||||
isDesignMode,
|
||||
}: PopTextComponentProps) {
|
||||
const textType = config?.textType || "text";
|
||||
|
||||
if (isDesignMode) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<DesignModePreview config={config} label={label} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 실제 렌더링
|
||||
switch (textType) {
|
||||
case "datetime":
|
||||
return <DateTimeDisplay config={config} />;
|
||||
case "image":
|
||||
return <ImageDisplay config={config} />;
|
||||
case "title":
|
||||
return <TitleDisplay config={config} label={label} />;
|
||||
default:
|
||||
return <TextDisplay config={config} label={label} />;
|
||||
}
|
||||
}
|
||||
|
||||
// 디자인 모드 미리보기 (실제 설정값 표시)
|
||||
function DesignModePreview({
|
||||
config,
|
||||
label,
|
||||
}: {
|
||||
config?: PopTextConfig;
|
||||
label?: string;
|
||||
}) {
|
||||
const textType = config?.textType || "text";
|
||||
|
||||
// 공통 정렬 래퍼 클래스 (상하좌우 정렬)
|
||||
const alignWrapperClass = cn(
|
||||
"flex w-full h-full",
|
||||
VERTICAL_ALIGN_CLASSES[config?.verticalAlign || "center"],
|
||||
JUSTIFY_CLASSES[config?.textAlign || "left"]
|
||||
);
|
||||
|
||||
switch (textType) {
|
||||
case "datetime":
|
||||
// 실시간 시간 미리보기
|
||||
return (
|
||||
<div className={alignWrapperClass}>
|
||||
<DateTimePreview config={config} />
|
||||
</div>
|
||||
);
|
||||
case "image":
|
||||
// 이미지 미리보기
|
||||
if (!config?.imageUrl) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center border border-dashed border-gray-300 text-[10px] text-gray-400">
|
||||
이미지 URL 없음
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 이미지도 정렬 래퍼 적용
|
||||
return (
|
||||
<div className={alignWrapperClass}>
|
||||
<img
|
||||
src={config.imageUrl}
|
||||
alt=""
|
||||
style={{
|
||||
objectFit: config.objectFit || "none",
|
||||
width: `${config.imageScale || 100}%`,
|
||||
height: `${config.imageScale || 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "title":
|
||||
// 제목 미리보기
|
||||
return (
|
||||
<div className={alignWrapperClass}>
|
||||
<span
|
||||
className={cn(
|
||||
"whitespace-pre-wrap",
|
||||
FONT_SIZE_CLASSES[config?.fontSize || "lg"],
|
||||
FONT_WEIGHT_CLASSES[config?.fontWeight || "bold"]
|
||||
)}
|
||||
>
|
||||
{config?.content || label || "제목"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
// 일반 텍스트 미리보기
|
||||
return (
|
||||
<div className={alignWrapperClass}>
|
||||
<span
|
||||
className={cn(
|
||||
"whitespace-pre-wrap",
|
||||
FONT_SIZE_CLASSES[config?.fontSize || "base"]
|
||||
)}
|
||||
>
|
||||
{config?.content || label || "텍스트"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 디자인 모드용 시간 미리보기 (실시간)
|
||||
function DateTimePreview({ config }: { config?: PopTextConfig }) {
|
||||
const [now, setNow] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
// 디자인 모드에서도 실시간 업데이트 (간격 늘림)
|
||||
const timer = setInterval(() => setNow(new Date()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
// 빌더 설정 또는 기존 dateFormat 사용 (하위 호환)
|
||||
const dateFormat = config?.dateTimeConfig
|
||||
? buildDateTimeFormat(config.dateTimeConfig)
|
||||
: config?.dateFormat || "HH:mm:ss";
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-gray-600",
|
||||
FONT_SIZE_CLASSES[config?.fontSize || "base"]
|
||||
)}
|
||||
>
|
||||
{format(now, dateFormat, { locale: ko })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// 시간/날짜 (실시간 지원)
|
||||
function DateTimeDisplay({ config }: { config?: PopTextConfig }) {
|
||||
const [now, setNow] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
if (!config?.isRealtime) return;
|
||||
const timer = setInterval(() => setNow(new Date()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [config?.isRealtime]);
|
||||
|
||||
// 빌더 설정 또는 기존 dateFormat 사용 (하위 호환)
|
||||
const dateFormat = config?.dateTimeConfig
|
||||
? buildDateTimeFormat(config.dateTimeConfig)
|
||||
: config?.dateFormat || "HH:mm:ss";
|
||||
|
||||
// 정렬 래퍼 클래스
|
||||
const alignWrapperClass = cn(
|
||||
"flex w-full h-full",
|
||||
VERTICAL_ALIGN_CLASSES[config?.verticalAlign || "center"],
|
||||
JUSTIFY_CLASSES[config?.textAlign || "left"]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={alignWrapperClass}>
|
||||
<span
|
||||
className={cn("font-mono", FONT_SIZE_CLASSES[config?.fontSize || "base"])}
|
||||
>
|
||||
{format(now, dateFormat, { locale: ko })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 이미지
|
||||
function ImageDisplay({ config }: { config?: PopTextConfig }) {
|
||||
if (!config?.imageUrl) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center border-2 border-dashed text-xs text-gray-400">
|
||||
이미지 URL 필요
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 정렬 래퍼 클래스
|
||||
const alignWrapperClass = cn(
|
||||
"flex w-full h-full",
|
||||
VERTICAL_ALIGN_CLASSES[config?.verticalAlign || "center"],
|
||||
JUSTIFY_CLASSES[config?.textAlign || "left"]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={alignWrapperClass}>
|
||||
<img
|
||||
src={config.imageUrl}
|
||||
alt=""
|
||||
style={{
|
||||
objectFit: config.objectFit || "none",
|
||||
width: `${config?.imageScale || 100}%`,
|
||||
height: `${config?.imageScale || 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 제목
|
||||
function TitleDisplay({
|
||||
config,
|
||||
label,
|
||||
}: {
|
||||
config?: PopTextConfig;
|
||||
label?: string;
|
||||
}) {
|
||||
const sizeClass = FONT_SIZE_CLASSES[config?.fontSize || "base"];
|
||||
const weightClass = FONT_WEIGHT_CLASSES[config?.fontWeight || "normal"];
|
||||
|
||||
// 정렬 래퍼 클래스
|
||||
const alignWrapperClass = cn(
|
||||
"flex w-full h-full",
|
||||
VERTICAL_ALIGN_CLASSES[config?.verticalAlign || "center"],
|
||||
JUSTIFY_CLASSES[config?.textAlign || "left"]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={alignWrapperClass}>
|
||||
<span className={cn("whitespace-pre-wrap", sizeClass, weightClass)}>
|
||||
{config?.content || label || "제목"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 일반 텍스트
|
||||
function TextDisplay({
|
||||
config,
|
||||
label,
|
||||
}: {
|
||||
config?: PopTextConfig;
|
||||
label?: string;
|
||||
}) {
|
||||
const sizeClass = FONT_SIZE_CLASSES[config?.fontSize || "base"];
|
||||
|
||||
// 정렬 래퍼 클래스
|
||||
const alignWrapperClass = cn(
|
||||
"flex w-full h-full",
|
||||
VERTICAL_ALIGN_CLASSES[config?.verticalAlign || "center"],
|
||||
JUSTIFY_CLASSES[config?.textAlign || "left"]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={alignWrapperClass}>
|
||||
<span className={cn("whitespace-pre-wrap", sizeClass)}>
|
||||
{config?.content || label || "텍스트"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 설정 패널
|
||||
// ========================================
|
||||
interface PopTextConfigPanelProps {
|
||||
config: PopTextConfig;
|
||||
onUpdate: (config: PopTextConfig) => void;
|
||||
}
|
||||
|
||||
export function PopTextConfigPanel({
|
||||
config,
|
||||
onUpdate,
|
||||
}: PopTextConfigPanelProps) {
|
||||
const textType = config?.textType || "text";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 텍스트 타입 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">텍스트 타입</Label>
|
||||
<Select
|
||||
value={textType}
|
||||
onValueChange={(v) =>
|
||||
onUpdate({ ...config, textType: v as PopTextType })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(TEXT_TYPE_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key} className="text-xs">
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 서브타입별 설정 */}
|
||||
{textType === "text" && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">내용</Label>
|
||||
<Textarea
|
||||
value={config?.content || ""}
|
||||
onChange={(e) => onUpdate({ ...config, content: e.target.value })}
|
||||
placeholder="여러 줄 입력 가능"
|
||||
rows={3}
|
||||
className="text-xs resize-none"
|
||||
/>
|
||||
</div>
|
||||
<FontSizeSelect config={config} onUpdate={onUpdate} />
|
||||
<AlignmentSelect config={config} onUpdate={onUpdate} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{textType === "datetime" && (
|
||||
<>
|
||||
{/* 포맷 빌더 UI */}
|
||||
<DateTimeFormatBuilder config={config} onUpdate={onUpdate} />
|
||||
|
||||
{/* 실시간 업데이트 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={config?.isRealtime ?? true}
|
||||
onCheckedChange={(v) => onUpdate({ ...config, isRealtime: v })}
|
||||
/>
|
||||
<Label className="text-xs">실시간 업데이트</Label>
|
||||
</div>
|
||||
|
||||
<FontSizeSelect config={config} onUpdate={onUpdate} />
|
||||
<AlignmentSelect config={config} onUpdate={onUpdate} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{textType === "image" && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">이미지 URL</Label>
|
||||
<Input
|
||||
value={config?.imageUrl || ""}
|
||||
onChange={(e) =>
|
||||
onUpdate({ ...config, imageUrl: e.target.value })
|
||||
}
|
||||
placeholder="https://..."
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">맞춤</Label>
|
||||
<Select
|
||||
value={config?.objectFit || "none"}
|
||||
onValueChange={(v) =>
|
||||
onUpdate({ ...config, objectFit: v as ObjectFit })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(OBJECT_FIT_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value} className="text-xs">
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">
|
||||
크기: {config?.imageScale || 100}%
|
||||
</Label>
|
||||
<input
|
||||
type="range"
|
||||
min={10}
|
||||
max={100}
|
||||
step={10}
|
||||
value={config?.imageScale || 100}
|
||||
onChange={(e) =>
|
||||
onUpdate({ ...config, imageScale: Number(e.target.value) })
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<AlignmentSelect config={config} onUpdate={onUpdate} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{textType === "title" && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">제목 텍스트</Label>
|
||||
<Input
|
||||
value={config?.content || ""}
|
||||
onChange={(e) => onUpdate({ ...config, content: e.target.value })}
|
||||
placeholder="제목 입력"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<FontSizeSelect config={config} onUpdate={onUpdate} />
|
||||
<FontWeightSelect config={config} onUpdate={onUpdate} />
|
||||
<AlignmentSelect config={config} onUpdate={onUpdate} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 공통: 글자 크기
|
||||
function FontSizeSelect({ config, onUpdate }: PopTextConfigPanelProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">글자 크기</Label>
|
||||
<Select
|
||||
value={config?.fontSize || "base"}
|
||||
onValueChange={(v) => onUpdate({ ...config, fontSize: v as FontSize })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(FONT_SIZE_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value} className="text-xs">
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 공통: 글자 굵기
|
||||
function FontWeightSelect({ config, onUpdate }: PopTextConfigPanelProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">글자 굵기</Label>
|
||||
<Select
|
||||
value={config?.fontWeight || "normal"}
|
||||
onValueChange={(v) =>
|
||||
onUpdate({ ...config, fontWeight: v as FontWeight })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(FONT_WEIGHT_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value} className="text-xs">
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// datetime 포맷 빌더 UI
|
||||
function DateTimeFormatBuilder({ config, onUpdate }: PopTextConfigPanelProps) {
|
||||
// 기본값 설정 (시:분:초)
|
||||
const dtConfig: DateTimeBuilderConfig = config?.dateTimeConfig || {
|
||||
showHour: true,
|
||||
showMinute: true,
|
||||
showSecond: true,
|
||||
useKorean: false,
|
||||
dateSeparator: "-",
|
||||
};
|
||||
|
||||
// dateTimeConfig 업데이트 헬퍼
|
||||
const updateDtConfig = (updates: Partial<DateTimeBuilderConfig>) => {
|
||||
onUpdate({
|
||||
...config,
|
||||
dateTimeConfig: { ...dtConfig, ...updates },
|
||||
});
|
||||
};
|
||||
|
||||
// 날짜 요소가 하나라도 선택되었는지
|
||||
const hasDateParts = dtConfig.showYear || dtConfig.showMonth || dtConfig.showDay;
|
||||
|
||||
// 미리보기용 포맷 생성
|
||||
const previewFormat = buildDateTimeFormat(dtConfig);
|
||||
const previewText = format(new Date(), previewFormat, { locale: ko });
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 날짜 요소 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">날짜 요소</Label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<label className="flex items-center gap-1.5 text-xs">
|
||||
<Checkbox
|
||||
checked={dtConfig.showYear || false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateDtConfig({ showYear: checked === true })
|
||||
}
|
||||
/>
|
||||
년
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs">
|
||||
<Checkbox
|
||||
checked={dtConfig.showMonth || false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateDtConfig({ showMonth: checked === true })
|
||||
}
|
||||
/>
|
||||
월
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs">
|
||||
<Checkbox
|
||||
checked={dtConfig.showDay || false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateDtConfig({ showDay: checked === true })
|
||||
}
|
||||
/>
|
||||
일
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs">
|
||||
<Checkbox
|
||||
checked={dtConfig.showWeekday || false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateDtConfig({ showWeekday: checked === true })
|
||||
}
|
||||
/>
|
||||
요일
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 시간 요소 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">시간 요소</Label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<label className="flex items-center gap-1.5 text-xs">
|
||||
<Checkbox
|
||||
checked={dtConfig.showHour || false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateDtConfig({ showHour: checked === true })
|
||||
}
|
||||
/>
|
||||
시
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs">
|
||||
<Checkbox
|
||||
checked={dtConfig.showMinute || false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateDtConfig({ showMinute: checked === true })
|
||||
}
|
||||
/>
|
||||
분
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs">
|
||||
<Checkbox
|
||||
checked={dtConfig.showSecond || false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateDtConfig({ showSecond: checked === true })
|
||||
}
|
||||
/>
|
||||
초
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 표기 방식 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">표기 방식</Label>
|
||||
<RadioGroup
|
||||
value={dtConfig.useKorean ? "korean" : "number"}
|
||||
onValueChange={(v) => updateDtConfig({ useKorean: v === "korean" })}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<label className="flex items-center gap-1.5 text-xs">
|
||||
<RadioGroupItem value="number" />
|
||||
숫자 (02/04)
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs">
|
||||
<RadioGroupItem value="korean" />
|
||||
한글 (02월 04일)
|
||||
</label>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 구분자 (숫자 모드 + 날짜 요소가 있을 때만) */}
|
||||
{!dtConfig.useKorean && hasDateParts && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">구분자</Label>
|
||||
<Select
|
||||
value={dtConfig.dateSeparator || "-"}
|
||||
onValueChange={(v) => updateDtConfig({ dateSeparator: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="-" className="text-xs">
|
||||
- (하이픈)
|
||||
</SelectItem>
|
||||
<SelectItem value="/" className="text-xs">
|
||||
/ (슬래시)
|
||||
</SelectItem>
|
||||
<SelectItem value="." className="text-xs">
|
||||
. (점)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 미리보기 */}
|
||||
<div className="rounded border bg-muted/50 p-2">
|
||||
<span className="text-[10px] text-muted-foreground">미리보기: </span>
|
||||
<span className="text-xs font-medium">{previewText}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 공통: 정렬 (좌우 + 상하)
|
||||
function AlignmentSelect({ config, onUpdate }: PopTextConfigPanelProps) {
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
<Label className="text-xs">정렬</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{/* 좌우 정렬 */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">좌/우</span>
|
||||
<Select
|
||||
value={config?.textAlign || "left"}
|
||||
onValueChange={(v) =>
|
||||
onUpdate({ ...config, textAlign: v as TextAlign })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="좌우" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left" className="text-xs">
|
||||
왼쪽
|
||||
</SelectItem>
|
||||
<SelectItem value="center" className="text-xs">
|
||||
가운데
|
||||
</SelectItem>
|
||||
<SelectItem value="right" className="text-xs">
|
||||
오른쪽
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 상하 정렬 */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">상/하</span>
|
||||
<Select
|
||||
value={config?.verticalAlign || "center"}
|
||||
onValueChange={(v) =>
|
||||
onUpdate({ ...config, verticalAlign: v as VerticalAlign })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="상하" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="top" className="text-xs">
|
||||
위
|
||||
</SelectItem>
|
||||
<SelectItem value="center" className="text-xs">
|
||||
가운데
|
||||
</SelectItem>
|
||||
<SelectItem value="bottom" className="text-xs">
|
||||
아래
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 디자이너 미리보기 컴포넌트
|
||||
// ========================================
|
||||
function PopTextPreviewComponent({ config }: { config?: PopTextConfig }) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden">
|
||||
<DesignModePreview config={config} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 레지스트리 등록
|
||||
// ========================================
|
||||
PopComponentRegistry.registerComponent({
|
||||
id: "pop-text",
|
||||
name: "텍스트",
|
||||
description: "텍스트, 시간, 이미지 표시",
|
||||
category: "display",
|
||||
icon: "FileText",
|
||||
component: PopTextComponent,
|
||||
configPanel: PopTextConfigPanel,
|
||||
preview: PopTextPreviewComponent,
|
||||
defaultProps: { textType: "text", fontSize: "base" },
|
||||
touchOptimized: true,
|
||||
supportedDevices: ["mobile", "tablet"],
|
||||
});
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* POP 컴포넌트 공통 타입 정의
|
||||
*/
|
||||
|
||||
// ===== 스타일 관련 공통 타입 =====
|
||||
// ===== 폰트 사이즈, 폰트 굵기, 텍스트 정렬, 이미지 맞춤 방식 정의 =====
|
||||
|
||||
export type FontSize = "xs" | "sm" | "base" | "lg" | "xl";
|
||||
export type FontWeight = "normal" | "medium" | "bold";
|
||||
export type TextAlign = "left" | "center" | "right" | "justify";
|
||||
export type ObjectFit = "contain" | "cover" | "fill" | "none";
|
||||
|
||||
// ===== 라벨 매핑 =====
|
||||
|
||||
export const FONT_SIZE_LABELS: Record<FontSize, string> = {
|
||||
xs: "아주 작게",
|
||||
sm: "작게",
|
||||
base: "보통",
|
||||
lg: "크게",
|
||||
xl: "매우 크게",
|
||||
};
|
||||
|
||||
export const FONT_WEIGHT_LABELS: Record<FontWeight, string> = {
|
||||
normal: "보통",
|
||||
medium: "중간",
|
||||
bold: "굵게",
|
||||
};
|
||||
|
||||
export const TEXT_ALIGN_LABELS: Record<TextAlign, string> = {
|
||||
left: "왼쪽",
|
||||
center: "가운데",
|
||||
right: "오른쪽",
|
||||
justify: "양쪽 정렬",
|
||||
};
|
||||
|
||||
export const OBJECT_FIT_LABELS: Record<ObjectFit, string> = {
|
||||
contain: "비율 유지",
|
||||
cover: "채우기",
|
||||
fill: "늘리기",
|
||||
none: "원본 크기",
|
||||
};
|
||||
|
||||
// ===== Tailwind 클래스 매핑 =====
|
||||
|
||||
// 작게는 Tailwind 기본, 크게는 base(16px) 기준 2배씩: 12px → 14px → 16px → 32px → 64px
|
||||
export const FONT_SIZE_CLASSES: Record<FontSize, string> = {
|
||||
xs: "text-xs",
|
||||
sm: "text-sm",
|
||||
base: "text-base",
|
||||
lg: "text-[32px]",
|
||||
xl: "text-[64px]",
|
||||
};
|
||||
|
||||
export const FONT_WEIGHT_CLASSES: Record<FontWeight, string> = {
|
||||
normal: "font-normal",
|
||||
medium: "font-medium",
|
||||
bold: "font-bold",
|
||||
};
|
||||
|
||||
export const TEXT_ALIGN_CLASSES: Record<TextAlign, string> = {
|
||||
left: "text-left",
|
||||
center: "text-center",
|
||||
right: "text-right",
|
||||
justify: "text-justify",
|
||||
};
|
||||
|
||||
// ===== 상하 정렬 =====
|
||||
|
||||
export type VerticalAlign = "top" | "center" | "bottom";
|
||||
|
||||
export const VERTICAL_ALIGN_LABELS: Record<VerticalAlign, string> = {
|
||||
top: "위",
|
||||
center: "가운데",
|
||||
bottom: "아래",
|
||||
};
|
||||
|
||||
export const VERTICAL_ALIGN_CLASSES: Record<VerticalAlign, string> = {
|
||||
top: "items-start",
|
||||
center: "items-center",
|
||||
bottom: "items-end",
|
||||
};
|
||||
|
||||
// 좌우 정렬 (justify용 - flex 컨테이너에서 사용)
|
||||
export const JUSTIFY_CLASSES: Record<string, string> = {
|
||||
left: "justify-start",
|
||||
center: "justify-center",
|
||||
right: "justify-end",
|
||||
};
|
||||
Loading…
Reference in New Issue