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:
shin 2026-02-06 17:07:56 +09:00
parent 40219fed08
commit 2dfc3cc681
9 changed files with 984 additions and 32 deletions

View File

@ -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";

View File

@ -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>
);
}

View File

@ -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: "텍스트, 시간, 이미지 표시",
},
];
// 드래그 가능한 컴포넌트 아이템

View File

@ -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>
{/* 위치 정보 표시 (유효 위치 사용) */}

View File

@ -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 },
};
/**

View File

@ -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;

View File

@ -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";

View File

@ -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"],
});

View File

@ -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",
};