Prevent calling PiP from going off-screen

This commit is contained in:
Evan Hahn 2020-12-01 13:21:47 -06:00 committed by GitHub
parent 688938b5a1
commit bb5036364e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 181 additions and 78 deletions

View file

@ -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 | HTMLDivElement>(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<PositionState>({
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<string, number> = {
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;
}
);
const snapCandidates: Array<SnapCandidate> = [
{
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),
},
];
setDragState({
...dragState,
isDragging: false,
});
// This fallback is mostly for TypeScript, because `minBy` says it can return
// `undefined`.
const snapTo =
minBy(snapCandidates, candidate => candidate.distanceToEdge) ||
snapCandidates[0];
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;
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);
}
if (snapTo === 'right') {
nextX = innerWidth - PIP_WIDTH - PIP_PADDING;
}
if (snapTo === 'bottom') {
nextY = innerHeight - PIP_HEIGHT - PIP_PADDING;
}
if (snapTo === 'left') {
nextX = PIP_PADDING;
}
setDragContainerStyle({
translateX: nextX,
translateY: nextY,
});
}
}, [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',
}}
>
<CallingPipRemoteVideo

View file

@ -14409,7 +14409,7 @@
"rule": "React-useRef",
"path": "ts/components/CallingPip.js",
"line": " const videoContainerRef = react_1.default.useRef(null);",
"lineNumber": 15,
"lineNumber": 25,
"reasonCategory": "usageTrusted",
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Element is measured. Its HTML is not used."
@ -14418,7 +14418,7 @@
"rule": "React-useRef",
"path": "ts/components/CallingPip.js",
"line": " const localVideoRef = react_1.default.useRef(null);",
"lineNumber": 16,
"lineNumber": 26,
"reasonCategory": "usageTrusted",
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Used to get the local video element for rendering."
@ -14427,7 +14427,7 @@
"rule": "React-useRef",
"path": "ts/components/CallingPip.tsx",
"line": " const localVideoRef = React.useRef(null);",
"lineNumber": 42,
"lineNumber": 78,
"reasonCategory": "usageTrusted",
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Used to get the local video element for rendering."