PuruVJ / neodrag

One Draggable to rule them all 💍
https://neodrag.dev
MIT License
1.59k stars 48 forks source link

Svelte 5 and touch behavior #169

Closed jessecoleman closed 2 weeks ago

jessecoleman commented 2 weeks ago

Want to preface by saying this library has been great for my needs, so thanks for all the hard work on it!

I am trying to migrate my site https://gramjam.app to svelte 5, and noticing some changes in the drag behavior before and after bumping the version. I don't know if this is a bug in svelte or in this library, but I'm noticing that when using a touch input, the on:neodrag:end isn't always being called and the dragging gets "stuck". This could also be a bug in my user code. This could be entirely a red-herring, but maybe the binding methods event binding needs to update to match the new svelte5 convention?

image

Here's a link to the svelte 5 version: https://svelte5.word-crush.pages.dev/classic (only seems to repro on a touch screen)

Screen recording (I added a little debug overlay to see the drag events as they fire (only printing the first on:drag)).

https://github.com/user-attachments/assets/0905db14-da6e-4393-92f9-2db432a3e8bc

Here's a (truncated) sample of the code:

<script lang="ts" module>
  import { SvelteSet } from 'svelte/reactivity';

  export const initializeSelection = (): Selection => ({
    coords: [],
    tiles: new SvelteSet(),
  });

<script lang="ts">

  const game = getGameState<GameState<TTile>>();
  type Props = {
    disabled?: boolean;
    selected?: Selection;
  }
  let { disabled = $bindable(), selected = $bindable(initializeSelection()), ...props }: Props = $props();

  // derived from responsive dimensions
  const tileWidth = $derived(boardWidth / ($game.board.length || 1));
  const tileHeight = $derived(boardHeight / $game.board[0]?.length);
  const PAD = $derived(tileWidth > 60 ? 0.95 : 0.925);

  const xScale = $derived((i: number) => (i + (1 - PAD) / 2) * tileWidth);
  const yScale = $derived((j: number) => (j + (1 - PAD) / 2) * tileHeight);

  const coordStr2Int = (c: string): Coord =>
    c.split(',').map((i) => parseInt(i)) as Coord;

  const clearSelection = () => {
    selected = initializeSelection();
    clearDragging();
  };

  const clearDragging = () => {
    dragging.id = undefined;
    dragging.origin = [-1, -1];
  };

  type Dragging = {
    timestamp: number;
    id?: string | number;
    origin?: Coord;
    coords?: { x: number, y: number };
  };
  let dragging = $state<Dragging>({
    timestamp: 0,
  });

  const getHoveredTile = $derived((e: CustomEvent<DragEventData>, board: Board) => {
    const [i, j] = [
      Math.round(e.detail.offsetX / tileWidth),
      Math.round(e.detail.offsetY / tileHeight),
    ];
    return {
      tile: board[i]?.[j],
      i,
      j,
    };
  });

  const onDragStart = async (
    e: CustomEvent<DragEventData>,
    id: TTile['id'],
    i: number,
    j: number,
  ) => {
    if (selected.tiles.size > 0 && e.timeStamp - dragging.timestamp < 100) {
      clearSelection();
    }
    dragging.timestamp = e.timeStamp;
    dragging.id = id;
    dragging.origin = [i, j];
    dragging.coords = { x: xScale(i), y: yScale(j) };

    selected.tiles.add(id);
    selected.coords.push([i, j].join());
  };

  const onDrag = async (e: CustomEvent<DragEventData>) => {
    if (e.timeStamp - dragging.timestamp > 100 && selected.tiles.size > 1) {
      selected.tiles = new SvelteSet([dragging.id!]);
      selected.coords = [dragging.origin!.join()];
    }

    dragging.coords = { x: e.detail.offsetX, y: e.detail.offsetY };
  };

  const onDragEnd = async (
    e: CustomEvent<DragEventData>,
    id: TTile['id'],
    i: number,
    j: number,
  ) => {
    if (selected.tiles.size === 0) return;
    $penalty = 0;

    const { i: i2, j: j2, tile } = getHoveredTile(e, $game.board);
    if (!tile) {
      clearSelection();
      return;
    }
    const targetId = tile.id;
    const coordStr = [i2, j2].join();

    const TAP_THRESH = 250;
    const { distance, direction } = getDragDistance(
      [xScale(i), yScale(j)],
      [dragging.coords!.x, dragging.coords!.y],
    );
    if (
      e.timeStamp - dragging.timestamp < TAP_THRESH &&
      selected.coords.slice(-1)[0] === coordStr
    ) {
      // clearDragging();
      if (distance / tileWidth < 0.2) {
        await handleClick(i, j);
      } else {
        const i2 = i + direction[0];
        const j2 = j + direction[1];
        // not strictly necessary because of drag bounds
        if (i2 >= 0 && i2 < $game.dims[0] && j2 >= 0 && j2 < $game.dims[1]) {
          await swapTiles([i, j], [i2, j2]);
        }
      }
    } else if (selected.coords.length && selected.coords[0] !== coordStr) {
      selected.coords.push(coordStr);
      selected.tiles.add(targetId);
      await swapTiles([i, j], [i2, j2]);
    } else {
      dragging.coords = { x: xScale(i), y: yScale(j) };
      clearSelection();
    }
    dragging.timestamp = e.timeStamp;
  };

  const DBL_CLICK_THRESH = 500;
  let prevClick = 0;
  const handleClick = async (i: number, j: number) => {
    if (!disabled) {
      const now = +new Date();
      disabled = true;
      await tick();
      if (selected.tiles.size === 2) {
        dragging.id = undefined;
        const [i2, j2] = coordStr2Int(selected.coords[0]);
        await swapTiles([i, j], [i2, j2]);
      }
      prevClick = now;
      disabled = false;
    }
  };

  const tiles = $derived($game.board
    .flatMap((row, i) => row.map((tile, j) => ({ i, j, tile })))
    .filter((t) => t.tile));

</script>

<div class="board" class:disabled>
      {#each tiles as { tile, i, j } (tile.id)}
        <div
          class="tile-container"
          style="
          width: {tileWidth * PAD}px;
          height: {tileHeight * PAD}px;
          transform: translate3d(
            {xScale(i)}px,
            {yScale(j)}px,
            0px
          )
        "
          data-id={tile.id}
          on:neodrag:start={(e) => onDragStart(e, tile.id, i, j)}
          on:neodrag={onDrag}
          on:neodrag:end={(e) => onDragEnd(e, tile.id, i, j)}
          use:draggable={{
            bounds: 'parent',
            defaultClassDragging: 'dragging',
            disabled: disabled,
            position:
              dragging.id === tile.id
                ? dragging.coords
                : { x: xScale(i), y: yScale(j) },
          }}
          animate:flip
          in:dropIn={{
            row: Math.max(...$collapsing) - j,
            rowOffset: $collapsing[i],
          }}
          out:send={{
            key: tile.id,
            gameMode: $game.type,
            index: $sendWord[tile.id],
            origin: $wordOrigin && [
              xScale($wordOrigin[0]),
              yScale($wordOrigin[1]),
            ],
          }}
          class:dragging={dragging.id === tile.id}
        >
            <Tile
              {tile}
              selected={selected.tiles.has(tile.id)}
              hovered={tile.id === hoveredTile?.id}
              dragging={tile.id === dragging.id}
              flying={selected.tiles.has(tile.id)}
              highlighted={$game.highlighted[tile.id]}
              width={0.8 * tileWidth}
            />
        </div>
      {/each}
</div>
jessecoleman commented 2 weeks ago

Of course I spend days debugging and writing testing structs before posting this, and then within hours find the solution, lol. The issue seemed to be missing a touch-action: none css style on the board-inner component. I have no idea why this only manifested after upgrading the svelte version, oh well.

I'll leave this up in case others run into the same issue, or want to see my reference implementation with click handling and more complex interactions.

I also think it would be worth while to update the library for the new Svelte 5 event handling syntax at some point, but that's not blocking me at the moment.