Support resize for mini apps modal (#5319)

This commit is contained in:
Alexander Zinchuk 2024-12-20 11:37:18 +01:00
parent 50621e91d3
commit 984a058e38
7 changed files with 318 additions and 69 deletions

View File

@ -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;
}

View File

@ -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<OwnProps & StateProps> = ({
modal,
chat,
@ -85,22 +90,18 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
const {
isDragging,
isResizing,
style: draggableStyle,
size,
} = useDraggable(
@ -177,28 +179,19 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
);
}
function buildResizeHandleClass(handleClassName: string) {
return buildClassName(RESIZE_HANDLE_CLASS_NAME, handleClassName);
}
function renderResizeHandles() {
return (
<>
<div className={buildResizeHandleClass('top')} />
<div className={buildResizeHandleClass('bottom')} />
<div className={buildResizeHandleClass('left')} />
<div className={buildResizeHandleClass('right')} />
<div className={buildResizeHandleClass('topLeft')} />
<div className={buildResizeHandleClass('topRight')} />
<div className={buildResizeHandleClass('bottomLeft')} />
<div className={buildResizeHandleClass('bottomRight')} />
</>
);
}
return (
<Modal
dialogRef={ref}
@ -656,6 +668,7 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
isFullScreen && styles.fullScreen,
)}
dialogStyle={supportMultiTabMode ? draggableStyle : undefined}
dialogContent={isDraggingEnabled ? renderResizeHandles() : undefined}
isOpen={isOpen}
isLowStackPriority
onClose={handleModalClose}
@ -672,9 +685,10 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
registerSendEventCallback={registerSendEventCallback}
registerReloadFrameCallback={registerReloadFrameCallback}
webApp={openedWebApps[key]}
isDragging={isDragging}
isTransforming={isDragging || isResizing}
onContextMenuButtonClick={handleContextMenu}
isMultiTabSupported={supportMultiTabMode}
modalHeight={currentHeight}
/>
))}
{ isMoreAppsTabActive && (<MoreAppsTabContent />)}

View File

@ -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<OwnProps & StateProps> = ({
paymentStatus,
registerSendEventCallback,
registerReloadFrameCallback,
isDragging,
isTransforming,
modalState,
frameSize,
isMultiTabSupported,
onContextMenuButtonClick,
botAppSettings,
modalHeight,
}) => {
const {
closeActiveWebApp,
@ -700,10 +698,11 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
}
}, [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) {

View File

@ -40,6 +40,7 @@ export type OwnProps = {
dialogStyle?: string;
dialogRef?: React.RefObject<HTMLDivElement>;
isLowStackPriority?: boolean;
dialogContent?: React.ReactNode;
onClose: () => void;
onCloseAnimationEnd?: () => void;
onEnter?: () => void;
@ -62,6 +63,7 @@ const Modal: FC<OwnProps> = ({
style,
dialogStyle,
isLowStackPriority,
dialogContent,
onClose,
onCloseAnimationEnd,
onEnter,
@ -171,6 +173,7 @@ const Modal: FC<OwnProps> = ({
<div className="modal-backdrop" onClick={!noBackdropClose ? onClose : undefined} />
<div className="modal-dialog" ref={dialogRef} style={dialogStyle}>
{renderHeader()}
{dialogContent}
<div className={buildClassName('modal-content custom-scroll', contentClassName)} style={style}>
{children}
</div>

View File

@ -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';

View File

@ -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<ResizeHandleSelectorType, ResizeHandleType> = {
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<Point | undefined>(undefined);
const [elementCurrentSize, setElementCurrentSize] = useState<Size | undefined>(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<ResizeHandleType | undefined>(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,
};
}

View File

@ -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;