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

View file

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