element-portable/src/components/structures/PictureInPictureDragger.tsx
David Langley 491f0cd08a
Change license (#13)
* Copyright headers 1

* Licence headers 2

* Copyright Headers 3

* Copyright Headers 4

* Copyright Headers 5

* Copyright Headers 6

* Copyright headers 7

* Add copyright headers for html and config file

* Replace license files and update package.json

* Update with CLA

* lint
2024-09-09 13:57:16 +00:00

263 lines
9.4 KiB
TypeScript

/*
Copyright 2021-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { createRef } from "react";
import UIStore, { UI_EVENTS } from "../../stores/UIStore";
import { lerp } from "../../utils/AnimationUtils";
import { MarkedExecution } from "../../utils/MarkedExecution";
const PIP_VIEW_WIDTH = 336;
const PIP_VIEW_HEIGHT = 232;
const MOVING_AMT = 0.2;
const SNAPPING_AMT = 0.1;
const PADDING = {
top: 58,
bottom: 58,
left: 76,
right: 8,
};
/**
* The type of a callback which will create the pip content children.
*/
export type CreatePipChildren = (options: IChildrenOptions) => JSX.Element;
interface IChildrenOptions {
// a callback which is called when a mouse event (most likely mouse down) occurs at start of moving the pip around
onStartMoving: (event: React.MouseEvent<Element, MouseEvent>) => void;
// a callback which is called when the content fo the pip changes in a way that is likely to cause a resize
onResize: (event: Event) => void;
}
interface IProps {
className?: string;
children: Array<CreatePipChildren>;
draggable: boolean;
onDoubleClick?: () => void;
onMove?: () => void;
}
/**
* PictureInPictureDragger shows a small version of CallView hovering over the UI in 'picture-in-picture'
* (PiP mode). It displays the call(s) which is *not* in the room the user is currently viewing.
*/
export default class PictureInPictureDragger extends React.Component<IProps> {
private callViewWrapper = createRef<HTMLDivElement>();
private initX = 0;
private initY = 0;
private desiredTranslationX = UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH;
private desiredTranslationY = UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_HEIGHT;
private translationX = this.desiredTranslationX;
private translationY = this.desiredTranslationY;
private mouseHeld = false;
private scheduledUpdate: MarkedExecution = new MarkedExecution(
() => this.animationCallback(),
() => requestAnimationFrame(() => this.scheduledUpdate.trigger()),
);
private startingPositionX = 0;
private startingPositionY = 0;
private _moving = false;
public get moving(): boolean {
return this._moving;
}
private set moving(value: boolean) {
this._moving = value;
}
public componentDidMount(): void {
document.addEventListener("mousemove", this.onMoving);
document.addEventListener("mouseup", this.onEndMoving);
UIStore.instance.on(UI_EVENTS.Resize, this.onResize);
// correctly position the PiP
this.snap();
}
public componentWillUnmount(): void {
document.removeEventListener("mousemove", this.onMoving);
document.removeEventListener("mouseup", this.onEndMoving);
UIStore.instance.off(UI_EVENTS.Resize, this.onResize);
}
public componentDidUpdate(prevProps: Readonly<IProps>): void {
if (prevProps.children !== this.props.children) this.snap(true);
}
private animationCallback = (): void => {
if (
!this.moving &&
Math.abs(this.translationX - this.desiredTranslationX) <= 1 &&
Math.abs(this.translationY - this.desiredTranslationY) <= 1
) {
// Break the loop by settling the element into its final position
this.translationX = this.desiredTranslationX;
this.translationY = this.desiredTranslationY;
this.setStyle();
} else {
const amt = this.moving ? MOVING_AMT : SNAPPING_AMT;
this.translationX = lerp(this.translationX, this.desiredTranslationX, amt);
this.translationY = lerp(this.translationY, this.desiredTranslationY, amt);
this.setStyle();
this.scheduledUpdate.mark();
}
this.props.onMove?.();
};
private setStyle = (): void => {
if (!this.callViewWrapper.current) return;
// Set the element's style directly, bypassing React for efficiency
this.callViewWrapper.current.style.transform = `translateX(${this.translationX}px) translateY(${this.translationY}px)`;
};
private setTranslation(inTranslationX: number, inTranslationY: number): void {
const width = this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH;
const height = this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT;
// Avoid overflow on the x axis
if (inTranslationX + width >= UIStore.instance.windowWidth) {
this.desiredTranslationX = UIStore.instance.windowWidth - width;
} else if (inTranslationX <= 0) {
this.desiredTranslationX = 0;
} else {
this.desiredTranslationX = inTranslationX;
}
// Avoid overflow on the y axis
if (inTranslationY + height >= UIStore.instance.windowHeight) {
this.desiredTranslationY = UIStore.instance.windowHeight - height;
} else if (inTranslationY <= 0) {
this.desiredTranslationY = 0;
} else {
this.desiredTranslationY = inTranslationY;
}
}
private onResize = (): void => {
this.snap(false);
};
private snap = (animate = false): void => {
const translationX = this.desiredTranslationX;
const translationY = this.desiredTranslationY;
// We subtract the PiP size from the window size in order to calculate
// the position to snap to from the PiP center and not its top-left
// corner
const windowWidth =
UIStore.instance.windowWidth - (this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH);
const windowHeight =
UIStore.instance.windowHeight - (this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT);
if (translationX >= windowWidth / 2 && translationY >= windowHeight / 2) {
this.desiredTranslationX = windowWidth - PADDING.right;
this.desiredTranslationY = windowHeight - PADDING.bottom;
} else if (translationX >= windowWidth / 2 && translationY <= windowHeight / 2) {
this.desiredTranslationX = windowWidth - PADDING.right;
this.desiredTranslationY = PADDING.top;
} else if (translationX <= windowWidth / 2 && translationY >= windowHeight / 2) {
this.desiredTranslationX = PADDING.left;
this.desiredTranslationY = windowHeight - PADDING.bottom;
} else {
this.desiredTranslationX = PADDING.left;
this.desiredTranslationY = PADDING.top;
}
if (!animate) {
this.translationX = this.desiredTranslationX;
this.translationY = this.desiredTranslationY;
}
// We start animating here because we want the PiP to move when we're
// resizing the window
this.scheduledUpdate.mark();
};
private onStartMoving = (event: React.MouseEvent | MouseEvent): void => {
event.preventDefault();
event.stopPropagation();
this.mouseHeld = true;
this.startingPositionX = event.clientX;
this.startingPositionY = event.clientY;
};
private onMoving = (event: MouseEvent): void => {
if (!this.mouseHeld) return;
if (
Math.abs(this.startingPositionX - event.clientX) < 5 &&
Math.abs(this.startingPositionY - event.clientY) < 5
) {
// User needs to move the widget by at least five pixels.
// Improves click detection when using a touchpad or with nervous hands.
return;
}
event.preventDefault();
event.stopPropagation();
if (!this.moving) {
this.moving = true;
this.initX = event.pageX - this.desiredTranslationX;
this.initY = event.pageY - this.desiredTranslationY;
this.scheduledUpdate.mark();
}
this.setTranslation(event.pageX - this.initX, event.pageY - this.initY);
};
private onEndMoving = (event: MouseEvent): void => {
if (!this.mouseHeld) return;
event.preventDefault();
event.stopPropagation();
this.mouseHeld = false;
// Delaying this to the next event loop tick is necessary for click
// event cancellation to work
setTimeout(() => (this.moving = false));
this.snap(true);
};
private onClickCapture = (event: React.MouseEvent): void => {
// To prevent mouse up events during dragging from being double-counted
// as clicks, we cancel clicks before they ever reach the target
if (this.moving) {
event.preventDefault();
event.stopPropagation();
}
};
public render(): React.ReactNode {
const style = {
transform: `translateX(${this.translationX}px) translateY(${this.translationY}px)`,
};
const children = this.props.children.map((create: CreatePipChildren) => {
return create({
onStartMoving: this.onStartMoving,
onResize: this.onResize,
});
});
return (
<aside
className={this.props.className}
style={style}
ref={this.callViewWrapper}
onClickCapture={this.onClickCapture}
onDoubleClick={this.props.onDoubleClick}
>
{children}
</aside>
);
}
}