From f2f49315eb0bd048ca5fea9c14f14c6b4279a79e Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 28 Sep 2026 19:36:46 +0200 Subject: [PATCH] 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. --- src/components/CallFooterViewModel.test.ts | 7 +- src/grid/CallLayout.test.tsx | 64 +++++-------------- src/grid/Grid.tsx | 31 ++------- src/grid/GridLayout.tsx | 55 ++++++++-------- src/grid/OneOnOneDesktopLayout.tsx | 21 ++---- src/grid/OneOnOneMobileLayout.tsx | 56 +++++++++------- src/grid/SpotlightExpandedLayout.tsx | 53 ++++++++------- src/grid/SpotlightLandscapeLayout.tsx | 4 +- src/grid/SpotlightPortraitLayout.tsx | 4 +- src/state/CallViewModel/CallViewModel.test.ts | 20 +++--- src/state/CallViewModel/CallViewModel.ts | 45 ++++++------- src/state/FloatingTile.test.ts | 38 +++++++++++ src/state/FloatingTile.ts | 43 +++++++++++++ src/state/GridLikeLayout.ts | 17 ++--- src/state/OneOnOneDesktopLayout.ts | 9 +-- src/state/OneOnOneMobileLayout.ts | 12 ++-- src/state/SpotlightExpandedLayout.ts | 10 ++- src/state/layout-types.test.ts | 20 ++---- src/state/layout-types.ts | 45 +++++++++---- 19 files changed, 290 insertions(+), 264 deletions(-) create mode 100644 src/state/FloatingTile.test.ts create mode 100644 src/state/FloatingTile.ts diff --git a/src/components/CallFooterViewModel.test.ts b/src/components/CallFooterViewModel.test.ts index 0c02a2ba7..ed0ffe8ed 100644 --- a/src/components/CallFooterViewModel.test.ts +++ b/src/components/CallFooterViewModel.test.ts @@ -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({ - inline: "end", - block: "end", - }), setVisibleTiles: (_: number) => {}, }; diff --git a/src/grid/CallLayout.test.tsx b/src/grid/CallLayout.test.tsx index 59e05d326..3338b5802 100644 --- a/src/grid/CallLayout.test.tsx +++ b/src/grid/CallLayout.test.tsx @@ -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(); - 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({ 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({ 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({ - 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(); +// Slots show the id of the tile they hold const Slot: LayoutProps["Slot"] = ({ id, model, onDrag, ...props -}) => { - drags.set(id, onDrag); - return ( -
- {model instanceof GridTileViewModel ? model.id : "spotlight"} -
- ); -}; +}) => ( +
+ {model instanceof GridTileViewModel ? model.id : "spotlight"} +
+); function slotIds(): string[] { return screen diff --git a/src/grid/Grid.tsx b/src/grid/Grid.tsx index 1e70475b2..39b8a8ed3 100644 --- a/src/grid/Grid.tsx +++ b/src/grid/Grid.tsx @@ -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 extends Omit, "onDrag"> { +export interface SlotProps extends Omit< + ComponentProps<"div">, + "onDrag" +> { id: string; model: Model; onDrag?: DragCallback; @@ -181,31 +185,6 @@ export interface TileProps { 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, diff --git a/src/grid/GridLayout.tsx b/src/grid/GridLayout.tsx index ecf451435..0e75f8966 100644 --- a/src/grid/GridLayout.tsx +++ b/src/grid/GridLayout.tsx @@ -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>; +} + +const SpotlightSlot: FC = ({ model, Slot }) => { + useUpdateLayout(); + const alignment = useBehavior(model.alignment$); + + return ( + + ); +}; + /** * 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 = ({ // 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 (
{model.spotlight && ( - + )}
); diff --git a/src/grid/OneOnOneDesktopLayout.tsx b/src/grid/OneOnOneDesktopLayout.tsx index beba14c10..0275dc1c4 100644 --- a/src/grid/OneOnOneDesktopLayout.tsx +++ b/src/grid/OneOnOneDesktopLayout.tsx @@ -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 (
diff --git a/src/grid/OneOnOneMobileLayout.tsx b/src/grid/OneOnOneMobileLayout.tsx index 628ade1c8..c642fcc4e 100644 --- a/src/grid/OneOnOneMobileLayout.tsx +++ b/src/grid/OneOnOneMobileLayout.tsx @@ -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>; +} + +const PipSlot: FC = ({ model, Slot }) => { + useUpdateLayout(); + const size = useBehavior(model.size$); + const alignment = useBehavior(model.alignment$); + + return ( + + ); +}; /** * 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 (
- {model.pip && ( - - )} + {model.pip && }
); }, diff --git a/src/grid/SpotlightExpandedLayout.tsx b/src/grid/SpotlightExpandedLayout.tsx index b4fd1d0e6..e8bb948d4 100644 --- a/src/grid/SpotlightExpandedLayout.tsx +++ b/src/grid/SpotlightExpandedLayout.tsx @@ -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>; +} + +const PipSlot: FC = ({ model, Slot }) => { + useUpdateLayout(); + const alignment = useBehavior(model.alignment$); + + return ( + + ); +}; /** * An implementation of the "expanded spotlight" layout, in which the spotlight @@ -28,7 +54,6 @@ export const makeSpotlightExpandedLayout: CallLayout< Slot, }): ReactNode { useUpdateLayout(); - return (
- model.pipAlignment$.next({ - block: yRatio < 0.5 ? "start" : "end", - inline: xRatio < 0.5 ? "start" : "end", - }), - [model.pipAlignment$], - ); - return (
- {model.pip && ( - - )} + {model.pip && }
); }, diff --git a/src/grid/SpotlightLandscapeLayout.tsx b/src/grid/SpotlightLandscapeLayout.tsx index 989e3d398..77bd31a59 100644 --- a/src/grid/SpotlightLandscapeLayout.tsx +++ b/src/grid/SpotlightLandscapeLayout.tsx @@ -40,7 +40,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
@@ -59,7 +59,7 @@ export const makeSpotlightLandscapeLayout: CallLayout< return (
- +
{model.grid.map((m) => ( diff --git a/src/grid/SpotlightPortraitLayout.tsx b/src/grid/SpotlightPortraitLayout.tsx index 70c428e97..21b3e32d6 100644 --- a/src/grid/SpotlightPortraitLayout.tsx +++ b/src/grid/SpotlightPortraitLayout.tsx @@ -43,7 +43,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
@@ -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 (
): Observable { 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): Observable { 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): Observable { ); 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): Observable { ); 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): Observable { 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 diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 1ab4ea134..ae29bb0e5 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -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({ + 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({ - inline: "end", - block: "end", - }); - /** - * The alignment of the small picture-in-picture tile, if present, when in landscape. - */ - const landscapePipAlignment$ = new BehaviorSubject({ + 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); diff --git a/src/state/FloatingTile.test.ts b/src/state/FloatingTile.test.ts new file mode 100644 index 000000000..aa6f6d5f4 --- /dev/null +++ b/src/state/FloatingTile.test.ts @@ -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" }, + }); + })); diff --git a/src/state/FloatingTile.ts b/src/state/FloatingTile.ts new file mode 100644 index 000000000..aa0360973 --- /dev/null +++ b/src/state/FloatingTile.ts @@ -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; + onDrag: DragCallback; +} + +export function createFloatingTile( + scope: ObservableScope, + defaultAlignment: Alignment, +): FloatingTile { + const dragEvents$ = new Subject(); + return { + alignment$: scope.behavior( + dragEvents$.pipe( + map(({ 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), + }; +} diff --git a/src/state/GridLikeLayout.ts b/src/state/GridLikeLayout.ts index f91f8e310..c0c83660f 100644 --- a/src/state/GridLikeLayout.ts +++ b/src/state/GridLikeLayout.ts @@ -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, + 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, diff --git a/src/state/OneOnOneDesktopLayout.ts b/src/state/OneOnOneDesktopLayout.ts index 51fab4c87..d50c9bb0b 100644 --- a/src/state/OneOnOneDesktopLayout.ts +++ b/src/state/OneOnOneDesktopLayout.ts @@ -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, + 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, ]; diff --git a/src/state/OneOnOneMobileLayout.ts b/src/state/OneOnOneMobileLayout.ts index d8395f0ca..03f809cc3 100644 --- a/src/state/OneOnOneMobileLayout.ts +++ b/src/state/OneOnOneMobileLayout.ts @@ -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, + 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, ]; diff --git a/src/state/SpotlightExpandedLayout.ts b/src/state/SpotlightExpandedLayout.ts index 45a37ca29..5e316bb55 100644 --- a/src/state/SpotlightExpandedLayout.ts +++ b/src/state/SpotlightExpandedLayout.ts @@ -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, + 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, ]; diff --git a/src/state/layout-types.test.ts b/src/state/layout-types.test.ts index 468c15bb5..a333d0013 100644 --- a/src/state/layout-types.test.ts +++ b/src/state/layout-types.test.ts @@ -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({ - 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); diff --git a/src/state/layout-types.ts b/src/state/layout-types.ts index be86f2a36..d2e080da7 100644 --- a/src/state/layout-types.ts +++ b/src/state/layout-types.ts @@ -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; 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; + pip?: FloatingTile & { vm: GridTileViewModel }; } export interface OneOnOneDesktopLayout { type: "one-on-one-desktop"; spotlight: GridTileViewModel; - pip: GridTileViewModel; - pipAlignment$: BehaviorSubject; + pip: FloatingTile & { vm: GridTileViewModel }; } export interface OneOnOneMobileLayout { type: "one-on-one-mobile"; spotlight: SpotlightTileViewModel; - pip?: GridTileViewModel; - pipSize$: Behavior<"sm" | "lg">; - pipAlignment$: BehaviorSubject; + pip?: FloatingTile & { vm: GridTileViewModel; size$: Behavior<"sm" | "lg"> }; } export interface PipLayout {