Make drag & drop performant in all layouts

While also giving the logic for drag & drop of a floating tile a common home in the view model layer.
This commit is contained in:
Robin
2026-09-28 19:53:30 +02:00
parent 99256fa01c
commit f2f49315eb
19 changed files with 290 additions and 264 deletions
+1 -6
View File
@@ -6,12 +6,11 @@ Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it, vi } from "vitest";
import { BehaviorSubject } from "rxjs";
import { testScope, mockMuteStates, mockMediaDevices } from "../utils/test";
import { constant } from "../state/Behavior";
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
import type { Alignment, Layout } from "../state/layout-types";
import type { Layout } from "../state/layout-types";
import type { SpotlightTileViewModel } from "../state/TileViewModel";
import type { DeviceLabel } from "../state/MediaDevices";
import { createCallFooterViewModel } from "./CallFooterViewModel";
@@ -65,10 +64,6 @@ function buildMinimalCallViewModel(layout: Layout): CallViewModel {
const gridLayout: Layout = {
type: "grid",
grid: [],
spotlightAlignment$: new BehaviorSubject<Alignment>({
inline: "end",
block: "end",
}),
setVisibleTiles: (_: number) => {},
};
+16 -48
View File
@@ -13,7 +13,7 @@ import { arrangeTiles, type Bounds } from "./CallLayout";
import { makeGridLayout } from "./GridLayout";
import { makeSpotlightLandscapeLayout } from "./SpotlightLandscapeLayout";
import { makeSpotlightPortraitLayout } from "./SpotlightPortraitLayout";
import { type DragCallback, type LayoutProps } from "./Grid";
import { type LayoutProps } from "./Grid";
import { constant } from "../state/Behavior";
import {
type Alignment,
@@ -53,33 +53,6 @@ describe("grid layout", () => {
`${Math.floor(arrangeTiles(400, 300, 3).tileWidth)}px`,
);
});
it("moves the spotlight only when a drag reaches another corner", () => {
const model = gridModel();
const { fixed: Fixed } = makeGridLayout({ minBounds$: bounds });
render(<Fixed model={model} Slot={Slot} />);
const alignments: Alignment[] = [];
model.spotlightAlignment$.subscribe((a) => alignments.push(a));
const drag = drags.get("spotlight")!;
// Still in the bottom right corner
act(() => drag({ x: 0, y: 0, xRatio: 0.9, yRatio: 0.9 }));
// Over to the bottom left
act(() => drag({ x: 0, y: 0, xRatio: 0.1, yRatio: 0.9 }));
// And a little further, still bottom left
act(() => drag({ x: 0, y: 0, xRatio: 0.2, yRatio: 0.8 }));
// Up to the top left
act(() => drag({ x: 0, y: 0, xRatio: 0.2, yRatio: 0.1 }));
expect(alignments).toEqual([
{ inline: "end", block: "end" },
{ inline: "start", block: "end" },
{ inline: "start", block: "start" },
]);
const slot = screen.getByTestId("slot-spotlight");
expect(slot.getAttribute("data-inline-alignment")).toBe("start");
expect(slot.getAttribute("data-block-alignment")).toBe("start");
});
});
describe("spotlight landscape layout", () => {
@@ -123,11 +96,15 @@ const bounds = constant<Bounds>({ width: 800, height: 600 });
// The layouts only pass tile view models through to their slots
const media = {} as UserMediaViewModel;
const spotlight = new SpotlightTileViewModel(
constant([]),
constant(false),
constant("solid"),
);
const spotlight = {
vm: new SpotlightTileViewModel(
constant([]),
constant(false),
constant("solid"),
),
alignment$: constant<Alignment>({ inline: "end", block: "end" }),
onDrag: () => {},
};
const tiles = (): GridTileViewModel[] =>
Array.from({ length: 3 }, () => new GridTileViewModel(constant(media)));
@@ -136,10 +113,6 @@ function gridModel(): GridLayout {
type: "grid",
spotlight,
grid: tiles(),
spotlightAlignment$: new BehaviorSubject<Alignment>({
inline: "end",
block: "end",
}),
setVisibleTiles: () => {},
};
}
@@ -162,22 +135,17 @@ function portraitModel(): SpotlightPortraitLayout {
};
}
// Slots record their drag callback so a test can drag them, and show the id
// of the tile they hold
const drags = new Map<string, DragCallback | undefined>();
// Slots show the id of the tile they hold
const Slot: LayoutProps<unknown, TileViewModel, HTMLDivElement>["Slot"] = ({
id,
model,
onDrag,
...props
}) => {
drags.set(id, onDrag);
return (
<div data-testid={`slot-${id}`} {...props}>
{model instanceof GridTileViewModel ? model.id : "spotlight"}
</div>
);
};
}) => (
<div data-testid={`slot-${id}`} {...props}>
{model instanceof GridTileViewModel ? model.id : "spotlight"}
</div>
);
function slotIds(): string[] {
return screen
+5 -26
View File
@@ -40,6 +40,7 @@ import { useMergedRefs } from "../useMergedRefs";
import { TileWrapper } from "./TileWrapper";
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
import { useInitial } from "../useInitial";
import { type DragCallback } from "../state/layout-types";
const MAX_ANIMATED_TILES = 50; // Capped for performance reasons
@@ -83,7 +84,10 @@ interface DragState {
cursorY: number;
}
interface SlotProps<Model> extends Omit<ComponentProps<"div">, "onDrag"> {
export interface SlotProps<Model> extends Omit<
ComponentProps<"div">,
"onDrag"
> {
id: string;
model: Model;
onDrag?: DragCallback;
@@ -181,31 +185,6 @@ export interface TileProps<Model, R extends HTMLElement> {
model: Model;
}
interface Drag {
/**
* The X coordinate of the dragged tile in grid space.
*/
x: number;
/**
* The Y coordinate of the dragged tile in grid space.
*/
y: number;
/**
* The X coordinate of the dragged tile, as a scalar of the grid width.
*/
xRatio: number;
/**
* The Y coordinate of the dragged tile, as a scalar of the grid height.
*/
yRatio: number;
/**
* Whether this event ends the drag gesture.
*/
endOfGesture: boolean
}
export type DragCallback = (drag: Drag) => void;
interface LayoutMemoProps<
LayoutModel,
TileModel,
+30 -25
View File
@@ -8,15 +8,21 @@ Please see LICENSE in the repository root for full details.
import {
type CSSProperties,
type ReactNode,
useCallback,
useMemo,
type FC,
type ComponentType,
} from "react";
import { type GridLayout as GridLayoutModel } from "../state/layout-types.ts";
import styles from "./GridLayout.module.css";
import { type CallLayout, arrangeTiles } from "./CallLayout";
import { type DragCallback, useUpdateLayout, useVisibleTiles } from "./Grid";
import { type SlotProps, useUpdateLayout, useVisibleTiles } from "./Grid";
import { useBehavior } from "../useBehavior";
import {
type SpotlightTileViewModel,
type TileViewModel,
} from "../state/TileViewModel.ts";
import { type FloatingTile } from "../state/FloatingTile.ts";
interface GridCSSProperties extends CSSProperties {
"--gap": string;
@@ -24,6 +30,27 @@ interface GridCSSProperties extends CSSProperties {
"--height": string;
}
interface SpotlightSlotProps {
model: FloatingTile & { vm: SpotlightTileViewModel };
Slot: ComponentType<SlotProps<TileViewModel>>;
}
const SpotlightSlot: FC<SpotlightSlotProps> = ({ model, Slot }) => {
useUpdateLayout();
const alignment = useBehavior(model.alignment$);
return (
<Slot
className={styles.slot}
id="spotlight"
model={model.vm}
onDrag={model.onDrag}
data-block-alignment={alignment.block}
data-inline-alignment={alignment.inline}
/>
);
};
/**
* An implementation of the "grid" layout, in which all participants are shown
* together in a scrolling grid.
@@ -37,32 +64,10 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
// lives
fixed: function GridLayoutFixed({ ref, model, Slot }): ReactNode {
useUpdateLayout();
const alignment = useBehavior(model.spotlightAlignment$);
const onDragSpotlight: DragCallback = useCallback(
({ xRatio, yRatio }) => {
const block = yRatio < 0.5 ? "start" : "end";
const inline = xRatio < 0.5 ? "start" : "end";
// A drag reports the same alignment on every move; only a change is
// worth a re-render
const current = model.spotlightAlignment$.value;
if (current.block !== block || current.inline !== inline)
model.spotlightAlignment$.next({ block, inline });
},
[model.spotlightAlignment$],
);
return (
<div ref={ref} className={styles.fixed}>
{model.spotlight && (
<Slot
className={styles.slot}
id="spotlight"
model={model.spotlight}
onDrag={onDragSpotlight}
data-block-alignment={alignment.block}
data-inline-alignment={alignment.inline}
/>
<SpotlightSlot model={model.spotlight} Slot={Slot} />
)}
</div>
);
+6 -15
View File
@@ -6,13 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type ReactNode, useCallback, useMemo } from "react";
import { type ReactNode, useMemo } from "react";
import classNames from "classnames";
import { type OneOnOneDesktopLayout as OneOnOneDesktopLayoutModel } from "../state/layout-types.ts";
import { type CallLayout, arrangeTiles } from "./CallLayout";
import styles from "./OneOnOneDesktopLayout.module.css";
import { type DragCallback, useUpdateLayout } from "./Grid";
import { useUpdateLayout } from "./Grid";
import { useBehavior } from "../useBehavior";
/**
@@ -37,21 +37,12 @@ export const makeOneOnOneDesktopLayout: CallLayout<
}): ReactNode {
useUpdateLayout();
const { width, height } = useBehavior(minBounds$);
const pipAlignment = useBehavior(model.pipAlignment$);
const pipAlignment = useBehavior(model.pip.alignment$);
const { tileWidth, tileHeight } = useMemo(
() => arrangeTiles(width, height, 1),
[width, height],
);
const onDragLocalTile: DragCallback = useCallback(
({ xRatio, yRatio }) =>
model.pipAlignment$.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
[model.pipAlignment$],
);
return (
<div ref={ref} className={styles.layer}>
<Slot
@@ -62,9 +53,9 @@ export const makeOneOnOneDesktopLayout: CallLayout<
>
<Slot
className={classNames(styles.slot, styles.local)}
id={model.pip.id}
model={model.pip}
onDrag={onDragLocalTile}
id={model.pip.vm.id}
model={model.pip.vm}
onDrag={model.pip.onDrag}
data-block-alignment={pipAlignment.block}
data-inline-alignment={pipAlignment.inline}
/>
+32 -24
View File
@@ -6,14 +6,43 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type ReactNode, useCallback } from "react";
import { type ComponentType, type FC, type ReactNode } from "react";
import classNames from "classnames";
import { type OneOnOneMobileLayout as OneOnOneMobileLayoutModel } from "../state/layout-types.ts";
import { type CallLayout } from "./CallLayout";
import styles from "./OneOnOneMobileLayout.module.css";
import { type DragCallback, useUpdateLayout } from "./Grid";
import { type SlotProps, useUpdateLayout } from "./Grid";
import { useBehavior } from "../useBehavior";
import { type FloatingTile } from "../state/FloatingTile.ts";
import {
type GridTileViewModel,
type TileViewModel,
} from "../state/TileViewModel.ts";
import { type Behavior } from "../state/Behavior.ts";
interface PipSlotProps {
model: FloatingTile & { vm: GridTileViewModel; size$: Behavior<"sm" | "lg"> };
Slot: ComponentType<SlotProps<TileViewModel>>;
}
const PipSlot: FC<PipSlotProps> = ({ model, Slot }) => {
useUpdateLayout();
const size = useBehavior(model.size$);
const alignment = useBehavior(model.alignment$);
return (
<Slot
className={classNames(styles.pip)}
id={model.vm.id}
model={model.vm}
onDrag={model.onDrag}
data-size={size}
data-block-alignment={alignment.block}
data-inline-alignment={alignment.inline}
/>
);
};
/**
* An implementation of the "one-on-one" layout for mobile platforms, in which
@@ -44,30 +73,9 @@ export const makeOneOnOneMobileLayout: CallLayout<
Slot,
}): ReactNode {
useUpdateLayout();
const pipSize = useBehavior(model.pipSize$);
const pipAlignment = useBehavior(model.pipAlignment$);
const onDragLocalTile: DragCallback = useCallback(
({ xRatio, yRatio }) =>
model.pipAlignment$.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
[model.pipAlignment$],
);
return (
<div ref={ref} className={styles.layer}>
{model.pip && (
<Slot
className={classNames(styles.pip)}
id={model.pip.id}
model={model.pip}
onDrag={onDragLocalTile}
data-size={pipSize}
data-block-alignment={pipAlignment.block}
data-inline-alignment={pipAlignment.inline}
/>
)}
{model.pip && <PipSlot model={model.pip} Slot={Slot} />}
</div>
);
},
+29 -24
View File
@@ -5,13 +5,39 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type ReactNode, useCallback } from "react";
import { type ReactNode, type ComponentType, type FC } from "react";
import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/layout-types.ts";
import { type CallLayout } from "./CallLayout";
import { type DragCallback, useUpdateLayout } from "./Grid";
import { type SlotProps, useUpdateLayout } from "./Grid";
import styles from "./SpotlightExpandedLayout.module.css";
import { useBehavior } from "../useBehavior";
import { type FloatingTile } from "../state/FloatingTile.ts";
import {
type GridTileViewModel,
type TileViewModel,
} from "../state/TileViewModel.ts";
interface PipSlotProps {
model: FloatingTile & { vm: GridTileViewModel };
Slot: ComponentType<SlotProps<TileViewModel>>;
}
const PipSlot: FC<PipSlotProps> = ({ model, Slot }) => {
useUpdateLayout();
const alignment = useBehavior(model.alignment$);
return (
<Slot
className={styles.pip}
id={model.vm.id}
model={model.vm}
onDrag={model.onDrag}
data-block-alignment={alignment.block}
data-inline-alignment={alignment.inline}
/>
);
};
/**
* An implementation of the "expanded spotlight" layout, in which the spotlight
@@ -28,7 +54,6 @@ export const makeSpotlightExpandedLayout: CallLayout<
Slot,
}): ReactNode {
useUpdateLayout();
return (
<div ref={ref} className={styles.layer}>
<Slot
@@ -46,29 +71,9 @@ export const makeSpotlightExpandedLayout: CallLayout<
Slot,
}): ReactNode {
useUpdateLayout();
const pipAlignment = useBehavior(model.pipAlignment$);
const onDragPip: DragCallback = useCallback(
({ xRatio, yRatio }) =>
model.pipAlignment$.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
[model.pipAlignment$],
);
return (
<div ref={ref} className={styles.layer}>
{model.pip && (
<Slot
className={styles.pip}
id={model.pip.id}
model={model.pip}
onDrag={onDragPip}
data-block-alignment={pipAlignment.block}
data-inline-alignment={pipAlignment.inline}
/>
)}
{model.pip && <PipSlot model={model.pip} Slot={Slot} />}
</div>
);
},
+2 -2
View File
@@ -40,7 +40,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
<Slot
className={styles.slot}
id="spotlight"
model={model.spotlight}
model={model.spotlight.vm}
/>
</div>
<div className={styles.grid} />
@@ -59,7 +59,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
return (
<div ref={ref} className={styles.layer}>
<SpotlightSlot media$={model.spotlight.media$} />
<SpotlightSlot media$={model.spotlight.vm.media$} />
<div className={styles.grid}>
{model.grid.map((m) => (
<Slot key={m.id} className={styles.slot} id={m.id} model={m} />
+2 -2
View File
@@ -43,7 +43,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
<Slot
className={styles.slot}
id="spotlight"
model={model.spotlight}
model={model.spotlight.vm}
/>
</div>
</div>
@@ -65,7 +65,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
width,
model.grid.length,
);
const withIndicators = useBehavior(model.spotlight.media$).length > 1;
const withIndicators = useBehavior(model.spotlight.vm.media$).length > 1;
return (
<div
+10 -10
View File
@@ -176,7 +176,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
case "grid":
return combineLatest(
[
l.spotlight?.media$ ?? constant(undefined),
l.spotlight?.vm.media$ ?? constant(undefined),
...l.grid.map((vm) => vm.media$),
],
(spotlight, ...grid) => ({
@@ -188,7 +188,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
case "spotlight-landscape":
case "spotlight-portrait":
return combineLatest(
[l.spotlight.media$, ...l.grid.map((vm) => vm.media$)],
[l.spotlight.vm.media$, ...l.grid.map((vm) => vm.media$)],
(spotlight, ...grid) => ({
type: l.type,
spotlight: spotlight.map((vm) => vm.id),
@@ -197,7 +197,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
);
case "spotlight-expanded":
return combineLatest(
[l.spotlight.media$, l.pip?.media$ ?? constant(undefined)],
[l.spotlight.media$, l.pip?.vm.media$ ?? constant(undefined)],
(spotlight, pip) => ({
type: l.type,
spotlight: spotlight.map((vm) => vm.id),
@@ -206,7 +206,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
);
case "one-on-one-desktop":
return combineLatest(
[l.spotlight.media$, l.pip.media$],
[l.spotlight.media$, l.pip.vm.media$],
(spotlight, pip) => ({
type: l.type,
spotlight: spotlight.id,
@@ -217,8 +217,8 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
return combineLatest(
[
l.spotlight.media$,
l.pip?.media$ ?? constant(undefined),
l.pipSize$,
l.pip?.vm.media$ ?? constant(undefined),
l.pip?.size$ ?? constant(undefined),
],
(spotlight, pip, pipSize) => ({
type: l.type,
@@ -480,7 +480,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
type: "one-on-one-mobile",
spotlight: [`${aliceId}:0`],
pip: undefined,
pipSize: "lg",
pipSize: undefined,
},
b: {
type: "one-on-one-mobile",
@@ -518,7 +518,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
type: "one-on-one-mobile",
spotlight: [`${aliceId}:0`],
pip: undefined,
pipSize: "lg",
pipSize: undefined,
},
});
// It wouldn't be clear whether Alice or Bob is the remote video tile,
@@ -566,7 +566,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
type: "one-on-one-mobile",
spotlight: [`${aliceId}:0`],
pip: undefined,
pipSize: "sm",
pipSize: undefined,
},
b: {
type: "spotlight-landscape",
@@ -765,7 +765,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
type: "one-on-one-mobile",
spotlight: [`${aliceId}:0`],
pip: undefined,
pipSize: "lg",
pipSize: undefined,
},
b: {
// In a larger window, expect the one-on-one desktop layout
+20 -25
View File
@@ -93,7 +93,6 @@ import { type ProcessorState } from "../../livekit/TrackProcessorContext";
import { type HostBridge, nullHostBridge } from "../../HostBridge";
import {
layoutShallowEquals,
type Alignment,
type GridLayoutMedia,
type Layout,
type LayoutMedia,
@@ -163,6 +162,7 @@ import {
type RingingMediaViewModel,
} from "../media/RingingMediaViewModel.ts";
import { type GridTileViewModel } from "../TileViewModel.ts";
import { createFloatingTile } from "../FloatingTile.ts";
//TODO
// Larger rename
@@ -1548,32 +1548,28 @@ export function createCallViewModel$(
);
/**
* The alignment of the floating spotlight tile, if present.
* State of the floating spotlight tile, if present.
*/
const spotlightAlignment$ = new BehaviorSubject<Alignment>({
const floatingSpotlightTile = createFloatingTile(scope, {
inline: "end",
block: "end",
});
/**
* The size of the small picture-in-picture tile, if present, when in portrait.
* State of the small picture-in-picture tile, if present, when in landscape.
*/
const portraitPipSize$ = scope.behavior(
showFooter$.pipe(map((showFooter) => (showFooter ? "lg" : "sm"))),
);
/**
* The alignment of the small picture-in-picture tile, if present, when in portrait.
*/
const portraitPipAlignment$ = new BehaviorSubject<Alignment>({
inline: "end",
block: "end",
});
/**
* The alignment of the small picture-in-picture tile, if present, when in landscape.
*/
const landscapePipAlignment$ = new BehaviorSubject<Alignment>({
const landscapePipTile = createFloatingTile(scope, {
inline: "end",
block: "start",
});
/**
* State of the small picture-in-picture tile, if present, when in portrait.
*/
const portraitPipTile = {
...createFloatingTile(scope, { inline: "end", block: "end" }),
size$: scope.behavior(
showFooter$.pipe(map((showFooter) => (showFooter ? "lg" : "sm"))),
),
};
// There is a cyclical dependency here: the layout algorithms want to know
// which tiles are on screen, but to know which tiles are on screen we have to
@@ -1603,7 +1599,7 @@ export function createCallViewModel$(
case "spotlight-portrait":
[layout, newTiles] = gridLikeLayout(
media,
spotlightAlignment$,
floatingSpotlightTile,
visibleTiles,
setVisibleTiles,
prevTiles,
@@ -1613,26 +1609,25 @@ export function createCallViewModel$(
case "spotlight-expanded":
[layout, newTiles] = spotlightExpandedLayout(
media,
landscapePipAlignment$,
landscapePipTile,
prevTiles,
);
break;
case "one-on-one-desktop":
[layout, newTiles] = oneOnOneDesktopLayout(
media,
landscapePipAlignment$,
landscapePipTile,
prevTiles,
);
pip = layout.pip;
pip = layout.pip.vm;
break;
case "one-on-one-mobile":
[layout, newTiles] = oneOnOneMobileLayout(
media,
portraitPipSize$,
portraitPipAlignment$,
portraitPipTile,
prevTiles,
);
pip = layout.pip;
pip = layout.pip?.vm;
break;
case "pip":
[layout, newTiles] = pipLayout(media, prevTiles);
+38
View File
@@ -0,0 +1,38 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { test } from "vitest";
import { withTestScheduler } from "../utils/test";
import { createFloatingTile } from "./FloatingTile";
test("FloatingTile changes alignment on drag", () =>
withTestScheduler(({ scope, expectObservable, schedule }) => {
// Tile starts in the top left
const { alignment$, onDrag } = createFloatingTile(scope, {
inline: "start",
block: "start",
});
// Drag to all four corners, and then repeatedly into the top left.
schedule("-↗↘↙↖↖", {
"↗": () =>
onDrag({ x: 100, y: 0, xRatio: 1, yRatio: 0, endOfGesture: false }),
"↘": () =>
onDrag({ x: 100, y: 100, xRatio: 1, yRatio: 1, endOfGesture: false }),
"↙": () =>
onDrag({ x: 0, y: 100, xRatio: 0, yRatio: 1, endOfGesture: false }),
"↖": () =>
onDrag({ x: 0, y: 0, xRatio: 0, yRatio: 0, endOfGesture: false }),
});
// Alignment should change accordingly. Notably, the alignment should *not*
// update when repeatedly dragged into the same corner, for performance.
expectObservable(alignment$).toBe("↖↗↘↙↖-", {
"↗": { inline: "end", block: "start" },
"↘": { inline: "end", block: "end" },
"↙": { inline: "start", block: "end" },
"↖": { inline: "start", block: "start" },
});
}));
+43
View File
@@ -0,0 +1,43 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { distinctUntilChanged, map, startWith, Subject } from "rxjs";
import { type Behavior } from "./Behavior";
import { type DragCallback, type Alignment, type Drag } from "./layout-types";
import { type ObservableScope } from "./ObservableScope";
/**
* Represents a floating tile which may be dragged to different corners of the
* call interface.
*/
export interface FloatingTile {
alignment$: Behavior<Alignment>;
onDrag: DragCallback;
}
export function createFloatingTile(
scope: ObservableScope,
defaultAlignment: Alignment,
): FloatingTile {
const dragEvents$ = new Subject<Drag>();
return {
alignment$: scope.behavior(
dragEvents$.pipe(
map<Drag, Alignment>(({ xRatio, yRatio }) => ({
inline: xRatio < 0.5 ? "start" : "end",
block: yRatio < 0.5 ? "start" : "end",
})),
startWith(defaultAlignment),
distinctUntilChanged(
(a1, a2) => a1.inline === a2.inline && a1.block === a2.block,
),
),
),
onDrag: (drag) => dragEvents$.next(drag),
};
}
+7 -10
View File
@@ -5,14 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type BehaviorSubject } from "rxjs";
import {
type Alignment,
type Layout,
type LayoutMedia,
} from "./layout-types.ts";
import { type Layout, type LayoutMedia } from "./layout-types.ts";
import { type TileStore } from "./TileStore";
import { type FloatingTile } from "./FloatingTile.ts";
export type GridLikeLayoutType =
| "grid"
@@ -25,7 +20,7 @@ export type GridLikeLayoutType =
*/
export function gridLikeLayout(
media: LayoutMedia & { type: GridLikeLayoutType },
spotlightAlignment$: BehaviorSubject<Alignment>,
floatingSpotlightTile: FloatingTile,
visibleTiles: number,
setVisibleTiles: (value: number) => void,
prevTiles: TileStore,
@@ -42,9 +37,11 @@ export function gridLikeLayout(
return [
{
type: media.type,
spotlight: tiles.spotlightTile,
spotlight: tiles.spotlightTile && {
vm: tiles.spotlightTile,
...floatingSpotlightTile,
},
grid: tiles.gridTiles,
spotlightAlignment$,
setVisibleTiles,
} as Layout & { type: GridLikeLayoutType },
tiles,
+3 -6
View File
@@ -6,21 +6,19 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type BehaviorSubject } from "rxjs";
import {
type Alignment,
type OneOnOneDesktopLayout,
type OneOnOneDesktopLayoutMedia,
} from "./layout-types";
import { type TileStore } from "./TileStore";
import { type FloatingTile } from "./FloatingTile";
/**
* Produces a one-on-one desktop layout with the given media.
*/
export function oneOnOneDesktopLayout(
media: OneOnOneDesktopLayoutMedia,
pipAlignment$: BehaviorSubject<Alignment>,
pipTile: FloatingTile,
prevTiles: TileStore,
): [OneOnOneDesktopLayout, TileStore] {
const update = prevTiles.from(2);
@@ -32,8 +30,7 @@ export function oneOnOneDesktopLayout(
{
type: media.type,
spotlight: tiles.gridTilesByMedia.get(media.spotlight)!,
pip: tiles.gridTilesByMedia.get(media.pip)!,
pipAlignment$,
pip: { vm: tiles.gridTilesByMedia.get(media.pip)!, ...pipTile },
},
tiles,
];
+4 -8
View File
@@ -6,23 +6,20 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type BehaviorSubject } from "rxjs";
import {
type Alignment,
type OneOnOneMobileLayout,
type OneOnOneMobileLayoutMedia,
} from "./layout-types";
import { type TileStore } from "./TileStore";
import { type Behavior } from "./Behavior";
import { type FloatingTile } from "./FloatingTile";
/**
* Produces a one-on-one mobile layout with the given media.
*/
export function oneOnOneMobileLayout(
media: OneOnOneMobileLayoutMedia,
pipSize$: Behavior<"sm" | "lg">,
pipAlignment$: BehaviorSubject<Alignment>,
pipTile: FloatingTile & { size$: Behavior<"sm" | "lg"> },
prevTiles: TileStore,
): [OneOnOneMobileLayout, TileStore] {
const update = prevTiles.from(media.pip === undefined ? 0 : 1);
@@ -30,13 +27,12 @@ export function oneOnOneMobileLayout(
if (media.pip !== undefined) update.registerGridTile(media.pip);
const tiles = update.build();
const pipVm = media.pip && tiles.gridTilesByMedia.get(media.pip);
return [
{
type: media.type,
spotlight: tiles.spotlightTile!,
pip: media.pip && tiles.gridTilesByMedia.get(media.pip),
pipSize$,
pipAlignment$,
pip: pipVm && { vm: pipVm, ...pipTile },
},
tiles,
];
+4 -6
View File
@@ -5,21 +5,19 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type BehaviorSubject } from "rxjs";
import {
type Alignment,
type SpotlightExpandedLayout,
type SpotlightExpandedLayoutMedia,
} from "./layout-types";
import { type TileStore } from "./TileStore";
import { type FloatingTile } from "./FloatingTile";
/**
* Produces an expanded spotlight layout with the given media.
*/
export function spotlightExpandedLayout(
media: SpotlightExpandedLayoutMedia,
pipAlignment$: BehaviorSubject<Alignment>,
pipTile: FloatingTile,
prevTiles: TileStore,
): [SpotlightExpandedLayout, TileStore] {
const update = prevTiles.from(1);
@@ -27,12 +25,12 @@ export function spotlightExpandedLayout(
if (media.pip !== undefined) update.registerPipTile(media.pip);
const tiles = update.build();
const pipVm = tiles.gridTiles.at(0);
return [
{
type: media.type,
spotlight: tiles.spotlightTile!,
pip: tiles.gridTiles.at(0),
pipAlignment$,
pip: pipVm && { vm: pipVm, ...pipTile },
},
tiles,
];
+6 -14
View File
@@ -6,33 +6,25 @@ Please see LICENSE in the repository root for full details.
*/
import { test, expect } from "vitest";
import {
type Alignment,
layoutShallowEquals,
type Layout,
} from "./layout-types";
import { layoutShallowEquals, type Layout } from "./layout-types";
import {
type SpotlightTileViewModel,
type GridTileViewModel,
} from "./TileViewModel";
import { BehaviorSubject } from "rxjs";
import { type FloatingTile } from "./FloatingTile";
const spotlightTile = {} as unknown as SpotlightTileViewModel;
const gridTile = {} as unknown as GridTileViewModel;
const pipAlignment$ = new BehaviorSubject<Alignment>({
inline: "end",
block: "end",
});
const pipTile = {} as unknown as FloatingTile & { vm: GridTileViewModel };
const spotlightExpanded: Layout = {
type: "spotlight-expanded",
spotlight: spotlightTile,
pipAlignment$,
};
const spotlightPortrait: Layout = {
type: "spotlight-portrait",
spotlight: spotlightTile,
spotlight: { vm: spotlightTile },
grid: [gridTile],
setVisibleTiles: () => {},
};
@@ -46,12 +38,12 @@ test("layoutShallowEquals detects a missing key", () => {
expect(
layoutShallowEquals(spotlightExpanded, {
...spotlightExpanded,
pip: gridTile,
pip: pipTile,
}),
).toBe(false);
expect(
layoutShallowEquals(
{ ...spotlightExpanded, pip: gridTile },
{ ...spotlightExpanded, pip: pipTile },
spotlightExpanded,
),
).toBe(false);
+32 -13
View File
@@ -5,8 +5,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type BehaviorSubject } from "rxjs";
import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel.ts";
import { type MediaViewModel } from "./media/MediaViewModel.ts";
import { type RingingMediaViewModel } from "./media/RingingMediaViewModel.ts";
@@ -17,6 +15,7 @@ import {
} from "./TileViewModel.ts";
import { type Behavior } from "./Behavior.ts";
import { shallowEquals as arrayShallowEquals } from "../utils/array.ts";
import { type FloatingTile } from "./FloatingTile.ts";
export interface GridLayoutMedia {
type: "grid";
@@ -80,24 +79,48 @@ export interface Alignment {
block: "start" | "end";
}
export interface Drag {
/**
* The X offset of the dragged tile relative to its container.
*/
x: number;
/**
* The Y offset of the dragged tile relative to its container.
*/
y: number;
/**
* The X coordinate of the dragged tile, as a scalar of the container's width.
*/
xRatio: number;
/**
* The Y coordinate of the dragged tile, as a scalar of the container's height.
*/
yRatio: number;
/**
* Whether this event ends the drag gesture.
*/
endOfGesture: boolean;
}
export type DragCallback = (drag: Drag) => void;
export interface GridLayout {
type: "grid";
spotlight?: SpotlightTileViewModel;
spotlight?: FloatingTile & { vm: SpotlightTileViewModel };
grid: GridTileViewModel[];
spotlightAlignment$: BehaviorSubject<Alignment>;
setVisibleTiles: (value: number) => void;
}
export interface SpotlightLandscapeLayout {
type: "spotlight-landscape";
spotlight: SpotlightTileViewModel;
spotlight: { vm: SpotlightTileViewModel };
grid: GridTileViewModel[];
setVisibleTiles: (value: number) => void;
}
export interface SpotlightPortraitLayout {
type: "spotlight-portrait";
spotlight: SpotlightTileViewModel;
spotlight: { vm: SpotlightTileViewModel };
grid: GridTileViewModel[];
setVisibleTiles: (value: number) => void;
}
@@ -105,23 +128,19 @@ export interface SpotlightPortraitLayout {
export interface SpotlightExpandedLayout {
type: "spotlight-expanded";
spotlight: SpotlightTileViewModel;
pip?: GridTileViewModel;
pipAlignment$: BehaviorSubject<Alignment>;
pip?: FloatingTile & { vm: GridTileViewModel };
}
export interface OneOnOneDesktopLayout {
type: "one-on-one-desktop";
spotlight: GridTileViewModel;
pip: GridTileViewModel;
pipAlignment$: BehaviorSubject<Alignment>;
pip: FloatingTile & { vm: GridTileViewModel };
}
export interface OneOnOneMobileLayout {
type: "one-on-one-mobile";
spotlight: SpotlightTileViewModel;
pip?: GridTileViewModel;
pipSize$: Behavior<"sm" | "lg">;
pipAlignment$: BehaviorSubject<Alignment>;
pip?: FloatingTile & { vm: GridTileViewModel; size$: Behavior<"sm" | "lg"> };
}
export interface PipLayout {