diff --git a/src/video-grid/NewVideoGrid.tsx b/src/video-grid/NewVideoGrid.tsx index 2c25f62d..aba499ea 100644 --- a/src/video-grid/NewVideoGrid.tsx +++ b/src/video-grid/NewVideoGrid.tsx @@ -17,8 +17,11 @@ limitations under the License. import { SpringRef, TransitionFn, useTransition } from "@react-spring/web"; import { EventTypes, Handler, useScroll } from "@use-gesture/react"; import React, { + Dispatch, FC, ReactNode, + SetStateAction, + useCallback, useEffect, useMemo, useRef, @@ -39,8 +42,82 @@ import { fillGaps, forEachCellInArea, cycleTileSize, + appendItems, } from "./model"; +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: Backfill gaps left behind by removed tiles + const grid2 = 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 = appendItems(newItems, grid2); + + 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]; +}; + interface Rect { x: number; y: number; @@ -133,55 +210,7 @@ export const NewVideoGrid: FC = ({ [gridBounds] ); - 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: slotGridGeneration, 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, - generation: prevGrid.generation + 1, - 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: Backfill gaps left behind by removed tiles - const grid2 = 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: Grid = { - ...grid2, - cells: [ - ...grid2.cells, - ...newItems.map((i) => ({ - item: i, - slot: true, - columns: 1, - rows: 1, - })), - ], - }; - - return grid3; - }, - [items, columns] - ); + const [grid, setGrid] = useGridState(columns, items); const [tiles] = useReactiveState( (prevTiles) => { @@ -189,9 +218,9 @@ export const NewVideoGrid: FC = ({ // the update, because grid and slotRects will be out of sync if (slotGridGeneration !== grid?.generation) return prevTiles ?? []; - const slotCells = grid.cells.filter((c) => c?.slot) as Cell[]; + const tileCells = grid.cells.filter((c) => c?.origin) as Cell[]; const tileRects = new Map( - zipWith(slotCells, slotRects, (cell, rect) => [cell.item, rect]) + zipWith(tileCells, slotRects, (cell, rect) => [cell.item, rect]) ); return items.map((item) => ({ ...tileRects.get(item)!, item })); }, @@ -247,7 +276,7 @@ export const NewVideoGrid: FC = ({ let slotId = 0; for (let i = 0; i < grid.cells.length; i++) { const cell = grid.cells[i]; - if (cell?.slot) { + if (cell?.origin) { const slotEnd = i + cell.columns - 1 + grid.columns * (cell.rows - 1); forEachCellInArea( i, diff --git a/src/video-grid/model.ts b/src/video-grid/model.ts index 0a4136c6..54f3c817 100644 --- a/src/video-grid/model.ts +++ b/src/video-grid/model.ts @@ -17,29 +17,34 @@ limitations under the License. import TinyQueue from "tinyqueue"; import { TileDescriptor } from "./TileDescriptor"; +/** + * A 1×1 cell in a grid which belongs to a tile. + */ export interface Cell { /** - * The item held by the slot containing this cell. + * The item displayed on the tile. */ item: TileDescriptor; /** - * Whether this cell is the first cell of the containing slot. + * Whether this cell is the origin (top left corner) of the tile. */ - // TODO: Rename to 'start'? - slot: boolean; + origin: boolean; /** - * The width, in columns, of the containing slot. + * The width, in columns, of the tile. */ columns: number; /** - * The height, in rows, of the containing slot. + * The height, in rows, of the tile. */ rows: number; } export interface Grid { - generation: number; columns: number; + /** + * The cells of the grid, in left-to-right top-to-bottom order. + * undefined = empty. + */ cells: (Cell | undefined)[]; } @@ -55,9 +60,9 @@ export function dijkstra(g: Grid): number[] { const visit = (curr: number, via: number) => { const viaCell = g.cells[via]; - const viaLargeSlot = + const viaLargeTile = viaCell !== undefined && (viaCell.rows > 1 || viaCell.columns > 1); - const distanceVia = distances[via] + (viaLargeSlot ? 4 : 1); + const distanceVia = distances[via] + (viaLargeTile ? 4 : 1); if (distanceVia < distances[curr]) { distances[curr] = distanceVia; @@ -252,6 +257,21 @@ export function fillGaps(g: Grid): Grid { return result; } +export function appendItems(items: TileDescriptor[], g: Grid): Grid { + return { + ...g, + cells: [ + ...g.cells, + ...items.map((i) => ({ + item: i, + origin: true, + columns: 1, + rows: 1, + })), + ], + }; +} + export function cycleTileSize(tileId: string, g: Grid): Grid { const from = g.cells.findIndex((c) => c?.item.id === tileId); if (from === -1) return g; // Tile removed, no change @@ -273,7 +293,6 @@ export function cycleTileSize(tileId: string, g: Grid): Grid { const gappyGrid: Grid = { ...g, - generation: g.generation + 1, cells: new Array(g.cells.length + newRows * g.columns), }; @@ -318,7 +337,7 @@ export function cycleTileSize(tileId: string, g: Grid): Grid { const toRow = row(to, g); g.cells.forEach((c, src) => { - if (c?.slot && c.item.id !== tileId) { + if (c?.origin && c.item.id !== tileId) { const offset = row(src, g) > toRow + candidateHeight - 1 ? g.columns * newRows : 0; forEachCellInArea(src, areaEnd(src, c.columns, c.rows, g), g, (c, i) => { @@ -333,7 +352,7 @@ export function cycleTileSize(tileId: string, g: Grid): Grid { if (c !== undefined) displacedTiles.push(c); gappyGrid.cells[i] = { item: g.cells[from]!.item, - slot: i === to, + origin: i === to, columns: toWidth, rows: toHeight, };