From 7dc0bbb329d2f6233534c8a726e49712510a1c22 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 9 Feb 2026 15:02:53 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A1=B0=EA=B1=B4=EB=B6=80=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=20=EA=B4=80=EB=A6=AC=20=EB=B0=8F=20=EC=95=A0?= =?UTF-8?q?=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=20=EC=B5=9C=EC=A0=81?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 레이어 저장 로직을 개선하여 conditionConfig의 명시적 전달 여부에 따라 저장 방식을 다르게 처리하도록 변경했습니다. - 조건부 레이어 로드 및 조건 평가 기능을 추가하여 레이어의 가시성을 동적으로 조정할 수 있도록 했습니다. - 컴포넌트 위치 변경 시 모든 애니메이션을 제거하여 사용자 경험을 개선했습니다. - LayerConditionPanel에서 조건 설정 시 기존 displayRegion을 보존하도록 업데이트했습니다. - RealtimePreview 및 ScreenDesigner에서 조건부 레이어의 크기를 적절히 조정하도록 수정했습니다. --- .../src/services/screenManagementService.ts | 32 ++- .../app/(main)/screens/[screenId]/page.tsx | 221 +++++++++++++++++- frontend/app/globals.css | 10 +- .../components/screen/LayerConditionPanel.tsx | 93 ++++++-- .../components/screen/LayerManagerPanel.tsx | 18 +- .../components/screen/RealtimePreview.tsx | 7 +- frontend/components/screen/ScreenDesigner.tsx | 65 ++++-- .../ConditionalSectionViewer.tsx | 2 +- 8 files changed, 389 insertions(+), 59 deletions(-) diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 244f2b2a..551fba91 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -5158,11 +5158,14 @@ export class ScreenManagementService { ): Promise { const layerId = layoutData.layerId || 1; const layerName = layoutData.layerName || (layerId === 1 ? '기본 레이어' : `레이어 ${layerId}`); + // conditionConfig가 명시적으로 전달되었는지 확인 (undefined = 미전달, null/object = 명시적 전달) + const hasConditionConfig = 'conditionConfig' in layoutData; const conditionConfig = layoutData.conditionConfig || null; console.log(`=== V2 레이아웃 저장 시작 ===`); console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 레이어: ${layerId} (${layerName})`); console.log(`컴포넌트 수: ${layoutData.components?.length || 0}`); + console.log(`조건 설정 포함 여부: ${hasConditionConfig}`); // 권한 확인 const screens = await query<{ company_code: string | null }>( @@ -5187,16 +5190,27 @@ export class ScreenManagementService { ...pureLayoutData, }; - // UPSERT (레이어별 저장) - await query( - `INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, condition_config, layout_data, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) - ON CONFLICT (screen_id, company_code, layer_id) - DO UPDATE SET layout_data = $6, layer_name = $4, condition_config = $5, updated_at = NOW()`, - [screenId, companyCode, layerId, layerName, conditionConfig ? JSON.stringify(conditionConfig) : null, JSON.stringify(dataToSave)], - ); + if (hasConditionConfig) { + // conditionConfig가 명시적으로 전달된 경우: condition_config도 함께 저장 + await query( + `INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, condition_config, layout_data, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) + ON CONFLICT (screen_id, company_code, layer_id) + DO UPDATE SET layout_data = $6, layer_name = $4, condition_config = $5, updated_at = NOW()`, + [screenId, companyCode, layerId, layerName, conditionConfig ? JSON.stringify(conditionConfig) : null, JSON.stringify(dataToSave)], + ); + } else { + // conditionConfig가 전달되지 않은 경우: 기존 condition_config 유지, layout_data만 업데이트 + await query( + `INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) + ON CONFLICT (screen_id, company_code, layer_id) + DO UPDATE SET layout_data = $5, layer_name = $4, updated_at = NOW()`, + [screenId, companyCode, layerId, layerName, JSON.stringify(dataToSave)], + ); + } - console.log(`V2 레이아웃 저장 완료 (레이어 ${layerId})`); + console.log(`V2 레이아웃 저장 완료 (레이어 ${layerId}, 조건설정 ${hasConditionConfig ? '포함' : '유지'})`); } /** diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 9f043adf..1b13e29a 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { Loader2 } from "lucide-react"; import { screenApi } from "@/lib/api/screen"; import { ScreenDefinition, LayoutData, ComponentData } from "@/types/screen"; +import { LayerDefinition } from "@/types/screen-management"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { initializeComponents } from "@/lib/registry/components"; @@ -86,6 +87,9 @@ function ScreenViewPage() { // 🆕 조건부 컨테이너 높이 추적 (컴포넌트 ID → 높이) const [conditionalContainerHeights, setConditionalContainerHeights] = useState>({}); + // 🆕 레이어 시스템 지원 + const [conditionalLayers, setConditionalLayers] = useState([]); + // 편집 모달 상태 const [editModalOpen, setEditModalOpen] = useState(false); const [editModalConfig, setEditModalConfig] = useState<{ @@ -204,6 +208,131 @@ function ScreenViewPage() { } }, [screenId]); + // 🆕 조건부 레이어 로드 (기본 레이어 외 모든 레이어 로드) + useEffect(() => { + const loadConditionalLayers = async () => { + if (!screenId || !layout) return; + + try { + // 1. 모든 레이어 목록 조회 + const allLayers = await screenApi.getScreenLayers(screenId); + // layer_id > 1인 레이어만 (기본 레이어 제외) + const nonBaseLayers = allLayers.filter((l: any) => l.layer_id > 1); + + if (nonBaseLayers.length === 0) { + setConditionalLayers([]); + return; + } + + // 2. 각 레이어의 레이아웃 데이터 로드 + const layerDefinitions: LayerDefinition[] = []; + + for (const layerInfo of nonBaseLayers) { + try { + const layerData = await screenApi.getLayerLayout(screenId, layerInfo.layer_id); + const condConfig = layerInfo.condition_config || layerData?.conditionConfig || {}; + + // 레이어 컴포넌트 변환 (V2 → Legacy) + // getLayerLayout 응답: { ...layout_data, layerId, layerName, conditionConfig } + // layout_data가 spread 되므로 components는 최상위에 있음 + let layerComponents: any[] = []; + const rawComponents = layerData?.components; + if (rawComponents && Array.isArray(rawComponents) && rawComponents.length > 0) { + // V2 컴포넌트를 Legacy 형식으로 변환 + const tempV2 = { + version: "2.0" as const, + components: rawComponents, + gridSettings: layerData.gridSettings, + screenResolution: layerData.screenResolution, + }; + if (isValidV2Layout(tempV2)) { + const converted = convertV2ToLegacy(tempV2); + if (converted) { + layerComponents = converted.components || []; + } + } + } + + // LayerDefinition 생성 + const layerDef: LayerDefinition = { + id: String(layerInfo.layer_id), + name: layerInfo.layer_name || `레이어 ${layerInfo.layer_id}`, + type: "conditional", + zIndex: layerInfo.layer_id * 10, + isVisible: false, // 조건 충족 시에만 표시 + isLocked: false, + condition: condConfig.targetComponentId ? { + targetComponentId: condConfig.targetComponentId, + operator: condConfig.operator || "eq", + value: condConfig.value, + } : undefined, + displayRegion: condConfig.displayRegion || undefined, + components: layerComponents, + }; + + layerDefinitions.push(layerDef); + } catch (layerError) { + console.warn(`레이어 ${layerInfo.layer_id} 로드 실패:`, layerError); + } + } + + console.log("🔄 조건부 레이어 로드 완료:", layerDefinitions.length, "개", layerDefinitions.map(l => ({ + id: l.id, name: l.name, condition: l.condition, displayRegion: l.displayRegion, + componentCount: l.components.length, + }))); + setConditionalLayers(layerDefinitions); + } catch (error) { + console.error("레이어 로드 실패:", error); + } + }; + + loadConditionalLayers(); + }, [screenId, layout]); + + // 🆕 조건부 레이어 조건 평가 (formData 변경 시 동기적으로 즉시 계산) + const activeLayerIds = useMemo(() => { + if (conditionalLayers.length === 0 || !layout) return [] as string[]; + + const allComponents = layout.components || []; + const newActiveIds: string[] = []; + + conditionalLayers.forEach((layer) => { + if (layer.condition) { + const { targetComponentId, operator, value } = layer.condition; + + // 트리거 컴포넌트 찾기 (기본 레이어에서) + const targetComponent = allComponents.find((c) => c.id === targetComponentId); + + // columnName으로 formData에서 값 조회 + const fieldKey = + (targetComponent as any)?.columnName || + (targetComponent as any)?.componentConfig?.columnName || + targetComponentId; + + const targetValue = formData[fieldKey]; + + let isMatch = false; + switch (operator) { + case "eq": + isMatch = targetValue == value; + break; + case "neq": + isMatch = targetValue != value; + break; + case "in": + isMatch = Array.isArray(value) && value.includes(targetValue); + break; + } + + if (isMatch) { + newActiveIds.push(layer.id); + } + } + }); + + return newActiveIds; + }, [formData, conditionalLayers, layout]); + // 🆕 메인 테이블 데이터 자동 로드 (단일 레코드 폼) // 화면의 메인 테이블에서 사용자 회사 코드로 데이터를 조회하여 폼에 자동 채움 useEffect(() => { @@ -513,6 +642,7 @@ function ScreenViewPage() { {layoutReady && layout && layout.components.length > 0 ? (
0) { + // 🆕 조건부 레이어 displayRegion 기반 높이 조정 + // 기본 레이어 컴포넌트는 displayRegion 포함한 위치에 배치되므로, + // 비활성(빈 영역) 시 아래 컴포넌트를 위로 당겨 빈 공간 제거 + for (const layer of conditionalLayers) { + if (!layer.displayRegion) continue; + const region = layer.displayRegion; + const regionBottom = region.y + region.height; + const isActive = activeLayerIds.includes(layer.id); + + // 컴포넌트가 조건부 영역 하단보다 아래에 있는 경우 + if (component.position.y >= regionBottom) { + if (!isActive) { + // 비활성: 영역 높이만큼 위로 당김 (빈 공간 제거) + totalHeightAdjustment -= region.height; + } + } + } + + if (totalHeightAdjustment !== 0) { return { ...component, position: { @@ -950,6 +1098,77 @@ function ScreenViewPage() {
); })} + + {/* 🆕 조건부 레이어 컴포넌트 렌더링 */} + {conditionalLayers.map((layer) => { + const isActive = activeLayerIds.includes(layer.id); + if (!isActive || !layer.components || layer.components.length === 0) return null; + + const region = layer.displayRegion; + + return ( +
+ {layer.components + .filter((comp) => !comp.parentId) + .map((comp) => ( + {}} + menuObjid={menuObjid} + screenId={screenId} + tableName={screen?.tableName} + userId={user?.userId} + userName={userName} + companyCode={companyCode} + selectedRowsData={selectedRowsData} + sortBy={tableSortBy} + sortOrder={tableSortOrder} + columnOrder={tableColumnOrder} + tableDisplayData={tableDisplayData} + onSelectedRowsChange={( + _, + selectedData, + sortBy, + sortOrder, + columnOrder, + tableDisplayData, + ) => { + setSelectedRowsData(selectedData); + setTableSortBy(sortBy); + setTableSortOrder(sortOrder || "asc"); + setTableColumnOrder(columnOrder); + setTableDisplayData(tableDisplayData || []); + }} + refreshKey={tableRefreshKey} + onRefresh={() => { + setTableRefreshKey((prev) => prev + 1); + setSelectedRowsData([]); + }} + formData={formData} + onFormDataChange={(fieldName, value) => { + setFormData((prev) => ({ ...prev, [fieldName]: value })); + }} + /> + ))} +
+ ); + })} ); })()} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index a252eaff..b8e7a178 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -263,12 +263,20 @@ input, textarea, select { transition-property: - color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, + color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, filter, backdrop-filter; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; } +/* 런타임 화면에서 컴포넌트 위치 변경 시 모든 애니메이션/트랜지션 완전 제거 */ +[data-screen-runtime] [id^="component-"] { + transition: none !important; +} +[data-screen-runtime] [data-conditional-layer] { + transition: none !important; +} + /* Disable animations for users who prefer reduced motion */ @media (prefers-reduced-motion: reduce) { *, diff --git a/frontend/components/screen/LayerConditionPanel.tsx b/frontend/components/screen/LayerConditionPanel.tsx index e74cc4a0..6a640ffc 100644 --- a/frontend/components/screen/LayerConditionPanel.tsx +++ b/frontend/components/screen/LayerConditionPanel.tsx @@ -81,16 +81,18 @@ export const LayerConditionPanel: React.FC = ({ const isTriggerComponent = (comp: ComponentData): boolean => { const componentType = (comp.componentType || "").toLowerCase(); const widgetType = ((comp as any).widgetType || "").toLowerCase(); - const webType = ((comp as any).webType || "").toLowerCase(); - const inputType = ((comp as any).componentConfig?.inputType || "").toLowerCase(); + const webType = ((comp as any).webType || comp.componentConfig?.webType || "").toLowerCase(); + const inputType = ((comp as any).inputType || comp.componentConfig?.inputType || "").toLowerCase(); + const source = ((comp as any).source || comp.componentConfig?.source || "").toLowerCase(); - // 셀렉트, 라디오, 코드 타입 컴포넌트만 허용 - const triggerTypes = ["select", "radio", "code", "checkbox", "toggle", "entity"]; + // 셀렉트, 라디오, 코드, 카테고리, 엔티티 타입 컴포넌트 허용 + const triggerTypes = ["select", "radio", "code", "checkbox", "toggle", "entity", "category"]; return triggerTypes.some((type) => componentType.includes(type) || widgetType.includes(type) || webType.includes(type) || - inputType.includes(type) + inputType.includes(type) || + source.includes(type) ); }; @@ -112,9 +114,21 @@ export const LayerConditionPanel: React.FC = ({ }, [components, baseLayerComponents]); // 선택된 컴포넌트 정보 + // 기본 레이어 + 현재 레이어 통합 컴포넌트 목록 (트리거 컴포넌트 검색용) + const allAvailableComponents = useMemo(() => { + const merged = [...(baseLayerComponents || []), ...components]; + // 중복 제거 (id 기준) + const seen = new Set(); + return merged.filter((c) => { + if (seen.has(c.id)) return false; + seen.add(c.id); + return true; + }); + }, [components, baseLayerComponents]); + const selectedComponent = useMemo(() => { - return components.find((c) => c.id === targetComponentId); - }, [components, targetComponentId]); + return allAvailableComponents.find((c) => c.id === targetComponentId); + }, [allAvailableComponents, targetComponentId]); // 선택된 컴포넌트의 데이터 소스 정보 추출 const dataSourceInfo = useMemo<{ @@ -136,8 +150,17 @@ export const LayerConditionPanel: React.FC = ({ const config = comp.componentConfig || comp.webTypeConfig || {}; const detailSettings = comp.detailSettings || {}; - // V2 컴포넌트: config.source 확인 - const source = config.source; + // V2 컴포넌트: source 확인 (componentConfig, 상위 레벨, inputType 모두 체크) + const source = config.source || comp.source; + const inputType = config.inputType || comp.inputType; + const webType = config.webType || comp.webType; + + // inputType/webType이 category면 카테고리로 판단 + if (inputType === "category" || webType === "category") { + const categoryTable = config.categoryTable || comp.tableName || config.tableName; + const categoryColumn = config.categoryColumn || comp.columnName || config.columnName; + return { type: "category", categoryTable, categoryColumn }; + } // 1. 카테고리 소스 (V2: source === "category", category_values 테이블) if (source === "category") { @@ -188,8 +211,17 @@ export const LayerConditionPanel: React.FC = ({ return { type: "none" }; }, [selectedComponent]); + // 의존성 안정화를 위한 직렬화 키 + const dataSourceKey = useMemo(() => { + const { type, categoryTable, categoryColumn, codeCategory, originTable, originColumn, referenceTable, referenceColumn } = dataSourceInfo; + return `${type}|${categoryTable || ""}|${categoryColumn || ""}|${codeCategory || ""}|${originTable || ""}|${originColumn || ""}|${referenceTable || ""}|${referenceColumn || ""}`; + }, [dataSourceInfo]); + // 컴포넌트 선택 시 옵션 목록 로드 (카테고리, 코드, 엔티티, 정적) useEffect(() => { + // race condition 방지 + let cancelled = false; + if (dataSourceInfo.type === "none") { setOptions([]); return; @@ -212,10 +244,13 @@ export const LayerConditionPanel: React.FC = ({ try { if (dataSourceInfo.type === "category" && dataSourceInfo.categoryTable && dataSourceInfo.categoryColumn) { // 카테고리 값에서 옵션 로드 (category_values 테이블) + console.log("[LayerCondition] 카테고리 옵션 로드:", dataSourceInfo.categoryTable, dataSourceInfo.categoryColumn); const response = await apiClient.get( `/table-categories/${dataSourceInfo.categoryTable}/${dataSourceInfo.categoryColumn}/values` ); + if (cancelled) return; const data = response.data; + console.log("[LayerCondition] 카테고리 API 응답:", data?.success, "항목수:", Array.isArray(data?.data) ? data.data.length : 0); if (data.success && data.data) { // 트리 구조를 평탄화 const flattenTree = (items: any[], depth = 0): ConditionOption[] => { @@ -232,22 +267,22 @@ export const LayerConditionPanel: React.FC = ({ } return result; }; - setOptions(flattenTree(Array.isArray(data.data) ? data.data : [])); + const loadedOptions = flattenTree(Array.isArray(data.data) ? data.data : []); + console.log("[LayerCondition] 카테고리 옵션 설정:", loadedOptions.length, "개"); + setOptions(loadedOptions); } else { setOptions([]); } } else if (dataSourceInfo.type === "code" && dataSourceInfo.codeCategory) { // 코드 카테고리에서 옵션 로드 const codes = await getCodesByCategory(dataSourceInfo.codeCategory); + if (cancelled) return; setOptions(codes.map((code) => ({ value: code.code, label: code.name, }))); } else if (dataSourceInfo.type === "entity") { // 엔티티 참조에서 옵션 로드 - // 방법 1: 원본 테이블.컬럼으로 entity-reference API 호출 - // (백엔드에서 table_type_columns를 통해 참조 테이블/컬럼을 자동 매핑) - // 방법 2: 직접 참조 테이블로 폴백 let entityLoaded = false; if (dataSourceInfo.originTable && dataSourceInfo.originColumn) { @@ -257,13 +292,13 @@ export const LayerConditionPanel: React.FC = ({ dataSourceInfo.originColumn, { limit: 100 } ); + if (cancelled) return; setOptions(entityData.options.map((opt) => ({ value: opt.value, label: opt.label, }))); entityLoaded = true; } catch { - // 원본 테이블.컬럼으로 실패 시 폴백 console.warn("원본 테이블.컬럼으로 엔티티 조회 실패, 직접 참조로 폴백"); } } @@ -277,6 +312,7 @@ export const LayerConditionPanel: React.FC = ({ refColumn, { limit: 100 } ); + if (cancelled) return; setOptions(entityData.options.map((opt) => ({ value: opt.value, label: opt.label, @@ -287,25 +323,32 @@ export const LayerConditionPanel: React.FC = ({ } } - // 모든 방법 실패 시 빈 옵션으로 설정하고 에러 표시하지 않음 - if (!entityLoaded) { - // 엔티티 소스이지만 테이블 조회 불가 시, 직접 입력 모드로 전환 + if (!entityLoaded && !cancelled) { setOptions([]); } } else { - setOptions([]); + if (!cancelled) setOptions([]); } } catch (error: any) { - console.error("옵션 목록 로드 실패:", error); - setLoadError(error.message || "옵션 목록을 불러올 수 없습니다."); - setOptions([]); + if (!cancelled) { + console.error("옵션 목록 로드 실패:", error); + setLoadError(error.message || "옵션 목록을 불러올 수 없습니다."); + setOptions([]); + } } finally { - setIsLoadingOptions(false); + if (!cancelled) { + setIsLoadingOptions(false); + } } }; loadOptions(); - }, [dataSourceInfo]); + + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dataSourceKey]); // 조건 저장 const handleSave = useCallback(() => { @@ -574,11 +617,11 @@ export const LayerConditionPanel: React.FC = ({ )} {/* 현재 조건 요약 */} - {targetComponentId && (value || multiValues.length > 0) && ( + {targetComponentId && selectedComponent && (value || multiValues.length > 0) && (
요약: - "{getComponentLabel(selectedComponent!)}" 값이{" "} + "{getComponentLabel(selectedComponent)}" 값이{" "} {operator === "eq" && `"${options.find(o => o.value === value)?.label || value}"와 같으면`} {operator === "neq" && `"${options.find(o => o.value === value)?.label || value}"와 다르면`} {operator === "in" && `[${multiValues.map(v => options.find(o => o.value === v)?.label || v).join(", ")}] 중 하나이면`} diff --git a/frontend/components/screen/LayerManagerPanel.tsx b/frontend/components/screen/LayerManagerPanel.tsx index 114723a1..c45ca18d 100644 --- a/frontend/components/screen/LayerManagerPanel.tsx +++ b/frontend/components/screen/LayerManagerPanel.tsx @@ -134,11 +134,25 @@ export const LayerManagerPanel: React.FC = ({ } }, [screenId, activeLayerId, loadLayers, onLayerChange]); - // 조건 업데이트 + // 조건 업데이트 (기존 condition_config의 displayRegion 보존) const handleUpdateCondition = useCallback(async (layerId: number, condition: LayerCondition | undefined) => { if (!screenId) return; try { - await screenApi.updateLayerCondition(screenId, layerId, condition || null); + // 기존 condition_config를 가져와서 displayRegion 보존 + const layerData = await screenApi.getLayerLayout(screenId, layerId); + const existingCondition = layerData?.conditionConfig || {}; + const displayRegion = existingCondition.displayRegion; + + let mergedCondition: any; + if (condition) { + // 조건 설정: 새 조건 + 기존 displayRegion 보존 + mergedCondition = { ...condition, ...(displayRegion ? { displayRegion } : {}) }; + } else { + // 조건 삭제: displayRegion만 남기거나, 없으면 null + mergedCondition = displayRegion ? { displayRegion } : null; + } + + await screenApi.updateLayerCondition(screenId, layerId, mergedCondition); toast.success("조건이 저장되었습니다."); await loadLayers(); } catch (error) { diff --git a/frontend/components/screen/RealtimePreview.tsx b/frontend/components/screen/RealtimePreview.tsx index 5a786616..4efcb696 100644 --- a/frontend/components/screen/RealtimePreview.tsx +++ b/frontend/components/screen/RealtimePreview.tsx @@ -561,9 +561,8 @@ export const RealtimePreviewDynamic: React.FC = ({ zIndex: position?.z || 1, // right 속성 강제 제거 right: undefined, - // 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동 - transition: - isOnSplitPanel && isButtonComponent ? (isDraggingSplitPanel ? "none" : "left 0.1s ease-out") : undefined, + // 모든 컴포넌트에서 transition 완전 제거 (위치 변경 시 애니메이션 방지) + transition: "none", }; // 선택된 컴포넌트 스타일 @@ -594,7 +593,7 @@ export const RealtimePreviewDynamic: React.FC = ({ return (
1 ? layerRegions[currentLayerId] : null; + const canvasBoundW = activeLayerRegion ? activeLayerRegion.width : screenResolution.width; + const canvasBoundH = activeLayerRegion ? activeLayerRegion.height : screenResolution.height; + const boundedX = Math.max(0, Math.min(dropX, canvasBoundW - componentWidth)); + const boundedY = Math.max(0, Math.min(dropY, canvasBoundH - componentHeight)); // 격자 스냅 적용 const snappedPosition = @@ -4265,9 +4269,15 @@ export default function ScreenDesigner({ const rawX = relativeMouseX - dragState.grabOffset.x; const rawY = relativeMouseY - dragState.grabOffset.y; + // 조건부 레이어 편집 시 displayRegion 크기 기준 경계 제한 + const dragLayerId = activeLayerIdRef.current || 1; + const dragLayerRegion = dragLayerId > 1 ? layerRegions[dragLayerId] : null; + const dragBoundW = dragLayerRegion ? dragLayerRegion.width : screenResolution.width; + const dragBoundH = dragLayerRegion ? dragLayerRegion.height : screenResolution.height; + const newPosition = { - x: Math.max(0, Math.min(rawX, screenResolution.width - componentWidth)), - y: Math.max(0, Math.min(rawY, screenResolution.height - componentHeight)), + x: Math.max(0, Math.min(rawX, dragBoundW - componentWidth)), + y: Math.max(0, Math.min(rawY, dragBoundH - componentHeight)), z: (dragState.draggedComponent.position as Position).z || 1, }; @@ -6623,28 +6633,50 @@ export default function ScreenDesigner({ {activeLayerId > 1 && (
- 레이어 {activeLayerId} 편집 중 + + 레이어 {activeLayerId} 편집 중 + {layerRegions[activeLayerId] && ( + + (캔버스: {layerRegions[activeLayerId].width} x {layerRegions[activeLayerId].height}px) + + )} + {!layerRegions[activeLayerId] && ( + + (조건부 영역 미설정 - 기본 레이어에서 영역을 먼저 배치하세요) + + )} +
)} {/* 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬 + contain 최적화 */} + {(() => { + // 🆕 조건부 레이어 편집 시 캔버스 크기를 displayRegion에 맞춤 + const activeRegion = activeLayerId > 1 ? layerRegions[activeLayerId] : null; + const canvasW = activeRegion ? activeRegion.width : screenResolution.width; + const canvasH = activeRegion ? activeRegion.height : screenResolution.height; + + return (
- {/* 실제 작업 캔버스 (해상도 크기) - 고정 크기 + 줌 적용 */} + {/* 실제 작업 캔버스 (해상도 크기 또는 조건부 레이어 영역 크기) */}
-
{" "} - {/* 🔥 줌 래퍼 닫기 */} +
+ ); /* 🔥 줌 래퍼 닫기 */ + })()}
{" "} {/* 메인 컨테이너 닫기 */} diff --git a/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx b/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx index 1338f40b..d55e12d0 100644 --- a/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx +++ b/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx @@ -105,7 +105,7 @@ export function ConditionalSectionViewer({ return (