diff --git a/packages/components/dialog/Dialog.tsx b/packages/components/dialog/Dialog.tsx index 50f2a818f3..7e1e3bafa8 100644 --- a/packages/components/dialog/Dialog.tsx +++ b/packages/components/dialog/Dialog.tsx @@ -19,6 +19,7 @@ import useLockStyle from './hooks/useLockStyle'; import { parseValueToPx } from './utils'; import type { StyledProps } from '../common'; import type { DialogInstance, TdDialogProps } from './type'; +import useDialogResize from './hooks/useDialogResize'; export interface DialogProps extends TdDialogProps, StyledProps { isPlugin?: boolean; // 是否以插件形式调用 @@ -39,6 +40,8 @@ const Dialog = forwardRef((originalProps, ref) => { const [state, setState] = useSetState({ isPlugin: false, ...restProps }); const [local] = useLocaleReceiver('dialog'); + const dragResizing = useRef(false); + const { className, dialogClassName, @@ -77,6 +80,13 @@ const Dialog = forwardRef((originalProps, ref) => { useLockStyle({ preventScrollThrough, visible, mode, showInAttachedElement }); useDialogEsc(visible, wrapRef); useDialogPosition(visible, dialogCardRef); + + useDialogResize({ + dialogCardRef, + sizeDraggableProps: mode === 'modeless' ? props.sizeDraggable : false, + onDragResizeChange(resizing) { dragResizing.current = resizing; }, + }); + useDialogDrag({ dialogCardRef, canDraggable: draggable && mode === 'modeless', @@ -113,6 +123,10 @@ const Dialog = forwardRef((originalProps, ref) => { } const onMaskClick = (e: React.MouseEvent) => { + if (dragResizing.current) { + return; + } + if (showOverlay && (closeOnOverlayClick ?? local.closeOnOverlayClick)) { // 判断点击事件初次点击是否为内容区域 if (contentClickRef.current) { diff --git a/packages/components/dialog/defaultProps.ts b/packages/components/dialog/defaultProps.ts index 919abbcbf7..8cfe577f57 100644 --- a/packages/components/dialog/defaultProps.ts +++ b/packages/components/dialog/defaultProps.ts @@ -22,4 +22,5 @@ export const dialogDefaultProps: TdDialogProps = { showInAttachedElement: false, showOverlay: true, lazy: true, + sizeDraggable: false, }; diff --git a/packages/components/dialog/dialog.en-US.md b/packages/components/dialog/dialog.en-US.md index 4f238034b4..9f626ac7ca 100644 --- a/packages/components/dialog/dialog.en-US.md +++ b/packages/components/dialog/dialog.en-US.md @@ -38,6 +38,7 @@ placement | String | top | options: top/center | N preventScrollThrough | Boolean | true | \- | N showInAttachedElement | Boolean | false | \- | N showOverlay | Boolean | true | \- | N +sizeDraggable | Boolean / Object | false | allow resizing dialog width/height, set max or min to limit size.。Typescript:`boolean \| DialogSizeDragLimit` `interface DialogSizeDragLimit { maxWidth: number\|undefined, minWidth: number\|undefined, maxHeight: number\|undefined, minHeight: number\|undefined }`。[see more ts definition](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/dialog/type.ts) | N theme | String | default | options: default/info/warning/danger/success | N top | String / Number | - | \- | N visible | Boolean | - | \- | N diff --git a/packages/components/dialog/dialog.md b/packages/components/dialog/dialog.md index bf479a31de..32064e5d55 100644 --- a/packages/components/dialog/dialog.md +++ b/packages/components/dialog/dialog.md @@ -59,6 +59,7 @@ placement | String | top | 对话框位置,内置两种:垂直水平居中 preventScrollThrough | Boolean | true | 防止滚动穿透 | N showInAttachedElement | Boolean | false | 仅在挂载元素中显示抽屉,默认在浏览器可视区域显示。父元素需要有定位属性,如:position: relative | N showOverlay | Boolean | true | 是否显示遮罩层 | N +sizeDraggable | Boolean / Object | false | 弹窗大小可拖拽调整。`sizeDraggable.maxWidth`、`sizeDraggable.minWidth`、`sizeDraggable.maxHeight`、`sizeDraggable.minHeight` 用于控制拖拽尺寸大小限制。。TS 类型:`boolean \| DialogSizeDragLimit` `interface DialogSizeDragLimit { maxWidth: number\|undefined, minWidth: number\|undefined, maxHeight: number\|undefined, minHeight: number\|undefined }`。[详细类型定义](https://github.com/Tencent/tdesign-react/blob/develop/packages/components/dialog/type.ts) | N theme | String | default | 对话框风格。可选项:default/info/warning/danger/success | N top | String / Number | - | 用于弹框具体窗口顶部的距离,优先级大于 placement | N visible | Boolean | - | 控制对话框是否显示 | N diff --git a/packages/components/dialog/hooks/useDialogDrag.ts b/packages/components/dialog/hooks/useDialogDrag.ts index decff73b17..d0e2da7d15 100644 --- a/packages/components/dialog/hooks/useDialogDrag.ts +++ b/packages/components/dialog/hooks/useDialogDrag.ts @@ -6,6 +6,8 @@ interface DialogDragProps { canDraggable?: boolean; } +const RESERVED_BORDER_WIDTH = 8; // 保留边框宽度,避免拖拽时误操作 + const useDialogDrag = (props: DialogDragProps) => { const { dialogCardRef, canDraggable } = props; @@ -15,18 +17,34 @@ const useDialogDrag = (props: DialogDragProps) => { const dragOffset = useRef({ x: 0, y: 0 }); + const moving = useRef(false); + useMouseEvent(dialogCardRef, { enabled: canDraggable, onDown: (e) => { const { offsetLeft, offsetTop, offsetWidth, offsetHeight, style } = dialogCardRef.current; - if (offsetWidth > screenWidth || offsetHeight > screenHeight) return; + if (offsetWidth > screenWidth || offsetHeight > screenHeight) + return; + + + if (offsetLeft !== 0 || offsetTop !== 0 || offsetWidth !== 0 || offsetHeight !== 0) { + if (e.clientX - offsetLeft <= RESERVED_BORDER_WIDTH || e.clientX - offsetLeft >= offsetWidth - RESERVED_BORDER_WIDTH) + return; + if (e.clientY - offsetTop <= RESERVED_BORDER_WIDTH || e.clientY - offsetTop >= offsetHeight - RESERVED_BORDER_WIDTH) + return; + } + style.cursor = 'move'; + moving.current = true; dragOffset.current = { x: e.clientX - offsetLeft, y: e.clientY - offsetTop, }; }, onMove: (e) => { + if (!moving.current) + return; + const { offsetWidth, offsetHeight, style } = dialogCardRef.current; let diffX = e.clientX - dragOffset.current.x; let diffY = e.clientY - dragOffset.current.y; @@ -40,7 +58,10 @@ const useDialogDrag = (props: DialogDragProps) => { style.top = `${diffY}px`; }, onUp: () => { - dialogCardRef.current.style.cursor = 'default'; + if (moving.current) { + moving.current = false; + dialogCardRef.current.style.cursor = 'default'; + } }, }); }; diff --git a/packages/components/dialog/hooks/useDialogResize.ts b/packages/components/dialog/hooks/useDialogResize.ts new file mode 100644 index 0000000000..c7b76518e9 --- /dev/null +++ b/packages/components/dialog/hooks/useDialogResize.ts @@ -0,0 +1,220 @@ +import React from "react"; +import useMouseEvent from "../../hooks/useMouseEvent"; +import { DialogSizeDragLimit } from "../type"; + + +const RESIZE_BORDER_WIDTH = 8; // 边框宽度,拖拽时的感应区域 + + +interface DialogResizeProps { + dialogCardRef: React.MutableRefObject; + sizeDraggableProps: boolean | DialogSizeDragLimit; + onDragResizeChange: (resizing: boolean) => void; +} + + +type ResizeDirection = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw' | false; + + +function mouseOnBorder( + e: React.MouseEvent, + dialogCardRef: React.MutableRefObject +): ResizeDirection { + const mouseX = e.clientX; + const mouseY = e.clientY; + const { left, top, width, height } = dialogCardRef.current.getBoundingClientRect(); + + const borderWidth = RESIZE_BORDER_WIDTH; + const onLeftBorder = mouseX >= left - borderWidth && mouseX <= left + borderWidth; + const onRightBorder = mouseX >= left + width - borderWidth && mouseX <= left + width + borderWidth; + const onTopBorder = mouseY >= top - borderWidth && mouseY <= top + borderWidth; + const onBottomBorder = mouseY >= top + height - borderWidth && mouseY <= top + height + borderWidth; + if (onLeftBorder && onTopBorder) + return 'nw'; + if (onRightBorder && onBottomBorder) + return 'se'; + if (onRightBorder && onTopBorder) + return 'ne'; + if (onLeftBorder && onBottomBorder) + return 'sw'; + if (onLeftBorder) + return 'w'; + if (onRightBorder) + return 'e'; + if (onTopBorder) + return 'n'; + if (onBottomBorder) + return 's'; + return false; +} + + +const useDialogResize = (props: DialogResizeProps) => { + const { dialogCardRef, sizeDraggableProps } = props; + + const validWindow = typeof window === 'object'; + + const getWindowHeight = () => (validWindow ? window.innerHeight || document.documentElement.clientHeight : undefined); + const getWindowWidth = () => (validWindow ? window.innerWidth || document.documentElement.clientWidth : undefined); + + const dialogSize = React.useRef({ x: 0, y: 0, width: 0, height: 0 }); + + const resizingDirection = React.useRef(false); + + const minWidth = React.useRef(0); + const maxWidth = React.useRef(Number.MAX_VALUE); + const minHeight = React.useRef(undefined); // If undefined, set to current height on first drag (lower than which causes buttons overflow). + const maxHeight = React.useRef(Number.MAX_VALUE); + + + React.useEffect(() => { + if (sizeDraggableProps === undefined || typeof sizeDraggableProps === 'boolean') { + minWidth.current = 0; + maxWidth.current = Number.MAX_VALUE; + minHeight.current = 0; + maxHeight.current = Number.MAX_VALUE; + return; + } + + if (sizeDraggableProps.maxHeight !== undefined) { + maxHeight.current = sizeDraggableProps.maxHeight; + } + + if (sizeDraggableProps.minHeight !== undefined) { + minHeight.current = sizeDraggableProps.minHeight; + } + + if (sizeDraggableProps.maxWidth !== undefined) { + maxWidth.current = sizeDraggableProps.maxWidth; + } + + if (sizeDraggableProps.minWidth !== undefined) { + minWidth.current = sizeDraggableProps.minWidth; + } + + }, [sizeDraggableProps]); + + + useMouseEvent(dialogCardRef, { + enabled: sizeDraggableProps !== undefined && sizeDraggableProps !== false, + alwaysEmitOnMove: sizeDraggableProps !== undefined && sizeDraggableProps !== false, + onDown: (e: React.MouseEvent) => { + if (minHeight.current === undefined && dialogCardRef.current) { + minHeight.current = dialogCardRef.current.offsetHeight; + } + + resizingDirection.current = mouseOnBorder(e, dialogCardRef); + if (resizingDirection.current !== false) { + dialogSize.current = { + x: dialogCardRef.current.offsetLeft, + y: dialogCardRef.current.offsetTop, + width: dialogCardRef.current.offsetWidth, + height: dialogCardRef.current.offsetHeight, + }; + + props.onDragResizeChange(true); + + e.stopPropagation(); + e.preventDefault(); + } + }, + onMove: (e: React.MouseEvent) => { + if (dialogCardRef.current.style.cursor === 'move') + return; + + // Check whether we should update cursor style. + const direction = mouseOnBorder(e, dialogCardRef); + if (direction) { + let cursor = ''; + if (direction === 'n' || direction === 's') + cursor = `${direction}-resize`; + else if (direction === 'e' || direction === 'w') + cursor = `${direction}-resize`; + else if (direction === 'ne' || direction === 'sw') + cursor = 'nesw-resize'; + else if (direction === 'nw' || direction === 'se') + cursor = 'nwse-resize'; + dialogCardRef.current.style.cursor = cursor; + } else if (resizingDirection.current === false) + dialogCardRef.current.style.cursor = 'default'; + + if (resizingDirection.current === false) + return; + + + // Do resize. + const dir = resizingDirection.current; + const {style} = dialogCardRef.current; + + style.position = 'absolute'; + + const mouseX = e.clientX; + const mouseY = e.clientY; + + if (mouseX <= 4 || mouseY <= 4 || mouseX >= getWindowWidth() - 4 || mouseY >= getWindowHeight() - 4) { + return; // Just ignore. + } + + let nextHeight = dialogCardRef.current.offsetHeight; + let nextWidth = dialogCardRef.current.offsetWidth; + let nextTop = dialogCardRef.current.offsetTop; + let nextLeft = dialogCardRef.current.offsetLeft; + + + if (dir.includes('n')) { // upper + const deltaY = mouseY - dialogSize.current.y; + nextHeight = dialogSize.current.height - deltaY; + nextTop = dialogSize.current.y + deltaY; + } + else if (dir.includes('s')) { // lower + const deltaY = mouseY - dialogSize.current.y - dialogSize.current.height; + nextHeight = dialogSize.current.height + deltaY; + } + + if (dir.includes('w')) { // left + const deltaX = mouseX - dialogSize.current.x; + nextWidth = dialogSize.current.width - deltaX; + nextLeft = dialogSize.current.x + deltaX; + } + else if (dir.includes('e')) { // right + const deltaX = mouseX - dialogSize.current.x - dialogSize.current.width; + nextWidth = dialogSize.current.width + deltaX; + } + + + style.left = `${nextLeft}px`; + style.width = `${nextWidth}px`; + dialogSize.current.x = nextLeft; + dialogSize.current.width = nextWidth; + + + if (nextHeight >= minHeight.current) { + style.top = `${nextTop}px`; + style.height = `${nextHeight}px`; + dialogSize.current.y = nextTop; + dialogSize.current.height = nextHeight; + } + + e.stopPropagation(); + e.preventDefault(); + + }, + onUp: (e: React.MouseEvent) => { + + if (resizingDirection.current === false) + return; + + resizingDirection.current = false; + + // prevent triggering overlay click. + setTimeout(() => { + props.onDragResizeChange(false); + }, 0); + + e.stopPropagation(); + e.preventDefault(); + }, + }); +}; + +export default useDialogResize; diff --git a/packages/components/dialog/type.ts b/packages/components/dialog/type.ts index 61e082508d..d716d646eb 100644 --- a/packages/components/dialog/type.ts +++ b/packages/components/dialog/type.ts @@ -111,6 +111,11 @@ export interface TdDialogProps { * @default true */ showOverlay?: boolean; + /** + * 弹窗大小可拖拽调整。`sizeDraggable.maxWidth`、`sizeDraggable.minWidth`、`sizeDraggable.maxHeight`、`sizeDraggable.minHeight` 用于控制拖拽尺寸大小限制。 + * @default false + */ + sizeDraggable?: boolean | DialogSizeDragLimit; /** * 对话框风格 * @default default @@ -230,6 +235,13 @@ export interface DialogInstance { update: (props: DialogOptions) => void; } +export interface DialogSizeDragLimit { + maxWidth: number | undefined; + minWidth: number | undefined; + maxHeight: number | undefined; + minHeight: number | undefined; +} + export type DialogEventSource = 'esc' | 'close-btn' | 'cancel' | 'overlay'; export interface DialogCloseContext { diff --git a/packages/components/hooks/useMouseEvent.ts b/packages/components/hooks/useMouseEvent.ts index 9855f0e4bc..fd7b59105c 100644 --- a/packages/components/hooks/useMouseEvent.ts +++ b/packages/components/hooks/useMouseEvent.ts @@ -22,6 +22,8 @@ type MouseEventOptions = { onUp?: (e: MouseCallback, ctx: MouseContext) => void; onEnter?: (e: MouseCallback, ctx: MouseContext) => void; onLeave?: (e: MouseCallback, ctx: MouseContext) => void; + + alwaysEmitOnMove?: boolean }; const useMouseEvent = (elementRef: React.RefObject, options: MouseEventOptions) => { @@ -66,7 +68,7 @@ const useMouseEvent = (elementRef: React.RefObject, options: MouseE }; const handleMouseMove = (e: MouseEventLike) => { - if (!isMovingRef.current) return; + if (!isMovingRef.current && !options.alwaysEmitOnMove) return; e.preventDefault(); emitMouseChange(e, options.onMove); };