Make NewVideoGrid support arbitrary layout systems
In preparation for adding layouts other than big grid to the NewVideoGrid component, I've abstracted the grid layout system into an interface called Layout. For now, the only implementation of this interface is BigGrid, but this will allow us to easily plug in Spotlight, SplitGrid, and OneOnOne layout systems so we can get rid of the old VideoGrid component and have One Grid to Rule Them All™. Please do shout if any of this seems obtuse or underdocumented, because I'm not super happy with how approachable the NewVideoGrid code looks right now… Incidentally, this refactoring made it way easier to save the state of the grid while in fullscreen / another layout, so I went ahead and did that.
This commit is contained in:
parent
8250526231
commit
cc35f243f2
8 changed files with 501 additions and 298 deletions
|
@ -73,7 +73,7 @@ import { useJoinRule } from "./useJoinRule";
|
||||||
import { ParticipantInfo } from "./useGroupCall";
|
import { ParticipantInfo } from "./useGroupCall";
|
||||||
import { ItemData, TileContent } from "../video-grid/VideoTile";
|
import { ItemData, TileContent } from "../video-grid/VideoTile";
|
||||||
import { Config } from "../config/Config";
|
import { Config } from "../config/Config";
|
||||||
import { NewVideoGrid } from "../video-grid/NewVideoGrid";
|
import { NewVideoGrid, useLayoutStates } from "../video-grid/NewVideoGrid";
|
||||||
import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
|
import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
|
||||||
import { SettingsModal } from "../settings/SettingsModal";
|
import { SettingsModal } from "../settings/SettingsModal";
|
||||||
import { InviteModal } from "./InviteModal";
|
import { InviteModal } from "./InviteModal";
|
||||||
|
@ -253,6 +253,10 @@ export function InCallView({
|
||||||
|
|
||||||
const prefersReducedMotion = usePrefersReducedMotion();
|
const prefersReducedMotion = usePrefersReducedMotion();
|
||||||
|
|
||||||
|
// This state is lifted out of NewVideoGrid so that layout states can be
|
||||||
|
// restored after a layout switch or upon exiting fullscreen
|
||||||
|
const layoutStates = useLayoutStates();
|
||||||
|
|
||||||
const renderContent = (): JSX.Element => {
|
const renderContent = (): JSX.Element => {
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
@ -282,6 +286,7 @@ export function InCallView({
|
||||||
items={items}
|
items={items}
|
||||||
layout={layout}
|
layout={layout}
|
||||||
disableAnimations={prefersReducedMotion || isSafari}
|
disableAnimations={prefersReducedMotion || isSafari}
|
||||||
|
layoutStates={layoutStates}
|
||||||
>
|
>
|
||||||
{(props) => (
|
{(props) => (
|
||||||
<VideoTile
|
<VideoTile
|
||||||
|
|
29
src/video-grid/BigGrid.module.css
Normal file
29
src/video-grid/BigGrid.module.css
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
/*
|
||||||
|
Copyright 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.
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.bigGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-rows: 163px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 800px) {
|
||||||
|
.bigGrid {
|
||||||
|
grid-auto-rows: 183px;
|
||||||
|
column-gap: 18px;
|
||||||
|
row-gap: 21px;
|
||||||
|
}
|
||||||
|
}
|
|
@ -15,33 +15,48 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import TinyQueue from "tinyqueue";
|
import TinyQueue from "tinyqueue";
|
||||||
|
import { RectReadOnly } from "react-use-measure";
|
||||||
|
import { FC, memo, ReactNode } from "react";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
import { TileDescriptor } from "./VideoGrid";
|
import { TileDescriptor } from "./VideoGrid";
|
||||||
|
import { Slot } from "./NewVideoGrid";
|
||||||
|
import { Layout } from "./Layout";
|
||||||
import { count, findLastIndex } from "../array-utils";
|
import { count, findLastIndex } from "../array-utils";
|
||||||
|
import styles from "./BigGrid.module.css";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A 1×1 cell in a grid which belongs to a tile.
|
* A 1×1 cell in a grid which belongs to a tile.
|
||||||
*/
|
*/
|
||||||
export interface Cell {
|
interface Cell {
|
||||||
/**
|
/**
|
||||||
* The item displayed on the tile.
|
* The item displayed on the tile.
|
||||||
*/
|
*/
|
||||||
item: TileDescriptor<unknown>;
|
readonly item: TileDescriptor<unknown>;
|
||||||
/**
|
/**
|
||||||
* Whether this cell is the origin (top left corner) of the tile.
|
* Whether this cell is the origin (top left corner) of the tile.
|
||||||
*/
|
*/
|
||||||
origin: boolean;
|
readonly origin: boolean;
|
||||||
/**
|
/**
|
||||||
* The width, in columns, of the tile.
|
* The width, in columns, of the tile.
|
||||||
*/
|
*/
|
||||||
columns: number;
|
readonly columns: number;
|
||||||
/**
|
/**
|
||||||
* The height, in rows, of the tile.
|
* The height, in rows, of the tile.
|
||||||
*/
|
*/
|
||||||
rows: number;
|
readonly rows: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Grid {
|
export interface BigGridState {
|
||||||
|
readonly columns: number;
|
||||||
|
/**
|
||||||
|
* The cells of the grid, in left-to-right top-to-bottom order.
|
||||||
|
* undefined = empty.
|
||||||
|
*/
|
||||||
|
readonly cells: (Cell | undefined)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MutableBigGridState {
|
||||||
columns: number;
|
columns: number;
|
||||||
/**
|
/**
|
||||||
* The cells of the grid, in left-to-right top-to-bottom order.
|
* The cells of the grid, in left-to-right top-to-bottom order.
|
||||||
|
@ -58,7 +73,7 @@ export interface Grid {
|
||||||
* @returns An array in which each cell holds the index of the next cell to move
|
* @returns An array in which each cell holds the index of the next cell to move
|
||||||
* to to reach the destination, or null if it is the destination.
|
* to to reach the destination, or null if it is the destination.
|
||||||
*/
|
*/
|
||||||
export function getPaths(dest: number, g: Grid): (number | null)[] {
|
export function getPaths(dest: number, g: BigGridState): (number | null)[] {
|
||||||
const destRow = row(dest, g);
|
const destRow = row(dest, g);
|
||||||
const destColumn = column(dest, g);
|
const destColumn = column(dest, g);
|
||||||
|
|
||||||
|
@ -106,18 +121,23 @@ export function getPaths(dest: number, g: Grid): (number | null)[] {
|
||||||
return edges as (number | null)[];
|
return edges as (number | null)[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const findLast1By1Index = (g: Grid): number | null =>
|
const findLast1By1Index = (g: BigGridState): number | null =>
|
||||||
findLastIndex(g.cells, (c) => c?.rows === 1 && c?.columns === 1);
|
findLastIndex(g.cells, (c) => c?.rows === 1 && c?.columns === 1);
|
||||||
|
|
||||||
export function row(index: number, g: Grid): number {
|
export function row(index: number, g: BigGridState): number {
|
||||||
return Math.floor(index / g.columns);
|
return Math.floor(index / g.columns);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function column(index: number, g: Grid): number {
|
export function column(index: number, g: BigGridState): number {
|
||||||
return ((index % g.columns) + g.columns) % g.columns;
|
return ((index % g.columns) + g.columns) % g.columns;
|
||||||
}
|
}
|
||||||
|
|
||||||
function inArea(index: number, start: number, end: number, g: Grid): boolean {
|
function inArea(
|
||||||
|
index: number,
|
||||||
|
start: number,
|
||||||
|
end: number,
|
||||||
|
g: BigGridState
|
||||||
|
): boolean {
|
||||||
const indexColumn = column(index, g);
|
const indexColumn = column(index, g);
|
||||||
const indexRow = row(index, g);
|
const indexRow = row(index, g);
|
||||||
return (
|
return (
|
||||||
|
@ -131,7 +151,7 @@ function inArea(index: number, start: number, end: number, g: Grid): boolean {
|
||||||
function* cellsInArea(
|
function* cellsInArea(
|
||||||
start: number,
|
start: number,
|
||||||
end: number,
|
end: number,
|
||||||
g: Grid
|
g: BigGridState
|
||||||
): Generator<number, void, unknown> {
|
): Generator<number, void, unknown> {
|
||||||
const startColumn = column(start, g);
|
const startColumn = column(start, g);
|
||||||
const endColumn = column(end, g);
|
const endColumn = column(end, g);
|
||||||
|
@ -149,7 +169,7 @@ function* cellsInArea(
|
||||||
export function forEachCellInArea(
|
export function forEachCellInArea(
|
||||||
start: number,
|
start: number,
|
||||||
end: number,
|
end: number,
|
||||||
g: Grid,
|
g: BigGridState,
|
||||||
fn: (c: Cell | undefined, i: number) => void
|
fn: (c: Cell | undefined, i: number) => void
|
||||||
): void {
|
): void {
|
||||||
for (const i of cellsInArea(start, end, g)) fn(g.cells[i], i);
|
for (const i of cellsInArea(start, end, g)) fn(g.cells[i], i);
|
||||||
|
@ -158,7 +178,7 @@ export function forEachCellInArea(
|
||||||
function allCellsInArea(
|
function allCellsInArea(
|
||||||
start: number,
|
start: number,
|
||||||
end: number,
|
end: number,
|
||||||
g: Grid,
|
g: BigGridState,
|
||||||
fn: (c: Cell | undefined, i: number) => boolean
|
fn: (c: Cell | undefined, i: number) => boolean
|
||||||
): boolean {
|
): boolean {
|
||||||
for (const i of cellsInArea(start, end, g)) {
|
for (const i of cellsInArea(start, end, g)) {
|
||||||
|
@ -172,16 +192,19 @@ const areaEnd = (
|
||||||
start: number,
|
start: number,
|
||||||
columns: number,
|
columns: number,
|
||||||
rows: number,
|
rows: number,
|
||||||
g: Grid
|
g: BigGridState
|
||||||
): number => start + columns - 1 + g.columns * (rows - 1);
|
): number => start + columns - 1 + g.columns * (rows - 1);
|
||||||
|
|
||||||
const cloneGrid = (g: Grid): Grid => ({ ...g, cells: [...g.cells] });
|
const cloneGrid = (g: BigGridState): BigGridState => ({
|
||||||
|
...g,
|
||||||
|
cells: [...g.cells],
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the index of the next gap in the grid that should be backfilled by 1×1
|
* Gets the index of the next gap in the grid that should be backfilled by 1×1
|
||||||
* tiles.
|
* tiles.
|
||||||
*/
|
*/
|
||||||
function getNextGap(g: Grid): number | null {
|
function getNextGap(g: BigGridState): number | null {
|
||||||
const last1By1Index = findLast1By1Index(g);
|
const last1By1Index = findLast1By1Index(g);
|
||||||
if (last1By1Index === null) return null;
|
if (last1By1Index === null) return null;
|
||||||
|
|
||||||
|
@ -204,7 +227,7 @@ function getNextGap(g: Grid): number | null {
|
||||||
/**
|
/**
|
||||||
* Gets the index of the origin of the tile to which the given cell belongs.
|
* Gets the index of the origin of the tile to which the given cell belongs.
|
||||||
*/
|
*/
|
||||||
function getOrigin(g: Grid, index: number): number {
|
function getOrigin(g: BigGridState, index: number): number {
|
||||||
const initialColumn = column(index, g);
|
const initialColumn = column(index, g);
|
||||||
|
|
||||||
for (
|
for (
|
||||||
|
@ -229,7 +252,7 @@ function getOrigin(g: Grid, index: number): number {
|
||||||
* along the way.
|
* along the way.
|
||||||
* Precondition: the destination area must consist of only 1×1 tiles.
|
* Precondition: the destination area must consist of only 1×1 tiles.
|
||||||
*/
|
*/
|
||||||
function moveTile(g: Grid, from: number, to: number) {
|
function moveTileUnchecked(g: BigGridState, from: number, to: number) {
|
||||||
const tile = g.cells[from]!;
|
const tile = g.cells[from]!;
|
||||||
const fromEnd = areaEnd(from, tile.columns, tile.rows, g);
|
const fromEnd = areaEnd(from, tile.columns, tile.rows, g);
|
||||||
const toEnd = areaEnd(to, tile.columns, tile.rows, g);
|
const toEnd = areaEnd(to, tile.columns, tile.rows, g);
|
||||||
|
@ -262,10 +285,15 @@ function moveTile(g: Grid, from: number, to: number) {
|
||||||
/**
|
/**
|
||||||
* Moves the tile at index "from" over to index "to", if there is space.
|
* Moves the tile at index "from" over to index "to", if there is space.
|
||||||
*/
|
*/
|
||||||
export function tryMoveTile(g: Grid, from: number, to: number): Grid {
|
export function moveTile(
|
||||||
|
g: BigGridState,
|
||||||
|
from: number,
|
||||||
|
to: number
|
||||||
|
): BigGridState {
|
||||||
const tile = g.cells[from]!;
|
const tile = g.cells[from]!;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
to !== from && // Skip the operation if nothing would move
|
||||||
to >= 0 &&
|
to >= 0 &&
|
||||||
to < g.cells.length &&
|
to < g.cells.length &&
|
||||||
column(to, g) <= g.columns - tile.columns
|
column(to, g) <= g.columns - tile.columns
|
||||||
|
@ -283,7 +311,7 @@ export function tryMoveTile(g: Grid, from: number, to: number): Grid {
|
||||||
if (allCellsInArea(to, toEnd, g, displaceable)) {
|
if (allCellsInArea(to, toEnd, g, displaceable)) {
|
||||||
// The target space is free; move
|
// The target space is free; move
|
||||||
const gClone = cloneGrid(g);
|
const gClone = cloneGrid(g);
|
||||||
moveTile(gClone, from, to);
|
moveTileUnchecked(gClone, from, to);
|
||||||
return gClone;
|
return gClone;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -297,7 +325,7 @@ export function tryMoveTile(g: Grid, from: number, to: number): Grid {
|
||||||
* enlarged tiles around when necessary.
|
* enlarged tiles around when necessary.
|
||||||
* @returns Whether the tile was actually pushed
|
* @returns Whether the tile was actually pushed
|
||||||
*/
|
*/
|
||||||
function pushTileUp(g: Grid, from: number): boolean {
|
function pushTileUp(g: BigGridState, from: number): boolean {
|
||||||
const tile = g.cells[from]!;
|
const tile = g.cells[from]!;
|
||||||
|
|
||||||
// TODO: pushing large tiles sideways might be more successful in some
|
// TODO: pushing large tiles sideways might be more successful in some
|
||||||
|
@ -315,7 +343,7 @@ function pushTileUp(g: Grid, from: number): boolean {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (cellsAboveAreDisplacable) {
|
if (cellsAboveAreDisplacable) {
|
||||||
moveTile(g, from, from - g.columns);
|
moveTileUnchecked(g, from, from - g.columns);
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
return false;
|
return false;
|
||||||
|
@ -325,8 +353,8 @@ function pushTileUp(g: Grid, from: number): boolean {
|
||||||
/**
|
/**
|
||||||
* Backfill any gaps in the grid.
|
* Backfill any gaps in the grid.
|
||||||
*/
|
*/
|
||||||
export function fillGaps(g: Grid): Grid {
|
export function fillGaps(g: BigGridState): BigGridState {
|
||||||
const result = cloneGrid(g);
|
const result = cloneGrid(g) as MutableBigGridState;
|
||||||
|
|
||||||
// This will hopefully be the size of the grid after we're done here, assuming
|
// This will hopefully be the size of the grid after we're done here, assuming
|
||||||
// that we can pack the large tiles tightly enough
|
// that we can pack the large tiles tightly enough
|
||||||
|
@ -403,7 +431,11 @@ export function fillGaps(g: Grid): Grid {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRows(g: Grid, count: number, atRow: number): Grid {
|
function createRows(
|
||||||
|
g: BigGridState,
|
||||||
|
count: number,
|
||||||
|
atRow: number
|
||||||
|
): BigGridState {
|
||||||
const result = {
|
const result = {
|
||||||
columns: g.columns,
|
columns: g.columns,
|
||||||
cells: new Array(g.cells.length + g.columns * count),
|
cells: new Array(g.cells.length + g.columns * count),
|
||||||
|
@ -430,9 +462,12 @@ function createRows(g: Grid, count: number, atRow: number): Grid {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a set of new items into the grid.
|
* Adds a set of new items into the grid. (May leave gaps.)
|
||||||
*/
|
*/
|
||||||
export function addItems(items: TileDescriptor<unknown>[], g: Grid): Grid {
|
export function addItems(
|
||||||
|
items: TileDescriptor<unknown>[],
|
||||||
|
g: BigGridState
|
||||||
|
): BigGridState {
|
||||||
let result = cloneGrid(g);
|
let result = cloneGrid(g);
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
|
@ -444,13 +479,11 @@ export function addItems(items: TileDescriptor<unknown>[], g: Grid): Grid {
|
||||||
};
|
};
|
||||||
|
|
||||||
let placeAt: number;
|
let placeAt: number;
|
||||||
let hasGaps: boolean;
|
|
||||||
|
|
||||||
if (item.placeNear === undefined) {
|
if (item.placeNear === undefined) {
|
||||||
// This item has no special placement requests, so let's put it
|
// This item has no special placement requests, so let's put it
|
||||||
// uneventfully at the end of the grid
|
// uneventfully at the end of the grid
|
||||||
placeAt = result.cells.length;
|
placeAt = result.cells.length;
|
||||||
hasGaps = false;
|
|
||||||
} else {
|
} else {
|
||||||
// This item wants to be placed near another; let's put it on a row
|
// This item wants to be placed near another; let's put it on a row
|
||||||
// directly below the related tile
|
// directly below the related tile
|
||||||
|
@ -460,7 +493,6 @@ export function addItems(items: TileDescriptor<unknown>[], g: Grid): Grid {
|
||||||
if (placeNear === -1) {
|
if (placeNear === -1) {
|
||||||
// Can't find the related tile, so let's give up and place it at the end
|
// Can't find the related tile, so let's give up and place it at the end
|
||||||
placeAt = result.cells.length;
|
placeAt = result.cells.length;
|
||||||
hasGaps = false;
|
|
||||||
} else {
|
} else {
|
||||||
const placeNearCell = result.cells[placeNear]!;
|
const placeNearCell = result.cells[placeNear]!;
|
||||||
const placeNearEnd = areaEnd(
|
const placeNearEnd = areaEnd(
|
||||||
|
@ -475,7 +507,6 @@ export function addItems(items: TileDescriptor<unknown>[], g: Grid): Grid {
|
||||||
placeNear +
|
placeNear +
|
||||||
Math.floor(placeNearCell.columns / 2) +
|
Math.floor(placeNearCell.columns / 2) +
|
||||||
result.columns * placeNearCell.rows;
|
result.columns * placeNearCell.rows;
|
||||||
hasGaps = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -484,21 +515,19 @@ export function addItems(items: TileDescriptor<unknown>[], g: Grid): Grid {
|
||||||
if (item.largeBaseSize) {
|
if (item.largeBaseSize) {
|
||||||
// Cycle the tile size once to set up the tile with its larger base size
|
// Cycle the tile size once to set up the tile with its larger base size
|
||||||
// This also fills any gaps in the grid, hence no extra call to fillGaps
|
// This also fills any gaps in the grid, hence no extra call to fillGaps
|
||||||
result = cycleTileSize(item.id, result);
|
result = cycleTileSize(result, item);
|
||||||
} else if (hasGaps) {
|
|
||||||
result = fillGaps(result);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const largeTileDimensions = (g: Grid): [number, number] => [
|
const largeTileDimensions = (g: BigGridState): [number, number] => [
|
||||||
Math.min(3, Math.max(2, g.columns - 1)),
|
Math.min(3, Math.max(2, g.columns - 1)),
|
||||||
2,
|
2,
|
||||||
];
|
];
|
||||||
|
|
||||||
const extraLargeTileDimensions = (g: Grid): [number, number] =>
|
const extraLargeTileDimensions = (g: BigGridState): [number, number] =>
|
||||||
g.columns > 3 ? [4, 3] : [g.columns, 2];
|
g.columns > 3 ? [4, 3] : [g.columns, 2];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -507,8 +536,11 @@ const extraLargeTileDimensions = (g: Grid): [number, number] =>
|
||||||
* @param g The grid.
|
* @param g The grid.
|
||||||
* @returns The updated grid.
|
* @returns The updated grid.
|
||||||
*/
|
*/
|
||||||
export function cycleTileSize(tileId: string, g: Grid): Grid {
|
export function cycleTileSize(
|
||||||
const from = g.cells.findIndex((c) => c?.item.id === tileId);
|
g: BigGridState,
|
||||||
|
tile: TileDescriptor<unknown>
|
||||||
|
): BigGridState {
|
||||||
|
const from = g.cells.findIndex((c) => c?.item === tile);
|
||||||
if (from === -1) return g; // Tile removed, no change
|
if (from === -1) return g; // Tile removed, no change
|
||||||
const fromCell = g.cells[from]!;
|
const fromCell = g.cells[from]!;
|
||||||
const fromWidth = fromCell.columns;
|
const fromWidth = fromCell.columns;
|
||||||
|
@ -629,8 +661,8 @@ export function cycleTileSize(tileId: string, g: Grid): Grid {
|
||||||
/**
|
/**
|
||||||
* Resizes the grid to a new column width.
|
* Resizes the grid to a new column width.
|
||||||
*/
|
*/
|
||||||
export function resize(g: Grid, columns: number): Grid {
|
export function resize(g: BigGridState, columns: number): BigGridState {
|
||||||
const result: Grid = { columns, cells: [] };
|
const result: BigGridState = { columns, cells: [] };
|
||||||
const [largeColumns, largeRows] = largeTileDimensions(result);
|
const [largeColumns, largeRows] = largeTileDimensions(result);
|
||||||
|
|
||||||
// Copy each tile from the old grid to the resized one in the same order
|
// Copy each tile from the old grid to the resized one in the same order
|
||||||
|
@ -640,6 +672,7 @@ export function resize(g: Grid, columns: number): Grid {
|
||||||
|
|
||||||
for (const cell of g.cells) {
|
for (const cell of g.cells) {
|
||||||
if (cell?.origin) {
|
if (cell?.origin) {
|
||||||
|
// TODO make aware of extra large tiles
|
||||||
const [nextColumns, nextRows] =
|
const [nextColumns, nextRows] =
|
||||||
cell.columns > 1 || cell.rows > 1 ? [largeColumns, largeRows] : [1, 1];
|
cell.columns > 1 || cell.rows > 1 ? [largeColumns, largeRows] : [1, 1];
|
||||||
|
|
||||||
|
@ -672,7 +705,7 @@ export function resize(g: Grid, columns: number): Grid {
|
||||||
/**
|
/**
|
||||||
* Promotes speakers to the first page of the grid.
|
* Promotes speakers to the first page of the grid.
|
||||||
*/
|
*/
|
||||||
export function promoteSpeakers(g: Grid) {
|
export function promoteSpeakers(g: BigGridState) {
|
||||||
// This is all a bit of a hack right now, because we don't know if the designs
|
// This is all a bit of a hack right now, because we don't know if the designs
|
||||||
// will stick with this approach in the long run
|
// will stick with this approach in the long run
|
||||||
// We assume that 4 rows are probably about 1 page
|
// We assume that 4 rows are probably about 1 page
|
||||||
|
@ -694,10 +727,148 @@ export function promoteSpeakers(g: Grid) {
|
||||||
toCell === undefined ||
|
toCell === undefined ||
|
||||||
(toCell.columns === 1 && toCell.rows === 1)
|
(toCell.columns === 1 && toCell.rows === 1)
|
||||||
) {
|
) {
|
||||||
moveTile(g, from, to);
|
moveTileUnchecked(g, from, to);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The algorithm for updating a grid with a new set of tiles.
|
||||||
|
*/
|
||||||
|
function updateTiles(
|
||||||
|
g: BigGridState,
|
||||||
|
tiles: TileDescriptor<unknown>[]
|
||||||
|
): BigGridState {
|
||||||
|
// Step 1: Update tiles that still exist, and remove tiles that have left
|
||||||
|
// the grid
|
||||||
|
const itemsById = new Map(tiles.map((i) => [i.id, i]));
|
||||||
|
const grid1: BigGridState = {
|
||||||
|
...g,
|
||||||
|
cells: g.cells.map((c) => {
|
||||||
|
if (c === undefined) return undefined;
|
||||||
|
const item = itemsById.get(c.item.id);
|
||||||
|
return item === undefined ? undefined : { ...c, item };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 2: Add new tiles
|
||||||
|
const existingItemIds = new Set(
|
||||||
|
grid1.cells.filter((c) => c !== undefined).map((c) => c!.item.id)
|
||||||
|
);
|
||||||
|
const newItems = tiles.filter((i) => !existingItemIds.has(i.id));
|
||||||
|
const grid2 = addItems(newItems, grid1);
|
||||||
|
|
||||||
|
// Step 3: Promote speakers to the top
|
||||||
|
promoteSpeakers(grid2);
|
||||||
|
|
||||||
|
return fillGaps(grid2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBounds(g: BigGridState, bounds: RectReadOnly): BigGridState {
|
||||||
|
const columns = Math.max(2, Math.floor(bounds.width * 0.0045));
|
||||||
|
return columns === g.columns ? g : resize(g, columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Slots: FC<{ s: BigGridState }> = memo(({ s: g }) => {
|
||||||
|
const areas = new Array<(number | null)[]>(
|
||||||
|
Math.ceil(g.cells.length / g.columns)
|
||||||
|
);
|
||||||
|
for (let i = 0; i < areas.length; i++)
|
||||||
|
areas[i] = new Array<number | null>(g.columns).fill(null);
|
||||||
|
|
||||||
|
let slotCount = 0;
|
||||||
|
for (let i = 0; i < g.cells.length; i++) {
|
||||||
|
const cell = g.cells[i];
|
||||||
|
if (cell?.origin) {
|
||||||
|
const slotEnd = i + cell.columns - 1 + g.columns * (cell.rows - 1);
|
||||||
|
forEachCellInArea(
|
||||||
|
i,
|
||||||
|
slotEnd,
|
||||||
|
g,
|
||||||
|
(_c, j) => (areas[row(j, g)][column(j, g)] = slotCount)
|
||||||
|
);
|
||||||
|
slotCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
gridTemplateAreas: areas
|
||||||
|
.map(
|
||||||
|
(row) =>
|
||||||
|
`'${row
|
||||||
|
.map((slotId) => (slotId === null ? "." : `s${slotId}`))
|
||||||
|
.join(" ")}'`
|
||||||
|
)
|
||||||
|
.join(" "),
|
||||||
|
gridTemplateColumns: `repeat(${g.columns}, 1fr)`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const slots = new Array<ReactNode>(slotCount);
|
||||||
|
for (let i = 0; i < slotCount; i++)
|
||||||
|
slots[i] = <Slot key={i} style={{ gridArea: `s${i}` }} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.bigGrid} style={style}>
|
||||||
|
{slots}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a tile and numbers in the range [0, 1) describing a position within the
|
||||||
|
* tile, this returns the index of the specific cell in which that position
|
||||||
|
* lies.
|
||||||
|
*/
|
||||||
|
function positionOnTileToCell(
|
||||||
|
g: BigGridState,
|
||||||
|
tileOriginIndex: number,
|
||||||
|
xPositionOnTile: number,
|
||||||
|
yPositionOnTile: number
|
||||||
|
): number {
|
||||||
|
const tileOrigin = g.cells[tileOriginIndex]!;
|
||||||
|
const columnOnTile = Math.floor(xPositionOnTile * tileOrigin.columns);
|
||||||
|
const rowOnTile = Math.floor(yPositionOnTile * tileOrigin.rows);
|
||||||
|
return tileOriginIndex + columnOnTile + g.columns * rowOnTile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragTile(
|
||||||
|
g: BigGridState,
|
||||||
|
from: TileDescriptor<unknown>,
|
||||||
|
to: TileDescriptor<unknown>,
|
||||||
|
xPositionOnFrom: number,
|
||||||
|
yPositionOnFrom: number,
|
||||||
|
xPositionOnTo: number,
|
||||||
|
yPositionOnTo: number
|
||||||
|
): BigGridState {
|
||||||
|
const fromOrigin = g.cells.findIndex((c) => c?.item === from);
|
||||||
|
const toOrigin = g.cells.findIndex((c) => c?.item === to);
|
||||||
|
const fromCell = positionOnTileToCell(
|
||||||
|
g,
|
||||||
|
fromOrigin,
|
||||||
|
xPositionOnFrom,
|
||||||
|
yPositionOnFrom
|
||||||
|
);
|
||||||
|
const toCell = positionOnTileToCell(
|
||||||
|
g,
|
||||||
|
toOrigin,
|
||||||
|
xPositionOnTo,
|
||||||
|
yPositionOnTo
|
||||||
|
);
|
||||||
|
|
||||||
|
return moveTile(g, fromOrigin, fromOrigin + toCell - fromCell);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BigGrid: Layout<BigGridState> = {
|
||||||
|
emptyState: { columns: 4, cells: [] },
|
||||||
|
updateTiles,
|
||||||
|
updateBounds,
|
||||||
|
getTiles: (g) => g.cells.filter((c) => c?.origin).map((c) => c!.item),
|
||||||
|
canDragTile: () => true,
|
||||||
|
dragTile,
|
||||||
|
toggleFocus: cycleTileSize,
|
||||||
|
Slots,
|
||||||
|
rememberState: false,
|
||||||
|
};
|
74
src/video-grid/Layout.ts
Normal file
74
src/video-grid/Layout.ts
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
/*
|
||||||
|
Copyright 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.
|
||||||
|
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 type { ComponentType } from "react";
|
||||||
|
import type { RectReadOnly } from "react-use-measure";
|
||||||
|
import type { TileDescriptor } from "./VideoGrid";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A video grid layout system with concrete states of type State.
|
||||||
|
*/
|
||||||
|
export interface Layout<State> {
|
||||||
|
/**
|
||||||
|
* The layout state for zero tiles.
|
||||||
|
*/
|
||||||
|
readonly emptyState: State;
|
||||||
|
/**
|
||||||
|
* Updates/adds/removes tiles in a way that looks natural in the context of
|
||||||
|
* the given initial state.
|
||||||
|
*/
|
||||||
|
readonly updateTiles: (s: State, tiles: TileDescriptor<unknown>[]) => State;
|
||||||
|
/**
|
||||||
|
* Adapts the layout to a new container size.
|
||||||
|
*/
|
||||||
|
readonly updateBounds: (s: State, bounds: RectReadOnly) => State;
|
||||||
|
/**
|
||||||
|
* Gets tiles in the order created by the layout.
|
||||||
|
*/
|
||||||
|
readonly getTiles: (s: State) => TileDescriptor<unknown>[];
|
||||||
|
/**
|
||||||
|
* Determines whether a tile is draggable.
|
||||||
|
*/
|
||||||
|
readonly canDragTile: (s: State, tile: TileDescriptor<unknown>) => boolean;
|
||||||
|
/**
|
||||||
|
* Drags the tile 'from' to the location of the tile 'to' (if possible).
|
||||||
|
* The position parameters are numbers in the range [0, 1) describing the
|
||||||
|
* specific positions on 'from' and 'to' that the drag gesture is targeting.
|
||||||
|
*/
|
||||||
|
readonly dragTile: (
|
||||||
|
s: State,
|
||||||
|
from: TileDescriptor<unknown>,
|
||||||
|
to: TileDescriptor<unknown>,
|
||||||
|
xPositionOnFrom: number,
|
||||||
|
yPositionOnFrom: number,
|
||||||
|
xPositionOnTo: number,
|
||||||
|
yPositionOnTo: number
|
||||||
|
) => State;
|
||||||
|
/**
|
||||||
|
* Toggles the focus of the given tile (if this layout has the concept of
|
||||||
|
* focus).
|
||||||
|
*/
|
||||||
|
readonly toggleFocus?: (s: State, tile: TileDescriptor<unknown>) => State;
|
||||||
|
/**
|
||||||
|
* A React component generating the slot elements for a given layout state.
|
||||||
|
*/
|
||||||
|
readonly Slots: ComponentType<{ s: State }>;
|
||||||
|
/**
|
||||||
|
* Whether the state of this layout should be remembered even while a
|
||||||
|
* different layout is active.
|
||||||
|
*/
|
||||||
|
readonly rememberState: boolean;
|
||||||
|
}
|
|
@ -23,11 +23,8 @@ limitations under the License.
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slotGrid {
|
.slots {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
|
||||||
grid-auto-rows: 163px;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.slot {
|
.slot {
|
||||||
|
@ -38,10 +35,4 @@ limitations under the License.
|
||||||
.grid {
|
.grid {
|
||||||
padding: 0 22px var(--footerHeight);
|
padding: 0 22px var(--footerHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.slotGrid {
|
|
||||||
grid-auto-rows: 183px;
|
|
||||||
column-gap: 18px;
|
|
||||||
row-gap: 21px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,17 +17,17 @@ limitations under the License.
|
||||||
import { SpringRef, TransitionFn, useTransition } from "@react-spring/web";
|
import { SpringRef, TransitionFn, useTransition } from "@react-spring/web";
|
||||||
import { EventTypes, Handler, useScroll } from "@use-gesture/react";
|
import { EventTypes, Handler, useScroll } from "@use-gesture/react";
|
||||||
import React, {
|
import React, {
|
||||||
Dispatch,
|
CSSProperties,
|
||||||
|
FC,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
SetStateAction,
|
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import useMeasure from "react-use-measure";
|
import useMeasure, { RectReadOnly } from "react-use-measure";
|
||||||
import { zipWith } from "lodash";
|
import { zip } from "lodash";
|
||||||
|
|
||||||
import styles from "./NewVideoGrid.module.css";
|
import styles from "./NewVideoGrid.module.css";
|
||||||
import {
|
import {
|
||||||
|
@ -38,98 +38,85 @@ import {
|
||||||
} from "./VideoGrid";
|
} from "./VideoGrid";
|
||||||
import { useReactiveState } from "../useReactiveState";
|
import { useReactiveState } from "../useReactiveState";
|
||||||
import { useMergedRefs } from "../useMergedRefs";
|
import { useMergedRefs } from "../useMergedRefs";
|
||||||
import {
|
|
||||||
Grid,
|
|
||||||
Cell,
|
|
||||||
row,
|
|
||||||
column,
|
|
||||||
fillGaps,
|
|
||||||
forEachCellInArea,
|
|
||||||
cycleTileSize,
|
|
||||||
addItems,
|
|
||||||
tryMoveTile,
|
|
||||||
resize,
|
|
||||||
promoteSpeakers,
|
|
||||||
} from "./model";
|
|
||||||
import { TileWrapper } from "./TileWrapper";
|
import { TileWrapper } from "./TileWrapper";
|
||||||
|
import { BigGrid } from "./BigGrid";
|
||||||
|
import { Layout } from "./Layout";
|
||||||
|
|
||||||
interface GridState extends Grid {
|
export const useLayoutStates = () => {
|
||||||
/**
|
const layoutStates = useRef<Map<Layout<unknown>, unknown>>();
|
||||||
* The ID of the current state of the grid.
|
if (layoutStates.current === undefined) layoutStates.current = new Map();
|
||||||
*/
|
return layoutStates.current;
|
||||||
generation: number;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
const useGridState = (
|
const useGrid = (
|
||||||
columns: number | null,
|
layout: Layout<unknown>,
|
||||||
items: TileDescriptor<unknown>[]
|
items: TileDescriptor<unknown>[],
|
||||||
): [GridState | null, Dispatch<SetStateAction<Grid>>] => {
|
bounds: RectReadOnly,
|
||||||
const [grid, setGrid_] = useReactiveState<GridState | null>(
|
layoutStates: Map<Layout<unknown>, unknown>
|
||||||
(prevGrid = null) => {
|
) => {
|
||||||
if (prevGrid === null) {
|
const prevLayout = useRef<Layout<unknown>>(layout);
|
||||||
// We can't do anything if the column count isn't known yet
|
const prevState = layoutStates.get(layout);
|
||||||
if (columns === null) {
|
|
||||||
return null;
|
const [state, setState] = useReactiveState<unknown>(() => {
|
||||||
|
// If the bounds aren't known yet, don't add anything to the layout
|
||||||
|
if (bounds.width === 0) {
|
||||||
|
return layout.emptyState;
|
||||||
} else {
|
} else {
|
||||||
prevGrid = { generation: 0, columns, cells: [] };
|
if (layout !== prevLayout.current && !prevLayout.current.rememberState)
|
||||||
}
|
layoutStates.delete(prevLayout.current);
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Update tiles that still exist, and remove tiles that have left
|
const baseState = layoutStates.get(layout) ?? layout.emptyState;
|
||||||
// the grid
|
return layout.updateTiles(layout.updateBounds(baseState, bounds), items);
|
||||||
const itemsById = new Map(items.map((i) => [i.id, i]));
|
}
|
||||||
const grid1: Grid = {
|
}, [layout, items, bounds]);
|
||||||
...prevGrid,
|
|
||||||
cells: prevGrid.cells.map((c) => {
|
const generation = useRef<number>(0);
|
||||||
if (c === undefined) return undefined;
|
if (state !== prevState) generation.current++;
|
||||||
const item = itemsById.get(c.item.id);
|
|
||||||
return item === undefined ? undefined : { ...c, item };
|
prevLayout.current = layout;
|
||||||
}),
|
// No point in remembering an empty state, plus it would end up clobbering the
|
||||||
|
// real saved state while restoring a layout
|
||||||
|
if (state !== layout.emptyState) layoutStates.set(layout, state);
|
||||||
|
|
||||||
|
return {
|
||||||
|
grid: state,
|
||||||
|
orderedItems: useMemo(() => layout.getTiles(state), [layout, state]),
|
||||||
|
generation: generation.current,
|
||||||
|
canDragTile: useCallback(
|
||||||
|
(tile: TileDescriptor<unknown>) => layout.canDragTile(state, tile),
|
||||||
|
[layout, state]
|
||||||
|
),
|
||||||
|
dragTile: useCallback(
|
||||||
|
(
|
||||||
|
from: TileDescriptor<unknown>,
|
||||||
|
to: TileDescriptor<unknown>,
|
||||||
|
xPositionOnFrom: number,
|
||||||
|
yPositionOnFrom: number,
|
||||||
|
xPositionOnTo: number,
|
||||||
|
yPositionOnTo: number
|
||||||
|
) =>
|
||||||
|
setState((s) =>
|
||||||
|
layout.dragTile(
|
||||||
|
s,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
xPositionOnFrom,
|
||||||
|
yPositionOnFrom,
|
||||||
|
xPositionOnTo,
|
||||||
|
yPositionOnTo
|
||||||
|
)
|
||||||
|
),
|
||||||
|
[layout, setState]
|
||||||
|
),
|
||||||
|
toggleFocus: useMemo(
|
||||||
|
() =>
|
||||||
|
layout.toggleFocus &&
|
||||||
|
((tile: TileDescriptor<unknown>) =>
|
||||||
|
setState((s) => layout.toggleFocus!(s, tile))),
|
||||||
|
[layout, setState]
|
||||||
|
),
|
||||||
|
slots: <layout.Slots s={state} />,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Step 2: Resize the grid if necessary and backfill gaps left behind by
|
|
||||||
// removed tiles
|
|
||||||
// Resizing already takes care of backfilling gaps
|
|
||||||
const grid2 =
|
|
||||||
columns !== grid1.columns ? resize(grid1, columns!) : fillGaps(grid1);
|
|
||||||
|
|
||||||
// Step 3: Add new tiles to the end of the grid
|
|
||||||
const existingItemIds = new Set(
|
|
||||||
grid2.cells.filter((c) => c !== undefined).map((c) => c!.item.id)
|
|
||||||
);
|
|
||||||
const newItems = items.filter((i) => !existingItemIds.has(i.id));
|
|
||||||
const grid3 = addItems(newItems, grid2);
|
|
||||||
|
|
||||||
// Step 4: Promote speakers to the top
|
|
||||||
promoteSpeakers(grid3);
|
|
||||||
|
|
||||||
return { ...grid3, generation: prevGrid.generation + 1 };
|
|
||||||
},
|
|
||||||
[columns, items]
|
|
||||||
);
|
|
||||||
|
|
||||||
const setGrid: Dispatch<SetStateAction<Grid>> = useCallback(
|
|
||||||
(action) => {
|
|
||||||
if (typeof action === "function") {
|
|
||||||
setGrid_((prevGrid) =>
|
|
||||||
prevGrid === null
|
|
||||||
? null
|
|
||||||
: {
|
|
||||||
...(action as (prev: Grid) => Grid)(prevGrid),
|
|
||||||
generation: prevGrid.generation + 1,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setGrid_((prevGrid) => ({
|
|
||||||
...action,
|
|
||||||
generation: prevGrid?.generation ?? 1,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setGrid_]
|
|
||||||
);
|
|
||||||
|
|
||||||
return [grid, setGrid];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Rect {
|
interface Rect {
|
||||||
|
@ -151,12 +138,21 @@ interface DragState {
|
||||||
cursorY: number;
|
cursorY: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SlotProps {
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Slot: FC<SlotProps> = ({ style }) => (
|
||||||
|
<div className={styles.slot} style={style} />
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An interactive, animated grid of video tiles.
|
* An interactive, animated grid of video tiles.
|
||||||
*/
|
*/
|
||||||
export function NewVideoGrid<T>({
|
export function NewVideoGrid<T>({
|
||||||
items,
|
items,
|
||||||
disableAnimations,
|
disableAnimations,
|
||||||
|
layoutStates,
|
||||||
children,
|
children,
|
||||||
}: Props<T>) {
|
}: Props<T>) {
|
||||||
// Overview: This component lays out tiles by rendering an invisible template
|
// Overview: This component lays out tiles by rendering an invisible template
|
||||||
|
@ -169,36 +165,36 @@ export function NewVideoGrid<T>({
|
||||||
// most recently rendered generation of the grid, and watch it with a
|
// most recently rendered generation of the grid, and watch it with a
|
||||||
// MutationObserver.
|
// MutationObserver.
|
||||||
|
|
||||||
const [slotGrid, setSlotGrid] = useState<HTMLDivElement | null>(null);
|
const [slotsRoot, setSlotsRoot] = useState<HTMLDivElement | null>(null);
|
||||||
const [slotGridGeneration, setSlotGridGeneration] = useState(0);
|
const [renderedGeneration, setRenderedGeneration] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (slotGrid !== null) {
|
if (slotsRoot !== null) {
|
||||||
setSlotGridGeneration(
|
setRenderedGeneration(
|
||||||
parseInt(slotGrid.getAttribute("data-generation")!)
|
parseInt(slotsRoot.getAttribute("data-generation")!)
|
||||||
);
|
);
|
||||||
|
|
||||||
const observer = new MutationObserver((mutations) => {
|
const observer = new MutationObserver((mutations) => {
|
||||||
if (mutations.some((m) => m.type === "attributes")) {
|
if (mutations.some((m) => m.type === "attributes")) {
|
||||||
setSlotGridGeneration(
|
setRenderedGeneration(
|
||||||
parseInt(slotGrid.getAttribute("data-generation")!)
|
parseInt(slotsRoot.getAttribute("data-generation")!)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
observer.observe(slotGrid, { attributes: true });
|
observer.observe(slotsRoot, { attributes: true });
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}
|
}
|
||||||
}, [slotGrid, setSlotGridGeneration]);
|
}, [slotsRoot, setRenderedGeneration]);
|
||||||
|
|
||||||
const [gridRef1, gridBounds] = useMeasure();
|
const [gridRef1, gridBounds] = useMeasure();
|
||||||
const gridRef2 = useRef<HTMLDivElement | null>(null);
|
const gridRef2 = useRef<HTMLDivElement | null>(null);
|
||||||
const gridRef = useMergedRefs(gridRef1, gridRef2);
|
const gridRef = useMergedRefs(gridRef1, gridRef2);
|
||||||
|
|
||||||
const slotRects = useMemo(() => {
|
const slotRects = useMemo(() => {
|
||||||
if (slotGrid === null) return [];
|
if (slotsRoot === null) return [];
|
||||||
|
|
||||||
const slots = slotGrid.getElementsByClassName(styles.slot);
|
const slots = slotsRoot.getElementsByClassName(styles.slot);
|
||||||
const rects = new Array<Rect>(slots.length);
|
const rects = new Array<Rect>(slots.length);
|
||||||
for (let i = 0; i < slots.length; i++) {
|
for (let i = 0; i < slots.length; i++) {
|
||||||
const slot = slots[i] as HTMLElement;
|
const slot = slots[i] as HTMLElement;
|
||||||
|
@ -214,32 +210,34 @@ export function NewVideoGrid<T>({
|
||||||
// The rects may change due to the grid being resized or rerendered, but
|
// The rects may change due to the grid being resized or rerendered, but
|
||||||
// eslint can't statically verify this
|
// eslint can't statically verify this
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [slotGrid, slotGridGeneration, gridBounds]);
|
}, [slotsRoot, renderedGeneration, gridBounds]);
|
||||||
|
|
||||||
const columns = useMemo(
|
// TODO: Implement more layouts and select the right one here
|
||||||
() =>
|
const layout = BigGrid;
|
||||||
// The grid bounds might not be known yet
|
const {
|
||||||
gridBounds.width === 0
|
grid,
|
||||||
? null
|
orderedItems,
|
||||||
: Math.max(2, Math.floor(gridBounds.width * 0.0045)),
|
generation,
|
||||||
[gridBounds]
|
canDragTile,
|
||||||
);
|
dragTile,
|
||||||
|
toggleFocus,
|
||||||
const [grid, setGrid] = useGridState(columns, items);
|
slots,
|
||||||
|
} = useGrid(layout as Layout<unknown>, items, gridBounds, layoutStates);
|
||||||
|
|
||||||
const [tiles] = useReactiveState<Tile[]>(
|
const [tiles] = useReactiveState<Tile[]>(
|
||||||
(prevTiles) => {
|
(prevTiles) => {
|
||||||
// If React hasn't yet rendered the current generation of the grid, skip
|
// If React hasn't yet rendered the current generation of the grid, skip
|
||||||
// the update, because grid and slotRects will be out of sync
|
// the update, because grid and slotRects will be out of sync
|
||||||
if (slotGridGeneration !== grid?.generation) return prevTiles ?? [];
|
if (renderedGeneration !== generation) return prevTiles ?? [];
|
||||||
|
|
||||||
const tileCells = grid.cells.filter((c) => c?.origin) as Cell[];
|
|
||||||
const tileRects = new Map<TileDescriptor<unknown>, Rect>(
|
const tileRects = new Map<TileDescriptor<unknown>, Rect>(
|
||||||
zipWith(tileCells, slotRects, (cell, rect) => [cell.item, rect])
|
zip(orderedItems, slotRects) as [TileDescriptor<unknown>, Rect][]
|
||||||
);
|
);
|
||||||
|
// In order to not break drag gestures, it's critical that we render tiles
|
||||||
|
// in a stable order (that of 'items')
|
||||||
return items.map((item) => ({ ...tileRects.get(item)!, item }));
|
return items.map((item) => ({ ...tileRects.get(item)!, item }));
|
||||||
},
|
},
|
||||||
[slotRects, grid, slotGridGeneration]
|
[slotRects, grid, renderedGeneration]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Drag state is stored in a ref rather than component state, because we use
|
// Drag state is stored in a ref rather than component state, because we use
|
||||||
|
@ -288,8 +286,6 @@ export function NewVideoGrid<T>({
|
||||||
const animateDraggedTile = (endOfGesture: boolean) => {
|
const animateDraggedTile = (endOfGesture: boolean) => {
|
||||||
const { tileId, tileX, tileY, cursorX, cursorY } = dragState.current!;
|
const { tileId, tileX, tileY, cursorX, cursorY } = dragState.current!;
|
||||||
const tile = tiles.find((t) => t.item.id === tileId)!;
|
const tile = tiles.find((t) => t.item.id === tileId)!;
|
||||||
const originIndex = grid!.cells.findIndex((c) => c?.item.id === tileId);
|
|
||||||
const originCell = grid!.cells[originIndex]!;
|
|
||||||
|
|
||||||
springRef.current
|
springRef.current
|
||||||
.find((c) => (c.item as Tile).item.id === tileId)
|
.find((c) => (c.item as Tile).item.id === tileId)
|
||||||
|
@ -320,36 +316,23 @@ export function NewVideoGrid<T>({
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns = grid!.columns;
|
const overTile = tiles.find(
|
||||||
const rows = row(grid!.cells.length - 1, grid!) + 1;
|
(t) =>
|
||||||
|
cursorX >= t.x &&
|
||||||
const cursorColumn = Math.floor(
|
cursorX < t.x + t.width &&
|
||||||
(cursorX / slotGrid!.clientWidth) * columns
|
cursorY >= t.y &&
|
||||||
);
|
cursorY < t.y + t.height
|
||||||
const cursorRow = Math.floor((cursorY / slotGrid!.clientHeight) * rows);
|
|
||||||
|
|
||||||
const cursorColumnOnTile = Math.floor(
|
|
||||||
((cursorX - tileX) / tile.width) * originCell.columns
|
|
||||||
);
|
|
||||||
const cursorRowOnTile = Math.floor(
|
|
||||||
((cursorY - tileY) / tile.height) * originCell.rows
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const dest =
|
if (overTile !== undefined)
|
||||||
Math.max(
|
dragTile(
|
||||||
0,
|
tile.item,
|
||||||
Math.min(
|
overTile.item,
|
||||||
columns - originCell.columns,
|
(cursorX - tileX) / tile.width,
|
||||||
cursorColumn - cursorColumnOnTile
|
(cursorY - tileY) / tile.height,
|
||||||
)
|
(cursorX - overTile.x) / overTile.width,
|
||||||
) +
|
(cursorY - overTile.y) / overTile.height
|
||||||
grid!.columns *
|
|
||||||
Math.max(
|
|
||||||
0,
|
|
||||||
Math.min(rows - originCell.rows, cursorRow - cursorRowOnTile)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (dest !== originIndex) setGrid((g) => tryMoveTile(g, originIndex, dest));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Callback for useDrag. We could call useDrag here, but the default
|
// Callback for useDrag. We could call useDrag here, but the default
|
||||||
|
@ -367,13 +350,15 @@ export function NewVideoGrid<T>({
|
||||||
}: Parameters<Handler<"drag", EventTypes["drag"]>>[0]
|
}: Parameters<Handler<"drag", EventTypes["drag"]>>[0]
|
||||||
) => {
|
) => {
|
||||||
if (tap) {
|
if (tap) {
|
||||||
setGrid((g) => cycleTileSize(tileId, g!));
|
toggleFocus?.(items.find((i) => i.id === tileId)!);
|
||||||
} else {
|
} else {
|
||||||
const tileSpring = springRef.current
|
const tileController = springRef.current.find(
|
||||||
.find((c) => (c.item as Tile).item.id === tileId)!
|
(c) => (c.item as Tile).item.id === tileId
|
||||||
.get();
|
)!;
|
||||||
|
|
||||||
|
if (canDragTile((tileController.item as Tile).item)) {
|
||||||
if (dragState.current === null) {
|
if (dragState.current === null) {
|
||||||
|
const tileSpring = tileController.get();
|
||||||
dragState.current = {
|
dragState.current = {
|
||||||
tileId,
|
tileId,
|
||||||
tileX: tileSpring.x,
|
tileX: tileSpring.x,
|
||||||
|
@ -382,6 +367,7 @@ export function NewVideoGrid<T>({
|
||||||
cursorY: initialY - gridBounds.y + scrollOffset.current,
|
cursorY: initialY - gridBounds.y + scrollOffset.current,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
dragState.current.tileX += dx;
|
dragState.current.tileX += dx;
|
||||||
dragState.current.tileY += dy;
|
dragState.current.tileY += dy;
|
||||||
dragState.current.cursorX += dx;
|
dragState.current.cursorX += dx;
|
||||||
|
@ -391,6 +377,7 @@ export function NewVideoGrid<T>({
|
||||||
|
|
||||||
if (last) dragState.current = null;
|
if (last) dragState.current = null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTileDragRef = useRef(onTileDrag);
|
const onTileDragRef = useRef(onTileDrag);
|
||||||
|
@ -411,52 +398,6 @@ export function NewVideoGrid<T>({
|
||||||
{ target: gridRef2 }
|
{ target: gridRef2 }
|
||||||
);
|
);
|
||||||
|
|
||||||
const slotGridStyle = useMemo(() => {
|
|
||||||
if (grid === null) return {};
|
|
||||||
|
|
||||||
const areas = new Array<(number | null)[]>(
|
|
||||||
Math.ceil(grid.cells.length / grid.columns)
|
|
||||||
);
|
|
||||||
for (let i = 0; i < areas.length; i++)
|
|
||||||
areas[i] = new Array<number | null>(grid.columns).fill(null);
|
|
||||||
|
|
||||||
let slotId = 0;
|
|
||||||
for (let i = 0; i < grid.cells.length; i++) {
|
|
||||||
const cell = grid.cells[i];
|
|
||||||
if (cell?.origin) {
|
|
||||||
const slotEnd = i + cell.columns - 1 + grid.columns * (cell.rows - 1);
|
|
||||||
forEachCellInArea(
|
|
||||||
i,
|
|
||||||
slotEnd,
|
|
||||||
grid,
|
|
||||||
(_c, j) => (areas[row(j, grid)][column(j, grid)] = slotId)
|
|
||||||
);
|
|
||||||
slotId++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
gridTemplateAreas: areas
|
|
||||||
.map(
|
|
||||||
(row) =>
|
|
||||||
`'${row
|
|
||||||
.map((slotId) => (slotId === null ? "." : `s${slotId}`))
|
|
||||||
.join(" ")}'`
|
|
||||||
)
|
|
||||||
.join(" "),
|
|
||||||
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
|
||||||
};
|
|
||||||
}, [grid, columns]);
|
|
||||||
|
|
||||||
const slots = useMemo(() => {
|
|
||||||
const slots = new Array<ReactNode>(items.length);
|
|
||||||
for (let i = 0; i < items.length; i++)
|
|
||||||
slots[i] = (
|
|
||||||
<div className={styles.slot} key={i} style={{ gridArea: `s${i}` }} />
|
|
||||||
);
|
|
||||||
return slots;
|
|
||||||
}, [items.length]);
|
|
||||||
|
|
||||||
// Render nothing if the grid has yet to be generated
|
// Render nothing if the grid has yet to be generated
|
||||||
if (grid === null) {
|
if (grid === null) {
|
||||||
return <div ref={gridRef} className={styles.grid} />;
|
return <div ref={gridRef} className={styles.grid} />;
|
||||||
|
@ -465,10 +406,9 @@ export function NewVideoGrid<T>({
|
||||||
return (
|
return (
|
||||||
<div ref={gridRef} className={styles.grid}>
|
<div ref={gridRef} className={styles.grid}>
|
||||||
<div
|
<div
|
||||||
style={slotGridStyle}
|
ref={setSlotsRoot}
|
||||||
ref={setSlotGrid}
|
className={styles.slots}
|
||||||
className={styles.slotGrid}
|
data-generation={generation}
|
||||||
data-generation={grid.generation}
|
|
||||||
>
|
>
|
||||||
{slots}
|
{slots}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -42,6 +42,7 @@ import { ResizeObserver as JuggleResizeObserver } from "@juggle/resize-observer"
|
||||||
import styles from "./VideoGrid.module.css";
|
import styles from "./VideoGrid.module.css";
|
||||||
import { Layout } from "../room/GridLayoutMenu";
|
import { Layout } from "../room/GridLayoutMenu";
|
||||||
import { TileWrapper } from "./TileWrapper";
|
import { TileWrapper } from "./TileWrapper";
|
||||||
|
import { Layout as LayoutSystem } from "./Layout";
|
||||||
|
|
||||||
interface TilePosition {
|
interface TilePosition {
|
||||||
x: number;
|
x: number;
|
||||||
|
@ -817,6 +818,7 @@ export interface VideoGridProps<T> {
|
||||||
items: TileDescriptor<T>[];
|
items: TileDescriptor<T>[];
|
||||||
layout: Layout;
|
layout: Layout;
|
||||||
disableAnimations: boolean;
|
disableAnimations: boolean;
|
||||||
|
layoutStates: Map<LayoutSystem<unknown>, unknown>;
|
||||||
children: (props: ChildrenProperties<T>) => React.ReactNode;
|
children: (props: ChildrenProperties<T>) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -20,23 +20,23 @@ import {
|
||||||
cycleTileSize,
|
cycleTileSize,
|
||||||
fillGaps,
|
fillGaps,
|
||||||
forEachCellInArea,
|
forEachCellInArea,
|
||||||
Grid,
|
BigGridState,
|
||||||
resize,
|
resize,
|
||||||
row,
|
row,
|
||||||
tryMoveTile,
|
moveTile,
|
||||||
} from "../../src/video-grid/model";
|
} from "../../src/video-grid/BigGrid";
|
||||||
import { TileDescriptor } from "../../src/video-grid/VideoGrid";
|
import { TileDescriptor } from "../../src/video-grid/VideoGrid";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a grid from a string specifying the contents of each cell as a letter.
|
* Builds a grid from a string specifying the contents of each cell as a letter.
|
||||||
*/
|
*/
|
||||||
function mkGrid(spec: string): Grid {
|
function mkGrid(spec: string): BigGridState {
|
||||||
const secondNewline = spec.indexOf("\n", 1);
|
const secondNewline = spec.indexOf("\n", 1);
|
||||||
const columns = secondNewline === -1 ? spec.length : secondNewline - 1;
|
const columns = secondNewline === -1 ? spec.length : secondNewline - 1;
|
||||||
const cells = spec.match(/[a-z ]/g) ?? ([] as string[]);
|
const cells = spec.match(/[a-z ]/g) ?? ([] as string[]);
|
||||||
const areas = new Set(cells);
|
const areas = new Set(cells);
|
||||||
areas.delete(" "); // Space represents an empty cell, not an area
|
areas.delete(" "); // Space represents an empty cell, not an area
|
||||||
const grid: Grid = { columns, cells: new Array(cells.length) };
|
const grid: BigGridState = { columns, cells: new Array(cells.length) };
|
||||||
|
|
||||||
for (const area of areas) {
|
for (const area of areas) {
|
||||||
const start = cells.indexOf(area);
|
const start = cells.indexOf(area);
|
||||||
|
@ -60,12 +60,12 @@ function mkGrid(spec: string): Grid {
|
||||||
/**
|
/**
|
||||||
* Turns a grid into a string showing the contents of each cell as a letter.
|
* Turns a grid into a string showing the contents of each cell as a letter.
|
||||||
*/
|
*/
|
||||||
function showGrid(g: Grid): string {
|
function showGrid(g: BigGridState): string {
|
||||||
let result = "\n";
|
let result = "\n";
|
||||||
g.cells.forEach((c, i) => {
|
for (let i = 0; i < g.cells.length; i++) {
|
||||||
if (i > 0 && i % g.columns == 0) result += "\n";
|
if (i > 0 && i % g.columns == 0) result += "\n";
|
||||||
result += c?.item.id ?? " ";
|
result += g.cells[i]?.item.id ?? " ";
|
||||||
});
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -222,21 +222,12 @@ function testCycleTileSize(
|
||||||
output: string
|
output: string
|
||||||
): void {
|
): void {
|
||||||
test(`cycleTileSize ${title}`, () => {
|
test(`cycleTileSize ${title}`, () => {
|
||||||
expect(showGrid(cycleTileSize(tileId, mkGrid(input)))).toBe(output);
|
const grid = mkGrid(input);
|
||||||
|
const tile = grid.cells.find((c) => c?.item.id === tileId)!.item;
|
||||||
|
expect(showGrid(cycleTileSize(grid, tile))).toBe(output);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
testCycleTileSize(
|
|
||||||
"does nothing if the tile is not present",
|
|
||||||
"z",
|
|
||||||
`
|
|
||||||
abcd
|
|
||||||
efgh`,
|
|
||||||
`
|
|
||||||
abcd
|
|
||||||
efgh`
|
|
||||||
);
|
|
||||||
|
|
||||||
testCycleTileSize(
|
testCycleTileSize(
|
||||||
"expands a tile to 2×2 in a 3 column layout",
|
"expands a tile to 2×2 in a 3 column layout",
|
||||||
"c",
|
"c",
|
||||||
|
@ -345,8 +336,8 @@ abc
|
||||||
def`,
|
def`,
|
||||||
`
|
`
|
||||||
abc
|
abc
|
||||||
gfe
|
g
|
||||||
d`
|
def`
|
||||||
);
|
);
|
||||||
|
|
||||||
testAddItems(
|
testAddItems(
|
||||||
|
@ -362,19 +353,19 @@ gge
|
||||||
d`
|
d`
|
||||||
);
|
);
|
||||||
|
|
||||||
function testTryMoveTile(
|
function testMoveTile(
|
||||||
title: string,
|
title: string,
|
||||||
from: number,
|
from: number,
|
||||||
to: number,
|
to: number,
|
||||||
input: string,
|
input: string,
|
||||||
output: string
|
output: string
|
||||||
): void {
|
): void {
|
||||||
test(`tryMoveTile ${title}`, () => {
|
test(`moveTile ${title}`, () => {
|
||||||
expect(showGrid(tryMoveTile(mkGrid(input), from, to))).toBe(output);
|
expect(showGrid(moveTile(mkGrid(input), from, to))).toBe(output);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
testTryMoveTile(
|
testMoveTile(
|
||||||
"refuses to move a tile too far to the left",
|
"refuses to move a tile too far to the left",
|
||||||
1,
|
1,
|
||||||
-1,
|
-1,
|
||||||
|
@ -384,7 +375,7 @@ abc`,
|
||||||
abc`
|
abc`
|
||||||
);
|
);
|
||||||
|
|
||||||
testTryMoveTile(
|
testMoveTile(
|
||||||
"refuses to move a tile too far to the right",
|
"refuses to move a tile too far to the right",
|
||||||
1,
|
1,
|
||||||
3,
|
3,
|
||||||
|
@ -394,7 +385,7 @@ abc`,
|
||||||
abc`
|
abc`
|
||||||
);
|
);
|
||||||
|
|
||||||
testTryMoveTile(
|
testMoveTile(
|
||||||
"moves a large tile to an unoccupied space",
|
"moves a large tile to an unoccupied space",
|
||||||
3,
|
3,
|
||||||
1,
|
1,
|
||||||
|
@ -408,7 +399,7 @@ bcc
|
||||||
d e`
|
d e`
|
||||||
);
|
);
|
||||||
|
|
||||||
testTryMoveTile(
|
testMoveTile(
|
||||||
"refuses to move a large tile to an occupied space",
|
"refuses to move a large tile to an occupied space",
|
||||||
3,
|
3,
|
||||||
1,
|
1,
|
Loading…
Add table
Reference in a new issue