* -------------------------------------------------------------------------
* This file is part of the MindStudio project.
* Copyright (c) 2025 Huawei Technologies Co.,Ltd.
*
* MindStudio is licensed under Mulan PSL v2.
* You can use this software according to the terms and conditions of the Mulan PSL v2.
* You may obtain a copy of Mulan PSL v2 at:
*
* http://license.coscl.org.cn/MulanPSL2
*
* THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
* EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
* MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
* See the Mulan PSL v2 for more details.
* -------------------------------------------------------------------------
*/
import styled from '@emotion/styled';
import { clamp } from 'lodash';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ArrowDownIcon, ArrowUpIcon } from '../icon/Icon';
import { themeInstance } from '../theme';
import { disableIframePointerEvent, recoverIframePointerEvent } from '../utils';
interface CssProps {
column: boolean;
translateXY: number;
draggableWH: string;
dragDirection: DragDirection;
minWH: number;
padding: number | string;
}
export interface ViewProps {
mainContainer: JSX.Element;
draggableContainer?: JSX.Element;
slot?: JSX.Element;
id: string;
padding?: number | string;
}
export enum DragDirection {
TOP = 0,
BOTTOM = 1,
LEFT = 2,
RIGHT = 3,
}
export interface DraggableContext {
dragDirection: DragDirection;
container: [number, number];
isOpen: React.MutableRefObject<boolean>;
minDragWh: number;
draggable: React.RefObject<HTMLDivElement>;
dragTranslate: number;
setDragTranslate: React.Dispatch<React.SetStateAction<number>>;
sizeMethod?: SizeMethod;
}
export enum SizeMethod {
NUMBER = 'number',
PERCENT = 'percent',
}
* @param dragDirection 拖动方向,DragDirection
* @param draggableWH 可拖动容器默认宽/高
* @param open 可拖动容器是否默认开启,可选
*/
interface DCProps {
dragDirection: DragDirection;
draggableWH: number;
open?: boolean;
minWH?: number;
foldWH?: number;
sizeMethod?: SizeMethod;
}
const MIN_HORIZONTAL_WH = 24;
const MIN_VERTICAL_WH = 36;
const ContainerBase = styled.div<CssProps>`
display: flex;
flex-grow: 1;
overflow: hidden;
width: 100%;
.bottomC {
background-color: ${(p): string => p.theme.contentBackgroundColor};
svg + .buttonShow {
position: absolute;
.caret {
position: absolute;
cursor: pointer;
top: 50%;
right: 0;
color: ${(p): string => p.theme.switchIconColor};
svg {
width: 10px;
}
}
}
& > .dragContainer {
height: 100%;
width: 100%;
}
}
`;
const ContainerLeft = styled(ContainerBase)`
flex-direction: row-reverse; // 应用 flex-direction: row-reverse; 并不会改变子元素或父容器相对于视口的位置。也就是说,如果弹性容器原本位于页面的某个位置(比如距离顶部50像素),那么即使你改变了内部子元素的排列顺序,这个容器及其内容相对于视口的位置保持不变。
height: 100%;
& > .topC {
flex: 1;
flex-flow: row;
overflow: hidden;
}
& > .bottomC {
position: relative;
height: 100%;
width: ${(p): string => p.draggableWH};
border-right: ${(p): string => p.theme.dividerColor} 2px solid;
display: flex;
& > .dragContainer {
overflow: hidden;
}
& > .splitLine {
position: absolute;
height: 100%;
width: 10px;
right: 0;
background-color: transparent;
border-right: ${(p): string => p.theme.dividerColor} 0 solid;
user-select: none;
&:hover[aria-disabled=false] {
border-right-width: 1px;
cursor: e-resize;
}
}
& > .buttonShow {
position: absolute;
cursor: pointer;
top: 50%;
z-index: 2;
transform: rotate(90deg);
right: -38px;
}
& > .caret {
position: absolute;
cursor: pointer;
transform: rotate(180deg);
z-index: 2;
top: 50%;
right: 0;
color: ${(p): string => p.theme.switchIconColor};
svg {
width: 10px;
}
}
&.width0 {
& > .buttonShow {
right: -53px;
}
}
}
`;
const ContainerRight = styled(ContainerBase)`
flex-direction: row;
height: 100%;
& > .topC {
flex: 1;
flex-flow: row;
overflow: hidden;
}
& > .bottomC {
position: relative;
height: 100%;
width: ${(p): string => p.draggableWH};
overflow: hidden;
display: flex;
& > .dragContainer {
z-index: 1;
}
& > .dragContainer[aria-disabled=true] {
border-left: ${(p): string => p.theme.dividerColor} 2px solid;
padding-left: 15px;
}
& > .splitLine {
position: absolute;
height: 100%;
width: 10px;
left: 0;
z-index: 1;
background-color: transparent;
border-left: ${(p): string => p.theme.dividerColor} 0 solid;
user-select: none;
&:hover[aria-disabled=false] {
border-left-width: 3px;
cursor: e-resize;
}
}
& > .buttonShow {
position: absolute;
cursor: pointer;
top: 50%;
z-index: 2;
transform: rotate(-90deg);
left: -37px;
opacity: 0.8;
}
& > .caret {
position: absolute;
cursor: pointer;
z-index: 2;
top: 50%;
left: 2px;
color: ${(p): string => p.theme.switchIconColor};
svg {
width: 10px;
}
}
}
`;
const ContainerBottom = styled(ContainerBase)`
flex-direction: column;
& > .topC {
width: 100%;
flex: 1;
flex-flow: row;
overflow: hidden;
padding: ${(p): number | string => p.padding ?? 0}px;
user-select: none;
}
& > .bottomC {
width: 100%;
height: ${(p): string => p.draggableWH};
border-top: ${(p): string => p.theme.dividerColor} 2px solid;
position: relative;
user-select: none;
& > .splitLine {
position: absolute;
z-index: 3;
height: 10px;
width: 100%;
top: 0;
background-color: transparent;
border-top: ${(p): string => p.theme.dividerColor} 0 solid;
user-select: none;
&:hover[aria-disabled=false] {
border-top-width: 1px;
cursor: n-resize;
}
}
& > .buttonShow {
position: absolute;
cursor: pointer;
top: 0;
left: calc(50% - 39px);
z-index: 4;
}
& > .caret {
position: absolute;
cursor: pointer;
z-index: 4;
left: calc(50% - 6px);;
top: -2px;
transform: rotate(90deg);
color: ${(p): string => p.theme.switchIconColor};
svg {
width: 10px;
}
}
}
& > .bottomC::before {
width: 100%;
top: -10px;
left: 0;
}
`;
const ContainerTop = styled(ContainerBase)`
flex-direction: column-reverse;
.topC {
height: calc(100vh - ${(p): string | number => p.translateXY === 0 ? p.draggableWH : p.minWH}px);
user-select: none;
}
.bottomC {
user-select: none;
top: 0;
left: 0;
right: 0;
height: ${(p): string => p.draggableWH}px;
.buttonShow {
position: absolute;
bottom: 0;
left: calc(50% - 39px);
z-index: 4;
}
}
`;
interface MovingState {
stat: 'idle' | 'movable' | 'moved';
startX: number;
startY: number;
screenX: number;
screenY: number;
}
const getHandleMouseDown = (dragDirection: DragDirection, draggable: React.RefObject<HTMLDivElement>,
movingState: React.MutableRefObject<MovingState>, isOpen: React.MutableRefObject<boolean>) => (e: MouseEvent): void => {
const domDrag = draggable.current;
if (!domDrag) { return; }
disableIframePointerEvent();
let offset; const baseMS: MovingState = { stat: 'movable', startX: 0, startY: 0, screenX: e.screenX, screenY: e.screenY };
const domDragRect = domDrag.getBoundingClientRect();
switch (dragDirection) {
case DragDirection.TOP:
offset = domDragRect.bottom - e.clientY;
if (offset <= 8 && offset > 0 && isOpen.current) {
movingState.current = {
...baseMS,
startX: domDragRect.x,
startY: domDragRect.bottom,
};
}
break;
case DragDirection.BOTTOM:
offset = e.clientY - domDragRect.top;
if (offset <= 8 && offset > 0 && isOpen.current) {
movingState.current = {
...baseMS,
startX: domDragRect.x,
startY: domDragRect.top,
};
}
break;
case DragDirection.LEFT:
offset = domDragRect.right - e.clientX;
if (offset <= 8 && offset > 0 && isOpen.current) {
movingState.current = {
...baseMS,
startX: domDragRect.right,
startY: domDragRect.y,
};
}
break;
default:
offset = e.clientX - domDragRect.left;
if (offset <= 8 && offset > 0 && isOpen.current) {
movingState.current = {
...baseMS,
startX: domDragRect.left,
startY: domDragRect.y,
};
}
break;
}
};
const RIGHT_PERCENT = 0.99;
const handleMouseMove = (container: React.RefObject<HTMLDivElement>, draggable: React.RefObject<HTMLDivElement>,
movingState: React.MutableRefObject<MovingState>, dragDirection: DragDirection, minDragWh: number) => (e: MouseEvent): void => {
const dom = container.current;
const domDrag = draggable.current;
const moving = movingState.current;
if (e.buttons !== 1) {
moving.stat = 'idle';
return;
}
if (!dom || !domDrag) { return; }
if (moving.stat === 'idle') { return; }
if (Math.abs(e.screenY - moving.screenY) < 2 && Math.abs(e.screenX - moving.screenX) < 2) { return; }
let offsetY: number;
let offsetX: number;
const domRect = dom.getBoundingClientRect();
switch (dragDirection) {
case DragDirection.BOTTOM:
offsetY = e.y - moving.startY;
if (Math.abs(offsetY) >= 5) {
domDrag.style.height = `${clamp(dom.clientHeight - e.y, minDragWh, dom.clientHeight - minDragWh)}px`;
}
break;
case DragDirection.TOP:
offsetY = e.y - moving.startY;
if (Math.abs(offsetY) >= 5) {
domDrag.style.height = `${clamp(e.y, minDragWh, dom.clientHeight - minDragWh)}px`;
}
break;
case DragDirection.LEFT:
offsetX = e.x - moving.startX;
if (Math.abs(offsetX) >= 5) {
domDrag.style.width = `${clamp(e.clientX - domRect.left, 245, dom.clientWidth - minDragWh)}px`;
}
break;
default:
offsetX = e.x - moving.startX;
if (Math.abs(offsetX) >= 5) {
domDrag.style.width = `${clamp(domRect.left + dom.clientWidth - e.clientX, minDragWh, dom.clientWidth * RIGHT_PERCENT)}px`;
}
break;
}
moving.stat = 'moved';
e.preventDefault();
};
const handleMouseUp = ({ container, draggable, movingState, dragDirection, minDragWh, sizeMethod }: {
container: React.RefObject<HTMLDivElement>;
draggable: React.RefObject<HTMLDivElement>;
movingState: React.MutableRefObject<MovingState>;
dragDirection: DragDirection;
minDragWh: number;
sizeMethod?: SizeMethod;
}) => (e: MouseEvent): void => {
recoverIframePointerEvent();
const dom = container.current;
const domDrag = draggable.current;
const moving = movingState.current;
const isDomInvalid = !dom || !domDrag || dom.clientHeight === 0 || dom.clientWidth === 0;
if (moving.stat !== 'moved' || isDomInvalid) {
moving.stat = 'idle';
return;
}
const domRect = dom.getBoundingClientRect();
let dragWHTmp: number;
switch (dragDirection) {
case DragDirection.TOP:
dragWHTmp = clamp(e.y, minDragWh, dom.clientHeight - minDragWh);
domDrag.style.height = sizeMethod === SizeMethod.NUMBER ? `${dragWHTmp}px` : `${dragWHTmp / dom.clientHeight * 100}%`;
window.dispatchEvent(new Event('topResize'));
break;
case DragDirection.BOTTOM:
dragWHTmp = clamp(dom.clientHeight - e.y, minDragWh, dom.clientHeight - minDragWh);
domDrag.style.height = sizeMethod === SizeMethod.NUMBER ? `${dragWHTmp}px` : `${dragWHTmp / dom.clientHeight * 100}%`;
window.dispatchEvent(new Event('bottomResize'));
break;
case DragDirection.LEFT:
dragWHTmp = clamp(e.clientX - domRect.left, 245, dom.clientWidth - minDragWh);
domDrag.style.width = sizeMethod === SizeMethod.NUMBER ? `${dragWHTmp}px` : `${dragWHTmp / dom.clientWidth * 100}%`;
window.dispatchEvent(new Event('leftResize'));
break;
case DragDirection.RIGHT:
dragWHTmp = clamp(domRect.left + dom.clientWidth - e.clientX, minDragWh, dom.clientWidth * RIGHT_PERCENT);
domDrag.style.width = sizeMethod === SizeMethod.NUMBER ? `${dragWHTmp}px` : `${dragWHTmp / dom.clientWidth * 100}%`;
window.dispatchEvent(new Event('rightResize'));
break;
default:
break;
}
movingState.current = {
stat: 'idle',
startX: 0,
startY: 0,
screenY: 0,
screenX: 0,
};
window.dispatchEvent(new Event('resize'));
};
const pxConvert = (px: number, container: [number, number], dragDirection: DragDirection, sizeMethod?: SizeMethod): string => {
let tempPx = px;
if (container[0] === 0 || container[1] === 0 || sizeMethod === SizeMethod.NUMBER || tempPx <= 40) {
return `${tempPx}px`;
}
if (dragDirection <= 1) {
if (dragDirection === 1) { tempPx += 4; }
return `${tempPx / container[1] * 100}%`;
} else {
return `${tempPx / container[0] * 100}%`;
}
};
const handleDraggableShow = (draggableProps: DraggableContext) => (): void => {
const domDrag = draggableProps.draggable.current;
if (!domDrag) { return; }
if (draggableProps.dragDirection <= 1) {
if (draggableProps.isOpen.current) {
draggableProps.setDragTranslate(domDrag.clientHeight);
domDrag.style.height = `${draggableProps.minDragWh}px`;
} else {
domDrag.style.height = `${pxConvert(draggableProps.dragTranslate, draggableProps.container, draggableProps.dragDirection, draggableProps.sizeMethod)}`;
draggableProps.setDragTranslate(0);
}
} else {
if (draggableProps.isOpen.current) {
draggableProps.setDragTranslate(domDrag.clientWidth);
domDrag.style.width = `${draggableProps.minDragWh}px`;
if (draggableProps.minDragWh === 0) {
domDrag.classList.add('width0');
}
} else {
domDrag.style.width = `${pxConvert(draggableProps.dragTranslate, draggableProps.container, draggableProps.dragDirection, draggableProps.sizeMethod)}`;
draggableProps.setDragTranslate(0);
domDrag.classList.remove('width0');
}
}
draggableProps.isOpen.current = !draggableProps.isOpen.current;
window.dispatchEvent(new Event('resize'));
};
const containerMap: Map<DragDirection, typeof ContainerBase> = new Map([
[DragDirection.TOP, ContainerTop],
[DragDirection.BOTTOM, ContainerBottom],
[DragDirection.LEFT, ContainerLeft],
[DragDirection.RIGHT, ContainerRight],
]);
const getMinDragWidth = (dragDirection: DragDirection, foldWH?: number): number => {
if (foldWH !== undefined && typeof foldWH === 'number') {
return foldWH;
}
return dragDirection <= 1 ? MIN_VERTICAL_WH : MIN_HORIZONTAL_WH;
};
* 在当前布局下创建两个容器,其中draggable容器可拖动改变宽/高,可点击收起隐藏,main容器自适应改变,根据传入拖动方向不同,提供leftResize/rightResize等事件
* @param props
* @return [view, handleOpen]
* view:可拖动布局构造函数;
* handleOpen:显示/隐藏可拖动容器;
*/
export const useDraggableContainer = (props: DCProps): [((props: ViewProps) => JSX.Element), ((needOpen?: boolean) => void), () => void] => {
const { draggableWH, dragDirection, foldWH, sizeMethod = SizeMethod.PERCENT, open = true } = props;
const container = useRef<HTMLDivElement>(null);
const draggable = useRef<HTMLDivElement>(null);
const [dragWh, setDragWh] = useState(String(draggableWH));
const [autoPopUp, setAutoPopUp] = useState(true);
const [containerWH, setContainerWH] = useState([0, 0] as [number, number]);
useEffect(() => { setDragWh(pxConvert(draggableWH, containerWH, dragDirection, sizeMethod)); }, [draggableWH, containerWH, dragDirection, sizeMethod]);
const MIN_DRAG_WH = useMemo(() => getMinDragWidth(dragDirection, foldWH), [dragDirection, foldWH]);
const [dragTranslate, setDragTranslate] = useState(open ? 0 : draggableWH);
const isOpen = useRef(dragTranslate === 0);
useEffect(() => {
const dom = container.current;
if (dom) {
setContainerWH([dom.clientWidth, dom.clientHeight]);
}
}, [isOpen.current, setContainerWH]);
const movingState = useRef<MovingState>({ stat: 'idle', startX: 0, startY: 0, screenY: 0, screenX: 0 });
const onMousedown = getHandleMouseDown(dragDirection, draggable, movingState, isOpen);
const onMousemove = handleMouseMove(container, draggable, movingState, dragDirection, MIN_DRAG_WH);
const onMouseup = handleMouseUp({ container, draggable, movingState, dragDirection, minDragWh: MIN_DRAG_WH, sizeMethod });
const showDraggable = handleDraggableShow({
dragDirection,
container: containerWH,
isOpen,
minDragWh: MIN_DRAG_WH,
draggable,
dragTranslate,
setDragTranslate,
sizeMethod,
});
const handleOpen = (needOpen = false): void => { if ((needOpen && !isOpen.current) || autoPopUp) { showDraggable(); setAutoPopUp(false); } };
const Container = containerMap.get(dragDirection) as typeof ContainerBase;
useEffect(() => {
const isDragTranslate = !isOpen.current && dragTranslate !== 0;
if (isDragTranslate && draggable.current && dragDirection === DragDirection.RIGHT) {
draggable.current.style.width = `${MIN_DRAG_WH}px`;
}
});
const DrawerButton = dragTranslate ? ArrowUpIcon : ArrowDownIcon;
const view = (viewProps: ViewProps): JSX.Element => {
return <Container
key={viewProps.id} ref={container} column translateXY={dragTranslate}
draggableWH={open ? dragWh : pxConvert(MIN_DRAG_WH, containerWH, dragDirection, sizeMethod)}
dragDirection={dragDirection} minWH={MIN_DRAG_WH}
padding={viewProps.padding ?? 0}
onMouseUp={(e): void => onMouseup(e.nativeEvent)} onMouseDown={(e): void => onMousedown(e.nativeEvent)}
onMouseMove={(e): void => onMousemove(e.nativeEvent)}>
<div className={'topC'}> {viewProps.mainContainer} </div>
<div className={'bottomC'} ref={draggable}>
<div className={'dragContainer'} aria-disabled={dragTranslate !== 0}>{viewProps.draggableContainer}</div>
<DrawerButton data-testid={'drawer-btn'} className={'buttonShow'} onClick={(): void => showDraggable()} theme={themeInstance.getCurrentTheme()}/>
<div className={'splitLine'} aria-disabled={dragTranslate !== 0} />
</div>
{viewProps.slot}
</Container>;
};
return [view, handleOpen, showDraggable];
};