diff --git a/src/video-grid/NewVideoGrid.module.css b/src/video-grid/NewVideoGrid.module.css index 75ee7076..7e34a2d7 100644 --- a/src/video-grid/NewVideoGrid.module.css +++ b/src/video-grid/NewVideoGrid.module.css @@ -18,7 +18,7 @@ limitations under the License. contain: strict; position: relative; flex-grow: 1; - padding: 0 20px; + padding: 0 20px var(--footerHeight); overflow-y: auto; overflow-x: hidden; } @@ -28,7 +28,6 @@ limitations under the License. display: grid; grid-auto-rows: 163px; gap: 8px; - padding-bottom: var(--footerHeight); } .slot { @@ -37,7 +36,7 @@ limitations under the License. @media (min-width: 800px) { .grid { - padding: 0 22px; + padding: 0 22px var(--footerHeight); } .slotGrid { diff --git a/src/video-grid/NewVideoGrid.tsx b/src/video-grid/NewVideoGrid.tsx index e27dea53..80f13145 100644 --- a/src/video-grid/NewVideoGrid.tsx +++ b/src/video-grid/NewVideoGrid.tsx @@ -44,6 +44,7 @@ import { forEachCellInArea, cycleTileSize, appendItems, + tryMoveTile, } from "./model"; import { TileWrapper } from "./TileWrapper"; @@ -280,6 +281,8 @@ export const NewVideoGrid: FC = ({ const animateDraggedTile = (endOfGesture: boolean) => { const { tileId, tileX, tileY, cursorX, cursorY } = dragState.current!; const tile = tiles.find((t) => t.item.id === tileId)!; + const originIndex = grid!.cells.findIndex((c) => c?.item.id === tileId); + const originCell = grid!.cells[originIndex]!; springRef.current .find((c) => (c.item as Tile).item.id === tileId) @@ -310,23 +313,36 @@ export const NewVideoGrid: FC = ({ } ); - const overTile = tiles.find( - (t) => - cursorX >= t.x && - cursorX < t.x + t.width && - cursorY >= t.y && - cursorY < t.y + t.height + const columns = grid!.columns; + const rows = row(grid!.cells.length - 1, grid!) + 1; + + const cursorColumn = Math.floor( + (cursorX / slotGrid!.clientWidth) * columns ); - 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; - }), - })); - } + const cursorRow = Math.floor((cursorY / slotGrid!.clientHeight) * rows); + + const cursorColumnOnTile = Math.floor( + ((cursorX - tileX) / tile.width) * originCell.columns + ); + const cursorRowOnTile = Math.floor( + ((cursorY - tileY) / tile.height) * originCell.rows + ); + + const dest = + Math.max( + 0, + Math.min( + columns - originCell.columns, + cursorColumn - cursorColumnOnTile + ) + ) + + grid!.columns * + Math.max( + 0, + Math.min(rows - originCell.rows, cursorRow - cursorRowOnTile) + ); + + if (dest !== originIndex) setGrid((g) => tryMoveTile(g, originIndex, dest)); }; // Callback for useDrag. We could call useDrag here, but the default diff --git a/src/video-grid/model.ts b/src/video-grid/model.ts index 14bd87e6..4a6cc327 100644 --- a/src/video-grid/model.ts +++ b/src/video-grid/model.ts @@ -175,6 +175,8 @@ const areaEnd = ( g: Grid ): number => start + columns - 1 + g.columns * (rows - 1); +const cloneGrid = (g: Grid): Grid => ({ ...g, cells: [...g.cells] }); + /** * Gets the index of the next gap in the grid that should be backfilled by 1×1 * tiles. @@ -257,6 +259,39 @@ function moveTile(g: Grid, from: number, to: number) { ); } +/** + * Moves the tile at index "from" over to index "to", if there is space. + */ +export function tryMoveTile(g: Grid, from: number, to: number): Grid { + const tile = g.cells[from]!; + + if ( + to > 0 && + to < g.cells.length && + column(to, g) <= g.columns - tile.columns + ) { + const fromEnd = areaEnd(from, tile.columns, tile.rows, g); + const toEnd = areaEnd(to, tile.columns, tile.rows, g); + + // 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); + + if (allCellsInArea(to, toEnd, g, displaceable)) { + // The target space is free; move + const gClone = cloneGrid(g); + moveTile(gClone, from, to); + return gClone; + } + } + + // The target space isn't free; don't move + return g; +} + /** * Attempts to push a tile upwards by one row, displacing 1×1 tiles and shifting * enlarged tiles around when necessary. @@ -291,7 +326,7 @@ function pushTileUp(g: Grid, from: number): boolean { * Backfill any gaps in the grid. */ export function fillGaps(g: Grid): Grid { - const result: Grid = { ...g, cells: [...g.cells] }; + const result = cloneGrid(g); // This will hopefully be the size of the grid after we're done here, assuming // that we can pack the large tiles tightly enough diff --git a/test/video-grid/model-test.ts b/test/video-grid/model-test.ts index d8cfdd84..57659155 100644 --- a/test/video-grid/model-test.ts +++ b/test/video-grid/model-test.ts @@ -22,6 +22,7 @@ import { forEachCellInArea, Grid, row, + tryMoveTile, } from "../../src/video-grid/model"; import { TileDescriptor } from "../../src/video-grid/TileDescriptor"; @@ -325,3 +326,63 @@ def`; ); expect(showGrid(appendItems(newItems, mkGrid(grid1)))).toBe(grid2); }); + +function testTryMoveTile( + title: string, + from: number, + to: number, + input: string, + output: string +): void { + test(`tryMoveTile ${title}`, () => { + expect(showGrid(tryMoveTile(mkGrid(input), from, to))).toBe(output); + }); +} + +testTryMoveTile( + "refuses to move a tile too far to the left", + 1, + -1, + ` +abc`, + ` +abc` +); + +testTryMoveTile( + "refuses to move a tile too far to the right", + 1, + 3, + ` +abc`, + ` +abc` +); + +testTryMoveTile( + "moves a large tile to an unoccupied space", + 3, + 1, + ` +a b +ccd +cce`, + ` +acc +bcc +d e` +); + +testTryMoveTile( + "refuses to move a large tile to an occupied space", + 3, + 1, + ` +abb +ccd +cce`, + ` +abb +ccd +cce` +);