Improve typing around layouts and grid components

This commit is contained in:
Robin Townsend 2023-06-28 10:59:36 -04:00
parent cc35f243f2
commit 1c6ef97457
7 changed files with 207 additions and 176 deletions

View file

@ -73,7 +73,7 @@ import { useJoinRule } from "./useJoinRule";
import { ParticipantInfo } from "./useGroupCall";
import { ItemData, TileContent } from "../video-grid/VideoTile";
import { Config } from "../config/Config";
import { NewVideoGrid, useLayoutStates } from "../video-grid/NewVideoGrid";
import { NewVideoGrid } from "../video-grid/NewVideoGrid";
import { OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
import { SettingsModal } from "../settings/SettingsModal";
import { InviteModal } from "./InviteModal";
@ -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

View file

@ -865,7 +865,8 @@ export const BigGrid: Layout<BigGridState> = {
emptyState: { columns: 4, cells: [] },
updateTiles,
updateBounds,
getTiles: (g) => g.cells.filter((c) => c?.origin).map((c) => c!.item),
getTiles: <T,>(g) =>
g.cells.filter((c) => c?.origin).map((c) => c!.item as T),
canDragTile: () => true,
dragTile,
toggleFocus: cycleTileSize,

View file

@ -1,74 +0,0 @@
/*
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;
}

178
src/video-grid/Layout.tsx Normal file
View file

@ -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<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: <T>(s: State, tiles: TileDescriptor<T>[]) => 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: <T>(s: State) => TileDescriptor<T>[];
/**
* Determines whether a tile is draggable.
*/
readonly canDragTile: <T>(s: State, tile: TileDescriptor<T>) => 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: <T>(
s: State,
from: TileDescriptor<T>,
to: TileDescriptor<T>,
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?: <T>(s: State, tile: TileDescriptor<T>) => 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<State>(layout: Layout<State>): State | undefined;
set<State>(layout: Layout<State>, state: State): LayoutStatesMap;
delete<State>(layout: Layout<State>): boolean;
}
/**
* Hook creating a Map to store layout states in.
*/
export const useLayoutStates = (): LayoutStatesMap => {
const layoutStates = useRef<Map<unknown, unknown>>();
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 = <State, T>(
layout: Layout<State>,
items: TileDescriptor<T>[],
bounds: RectReadOnly,
layoutStates: LayoutStatesMap
) => {
const prevLayout = useRef<Layout<unknown>>();
const prevState = layoutStates.get(layout);
const [state, setState] = useReactiveState<State>(() => {
// 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<number>(0);
if (state !== prevState) generation.current++;
prevLayout.current = layout as Layout<unknown>;
// 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<T>(state), [layout, state]),
generation: generation.current,
canDragTile: useCallback(
(tile: TileDescriptor<T>) => layout.canDragTile(state, tile),
[layout, state]
),
dragTile: useCallback(
(
from: TileDescriptor<T>,
to: TileDescriptor<T>,
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<T>) =>
setState((s) => layout.toggleFocus!(s, tile))),
[layout, setState]
),
slots: <layout.Slots s={state} />,
};
};

View file

@ -20,13 +20,12 @@ import React, {
CSSProperties,
FC,
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import useMeasure, { RectReadOnly } from "react-use-measure";
import useMeasure from "react-use-measure";
import { zip } from "lodash";
import styles from "./NewVideoGrid.module.css";
@ -40,84 +39,7 @@ import { useReactiveState } from "../useReactiveState";
import { useMergedRefs } from "../useMergedRefs";
import { TileWrapper } from "./TileWrapper";
import { BigGrid } from "./BigGrid";
import { Layout } from "./Layout";
export const useLayoutStates = () => {
const layoutStates = useRef<Map<Layout<unknown>, unknown>>();
if (layoutStates.current === undefined) layoutStates.current = new Map();
return layoutStates.current;
};
const useGrid = (
layout: Layout<unknown>,
items: TileDescriptor<unknown>[],
bounds: RectReadOnly,
layoutStates: Map<Layout<unknown>, unknown>
) => {
const prevLayout = useRef<Layout<unknown>>(layout);
const prevState = layoutStates.get(layout);
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 {
if (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<number>(0);
if (state !== prevState) generation.current++;
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} />,
};
};
import { useLayout } from "./Layout";
interface Rect {
x: number;
@ -126,8 +48,8 @@ interface Rect {
height: number;
}
interface Tile extends Rect {
item: TileDescriptor<unknown>;
interface Tile<T> extends Rect {
item: TileDescriptor<T>;
}
interface DragState {
@ -215,23 +137,23 @@ export function NewVideoGrid<T>({
// TODO: Implement more layouts and select the right one here
const layout = BigGrid;
const {
grid,
state: grid,
orderedItems,
generation,
canDragTile,
dragTile,
toggleFocus,
slots,
} = useGrid(layout as Layout<unknown>, items, gridBounds, layoutStates);
} = useLayout(layout, items, gridBounds, layoutStates);
const [tiles] = useReactiveState<Tile[]>(
const [tiles] = useReactiveState<Tile<T>[]>(
(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 (renderedGeneration !== generation) return prevTiles ?? [];
const tileRects = new Map<TileDescriptor<unknown>, Rect>(
zip(orderedItems, slotRects) as [TileDescriptor<unknown>, Rect][]
const tileRects = new Map(
zip(orderedItems, slotRects) as [TileDescriptor<T>, Rect][]
);
// In order to not break drag gestures, it's critical that we render tiles
// in a stable order (that of 'items')
@ -247,8 +169,8 @@ export function NewVideoGrid<T>({
const [tileTransitions, springRef] = useTransition(
tiles,
() => ({
key: ({ item }: Tile) => item.id,
from: ({ x, y, width, height }: Tile) => ({
key: ({ item }: Tile<T>) => item.id,
from: ({ x, y, width, height }: Tile<T>) => ({
opacity: 0,
scale: 0,
shadow: 1,
@ -261,7 +183,7 @@ export function NewVideoGrid<T>({
immediate: disableAnimations,
}),
enter: { opacity: 1, scale: 1, immediate: disableAnimations },
update: ({ item, x, y, width, height }: Tile) =>
update: ({ item, x, y, width, height }: Tile<T>) =>
item.id === dragState.current?.tileId
? null
: {
@ -275,7 +197,7 @@ export function NewVideoGrid<T>({
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<Tile, TileSpring>, SpringRef<TileSpring>];
) as unknown as [TransitionFn<Tile<T>, TileSpring>, SpringRef<TileSpring>];
// Because we're using react-spring in imperative mode, we're responsible for
// firing animations manually whenever the tiles array updates
@ -288,7 +210,7 @@ export function NewVideoGrid<T>({
const tile = tiles.find((t) => t.item.id === tileId)!;
springRef.current
.find((c) => (c.item as Tile).item.id === tileId)
.find((c) => (c.item as Tile<T>).item.id === tileId)
?.start(
endOfGesture
? {
@ -353,10 +275,10 @@ export function NewVideoGrid<T>({
toggleFocus?.(items.find((i) => i.id === tileId)!);
} else {
const tileController = springRef.current.find(
(c) => (c.item as Tile).item.id === tileId
(c) => (c.item as Tile<T>).item.id === tileId
)!;
if (canDragTile((tileController.item as Tile).item)) {
if (canDragTile((tileController.item as Tile<T>).item)) {
if (dragState.current === null) {
const tileSpring = tileController.get();
dragState.current = {
@ -422,7 +344,7 @@ export function NewVideoGrid<T>({
data={tile.item.data}
{...spring}
>
{children as (props: ChildrenProperties<unknown>) => ReactNode}
{children as (props: ChildrenProperties<T>) => ReactNode}
</TileWrapper>
))}
</div>

View file

@ -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<T> {
* A wrapper around a tile in a video grid. This component exists to decouple
* child components from the grid.
*/
export const TileWrapper: FC<Props<unknown>> = memo(
export const TileWrapper = memo(
({
id,
onDragRef,
@ -97,4 +97,7 @@ export const TileWrapper: FC<Props<unknown>> = 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 <T>(props: Props<T>) => JSX.Element;

View file

@ -42,7 +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 { Layout as LayoutSystem } from "./Layout";
import { LayoutStatesMap } from "./Layout";
interface TilePosition {
x: number;
@ -818,7 +818,7 @@ export interface VideoGridProps<T> {
items: TileDescriptor<T>[];
layout: Layout;
disableAnimations: boolean;
layoutStates: Map<LayoutSystem<unknown>, unknown>;
layoutStates: LayoutStatesMap;
children: (props: ChildrenProperties<T>) => React.ReactNode;
}