Skip to content

Commit

Permalink
Prevent calling PiP from going off-screen
Browse files Browse the repository at this point in the history
  • Loading branch information
EvanHahn-Signal committed Dec 1, 2020
1 parent 688938b commit bb50363
Show file tree
Hide file tree
Showing 2 changed files with 182 additions and 79 deletions.
255 changes: 179 additions & 76 deletions ts/components/CallingPip.tsx
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -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 = ({
Expand All @@ -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(() => {
Expand All @@ -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;
}
);

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<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),
},
];

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
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions ts/util/lint/exceptions.json
Expand Up @@ -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."
Expand All @@ -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."
Expand All @@ -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."
Expand Down

0 comments on commit bb50363

Please sign in to comment.