ERP-node/frontend/lib/registry/components/split-panel-layout/SplitPanelContext.tsx

401 lines
13 KiB
TypeScript

"use client";
import React, { createContext, useContext, useState, useCallback, useRef, useMemo } from "react";
/**
* SplitPanelResize Context 타입 정의
* 분할 패널의 드래그 리사이즈 상태를 외부 컴포넌트(버튼 등)와 공유하기 위한 Context
*
* 주의: contexts/SplitPanelContext.tsx는 데이터 전달용 Context이고,
* 이 Context는 드래그 리사이즈 시 버튼 위치 조정을 위한 별도 Context입니다.
*/
/**
* 분할 패널 정보 (컴포넌트 좌표 기준)
*/
export interface SplitPanelInfo {
id: string;
// 분할 패널의 좌표 (스크린 캔버스 기준, px)
x: number;
y: number;
width: number;
height: number;
// 좌측 패널 비율 (0-100)
leftWidthPercent: number;
// 초기 좌측 패널 비율 (드래그 시작 시점)
initialLeftWidthPercent: number;
// 드래그 중 여부
isDragging: boolean;
}
export interface SplitPanelResizeContextValue {
// 등록된 분할 패널들
splitPanels: Map<string, SplitPanelInfo>;
// 분할 패널 등록/해제/업데이트
registerSplitPanel: (id: string, info: Omit<SplitPanelInfo, "id">) => void;
unregisterSplitPanel: (id: string) => void;
updateSplitPanel: (id: string, updates: Partial<SplitPanelInfo>) => void;
// 컴포넌트가 어떤 분할 패널의 좌측 영역 위에 있는지 확인
// 반환값: { panelId, offsetX } 또는 null
getOverlappingSplitPanel: (
componentX: number,
componentY: number,
componentWidth: number,
componentHeight: number,
) => { panelId: string; panel: SplitPanelInfo; isInLeftPanel: boolean } | null;
// 컴포넌트의 조정된 X 좌표 계산
// 분할 패널 좌측 영역 위에 있으면, 드래그에 따라 조정된 X 좌표 반환
getAdjustedX: (componentX: number, componentY: number, componentWidth: number, componentHeight: number) => number;
// 레거시 호환 (단일 분할 패널용)
leftWidthPercent: number;
containerRect: DOMRect | null;
dividerX: number;
isDragging: boolean;
splitPanelId: string | null;
updateLeftWidth: (percent: number) => void;
updateContainerRect: (rect: DOMRect | null) => void;
updateDragging: (dragging: boolean) => void;
}
// Context 생성
const SplitPanelResizeContext = createContext<SplitPanelResizeContextValue | null>(null);
/**
* SplitPanelResize Context Provider
* 스크린 빌더 레벨에서 감싸서 사용
*/
export const SplitPanelProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
// 등록된 분할 패널들
const splitPanelsRef = useRef<Map<string, SplitPanelInfo>>(new Map());
const [, forceUpdate] = useState(0);
// 레거시 호환용 상태
const [legacyLeftWidthPercent, setLegacyLeftWidthPercent] = useState(30);
const [legacyContainerRect, setLegacyContainerRect] = useState<DOMRect | null>(null);
const [legacyIsDragging, setLegacyIsDragging] = useState(false);
const [legacySplitPanelId, setLegacySplitPanelId] = useState<string | null>(null);
// 분할 패널 등록
const registerSplitPanel = useCallback((id: string, info: Omit<SplitPanelInfo, "id">) => {
splitPanelsRef.current.set(id, { id, ...info });
setLegacySplitPanelId(id);
setLegacyLeftWidthPercent(info.leftWidthPercent);
forceUpdate((n) => n + 1);
}, []);
// 분할 패널 해제
const unregisterSplitPanel = useCallback(
(id: string) => {
splitPanelsRef.current.delete(id);
if (legacySplitPanelId === id) {
setLegacySplitPanelId(null);
}
forceUpdate((n) => n + 1);
},
[legacySplitPanelId],
);
// 분할 패널 업데이트
const updateSplitPanel = useCallback((id: string, updates: Partial<SplitPanelInfo>) => {
const panel = splitPanelsRef.current.get(id);
if (panel) {
const updatedPanel = { ...panel, ...updates };
splitPanelsRef.current.set(id, updatedPanel);
// 레거시 호환 상태 업데이트
if (updates.leftWidthPercent !== undefined) {
setLegacyLeftWidthPercent(updates.leftWidthPercent);
}
if (updates.isDragging !== undefined) {
setLegacyIsDragging(updates.isDragging);
}
forceUpdate((n) => n + 1);
}
}, []);
/**
* 컴포넌트가 어떤 분할 패널의 좌측 영역 위에 있는지 확인
*/
const getOverlappingSplitPanel = useCallback(
(
componentX: number,
componentY: number,
componentWidth: number,
componentHeight: number,
): { panelId: string; panel: SplitPanelInfo; isInLeftPanel: boolean } | null => {
for (const [panelId, panel] of splitPanelsRef.current) {
// 컴포넌트의 중심점
const componentCenterX = componentX + componentWidth / 2;
const componentCenterY = componentY + componentHeight / 2;
// 컴포넌트가 분할 패널 영역 내에 있는지 확인
const isInPanelX = componentCenterX >= panel.x && componentCenterX <= panel.x + panel.width;
const isInPanelY = componentCenterY >= panel.y && componentCenterY <= panel.y + panel.height;
if (isInPanelX && isInPanelY) {
// 좌측 패널의 현재 너비 (px)
const leftPanelWidth = (panel.width * panel.leftWidthPercent) / 100;
// 좌측 패널 경계 (분할 패널 기준 상대 좌표)
const dividerX = panel.x + leftPanelWidth;
// 컴포넌트 중심이 좌측 패널 내에 있는지 확인
const isInLeftPanel = componentCenterX < dividerX;
return { panelId, panel, isInLeftPanel };
}
}
return null;
},
[],
);
/**
* 컴포넌트의 조정된 X 좌표 계산
* 분할 패널 좌측 영역 위에 있으면, 드래그에 따라 조정된 X 좌표 반환
*
* 핵심 로직:
* - 버튼의 원래 X 좌표가 초기 좌측 패널 너비 내에서 어느 비율에 있는지 계산
* - 드래그로 좌측 패널 너비가 바뀌면, 같은 비율을 유지하도록 X 좌표 조정
*/
const getAdjustedX = useCallback(
(componentX: number, componentY: number, componentWidth: number, componentHeight: number): number => {
const overlap = getOverlappingSplitPanel(componentX, componentY, componentWidth, componentHeight);
if (!overlap || !overlap.isInLeftPanel) {
// 분할 패널 위에 없거나, 우측 패널 위에 있으면 원래 위치 유지
return componentX;
}
const { panel } = overlap;
// 초기 좌측 패널 너비 (설정된 splitRatio 기준)
const initialLeftPanelWidth = (panel.width * panel.initialLeftWidthPercent) / 100;
// 현재 좌측 패널 너비 (드래그로 변경된 값)
const currentLeftPanelWidth = (panel.width * panel.leftWidthPercent) / 100;
// 변화가 없으면 원래 위치 반환
if (Math.abs(initialLeftPanelWidth - currentLeftPanelWidth) < 1) {
return componentX;
}
// 컴포넌트의 분할 패널 내 상대 X 좌표
const relativeX = componentX - panel.x;
// 좌측 패널 내에서의 비율 (0~1)
const ratioInLeftPanel = relativeX / initialLeftPanelWidth;
// 조정된 상대 X 좌표 = 원래 비율 * 현재 좌측 패널 너비
const adjustedRelativeX = ratioInLeftPanel * currentLeftPanelWidth;
// 절대 X 좌표로 변환
const adjustedX = panel.x + adjustedRelativeX;
console.log("📍 [SplitPanel] 버튼 위치 조정:", {
componentX,
panelX: panel.x,
relativeX,
initialLeftPanelWidth,
currentLeftPanelWidth,
ratioInLeftPanel,
adjustedX,
delta: adjustedX - componentX,
});
return adjustedX;
},
[getOverlappingSplitPanel],
);
// 레거시 호환 - dividerX 계산
const legacyDividerX = legacyContainerRect ? (legacyContainerRect.width * legacyLeftWidthPercent) / 100 : 0;
// 레거시 호환 함수들
const updateLeftWidth = useCallback((percent: number) => {
setLegacyLeftWidthPercent(percent);
// 첫 번째 분할 패널 업데이트
const firstPanelId = splitPanelsRef.current.keys().next().value;
if (firstPanelId) {
const panel = splitPanelsRef.current.get(firstPanelId);
if (panel) {
splitPanelsRef.current.set(firstPanelId, { ...panel, leftWidthPercent: percent });
}
}
forceUpdate((n) => n + 1);
}, []);
const updateContainerRect = useCallback((rect: DOMRect | null) => {
setLegacyContainerRect(rect);
}, []);
const updateDragging = useCallback((dragging: boolean) => {
setLegacyIsDragging(dragging);
// 첫 번째 분할 패널 업데이트
const firstPanelId = splitPanelsRef.current.keys().next().value;
if (firstPanelId) {
const panel = splitPanelsRef.current.get(firstPanelId);
if (panel) {
// 드래그 시작 시 초기 비율 저장
const updates: Partial<SplitPanelInfo> = { isDragging: dragging };
if (dragging) {
updates.initialLeftWidthPercent = panel.leftWidthPercent;
}
splitPanelsRef.current.set(firstPanelId, { ...panel, ...updates });
}
}
forceUpdate((n) => n + 1);
}, []);
const value = useMemo<SplitPanelResizeContextValue>(
() => ({
splitPanels: splitPanelsRef.current,
registerSplitPanel,
unregisterSplitPanel,
updateSplitPanel,
getOverlappingSplitPanel,
getAdjustedX,
// 레거시 호환
leftWidthPercent: legacyLeftWidthPercent,
containerRect: legacyContainerRect,
dividerX: legacyDividerX,
isDragging: legacyIsDragging,
splitPanelId: legacySplitPanelId,
updateLeftWidth,
updateContainerRect,
updateDragging,
}),
[
registerSplitPanel,
unregisterSplitPanel,
updateSplitPanel,
getOverlappingSplitPanel,
getAdjustedX,
legacyLeftWidthPercent,
legacyContainerRect,
legacyDividerX,
legacyIsDragging,
legacySplitPanelId,
updateLeftWidth,
updateContainerRect,
updateDragging,
],
);
return <SplitPanelResizeContext.Provider value={value}>{children}</SplitPanelResizeContext.Provider>;
};
/**
* SplitPanelResize Context 사용 훅
* 분할 패널의 드래그 리사이즈 상태를 구독합니다.
*/
export const useSplitPanel = (): SplitPanelResizeContextValue => {
const context = useContext(SplitPanelResizeContext);
// Context가 없으면 기본값 반환 (Provider 외부에서 사용 시)
if (!context) {
return {
splitPanels: new Map(),
registerSplitPanel: () => {},
unregisterSplitPanel: () => {},
updateSplitPanel: () => {},
getOverlappingSplitPanel: () => null,
getAdjustedX: (x) => x,
leftWidthPercent: 30,
containerRect: null,
dividerX: 0,
isDragging: false,
splitPanelId: null,
updateLeftWidth: () => {},
updateContainerRect: () => {},
updateDragging: () => {},
};
}
return context;
};
/**
* 컴포넌트의 조정된 위치를 계산하는 훅
* 분할 패널 좌측 영역 위에 있으면, 드래그에 따라 X 좌표가 조정됨
*
* @param componentX - 컴포넌트의 X 좌표 (px)
* @param componentY - 컴포넌트의 Y 좌표 (px)
* @param componentWidth - 컴포넌트 너비 (px)
* @param componentHeight - 컴포넌트 높이 (px)
* @returns 조정된 X 좌표와 관련 정보
*/
export const useAdjustedComponentPosition = (
componentX: number,
componentY: number,
componentWidth: number,
componentHeight: number,
) => {
const context = useSplitPanel();
const adjustedX = context.getAdjustedX(componentX, componentY, componentWidth, componentHeight);
const overlap = context.getOverlappingSplitPanel(componentX, componentY, componentWidth, componentHeight);
return {
adjustedX,
isInSplitPanel: !!overlap,
isInLeftPanel: overlap?.isInLeftPanel ?? false,
isDragging: overlap?.panel.isDragging ?? false,
panelId: overlap?.panelId ?? null,
};
};
/**
* 버튼 등 외부 컴포넌트에서 분할 패널 좌측 영역 내 위치를 계산하는 훅 (레거시 호환)
*/
export const useAdjustedPosition = (originalXPercent: number) => {
const { leftWidthPercent, containerRect, dividerX, isDragging } = useSplitPanel();
const isInLeftPanel = originalXPercent <= leftWidthPercent;
const adjustedXPercent = isInLeftPanel ? (originalXPercent / 100) * leftWidthPercent : originalXPercent;
const adjustedXPx = containerRect ? (containerRect.width * adjustedXPercent) / 100 : 0;
return {
adjustedXPercent,
adjustedXPx,
isInLeftPanel,
isDragging,
dividerX,
containerRect,
leftWidthPercent,
};
};
/**
* 버튼이 좌측 패널 위에 배치되었을 때, 드래그에 따라 위치가 조정되는 스타일을 반환하는 훅 (레거시 호환)
*/
export const useSplitPanelAwarePosition = (
initialLeftPercent: number,
options?: {
followDivider?: boolean;
offset?: number;
},
) => {
const { leftWidthPercent, containerRect, dividerX, isDragging } = useSplitPanel();
const { followDivider = false, offset = 0 } = options || {};
if (followDivider) {
return {
left: containerRect ? `${dividerX + offset}px` : `${leftWidthPercent}%`,
transition: isDragging ? "none" : "left 0.15s ease-out",
};
}
const adjustedLeft = (initialLeftPercent / 100) * leftWidthPercent;
return {
left: `${adjustedLeft}%`,
transition: isDragging ? "none" : "left 0.15s ease-out",
};
};
export default SplitPanelResizeContext;