diff --git a/ts/components/CallingPip.tsx b/ts/components/CallingPip.tsx index ee4a361bdb7..bc054b03e5c 100644 --- a/ts/components/CallingPip.tsx +++ b/ts/components/CallingPip.tsx @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import React from 'react'; +import { minBy, debounce, noop } from 'lodash'; import { CallingPipRemoteVideo } from './CallingPipRemoteVideo'; import { LocalizerType } from '../types/Util'; import { VideoFrameSource } from '../types/Calling'; @@ -11,6 +12,41 @@ import { SetLocalPreviewType, SetRendererCanvasType, } from '../state/ducks/calling'; +import { missingCaseError } from '../util/missingCaseError'; + +enum PositionMode { + BeingDragged, + SnapToBottom, + SnapToLeft, + SnapToRight, + SnapToTop, +} + +type PositionState = + | { + mode: PositionMode.BeingDragged; + mouseX: number; + mouseY: number; + dragOffsetX: number; + dragOffsetY: number; + } + | { + mode: PositionMode.SnapToLeft | PositionMode.SnapToRight; + offsetY: number; + } + | { + mode: PositionMode.SnapToTop | PositionMode.SnapToBottom; + offsetX: number; + }; + +interface SnapCandidate { + mode: + | PositionMode.SnapToBottom + | PositionMode.SnapToLeft + | PositionMode.SnapToRight + | PositionMode.SnapToTop; + distanceToEdge: number; +} export type PropsType = { activeCall: ActiveCallType; @@ -25,7 +61,7 @@ export type PropsType = { const PIP_HEIGHT = 156; const PIP_WIDTH = 120; -const PIP_DEFAULT_Y = 56; +const PIP_TOP_MARGIN = 56; const PIP_PADDING = 8; export const CallingPip = ({ @@ -41,15 +77,11 @@ export const CallingPip = ({ const videoContainerRef = React.useRef(null); const localVideoRef = React.useRef(null); - const [dragState, setDragState] = React.useState({ - offsetX: 0, + const [windowWidth, setWindowWidth] = React.useState(window.innerWidth); + const [windowHeight, setWindowHeight] = React.useState(window.innerHeight); + const [positionState, setPositionState] = React.useState({ + mode: PositionMode.SnapToRight, offsetY: 0, - isDragging: false, - }); - - const [dragContainerStyle, setDragContainerStyle] = React.useState({ - translateX: window.innerWidth - PIP_WIDTH - PIP_PADDING, - translateY: PIP_DEFAULT_Y, }); React.useEffect(() => { @@ -58,82 +90,145 @@ export const CallingPip = ({ const handleMouseMove = React.useCallback( (ev: MouseEvent) => { - if (dragState.isDragging) { - setDragContainerStyle({ - translateX: ev.clientX - dragState.offsetX, - translateY: ev.clientY - dragState.offsetY, - }); + if (positionState.mode === PositionMode.BeingDragged) { + setPositionState(oldState => ({ + ...oldState, + mouseX: ev.screenX, + mouseY: ev.screenY, + })); } }, - [dragState] + [positionState] ); const handleMouseUp = React.useCallback(() => { - if (dragState.isDragging) { - const { translateX, translateY } = dragContainerStyle; + if (positionState.mode === PositionMode.BeingDragged) { + const { mouseX, mouseY, dragOffsetX, dragOffsetY } = positionState; const { innerHeight, innerWidth } = window; - const proximityRatio: Record = { - top: translateY / innerHeight, - right: (innerWidth - translateX) / innerWidth, - bottom: (innerHeight - translateY) / innerHeight, - left: translateX / innerWidth, - }; + const offsetX = mouseX - dragOffsetX; + const offsetY = mouseY - dragOffsetY; - const snapTo = Object.keys(proximityRatio).reduce( - (minKey: string, key: string): string => { - return proximityRatio[key] < proximityRatio[minKey] ? key : minKey; - } - ); - - setDragState({ - ...dragState, - isDragging: false, - }); - - let nextX = Math.max( - PIP_PADDING, - Math.min(translateX, innerWidth - PIP_WIDTH - PIP_PADDING) - ); - let nextY = Math.max( - PIP_DEFAULT_Y, - Math.min(translateY, innerHeight - PIP_HEIGHT - PIP_PADDING) - ); - - if (snapTo === 'top') { - nextY = PIP_DEFAULT_Y; - } - if (snapTo === 'right') { - nextX = innerWidth - PIP_WIDTH - PIP_PADDING; - } - if (snapTo === 'bottom') { - nextY = innerHeight - PIP_HEIGHT - PIP_PADDING; - } - if (snapTo === 'left') { - nextX = PIP_PADDING; - } + const snapCandidates: Array = [ + { + mode: PositionMode.SnapToLeft, + distanceToEdge: offsetX, + }, + { + mode: PositionMode.SnapToRight, + distanceToEdge: innerWidth - (offsetX + PIP_WIDTH), + }, + { + mode: PositionMode.SnapToTop, + distanceToEdge: offsetY - PIP_TOP_MARGIN, + }, + { + mode: PositionMode.SnapToBottom, + distanceToEdge: innerHeight - (offsetY + PIP_HEIGHT), + }, + ]; - setDragContainerStyle({ - translateX: nextX, - translateY: nextY, - }); + // This fallback is mostly for TypeScript, because `minBy` says it can return + // `undefined`. + const snapTo = + minBy(snapCandidates, candidate => candidate.distanceToEdge) || + snapCandidates[0]; + + switch (snapTo.mode) { + case PositionMode.SnapToLeft: + case PositionMode.SnapToRight: + setPositionState({ + mode: snapTo.mode, + offsetY, + }); + break; + case PositionMode.SnapToTop: + case PositionMode.SnapToBottom: + setPositionState({ + mode: snapTo.mode, + offsetX, + }); + break; + default: + throw missingCaseError(snapTo.mode); + } } - }, [dragState, dragContainerStyle]); + }, [positionState, setPositionState]); React.useEffect(() => { - if (dragState.isDragging) { + if (positionState.mode === PositionMode.BeingDragged) { document.addEventListener('mousemove', handleMouseMove, false); document.addEventListener('mouseup', handleMouseUp, false); - } else { - document.removeEventListener('mouseup', handleMouseUp, false); - document.removeEventListener('mousemove', handleMouseMove, false); + + return () => { + document.removeEventListener('mouseup', handleMouseUp, false); + document.removeEventListener('mousemove', handleMouseMove, false); + }; } + return noop; + }, [positionState.mode, handleMouseMove, handleMouseUp]); + + React.useEffect(() => { + const handleWindowResize = debounce( + () => { + setWindowWidth(window.innerWidth); + setWindowHeight(window.innerHeight); + }, + 100, + { + maxWait: 3000, + } + ); + window.addEventListener('resize', handleWindowResize, false); return () => { - document.removeEventListener('mouseup', handleMouseUp, false); - document.removeEventListener('mousemove', handleMouseMove, false); + window.removeEventListener('resize', handleWindowResize, false); }; - }, [dragState, handleMouseMove, handleMouseUp]); + }, []); + + const [translateX, translateY] = React.useMemo<[number, number]>(() => { + switch (positionState.mode) { + case PositionMode.BeingDragged: + return [ + positionState.mouseX - positionState.dragOffsetX, + positionState.mouseY - positionState.dragOffsetY, + ]; + case PositionMode.SnapToLeft: + return [ + PIP_PADDING, + Math.min( + PIP_TOP_MARGIN + positionState.offsetY, + windowHeight - PIP_PADDING - PIP_HEIGHT + ), + ]; + case PositionMode.SnapToRight: + return [ + windowWidth - PIP_PADDING - PIP_WIDTH, + Math.min( + PIP_TOP_MARGIN + positionState.offsetY, + windowHeight - PIP_PADDING - PIP_HEIGHT + ), + ]; + case PositionMode.SnapToTop: + return [ + Math.min( + positionState.offsetX, + windowWidth - PIP_PADDING - PIP_WIDTH + ), + PIP_TOP_MARGIN + PIP_PADDING, + ]; + case PositionMode.SnapToBottom: + return [ + Math.min( + positionState.offsetX, + windowWidth - PIP_PADDING - PIP_WIDTH + ), + windowHeight - PIP_PADDING - PIP_HEIGHT, + ]; + default: + throw missingCaseError(positionState); + } + }, [windowWidth, windowHeight, positionState]); return ( // eslint-disable-next-line jsx-a11y/no-static-element-interactions @@ -145,20 +240,28 @@ export const CallingPip = ({ return; } const rect = node.getBoundingClientRect(); - const offsetX = ev.clientX - rect.left; - const offsetY = ev.clientY - rect.top; + const dragOffsetX = ev.screenX - rect.left; + const dragOffsetY = ev.screenY - rect.top; - setDragState({ - isDragging: true, - offsetX, - offsetY, + setPositionState({ + mode: PositionMode.BeingDragged, + mouseX: ev.screenX, + mouseY: ev.screenY, + dragOffsetX, + dragOffsetY, }); }} ref={videoContainerRef} style={{ - cursor: dragState.isDragging ? '-webkit-grabbing' : '-webkit-grab', - transform: `translate3d(${dragContainerStyle.translateX}px,${dragContainerStyle.translateY}px, 0)`, - transition: dragState.isDragging ? 'none' : 'transform ease-out 300ms', + cursor: + positionState.mode === PositionMode.BeingDragged + ? '-webkit-grabbing' + : '-webkit-grab', + transform: `translate3d(${translateX}px,${translateY}px, 0)`, + transition: + positionState.mode === PositionMode.BeingDragged + ? 'none' + : 'transform ease-out 300ms', }} >