From 984a058e388a2a372715646d96753d9b23533229 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Fri, 20 Dec 2024 11:37:18 +0100 Subject: [PATCH] Support resize for mini apps modal (#5319) --- .../modals/webApp/WebAppModal.module.scss | 72 ++++++ src/components/modals/webApp/WebAppModal.tsx | 68 +++--- .../modals/webApp/WebAppModalTabContent.tsx | 26 +-- src/components/ui/Modal.tsx | 3 + src/config.ts | 3 + src/hooks/useDraggable.ts | 214 ++++++++++++++++-- src/styles/_variables.scss | 1 + 7 files changed, 318 insertions(+), 69 deletions(-) diff --git a/src/components/modals/webApp/WebAppModal.module.scss b/src/components/modals/webApp/WebAppModal.module.scss index 33d9619cd..5e8437077 100644 --- a/src/components/modals/webApp/WebAppModal.module.scss +++ b/src/components/modals/webApp/WebAppModal.module.scss @@ -24,6 +24,78 @@ overflow: hidden; } + .resizeHandle { + position: absolute; + background: transparent; + z-index: var(--z-resize-grip); + + &.top, + &.bottom { + left: 0; + right: 0; + width: 100%; + height: 0.5rem; + cursor: ns-resize; + } + + &.left, + &.right { + top: 0; + bottom: 0; + width: 0.5rem; + height: 100%; + cursor: ew-resize; + } + + &.top { + top: 0; + } + + &.bottom { + bottom: 0; + } + + &.left { + left: 0; + } + + &.right { + right: 0; + } + + &.topLeft, + &.topRight, + &.bottomLeft, + &.bottomRight { + width: 0.5rem; + height: 0.5rem; + } + + &.topLeft { + top: 0; + left: 0; + cursor: nwse-resize; + } + + &.topRight { + top: 0; + right: 0; + cursor: nesw-resize; + } + + &.bottomLeft { + bottom: 0; + left: 0; + cursor: nesw-resize; + } + + &.bottomRight { + bottom: 0; + right: 0; + cursor: nwse-resize; + } + } + .modal-container { pointer-events: none; } diff --git a/src/components/modals/webApp/WebAppModal.tsx b/src/components/modals/webApp/WebAppModal.tsx index 59fedec9d..7ba45b684 100644 --- a/src/components/modals/webApp/WebAppModal.tsx +++ b/src/components/modals/webApp/WebAppModal.tsx @@ -12,6 +12,7 @@ import type { TabState, WebApp } from '../../../global/types'; import type { ThemeKey } from '../../../types'; import type { WebAppOutboundEvent } from '../../../types/webapp'; +import { RESIZE_HANDLE_CLASS_NAME } from '../../../config'; import { getWebAppKey } from '../../../global/helpers/bots'; import { selectCurrentChat, selectTheme, selectUser, @@ -65,6 +66,10 @@ type StateProps = { const PROLONG_INTERVAL = 45000; // 45s const LUMA_THRESHOLD = 128; +const MINIMIZED_STATE_SIZE = { width: 300, height: 40 }; +const DEFAULT_MAXIMIZED_STATE_SIZE = { width: 420, height: 730 }; +const MAXIMIZED_STATE_MINIMUM_SIZE = { width: 300, height: 300 }; + const WebAppModal: FC = ({ modal, chat, @@ -85,22 +90,18 @@ const WebAppModal: FC = ({ closeMoreAppsTab, } = getActions(); - const maximizedStateSize = useMemo(() => { - return { width: 420, height: 730 }; - }, []); - const minimizedStateSize = useMemo(() => { - return { width: 300, height: 40 }; - }, []); - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [getFrameSize, setFrameSize] = useSignal( - { width: maximizedStateSize.width, height: maximizedStateSize.height - minimizedStateSize.height }, + const [getMaximizedStateSize, setMaximizedStateSize] = useSignal( + { width: DEFAULT_MAXIMIZED_STATE_SIZE.width, height: DEFAULT_MAXIMIZED_STATE_SIZE.height }, ); function getSize() { if (modal?.modalState === 'fullScreen') return windowSize.get(); - if (modal?.modalState === 'maximized') return maximizedStateSize; - return minimizedStateSize; + if (modal?.modalState === 'maximized') return getMaximizedStateSize(); + return MINIMIZED_STATE_SIZE; + } + function getMinimumSize() { + if (modal?.modalState === 'maximized') return MAXIMIZED_STATE_MINIMUM_SIZE; + return undefined; } const { @@ -169,6 +170,7 @@ const WebAppModal: FC = ({ const { isDragging, + isResizing, style: draggableStyle, size, } = useDraggable( @@ -177,28 +179,19 @@ const WebAppModal: FC = ({ isDraggingEnabled, getSize(), isFullScreen, + getMinimumSize(), ); const currentSize = size || getSize(); const currentWidth = currentSize.width; const currentHeight = currentSize.height; + useEffect(() => { - if (currentHeight === minimizedStateSize.height && currentWidth === minimizedStateSize.width) return; - if (isMaximizedState) { - const height = currentHeight - minimizedStateSize.height; - setFrameSize({ width: currentWidth, height }); + if (isResizing) { + setMaximizedStateSize({ width: currentWidth, height: currentHeight }); } - if (isFullScreen) { - setFrameSize({ width: window.innerWidth, height: window.innerHeight }); - } - }, [currentWidth, - currentHeight, - isMaximizedState, - minimizedStateSize, - setFrameSize, - isFullScreen, - isMinimizedState]); + }, [currentHeight, currentWidth, isResizing, setMaximizedStateSize]); const oldLang = useOldLang(); const lang = useLang(); @@ -646,6 +639,25 @@ const WebAppModal: FC = ({ ); } + function buildResizeHandleClass(handleClassName: string) { + return buildClassName(RESIZE_HANDLE_CLASS_NAME, handleClassName); + } + + function renderResizeHandles() { + return ( + <> +
+
+
+
+
+
+
+
+ + ); + } + return ( = ({ isFullScreen && styles.fullScreen, )} dialogStyle={supportMultiTabMode ? draggableStyle : undefined} + dialogContent={isDraggingEnabled ? renderResizeHandles() : undefined} isOpen={isOpen} isLowStackPriority onClose={handleModalClose} @@ -672,9 +685,10 @@ const WebAppModal: FC = ({ registerSendEventCallback={registerSendEventCallback} registerReloadFrameCallback={registerReloadFrameCallback} webApp={openedWebApps[key]} - isDragging={isDragging} + isTransforming={isDragging || isResizing} onContextMenuButtonClick={handleContextMenu} isMultiTabSupported={supportMultiTabMode} + modalHeight={currentHeight} /> ))} { isMoreAppsTabActive && ()} diff --git a/src/components/modals/webApp/WebAppModalTabContent.tsx b/src/components/modals/webApp/WebAppModalTabContent.tsx index edcd47635..1f231d33a 100644 --- a/src/components/modals/webApp/WebAppModalTabContent.tsx +++ b/src/components/modals/webApp/WebAppModalTabContent.tsx @@ -21,11 +21,9 @@ import { selectWebApp, } from '../../../global/selectors'; import buildClassName from '../../../util/buildClassName'; -import buildStyle from '../../../util/buildStyle'; import download from '../../../util/download'; import { extractCurrentThemeParams, validateHexColor } from '../../../util/themeStyle'; import { callApi } from '../../../api/gramjs'; -import { REM } from '../../common/helpers/mediaDimensions'; import renderText from '../../common/helpers/renderText'; import { getIsWebAppsFullscreenSupported } from '../../../hooks/useAppLayout'; @@ -65,9 +63,9 @@ export type OwnProps = { registerSendEventCallback: (callback: (event: WebAppOutboundEvent) => void) => void; registerReloadFrameCallback: (callback: (url: string) => void) => void; onContextMenuButtonClick: (e: React.MouseEvent) => void; - isDragging?: boolean; - frameSize?: { width: number; height: number }; + isTransforming?: boolean; isMultiTabSupported? : boolean; + modalHeight: number; }; type StateProps = { @@ -113,12 +111,12 @@ const WebAppModalTabContent: FC = ({ paymentStatus, registerSendEventCallback, registerReloadFrameCallback, - isDragging, + isTransforming, modalState, - frameSize, isMultiTabSupported, onContextMenuButtonClick, botAppSettings, + modalHeight, }) => { const { closeActiveWebApp, @@ -700,10 +698,11 @@ const WebAppModalTabContent: FC = ({ setTimeout(() => { sendViewport(); sendSafeArea(); - }, ANIMATION_WAIT); + }, isTransforming ? 0 : ANIMATION_WAIT); }, [shouldShowSecondaryButton, shouldHideSecondaryButton, shouldShowMainButton, shouldShowMainButton, - secondaryButton?.position, sendViewport, sendSafeArea]); + secondaryButton?.position, sendViewport, isTransforming, modalHeight, + sendSafeArea]); const isVerticalLayout = secondaryButtonCurrentPosition === 'top' || secondaryButtonCurrentPosition === 'bottom'; const isHorizontalLayout = !isVerticalLayout; @@ -798,16 +797,7 @@ const WebAppModalTabContent: FC = ({ } }, [setShouldDecreaseWebFrameSize, shouldShowSecondaryButton, shouldShowMainButton]); - const frameWidth = frameSize?.width || 0; - let frameHeight = frameSize?.height || 0; - if (shouldDecreaseWebFrameSize) { frameHeight -= 4 * REM; } - const frameStyle = frameSize ? buildStyle( - `left: ${0}px;`, - `top: ${0}px;`, - `width: ${frameWidth}px;`, - `height: ${frameHeight}px;`, - isDragging ? 'pointer-events: none;' : '', - ) : isDragging ? 'pointer-events: none;' : ''; + const frameStyle = isTransforming ? 'pointer-events: none;' : ''; const handleBackClick = useLastCallback(() => { if (isBackButtonVisible) { diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx index 1222bdc7a..d82a42f0e 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -40,6 +40,7 @@ export type OwnProps = { dialogStyle?: string; dialogRef?: React.RefObject; isLowStackPriority?: boolean; + dialogContent?: React.ReactNode; onClose: () => void; onCloseAnimationEnd?: () => void; onEnter?: () => void; @@ -62,6 +63,7 @@ const Modal: FC = ({ style, dialogStyle, isLowStackPriority, + dialogContent, onClose, onCloseAnimationEnd, onEnter, @@ -171,6 +173,7 @@ const Modal: FC = ({
{renderHeader()} + {dialogContent}
{children}
diff --git a/src/config.ts b/src/config.ts index 127e2812e..bcadeec26 100644 --- a/src/config.ts +++ b/src/config.ts @@ -148,6 +148,9 @@ export const CUSTOM_APPENDIX_ATTRIBUTE = 'data-has-custom-appendix'; export const MESSAGE_CONTENT_CLASS_NAME = 'message-content'; export const MESSAGE_CONTENT_SELECTOR = '.message-content'; +export const RESIZE_HANDLE_CLASS_NAME = 'resizeHandle'; +export const RESIZE_HANDLE_SELECTOR = `.${RESIZE_HANDLE_CLASS_NAME}`; + export const SNAP_EFFECT_CONTAINER_ID = 'snap-effect-container'; export const SNAP_EFFECT_ID = 'snap-effect'; diff --git a/src/hooks/useDraggable.ts b/src/hooks/useDraggable.ts index 48cdba28e..000650d50 100644 --- a/src/hooks/useDraggable.ts +++ b/src/hooks/useDraggable.ts @@ -3,6 +3,7 @@ import { useEffect, useSignal, useState, } from '../lib/teact/teact'; +import { RESIZE_HANDLE_SELECTOR } from '../config'; import buildStyle from '../util/buildStyle'; import { captureEvents } from '../util/captureEvents'; import useFlag from './useFlag'; @@ -13,6 +14,33 @@ export interface Size { height: number; } +export enum ResizeHandleType { + Top, + Bottom, + Left, + Right, + TopLeft, + TopRight, + BottomLeft, + BottomRight, +} + +type ResizeHandleSelectorType = 'top' | 'bottom' | 'left' +| 'right' | 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight'; + +const resizeHandleSelectorsMap: Record = { + top: ResizeHandleType.Top, + bottom: ResizeHandleType.Bottom, + left: ResizeHandleType.Left, + right: ResizeHandleType.Right, + topLeft: ResizeHandleType.TopLeft, + topRight: ResizeHandleType.TopRight, + bottomLeft: ResizeHandleType.BottomLeft, + bottomRight: ResizeHandleType.BottomRight, +}; + +const resizeHandleSelectors = Object.keys(resizeHandleSelectorsMap) as ResizeHandleSelectorType[]; + export interface Point { x: number; y: number; @@ -27,15 +55,17 @@ export default function useDraggable( isDragEnabled: boolean = true, originalSize: Size, isFullscreen: boolean = false, + minimumSize: Size = { width: 0, height: 0 }, ) { const [elementCurrentPosition, setElementCurrentPosition] = useState(undefined); const [elementCurrentSize, setElementCurrentSize] = useState(undefined); - const [getElementPositionOnStartDrag, setElementPositionOnStartDrag] = useSignal({ x: 0, y: 0 }); - const [getDragStartPoint, setDragStartPoint] = useSignal({ x: 0, y: 0 }); + const [getElementPositionOnStartTransform, setElementPositionOnStartTransform] = useSignal({ x: 0, y: 0 }); + const [getElementSizeOnStartTransform, setElementSizeOnStartTransform] = useSignal({ width: 0, height: 0 }); + const [getTransformStartPoint, setTransformStartPoint] = useSignal({ x: 0, y: 0 }); - const elementPositionOnStartDrag = getElementPositionOnStartDrag(); - const dragStartPoint = getDragStartPoint(); + const elementPositionOnStartTransform = getElementPositionOnStartTransform(); + const transformStartPoint = getTransformStartPoint(); const element = ref.current; const dragHandleElement = dragHandleElementRef.current; @@ -43,8 +73,11 @@ export default function useDraggable( const [isInitiated, setIsInitiated] = useFlag(false); const [wasElementShown, setWasElementShown] = useFlag(false); const [isDragging, startDragging, stopDragging] = useFlag(false); + const [isResizing, startResizing, stopResizing] = useFlag(false); const [isWindowsResizing, startWindowResizing, stopWindowResizing] = useFlag(false); + const [hitResizeHandle, setHitResizeHandle] = useState(undefined); + function getVisibleArea() { return { width: window.innerWidth, @@ -89,6 +122,10 @@ export default function useDraggable( }, [elementCurrentSize, isInitiated, element]); const handleStartDrag = useLastCallback((event: MouseEvent | TouchEvent) => { + if (event instanceof MouseEvent && event.button !== 0) { + return; + } + const targetElement = event.target as HTMLElement; if (targetElement.closest('.no-drag') || !element) { return; @@ -96,16 +133,56 @@ export default function useDraggable( const { pageX, pageY } = ('touches' in event) ? event.touches[0] : event; const { left, top } = element.getBoundingClientRect(); - setElementPositionOnStartDrag({ x: left, y: top }); - setDragStartPoint({ x: pageX, y: pageY }); + setElementPositionOnStartTransform({ x: left, y: top }); + setTransformStartPoint({ x: pageX, y: pageY }); startDragging(); }); - const handleRelease = useLastCallback(() => { + function getResizeHandleFromTarget(targetElement: HTMLElement) { + const closest = (selector: string) => targetElement.closest(selector); + + if (!closest(RESIZE_HANDLE_SELECTOR)) return undefined; + for (const selector of resizeHandleSelectors) { + if (closest(`.${selector}`)) { return resizeHandleSelectorsMap[selector]; } + } + return undefined; + } + + const handleStartResize = useLastCallback((event: MouseEvent | TouchEvent) => { + if (event instanceof MouseEvent && event.button !== 0) { + return; + } + + const targetElement = event.target as HTMLElement; + if (!element || !targetElement) { + return; + } + const resizeHandle = getResizeHandleFromTarget(targetElement); + + if (resizeHandle === undefined) return; + setHitResizeHandle(resizeHandle); + + const { pageX, pageY } = ('touches' in event) ? event.touches[0] : event; + + const { + left, right, top, bottom, + } = element.getBoundingClientRect(); + setElementPositionOnStartTransform({ x: left, y: top }); + setElementSizeOnStartTransform({ width: right - left, height: bottom - top }); + setTransformStartPoint({ x: pageX, y: pageY }); + + startResizing(); + }); + + const handleDragRelease = useLastCallback(() => { stopDragging(); }); + const handleResizeRelease = useLastCallback(() => { + stopResizing(); + }); + useEffect(() => { if (!isDragEnabled) { stopDragging(); @@ -145,29 +222,33 @@ export default function useDraggable( const visibleArea = getVisibleArea(); - newSize.width = Math.min(visibleArea.width, Math.max(originalSize.width, newSize.width)); - newSize.height = Math.min(visibleArea.height, Math.max(originalSize.height, newSize.height)); + const originalWidth = originalSize.width; + const originalHeight = originalSize.height; + newSize.width = Math.min(visibleArea.width, Math.max(originalWidth, newSize.width)); + newSize.height = Math.min(visibleArea.height, Math.max(originalHeight, newSize.height)); return newSize; }); useEffect(() => { + if (isResizing) return; const newSize = ensureSizeInVisibleArea({ width: originalSize.width, height: originalSize.height }); if (newSize) setElementCurrentSize(newSize); - }, [originalSize]); + }, [originalSize, isResizing]); const adjustSizeWithinBounds = useLastCallback(() => { - if (!elementCurrentSize) return; + if (!elementCurrentSize || isResizing) return; const newSize = ensureSizeInVisibleArea(elementCurrentSize); if (newSize) setElementCurrentSize(newSize); }); useEffect(() => { + if (isResizing) return; adjustPositionWithinBounds(); - }, [elementCurrentSize]); + }, [elementCurrentSize, isResizing]); useEffect(() => { - const handleResize = () => { + const handleWindowResize = () => { startWindowResizing(); adjustSizeWithinBounds(); adjustPositionWithinBounds(); @@ -181,12 +262,12 @@ export default function useDraggable( }, 250); }; - window.addEventListener('resize', handleResize); + window.addEventListener('resize', handleWindowResize); return () => { clearTimeout(resizeTimeout); resizeTimeout = undefined; - window.removeEventListener('resize', handleResize); + window.removeEventListener('resize', handleWindowResize); }; }, [adjustPositionWithinBounds]); @@ -194,28 +275,112 @@ export default function useDraggable( if (!isDragging || !element) return; const { pageX, pageY } = ('touches' in event) ? event.touches[0] : event; - const offsetX = pageX - dragStartPoint.x; - const offsetY = pageY - dragStartPoint.y; + const offsetX = pageX - transformStartPoint.x; + const offsetY = pageY - transformStartPoint.y; - const newX = elementPositionOnStartDrag.x + offsetX; - const newY = elementPositionOnStartDrag.y + offsetY; + const newX = elementPositionOnStartTransform.x + offsetX; + const newY = elementPositionOnStartTransform.y + offsetY; if (elementCurrentSize) setElementCurrentPosition(ensurePositionInVisibleArea(newX, newY)); }); + const handleResize = useLastCallback((event: MouseEvent | TouchEvent) => { + if (!isResizing || !element || hitResizeHandle === undefined) return; + const { pageX, pageY } = ('touches' in event) ? event.touches[0] : event; + const sizeOnStartTransform = getElementSizeOnStartTransform(); + + const pageVisibleX = Math.min(Math.max(0, pageX), getVisibleArea().width); + const pageVisibleY = Math.min(Math.max(0, pageY), getVisibleArea().height); + + const offsetX = pageVisibleX - transformStartPoint.x; + const offsetY = pageVisibleY - transformStartPoint.y; + + const maxX = elementPositionOnStartTransform.x + sizeOnStartTransform.width - minimumSize.width; + const maxY = elementPositionOnStartTransform.y + sizeOnStartTransform.height - minimumSize.height; + + const originalBounds = { + x: elementPositionOnStartTransform.x, + y: elementPositionOnStartTransform.y, + width: sizeOnStartTransform.width, + height: sizeOnStartTransform.height, + }; + + const newBounds = { ...originalBounds }; + + if (hitResizeHandle === ResizeHandleType.Left + || hitResizeHandle === ResizeHandleType.TopLeft + || hitResizeHandle === ResizeHandleType.BottomLeft + ) { + newBounds.width = Math.max(sizeOnStartTransform.width - offsetX, minimumSize.width); + newBounds.x = Math.min(newBounds.x + offsetX, maxX); + } + + if (hitResizeHandle === ResizeHandleType.Right + || hitResizeHandle === ResizeHandleType.TopRight + || hitResizeHandle === ResizeHandleType.BottomRight + ) { + newBounds.width = Math.max(sizeOnStartTransform.width + offsetX, minimumSize.width); + } + + if (hitResizeHandle === ResizeHandleType.Top + || hitResizeHandle === ResizeHandleType.TopLeft + || hitResizeHandle === ResizeHandleType.TopRight + ) { + newBounds.height = Math.max(sizeOnStartTransform.height - offsetY, minimumSize.height); + newBounds.y = Math.min(newBounds.y + offsetY, maxY); + } + + if (hitResizeHandle === ResizeHandleType.Bottom + || hitResizeHandle === ResizeHandleType.BottomLeft + || hitResizeHandle === ResizeHandleType.BottomRight + ) { + newBounds.height = Math.max(sizeOnStartTransform.height + offsetY, minimumSize.height); + } + + setElementCurrentSize({ width: newBounds.width, height: newBounds.height }); + setElementCurrentPosition({ x: newBounds.x, y: newBounds.y }); + }); + useEffect(() => { let cleanup: NoneToVoidFunction | undefined; if (dragHandleElement && isDragEnabled) { cleanup = captureEvents(dragHandleElement, { onCapture: handleStartDrag, onDrag: handleDrag, - onRelease: handleRelease, - onClick: handleRelease, - onDoubleClick: handleRelease, + onRelease: handleDragRelease, + onClick: handleDragRelease, + onDoubleClick: handleDragRelease, }); } return cleanup; - }, [handleDrag, handleStartDrag, isDragEnabled, dragHandleElement]); + }, [isDragEnabled, dragHandleElement]); + + useEffect(() => { + const cleanups: NoneToVoidFunction[] = []; + if (element && isDragEnabled) { + for (const selector of resizeHandleSelectors) { + const resizeHandler = element.querySelector(`.resizeHandle.${selector}`) as HTMLElement; + + if (resizeHandler) { + const cleanup = captureEvents(resizeHandler, { + onCapture: handleStartResize, + onDrag: handleResize, + onRelease: handleResizeRelease, + onClick: handleResizeRelease, + onDoubleClick: handleResizeRelease, + }); + + if (cleanup) { + cleanups.push(cleanup); + } + } + } + } + + return () => { + cleanups.forEach((cleanup) => cleanup()); + }; + }, [isDragEnabled, element]); const cursorStyle = isDragging ? 'cursor: grabbing !important; ' : ''; @@ -234,7 +399,7 @@ export default function useDraggable( !isFullscreen && `max-width: ${elementCurrentSize.width}px;`, !isFullscreen && `max-height: ${elementCurrentSize.height}px;`, 'position: fixed;', - (isDragging || isWindowsResizing) && 'transition: none !important;', + (isDragging || isResizing || isWindowsResizing) && 'transition: none !important;', cursorStyle, ); @@ -242,6 +407,7 @@ export default function useDraggable( position: elementCurrentPosition, size: elementCurrentSize, isDragging, + isResizing, style, }; } diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index bea2c71a3..8b2a2fcce 100644 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -253,6 +253,7 @@ $color-message-story-mention-to: #74bcff; --z-header-menu-backdrop: 980; --z-modal: 1510; --z-modal-menu: 1600; + --z-resize-grip: 1000; --z-media-viewer: 1500; --z-modal-low-priority: 1400; --z-video-player-controls: 3;