Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary, ; especially if it merges an updated upstream into a topic branch. ; ; Lines starting with ';' will be ignored, and an empty message aborts ; the commit.
This commit is contained in:
commit
753c170839
|
|
@ -10,8 +10,7 @@ import { useRouter } from "next/navigation";
|
|||
import { toast } from "sonner";
|
||||
import { initializeComponents } from "@/lib/registry/components";
|
||||
import { EditModal } from "@/components/screen/EditModal";
|
||||
import { ResponsiveLayoutEngine } from "@/components/screen/ResponsiveLayoutEngine";
|
||||
import { useBreakpoint } from "@/hooks/useBreakpoint";
|
||||
import { RealtimePreview } from "@/components/screen/RealtimePreviewDynamic";
|
||||
|
||||
export default function ScreenViewPage() {
|
||||
const params = useParams();
|
||||
|
|
@ -22,13 +21,9 @@ export default function ScreenViewPage() {
|
|||
const [layout, setLayout] = useState<LayoutData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
||||
|
||||
// 화면 너비에 따라 Y좌표 유지 여부 결정
|
||||
const [preserveYPosition, setPreserveYPosition] = useState(true);
|
||||
|
||||
const breakpoint = useBreakpoint();
|
||||
|
||||
// 편집 모달 상태
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [editModalConfig, setEditModalConfig] = useState<{
|
||||
|
|
@ -124,24 +119,6 @@ export default function ScreenViewPage() {
|
|||
}
|
||||
}, [screenId]);
|
||||
|
||||
// 윈도우 크기 변경 감지 - layout이 로드된 후에만 실행
|
||||
useEffect(() => {
|
||||
if (!layout) return;
|
||||
|
||||
const screenWidth = layout?.screenResolution?.width || 1200;
|
||||
|
||||
const handleResize = () => {
|
||||
const shouldPreserve = window.innerWidth >= screenWidth - 100;
|
||||
setPreserveYPosition(shouldPreserve);
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
// 초기 값도 설정
|
||||
handleResize();
|
||||
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [layout]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[400px] w-full items-center justify-center bg-gradient-to-br from-gray-50 to-slate-100">
|
||||
|
|
@ -172,39 +149,70 @@ export default function ScreenViewPage() {
|
|||
|
||||
// 화면 해상도 정보가 있으면 해당 크기로, 없으면 기본 크기 사용
|
||||
const screenWidth = layout?.screenResolution?.width || 1200;
|
||||
const screenHeight = layout?.screenResolution?.height || 800;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-white">
|
||||
<div style={{ padding: "16px 0" }}>
|
||||
{/* 항상 반응형 모드로 렌더링 */}
|
||||
{layout && layout.components.length > 0 ? (
|
||||
<ResponsiveLayoutEngine
|
||||
components={layout?.components || []}
|
||||
breakpoint={breakpoint}
|
||||
containerWidth={window.innerWidth}
|
||||
screenWidth={screenWidth}
|
||||
preserveYPosition={preserveYPosition}
|
||||
isDesignMode={false}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName: string, value: unknown) => {
|
||||
console.log("📝 page.tsx formData 업데이트:", fieldName, value);
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
}}
|
||||
screenInfo={{ id: screenId, tableName: screen?.tableName }}
|
||||
/>
|
||||
) : (
|
||||
// 빈 화면일 때
|
||||
<div className="flex items-center justify-center bg-white" style={{ minHeight: "600px" }}>
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-white shadow-sm">
|
||||
<span className="text-2xl">📄</span>
|
||||
</div>
|
||||
<h2 className="mb-2 text-xl font-semibold text-gray-900">화면이 비어있습니다</h2>
|
||||
<p className="text-gray-600">이 화면에는 아직 설계된 컴포넌트가 없습니다.</p>
|
||||
<div className="bg-background h-full w-full">
|
||||
{/* 절대 위치 기반 렌더링 */}
|
||||
{layout && layout.components.length > 0 ? (
|
||||
<div
|
||||
className="bg-background relative mx-auto"
|
||||
style={{
|
||||
width: screenWidth,
|
||||
minHeight: screenHeight,
|
||||
}}
|
||||
>
|
||||
{/* 최상위 컴포넌트들 렌더링 */}
|
||||
{layout.components
|
||||
.filter((component) => !component.parentId)
|
||||
.map((component) => (
|
||||
<RealtimePreview
|
||||
key={component.id}
|
||||
component={component}
|
||||
isSelected={false}
|
||||
isDesignMode={false}
|
||||
onClick={() => {}}
|
||||
>
|
||||
{/* 자식 컴포넌트들 */}
|
||||
{(component.type === "group" || component.type === "container" || component.type === "area") &&
|
||||
layout.components
|
||||
.filter((child) => child.parentId === component.id)
|
||||
.map((child) => {
|
||||
// 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정
|
||||
const relativeChildComponent = {
|
||||
...child,
|
||||
position: {
|
||||
x: child.position.x - component.position.x,
|
||||
y: child.position.y - component.position.y,
|
||||
z: child.position.z || 1,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<RealtimePreview
|
||||
key={child.id}
|
||||
component={relativeChildComponent}
|
||||
isSelected={false}
|
||||
isDesignMode={false}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</RealtimePreview>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// 빈 화면일 때
|
||||
<div className="bg-background flex items-center justify-center" style={{ minHeight: screenHeight }}>
|
||||
<div className="text-center">
|
||||
<div className="bg-muted mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full shadow-sm">
|
||||
<span className="text-2xl">📄</span>
|
||||
</div>
|
||||
<h2 className="text-foreground mb-2 text-xl font-semibold">화면이 비어있습니다</h2>
|
||||
<p className="text-muted-foreground">이 화면에는 아직 설계된 컴포넌트가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 편집 모달 */}
|
||||
<EditModal
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import React, { useState, useCallback, useRef, useEffect } from "react";
|
|||
import dynamic from "next/dynamic";
|
||||
import { DashboardElement, QueryResult, Position } from "./types";
|
||||
import { ChartRenderer } from "./charts/ChartRenderer";
|
||||
import { GRID_CONFIG } from "./gridUtils";
|
||||
import { GRID_CONFIG, magneticSnap, snapSizeToGrid } from "./gridUtils";
|
||||
|
||||
// 위젯 동적 임포트
|
||||
const WeatherWidget = dynamic(() => import("@/components/dashboard/widgets/WeatherWidget"), {
|
||||
|
|
@ -135,6 +135,8 @@ interface CanvasElementProps {
|
|||
cellSize: number;
|
||||
subGridSize: number;
|
||||
canvasWidth?: number;
|
||||
verticalGuidelines: number[];
|
||||
horizontalGuidelines: number[];
|
||||
onUpdate: (id: string, updates: Partial<DashboardElement>) => void;
|
||||
onUpdateMultiple?: (updates: { id: string; updates: Partial<DashboardElement> }[]) => void; // 🔥 다중 업데이트
|
||||
onMultiDragStart?: (draggedId: string, otherOffsets: Record<string, { x: number; y: number }>) => void;
|
||||
|
|
@ -159,6 +161,8 @@ export function CanvasElement({
|
|||
cellSize,
|
||||
subGridSize,
|
||||
canvasWidth = 1560,
|
||||
verticalGuidelines,
|
||||
horizontalGuidelines,
|
||||
onUpdate,
|
||||
onUpdateMultiple,
|
||||
onMultiDragStart,
|
||||
|
|
@ -307,7 +311,7 @@ export function CanvasElement({
|
|||
const deltaX = e.clientX - dragStartRef.current.x;
|
||||
const deltaY = e.clientY - dragStartRef.current.y + scrollDelta; // 🔥 스크롤 변화량 반영
|
||||
|
||||
// 임시 위치 계산
|
||||
// 임시 위치 계산 (드래그 중에는 부드럽게 이동)
|
||||
let rawX = Math.max(0, dragStart.elementX + deltaX);
|
||||
const rawY = Math.max(0, dragStart.elementY + deltaY);
|
||||
|
||||
|
|
@ -315,15 +319,12 @@ export function CanvasElement({
|
|||
const maxX = canvasWidth - element.size.width;
|
||||
rawX = Math.min(rawX, maxX);
|
||||
|
||||
// 드래그 중 실시간 스냅 (서브그리드만 사용)
|
||||
const snappedX = Math.round(rawX / subGridSize) * subGridSize;
|
||||
const snappedY = Math.round(rawY / subGridSize) * subGridSize;
|
||||
|
||||
setTempPosition({ x: snappedX, y: snappedY });
|
||||
// 드래그 중에는 스냅 없이 부드럽게 이동
|
||||
setTempPosition({ x: rawX, y: rawY });
|
||||
|
||||
// 🔥 다중 드래그 중 - 다른 위젯들의 위치 업데이트
|
||||
if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragMove) {
|
||||
onMultiDragMove(element, { x: snappedX, y: snappedY });
|
||||
onMultiDragMove(element, { x: rawX, y: rawY });
|
||||
}
|
||||
} else if (isResizing) {
|
||||
const deltaX = e.clientX - resizeStart.x;
|
||||
|
|
@ -367,15 +368,13 @@ export function CanvasElement({
|
|||
const maxWidth = canvasWidth - newX;
|
||||
newWidth = Math.min(newWidth, maxWidth);
|
||||
|
||||
// 리사이즈 중 실시간 스냅 (서브그리드만 사용)
|
||||
const snappedX = Math.round(newX / subGridSize) * subGridSize;
|
||||
const snappedY = Math.round(newY / subGridSize) * subGridSize;
|
||||
const snappedWidth = Math.round(newWidth / subGridSize) * subGridSize;
|
||||
const snappedHeight = Math.round(newHeight / subGridSize) * subGridSize;
|
||||
// 리사이즈 중에는 스냅 없이 부드럽게 조절
|
||||
const boundedX = Math.max(0, Math.min(newX, canvasWidth - newWidth));
|
||||
const boundedY = Math.max(0, newY);
|
||||
|
||||
// 임시 크기/위치 저장 (스냅됨)
|
||||
setTempPosition({ x: Math.max(0, snappedX), y: Math.max(0, snappedY) });
|
||||
setTempSize({ width: snappedWidth, height: snappedHeight });
|
||||
// 임시 크기/위치 저장 (부드러운 이동)
|
||||
setTempPosition({ x: boundedX, y: boundedY });
|
||||
setTempSize({ width: newWidth, height: newHeight });
|
||||
}
|
||||
},
|
||||
[
|
||||
|
|
@ -386,7 +385,8 @@ export function CanvasElement({
|
|||
element,
|
||||
canvasWidth,
|
||||
cellSize,
|
||||
subGridSize,
|
||||
verticalGuidelines,
|
||||
horizontalGuidelines,
|
||||
selectedElements,
|
||||
allElements,
|
||||
onUpdateMultiple,
|
||||
|
|
@ -398,10 +398,9 @@ export function CanvasElement({
|
|||
// 마우스 업 처리 (이미 스냅된 위치 사용)
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (isDragging && tempPosition) {
|
||||
// tempPosition은 이미 드래그 중에 마그네틱 스냅 적용됨
|
||||
// 다시 스냅하지 않고 그대로 사용!
|
||||
let finalX = tempPosition.x;
|
||||
const finalY = tempPosition.y;
|
||||
// 마우스를 놓을 때 그리드에 스냅
|
||||
let finalX = magneticSnap(tempPosition.x, verticalGuidelines);
|
||||
const finalY = magneticSnap(tempPosition.y, horizontalGuidelines);
|
||||
|
||||
// X 좌표가 캔버스 너비를 벗어나지 않도록 최종 제한
|
||||
const maxX = canvasWidth - element.size.width;
|
||||
|
|
@ -459,20 +458,19 @@ export function CanvasElement({
|
|||
}
|
||||
|
||||
if (isResizing && tempPosition && tempSize) {
|
||||
// tempPosition과 tempSize는 이미 리사이즈 중에 마그네틱 스냅 적용됨
|
||||
// 다시 스냅하지 않고 그대로 사용!
|
||||
const finalX = tempPosition.x;
|
||||
const finalY = tempPosition.y;
|
||||
let finalWidth = tempSize.width;
|
||||
const finalHeight = tempSize.height;
|
||||
// 마우스를 놓을 때 그리드에 스냅
|
||||
const finalX = magneticSnap(tempPosition.x, verticalGuidelines);
|
||||
const finalY = magneticSnap(tempPosition.y, horizontalGuidelines);
|
||||
const finalWidth = snapSizeToGrid(tempSize.width, canvasWidth || 1560);
|
||||
const finalHeight = snapSizeToGrid(tempSize.height, canvasWidth || 1560);
|
||||
|
||||
// 가로 너비가 캔버스를 벗어나지 않도록 최종 제한
|
||||
const maxWidth = canvasWidth - finalX;
|
||||
finalWidth = Math.min(finalWidth, maxWidth);
|
||||
const boundedWidth = Math.min(finalWidth, maxWidth);
|
||||
|
||||
onUpdate(element.id, {
|
||||
position: { x: finalX, y: finalY },
|
||||
size: { width: finalWidth, height: finalHeight },
|
||||
size: { width: boundedWidth, height: finalHeight },
|
||||
});
|
||||
|
||||
setTempPosition(null);
|
||||
|
|
@ -504,6 +502,8 @@ export function CanvasElement({
|
|||
allElements,
|
||||
dragStart.elementX,
|
||||
dragStart.elementY,
|
||||
verticalGuidelines,
|
||||
horizontalGuidelines,
|
||||
]);
|
||||
|
||||
// 🔥 자동 스크롤 루프 (requestAnimationFrame 사용)
|
||||
|
|
@ -891,12 +891,7 @@ export function CanvasElement({
|
|||
) : element.type === "widget" && element.subtype === "list" ? (
|
||||
// 리스트 위젯 렌더링
|
||||
<div className="h-full w-full">
|
||||
<ListWidget
|
||||
element={element}
|
||||
onConfigUpdate={(newConfig) => {
|
||||
onUpdate(element.id, { listConfig: newConfig as any });
|
||||
}}
|
||||
/>
|
||||
<ListWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "yard-management-3d" ? (
|
||||
// 야드 관리 3D 위젯 렌더링
|
||||
|
|
|
|||
|
|
@ -3,7 +3,15 @@
|
|||
import React, { forwardRef, useState, useCallback, useMemo, useEffect } from "react";
|
||||
import { DashboardElement, ElementType, ElementSubtype, DragData } from "./types";
|
||||
import { CanvasElement } from "./CanvasElement";
|
||||
import { GRID_CONFIG, snapToGrid, calculateGridConfig } from "./gridUtils";
|
||||
import {
|
||||
GRID_CONFIG,
|
||||
snapToGrid,
|
||||
calculateGridConfig,
|
||||
calculateVerticalGuidelines,
|
||||
calculateHorizontalGuidelines,
|
||||
calculateBoxSize,
|
||||
magneticSnap,
|
||||
} from "./gridUtils";
|
||||
import { resolveAllCollisions } from "./collisionUtils";
|
||||
|
||||
interface DashboardCanvasProps {
|
||||
|
|
@ -40,14 +48,14 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
onSelectElement,
|
||||
onSelectMultiple,
|
||||
onConfigureElement,
|
||||
backgroundColor = "#f9fafb",
|
||||
backgroundColor = "transparent",
|
||||
canvasWidth = 1560,
|
||||
canvasHeight = 768,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
|
||||
// 🔥 선택 박스 상태
|
||||
const [selectionBox, setSelectionBox] = useState<{
|
||||
startX: number;
|
||||
|
|
@ -58,10 +66,10 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
const [isSelecting, setIsSelecting] = useState(false);
|
||||
const [justSelected, setJustSelected] = useState(false); // 🔥 방금 선택했는지 플래그
|
||||
const [isDraggingAny, setIsDraggingAny] = useState(false); // 🔥 현재 드래그 중인지 플래그
|
||||
|
||||
|
||||
// 🔥 다중 선택된 위젯들의 임시 위치 (드래그 중 시각적 피드백)
|
||||
const [multiDragOffsets, setMultiDragOffsets] = useState<Record<string, { x: number; y: number }>>({});
|
||||
|
||||
|
||||
// 🔥 선택 박스 드래그 중 자동 스크롤
|
||||
const lastMouseYForSelectionRef = React.useRef<number>(window.innerHeight / 2);
|
||||
const selectionAutoScrollFrameRef = React.useRef<number | null>(null);
|
||||
|
|
@ -70,6 +78,14 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
const gridConfig = useMemo(() => calculateGridConfig(canvasWidth), [canvasWidth]);
|
||||
const cellSize = gridConfig.CELL_SIZE;
|
||||
|
||||
// 🔥 그리드 박스 시스템 - 12개 박스가 캔버스 너비에 꽉 차게
|
||||
const verticalGuidelines = useMemo(() => calculateVerticalGuidelines(canvasWidth), [canvasWidth]);
|
||||
const horizontalGuidelines = useMemo(
|
||||
() => calculateHorizontalGuidelines(canvasHeight, canvasWidth),
|
||||
[canvasHeight, canvasWidth],
|
||||
);
|
||||
const boxSize = useMemo(() => calculateBoxSize(canvasWidth), [canvasWidth]);
|
||||
|
||||
// 충돌 방지 기능이 포함된 업데이트 핸들러
|
||||
const handleUpdateWithCollisionDetection = useCallback(
|
||||
(id: string, updates: Partial<DashboardElement>) => {
|
||||
|
|
@ -177,23 +193,13 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
const rawX = e.clientX - rect.left + (ref.current?.scrollLeft || 0);
|
||||
const rawY = e.clientY - rect.top + (ref.current?.scrollTop || 0);
|
||||
|
||||
// 마그네틱 스냅 (큰 그리드 우선, 없으면 서브그리드)
|
||||
const gridSize = cellSize + GRID_CONFIG.GAP; // GAP 포함한 실제 그리드 크기
|
||||
const magneticThreshold = 15;
|
||||
// 자석 스냅 적용
|
||||
let snappedX = magneticSnap(rawX, verticalGuidelines);
|
||||
let snappedY = magneticSnap(rawY, horizontalGuidelines);
|
||||
|
||||
// X 좌표 스냅
|
||||
const nearestGridX = Math.round(rawX / gridSize) * gridSize;
|
||||
const distToGridX = Math.abs(rawX - nearestGridX);
|
||||
let snappedX = distToGridX <= magneticThreshold ? nearestGridX : Math.round(rawX / subGridSize) * subGridSize;
|
||||
|
||||
// Y 좌표 스냅
|
||||
const nearestGridY = Math.round(rawY / gridSize) * gridSize;
|
||||
const distToGridY = Math.abs(rawY - nearestGridY);
|
||||
const snappedY =
|
||||
distToGridY <= magneticThreshold ? nearestGridY : Math.round(rawY / subGridSize) * subGridSize;
|
||||
|
||||
// X 좌표가 캔버스 너비를 벗어나지 않도록 제한
|
||||
const maxX = canvasWidth - cellSize * 2; // 최소 2칸 너비 보장
|
||||
// X 좌표가 캔버스 너비를 벗어나지 않도록 제한 (최소 2칸 너비 보장)
|
||||
const minElementWidth = cellSize * 2 + GRID_CONFIG.GAP;
|
||||
const maxX = canvasWidth - minElementWidth;
|
||||
snappedX = Math.max(0, Math.min(snappedX, maxX));
|
||||
|
||||
onCreateElement(dragData.type, dragData.subtype, snappedX, snappedY);
|
||||
|
|
@ -201,7 +207,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
// 드롭 데이터 파싱 오류 무시
|
||||
}
|
||||
},
|
||||
[ref, onCreateElement, canvasWidth, cellSize],
|
||||
[ref, onCreateElement, canvasWidth, cellSize, verticalGuidelines, horizontalGuidelines],
|
||||
);
|
||||
|
||||
// 🔥 선택 박스 드래그 시작
|
||||
|
|
@ -210,14 +216,14 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
// 🔥 위젯 내부 클릭이 아닌 경우만 (data-element-id가 없는 경우)
|
||||
const target = e.target as HTMLElement;
|
||||
const isWidget = target.closest("[data-element-id]");
|
||||
|
||||
|
||||
if (isWidget) {
|
||||
// console.log("🚫 위젯 내부 클릭 - 선택 박스 시작 안함");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// console.log("✅ 빈 공간 클릭 - 선택 박스 시작");
|
||||
|
||||
|
||||
if (!ref || typeof ref === "function") return;
|
||||
const rect = ref.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
|
@ -274,20 +280,20 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
|
||||
// 겹치는 영역의 넓이
|
||||
const overlapArea = (overlapRight - overlapLeft) * (overlapBottom - overlapTop);
|
||||
|
||||
|
||||
// 요소의 전체 넓이
|
||||
const elementArea = el.size.width * el.size.height;
|
||||
|
||||
// 70% 이상 겹치면 선택
|
||||
const overlapPercentage = overlapArea / elementArea;
|
||||
|
||||
|
||||
// console.log(`📦 요소 ${el.id}:`, {
|
||||
// position: el.position,
|
||||
// size: el.size,
|
||||
// overlapPercentage: (overlapPercentage * 100).toFixed(1) + "%",
|
||||
// selected: overlapPercentage >= 0.7,
|
||||
// });
|
||||
|
||||
|
||||
return overlapPercentage >= 0.7;
|
||||
})
|
||||
.map((el) => el.id);
|
||||
|
|
@ -327,9 +333,9 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
if (!isSelecting) {
|
||||
const deltaX = Math.abs(x - selectionBox.startX);
|
||||
const deltaY = Math.abs(y - selectionBox.startY);
|
||||
|
||||
|
||||
// console.log("📏 이동 거리:", { deltaX, deltaY });
|
||||
|
||||
|
||||
// 🔥 5px 이상 움직이면 선택 박스 활성화 (위젯 드래그와 구분)
|
||||
if (deltaX > 5 || deltaY > 5) {
|
||||
// console.log("🎯 선택 박스 활성화 (5px 이상 이동)");
|
||||
|
|
@ -374,10 +380,10 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
const autoScrollLoop = (currentTime: number) => {
|
||||
const viewportHeight = window.innerHeight;
|
||||
const lastMouseY = lastMouseYForSelectionRef.current;
|
||||
|
||||
|
||||
let shouldScroll = false;
|
||||
let scrollDirection = 0;
|
||||
|
||||
|
||||
if (lastMouseY < scrollThreshold) {
|
||||
shouldScroll = true;
|
||||
scrollDirection = -scrollSpeed;
|
||||
|
|
@ -387,9 +393,9 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
scrollDirection = scrollSpeed;
|
||||
// console.log("⬇️ 아래로 스크롤 (선택 박스):", { lastMouseY, boundary: viewportHeight - scrollThreshold });
|
||||
}
|
||||
|
||||
|
||||
const deltaTime = currentTime - lastTime;
|
||||
|
||||
|
||||
if (shouldScroll && deltaTime >= 10) {
|
||||
window.scrollBy(0, scrollDirection);
|
||||
// console.log("✅ 스크롤 실행 (선택 박스):", { scrollDirection, deltaTime });
|
||||
|
|
@ -418,7 +424,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
// console.log("🚫 방금 선택했거나 드래그 중이므로 클릭 이벤트 무시");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (e.target === e.currentTarget) {
|
||||
// console.log("✅ 빈 공간 클릭 - 선택 해제");
|
||||
onSelectElement(null);
|
||||
|
|
@ -433,7 +439,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
// 동적 그리드 크기 계산
|
||||
const cellWithGap = cellSize + GRID_CONFIG.GAP;
|
||||
const gridSize = `${cellWithGap}px ${cellWithGap}px`;
|
||||
|
||||
|
||||
// 서브그리드 크기 계산 (gridConfig에서 정확하게 계산된 값 사용)
|
||||
const subGridSize = gridConfig.SUB_GRID_SIZE;
|
||||
|
||||
|
|
@ -443,7 +449,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
// 🔥 선택 박스 스타일 계산
|
||||
const selectionBoxStyle = useMemo(() => {
|
||||
if (!selectionBox) return null;
|
||||
|
||||
|
||||
const minX = Math.min(selectionBox.startX, selectionBox.endX);
|
||||
const maxX = Math.max(selectionBox.startX, selectionBox.endX);
|
||||
const minY = Math.min(selectionBox.startY, selectionBox.endY);
|
||||
|
|
@ -460,19 +466,11 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`relative w-full rounded-lg shadow-inner ${isDragOver ? "bg-blue-50/50" : ""} `}
|
||||
className={`relative w-full ${isDragOver ? "bg-blue-50/50" : ""} `}
|
||||
style={{
|
||||
backgroundColor,
|
||||
height: `${canvasHeight}px`,
|
||||
minHeight: `${canvasHeight}px`,
|
||||
// 서브그리드 배경 (세밀한 점선)
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(59, 130, 246, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(59, 130, 246, 0.05) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: `${subGridSize}px ${subGridSize}px`,
|
||||
backgroundPosition: "0 0",
|
||||
backgroundRepeat: "repeat",
|
||||
cursor: isSelecting ? "crosshair" : "default",
|
||||
}}
|
||||
onDragOver={handleDragOver}
|
||||
|
|
@ -494,6 +492,24 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
}}
|
||||
/>
|
||||
))} */}
|
||||
{/* 그리드 박스들 (12px 간격, 캔버스 너비에 꽉 차게, 마지막 행 제외) */}
|
||||
{verticalGuidelines.map((x, xIdx) =>
|
||||
horizontalGuidelines.slice(0, -1).map((y, yIdx) => (
|
||||
<div
|
||||
key={`grid-box-${xIdx}-${yIdx}`}
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
width: `${boxSize}px`,
|
||||
height: `${boxSize}px`,
|
||||
backgroundColor: "#ffffff",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
{/* 배치된 요소들 렌더링 */}
|
||||
{elements.length === 0 && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-gray-400">
|
||||
|
|
@ -513,6 +529,8 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
cellSize={cellSize}
|
||||
subGridSize={subGridSize}
|
||||
canvasWidth={canvasWidth}
|
||||
verticalGuidelines={verticalGuidelines}
|
||||
horizontalGuidelines={horizontalGuidelines}
|
||||
onUpdate={handleUpdateWithCollisionDetection}
|
||||
onUpdateMultiple={(updates) => {
|
||||
// 🔥 여러 요소 동시 업데이트 (충돌 감지 건너뛰기)
|
||||
|
|
@ -552,10 +570,9 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
}}
|
||||
onRemove={onRemoveElement}
|
||||
onSelect={onSelectElement}
|
||||
onConfigure={onConfigureElement}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
{/* 🔥 선택 박스 렌더링 */}
|
||||
{selectionBox && selectionBoxStyle && (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -7,7 +7,14 @@ import { DashboardTopMenu } from "./DashboardTopMenu";
|
|||
import { ElementConfigSidebar } from "./ElementConfigSidebar";
|
||||
import { DashboardSaveModal } from "./DashboardSaveModal";
|
||||
import { DashboardElement, ElementType, ElementSubtype } from "./types";
|
||||
import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateGridConfig } from "./gridUtils";
|
||||
import {
|
||||
GRID_CONFIG,
|
||||
snapToGrid,
|
||||
snapSizeToGrid,
|
||||
calculateCellSize,
|
||||
calculateGridConfig,
|
||||
calculateBoxSize,
|
||||
} from "./gridUtils";
|
||||
import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector";
|
||||
import { DashboardProvider } from "@/contexts/DashboardContext";
|
||||
import { useMenu } from "@/contexts/MenuContext";
|
||||
|
|
@ -47,7 +54,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
const [dashboardId, setDashboardId] = useState<string | null>(initialDashboardId || null);
|
||||
const [dashboardTitle, setDashboardTitle] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [canvasBackgroundColor, setCanvasBackgroundColor] = useState<string>("#f9fafb");
|
||||
const [canvasBackgroundColor, setCanvasBackgroundColor] = useState<string>("transparent");
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 저장 모달 상태
|
||||
|
|
@ -65,7 +72,11 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
|
||||
// 화면 해상도 자동 감지
|
||||
const [screenResolution] = useState<Resolution>(() => detectScreenResolution());
|
||||
const [resolution, setResolution] = useState<Resolution>(screenResolution);
|
||||
const [resolution, setResolution] = useState<Resolution>(() => {
|
||||
// 새 대시보드인 경우 (dashboardId 없음) 화면 해상도 감지값 사용
|
||||
// 기존 대시보드 편집인 경우 FHD로 시작 (로드 시 덮어씀)
|
||||
return initialDashboardId ? "fhd" : detectScreenResolution();
|
||||
});
|
||||
|
||||
// resolution 변경 감지 및 요소 자동 조정
|
||||
const handleResolutionChange = useCallback(
|
||||
|
|
@ -89,8 +100,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
// 그리드에 스냅 (X, Y, 너비, 높이 모두)
|
||||
const snappedX = snapToGrid(scaledX, newCellSize);
|
||||
const snappedY = snapToGrid(el.position.y, newCellSize);
|
||||
const snappedWidth = snapSizeToGrid(scaledWidth, 2, newCellSize);
|
||||
const snappedHeight = snapSizeToGrid(el.size.height, 2, newCellSize);
|
||||
const snappedWidth = snapSizeToGrid(scaledWidth, newConfig.width);
|
||||
const snappedHeight = snapSizeToGrid(el.size.height, newConfig.width);
|
||||
|
||||
return {
|
||||
...el,
|
||||
|
|
@ -136,8 +147,12 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
// 대시보드 ID가 props로 전달되면 로드
|
||||
React.useEffect(() => {
|
||||
if (initialDashboardId) {
|
||||
console.log("📝 기존 대시보드 편집 모드");
|
||||
loadDashboard(initialDashboardId);
|
||||
} else {
|
||||
console.log("✨ 새 대시보드 생성 모드 - 감지된 해상도:", resolution);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialDashboardId]);
|
||||
|
||||
// 대시보드 데이터 로드
|
||||
|
|
@ -164,23 +179,19 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
settings,
|
||||
resolution: settings?.resolution,
|
||||
backgroundColor: settings?.backgroundColor,
|
||||
currentResolution: resolution,
|
||||
});
|
||||
|
||||
if (settings?.resolution) {
|
||||
setResolution(settings.resolution);
|
||||
console.log("✅ Resolution 설정됨:", settings.resolution);
|
||||
} else {
|
||||
console.log("⚠️ Resolution 없음, 기본값 유지:", resolution);
|
||||
}
|
||||
|
||||
// 배경색 설정
|
||||
if (settings?.backgroundColor) {
|
||||
setCanvasBackgroundColor(settings.backgroundColor);
|
||||
console.log("✅ BackgroundColor 설정됨:", settings.backgroundColor);
|
||||
} else {
|
||||
console.log("⚠️ BackgroundColor 없음, 기본값 유지:", canvasBackgroundColor);
|
||||
}
|
||||
|
||||
// 해상도와 요소를 함께 설정 (해상도가 먼저 반영되어야 함)
|
||||
const loadedResolution = settings?.resolution || "fhd";
|
||||
setResolution(loadedResolution);
|
||||
console.log("✅ Resolution 설정됨:", loadedResolution);
|
||||
|
||||
// 요소들 설정
|
||||
if (dashboard.elements && dashboard.elements.length > 0) {
|
||||
setElements(dashboard.elements);
|
||||
|
|
@ -215,22 +226,25 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
return;
|
||||
}
|
||||
|
||||
// 기본 크기 설정 (서브그리드 기준)
|
||||
const gridConfig = calculateGridConfig(canvasConfig.width);
|
||||
const subGridSize = gridConfig.SUB_GRID_SIZE;
|
||||
|
||||
// 서브그리드 기준 기본 크기 (픽셀)
|
||||
let defaultWidth = subGridSize * 10; // 기본 위젯: 서브그리드 10칸
|
||||
let defaultHeight = subGridSize * 10; // 기본 위젯: 서브그리드 10칸
|
||||
// 기본 크기 설정 (그리드 박스 단위)
|
||||
const boxSize = calculateBoxSize(canvasConfig.width);
|
||||
|
||||
// 그리드 박스 단위 기본 크기
|
||||
let boxesWidth = 3; // 기본 위젯: 박스 3개
|
||||
let boxesHeight = 3; // 기본 위젯: 박스 3개
|
||||
|
||||
if (type === "chart") {
|
||||
defaultWidth = subGridSize * 20; // 차트: 서브그리드 20칸
|
||||
defaultHeight = subGridSize * 15; // 차트: 서브그리드 15칸
|
||||
boxesWidth = 4; // 차트: 박스 4개
|
||||
boxesHeight = 3; // 차트: 박스 3개
|
||||
} else if (type === "widget" && subtype === "calendar") {
|
||||
defaultWidth = subGridSize * 10; // 달력: 서브그리드 10칸
|
||||
defaultHeight = subGridSize * 15; // 달력: 서브그리드 15칸
|
||||
boxesWidth = 3; // 달력: 박스 3개
|
||||
boxesHeight = 4; // 달력: 박스 4개
|
||||
}
|
||||
|
||||
// 박스 개수를 픽셀로 변환 (마지막 간격 제거)
|
||||
const defaultWidth = boxesWidth * boxSize + (boxesWidth - 1) * GRID_CONFIG.GRID_BOX_GAP;
|
||||
const defaultHeight = boxesHeight * boxSize + (boxesHeight - 1) * GRID_CONFIG.GRID_BOX_GAP;
|
||||
|
||||
// 크기 유효성 검사
|
||||
if (isNaN(defaultWidth) || isNaN(defaultHeight) || defaultWidth <= 0 || defaultHeight <= 0) {
|
||||
// console.error("Invalid size calculated:", {
|
||||
|
|
@ -422,8 +436,15 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
id: el.id,
|
||||
type: el.type,
|
||||
subtype: el.subtype,
|
||||
position: el.position,
|
||||
size: el.size,
|
||||
// 위치와 크기는 정수로 반올림 (DB integer 타입)
|
||||
position: {
|
||||
x: Math.round(el.position.x),
|
||||
y: Math.round(el.position.y),
|
||||
},
|
||||
size: {
|
||||
width: Math.round(el.size.width),
|
||||
height: Math.round(el.size.height),
|
||||
},
|
||||
title: el.title,
|
||||
customTitle: el.customTitle,
|
||||
showHeader: el.showHeader,
|
||||
|
|
@ -449,6 +470,12 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
},
|
||||
};
|
||||
|
||||
console.log("💾 대시보드 업데이트 요청:", {
|
||||
dashboardId,
|
||||
updateData,
|
||||
elementsCount: elementsData.length,
|
||||
});
|
||||
|
||||
savedDashboard = await dashboardApi.updateDashboard(dashboardId, updateData);
|
||||
} else {
|
||||
// 새 대시보드 생성
|
||||
|
|
@ -509,7 +536,18 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
// 성공 모달 표시
|
||||
setSuccessModalOpen(true);
|
||||
} catch (error) {
|
||||
console.error("❌ 대시보드 저장 실패:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류";
|
||||
|
||||
// 상세한 에러 정보 로깅
|
||||
if (error instanceof Error) {
|
||||
console.error("Error details:", {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
name: error.name,
|
||||
});
|
||||
}
|
||||
|
||||
alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}`);
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -550,7 +588,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
{/* overflow-auto 제거 - 외부 페이지 스크롤 사용 */}
|
||||
<div className="flex flex-1 items-start justify-center bg-gray-100 p-8">
|
||||
<div
|
||||
className="relative shadow-2xl"
|
||||
className="relative"
|
||||
style={{
|
||||
width: `${canvasConfig.width}px`,
|
||||
minHeight: `${canvasConfig.height}px`,
|
||||
|
|
|
|||
|
|
@ -54,14 +54,55 @@ interface ResolutionSelectorProps {
|
|||
export function detectScreenResolution(): Resolution {
|
||||
if (typeof window === "undefined") return "fhd";
|
||||
|
||||
const width = window.screen.width;
|
||||
const height = window.screen.height;
|
||||
// 1. 브라우저 뷰포트 크기 (실제 사용 가능한 공간)
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// 화면 해상도에 따라 적절한 캔버스 해상도 반환
|
||||
if (width >= 3840 || height >= 2160) return "uhd";
|
||||
if (width >= 2560 || height >= 1440) return "qhd";
|
||||
if (width >= 1920 || height >= 1080) return "fhd";
|
||||
return "hd";
|
||||
// 2. 화면 해상도 + devicePixelRatio (Retina 디스플레이 대응)
|
||||
const pixelRatio = window.devicePixelRatio || 1;
|
||||
const physicalWidth = window.screen.width;
|
||||
const physicalHeight = window.screen.height;
|
||||
const logicalWidth = physicalWidth / pixelRatio;
|
||||
const logicalHeight = physicalHeight / pixelRatio;
|
||||
|
||||
let detectedResolution: Resolution;
|
||||
|
||||
// 뷰포트와 논리적 해상도 중 더 큰 값을 기준으로 결정
|
||||
// (크램쉘 모드나 특수한 경우에도 대응)
|
||||
const effectiveWidth = Math.max(viewportWidth, logicalWidth);
|
||||
const effectiveHeight = Math.max(viewportHeight, logicalHeight);
|
||||
|
||||
// 캔버스가 여유있게 들어갈 수 있는 크기로 결정
|
||||
// 여유 공간: 좌우 패딩, 사이드바 등을 고려하여 약 400-500px 여유
|
||||
if (effectiveWidth >= 3400) {
|
||||
// UHD 캔버스 2940px + 여유 460px
|
||||
detectedResolution = "uhd";
|
||||
} else if (effectiveWidth >= 2400) {
|
||||
// QHD 캔버스 1960px + 여유 440px
|
||||
detectedResolution = "qhd";
|
||||
} else if (effectiveWidth >= 1900) {
|
||||
// FHD 캔버스 1560px + 여유 340px
|
||||
detectedResolution = "fhd";
|
||||
} else {
|
||||
// HD 캔버스 1160px 이하
|
||||
detectedResolution = "hd";
|
||||
}
|
||||
|
||||
console.log("🖥️ 화면 해상도 자동 감지:", {
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
physicalWidth,
|
||||
physicalHeight,
|
||||
pixelRatio,
|
||||
logicalWidth: Math.round(logicalWidth),
|
||||
logicalHeight: Math.round(logicalHeight),
|
||||
effectiveWidth: Math.round(effectiveWidth),
|
||||
effectiveHeight: Math.round(effectiveHeight),
|
||||
detectedResolution,
|
||||
canvasSize: RESOLUTIONS[detectedResolution],
|
||||
});
|
||||
|
||||
return detectedResolution;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -12,6 +12,13 @@ export const GRID_CONFIG = {
|
|||
SNAP_THRESHOLD: 10, // 스냅 임계값 (px)
|
||||
ELEMENT_PADDING: 4, // 요소 주위 여백 (px)
|
||||
SUB_GRID_DIVISIONS: 5, // 각 그리드 칸을 5x5로 세분화 (세밀한 조정용)
|
||||
// 가이드라인 시스템
|
||||
GUIDELINE_SPACING: 12, // 가이드라인 간격 (px)
|
||||
SNAP_DISTANCE: 10, // 자석 스냅 거리 (px)
|
||||
GUIDELINE_COLOR: "rgba(59, 130, 246, 0.3)", // 가이드라인 색상
|
||||
ROW_HEIGHT: 96, // 각 행의 높이 (12px * 8 = 96px)
|
||||
GRID_BOX_SIZE: 40, // 그리드 박스 크기 (px) - [ ] 한 칸의 크기
|
||||
GRID_BOX_GAP: 12, // 그리드 박스 간 간격 (px)
|
||||
// CELL_SIZE와 CANVAS_WIDTH는 해상도에 따라 동적 계산
|
||||
} as const;
|
||||
|
||||
|
|
@ -47,9 +54,11 @@ export function calculateGridConfig(canvasWidth: number) {
|
|||
|
||||
/**
|
||||
* 실제 그리드 셀 크기 계산 (gap 포함)
|
||||
* @param canvasWidth - 캔버스 너비
|
||||
*/
|
||||
export const getCellWithGap = () => {
|
||||
return GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP;
|
||||
export const getCellWithGap = (canvasWidth: number = 1560) => {
|
||||
const boxSize = calculateBoxSize(canvasWidth);
|
||||
return boxSize + GRID_CONFIG.GRID_BOX_GAP;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -63,14 +72,14 @@ export const getCanvasWidth = () => {
|
|||
/**
|
||||
* 좌표를 서브 그리드에 스냅 (세밀한 조정 가능)
|
||||
* @param value - 스냅할 좌표값
|
||||
* @param subGridSize - 서브 그리드 크기 (선택사항, 기본값: cellSize/3 ≈ 43px)
|
||||
* @param subGridSize - 서브 그리드 크기 (선택사항)
|
||||
* @returns 스냅된 좌표값
|
||||
*/
|
||||
export const snapToGrid = (value: number, subGridSize?: number): number => {
|
||||
// 서브 그리드 크기가 지정되지 않으면 기본 그리드 크기의 1/3 사용 (3x3 서브그리드)
|
||||
const snapSize = subGridSize ?? Math.floor(GRID_CONFIG.CELL_SIZE / 3);
|
||||
|
||||
// 서브 그리드 단위로 스냅
|
||||
// 서브 그리드 크기가 지정되지 않으면 기본 박스 크기 사용
|
||||
const snapSize = subGridSize ?? calculateBoxSize(1560);
|
||||
|
||||
// 그리드 단위로 스냅
|
||||
const gridIndex = Math.round(value / snapSize);
|
||||
return gridIndex * snapSize;
|
||||
};
|
||||
|
|
@ -81,8 +90,9 @@ export const snapToGrid = (value: number, subGridSize?: number): number => {
|
|||
* @param cellSize - 셀 크기 (선택사항)
|
||||
* @returns 스냅된 좌표값 (임계값 내에 있으면 스냅, 아니면 원래 값)
|
||||
*/
|
||||
export const snapToGridWithThreshold = (value: number, cellSize: number = GRID_CONFIG.CELL_SIZE): number => {
|
||||
const snapped = snapToGrid(value, cellSize);
|
||||
export const snapToGridWithThreshold = (value: number, cellSize?: number): number => {
|
||||
const snapSize = cellSize ?? calculateBoxSize(1560);
|
||||
const snapped = snapToGrid(value, snapSize);
|
||||
const distance = Math.abs(value - snapped);
|
||||
|
||||
return distance <= GRID_CONFIG.SNAP_THRESHOLD ? snapped : value;
|
||||
|
|
@ -95,15 +105,7 @@ export const snapToGridWithThreshold = (value: number, cellSize: number = GRID_C
|
|||
* @param cellSize - 셀 크기 (선택사항)
|
||||
* @returns 스냅된 크기
|
||||
*/
|
||||
export const snapSizeToGrid = (
|
||||
size: number,
|
||||
minCells: number = 2,
|
||||
cellSize: number = GRID_CONFIG.CELL_SIZE,
|
||||
): number => {
|
||||
const cellWithGap = cellSize + GRID_CONFIG.GAP;
|
||||
const cells = Math.max(minCells, Math.round(size / cellWithGap));
|
||||
return cells * cellWithGap - GRID_CONFIG.GAP;
|
||||
};
|
||||
// 기존 snapSizeToGrid 제거 - 새 버전은 269번 줄에 있음
|
||||
|
||||
/**
|
||||
* 위치와 크기를 모두 그리드에 스냅
|
||||
|
|
@ -135,9 +137,10 @@ export const snapBoundsToGrid = (bounds: GridBounds, canvasWidth?: number, canva
|
|||
let snappedX = snapToGrid(bounds.position.x);
|
||||
let snappedY = snapToGrid(bounds.position.y);
|
||||
|
||||
// 크기 스냅
|
||||
const snappedWidth = snapSizeToGrid(bounds.size.width);
|
||||
const snappedHeight = snapSizeToGrid(bounds.size.height);
|
||||
// 크기 스냅 (canvasWidth 기본값 1560)
|
||||
const width = canvasWidth || 1560;
|
||||
const snappedWidth = snapSizeToGrid(bounds.size.width, width);
|
||||
const snappedHeight = snapSizeToGrid(bounds.size.height, width);
|
||||
|
||||
// 캔버스 경계 체크
|
||||
if (canvasWidth) {
|
||||
|
|
@ -198,3 +201,75 @@ export const getNearbyGridLines = (value: number): number[] => {
|
|||
export const isWithinSnapThreshold = (value: number, snapValue: number): boolean => {
|
||||
return Math.abs(value - snapValue) <= GRID_CONFIG.SNAP_THRESHOLD;
|
||||
};
|
||||
|
||||
// 박스 크기 계산 (캔버스 너비에 맞게)
|
||||
export function calculateBoxSize(canvasWidth: number): number {
|
||||
const totalGaps = 11 * GRID_CONFIG.GRID_BOX_GAP; // 12개 박스 사이 간격 11개
|
||||
const availableWidth = canvasWidth - totalGaps;
|
||||
return availableWidth / 12;
|
||||
}
|
||||
|
||||
// 수직 그리드 박스 좌표 계산 (12개, 너비에 꽉 차게)
|
||||
export function calculateVerticalGuidelines(canvasWidth: number): number[] {
|
||||
const lines: number[] = [];
|
||||
const boxSize = calculateBoxSize(canvasWidth);
|
||||
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const x = i * (boxSize + GRID_CONFIG.GRID_BOX_GAP);
|
||||
lines.push(x);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
// 수평 그리드 박스 좌표 계산 (캔버스 너비 기준으로 정사각형 유지)
|
||||
export function calculateHorizontalGuidelines(canvasHeight: number, canvasWidth: number): number[] {
|
||||
const lines: number[] = [];
|
||||
const boxSize = calculateBoxSize(canvasWidth); // 수직과 동일한 박스 크기 사용
|
||||
const cellSize = boxSize + GRID_CONFIG.GRID_BOX_GAP;
|
||||
|
||||
for (let y = 0; y <= canvasHeight; y += cellSize) {
|
||||
lines.push(y);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
// 가장 가까운 가이드라인 찾기
|
||||
export function findNearestGuideline(
|
||||
value: number,
|
||||
guidelines: number[],
|
||||
): {
|
||||
nearest: number;
|
||||
distance: number;
|
||||
} {
|
||||
let nearest = guidelines[0];
|
||||
let minDistance = Math.abs(value - guidelines[0]);
|
||||
|
||||
for (const guideline of guidelines) {
|
||||
const distance = Math.abs(value - guideline);
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
nearest = guideline;
|
||||
}
|
||||
}
|
||||
|
||||
return { nearest, distance: minDistance };
|
||||
}
|
||||
|
||||
// 강제 스냅 (항상 가장 가까운 가이드라인에 스냅)
|
||||
export function magneticSnap(value: number, guidelines: number[]): number {
|
||||
const { nearest } = findNearestGuideline(value, guidelines);
|
||||
return nearest; // 거리 체크 없이 무조건 스냅
|
||||
}
|
||||
|
||||
// 크기를 그리드 박스 단위로 스냅 (박스 크기의 배수로만 가능)
|
||||
export function snapSizeToGrid(size: number, canvasWidth: number): number {
|
||||
const boxSize = calculateBoxSize(canvasWidth);
|
||||
const cellSize = boxSize + GRID_CONFIG.GRID_BOX_GAP; // 박스 + 간격
|
||||
|
||||
// 최소 1개 박스 크기
|
||||
const minBoxes = 1;
|
||||
const boxes = Math.max(minBoxes, Math.round(size / cellSize));
|
||||
|
||||
// 박스 개수에서 마지막 간격 제거
|
||||
return boxes * boxSize + (boxes - 1) * GRID_CONFIG.GRID_BOX_GAP;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -331,26 +331,28 @@ export function DashboardViewer({
|
|||
</div>
|
||||
) : (
|
||||
// 데스크톱: 기존 고정 캔버스 레이아웃
|
||||
<div className="flex min-h-screen items-start justify-center bg-gray-100 p-8">
|
||||
<div
|
||||
className="relative rounded-lg"
|
||||
style={{
|
||||
width: `${canvasConfig.width}px`,
|
||||
minHeight: `${canvasConfig.height}px`,
|
||||
height: `${canvasHeight}px`,
|
||||
backgroundColor: backgroundColor,
|
||||
}}
|
||||
>
|
||||
{sortedElements.map((element) => (
|
||||
<ViewerElement
|
||||
key={element.id}
|
||||
element={element}
|
||||
data={elementData[element.id]}
|
||||
isLoading={loadingElements.has(element.id)}
|
||||
onRefresh={() => loadElementData(element)}
|
||||
isMobile={false}
|
||||
/>
|
||||
))}
|
||||
<div className="min-h-screen bg-gray-100 py-8">
|
||||
<div className="mx-auto" style={{ width: `${canvasConfig.width}px` }}>
|
||||
<div
|
||||
className="relative rounded-lg"
|
||||
style={{
|
||||
width: `${canvasConfig.width}px`,
|
||||
minHeight: `${canvasConfig.height}px`,
|
||||
height: `${canvasHeight}px`,
|
||||
backgroundColor: backgroundColor,
|
||||
}}
|
||||
>
|
||||
{sortedElements.map((element) => (
|
||||
<ViewerElement
|
||||
key={element.id}
|
||||
element={element}
|
||||
data={elementData[element.id]}
|
||||
isLoading={loadingElements.has(element.id)}
|
||||
onRefresh={() => loadElementData(element)}
|
||||
isMobile={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -108,14 +108,6 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
|||
category: "utility",
|
||||
color: "#6B7280", // 회색
|
||||
},
|
||||
{
|
||||
type: "log",
|
||||
label: "로그",
|
||||
icon: "",
|
||||
description: "로그를 출력합니다",
|
||||
category: "utility",
|
||||
color: "#6B7280", // 회색
|
||||
},
|
||||
];
|
||||
|
||||
export const NODE_CATEGORIES = [
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import {
|
|||
Save,
|
||||
Undo,
|
||||
Redo,
|
||||
Play,
|
||||
ArrowLeft,
|
||||
Cog,
|
||||
Layout,
|
||||
|
|
@ -28,7 +27,6 @@ interface DesignerToolbarProps {
|
|||
onSave: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onPreview: () => void;
|
||||
onTogglePanel: (panelId: string) => void;
|
||||
panelStates: Record<string, { isOpen: boolean }>;
|
||||
canUndo: boolean;
|
||||
|
|
@ -45,7 +43,6 @@ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
|
|||
onSave,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onPreview,
|
||||
onTogglePanel,
|
||||
panelStates,
|
||||
canUndo,
|
||||
|
|
@ -229,11 +226,6 @@ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
|
|||
|
||||
<div className="h-6 w-px bg-gray-300" />
|
||||
|
||||
<Button variant="outline" size="sm" onClick={onPreview} className="flex items-center space-x-2">
|
||||
<Play className="h-4 w-4" />
|
||||
<span>미리보기</span>
|
||||
</Button>
|
||||
|
||||
<Button onClick={onSave} disabled={isSaving} className="flex items-center space-x-2">
|
||||
<Save className="h-4 w-4" />
|
||||
<span>{isSaving ? "저장 중..." : "저장"}</span>
|
||||
|
|
|
|||
|
|
@ -437,10 +437,8 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
|
||||
{/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */}
|
||||
{type === "widget" && !isFileComponent(component) && (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="pointer-events-none flex-1">
|
||||
<WidgetRenderer component={component} />
|
||||
</div>
|
||||
<div className="pointer-events-none h-full w-full">
|
||||
<WidgetRenderer component={component} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: getWidth(),
|
||||
height: getHeight(),
|
||||
height: getHeight(), // 모든 컴포넌트 고정 높이로 변경
|
||||
zIndex: component.type === "layout" ? 1 : position.z || 2, // 레이아웃은 z-index 1, 다른 컴포넌트는 2 이상
|
||||
...componentStyle,
|
||||
// style.width와 style.height는 이미 getWidth/getHeight에서 처리했으므로 중복 적용됨
|
||||
|
|
@ -162,7 +162,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
{/* 동적 컴포넌트 렌더링 */}
|
||||
<div
|
||||
className={`h-full w-full max-w-full ${
|
||||
component.componentConfig?.type === "table-list" ? "overflow-hidden" : "overflow-hidden"
|
||||
component.componentConfig?.type === "table-list" ? "overflow-hidden" : "overflow-visible"
|
||||
}`}
|
||||
>
|
||||
<DynamicComponentRenderer
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Database } from "lucide-react";
|
||||
import { Database, Cog } from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
ScreenDefinition,
|
||||
ComponentData,
|
||||
|
|
@ -57,7 +58,6 @@ import DetailSettingsPanel from "./panels/DetailSettingsPanel";
|
|||
import GridPanel from "./panels/GridPanel";
|
||||
import ResolutionPanel from "./panels/ResolutionPanel";
|
||||
import { usePanelState, PanelConfig } from "@/hooks/usePanelState";
|
||||
import { ResponsivePreviewModal } from "./ResponsivePreviewModal";
|
||||
|
||||
// 새로운 통합 UI 컴포넌트
|
||||
import { LeftUnifiedToolbar, defaultToolbarButtons } from "./toolbar/LeftUnifiedToolbar";
|
||||
|
|
@ -74,17 +74,9 @@ interface ScreenDesignerProps {
|
|||
onBackToList: () => void;
|
||||
}
|
||||
|
||||
// 패널 설정 (간소화: 템플릿, 격자 제거)
|
||||
// 패널 설정 (컴포넌트와 편집 2개)
|
||||
const panelConfigs: PanelConfig[] = [
|
||||
// 좌측 그룹: 입력/소스
|
||||
{
|
||||
id: "tables",
|
||||
title: "테이블 목록",
|
||||
defaultPosition: "left",
|
||||
defaultWidth: 400,
|
||||
defaultHeight: 700,
|
||||
shortcutKey: "t",
|
||||
},
|
||||
// 컴포넌트 패널 (테이블 + 컴포넌트 탭)
|
||||
{
|
||||
id: "components",
|
||||
title: "컴포넌트",
|
||||
|
|
@ -93,31 +85,15 @@ const panelConfigs: PanelConfig[] = [
|
|||
defaultHeight: 700,
|
||||
shortcutKey: "c",
|
||||
},
|
||||
// 좌측 그룹: 편집/설정
|
||||
// 편집 패널 (속성 + 스타일 & 해상도 탭)
|
||||
{
|
||||
id: "properties",
|
||||
title: "속성",
|
||||
title: "편집",
|
||||
defaultPosition: "left",
|
||||
defaultWidth: 400,
|
||||
defaultHeight: 700,
|
||||
shortcutKey: "p",
|
||||
},
|
||||
{
|
||||
id: "styles",
|
||||
title: "스타일",
|
||||
defaultPosition: "left",
|
||||
defaultWidth: 400,
|
||||
defaultHeight: 700,
|
||||
shortcutKey: "s",
|
||||
},
|
||||
{
|
||||
id: "resolution",
|
||||
title: "해상도",
|
||||
defaultPosition: "left",
|
||||
defaultWidth: 400,
|
||||
defaultHeight: 700,
|
||||
shortcutKey: "e",
|
||||
},
|
||||
];
|
||||
|
||||
export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) {
|
||||
|
|
@ -145,9 +121,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
const [showFileAttachmentModal, setShowFileAttachmentModal] = useState(false);
|
||||
const [selectedFileComponent, setSelectedFileComponent] = useState<ComponentData | null>(null);
|
||||
|
||||
// 반응형 미리보기 모달 상태
|
||||
const [showResponsivePreview, setShowResponsivePreview] = useState(false);
|
||||
|
||||
// 해상도 설정 상태
|
||||
const [screenResolution, setScreenResolution] = useState<ScreenResolution>(
|
||||
SCREEN_RESOLUTIONS[0], // 기본값: Full HD
|
||||
|
|
@ -198,8 +171,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
isPanning: false,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
scrollLeft: 0,
|
||||
scrollTop: 0,
|
||||
outerScrollLeft: 0,
|
||||
outerScrollTop: 0,
|
||||
innerScrollLeft: 0,
|
||||
innerScrollTop: 0,
|
||||
});
|
||||
const canvasContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -381,6 +356,31 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
);
|
||||
}, [tables, searchTerm]);
|
||||
|
||||
// 이미 배치된 컬럼 목록 계산
|
||||
const placedColumns = useMemo(() => {
|
||||
const placed = new Set<string>();
|
||||
|
||||
const collectColumns = (components: ComponentData[]) => {
|
||||
components.forEach((comp) => {
|
||||
const anyComp = comp as any;
|
||||
|
||||
// widget 타입 또는 component 타입 (새로운 시스템)에서 tableName과 columnName 확인
|
||||
if ((comp.type === "widget" || comp.type === "component") && anyComp.tableName && anyComp.columnName) {
|
||||
const key = `${anyComp.tableName}.${anyComp.columnName}`;
|
||||
placed.add(key);
|
||||
}
|
||||
|
||||
// 자식 컴포넌트도 확인 (재귀)
|
||||
if (comp.children && comp.children.length > 0) {
|
||||
collectColumns(comp.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
collectColumns(layout.components);
|
||||
return placed;
|
||||
}, [layout.components]);
|
||||
|
||||
// 히스토리에 저장
|
||||
const saveToHistory = useCallback(
|
||||
(newLayout: LayoutData) => {
|
||||
|
|
@ -1061,14 +1061,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
};
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (isPanMode && canvasContainerRef.current) {
|
||||
if (isPanMode) {
|
||||
e.preventDefault();
|
||||
// 외부와 내부 스크롤 컨테이너 모두 저장
|
||||
setPanState({
|
||||
isPanning: true,
|
||||
startX: e.pageX,
|
||||
startY: e.pageY,
|
||||
scrollLeft: canvasContainerRef.current.scrollLeft,
|
||||
scrollTop: canvasContainerRef.current.scrollTop,
|
||||
outerScrollLeft: canvasContainerRef.current?.scrollLeft || 0,
|
||||
outerScrollTop: canvasContainerRef.current?.scrollTop || 0,
|
||||
innerScrollLeft: canvasRef.current?.scrollLeft || 0,
|
||||
innerScrollTop: canvasRef.current?.scrollTop || 0,
|
||||
});
|
||||
// 드래그 중 커서 변경
|
||||
document.body.style.cursor = "grabbing";
|
||||
|
|
@ -1076,12 +1079,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (isPanMode && panState.isPanning && canvasContainerRef.current) {
|
||||
if (isPanMode && panState.isPanning) {
|
||||
e.preventDefault();
|
||||
const dx = e.pageX - panState.startX;
|
||||
const dy = e.pageY - panState.startY;
|
||||
canvasContainerRef.current.scrollLeft = panState.scrollLeft - dx;
|
||||
canvasContainerRef.current.scrollTop = panState.scrollTop - dy;
|
||||
|
||||
// 외부 컨테이너 스크롤
|
||||
if (canvasContainerRef.current) {
|
||||
canvasContainerRef.current.scrollLeft = panState.outerScrollLeft - dx;
|
||||
canvasContainerRef.current.scrollTop = panState.outerScrollTop - dy;
|
||||
}
|
||||
|
||||
// 내부 캔버스 스크롤
|
||||
if (canvasRef.current) {
|
||||
canvasRef.current.scrollLeft = panState.innerScrollLeft - dx;
|
||||
canvasRef.current.scrollTop = panState.innerScrollTop - dy;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1106,7 +1119,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [isPanMode, panState.isPanning, panState.startX, panState.startY, panState.scrollLeft, panState.scrollTop]);
|
||||
}, [
|
||||
isPanMode,
|
||||
panState.isPanning,
|
||||
panState.startX,
|
||||
panState.startY,
|
||||
panState.outerScrollLeft,
|
||||
panState.outerScrollTop,
|
||||
panState.innerScrollLeft,
|
||||
panState.innerScrollTop,
|
||||
]);
|
||||
|
||||
// 마우스 휠로 줌 제어
|
||||
useEffect(() => {
|
||||
|
|
@ -3972,18 +3994,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
|
||||
if (!selectedScreen) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Database className="mx-auto mb-4 h-12 w-12 text-gray-400" />
|
||||
<h3 className="text-lg font-medium text-gray-900">화면을 선택하세요</h3>
|
||||
<p className="text-gray-500">설계할 화면을 먼저 선택해주세요.</p>
|
||||
<div className="bg-background flex h-full items-center justify-center">
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="bg-muted mx-auto flex h-16 w-16 items-center justify-center rounded-full">
|
||||
<Database className="text-muted-foreground h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="text-foreground text-lg font-semibold">화면을 선택하세요</h3>
|
||||
<p className="text-muted-foreground max-w-sm text-sm">설계할 화면을 먼저 선택해주세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-gradient-to-br from-gray-50 to-slate-100">
|
||||
<div className="bg-background flex h-full w-full flex-col">
|
||||
{/* 상단 슬림 툴바 */}
|
||||
<SlimToolbar
|
||||
screenName={selectedScreen?.screenName}
|
||||
|
|
@ -3992,7 +4016,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
onBack={onBackToList}
|
||||
onSave={handleSave}
|
||||
isSaving={isSaving}
|
||||
onPreview={() => setShowResponsivePreview(true)}
|
||||
/>
|
||||
{/* 메인 컨테이너 (좌측 툴바 + 패널들 + 캔버스) */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
|
|
@ -4000,20 +4023,23 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
<LeftUnifiedToolbar buttons={defaultToolbarButtons} panelStates={panelStates} onTogglePanel={togglePanel} />
|
||||
|
||||
{/* 열린 패널들 (좌측에서 우측으로 누적) */}
|
||||
{panelStates.tables?.isOpen && (
|
||||
<div className="flex h-full w-[400px] flex-col border-r border-gray-200 bg-white shadow-lg">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 p-3">
|
||||
<h3 className="font-semibold text-gray-900">테이블 목록</h3>
|
||||
<button onClick={() => closePanel("tables")} className="text-gray-400 hover:text-gray-600">
|
||||
{panelStates.components?.isOpen && (
|
||||
<div className="border-border bg-card flex h-full w-[400px] flex-col border-r shadow-sm">
|
||||
<div className="border-border flex items-center justify-between border-b px-6 py-4">
|
||||
<h3 className="text-foreground text-lg font-semibold">컴포넌트</h3>
|
||||
<button
|
||||
onClick={() => closePanel("components")}
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring rounded-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<TablesPanel
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ComponentsPanel
|
||||
tables={filteredTables}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
onDragStart={(e, table, column) => {
|
||||
onTableDragStart={(e, table, column) => {
|
||||
const dragData = {
|
||||
type: column ? "column" : "table",
|
||||
table,
|
||||
|
|
@ -4022,30 +4048,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
|
||||
}}
|
||||
selectedTableName={selectedScreen.tableName}
|
||||
placedColumns={placedColumns}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{panelStates.components?.isOpen && (
|
||||
<div className="flex h-full w-[400px] flex-col border-r border-gray-200 bg-white shadow-lg">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 p-3">
|
||||
<h3 className="font-semibold text-gray-900">컴포넌트</h3>
|
||||
<button onClick={() => closePanel("components")} className="text-gray-400 hover:text-gray-600">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ComponentsPanel />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{panelStates.properties?.isOpen && (
|
||||
<div className="flex h-full w-[400px] flex-col border-r border-gray-200 bg-white shadow-lg">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 p-3">
|
||||
<h3 className="font-semibold text-gray-900">속성</h3>
|
||||
<button onClick={() => closePanel("properties")} className="text-gray-400 hover:text-gray-600">
|
||||
<div className="border-border bg-card flex h-full w-[400px] flex-col border-r shadow-sm">
|
||||
<div className="border-border flex items-center justify-between border-b px-6 py-4">
|
||||
<h3 className="text-foreground text-lg font-semibold">속성</h3>
|
||||
<button
|
||||
onClick={() => closePanel("properties")}
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring rounded-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -4059,85 +4075,49 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
currentTable={tables.length > 0 ? tables[0] : undefined}
|
||||
currentTableName={selectedScreen?.tableName}
|
||||
dragState={dragState}
|
||||
onStyleChange={(style) => {
|
||||
if (selectedComponent) {
|
||||
updateComponentProperty(selectedComponent.id, "style", style);
|
||||
}
|
||||
}}
|
||||
currentResolution={screenResolution}
|
||||
onResolutionChange={handleResolutionChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{panelStates.styles?.isOpen && (
|
||||
<div className="flex h-full w-[400px] flex-col border-r border-gray-200 bg-white shadow-lg">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 p-3">
|
||||
<h3 className="font-semibold text-gray-900">스타일</h3>
|
||||
<button onClick={() => closePanel("styles")} className="text-gray-400 hover:text-gray-600">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{selectedComponent ? (
|
||||
<StyleEditor
|
||||
style={selectedComponent.style || {}}
|
||||
onStyleChange={(style) => {
|
||||
if (selectedComponent) {
|
||||
updateComponentProperty(selectedComponent.id, "style", style);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-gray-500">
|
||||
컴포넌트를 선택하여 스타일을 편집하세요
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{panelStates.resolution?.isOpen && (
|
||||
<div className="flex h-full w-[400px] flex-col border-r border-gray-200 bg-white shadow-lg">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 p-3">
|
||||
<h3 className="font-semibold text-gray-900">해상도</h3>
|
||||
<button onClick={() => closePanel("resolution")} className="text-gray-400 hover:text-gray-600">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<ResolutionPanel currentResolution={screenResolution} onResolutionChange={handleResolutionChange} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 스타일과 해상도 패널은 속성 패널의 탭으로 통합됨 */}
|
||||
|
||||
{/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - 좌우 최소화, 위아래 넉넉한 여유 */}
|
||||
<div
|
||||
ref={canvasContainerRef}
|
||||
className="relative flex-1 overflow-auto bg-gradient-to-br from-gray-50 to-slate-100 px-2 py-6"
|
||||
>
|
||||
<div ref={canvasContainerRef} className="bg-muted relative flex-1 overflow-auto px-16 py-6">
|
||||
{/* Pan 모드 안내 - 제거됨 */}
|
||||
{/* 줌 레벨 표시 */}
|
||||
<div className="pointer-events-none fixed right-6 bottom-6 z-50 rounded-lg bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-lg ring-1 ring-gray-200">
|
||||
<div className="bg-card text-foreground border-border pointer-events-none fixed right-6 bottom-6 z-50 rounded-lg border px-4 py-2 text-sm font-medium shadow-md">
|
||||
🔍 {Math.round(zoomLevel * 100)}%
|
||||
</div>
|
||||
{/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 */}
|
||||
<div
|
||||
className="mx-auto"
|
||||
className="flex justify-center"
|
||||
style={{
|
||||
width: screenResolution.width * zoomLevel,
|
||||
height: Math.max(screenResolution.height, 800) * zoomLevel,
|
||||
width: "100%",
|
||||
minHeight: Math.max(screenResolution.height, 800) * zoomLevel,
|
||||
}}
|
||||
>
|
||||
{/* 실제 작업 캔버스 (해상도 크기) - 반응형 개선 + 줌 적용 */}
|
||||
<div
|
||||
className="bg-white shadow-lg"
|
||||
className="bg-background border-border border shadow-lg"
|
||||
style={{
|
||||
width: screenResolution.width,
|
||||
height: Math.max(screenResolution.height, 800), // 최소 높이 보장
|
||||
minHeight: screenResolution.height,
|
||||
transform: `scale(${zoomLevel})`, // 줌 레벨에 따라 시각적으로 확대/축소
|
||||
transformOrigin: "top center",
|
||||
transition: "transform 0.1s ease-out",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className="relative h-full w-full overflow-auto bg-gradient-to-br from-slate-50/30 to-gray-100/20"
|
||||
className="bg-background relative h-full w-full overflow-auto"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget && !selectionDrag.wasSelecting && !isPanMode) {
|
||||
setSelectedComponent(null);
|
||||
|
|
@ -4164,14 +4144,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
{gridLines.map((line, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="pointer-events-none absolute"
|
||||
className="bg-border pointer-events-none absolute"
|
||||
style={{
|
||||
left: line.type === "vertical" ? `${line.position}px` : 0,
|
||||
top: line.type === "horizontal" ? `${line.position}px` : 0,
|
||||
width: line.type === "vertical" ? "1px" : "100%",
|
||||
height: line.type === "horizontal" ? "1px" : "100%",
|
||||
backgroundColor: layout.gridSettings?.gridColor || "#d1d5db",
|
||||
opacity: layout.gridSettings?.gridOpacity || 0.5,
|
||||
opacity: layout.gridSettings?.gridOpacity || 0.3,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -4383,15 +4362,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
{/* 드래그 선택 영역 */}
|
||||
{selectionDrag.isSelecting && (
|
||||
<div
|
||||
className="pointer-events-none absolute"
|
||||
className="border-primary bg-primary/5 pointer-events-none absolute rounded-md border-2 border-dashed"
|
||||
style={{
|
||||
left: `${Math.min(selectionDrag.startPoint.x, selectionDrag.currentPoint.x)}px`,
|
||||
top: `${Math.min(selectionDrag.startPoint.y, selectionDrag.currentPoint.y)}px`,
|
||||
width: `${Math.abs(selectionDrag.currentPoint.x - selectionDrag.startPoint.x)}px`,
|
||||
height: `${Math.abs(selectionDrag.currentPoint.y - selectionDrag.startPoint.y)}px`,
|
||||
border: "2px dashed #3b82f6",
|
||||
backgroundColor: "rgba(59, 130, 246, 0.05)", // 매우 투명한 배경 (5%)
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -4399,19 +4375,28 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
{/* 빈 캔버스 안내 */}
|
||||
{layout.components.length === 0 && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center text-gray-400">
|
||||
<Database className="mx-auto mb-4 h-16 w-16" />
|
||||
<h3 className="mb-2 text-xl font-medium">캔버스가 비어있습니다</h3>
|
||||
<p className="text-sm">좌측 패널에서 테이블/컬럼이나 템플릿을 드래그하여 화면을 설계하세요</p>
|
||||
<p className="mt-2 text-xs">
|
||||
단축키: T(테이블), M(템플릿), P(속성), S(스타일), R(격자), D(상세설정), E(해상도)
|
||||
</p>
|
||||
<p className="mt-1 text-xs">
|
||||
편집: Ctrl+C(복사), Ctrl+V(붙여넣기), Ctrl+S(저장), Ctrl+Z(실행취소), Delete(삭제)
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-amber-600">
|
||||
⚠️ 브라우저 기본 단축키가 차단되어 애플리케이션 기능만 작동합니다
|
||||
<div className="max-w-2xl space-y-4 px-6 text-center">
|
||||
<div className="bg-muted mx-auto flex h-16 w-16 items-center justify-center rounded-full">
|
||||
<Database className="text-muted-foreground h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="text-foreground text-xl font-semibold">캔버스가 비어있습니다</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
좌측 패널에서 테이블/컬럼이나 템플릿을 드래그하여 화면을 설계하세요
|
||||
</p>
|
||||
<div className="text-muted-foreground space-y-2 text-xs">
|
||||
<p>
|
||||
<span className="font-medium">단축키:</span> T(테이블), M(템플릿), P(속성), S(스타일),
|
||||
R(격자), D(상세설정), E(해상도)
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">편집:</span> Ctrl+C(복사), Ctrl+V(붙여넣기), Ctrl+S(저장),
|
||||
Ctrl+Z(실행취소), Delete(삭제)
|
||||
</p>
|
||||
<p className="text-warning flex items-center justify-center gap-2">
|
||||
<span>⚠️</span>
|
||||
<span>브라우저 기본 단축키가 차단되어 애플리케이션 기능만 작동합니다</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -4449,13 +4434,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
screenId={selectedScreen.screenId}
|
||||
/>
|
||||
)}
|
||||
{/* 반응형 미리보기 모달 */}
|
||||
<ResponsivePreviewModal
|
||||
isOpen={showResponsivePreview}
|
||||
onClose={() => setShowResponsivePreview(false)}
|
||||
components={layout.components}
|
||||
screenWidth={screenResolution.width}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -255,45 +255,45 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="fontWeight" className="text-xs font-medium">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="max-w-[140px] space-y-0.5">
|
||||
<Label htmlFor="fontWeight" className="text-[10px] font-medium">
|
||||
굵기
|
||||
</Label>
|
||||
<Select
|
||||
value={localStyle.fontWeight || "normal"}
|
||||
onValueChange={(value) => handleStyleChange("fontWeight", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectTrigger className="h-6 w-full text-[10px] px-2">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="normal">보통</SelectItem>
|
||||
<SelectItem value="bold">굵게</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
<SelectItem value="400">400</SelectItem>
|
||||
<SelectItem value="500">500</SelectItem>
|
||||
<SelectItem value="600">600</SelectItem>
|
||||
<SelectItem value="700">700</SelectItem>
|
||||
<SelectItem value="normal" className="text-[10px]">보통</SelectItem>
|
||||
<SelectItem value="bold" className="text-[10px]">굵게</SelectItem>
|
||||
<SelectItem value="100" className="text-[10px]">100</SelectItem>
|
||||
<SelectItem value="400" className="text-[10px]">400</SelectItem>
|
||||
<SelectItem value="500" className="text-[10px]">500</SelectItem>
|
||||
<SelectItem value="600" className="text-[10px]">600</SelectItem>
|
||||
<SelectItem value="700" className="text-[10px]">700</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="textAlign" className="text-xs font-medium">
|
||||
<div className="max-w-[140px] space-y-0.5">
|
||||
<Label htmlFor="textAlign" className="text-[10px] font-medium">
|
||||
정렬
|
||||
</Label>
|
||||
<Select
|
||||
value={localStyle.textAlign || "left"}
|
||||
onValueChange={(value) => handleStyleChange("textAlign", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectTrigger className="h-6 w-full text-[10px] px-2">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="center">가운데</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
<SelectItem value="justify">양쪽</SelectItem>
|
||||
<SelectItem value="left" className="text-[10px]">왼쪽</SelectItem>
|
||||
<SelectItem value="center" className="text-[10px]">가운데</SelectItem>
|
||||
<SelectItem value="right" className="text-[10px]">오른쪽</SelectItem>
|
||||
<SelectItem value="justify" className="text-[10px]">양쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,13 +6,30 @@ import { Badge } from "@/components/ui/badge";
|
|||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
||||
import { ComponentDefinition, ComponentCategory } from "@/types/component";
|
||||
import { Search, Package, Grid, Layers, Palette, Zap, MousePointer, Edit3, BarChart3 } from "lucide-react";
|
||||
import { Search, Package, Grid, Layers, Palette, Zap, MousePointer, Edit3, BarChart3, Database } from "lucide-react";
|
||||
import { TableInfo, ColumnInfo } from "@/types/screen";
|
||||
import TablesPanel from "./TablesPanel";
|
||||
|
||||
interface ComponentsPanelProps {
|
||||
className?: string;
|
||||
// 테이블 관련 props
|
||||
tables?: TableInfo[];
|
||||
searchTerm?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
onTableDragStart?: (e: React.DragEvent, table: TableInfo, column?: ColumnInfo) => void;
|
||||
selectedTableName?: string;
|
||||
placedColumns?: Set<string>; // 이미 배치된 컬럼명 집합
|
||||
}
|
||||
|
||||
export function ComponentsPanel({ className }: ComponentsPanelProps) {
|
||||
export function ComponentsPanel({
|
||||
className,
|
||||
tables = [],
|
||||
searchTerm = "",
|
||||
onSearchChange,
|
||||
onTableDragStart,
|
||||
selectedTableName,
|
||||
placedColumns
|
||||
}: ComponentsPanelProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// 레지스트리에서 모든 컴포넌트 조회
|
||||
|
|
@ -160,7 +177,11 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) {
|
|||
|
||||
{/* 카테고리 탭 */}
|
||||
<Tabs defaultValue="input" className="flex flex-1 flex-col">
|
||||
<TabsList className="mb-3 grid h-8 w-full grid-cols-4">
|
||||
<TabsList className="mb-3 grid h-8 w-full grid-cols-5">
|
||||
<TabsTrigger value="tables" className="flex items-center gap-1 px-1 text-xs">
|
||||
<Database className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">테이블</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="input" className="flex items-center gap-1 px-1 text-xs">
|
||||
<Edit3 className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">입력</span>
|
||||
|
|
@ -179,6 +200,27 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) {
|
|||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 테이블 탭 */}
|
||||
<TabsContent value="tables" className="mt-0 flex-1 overflow-y-auto">
|
||||
{tables.length > 0 && onTableDragStart ? (
|
||||
<TablesPanel
|
||||
tables={tables}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={onSearchChange || (() => {})}
|
||||
onDragStart={onTableDragStart}
|
||||
selectedTableName={selectedTableName}
|
||||
placedColumns={placedColumns}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-32 items-center justify-center text-center">
|
||||
<div className="p-6">
|
||||
<Database className="text-muted-foreground/40 mx-auto mb-2 h-10 w-10" />
|
||||
<p className="text-muted-foreground text-xs font-medium">테이블이 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* 입력 컴포넌트 */}
|
||||
<TabsContent value="input" className="mt-0 flex-1 space-y-2 overflow-y-auto">
|
||||
{getFilteredComponents("input").length > 0
|
||||
|
|
|
|||
|
|
@ -926,7 +926,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
|
||||
<div className="col-span-2">
|
||||
<Label htmlFor="height" className="text-sm font-medium">
|
||||
높이 (40px 단위)
|
||||
최소 높이 (40px 단위)
|
||||
</Label>
|
||||
<div className="mt-1 flex items-center space-x-2">
|
||||
<Input
|
||||
|
|
@ -946,7 +946,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
<span className="text-sm text-gray-500">행 = {localInputs.height || 40}px</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
1행 = 40px (현재 {Math.round((localInputs.height || 40) / 40)}행)
|
||||
1행 = 40px (현재 {Math.round((localInputs.height || 40) / 40)}행) - 내부 콘텐츠에 맞춰 늘어남
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -80,17 +80,6 @@ const ResolutionPanel: React.FC<ResolutionPanelProps> = ({ currentResolution, on
|
|||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 현재 해상도 표시 */}
|
||||
<div className="rounded-lg border bg-gray-50 p-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
{getCategoryIcon(currentResolution.category)}
|
||||
<span className="text-sm font-medium">{currentResolution.name}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
{currentResolution.width} × {currentResolution.height} 픽셀
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 프리셋 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">해상도 프리셋</Label>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ interface TablesPanelProps {
|
|||
onSearchChange: (term: string) => void;
|
||||
onDragStart: (e: React.DragEvent, table: TableInfo, column?: any) => void;
|
||||
selectedTableName?: string;
|
||||
placedColumns?: Set<string>; // 이미 배치된 컬럼명 집합 (tableName.columnName 형식)
|
||||
}
|
||||
|
||||
// 위젯 타입별 아이콘
|
||||
|
|
@ -67,6 +68,7 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
|
|||
onSearchChange,
|
||||
onDragStart,
|
||||
selectedTableName,
|
||||
placedColumns = new Set(),
|
||||
}) => {
|
||||
const [expandedTables, setExpandedTables] = useState<Set<string>>(new Set());
|
||||
|
||||
|
|
@ -80,11 +82,22 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
|
|||
setExpandedTables(newExpanded);
|
||||
};
|
||||
|
||||
const filteredTables = tables.filter(
|
||||
(table) =>
|
||||
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
table.columns.some((col) => col.columnName.toLowerCase().includes(searchTerm.toLowerCase())),
|
||||
);
|
||||
// 이미 배치된 컬럼을 제외한 테이블 정보 생성
|
||||
const tablesWithAvailableColumns = tables.map((table) => ({
|
||||
...table,
|
||||
columns: table.columns.filter((col) => {
|
||||
const columnKey = `${table.tableName}.${col.columnName}`;
|
||||
return !placedColumns.has(columnKey);
|
||||
}),
|
||||
}));
|
||||
|
||||
const filteredTables = tablesWithAvailableColumns
|
||||
.filter((table) => table.columns.length > 0) // 사용 가능한 컬럼이 있는 테이블만 표시
|
||||
.filter(
|
||||
(table) =>
|
||||
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
table.columns.some((col) => col.columnName.toLowerCase().includes(searchTerm.toLowerCase())),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -10,7 +9,8 @@ import { Separator } from "@/components/ui/separator";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { ChevronDown, Settings, Info, Database, Trash2, Copy } from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ChevronDown, Settings, Info, Database, Trash2, Copy, Palette, Monitor } from "lucide-react";
|
||||
import {
|
||||
ComponentData,
|
||||
WebType,
|
||||
|
|
@ -48,10 +48,11 @@ import { DashboardConfigPanel } from "../config-panels/DashboardConfigPanel";
|
|||
import { StatsCardConfigPanel } from "../config-panels/StatsCardConfigPanel";
|
||||
import { ProgressBarConfigPanel } from "../config-panels/ProgressBarConfigPanel";
|
||||
import { ChartConfigPanel } from "../config-panels/ChartConfigPanel";
|
||||
import { ResponsiveConfigPanel } from "./ResponsiveConfigPanel";
|
||||
import { AlertConfigPanel } from "../config-panels/AlertConfigPanel";
|
||||
import { BadgeConfigPanel } from "../config-panels/BadgeConfigPanel";
|
||||
import { DynamicComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
|
||||
import StyleEditor from "../StyleEditor";
|
||||
import ResolutionPanel from "./ResolutionPanel";
|
||||
|
||||
interface UnifiedPropertiesPanelProps {
|
||||
selectedComponent?: ComponentData;
|
||||
|
|
@ -62,6 +63,11 @@ interface UnifiedPropertiesPanelProps {
|
|||
currentTable?: TableInfo;
|
||||
currentTableName?: string;
|
||||
dragState?: any;
|
||||
// 스타일 관련
|
||||
onStyleChange?: (style: any) => void;
|
||||
// 해상도 관련
|
||||
currentResolution?: { name: string; width: number; height: number };
|
||||
onResolutionChange?: (resolution: { name: string; width: number; height: number }) => void;
|
||||
}
|
||||
|
||||
export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||
|
|
@ -73,9 +79,11 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
currentTable,
|
||||
currentTableName,
|
||||
dragState,
|
||||
onStyleChange,
|
||||
currentResolution,
|
||||
onResolutionChange,
|
||||
}) => {
|
||||
const { webTypes } = useWebTypes({ active: "Y" });
|
||||
const [activeTab, setActiveTab] = useState("basic");
|
||||
const [localComponentDetailType, setLocalComponentDetailType] = useState<string>("");
|
||||
|
||||
// 새로운 컴포넌트 시스템의 webType 동기화
|
||||
|
|
@ -91,10 +99,10 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
// 컴포넌트가 선택되지 않았을 때
|
||||
if (!selectedComponent) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center p-8 text-center">
|
||||
<Settings className="mb-4 h-12 w-12 text-gray-300" />
|
||||
<p className="text-sm text-gray-500">컴포넌트를 선택하여</p>
|
||||
<p className="text-sm text-gray-500">속성을 편집하세요</p>
|
||||
<div className="flex h-full flex-col items-center justify-center p-4 text-center">
|
||||
<Settings className="mb-2 h-8 w-8 text-gray-300" />
|
||||
<p className="text-[10px] text-gray-500">컴포넌트를 선택하여</p>
|
||||
<p className="text-[10px] text-gray-500">속성을 편집하세요</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -164,119 +172,92 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
const area = selectedComponent as AreaComponent;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 컴포넌트 정보 */}
|
||||
<div className="rounded-lg bg-slate-50 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Info className="h-4 w-4 text-slate-500" />
|
||||
<span className="text-sm font-medium text-slate-700">컴포넌트 정보</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{selectedComponent.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1 text-xs text-slate-600">
|
||||
<div>ID: {selectedComponent.id}</div>
|
||||
{widget.widgetType && <div>위젯: {widget.widgetType}</div>}
|
||||
<div className="space-y-1.5">
|
||||
{/* 컴포넌트 정보 - 간소화 */}
|
||||
<div className="flex items-center justify-between rounded bg-muted px-2 py-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<Info className="h-2.5 w-2.5 text-muted-foreground" />
|
||||
<span className="text-[10px] font-medium text-foreground">{selectedComponent.type}</span>
|
||||
</div>
|
||||
<span className="text-[9px] text-muted-foreground">{selectedComponent.id.slice(0, 8)}</span>
|
||||
</div>
|
||||
|
||||
{/* 라벨 */}
|
||||
<div>
|
||||
<Label>라벨</Label>
|
||||
<Input
|
||||
value={widget.label || ""}
|
||||
onChange={(e) => handleUpdate("label", e.target.value)}
|
||||
placeholder="컴포넌트 라벨"
|
||||
/>
|
||||
{/* 라벨 + 최소 높이 (같은 행) */}
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-[10px]">라벨</Label>
|
||||
<Input
|
||||
value={widget.label || ""}
|
||||
onChange={(e) => handleUpdate("label", e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="h-6 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-[10px]">높이</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={selectedComponent.size?.height || 0}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value) || 0;
|
||||
const roundedValue = Math.max(40, Math.round(value / 40) * 40);
|
||||
handleUpdate("size.height", roundedValue);
|
||||
}}
|
||||
step={40}
|
||||
placeholder="40"
|
||||
className="h-6 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Placeholder (widget만) */}
|
||||
{selectedComponent.type === "widget" && (
|
||||
<div>
|
||||
<Label>Placeholder</Label>
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-[10px]">Placeholder</Label>
|
||||
<Input
|
||||
value={widget.placeholder || ""}
|
||||
onChange={(e) => handleUpdate("placeholder", e.target.value)}
|
||||
placeholder="입력 안내 텍스트"
|
||||
className="h-6 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title (group/area) */}
|
||||
{(selectedComponent.type === "group" || selectedComponent.type === "area") && (
|
||||
<div>
|
||||
<Label>제목</Label>
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-[10px]">제목</Label>
|
||||
<Input
|
||||
value={group.title || area.title || ""}
|
||||
onChange={(e) => handleUpdate("title", e.target.value)}
|
||||
placeholder="제목"
|
||||
className="h-6 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description (area만) */}
|
||||
{selectedComponent.type === "area" && (
|
||||
<div>
|
||||
<Label>설명</Label>
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-[10px]">설명</Label>
|
||||
<Input
|
||||
value={area.description || ""}
|
||||
onChange={(e) => handleUpdate("description", e.target.value)}
|
||||
placeholder="설명"
|
||||
className="h-6 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 크기 */}
|
||||
<div>
|
||||
<Label>높이 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={selectedComponent.size?.height || 0}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value) || 0;
|
||||
// 40 단위로 반올림
|
||||
const roundedValue = Math.max(40, Math.round(value / 40) * 40);
|
||||
handleUpdate("size.height", roundedValue);
|
||||
}}
|
||||
step={40}
|
||||
placeholder="40 단위로 입력"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">40 단위로 자동 조정됩니다</p>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 스팬 */}
|
||||
{widget.columnSpan !== undefined && (
|
||||
<div>
|
||||
<Label>컬럼 스팬</Label>
|
||||
<Select
|
||||
value={widget.columnSpan?.toString() || "12"}
|
||||
onValueChange={(value) => handleUpdate("columnSpan", parseInt(value))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLUMN_NUMBERS.map((span) => (
|
||||
<SelectItem key={span} value={span.toString()}>
|
||||
{span} 컬럼 ({Math.round((span / 12) * 100)}%)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid Columns */}
|
||||
{(selectedComponent as any).gridColumns !== undefined && (
|
||||
<div>
|
||||
<Label>Grid Columns</Label>
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-[10px]">Grid Columns</Label>
|
||||
<Select
|
||||
value={((selectedComponent as any).gridColumns || 12).toString()}
|
||||
onValueChange={(value) => handleUpdate("gridColumns", parseInt(value))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="h-6 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -432,8 +413,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
<DataTableConfigPanel
|
||||
component={selectedComponent as DataTableComponent}
|
||||
tables={tables}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
onUpdateComponent={(updates) => {
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
handleUpdate(key, value);
|
||||
|
|
@ -613,99 +592,80 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
// 데이터 바인딩 탭
|
||||
const renderDataTab = () => {
|
||||
if (selectedComponent.type !== "widget") {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-8 text-center">
|
||||
<p className="text-sm text-gray-500">이 컴포넌트는 데이터 바인딩을 지원하지 않습니다</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const widget = selectedComponent as WidgetComponent;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-blue-50 p-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Database className="h-4 w-4 text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-900">데이터 바인딩</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 컬럼 */}
|
||||
<div>
|
||||
<Label>테이블 컬럼</Label>
|
||||
<Input
|
||||
value={widget.columnName || ""}
|
||||
onChange={(e) => handleUpdate("columnName", e.target.value)}
|
||||
placeholder="컬럼명 입력"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 기본값 */}
|
||||
<div>
|
||||
<Label>기본값</Label>
|
||||
<Input
|
||||
value={widget.defaultValue || ""}
|
||||
onChange={(e) => handleUpdate("defaultValue", e.target.value)}
|
||||
placeholder="기본값 입력"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-white">
|
||||
{/* 헤더 */}
|
||||
<div className="border-b border-gray-200 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Settings className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="font-semibold text-gray-900">속성 편집</h3>
|
||||
</div>
|
||||
<Badge variant="outline">{selectedComponent.type}</Badge>
|
||||
</div>
|
||||
{/* 헤더 - 간소화 */}
|
||||
<div className="border-b border-gray-200 px-3 py-2">
|
||||
{selectedComponent.type === "widget" && (
|
||||
<div className="mt-2 text-xs text-gray-600">
|
||||
<div className="text-[10px] text-gray-600 truncate">
|
||||
{(selectedComponent as WidgetComponent).label || selectedComponent.id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 탭 컨텐츠 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex h-full flex-col">
|
||||
<TabsList className="w-full justify-start rounded-none border-b px-4">
|
||||
<TabsTrigger value="basic">기본</TabsTrigger>
|
||||
<TabsTrigger value="detail">상세</TabsTrigger>
|
||||
<TabsTrigger value="data">데이터</TabsTrigger>
|
||||
<TabsTrigger value="responsive">반응형</TabsTrigger>
|
||||
</TabsList>
|
||||
<Tabs defaultValue="properties" className="flex flex-1 flex-col overflow-hidden">
|
||||
<TabsList className="grid h-7 w-full flex-shrink-0 grid-cols-2">
|
||||
<TabsTrigger value="properties" className="text-[10px]">
|
||||
편집
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="styles" className="text-[10px]">
|
||||
<Palette className="mr-0.5 h-2.5 w-2.5" />
|
||||
스타일 & 해상도
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<TabsContent value="basic" className="m-0 p-4">
|
||||
{renderBasicTab()}
|
||||
</TabsContent>
|
||||
<TabsContent value="detail" className="m-0 p-4">
|
||||
{renderDetailTab()}
|
||||
</TabsContent>
|
||||
<TabsContent value="data" className="m-0 p-4">
|
||||
{renderDataTab()}
|
||||
</TabsContent>
|
||||
<TabsContent value="responsive" className="m-0 p-4">
|
||||
<ResponsiveConfigPanel
|
||||
component={selectedComponent}
|
||||
onUpdate={(config) => {
|
||||
onUpdateProperty(selectedComponent.id, "responsiveConfig", config);
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
{/* 속성 탭 */}
|
||||
<TabsContent value="properties" className="mt-0 flex-1 overflow-y-auto p-2">
|
||||
<div className="space-y-2 text-xs">
|
||||
{/* 기본 설정 */}
|
||||
{renderBasicTab()}
|
||||
|
||||
{/* 상세 설정 통합 */}
|
||||
<Separator className="my-2" />
|
||||
{renderDetailTab()}
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 스타일 & 해상도 탭 */}
|
||||
<TabsContent value="styles" className="mt-0 flex-1 overflow-y-auto">
|
||||
<div className="space-y-2">
|
||||
{/* 해상도 설정 */}
|
||||
{currentResolution && onResolutionChange && (
|
||||
<div className="border-b pb-2 px-2">
|
||||
<ResolutionPanel
|
||||
currentResolution={currentResolution}
|
||||
onResolutionChange={onResolutionChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 스타일 설정 */}
|
||||
{selectedComponent ? (
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-center gap-1.5 px-2">
|
||||
<Palette className="h-3 w-3 text-primary" />
|
||||
<h4 className="text-xs font-semibold">컴포넌트 스타일</h4>
|
||||
</div>
|
||||
<StyleEditor
|
||||
style={selectedComponent.style || {}}
|
||||
onStyleChange={(style) => {
|
||||
if (onStyleChange) {
|
||||
onStyleChange(style);
|
||||
} else {
|
||||
handleUpdate("style", style);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground text-xs">
|
||||
컴포넌트를 선택하여 스타일을 편집하세요
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -65,51 +65,27 @@ export const LeftUnifiedToolbar: React.FC<LeftUnifiedToolbarProps> = ({ buttons,
|
|||
);
|
||||
};
|
||||
|
||||
// 기본 버튼 설정
|
||||
// 기본 버튼 설정 (컴포넌트와 편집 2개)
|
||||
export const defaultToolbarButtons: ToolbarButton[] = [
|
||||
// 입력/소스 그룹
|
||||
{
|
||||
id: "tables",
|
||||
label: "테이블",
|
||||
icon: <Database className="h-5 w-5" />,
|
||||
shortcut: "T",
|
||||
group: "source",
|
||||
panelWidth: 380,
|
||||
},
|
||||
// 컴포넌트 그룹 (테이블 + 컴포넌트 탭)
|
||||
{
|
||||
id: "components",
|
||||
label: "컴포넌트",
|
||||
icon: <Cog className="h-5 w-5" />,
|
||||
icon: <Layout className="h-5 w-5" />,
|
||||
shortcut: "C",
|
||||
group: "source",
|
||||
panelWidth: 350,
|
||||
panelWidth: 400,
|
||||
},
|
||||
|
||||
// 편집/설정 그룹
|
||||
// 편집 그룹 (속성 + 스타일 & 해상도 탭)
|
||||
{
|
||||
id: "properties",
|
||||
label: "속성",
|
||||
label: "편집",
|
||||
icon: <Settings className="h-5 w-5" />,
|
||||
shortcut: "P",
|
||||
group: "editor",
|
||||
panelWidth: 400,
|
||||
},
|
||||
{
|
||||
id: "styles",
|
||||
label: "스타일",
|
||||
icon: <Palette className="h-5 w-5" />,
|
||||
shortcut: "S",
|
||||
group: "editor",
|
||||
panelWidth: 360,
|
||||
},
|
||||
{
|
||||
id: "resolution",
|
||||
label: "해상도",
|
||||
icon: <Monitor className="h-5 w-5" />,
|
||||
shortcut: "E",
|
||||
group: "editor",
|
||||
panelWidth: 300,
|
||||
},
|
||||
];
|
||||
|
||||
export default LeftUnifiedToolbar;
|
||||
|
|
|
|||
|
|
@ -176,8 +176,13 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
onSelectedRowsChange,
|
||||
refreshKey,
|
||||
onConfigChange,
|
||||
...safeProps
|
||||
isPreview,
|
||||
autoGeneration,
|
||||
...restProps
|
||||
} = props;
|
||||
|
||||
// DOM 안전한 props만 필터링
|
||||
const safeProps = filterDOMProps(restProps);
|
||||
|
||||
// 컴포넌트의 columnName에 해당하는 formData 값 추출
|
||||
const fieldName = (component as any).columnName || component.id;
|
||||
|
|
@ -232,6 +237,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
};
|
||||
|
||||
// 렌더러 props 구성
|
||||
// component.style에서 height 제거 (RealtimePreviewDynamic에서 size.height로 처리)
|
||||
const { height: _height, ...styleWithoutHeight } = component.style || {};
|
||||
|
||||
const rendererProps = {
|
||||
component,
|
||||
isSelected,
|
||||
|
|
@ -240,7 +248,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
onDragEnd,
|
||||
size: component.size || newComponent.defaultSize,
|
||||
position: component.position,
|
||||
style: component.style,
|
||||
style: styleWithoutHeight,
|
||||
config: component.componentConfig,
|
||||
componentConfig: component.componentConfig,
|
||||
value: currentValue, // formData에서 추출한 현재 값 전달
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { WebTypeRegistry } from "./WebTypeRegistry";
|
|||
import { DynamicComponentProps } from "./types";
|
||||
// import { getWidgetComponentByWebType, getWidgetComponentByName } from "@/components/screen/widgets/types"; // 임시 비활성화
|
||||
import { useWebTypes } from "@/hooks/admin/useWebTypes";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
|
||||
/**
|
||||
* 동적 웹타입 렌더러 컴포넌트
|
||||
|
|
@ -160,8 +161,10 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
|
|||
|
||||
// 기본 폴백: Input 컴포넌트 사용
|
||||
const { Input } = require("@/components/ui/input");
|
||||
const { filterDOMProps } = require("@/lib/utils/domPropsFilter");
|
||||
console.log(`✅ 폴백: ${webType} 웹타입 → 기본 Input 사용`);
|
||||
return <Input placeholder={`${webType}`} disabled={props.readonly} className="w-full" {...props} />;
|
||||
const safeFallbackProps = filterDOMProps(props);
|
||||
return <Input placeholder={`${webType}`} disabled={props.readonly} className="w-full" {...safeFallbackProps} />;
|
||||
} catch (error) {
|
||||
console.error(`웹타입 "${webType}" 폴백 컴포넌트 렌더링 실패:`, error);
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -597,8 +597,27 @@ export const AccordionBasicComponent: React.FC<AccordionBasicComponentProps> = (
|
|||
formData: _formData,
|
||||
onFormDataChange: _onFormDataChange,
|
||||
componentConfig: _componentConfig,
|
||||
...domProps
|
||||
autoGeneration: _autoGeneration,
|
||||
hidden: _hidden,
|
||||
isInModal: _isInModal,
|
||||
isPreview: _isPreview,
|
||||
originalData: _originalData,
|
||||
allComponents: _allComponents,
|
||||
selectedRows: _selectedRows,
|
||||
selectedRowsData: _selectedRowsData,
|
||||
refreshKey: _refreshKey,
|
||||
onUpdateLayout: _onUpdateLayout,
|
||||
onSelectedRowsChange: _onSelectedRowsChange,
|
||||
onConfigChange: _onConfigChange,
|
||||
onZoneClick: _onZoneClick,
|
||||
selectedScreen: _selectedScreen,
|
||||
onZoneComponentDrop: _onZoneComponentDrop,
|
||||
...restProps
|
||||
} = props;
|
||||
|
||||
// filterDOMProps import 추가 필요
|
||||
const { filterDOMProps } = require("@/lib/utils/domPropsFilter");
|
||||
const domProps = filterDOMProps(restProps);
|
||||
|
||||
// 사용할 아이템들 결정 (우선순위: 데이터소스 > 정적아이템 > 기본아이템)
|
||||
const finalItems = (() => {
|
||||
|
|
|
|||
|
|
@ -186,7 +186,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
// selectedRowsData,
|
||||
// });
|
||||
|
||||
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
|
||||
// 스타일 계산
|
||||
// height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감
|
||||
const componentStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
|
|
@ -194,9 +195,11 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
...style,
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일
|
||||
|
||||
// 디자인 모드 스타일 (border 속성 분리하여 충돌 방지)
|
||||
if (isDesignMode) {
|
||||
componentStyle.border = "1px dashed #cbd5e1";
|
||||
componentStyle.borderWidth = "1px";
|
||||
componentStyle.borderStyle = "dashed";
|
||||
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
|
||||
}
|
||||
|
||||
|
|
@ -483,8 +486,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: "100%",
|
||||
maxHeight: "100%",
|
||||
minHeight: "40px",
|
||||
border: "none",
|
||||
borderRadius: "0.5rem",
|
||||
background: componentConfig.disabled
|
||||
|
|
|
|||
|
|
@ -179,6 +179,18 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
tableName: _tableName,
|
||||
onRefresh: _onRefresh,
|
||||
onClose: _onClose,
|
||||
autoGeneration: _autoGeneration,
|
||||
hidden: _hidden,
|
||||
isInModal: _isInModal,
|
||||
isPreview: _isPreview,
|
||||
originalData: _originalData,
|
||||
allComponents: _allComponents,
|
||||
selectedRows: _selectedRows,
|
||||
selectedRowsData: _selectedRowsData,
|
||||
refreshKey: _refreshKey,
|
||||
onUpdateLayout: _onUpdateLayout,
|
||||
onSelectedRowsChange: _onSelectedRowsChange,
|
||||
onConfigChange: _onConfigChange,
|
||||
...domProps
|
||||
} = props;
|
||||
|
||||
|
|
@ -582,7 +594,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={`relative w-full max-w-full overflow-hidden ${className || ""}`} {...safeDomProps}>
|
||||
<div className={`relative w-full ${className || ""}`} {...safeDomProps}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component.label && component.style?.labelDisplay !== false && (
|
||||
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600">
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ const REACT_ONLY_PROPS = new Set([
|
|||
// 상태 관련
|
||||
"mode",
|
||||
"isInModal",
|
||||
"isPreview",
|
||||
|
||||
// 테이블 관련
|
||||
"selectedRows",
|
||||
|
|
@ -48,6 +49,9 @@ const REACT_ONLY_PROPS = new Set([
|
|||
// 컴포넌트 기능 관련
|
||||
"autoGeneration",
|
||||
"hidden", // 이미 SAFE_DOM_PROPS에 있지만 커스텀 구현을 위해 제외
|
||||
|
||||
// 필터링할 특수 속성
|
||||
"readonly", // readOnly로 변환되므로 원본은 제거
|
||||
]);
|
||||
|
||||
// DOM에 안전하게 전달할 수 있는 표준 HTML 속성들
|
||||
|
|
@ -67,6 +71,28 @@ const SAFE_DOM_PROPS = new Set([
|
|||
"hidden",
|
||||
"spellCheck",
|
||||
"translate",
|
||||
|
||||
// 폼 관련 속성
|
||||
"readOnly",
|
||||
"disabled",
|
||||
"required",
|
||||
"placeholder",
|
||||
"value",
|
||||
"defaultValue",
|
||||
"checked",
|
||||
"defaultChecked",
|
||||
"name",
|
||||
"type",
|
||||
"accept",
|
||||
"autoComplete",
|
||||
"autoFocus",
|
||||
"multiple",
|
||||
"pattern",
|
||||
"min",
|
||||
"max",
|
||||
"step",
|
||||
"minLength",
|
||||
"maxLength",
|
||||
|
||||
// ARIA 속성 (aria-로 시작)
|
||||
// data 속성 (data-로 시작)
|
||||
|
|
@ -115,6 +141,12 @@ export function filterDOMProps<T extends Record<string, any>>(props: T): Partial
|
|||
continue;
|
||||
}
|
||||
|
||||
// readonly → readOnly 변환 (React의 camelCase 규칙)
|
||||
if (key === "readonly") {
|
||||
filtered["readOnly" as keyof T] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
// aria- 또는 data- 속성은 안전하게 포함
|
||||
if (key.startsWith("aria-") || key.startsWith("data-")) {
|
||||
filtered[key as keyof T] = value;
|
||||
|
|
|
|||
Loading…
Reference in New Issue