/* 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: , }; };