Improve typing around layouts and grid components
This commit is contained in:
parent
cc35f243f2
commit
1c6ef97457
7 changed files with 207 additions and 176 deletions
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
178
src/video-grid/Layout.tsx
Normal 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} />,
|
||||
};
|
||||
};
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue