Decouple video grid from video tile components

This is an attempt to address the feedback in https://github.com/vector-im/element-call/pull/1099#discussion_r1226863404 that the video grid and video tile components have become too tightly coupled. After this change, the only requirements that the video grid makes of its child components are:

- They accept ref, style, and item props
- They attach the ref and styles to a react-spring animated element

Note: I removed the video grid Storybook file, because I'm not aware of anyone using Storybook for development of Element Call beyond Robert, and it would take some effort to fix to work with these changes.
This commit is contained in:
Robin Townsend
2023-06-12 18:06:18 -04:00
parent 8c21dbaade
commit 1207ecc9d7
10 changed files with 426 additions and 531 deletions

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 New Vector Ltd
Copyright 2022-2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,21 +14,34 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { Key, useCallback, useEffect, useRef, useState } from "react";
import { FullGestureState, useDrag, useGesture } from "@use-gesture/react";
import React, {
ComponentProps,
Key,
Ref,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import {
EventTypes,
FullGestureState,
Handler,
useGesture,
} from "@use-gesture/react";
import {
animated,
SpringRef,
SpringValue,
SpringValues,
useSprings,
} from "@react-spring/web";
import useMeasure from "react-use-measure";
import { ResizeObserver as JuggleResizeObserver } from "@juggle/resize-observer";
import { ReactDOMAttributes } from "@use-gesture/react/dist/declarations/src/types";
import styles from "./VideoGrid.module.css";
import { Layout } from "../room/GridLayoutMenu";
import { TileDescriptor } from "./TileDescriptor";
import { TileWrapper } from "./TileWrapper";
interface TilePosition {
x: number;
@@ -39,7 +52,7 @@ interface TilePosition {
}
interface Tile {
key: Key;
key: string;
order: number;
item: TileDescriptor;
remove: boolean;
@@ -717,20 +730,18 @@ interface DragTileData {
y: number;
}
export interface ChildrenProperties extends ReactDOMAttributes {
key: Key;
export interface ChildrenProperties {
ref: Ref<HTMLElement>;
style: ComponentProps<typeof animated.div>["style"];
/**
* The width this tile will have once its animations have settled.
*/
targetWidth: number;
/**
* The height this tile will have once its animations have settled.
*/
targetHeight: number;
item: TileDescriptor;
opacity: SpringValue<number>;
scale: SpringValue<number>;
shadow: SpringValue<number>;
zIndex: SpringValue<number>;
x: SpringValue<number>;
y: SpringValue<number>;
width: SpringValue<number>;
height: SpringValue<number>;
[index: string]: unknown;
}
export interface VideoGridProps {
@@ -1063,117 +1074,132 @@ export function VideoGrid({
[tiles, layout, gridBounds.width, gridBounds.height, pipXRatio, pipYRatio]
);
const bindTile = useDrag(
({ args: [key], active, xy, movement, tap, last, event }) => {
event.preventDefault();
// Callback for useDrag. We could call useDrag here, but the default
// pattern of spreading {...bind()} across the children to bind the gesture
// ends up breaking memoization and ruining this component's performance.
// Instead, we pass this callback to each tile via a ref, to let them bind the
// gesture using the much more sensible ref-based method.
const onTileDrag = (
tileId: string,
{
active,
xy,
movement,
tap,
last,
event,
}: Parameters<Handler<"drag", EventTypes["drag"]>>[0]
) => {
event.preventDefault();
if (tap) {
onTap(key);
return;
}
if (tap) {
onTap(tileId);
return;
}
if (layout !== "freedom") return;
if (layout !== "freedom") return;
const dragTileIndex = tiles.findIndex((tile) => tile.key === key);
const dragTile = tiles[dragTileIndex];
const dragTilePosition = tilePositions[dragTile.order];
const dragTileIndex = tiles.findIndex((tile) => tile.key === tileId);
const dragTile = tiles[dragTileIndex];
const dragTilePosition = tilePositions[dragTile.order];
const cursorPosition = [xy[0] - gridBounds.left, xy[1] - gridBounds.top];
const cursorPosition = [xy[0] - gridBounds.left, xy[1] - gridBounds.top];
let newTiles = tiles;
let newTiles = tiles;
if (tiles.length === 2 && !tiles.some((t) => t.presenter || t.focused)) {
// We're in 1:1 mode, so only the local tile should be draggable
if (!dragTile.item.isLocal) return;
if (tiles.length === 2 && !tiles.some((t) => t.presenter || t.focused)) {
// We're in 1:1 mode, so only the local tile should be draggable
if (!dragTile.item.isLocal) return;
// Position should only update on the very last event, to avoid
// compounding the offset on every drag event
if (last) {
const remotePosition = tilePositions[1];
// Position should only update on the very last event, to avoid
// compounding the offset on every drag event
if (last) {
const remotePosition = tilePositions[1];
const pipGap = getPipGap(
gridBounds.width / gridBounds.height,
gridBounds.width
);
const pipMinX = remotePosition.x + pipGap;
const pipMinY = remotePosition.y + pipGap;
const pipMaxX =
remotePosition.x +
remotePosition.width -
dragTilePosition.width -
pipGap;
const pipMaxY =
remotePosition.y +
remotePosition.height -
dragTilePosition.height -
pipGap;
const newPipXRatio =
(dragTilePosition.x + movement[0] - pipMinX) / (pipMaxX - pipMinX);
const newPipYRatio =
(dragTilePosition.y + movement[1] - pipMinY) / (pipMaxY - pipMinY);
setPipXRatio(Math.max(0, Math.min(1, newPipXRatio)));
setPipYRatio(Math.max(0, Math.min(1, newPipYRatio)));
}
} else {
const hoverTile = tiles.find(
(tile) =>
tile.key !== key &&
isInside(cursorPosition, tilePositions[tile.order])
const pipGap = getPipGap(
gridBounds.width / gridBounds.height,
gridBounds.width
);
const pipMinX = remotePosition.x + pipGap;
const pipMinY = remotePosition.y + pipGap;
const pipMaxX =
remotePosition.x +
remotePosition.width -
dragTilePosition.width -
pipGap;
const pipMaxY =
remotePosition.y +
remotePosition.height -
dragTilePosition.height -
pipGap;
if (hoverTile) {
// Shift the tiles into their new order
newTiles = newTiles.map((tile) => {
let order = tile.order;
if (order < dragTile.order) {
if (order >= hoverTile.order) order++;
} else if (order > dragTile.order) {
if (order <= hoverTile.order) order--;
} else {
order = hoverTile.order;
}
const newPipXRatio =
(dragTilePosition.x + movement[0] - pipMinX) / (pipMaxX - pipMinX);
const newPipYRatio =
(dragTilePosition.y + movement[1] - pipMinY) / (pipMaxY - pipMinY);
let focused;
if (tile === hoverTile) {
focused = dragTile.focused;
} else if (tile === dragTile) {
focused = hoverTile.focused;
} else {
focused = tile.focused;
}
return { ...tile, order, focused };
});
reorderTiles(newTiles, layout);
setTileState((state) => ({ ...state, tiles: newTiles }));
}
setPipXRatio(Math.max(0, Math.min(1, newPipXRatio)));
setPipYRatio(Math.max(0, Math.min(1, newPipYRatio)));
}
} else {
const hoverTile = tiles.find(
(tile) =>
tile.key !== tileId &&
isInside(cursorPosition, tilePositions[tile.order])
);
if (active) {
if (!draggingTileRef.current) {
draggingTileRef.current = {
key: dragTile.key,
offsetX: dragTilePosition.x,
offsetY: dragTilePosition.y,
x: movement[0],
y: movement[1],
};
} else {
draggingTileRef.current.x = movement[0];
draggingTileRef.current.y = movement[1];
}
if (hoverTile) {
// Shift the tiles into their new order
newTiles = newTiles.map((tile) => {
let order = tile.order;
if (order < dragTile.order) {
if (order >= hoverTile.order) order++;
} else if (order > dragTile.order) {
if (order <= hoverTile.order) order--;
} else {
order = hoverTile.order;
}
let focused;
if (tile === hoverTile) {
focused = dragTile.focused;
} else if (tile === dragTile) {
focused = hoverTile.focused;
} else {
focused = tile.focused;
}
return { ...tile, order, focused };
});
reorderTiles(newTiles, layout);
setTileState((state) => ({ ...state, tiles: newTiles }));
}
}
if (active) {
if (!draggingTileRef.current) {
draggingTileRef.current = {
key: dragTile.key,
offsetX: dragTilePosition.x,
offsetY: dragTilePosition.y,
x: movement[0],
y: movement[1],
};
} else {
draggingTileRef.current = null;
draggingTileRef.current.x = movement[0];
draggingTileRef.current.y = movement[1];
}
} else {
draggingTileRef.current = null;
}
api.start(animate(newTiles));
},
{ filterTaps: true, pointer: { buttons: [1] } }
);
api.start(animate(newTiles));
};
const onTileDragRef = useRef(onTileDrag);
onTileDragRef.current = onTileDrag;
const onGridGesture = useCallback(
(
@@ -1220,18 +1246,23 @@ export function VideoGrid({
return (
<div className={styles.videoGrid} ref={gridRef} {...bindGrid()}>
{springs.map((style, i) => {
{springs.map((spring, i) => {
const tile = tiles[i];
const tilePosition = tilePositions[tile.order];
return children({
...bindTile(tile.key),
...style,
key: tile.item.id,
targetWidth: tilePosition.width,
targetHeight: tilePosition.height,
item: tile.item,
});
return (
<TileWrapper
key={tile.key}
id={tile.key}
onDragRef={onTileDragRef}
targetWidth={tilePosition.width}
targetHeight={tilePosition.height}
item={tile.item}
{...spring}
>
{children}
</TileWrapper>
);
})}
</div>
);