mirror of https://github.com/vector-im/riot-web
				
				
				
			Merge pull request #5952 from SimonBrandner/fix/17130/draggable-pip
						commit
						e9600e9f57
					
				| 
						 | 
				
			
			@ -262,6 +262,7 @@
 | 
			
		|||
@import "./views/voip/_CallContainer.scss";
 | 
			
		||||
@import "./views/voip/_CallView.scss";
 | 
			
		||||
@import "./views/voip/_CallViewForRoom.scss";
 | 
			
		||||
@import "./views/voip/_CallPreview.scss";
 | 
			
		||||
@import "./views/voip/_DialPad.scss";
 | 
			
		||||
@import "./views/voip/_DialPadContextMenu.scss";
 | 
			
		||||
@import "./views/voip/_DialPadModal.scss";
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -30,8 +30,8 @@ limitations under the License.
 | 
			
		|||
        pointer-events: initial; // restore pointer events so the user can leave/interact
 | 
			
		||||
        cursor: pointer;
 | 
			
		||||
 | 
			
		||||
        .mx_CallView_video {
 | 
			
		||||
            width: 350px;
 | 
			
		||||
        .mx_VideoFeed_remote.mx_VideoFeed_voice {
 | 
			
		||||
            min-height: 150px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .mx_VideoFeed_local {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,21 @@
 | 
			
		|||
/*
 | 
			
		||||
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
 | 
			
		||||
 | 
			
		||||
Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
you may not use this file except in compliance with the License.
 | 
			
		||||
You may obtain a copy of the License at
 | 
			
		||||
 | 
			
		||||
    http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
 | 
			
		||||
Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
See the License for the specific language governing permissions and
 | 
			
		||||
limitations under the License.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
.mx_CallPreview {
 | 
			
		||||
    position: fixed;
 | 
			
		||||
    left: 0;
 | 
			
		||||
    top: 0;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -39,7 +39,6 @@ limitations under the License.
 | 
			
		|||
.mx_CallView_pip {
 | 
			
		||||
    width: 320px;
 | 
			
		||||
    padding-bottom: 8px;
 | 
			
		||||
    margin-top: 10px;
 | 
			
		||||
    background-color: $voipcall-plinth-color;
 | 
			
		||||
    box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.20);
 | 
			
		||||
    border-radius: 8px;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -15,8 +15,6 @@ limitations under the License.
 | 
			
		|||
*/
 | 
			
		||||
 | 
			
		||||
.mx_VideoFeed_voice {
 | 
			
		||||
    // We don't want to collide with the call controls that have 52px of height
 | 
			
		||||
    padding-bottom: 52px;
 | 
			
		||||
    background-color: $inverted-bg-color;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
 | 
			
		|||
limitations under the License.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
import React from 'react';
 | 
			
		||||
import React, { createRef } from 'react';
 | 
			
		||||
 | 
			
		||||
import CallView from "./CallView";
 | 
			
		||||
import RoomViewStore from '../../../stores/RoomViewStore';
 | 
			
		||||
| 
						 | 
				
			
			@ -27,6 +27,22 @@ import SettingsStore from "../../../settings/SettingsStore";
 | 
			
		|||
import { CallEvent, CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
 | 
			
		||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
 | 
			
		||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
 | 
			
		||||
import UIStore 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.05;
 | 
			
		||||
 | 
			
		||||
const PADDING = {
 | 
			
		||||
    top: 58,
 | 
			
		||||
    bottom: 58,
 | 
			
		||||
    left: 76,
 | 
			
		||||
    right: 8,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const SHOW_CALL_IN_STATES = [
 | 
			
		||||
    CallState.Connected,
 | 
			
		||||
| 
						 | 
				
			
			@ -49,6 +65,10 @@ interface IState {
 | 
			
		|||
    // Any other call we're displaying: only if the user is on two calls and not viewing either of the rooms
 | 
			
		||||
    // they belong to
 | 
			
		||||
    secondaryCall: MatrixCall;
 | 
			
		||||
 | 
			
		||||
    // Position of the CallPreview
 | 
			
		||||
    translationX: number;
 | 
			
		||||
    translationY: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Splits a list of calls into one 'primary' one and a list
 | 
			
		||||
| 
						 | 
				
			
			@ -91,6 +111,16 @@ export default class CallPreview extends React.Component<IProps, IState> {
 | 
			
		|||
    private roomStoreToken: any;
 | 
			
		||||
    private dispatcherRef: string;
 | 
			
		||||
    private settingsWatcherRef: string;
 | 
			
		||||
    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_WIDTH;
 | 
			
		||||
    private moving = false;
 | 
			
		||||
    private scheduledUpdate = new MarkedExecution(
 | 
			
		||||
        () => this.animationCallback(),
 | 
			
		||||
        () => requestAnimationFrame(() => this.scheduledUpdate.trigger()),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    constructor(props: IProps) {
 | 
			
		||||
        super(props);
 | 
			
		||||
| 
						 | 
				
			
			@ -105,12 +135,17 @@ export default class CallPreview extends React.Component<IProps, IState> {
 | 
			
		|||
            roomId,
 | 
			
		||||
            primaryCall: primaryCall,
 | 
			
		||||
            secondaryCall: secondaryCalls[0],
 | 
			
		||||
            translationX: UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH,
 | 
			
		||||
            translationY: UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_WIDTH,
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public componentDidMount() {
 | 
			
		||||
        CallHandler.sharedInstance().addListener(CallHandlerEvent.CallChangeRoom, this.updateCalls);
 | 
			
		||||
        this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
 | 
			
		||||
        document.addEventListener("mousemove", this.onMoving);
 | 
			
		||||
        document.addEventListener("mouseup", this.onEndMoving);
 | 
			
		||||
        window.addEventListener("resize", this.snap);
 | 
			
		||||
        this.dispatcherRef = dis.register(this.onAction);
 | 
			
		||||
        MatrixClientPeg.get().on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -118,6 +153,9 @@ export default class CallPreview extends React.Component<IProps, IState> {
 | 
			
		|||
    public componentWillUnmount() {
 | 
			
		||||
        CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallChangeRoom, this.updateCalls);
 | 
			
		||||
        MatrixClientPeg.get().removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
 | 
			
		||||
        document.removeEventListener("mousemove", this.onMoving);
 | 
			
		||||
        document.removeEventListener("mouseup", this.onEndMoving);
 | 
			
		||||
        window.removeEventListener("resize", this.snap);
 | 
			
		||||
        if (this.roomStoreToken) {
 | 
			
		||||
            this.roomStoreToken.remove();
 | 
			
		||||
        }
 | 
			
		||||
| 
						 | 
				
			
			@ -125,6 +163,83 @@ export default class CallPreview extends React.Component<IProps, IState> {
 | 
			
		|||
        SettingsStore.unwatchSetting(this.settingsWatcherRef);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private animationCallback = () => {
 | 
			
		||||
        // If the PiP isn't being dragged and there is only a tiny difference in
 | 
			
		||||
        // the desiredTranslation and translation, quit the animationCallback
 | 
			
		||||
        // loop. If that is the case, it means the PiP has snapped into its
 | 
			
		||||
        // position and there is nothing to do. Not doing this would cause an
 | 
			
		||||
        // infinite loop
 | 
			
		||||
        if (
 | 
			
		||||
            !this.moving &&
 | 
			
		||||
            Math.abs(this.state.translationX - this.desiredTranslationX) <= 1 &&
 | 
			
		||||
            Math.abs(this.state.translationY - this.desiredTranslationY) <= 1
 | 
			
		||||
        ) return;
 | 
			
		||||
 | 
			
		||||
        const amt = this.moving ? MOVING_AMT : SNAPPING_AMT;
 | 
			
		||||
        this.setState({
 | 
			
		||||
            translationX: lerp(this.state.translationX, this.desiredTranslationX, amt),
 | 
			
		||||
            translationY: lerp(this.state.translationY, this.desiredTranslationY, amt),
 | 
			
		||||
        });
 | 
			
		||||
        this.scheduledUpdate.mark();
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    private setTranslation(inTranslationX: number, inTranslationY: number) {
 | 
			
		||||
        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 snap = () => {
 | 
			
		||||
        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;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // We start animating here because we want the PiP to move when we're
 | 
			
		||||
        // resizing the window
 | 
			
		||||
        this.scheduledUpdate.mark();
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    private onRoomViewStoreUpdate = (payload) => {
 | 
			
		||||
        if (RoomViewStore.getRoomId() === this.state.roomId) return;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -173,10 +288,52 @@ export default class CallPreview extends React.Component<IProps, IState> {
 | 
			
		|||
        });
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    private onStartMoving = (event: React.MouseEvent) => {
 | 
			
		||||
        event.preventDefault();
 | 
			
		||||
        event.stopPropagation();
 | 
			
		||||
 | 
			
		||||
        this.moving = true;
 | 
			
		||||
        this.initX = event.pageX - this.desiredTranslationX;
 | 
			
		||||
        this.initY = event.pageY - this.desiredTranslationY;
 | 
			
		||||
        this.scheduledUpdate.mark();
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    private onMoving = (event: React.MouseEvent | MouseEvent) => {
 | 
			
		||||
        if (!this.moving) return;
 | 
			
		||||
 | 
			
		||||
        event.preventDefault();
 | 
			
		||||
        event.stopPropagation();
 | 
			
		||||
 | 
			
		||||
        this.setTranslation(event.pageX - this.initX, event.pageY - this.initY);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    private onEndMoving = () => {
 | 
			
		||||
        this.moving = false;
 | 
			
		||||
        this.snap();
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    public render() {
 | 
			
		||||
        if (this.state.primaryCall) {
 | 
			
		||||
            const translatePixelsX = this.state.translationX + "px";
 | 
			
		||||
            const translatePixelsY = this.state.translationY + "px";
 | 
			
		||||
            const style = {
 | 
			
		||||
                transform: `translateX(${translatePixelsX})
 | 
			
		||||
                            translateY(${translatePixelsY})`,
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            return (
 | 
			
		||||
                <CallView call={this.state.primaryCall} secondaryCall={this.state.secondaryCall} pipMode={true} />
 | 
			
		||||
                <div
 | 
			
		||||
                    className="mx_CallPreview"
 | 
			
		||||
                    style={style}
 | 
			
		||||
                    ref={this.callViewWrapper}
 | 
			
		||||
                >
 | 
			
		||||
                    <CallView
 | 
			
		||||
                        call={this.state.primaryCall}
 | 
			
		||||
                        secondaryCall={this.state.secondaryCall}
 | 
			
		||||
                        pipMode={true}
 | 
			
		||||
                        onMouseDownOnHeader={this.onStartMoving}
 | 
			
		||||
                    />
 | 
			
		||||
                </div>
 | 
			
		||||
            );
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -49,6 +49,9 @@ interface IProps {
 | 
			
		|||
        // This is sort of a proxy for a number of things but we currently have no
 | 
			
		||||
        // need to control those things separately, so this is simpler.
 | 
			
		||||
        pipMode?: boolean;
 | 
			
		||||
 | 
			
		||||
        // Used for dragging the PiP CallView
 | 
			
		||||
        onMouseDownOnHeader?: (event: React.MouseEvent) => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface IState {
 | 
			
		||||
| 
						 | 
				
			
			@ -698,19 +701,24 @@ export default class CallView extends React.Component<IProps, IState> {
 | 
			
		|||
                </span>;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            header = <div className="mx_CallView_header">
 | 
			
		||||
                <AccessibleButton onClick={this.onRoomAvatarClick}>
 | 
			
		||||
                    <RoomAvatar room={callRoom} height={32} width={32} />
 | 
			
		||||
                </AccessibleButton>
 | 
			
		||||
                <div className="mx_CallView_header_callInfo">
 | 
			
		||||
                    <div className="mx_CallView_header_roomName">{callRoom.name}</div>
 | 
			
		||||
                    <div className="mx_CallView_header_callTypeSmall">
 | 
			
		||||
                        {callTypeText}
 | 
			
		||||
                        {secondaryCallInfo}
 | 
			
		||||
            header = (
 | 
			
		||||
                <div
 | 
			
		||||
                    className="mx_CallView_header"
 | 
			
		||||
                    onMouseDown={this.props.onMouseDownOnHeader}
 | 
			
		||||
                >
 | 
			
		||||
                    <AccessibleButton onClick={this.onRoomAvatarClick}>
 | 
			
		||||
                        <RoomAvatar room={callRoom} height={32} width={32} />
 | 
			
		||||
                    </AccessibleButton>
 | 
			
		||||
                    <div className="mx_CallView_header_callInfo">
 | 
			
		||||
                        <div className="mx_CallView_header_roomName">{callRoom.name}</div>
 | 
			
		||||
                        <div className="mx_CallView_header_callTypeSmall">
 | 
			
		||||
                            {callTypeText}
 | 
			
		||||
                            {secondaryCallInfo}
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    {headerControls}
 | 
			
		||||
                </div>
 | 
			
		||||
                {headerControls}
 | 
			
		||||
            </div>;
 | 
			
		||||
            );
 | 
			
		||||
            myClassName = 'mx_CallView_pip';
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,32 @@
 | 
			
		|||
/*
 | 
			
		||||
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
 | 
			
		||||
 | 
			
		||||
Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
you may not use this file except in compliance with the License.
 | 
			
		||||
You may obtain a copy of the License at
 | 
			
		||||
 | 
			
		||||
    http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
 | 
			
		||||
Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
See the License for the specific language governing permissions and
 | 
			
		||||
limitations under the License.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
import { clamp } from "lodash";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * This method linearly interpolates between two points (start, end). This is
 | 
			
		||||
 * most commonly used to find a point some fraction of the way along a line
 | 
			
		||||
 * between two endpoints (e.g. to move an object gradually between those
 | 
			
		||||
 * points).
 | 
			
		||||
 * @param {number} start the starting point
 | 
			
		||||
 * @param {number} end the ending point
 | 
			
		||||
 * @param {number} amt the interpolant
 | 
			
		||||
 * @returns
 | 
			
		||||
 */
 | 
			
		||||
export function lerp(start: number, end: number, amt: number) {
 | 
			
		||||
    amt = clamp(amt, 0, 1);
 | 
			
		||||
    return (1 - amt) * start + amt * end;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,35 @@
 | 
			
		|||
/*
 | 
			
		||||
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
 | 
			
		||||
 | 
			
		||||
Licensed under the Apache License, Version 2.0 (the "License");
 | 
			
		||||
you may not use this file except in compliance with the License.
 | 
			
		||||
You may obtain a copy of the License at
 | 
			
		||||
 | 
			
		||||
    http://www.apache.org/licenses/LICENSE-2.0
 | 
			
		||||
 | 
			
		||||
Unless required by applicable law or agreed to in writing, software
 | 
			
		||||
distributed under the License is distributed on an "AS IS" BASIS,
 | 
			
		||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
See the License for the specific language governing permissions and
 | 
			
		||||
limitations under the License.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
import { lerp } from "../../src/utils/AnimationUtils";
 | 
			
		||||
 | 
			
		||||
describe("lerp", () => {
 | 
			
		||||
    it("correctly interpolates", () => {
 | 
			
		||||
        expect(lerp(0, 100, 0.5)).toBe(50);
 | 
			
		||||
        expect(lerp(50, 100, 0.5)).toBe(75);
 | 
			
		||||
        expect(lerp(0, 1, 0.1)).toBe(0.1);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it("clamps the interpolant", () => {
 | 
			
		||||
        expect(lerp(0, 100, 50)).toBe(100);
 | 
			
		||||
        expect(lerp(0, 100, -50)).toBe(0);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it("handles negative numbers", () => {
 | 
			
		||||
        expect(lerp(-100, 0, 0.5)).toBe(-50);
 | 
			
		||||
        expect(lerp(100, -100, 0.5)).toBe(0);
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
		Loading…
	
		Reference in New Issue