Merge pull request #911 from robintown/big-grid

New grid system
This commit is contained in:
Robin 2023-02-19 09:02:57 -05:00 committed by GitHub
commit cdf2d560b8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 1577 additions and 139 deletions

View file

@ -63,6 +63,7 @@
"react-use-clipboard": "^1.0.7",
"react-use-measure": "^2.1.1",
"sdp-transform": "^2.14.1",
"tinyqueue": "^2.0.3",
"unique-names-generator": "^4.6.0"
},
"devDependencies": {

View file

@ -124,6 +124,7 @@
"Turn off camera": "Turn off camera",
"Turn on camera": "Turn on camera",
"Unmute microphone": "Unmute microphone",
"Use the upcoming grid system": "Use the upcoming grid system",
"User ID": "User ID",
"User menu": "User menu",
"Username": "Username",

View file

@ -20,9 +20,10 @@ limitations under the License.
flex-direction: column;
overflow: hidden;
min-height: 100%;
position: fixed;
height: 100%;
width: 100%;
--footerPadding: 8px;
--footerHeight: calc(50px + 2 * var(--footerPadding));
}
.centerMessage {
@ -39,11 +40,27 @@ limitations under the License.
}
.footer {
position: relative;
position: absolute;
left: 0;
bottom: 0;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
height: calc(50px + 2 * 8px);
padding: var(--footerPadding) 0;
/* TODO: Un-hardcode these colors */
background: linear-gradient(
360deg,
#15191e 0%,
rgba(21, 25, 30, 0.9) 37%,
rgba(21, 25, 30, 0.8) 49.68%,
rgba(21, 25, 30, 0.7) 56.68%,
rgba(21, 25, 30, 0.427397) 72.92%,
rgba(21, 25, 30, 0.257534) 81.06%,
rgba(21, 25, 30, 0.136986) 87.29%,
rgba(21, 25, 30, 0.0658079) 92.4%,
rgba(21, 25, 30, 0) 100%
);
}
.footer > * {
@ -65,16 +82,22 @@ limitations under the License.
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
/* To make avatars scale smoothly with their tiles during animations, we
override the styles set on the element */
--avatarSize: calc(min(var(--tileWidth), var(--tileHeight)) / 2);
width: var(--avatarSize) !important;
height: var(--avatarSize) !important;
border-radius: 10000px !important;
}
@media (min-height: 300px) {
.footer {
height: calc(50px + 2 * 24px);
.inRoom {
--footerPadding: 24px;
}
}
@media (min-width: 800px) {
.footer {
height: calc(50px + 2 * 32px);
.inRoom {
--footerPadding: 32px;
}
}

View file

@ -41,7 +41,11 @@ import {
RoomHeaderInfo,
VersionMismatchWarning,
} from "../Header";
import { VideoGrid, useVideoGridLayout } from "../video-grid/VideoGrid";
import {
VideoGrid,
useVideoGridLayout,
ChildrenProperties,
} from "../video-grid/VideoGrid";
import { VideoTileContainer } from "../video-grid/VideoTileContainer";
import { GroupCallInspector } from "./GroupCallInspector";
import { OverflowMenu } from "./OverflowMenu";
@ -51,7 +55,11 @@ import { UserMenuContainer } from "../UserMenuContainer";
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
import { RageshakeRequestModal } from "./RageshakeRequestModal";
import { useMediaHandler } from "../settings/useMediaHandler";
import { useShowInspector, useSpatialAudio } from "../settings/useSetting";
import {
useNewGrid,
useShowInspector,
useSpatialAudio,
} from "../settings/useSetting";
import { useModalTriggerState } from "../Modal";
import { useAudioContext } from "../video-grid/useMediaStream";
import { useFullscreen } from "../video-grid/useFullscreen";
@ -64,6 +72,7 @@ import { ParticipantInfo } from "./useGroupCall";
import { TileDescriptor } from "../video-grid/TileDescriptor";
import { AudioSink } from "../video-grid/AudioSink";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { NewVideoGrid } from "../video-grid/NewVideoGrid";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
@ -276,6 +285,8 @@ export function InCallView({
[]
);
const [newGrid] = useNewGrid();
const Grid = newGrid ? NewVideoGrid : VideoGrid;
const prefersReducedMotion = usePrefersReducedMotion();
const renderContent = (): JSX.Element => {
@ -289,8 +300,8 @@ export function InCallView({
if (maximisedParticipant) {
return (
<VideoTileContainer
height={bounds.height}
width={bounds.width}
targetHeight={bounds.height}
targetWidth={bounds.width}
key={maximisedParticipant.id}
item={maximisedParticipant}
getAvatar={renderAvatar}
@ -305,20 +316,13 @@ export function InCallView({
}
return (
<VideoGrid
<Grid
items={items}
layout={layout}
disableAnimations={prefersReducedMotion || isSafari}
>
{({
item,
...rest
}: {
item: TileDescriptor;
[x: string]: unknown;
}) => (
{({ item, ...rest }: ChildrenProperties) => (
<VideoTileContainer
key={item.id}
item={item}
getAvatar={renderAvatar}
audioContext={audioContext}
@ -330,7 +334,7 @@ export function InCallView({
{...rest}
/>
)}
</VideoGrid>
</Grid>
);
};

View file

@ -33,6 +33,7 @@ import {
useShowInspector,
useOptInAnalytics,
canEnableSpatialAudio,
useNewGrid,
} from "./useSetting";
import { FieldRow, InputField } from "../input/Input";
import { Button } from "../button";
@ -62,6 +63,7 @@ export const SettingsModal = (props: Props) => {
const [showInspector, setShowInspector] = useShowInspector();
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
const [keyboardShortcuts, setKeyboardShortcuts] = useKeyboardShortcuts();
const [newGrid, setNewGrid] = useNewGrid();
const downloadDebugLog = useDownloadDebugLog();
@ -216,6 +218,17 @@ export const SettingsModal = (props: Props) => {
}
/>
</FieldRow>
<FieldRow>
<InputField
id="newGrid"
label={t("Use the upcoming grid system")}
type="checkbox"
checked={newGrid}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setNewGrid(e.target.checked)
}
/>
</FieldRow>
<FieldRow>
<Button onPress={downloadDebugLog}>
{t("Download debug logs")}

View file

@ -90,3 +90,4 @@ export const useShowInspector = () => useSetting("show-inspector", false);
export const useOptInAnalytics = () => useSetting("opt-in-analytics", false);
export const useKeyboardShortcuts = () =>
useSetting("keyboard-shortcuts", true);
export const useNewGrid = () => useSetting("new-grid", false);

39
src/useMergedRefs.ts Normal file
View file

@ -0,0 +1,39 @@
/*
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 { MutableRefObject, RefCallback, useCallback } from "react";
/**
* Combines multiple refs into one, useful for attaching multiple refs to the
* same DOM node.
*/
export const useMergedRefs = <T>(
...refs: (MutableRefObject<T | null> | RefCallback<T | null>)[]
): RefCallback<T | null> =>
useCallback(
(value) =>
refs.forEach((ref) => {
if (typeof ref === "function") {
ref(value);
} else {
ref.current = value;
}
}),
// Since this isn't an array literal, we can't use the static dependency
// checker, but that's okay
// eslint-disable-next-line react-hooks/exhaustive-deps
refs
);

67
src/useReactiveState.ts Normal file
View file

@ -0,0 +1,67 @@
/*
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 {
DependencyList,
Dispatch,
SetStateAction,
useCallback,
useRef,
useState,
} from "react";
/**
* Hook creating a stateful value that updates automatically whenever the
* dependencies change. Or equivalently, a version of useMemo that takes its own
* previous value as an input, and can be updated manually.
*/
export const useReactiveState = <T>(
updateFn: (prevState?: T) => T,
deps: DependencyList
): [T, Dispatch<SetStateAction<T>>] => {
const state = useRef<T>();
if (state.current === undefined) state.current = updateFn();
const prevDeps = useRef<DependencyList>();
// Since we store the state in a ref, we use this counter to force an update
// when someone calls setState
const [, setNumUpdates] = useState(0);
// If this is the first render or the deps have changed, recalculate the state
if (
prevDeps.current === undefined ||
deps.length !== prevDeps.current.length ||
deps.some((d, i) => d !== prevDeps.current![i])
) {
state.current = updateFn(state.current);
}
prevDeps.current = deps;
return [
state.current,
useCallback(
(action) => {
if (typeof action === "function") {
state.current = (action as (prevValue: T) => T)(state.current!);
} else {
state.current = action;
}
setNumUpdates((n) => n + 1); // Force an update
},
[setNumUpdates]
),
];
};

View file

@ -0,0 +1,48 @@
/*
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.
*/
.grid {
contain: strict;
position: relative;
flex-grow: 1;
padding: 0 20px;
overflow-y: auto;
overflow-x: hidden;
}
.slotGrid {
position: relative;
display: grid;
grid-auto-rows: 163px;
gap: 8px;
padding-bottom: var(--footerHeight);
}
.slot {
contain: strict;
}
@media (min-width: 800px) {
.grid {
padding: 0 22px;
}
.slotGrid {
grid-auto-rows: 183px;
column-gap: 18px;
row-gap: 21px;
}
}

View file

@ -0,0 +1,461 @@
/*
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 { 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,
useState,
} from "react";
import useMeasure from "react-use-measure";
import { zipWith } from "lodash";
import styles from "./NewVideoGrid.module.css";
import { TileDescriptor } from "./TileDescriptor";
import { VideoGridProps as Props, TileSpring } from "./VideoGrid";
import { useReactiveState } from "../useReactiveState";
import { useMergedRefs } from "../useMergedRefs";
import {
Grid,
Cell,
row,
column,
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<SetStateAction<Grid>>] => {
const [grid, setGrid_] = useReactiveState<GridState | null>(
(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<SetStateAction<Grid>> = 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;
width: number;
height: number;
}
interface Tile extends Rect {
item: TileDescriptor;
}
interface DragState {
tileId: string;
tileX: number;
tileY: number;
cursorX: number;
cursorY: number;
}
/**
* An interactive, animated grid of video tiles.
*/
export const NewVideoGrid: FC<Props> = ({
items,
disableAnimations,
children,
}) => {
// Overview: This component lays out tiles by rendering an invisible template
// grid of "slots" for tiles to go in. Once rendered, it uses the DOM API to
// get the dimensions of each slot, feeding these numbers back into
// react-spring to let the actual tiles move freely atop the template.
// To know when the rendered grid becomes consistent with the layout we've
// requested, we give it a data-generation attribute which holds the ID of the
// most recently rendered generation of the grid, and watch it with a
// MutationObserver.
const [slotGrid, setSlotGrid] = useState<HTMLDivElement | null>(null);
const [slotGridGeneration, setSlotGridGeneration] = useState(0);
useEffect(() => {
if (slotGrid !== null) {
setSlotGridGeneration(
parseInt(slotGrid.getAttribute("data-generation")!)
);
const observer = new MutationObserver((mutations) => {
if (mutations.some((m) => m.type === "attributes")) {
setSlotGridGeneration(
parseInt(slotGrid.getAttribute("data-generation")!)
);
}
});
observer.observe(slotGrid, { attributes: true });
return () => observer.disconnect();
}
}, [slotGrid, setSlotGridGeneration]);
const [gridRef1, gridBounds] = useMeasure();
const gridRef2 = useRef<HTMLDivElement | null>(null);
const gridRef = useMergedRefs(gridRef1, gridRef2);
const slotRects = useMemo(() => {
if (slotGrid === null) return [];
const slots = slotGrid.getElementsByClassName(styles.slot);
const rects = new Array<Rect>(slots.length);
for (let i = 0; i < slots.length; i++) {
const slot = slots[i] as HTMLElement;
rects[i] = {
x: slot.offsetLeft,
y: slot.offsetTop,
width: slot.offsetWidth,
height: slot.offsetHeight,
};
}
return rects;
// 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]);
const [columns] = useReactiveState<number | null>(
// Since grid resizing isn't implemented yet, pick a column count on mount
// and stick to it
(prevColumns) =>
prevColumns !== undefined && prevColumns !== null
? prevColumns
: // The grid bounds might not be known yet
gridBounds.width === 0
? null
: Math.max(2, Math.floor(gridBounds.width * 0.0045)),
[gridBounds]
);
const [grid, setGrid] = useGridState(columns, items);
const [tiles] = useReactiveState<Tile[]>(
(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 ?? [];
const tileCells = grid.cells.filter((c) => c?.origin) as Cell[];
const tileRects = new Map<TileDescriptor, Rect>(
zipWith(tileCells, slotRects, (cell, rect) => [cell.item, rect])
);
return items.map((item) => ({ ...tileRects.get(item)!, item }));
},
[slotRects, grid, slotGridGeneration]
);
// Drag state is stored in a ref rather than component state, because we use
// react-spring's imperative API during gestures to improve responsiveness
const dragState = useRef<DragState | null>(null);
const [tileTransitions, springRef] = useTransition(
tiles,
() => ({
key: ({ item }: Tile) => item.id,
from: ({ x, y, width, height }: Tile) => ({
opacity: 0,
scale: 0,
shadow: 1,
zIndex: 1,
x,
y,
width,
height,
immediate: disableAnimations,
}),
enter: { opacity: 1, scale: 1, immediate: disableAnimations },
update: ({ item, x, y, width, height }: Tile) =>
item.id === dragState.current?.tileId
? {}
: {
x,
y,
width,
height,
immediate: disableAnimations,
},
leave: { opacity: 0, scale: 0, immediate: disableAnimations },
config: { mass: 0.7, tension: 252, friction: 25 },
}),
[tiles, disableAnimations]
// react-spring's types are bugged and can't infer the spring type
) as unknown as [TransitionFn<Tile, TileSpring>, SpringRef<TileSpring>];
const animateDraggedTile = (endOfGesture: boolean) => {
const { tileId, tileX, tileY, cursorX, cursorY } = dragState.current!;
const tile = tiles.find((t) => t.item.id === tileId)!;
springRef.start((_i, controller) => {
if ((controller.item as Tile).item.id === tileId) {
if (endOfGesture) {
return {
scale: 1,
zIndex: 1,
shadow: 1,
x: tile.x,
y: tile.y,
width: tile.width,
height: tile.height,
immediate: disableAnimations || ((key) => key === "zIndex"),
// Allow the tile's position to settle before pushing its
// z-index back down
delay: (key) => (key === "zIndex" ? 500 : 0),
};
} else {
return {
scale: 1.1,
zIndex: 2,
shadow: 15,
x: tileX,
y: tileY,
immediate:
disableAnimations ||
((key) => key === "zIndex" || key === "x" || key === "y"),
};
}
} else {
return {};
}
});
const overTile = tiles.find(
(t) =>
cursorX >= t.x &&
cursorX < t.x + t.width &&
cursorY >= t.y &&
cursorY < t.y + t.height
);
if (overTile !== undefined && overTile.item.id !== tileId) {
setGrid((g) => ({
...g!,
cells: g!.cells.map((c) => {
if (c?.item === overTile.item) return { ...c, item: tile.item };
if (c?.item === tile.item) return { ...c, item: overTile.item };
return c;
}),
}));
}
};
// Callback for useDrag. We could call useDrag here, but the default
// pattern of spreading {...bind()} across the children to bind the gesture
// ends up breaking memoization and ruining this component's performance.
// Instead, we pass this callback to each tile via a ref, to let them bind the
// gesture using the much more sensible ref-based method.
const onTileDrag = (
tileId: string,
{
tap,
initial: [initialX, initialY],
delta: [dx, dy],
last,
}: Parameters<Handler<"drag", EventTypes["drag"]>>[0]
) => {
if (tap) {
setGrid((g) => cycleTileSize(tileId, g!));
} else {
const tileSpring = springRef.current
.find((c) => (c.item as Tile).item.id === tileId)!
.get();
if (dragState.current === null) {
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;
}
};
const onTileDragRef = useRef(onTileDrag);
onTileDragRef.current = onTileDrag;
const scrollOffset = useRef(0);
useScroll(
({ xy: [, y], delta: [, dy] }) => {
scrollOffset.current = y;
if (dragState.current !== null) {
dragState.current.tileY += dy;
dragState.current.cursorY += dy;
animateDraggedTile(false);
}
},
{ 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<number | null>(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<ReactNode>(items.length);
for (let i = 0; i < items.length; i++)
slots[i] = (
<div className={styles.slot} key={i} style={{ gridArea: `s${i}` }} />
);
return slots;
}, [items.length]);
// Render nothing if the grid has yet to be generated
if (grid === null) {
return <div ref={gridRef} className={styles.grid} />;
}
return (
<div ref={gridRef} className={styles.grid}>
<div
style={slotGridStyle}
ref={setSlotGrid}
className={styles.slotGrid}
data-generation={grid.generation}
>
{slots}
</div>
{tileTransitions((style, tile) =>
children({
...style,
key: tile.item.id,
targetWidth: tile.width,
targetHeight: tile.height,
item: tile.item,
onDragRef: onTileDragRef,
})
)}
</div>
);
};

View file

@ -19,4 +19,5 @@ limitations under the License.
overflow: hidden;
flex: 1;
touch-action: none;
margin-bottom: var(--footerHeight);
}

View file

@ -16,7 +16,12 @@ limitations under the License.
import React, { Key, useCallback, useEffect, useRef, useState } from "react";
import { FullGestureState, useDrag, useGesture } from "@use-gesture/react";
import { Interpolation, SpringValue, useSprings } from "@react-spring/web";
import {
SpringRef,
SpringValue,
SpringValues,
useSprings,
} from "@react-spring/web";
import useMeasure from "react-use-measure";
import { ResizeObserver } from "@juggle/resize-observer";
import { ReactDOMAttributes } from "@use-gesture/react/dist/declarations/src/types";
@ -42,6 +47,17 @@ interface Tile {
presenter: boolean;
}
export interface TileSpring {
opacity: number;
scale: number;
shadow: number;
zIndex: number;
x: number;
y: number;
width: number;
height: number;
}
type LayoutDirection = "vertical" | "horizontal";
export function useVideoGridLayout(hasScreenshareFeeds: boolean): {
@ -692,20 +708,23 @@ interface DragTileData {
y: number;
}
interface ChildrenProperties extends ReactDOMAttributes {
export interface ChildrenProperties extends ReactDOMAttributes {
key: Key;
style: {
scale: SpringValue<number>;
opacity: SpringValue<number>;
boxShadow: Interpolation<number, string>;
};
width: number;
height: number;
targetWidth: number;
targetHeight: number;
item: TileDescriptor;
opacity: SpringValue<number>;
scale: SpringValue<number>;
shadow: SpringValue<number>;
zIndex: SpringValue<number>;
x: SpringValue<number>;
y: SpringValue<number>;
width: SpringValue<number>;
height: SpringValue<number>;
[index: string]: unknown;
}
interface VideoGridProps {
export interface VideoGridProps {
items: TileDescriptor[];
layout: Layout;
disableAnimations?: boolean;
@ -896,6 +915,7 @@ export function VideoGrid({
shadow: 0,
scale: 0,
opacity: 0,
zIndex: 0,
},
reset: false,
};
@ -919,6 +939,7 @@ export function VideoGrid({
shadow: number;
scale: number;
opacity: number;
zIndex?: number;
x?: number;
y?: number;
width?: number;
@ -965,7 +986,8 @@ export function VideoGrid({
tilePositions,
tiles,
scrollPosition,
]);
// react-spring's types are bugged and can't infer the spring type
]) as unknown as [SpringValues<TileSpring>[], SpringRef<TileSpring>];
const onTap = useCallback(
(tileKey: Key) => {
@ -1175,21 +1197,16 @@ export function VideoGrid({
return (
<div className={styles.videoGrid} ref={gridRef} {...bindGrid()}>
{springs.map(({ shadow, ...style }, i) => {
{springs.map((style, i) => {
const tile = tiles[i];
const tilePosition = tilePositions[tile.order];
return children({
...bindTile(tile.key),
key: tile.key,
style: {
boxShadow: shadow.to(
(s) => `rgba(0, 0, 0, 0.5) 0px ${s}px ${2 * s}px 0px`
),
...style,
},
width: tilePosition.width,
height: tilePosition.height,
...style,
key: tile.item.id,
targetWidth: tilePosition.width,
targetHeight: tilePosition.height,
item: tile.item,
});
})}

View file

@ -16,11 +16,13 @@ limitations under the License.
.videoTile {
position: absolute;
will-change: transform, width, height, opacity, box-shadow;
border-radius: 20px;
contain: strict;
top: 0;
width: var(--tileWidth);
height: var(--tileHeight);
border-radius: 8px;
overflow: hidden;
cursor: pointer;
touch-action: none;
/* HACK: This has no visual effect due to the short duration, but allows the
JS to detect movement via the transform property for audio spatialization */
@ -28,9 +30,6 @@ limitations under the License.
}
.videoTile * {
touch-action: none;
-moz-user-select: none;
-webkit-user-drag: none;
user-select: none;
}
@ -143,13 +142,6 @@ limitations under the License.
white-space: nowrap;
}
.videoMutedAvatar {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.videoMutedOverlay {
width: 100%;
height: 100%;
@ -179,3 +171,9 @@ limitations under the License.
max-width: 360px;
border-radius: 20px;
}
@media (min-width: 800px) {
.videoTile {
border-radius: 20px;
}
}

View file

@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { forwardRef } from "react";
import { animated } from "@react-spring/web";
import React, { ForwardedRef, forwardRef } from "react";
import { animated, SpringValue } from "@react-spring/web";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
@ -44,9 +44,17 @@ interface Props {
showOptions?: boolean;
isLocal?: boolean;
disableSpeakingIndicator?: boolean;
opacity?: SpringValue<number>;
scale?: SpringValue<number>;
shadow?: SpringValue<number>;
zIndex?: SpringValue<number>;
x?: SpringValue<number>;
y?: SpringValue<number>;
width?: SpringValue<number>;
height?: SpringValue<number>;
}
export const VideoTile = forwardRef<HTMLDivElement, Props>(
export const VideoTile = forwardRef<HTMLElement, Props>(
(
{
name,
@ -68,6 +76,14 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
isLocal,
// TODO: disableSpeakingIndicator is not used atm.
disableSpeakingIndicator,
opacity,
scale,
shadow,
zIndex,
x,
y,
width,
height,
...rest
},
ref
@ -122,7 +138,22 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
[styles.screenshare]: screenshare,
[styles.maximised]: maximised,
})}
ref={ref}
style={{
opacity,
scale,
boxShadow: shadow?.to(
(s) => `rgba(0, 0, 0, 0.5) 0px ${s}px ${2 * s}px 0px`
),
zIndex,
x,
y,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore React does in fact support assigning custom properties,
// but React's types say no
"--tileWidth": width?.to((w) => `${w}px`),
"--tileHeight": height?.to((h) => `${h}px`),
}}
ref={ref as ForwardedRef<HTMLDivElement>}
{...rest}
>
{toolbarButtons.length > 0 && !maximised && (

View file

@ -15,9 +15,11 @@ limitations under the License.
*/
import { SDPStreamMetadataPurpose } from "matrix-js-sdk/src/webrtc/callEventTypes";
import React from "react";
import React, { FC, memo, RefObject } from "react";
import { useCallback } from "react";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { SpringValue } from "@react-spring/web";
import { EventTypes, Handler, useDrag } from "@use-gesture/react";
import { useCallFeed } from "./useCallFeed";
import { useSpatialMediaStream } from "./useMediaStream";
@ -29,8 +31,8 @@ import { TileDescriptor } from "./TileDescriptor";
interface Props {
item: TileDescriptor;
width?: number;
height?: number;
targetWidth: number;
targetHeight: number;
getAvatar: (
roomMember: RoomMember,
width: number,
@ -42,86 +44,113 @@ interface Props {
maximised: boolean;
fullscreen: boolean;
onFullscreen: (item: TileDescriptor) => void;
opacity?: SpringValue<number>;
scale?: SpringValue<number>;
shadow?: SpringValue<number>;
zIndex?: SpringValue<number>;
x?: SpringValue<number>;
y?: SpringValue<number>;
width?: SpringValue<number>;
height?: SpringValue<number>;
onDragRef?: RefObject<
(
tileId: string,
state: Parameters<Handler<"drag", EventTypes["drag"]>>[0]
) => void
>;
}
export function VideoTileContainer({
item,
width,
height,
getAvatar,
audioContext,
audioDestination,
disableSpeakingIndicator,
maximised,
fullscreen,
onFullscreen,
...rest
}: Props) {
const {
isLocal,
audioMuted,
videoMuted,
localVolume,
hasAudio,
speaking,
stream,
purpose,
} = useCallFeed(item.callFeed);
const { rawDisplayName } = useRoomMemberName(item.member);
const [tileRef, mediaRef] = useSpatialMediaStream(
stream ?? null,
export const VideoTileContainer: FC<Props> = memo(
({
item,
targetWidth,
targetHeight,
getAvatar,
audioContext,
audioDestination,
localVolume,
// The feed is muted if it's local audio (because we don't want our own audio,
// but it's a hook and we can't call it conditionally so we're stuck with it)
// or if there's a maximised feed in which case we always render audio via audio
// elements because we wire it up at the video tile container level and only one
// video tile container is displayed.
isLocal || maximised
);
const {
modalState: videoTileSettingsModalState,
modalProps: videoTileSettingsModalProps,
} = useModalTriggerState();
const onOptionsPress = () => {
videoTileSettingsModalState.open();
};
disableSpeakingIndicator,
maximised,
fullscreen,
onFullscreen,
onDragRef,
...rest
}) => {
const {
isLocal,
audioMuted,
videoMuted,
localVolume,
hasAudio,
speaking,
stream,
purpose,
} = useCallFeed(item.callFeed);
const { rawDisplayName } = useRoomMemberName(item.member);
const onFullscreenCallback = useCallback(() => {
onFullscreen(item);
}, [onFullscreen, item]);
const [tileRef, mediaRef] = useSpatialMediaStream(
stream ?? null,
audioContext,
audioDestination,
localVolume,
// The feed is muted if it's local audio (because we don't want our own audio,
// but it's a hook and we can't call it conditionally so we're stuck with it)
// or if there's a maximised feed in which case we always render audio via audio
// elements because we wire it up at the video tile container level and only one
// video tile container is displayed.
isLocal || maximised
);
// Firefox doesn't respect the disablePictureInPicture attribute
// https://bugzilla.mozilla.org/show_bug.cgi?id=1611831
useDrag((state) => onDragRef?.current!(item.id, state), {
target: tileRef,
filterTaps: true,
preventScroll: true,
});
return (
<>
<VideoTile
isLocal={isLocal}
speaking={speaking && !disableSpeakingIndicator}
audioMuted={audioMuted}
videoMuted={videoMuted}
screenshare={purpose === SDPStreamMetadataPurpose.Screenshare}
name={rawDisplayName}
connectionState={item.connectionState}
ref={tileRef}
mediaRef={mediaRef}
avatar={getAvatar && getAvatar(item.member, width, height)}
onOptionsPress={onOptionsPress}
localVolume={localVolume}
hasAudio={hasAudio}
maximised={maximised}
fullscreen={fullscreen}
onFullscreen={onFullscreenCallback}
{...rest}
/>
{videoTileSettingsModalState.isOpen && !maximised && item.callFeed && (
<VideoTileSettingsModal
{...videoTileSettingsModalProps}
feed={item.callFeed}
const {
modalState: videoTileSettingsModalState,
modalProps: videoTileSettingsModalProps,
} = useModalTriggerState();
const onOptionsPress = () => {
videoTileSettingsModalState.open();
};
const onFullscreenCallback = useCallback(() => {
onFullscreen(item);
}, [onFullscreen, item]);
// Firefox doesn't respect the disablePictureInPicture attribute
// https://bugzilla.mozilla.org/show_bug.cgi?id=1611831
return (
<>
<VideoTile
isLocal={isLocal}
speaking={speaking && !disableSpeakingIndicator}
audioMuted={audioMuted}
videoMuted={videoMuted}
screenshare={purpose === SDPStreamMetadataPurpose.Screenshare}
name={rawDisplayName}
connectionState={item.connectionState}
ref={tileRef}
mediaRef={mediaRef}
avatar={
getAvatar && getAvatar(item.member, targetWidth, targetHeight)
}
onOptionsPress={onOptionsPress}
localVolume={localVolume}
hasAudio={hasAudio}
maximised={maximised}
fullscreen={fullscreen}
onFullscreen={onFullscreenCallback}
{...rest}
/>
)}
</>
);
}
{videoTileSettingsModalState.isOpen && !maximised && item.callFeed && (
<VideoTileSettingsModal
{...videoTileSettingsModalProps}
feed={item.callFeed}
/>
)}
</>
);
}
);

416
src/video-grid/model.ts Normal file
View file

@ -0,0 +1,416 @@
/*
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 TinyQueue from "tinyqueue";
import { TileDescriptor } from "./TileDescriptor";
/**
* A 1×1 cell in a grid which belongs to a tile.
*/
export interface Cell {
/**
* The item displayed on the tile.
*/
item: TileDescriptor;
/**
* Whether this cell is the origin (top left corner) of the tile.
*/
origin: boolean;
/**
* The width, in columns, of the tile.
*/
columns: number;
/**
* The height, in rows, of the tile.
*/
rows: number;
}
export interface Grid {
columns: number;
/**
* The cells of the grid, in left-to-right top-to-bottom order.
* undefined = empty.
*/
cells: (Cell | undefined)[];
}
/**
* Gets the paths that tiles should travel along in the grid to reach a
* particular destination.
* @param dest The destination index.
* @param g The 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)[] {
const destRow = row(dest, g);
const destColumn = column(dest, g);
// This is Dijkstra's algorithm
const distances = new Array<number>(dest + 1).fill(Infinity);
distances[dest] = 0;
const edges = new Array<number | null | undefined>(dest).fill(undefined);
edges[dest] = null;
const heap = new TinyQueue([dest], (i) => distances[i]);
const visit = (curr: number, via: number) => {
const viaCell = g.cells[via];
const viaLargeTile =
viaCell !== undefined && (viaCell.rows > 1 || viaCell.columns > 1);
// Since it looks nicer to have paths go around large tiles, we impose an
// increased cost for moving through them
const distanceVia = distances[via] + (viaLargeTile ? 8 : 1);
if (distanceVia < distances[curr]) {
distances[curr] = distanceVia;
edges[curr] = via;
heap.push(curr);
}
};
while (heap.length > 0) {
const via = heap.pop()!;
const viaRow = row(via, g);
const viaColumn = column(via, g);
// Visit each neighbor
if (viaRow > 0) visit(via - g.columns, via);
if (viaColumn > 0) visit(via - 1, via);
if (viaColumn < (viaRow === destRow ? destColumn : g.columns - 1))
visit(via + 1, via);
if (
viaRow < destRow - 1 ||
(viaRow === destRow - 1 && viaColumn <= destColumn)
)
visit(via + g.columns, via);
}
// The heap is empty, so we've generated all paths
return edges as (number | null)[];
}
function findLastIndex<T>(
array: T[],
predicate: (item: T) => boolean
): number | null {
for (let i = array.length - 1; i >= 0; i--) {
if (predicate(array[i])) return i;
}
return null;
}
const findLast1By1Index = (g: Grid): number | null =>
findLastIndex(g.cells, (c) => c?.rows === 1 && c?.columns === 1);
export function row(index: number, g: Grid): number {
return Math.floor(index / g.columns);
}
export function column(index: number, g: Grid): number {
return ((index % g.columns) + g.columns) % g.columns;
}
function inArea(index: number, start: number, end: number, g: Grid): boolean {
const indexColumn = column(index, g);
const indexRow = row(index, g);
return (
indexRow >= row(start, g) &&
indexRow <= row(end, g) &&
indexColumn >= column(start, g) &&
indexColumn <= column(end, g)
);
}
function* cellsInArea(
start: number,
end: number,
g: Grid
): Generator<number, void, unknown> {
const startColumn = column(start, g);
const endColumn = column(end, g);
for (
let i = start;
i <= end;
i =
column(i, g) === endColumn
? i + g.columns + startColumn - endColumn
: i + 1
)
yield i;
}
export function forEachCellInArea(
start: number,
end: number,
g: Grid,
fn: (c: Cell | undefined, i: number) => void
): void {
for (const i of cellsInArea(start, end, g)) fn(g.cells[i], i);
}
function allCellsInArea(
start: number,
end: number,
g: Grid,
fn: (c: Cell | undefined, i: number) => boolean
): boolean {
for (const i of cellsInArea(start, end, g)) {
if (!fn(g.cells[i], i)) return false;
}
return true;
}
const areaEnd = (
start: number,
columns: number,
rows: number,
g: Grid
): number => start + columns - 1 + g.columns * (rows - 1);
/**
* Gets the index of the next gap in the grid that should be backfilled by 1×1
* tiles.
*/
function getNextGap(g: Grid): number | null {
const last1By1Index = findLast1By1Index(g);
if (last1By1Index === null) return null;
for (let i = 0; i < last1By1Index; i++) {
// To make the backfilling process look natural when there are multiple
// gaps, we actually scan each row from right to left
const j =
(row(i, g) === row(last1By1Index, g)
? last1By1Index
: (row(i, g) + 1) * g.columns) -
1 -
column(i, g);
if (g.cells[j] === undefined) return j;
}
return null;
}
/**
* Backfill any gaps in the grid.
*/
export function fillGaps(g: Grid): Grid {
const result: Grid = { ...g, cells: [...g.cells] };
let gap = getNextGap(result);
if (gap !== null) {
const pathsToEnd = getPaths(findLast1By1Index(result)!, result);
do {
let filled = false;
let to = gap;
let from = pathsToEnd[gap];
// First, attempt to fill the gap by moving 1×1 tiles backwards from the
// end of the grid along a set path
while (from !== null) {
const toCell = result.cells[to];
const fromCell = result.cells[from];
// Skip over slots that are already full
if (toCell !== undefined) {
to = pathsToEnd[to]!;
// Skip over large tiles. Also, we might run into gaps along the path
// created during the filling of previous gaps. Skip over those too;
// they'll be picked up on the next iteration of the outer loop.
} else if (
fromCell === undefined ||
fromCell.rows > 1 ||
fromCell.columns > 1
) {
from = pathsToEnd[from];
} else {
result.cells[to] = result.cells[from];
result.cells[from] = undefined;
filled = true;
to = pathsToEnd[to]!;
from = pathsToEnd[from];
}
}
// In case the path approach failed, fall back to taking the very last 1×1
// tile, and just dropping it into place
if (!filled) {
const last1By1Index = findLast1By1Index(result)!;
result.cells[gap] = result.cells[last1By1Index];
result.cells[last1By1Index] = undefined;
}
gap = getNextGap(result);
} while (gap !== null);
}
// TODO: If there are any large tiles on the last row, shuffle them back
// upwards into a full row
// Shrink the array to remove trailing gaps
const finalLength =
(findLastIndex(result.cells, (c) => c !== undefined) ?? -1) + 1;
if (finalLength < result.cells.length)
result.cells = result.cells.slice(0, finalLength);
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,
})),
],
};
}
/**
* Changes the size of a tile, rearranging the grid to make space.
* @param tileId The ID of the tile to modify.
* @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);
if (from === -1) return g; // Tile removed, no change
const fromWidth = g.cells[from]!.columns;
const fromHeight = g.cells[from]!.rows;
const fromEnd = areaEnd(from, fromWidth, fromHeight, g);
// The target dimensions, which toggle between 1×1 and larger than 1×1
const [toWidth, toHeight] =
fromWidth === 1 && fromHeight === 1
? [Math.min(3, Math.max(2, g.columns - 1)), 2]
: [1, 1];
// If we're expanding the tile, we want to create enough new rows at the
// tile's target position such that every new unit of grid area created during
// the expansion can fit within the new rows.
// We do it this way, since it's easier to backfill gaps in the grid than it
// is to push colliding tiles outwards.
const newRows = Math.max(
0,
Math.ceil((toWidth * toHeight - fromWidth * fromHeight) / g.columns)
);
// This is the grid with the new rows added
const gappyGrid: Grid = {
...g,
cells: new Array(g.cells.length + newRows * g.columns),
};
// The next task is to scan for a spot to place the modified tile. Since we
// might be creating new rows at the target position, this spot can be shorter
// than the target height.
const candidateWidth = toWidth;
const candidateHeight = toHeight - newRows;
// To make the tile appear to expand outwards from its center, we're actually
// scanning for locations to put the *center* of the tile. These numbers are
// the offsets between the tile's origin and its center.
const scanColumnOffset = Math.floor((toWidth - 1) / 2);
const scanRowOffset = Math.floor((toHeight - 1) / 2);
const nextScanLocations = new Set<number>([from]);
const rows = row(g.cells.length - 1, g) + 1;
let to: number | null = null;
// The contents of a given cell are 'displaceable' if it's empty, holds a 1×1
// tile, or is part of the original tile we're trying to reposition
const displaceable = (c: Cell | undefined, i: number): boolean =>
c === undefined ||
(c.columns === 1 && c.rows === 1) ||
inArea(i, from, fromEnd, g);
// Do the scanning
for (const scanLocation of nextScanLocations) {
const start = scanLocation - scanColumnOffset - g.columns * scanRowOffset;
const end = areaEnd(start, candidateWidth, candidateHeight, g);
const startColumn = column(start, g);
const startRow = row(start, g);
const endColumn = column(end, g);
const endRow = row(end, g);
if (
start >= 0 &&
endColumn - startColumn + 1 === candidateWidth &&
allCellsInArea(start, end, g, displaceable)
) {
// This location works!
to = start;
break;
}
// Scan outwards in all directions
if (startColumn > 0) nextScanLocations.add(scanLocation - 1);
if (endColumn < g.columns - 1) nextScanLocations.add(scanLocation + 1);
if (startRow > 0) nextScanLocations.add(scanLocation - g.columns);
if (endRow < rows - 1) nextScanLocations.add(scanLocation + g.columns);
}
// If there is no space in the grid, give up
if (to === null) return g;
const toRow = row(to, g);
// Copy tiles from the original grid to the new one, with the new rows
// inserted at the target location
g.cells.forEach((c, src) => {
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) => {
gappyGrid.cells[i + offset] = c;
});
}
});
// Place the tile in its target position, making a note of the tiles being
// overwritten
const displacedTiles: Cell[] = [];
const toEnd = areaEnd(to, toWidth, toHeight, g);
forEachCellInArea(to, toEnd, gappyGrid, (c, i) => {
if (c !== undefined) displacedTiles.push(c);
gappyGrid.cells[i] = {
item: g.cells[from]!.item,
origin: i === to,
columns: toWidth,
rows: toHeight,
};
});
// Place the displaced tiles in the remaining space
for (let i = 0; displacedTiles.length > 0; i++) {
if (gappyGrid.cells[i] === undefined)
gappyGrid.cells[i] = displacedTiles.shift();
}
// Fill any gaps that remain
return fillGaps(gappyGrid);
}

View file

@ -158,8 +158,8 @@ export const useSpatialMediaStream = (
audioDestination: AudioNode,
localVolume: number,
mute = false
): [RefObject<HTMLDivElement>, RefObject<MediaElement>] => {
const tileRef = useRef<HTMLDivElement | null>(null);
): [RefObject<HTMLElement>, RefObject<MediaElement>] => {
const tileRef = useRef<HTMLElement | null>(null);
const [spatialAudio] = useSpatialAudio();
// This media stream is only used for the video - the audio goes via the audio

View file

@ -0,0 +1,283 @@
/*
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 {
appendItems,
column,
cycleTileSize,
fillGaps,
forEachCellInArea,
Grid,
row,
} from "../../src/video-grid/model";
import { TileDescriptor } from "../../src/video-grid/TileDescriptor";
/**
* Builds a grid from a string specifying the contents of each cell as a letter.
*/
function mkGrid(spec: string): Grid {
const secondNewline = spec.indexOf("\n", 1);
const columns = secondNewline === -1 ? spec.length : secondNewline - 1;
const cells = spec.match(/[a-z ]/g) ?? [];
const areas = new Set(cells);
areas.delete(" "); // Space represents an empty cell, not an area
const grid: Grid = { columns, cells: new Array(cells.length) };
for (const area of areas) {
const start = cells.indexOf(area);
const end = cells.lastIndexOf(area);
const rows = row(end, grid) - row(start, grid) + 1;
const columns = column(end, grid) - column(start, grid) + 1;
forEachCellInArea(start, end, grid, (_c, i) => {
grid.cells[i] = {
item: { id: area } as unknown as TileDescriptor,
origin: i === start,
rows,
columns,
};
});
}
return grid;
}
/**
* Turns a grid into a string showing the contents of each cell as a letter.
*/
function showGrid(g: Grid): string {
let result = "\n";
g.cells.forEach((c, i) => {
if (i > 0 && i % g.columns == 0) result += "\n";
result += c?.item.id ?? " ";
});
return result;
}
function testFillGaps(title: string, input: string, output: string): void {
test(`fillGaps ${title}`, () => {
expect(showGrid(fillGaps(mkGrid(input)))).toBe(output);
});
}
testFillGaps(
"does nothing on an empty grid",
`
`,
`
`
);
testFillGaps(
"does nothing if there are no gaps",
`
ab
cd
ef`,
`
ab
cd
ef`
);
testFillGaps(
"fills a gap",
`
a b
cde
f`,
`
cab
fde`
);
testFillGaps(
"fills multiple gaps",
`
a bc
defgh
ijkl
mno`,
`
aebch
difgl
monjk`
);
testFillGaps(
"fills a big gap",
`
abcd
e f
g h
ijkl`,
`
abcd
elhf
gkji`
);
testFillGaps(
"only moves 1×1 tiles",
`
aa
bc`,
`
bc
aa`
);
testFillGaps(
"prefers moving around large tiles",
`
a bc
ddde
dddf
ghij
k`,
`
abce
dddf
dddj
kghi`
);
testFillGaps(
"moves through large tiles if necessary",
`
a bc
dddd
efgh
i`,
`
afbc
dddd
iegh`
);
function testCycleTileSize(
title: string,
tileId: string,
input: string,
output: string
): void {
test(`cycleTileSize ${title}`, () => {
expect(showGrid(cycleTileSize(tileId, mkGrid(input)))).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",
`
abc
def
ghi`,
`
acc
bcc
def
ghi`
);
testCycleTileSize(
"expands a tile to 3×3 in a 4 column layout",
"g",
`
abcd
efgh`,
`
abcd
eggg
fggg
h`
);
testCycleTileSize(
"restores a tile to 1×1",
"b",
`
abbc
dbbe
fghi
jk`,
`
abhc
djge
fik`
);
testCycleTileSize(
"expands a tile even in a crowded grid",
"c",
`
abb
cbb
dde
ddf
ghi
klm`,
`
abb
gbb
dde
ddf
cci
cch
klm`
);
testCycleTileSize(
"does nothing if the tile has no room to expand",
"c",
`
abb
cbb
dde
ddf`,
`
abb
cbb
dde
ddf`
);
test("appendItems appends 1×1 tiles", () => {
const grid1 = `
aab
aac
d`;
const grid2 = `
aab
aac
def`;
const newItems = ["e", "f"].map(
(i) => ({ id: i } as unknown as TileDescriptor)
);
expect(showGrid(appendItems(newItems, mkGrid(grid1)))).toBe(grid2);
});

View file

@ -13717,6 +13717,11 @@ tiny-warning@^1.0.0, tiny-warning@^1.0.3:
resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754"
integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
tinyqueue@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/tinyqueue/-/tinyqueue-2.0.3.tgz#64d8492ebf39e7801d7bd34062e29b45b2035f08"
integrity sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==
tmpl@1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc"