401 lines
13 KiB
TypeScript
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;
|