diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 7125720..a8a171b 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -83,6 +83,7 @@ import { VideoTile } from "../video-grid/VideoTile"; import { UserChoices, useLiveKit } from "../livekit/useLiveKit"; import { useMediaDevices } from "../livekit/useMediaDevices"; import { useFullscreen } from "./useFullscreen"; +import { useLayoutStates } from "../video-grid/Layout"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); // There is currently a bug in Safari our our code with cloning and sending MediaStreams @@ -253,6 +254,10 @@ export function InCallView({ 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 => { if (items.length === 0) { return ( @@ -282,6 +287,7 @@ export function InCallView({ items={items} layout={layout} disableAnimations={prefersReducedMotion || isSafari} + layoutStates={layoutStates} > {(props) => ( ; + readonly item: TileDescriptor; /** * Whether this cell is the origin (top left corner) of the tile. */ - origin: boolean; + readonly origin: boolean; /** * The width, in columns, of the tile. */ - columns: number; + readonly columns: number; /** * 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; /** * 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 * 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 destColumn = column(dest, g); @@ -106,18 +121,23 @@ export function getPaths(dest: number, g: Grid): (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); -export function row(index: number, g: Grid): number { +export function row(index: number, g: BigGridState): number { 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; } -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 indexRow = row(index, g); return ( @@ -131,7 +151,7 @@ function inArea(index: number, start: number, end: number, g: Grid): boolean { function* cellsInArea( start: number, end: number, - g: Grid + g: BigGridState ): Generator { const startColumn = column(start, g); const endColumn = column(end, g); @@ -149,7 +169,7 @@ function* cellsInArea( export function forEachCellInArea( start: number, end: number, - g: Grid, + g: BigGridState, fn: (c: Cell | undefined, i: number) => void ): void { for (const i of cellsInArea(start, end, g)) fn(g.cells[i], i); @@ -158,7 +178,7 @@ export function forEachCellInArea( function allCellsInArea( start: number, end: number, - g: Grid, + g: BigGridState, fn: (c: Cell | undefined, i: number) => boolean ): boolean { for (const i of cellsInArea(start, end, g)) { @@ -172,16 +192,19 @@ const areaEnd = ( start: number, columns: number, rows: number, - g: Grid + g: BigGridState ): 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 * tiles. */ -function getNextGap(g: Grid): number | null { +function getNextGap(g: BigGridState): number | null { const last1By1Index = findLast1By1Index(g); 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. */ -function getOrigin(g: Grid, index: number): number { +function getOrigin(g: BigGridState, index: number): number { const initialColumn = column(index, g); for ( @@ -229,7 +252,7 @@ function getOrigin(g: Grid, index: number): number { * along the way. * 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 fromEnd = areaEnd(from, 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. */ -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]!; if ( + to !== from && // Skip the operation if nothing would move to >= 0 && to < g.cells.length && 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)) { // The target space is free; move const gClone = cloneGrid(g); - moveTile(gClone, from, to); + moveTileUnchecked(gClone, from, to); return gClone; } } @@ -297,7 +325,7 @@ export function tryMoveTile(g: Grid, from: number, to: number): Grid { * enlarged tiles around when necessary. * @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]!; // TODO: pushing large tiles sideways might be more successful in some @@ -315,7 +343,7 @@ function pushTileUp(g: Grid, from: number): boolean { ); if (cellsAboveAreDisplacable) { - moveTile(g, from, from - g.columns); + moveTileUnchecked(g, from, from - g.columns); return true; } else { return false; @@ -325,8 +353,8 @@ function pushTileUp(g: Grid, from: number): boolean { /** * Backfill any gaps in the grid. */ -export function fillGaps(g: Grid): Grid { - const result = cloneGrid(g); +export function fillGaps(g: BigGridState): BigGridState { + const result = cloneGrid(g) as MutableBigGridState; // This will hopefully be the size of the grid after we're done here, assuming // that we can pack the large tiles tightly enough @@ -403,7 +431,11 @@ export function fillGaps(g: Grid): Grid { return result; } -function createRows(g: Grid, count: number, atRow: number): Grid { +function createRows( + g: BigGridState, + count: number, + atRow: number +): BigGridState { const result = { columns: g.columns, 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[], g: Grid): Grid { +export function addItems( + items: TileDescriptor[], + g: BigGridState +): BigGridState { let result = cloneGrid(g); for (const item of items) { @@ -444,13 +479,11 @@ export function addItems(items: TileDescriptor[], g: Grid): Grid { }; let placeAt: number; - let hasGaps: boolean; if (item.placeNear === undefined) { // This item has no special placement requests, so let's put it // uneventfully at the end of the grid placeAt = result.cells.length; - hasGaps = false; } else { // This item wants to be placed near another; let's put it on a row // directly below the related tile @@ -460,7 +493,6 @@ export function addItems(items: TileDescriptor[], g: Grid): Grid { if (placeNear === -1) { // Can't find the related tile, so let's give up and place it at the end placeAt = result.cells.length; - hasGaps = false; } else { const placeNearCell = result.cells[placeNear]!; const placeNearEnd = areaEnd( @@ -475,7 +507,6 @@ export function addItems(items: TileDescriptor[], g: Grid): Grid { placeNear + Math.floor(placeNearCell.columns / 2) + result.columns * placeNearCell.rows; - hasGaps = true; } } @@ -484,21 +515,19 @@ export function addItems(items: TileDescriptor[], g: Grid): Grid { if (item.largeBaseSize) { // 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 - result = cycleTileSize(item.id, result); - } else if (hasGaps) { - result = fillGaps(result); + result = cycleTileSize(result, item); } } return result; } -const largeTileDimensions = (g: Grid): [number, number] => [ +const largeTileDimensions = (g: BigGridState): [number, number] => [ Math.min(3, Math.max(2, g.columns - 1)), 2, ]; -const extraLargeTileDimensions = (g: Grid): [number, number] => +const extraLargeTileDimensions = (g: BigGridState): [number, number] => g.columns > 3 ? [4, 3] : [g.columns, 2]; /** @@ -507,8 +536,11 @@ const extraLargeTileDimensions = (g: Grid): [number, number] => * @param g The grid. * @returns The updated grid. */ -export function cycleTileSize(tileId: string, g: Grid): Grid { - const from = g.cells.findIndex((c) => c?.item.id === tileId); +export function cycleTileSize( + g: BigGridState, + tile: TileDescriptor +): BigGridState { + const from = g.cells.findIndex((c) => c?.item === tile); if (from === -1) return g; // Tile removed, no change const fromCell = g.cells[from]!; const fromWidth = fromCell.columns; @@ -629,8 +661,8 @@ export function cycleTileSize(tileId: string, g: Grid): Grid { /** * Resizes the grid to a new column width. */ -export function resize(g: Grid, columns: number): Grid { - const result: Grid = { columns, cells: [] }; +export function resize(g: BigGridState, columns: number): BigGridState { + const result: BigGridState = { columns, cells: [] }; const [largeColumns, largeRows] = largeTileDimensions(result); // 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) { if (cell?.origin) { + // TODO make aware of extra large tiles const [nextColumns, nextRows] = 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. */ -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 // will stick with this approach in the long run // We assume that 4 rows are probably about 1 page @@ -694,10 +727,149 @@ export function promoteSpeakers(g: Grid) { toCell === undefined || (toCell.columns === 1 && toCell.rows === 1) ) { - moveTile(g, from, to); + moveTileUnchecked(g, from, to); break; } } } } } + +/** + * The algorithm for updating a grid with a new set of tiles. + */ +function updateTiles( + g: BigGridState, + tiles: TileDescriptor[] +): 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(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(slotCount); + for (let i = 0; i < slotCount; i++) + slots[i] = ; + + return ( +
+ {slots} +
+ ); +}); + +/** + * 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, + to: TileDescriptor, + 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 = { + emptyState: { columns: 4, cells: [] }, + updateTiles, + updateBounds, + getTiles: (g) => + g.cells.filter((c) => c?.origin).map((c) => c!.item as T), + canDragTile: () => true, + dragTile, + toggleFocus: cycleTileSize, + Slots, + rememberState: false, +}; diff --git a/src/video-grid/Layout.tsx b/src/video-grid/Layout.tsx new file mode 100644 index 0000000..2b29594 --- /dev/null +++ b/src/video-grid/Layout.tsx @@ -0,0 +1,178 @@ +/* +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 { ComponentType, useCallback, useMemo, useRef } from "react"; + +import type { RectReadOnly } from "react-use-measure"; +import { useReactiveState } from "../useReactiveState"; +import type { TileDescriptor } from "./VideoGrid"; + +/** + * A video grid layout system with concrete states of type State. + */ +// Ideally State would be parameterized by the tile data type, but then that +// makes Layout a higher-kinded type, which isn't achievable in TypeScript +// (unless you invoke some dark type-level computation magic… 😏) +// So we're stuck with these types being a little too strong. +export interface Layout { + /** + * 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[]) => 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[]; + /** + * Determines whether a tile is draggable. + */ + readonly canDragTile: (s: State, tile: TileDescriptor) => 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, + to: TileDescriptor, + 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) => 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; +} + +/** + * A version of Map with stronger types that allow us to save layout states in a + * type-safe way. + */ +export interface LayoutStatesMap { + get(layout: Layout): State | undefined; + set(layout: Layout, state: State): LayoutStatesMap; + delete(layout: Layout): boolean; +} + +/** + * Hook creating a Map to store layout states in. + */ +export const useLayoutStates = (): LayoutStatesMap => { + const layoutStates = useRef>(); + if (layoutStates.current === undefined) layoutStates.current = new Map(); + return layoutStates.current as LayoutStatesMap; +}; + +/** + * Hook which uses the provided layout system to arrange a set of items into a + * concrete layout state, and provides callbacks for user interaction. + */ +export const useLayout = ( + layout: Layout, + items: TileDescriptor[], + bounds: RectReadOnly, + layoutStates: LayoutStatesMap +) => { + const prevLayout = useRef>(); + const prevState = layoutStates.get(layout); + + const [state, setState] = useReactiveState(() => { + // If the bounds aren't known yet, don't add anything to the layout + if (bounds.width === 0) { + return layout.emptyState; + } else { + if ( + prevLayout.current !== undefined && + layout !== prevLayout.current && + !prevLayout.current.rememberState + ) + layoutStates.delete(prevLayout.current); + + const baseState = layoutStates.get(layout) ?? layout.emptyState; + return layout.updateTiles(layout.updateBounds(baseState, bounds), items); + } + }, [layout, items, bounds]); + + const generation = useRef(0); + if (state !== prevState) generation.current++; + + prevLayout.current = layout as 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 { + state, + orderedItems: useMemo(() => layout.getTiles(state), [layout, state]), + generation: generation.current, + canDragTile: useCallback( + (tile: TileDescriptor) => layout.canDragTile(state, tile), + [layout, state] + ), + dragTile: useCallback( + ( + from: TileDescriptor, + to: TileDescriptor, + 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) => + setState((s) => layout.toggleFocus!(s, tile))), + [layout, setState] + ), + slots: , + }; +}; diff --git a/src/video-grid/NewVideoGrid.module.css b/src/video-grid/NewVideoGrid.module.css index 7e34a2d..c822b41 100644 --- a/src/video-grid/NewVideoGrid.module.css +++ b/src/video-grid/NewVideoGrid.module.css @@ -23,11 +23,8 @@ limitations under the License. overflow-x: hidden; } -.slotGrid { +.slots { position: relative; - display: grid; - grid-auto-rows: 163px; - gap: 8px; } .slot { @@ -38,10 +35,4 @@ limitations under the License. .grid { padding: 0 22px var(--footerHeight); } - - .slotGrid { - grid-auto-rows: 183px; - column-gap: 18px; - row-gap: 21px; - } } diff --git a/src/video-grid/NewVideoGrid.tsx b/src/video-grid/NewVideoGrid.tsx index 754b029..b88128d 100644 --- a/src/video-grid/NewVideoGrid.tsx +++ b/src/video-grid/NewVideoGrid.tsx @@ -17,17 +17,16 @@ limitations under the License. import { SpringRef, TransitionFn, useTransition } from "@react-spring/web"; import { EventTypes, Handler, useScroll } from "@use-gesture/react"; import React, { - Dispatch, + CSSProperties, + FC, ReactNode, - SetStateAction, - useCallback, useEffect, useMemo, useRef, useState, } from "react"; import useMeasure from "react-use-measure"; -import { zipWith } from "lodash"; +import { zip } from "lodash"; import styles from "./NewVideoGrid.module.css"; import { @@ -38,99 +37,9 @@ import { } from "./VideoGrid"; import { useReactiveState } from "../useReactiveState"; import { useMergedRefs } from "../useMergedRefs"; -import { - Grid, - Cell, - row, - column, - fillGaps, - forEachCellInArea, - cycleTileSize, - addItems, - tryMoveTile, - resize, - promoteSpeakers, -} from "./model"; import { TileWrapper } from "./TileWrapper"; - -interface GridState extends Grid { - /** - * The ID of the current state of the grid. - */ - generation: number; -} - -const useGridState = ( - columns: number | null, - items: TileDescriptor[] -): [GridState | null, Dispatch>] => { - const [grid, setGrid_] = useReactiveState( - (prevGrid = null) => { - if (prevGrid === null) { - // We can't do anything if the column count isn't known yet - if (columns === null) { - return null; - } else { - prevGrid = { generation: 0, columns, cells: [] }; - } - } - - // Step 1: Update tiles that still exist, and remove tiles that have left - // the grid - const itemsById = new Map(items.map((i) => [i.id, i])); - const grid1: Grid = { - ...prevGrid, - cells: prevGrid.cells.map((c) => { - if (c === undefined) return undefined; - const item = itemsById.get(c.item.id); - return item === undefined ? undefined : { ...c, item }; - }), - }; - - // 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> = 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]; -}; +import { BigGrid } from "./BigGrid"; +import { useLayout } from "./Layout"; interface Rect { x: number; @@ -139,8 +48,8 @@ interface Rect { height: number; } -interface Tile extends Rect { - item: TileDescriptor; +interface Tile extends Rect { + item: TileDescriptor; } interface DragState { @@ -151,12 +60,21 @@ interface DragState { cursorY: number; } +interface SlotProps { + style?: CSSProperties; +} + +export const Slot: FC = ({ style }) => ( +
+); + /** * An interactive, animated grid of video tiles. */ export function NewVideoGrid({ items, disableAnimations, + layoutStates, children, }: Props) { // Overview: This component lays out tiles by rendering an invisible template @@ -169,36 +87,36 @@ export function NewVideoGrid({ // most recently rendered generation of the grid, and watch it with a // MutationObserver. - const [slotGrid, setSlotGrid] = useState(null); - const [slotGridGeneration, setSlotGridGeneration] = useState(0); + const [slotsRoot, setSlotsRoot] = useState(null); + const [renderedGeneration, setRenderedGeneration] = useState(0); useEffect(() => { - if (slotGrid !== null) { - setSlotGridGeneration( - parseInt(slotGrid.getAttribute("data-generation")!) + if (slotsRoot !== null) { + setRenderedGeneration( + parseInt(slotsRoot.getAttribute("data-generation")!) ); const observer = new MutationObserver((mutations) => { if (mutations.some((m) => m.type === "attributes")) { - setSlotGridGeneration( - parseInt(slotGrid.getAttribute("data-generation")!) + setRenderedGeneration( + parseInt(slotsRoot.getAttribute("data-generation")!) ); } }); - observer.observe(slotGrid, { attributes: true }); + observer.observe(slotsRoot, { attributes: true }); return () => observer.disconnect(); } - }, [slotGrid, setSlotGridGeneration]); + }, [slotsRoot, setRenderedGeneration]); const [gridRef1, gridBounds] = useMeasure(); const gridRef2 = useRef(null); const gridRef = useMergedRefs(gridRef1, gridRef2); 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(slots.length); for (let i = 0; i < slots.length; i++) { const slot = slots[i] as HTMLElement; @@ -214,32 +132,34 @@ export function NewVideoGrid({ // The rects may change due to the grid being resized or rerendered, but // eslint can't statically verify this // eslint-disable-next-line react-hooks/exhaustive-deps - }, [slotGrid, slotGridGeneration, gridBounds]); + }, [slotsRoot, renderedGeneration, gridBounds]); - const columns = useMemo( - () => - // The grid bounds might not be known yet - gridBounds.width === 0 - ? null - : Math.max(2, Math.floor(gridBounds.width * 0.0045)), - [gridBounds] - ); + // TODO: Implement more layouts and select the right one here + const layout = BigGrid; + const { + state: grid, + orderedItems, + generation, + canDragTile, + dragTile, + toggleFocus, + slots, + } = useLayout(layout, items, gridBounds, layoutStates); - const [grid, setGrid] = useGridState(columns, items); - - const [tiles] = useReactiveState( + const [tiles] = useReactiveState[]>( (prevTiles) => { // If React hasn't yet rendered the current generation of the grid, skip // 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, Rect>( - zipWith(tileCells, slotRects, (cell, rect) => [cell.item, rect]) + const tileRects = new Map( + zip(orderedItems, slotRects) as [TileDescriptor, 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 })); }, - [slotRects, grid, slotGridGeneration] + [slotRects, grid, renderedGeneration] ); // Drag state is stored in a ref rather than component state, because we use @@ -249,8 +169,8 @@ export function NewVideoGrid({ const [tileTransitions, springRef] = useTransition( tiles, () => ({ - key: ({ item }: Tile) => item.id, - from: ({ x, y, width, height }: Tile) => ({ + key: ({ item }: Tile) => item.id, + from: ({ x, y, width, height }: Tile) => ({ opacity: 0, scale: 0, shadow: 1, @@ -263,7 +183,7 @@ export function NewVideoGrid({ immediate: disableAnimations, }), enter: { opacity: 1, scale: 1, immediate: disableAnimations }, - update: ({ item, x, y, width, height }: Tile) => + update: ({ item, x, y, width, height }: Tile) => item.id === dragState.current?.tileId ? null : { @@ -277,7 +197,7 @@ export function NewVideoGrid({ config: { mass: 0.7, tension: 252, friction: 25 }, }) // react-spring's types are bugged and can't infer the spring type - ) as unknown as [TransitionFn, SpringRef]; + ) as unknown as [TransitionFn, TileSpring>, SpringRef]; // Because we're using react-spring in imperative mode, we're responsible for // firing animations manually whenever the tiles array updates @@ -288,11 +208,9 @@ export function NewVideoGrid({ const animateDraggedTile = (endOfGesture: boolean) => { const { tileId, tileX, tileY, cursorX, cursorY } = dragState.current!; 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 - .find((c) => (c.item as Tile).item.id === tileId) + .find((c) => (c.item as Tile).item.id === tileId) ?.start( endOfGesture ? { @@ -320,36 +238,23 @@ export function NewVideoGrid({ } ); - const columns = grid!.columns; - const rows = row(grid!.cells.length - 1, grid!) + 1; - - const cursorColumn = Math.floor( - (cursorX / slotGrid!.clientWidth) * columns - ); - 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 overTile = tiles.find( + (t) => + cursorX >= t.x && + cursorX < t.x + t.width && + cursorY >= t.y && + cursorY < t.y + t.height ); - const dest = - Math.max( - 0, - Math.min( - columns - originCell.columns, - cursorColumn - cursorColumnOnTile - ) - ) + - grid!.columns * - Math.max( - 0, - Math.min(rows - originCell.rows, cursorRow - cursorRowOnTile) - ); - - if (dest !== originIndex) setGrid((g) => tryMoveTile(g, originIndex, dest)); + if (overTile !== undefined) + dragTile( + tile.item, + overTile.item, + (cursorX - tileX) / tile.width, + (cursorY - tileY) / tile.height, + (cursorX - overTile.x) / overTile.width, + (cursorY - overTile.y) / overTile.height + ); }; // Callback for useDrag. We could call useDrag here, but the default @@ -367,29 +272,33 @@ export function NewVideoGrid({ }: Parameters>[0] ) => { if (tap) { - setGrid((g) => cycleTileSize(tileId, g!)); + toggleFocus?.(items.find((i) => i.id === tileId)!); } else { - const tileSpring = springRef.current - .find((c) => (c.item as Tile).item.id === tileId)! - .get(); + const tileController = springRef.current.find( + (c) => (c.item as Tile).item.id === tileId + )!; - if (dragState.current === null) { - dragState.current = { - tileId, - tileX: tileSpring.x, - tileY: tileSpring.y, - cursorX: initialX - gridBounds.x, - cursorY: initialY - gridBounds.y + scrollOffset.current, - }; + if (canDragTile((tileController.item as Tile).item)) { + if (dragState.current === null) { + const tileSpring = tileController.get(); + dragState.current = { + tileId, + tileX: tileSpring.x, + tileY: tileSpring.y, + cursorX: initialX - gridBounds.x, + cursorY: initialY - gridBounds.y + scrollOffset.current, + }; + } + + dragState.current.tileX += dx; + dragState.current.tileY += dy; + dragState.current.cursorX += dx; + dragState.current.cursorY += dy; + + animateDraggedTile(last); + + if (last) dragState.current = null; } - dragState.current.tileX += dx; - dragState.current.tileY += dy; - dragState.current.cursorX += dx; - dragState.current.cursorY += dy; - - animateDraggedTile(last); - - if (last) dragState.current = null; } }; @@ -411,52 +320,6 @@ export function NewVideoGrid({ { 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(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(items.length); - for (let i = 0; i < items.length; i++) - slots[i] = ( -
- ); - return slots; - }, [items.length]); - // Render nothing if the grid has yet to be generated if (grid === null) { return
; @@ -465,10 +328,9 @@ export function NewVideoGrid({ return (
{slots}
@@ -482,7 +344,7 @@ export function NewVideoGrid({ data={tile.item.data} {...spring} > - {children as (props: ChildrenProperties) => ReactNode} + {children as (props: ChildrenProperties) => ReactNode} ))}
diff --git a/src/video-grid/TileWrapper.tsx b/src/video-grid/TileWrapper.tsx index b9f84b5..09b67aa 100644 --- a/src/video-grid/TileWrapper.tsx +++ b/src/video-grid/TileWrapper.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { FC, memo, ReactNode, RefObject, useRef } from "react"; +import React, { memo, ReactNode, RefObject, useRef } from "react"; import { EventTypes, Handler, useDrag } from "@use-gesture/react"; import { SpringValue, to } from "@react-spring/web"; @@ -47,7 +47,7 @@ interface Props { * A wrapper around a tile in a video grid. This component exists to decouple * child components from the grid. */ -export const TileWrapper: FC> = memo( +export const TileWrapper = memo( ({ id, onDragRef, @@ -97,4 +97,7 @@ export const TileWrapper: FC> = memo( ); } -); + // We pretend this component is a simple function rather than a + // NamedExoticComponent, because that's the only way we can fit in a type + // parameter +) as (props: Props) => JSX.Element; diff --git a/src/video-grid/VideoGrid.tsx b/src/video-grid/VideoGrid.tsx index d87f58b..a9b847b 100644 --- a/src/video-grid/VideoGrid.tsx +++ b/src/video-grid/VideoGrid.tsx @@ -42,6 +42,7 @@ import { ResizeObserver as JuggleResizeObserver } from "@juggle/resize-observer" import styles from "./VideoGrid.module.css"; import { Layout } from "../room/GridLayoutMenu"; import { TileWrapper } from "./TileWrapper"; +import { LayoutStatesMap } from "./Layout"; interface TilePosition { x: number; @@ -817,6 +818,7 @@ export interface VideoGridProps { items: TileDescriptor[]; layout: Layout; disableAnimations: boolean; + layoutStates: LayoutStatesMap; children: (props: ChildrenProperties) => React.ReactNode; } diff --git a/test/video-grid/model-test.ts b/test/video-grid/BigGrid-test.ts similarity index 88% rename from test/video-grid/model-test.ts rename to test/video-grid/BigGrid-test.ts index 3699487..b035bd2 100644 --- a/test/video-grid/model-test.ts +++ b/test/video-grid/BigGrid-test.ts @@ -20,23 +20,23 @@ import { cycleTileSize, fillGaps, forEachCellInArea, - Grid, + BigGridState, resize, row, - tryMoveTile, -} from "../../src/video-grid/model"; + moveTile, +} from "../../src/video-grid/BigGrid"; import { TileDescriptor } from "../../src/video-grid/VideoGrid"; /** * 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 columns = secondNewline === -1 ? spec.length : secondNewline - 1; const cells = spec.match(/[a-z ]/g) ?? ([] as string[]); const areas = new Set(cells); 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) { 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. */ -function showGrid(g: Grid): string { +function showGrid(g: BigGridState): string { 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"; - result += c?.item.id ?? " "; - }); + result += g.cells[i]?.item.id ?? " "; + } return result; } @@ -222,21 +222,12 @@ function testCycleTileSize( output: string ): void { 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( "expands a tile to 2×2 in a 3 column layout", "c", @@ -345,8 +336,8 @@ abc def`, ` abc -gfe -d` + g +def` ); testAddItems( @@ -362,19 +353,19 @@ gge d` ); -function testTryMoveTile( +function testMoveTile( title: string, from: number, to: number, input: string, output: string ): void { - test(`tryMoveTile ${title}`, () => { - expect(showGrid(tryMoveTile(mkGrid(input), from, to))).toBe(output); + test(`moveTile ${title}`, () => { + expect(showGrid(moveTile(mkGrid(input), from, to))).toBe(output); }); } -testTryMoveTile( +testMoveTile( "refuses to move a tile too far to the left", 1, -1, @@ -384,7 +375,7 @@ abc`, abc` ); -testTryMoveTile( +testMoveTile( "refuses to move a tile too far to the right", 1, 3, @@ -394,7 +385,7 @@ abc`, abc` ); -testTryMoveTile( +testMoveTile( "moves a large tile to an unoccupied space", 3, 1, @@ -408,7 +399,7 @@ bcc d e` ); -testTryMoveTile( +testMoveTile( "refuses to move a large tile to an occupied space", 3, 1,