From bd47597bad0ad02d5cb0d6c611fded9c7ef91c50 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 01:53:29 +0000 Subject: [PATCH 1/2] chess: drag pieces to move, mouse and touch --- README.md | 2 +- .../chess/components/ChessTable.module.css | 23 +++++ src/apps/chess/components/ChessTable.tsx | 90 +++++++++++++++++-- .../components/__tests__/ChessTable.test.tsx | 87 ++++++++++++++++++ 4 files changed, 196 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1a404ec..ac97495 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ over the world (MoonBase#1502); `GolfTable`, `CastleTable` and `RummyTable` (Moo are the tables, `useGolfTable`, `useCastleTable` and `useRummyTable` their state over the room stream's game envelopes. Castle and rummy share the felt and the card faces (`castle/components/Cards.tsx`, `CastleTable.module.css`). `ChessTable` and `useChessTable` -are chess's: two seats, the board from the viewer's side, the moves the hub lists for the +are chess's: two seats, the board from the viewer's side (tap a piece then its square, or drag it), the moves the hub lists for the side to move (the client holds no rules), the clocks counted down from each view, and a score notepad as the table plays on: Next game starts another there, sides swapped. Share links are `/games/room/:roomId` and `/games/room/:roomId/table/:gameId`; the old `/golf` and `/castle` diff --git a/src/apps/chess/components/ChessTable.module.css b/src/apps/chess/components/ChessTable.module.css index f49b225..c0f2090 100644 --- a/src/apps/chess/components/ChessTable.module.css +++ b/src/apps/chess/components/ChessTable.module.css @@ -65,6 +65,7 @@ the squares past a tap. The pieces are sized from the board, not the screen, so they always fit their squares. */ .board { + position: relative; container-type: inline-size; display: grid; grid-template-columns: repeat(8, 1fr); @@ -288,3 +289,25 @@ font-style: italic; opacity: 0.7; } + +/* A drag: the viewer's movable pieces take the touch, so the page does not + scroll under it; the piece rides the pointer over the board, a size up, + and its square holds a faint copy until it lands. */ +.square[data-grab='true'] { + touch-action: none; + cursor: grab; +} + +.square[data-dragging='true'] > span:last-child { + opacity: 0.3; +} + +.ghost { + position: absolute; + z-index: 3; + font-size: 11cqi; + line-height: 1; + transform: translate(-50%, -50%); + pointer-events: none; + cursor: grabbing; +} diff --git a/src/apps/chess/components/ChessTable.tsx b/src/apps/chess/components/ChessTable.tsx index a8899dc..a9d1df2 100644 --- a/src/apps/chess/components/ChessTable.tsx +++ b/src/apps/chess/components/ChessTable.tsx @@ -1,4 +1,5 @@ import { useEffect, useId, useRef, useState } from 'react' +import type { PointerEvent as ReactPointerEvent } from 'react' import type { ChessTableActions } from '@/hooks/useChessTable' import felt from '@/apps/castle/components/CastleTable.module.css' import type { ChessColor, ChessView } from '../wire' @@ -37,6 +38,9 @@ const PROMOTIONS: Array<{ letter: string; name: string }> = [ // How often a running clock repaints: fine enough for its tenths. const TICK_MS = 100 +// How far a press travels before it is a drag rather than a tap. +const DRAG_SLOP_PX = 6 + const colorOfPiece = (piece: string): ChessColor => (piece === piece.toUpperCase() ? 'white' : 'black') const other = (color: ChessColor): ChessColor => (color === 'white' ? 'black' : 'white') @@ -115,16 +119,26 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => { // The king of the side to move — or, once mated, of the side that was. const checkedKing = view.inCheck ? (view.fen?.split(' ')[1] === 'b' ? 'k' : 'K') : null + // The move from `start` to `square`: played, or the promotion asked. + const land = (start: string, square: string) => { + const moves = movesTo(view.legalMoves, start, square) + setPicked(null) + if (moves.length === 1) table.play(moves[0]) + else setPromoting({ view, from: start, moves }) + } + const tap = (square: string) => { + // The click a release fires after a drag is the drag's, already done. + if (dragged.current) { + dragged.current = false + return + } if (!myTurn || !connected) return // A tap on the board is a new gesture: a promotion still asking is // abandoned, never left up to send a second move for this turn. setPromoting(null) if (from !== null && targets.includes(square)) { - const moves = movesTo(view.legalMoves, from, square) - setPicked(null) - if (moves.length === 1) table.play(moves[0]) - else setPromoting({ view, from, moves }) + land(from, square) return } // Another of the viewer's pieces with a move picks it up; anything @@ -133,6 +147,50 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => { setPicked(movable && square !== from ? { view, square } : null) } + // A drag: a press on a piece with moves that travels past the slop. The + // piece follows the pointer and its squares show; the release lands it on + // the square under the pointer — not the event's target, which for a + // touch stays the square the press began on. A drop anywhere else leaves + // the piece picked up, for a tap to finish. + const press = useRef<{ id: number; square: string; x: number; y: number; moving: boolean } | null>(null) + const dragged = useRef(false) + const [ghost, setGhost] = useState<{ piece: string; x: number; y: number } | null>(null) + const squareAt = (x: number, y: number) => + document.elementFromPoint(x, y)?.closest('[data-square]')?.dataset.square ?? null + + const onPointerDown = (event: ReactPointerEvent, square: string) => { + dragged.current = false + if (!myTurn || !connected || event.button !== 0) return + if (targetsFrom(view.legalMoves, square).length === 0) return + press.current = { id: event.pointerId, square, x: event.clientX, y: event.clientY, moving: false } + } + const onPointerMove = (event: ReactPointerEvent) => { + const held = press.current + if (held === null || held.id !== event.pointerId) return + if (!held.moving && Math.hypot(event.clientX - held.x, event.clientY - held.y) < DRAG_SLOP_PX) return + if (!held.moving) { + held.moving = true + setPromoting(null) + setPicked({ view, square: held.square }) + } + const rect = boardRef.current?.getBoundingClientRect() + setGhost({ piece: board.get(held.square) ?? '', x: event.clientX - (rect?.left ?? 0), y: event.clientY - (rect?.top ?? 0) }) + } + const onPointerUp = (event: ReactPointerEvent) => { + const held = press.current + if (held === null || held.id !== event.pointerId) return + press.current = null + setGhost(null) + if (!held.moving) return + dragged.current = true + const square = squareAt(event.clientX, event.clientY) + if (square !== null && targetsFrom(view.legalMoves, held.square).includes(square)) land(held.square, square) + } + const onPointerCancel = () => { + press.current = null + setGhost(null) + } + const cancelPromotion = () => { if (pendingPromotion !== null) refocus.current = { square: pendingPromotion.from } setPromoting(null) @@ -213,13 +271,22 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => { a move -
+
{squares.map((square, index) => { const piece = board.get(square) const mine = piece !== undefined && colorOfPiece(piece) === myColor // a1 dark: a square is dark where its file and rank index sum even. const light = (square.charCodeAt(0) - 97 + Number(square[1])) % 2 === 0 const target = targets.includes(square) + const grab = mine && myTurn && connected && targetsFrom(view.legalMoves, square).length > 0 return ( ) })} + {ghost !== null && ( + + )}
{me?.color !== undefined && } diff --git a/src/apps/chess/components/__tests__/ChessTable.test.tsx b/src/apps/chess/components/__tests__/ChessTable.test.tsx index b8e505a..9d5519d 100644 --- a/src/apps/chess/components/__tests__/ChessTable.test.tsx +++ b/src/apps/chess/components/__tests__/ChessTable.test.tsx @@ -227,6 +227,93 @@ describe('ChessTable', () => { }) }) + // A drag is a press that moves: the piece follows the pointer, the + // squares it reaches show, and letting go on one plays it. Mouse and touch + // alike are pointer events; where it lands is the square under the + // pointer, since a touch keeps every event on the square it began on. + describe('dragging', () => { + // jsdom does no layout: the square under the pointer is the test's to say. + const under = (name: string | null) => { + const element = name === null ? null : square(name) + document.elementFromPoint = () => element + } + const drag = (fromName: string, toName: string | null) => { + const from = square(fromName) + fireEvent.pointerDown(from, { pointerId: 1, clientX: 10, clientY: 10, button: 0 }) + fireEvent.pointerMove(from, { pointerId: 1, clientX: 40, clientY: 40 }) + under(toName) + fireEvent.pointerUp(from, { pointerId: 1, clientX: 40, clientY: 40 }) + // The click a touch's release fires on the square it began on. + fireEvent.click(from) + } + + it('a piece dropped on a square it reaches plays the move, in one gesture', () => { + const { t } = mountWith(view()) + drag('g6', 'f7') + expect(t.play).toHaveBeenCalledTimes(1) + expect(t.play).toHaveBeenCalledWith('g6f7') + }) + + it('while it moves, the piece follows the pointer and its squares show', () => { + mountWith(view()) + fireEvent.pointerDown(square('g6'), { pointerId: 1, clientX: 10, clientY: 10, button: 0 }) + expect(screen.queryByTestId('drag-ghost')).toBeNull() // a press is not yet a drag + fireEvent.pointerMove(square('g6'), { pointerId: 1, clientX: 40, clientY: 40 }) + expect(screen.getByTestId('drag-ghost')).toBeInTheDocument() + expect(square('f7')).toHaveAccessibleDescription('a move') + fireEvent.pointerCancel(square('g6'), { pointerId: 1 }) + expect(screen.queryByTestId('drag-ghost')).toBeNull() + }) + + // A finger never lands still: a press that barely moves is a tap. + it('a press that wobbles a few pixels is still a tap', () => { + const { t } = mountWith(view()) + fireEvent.pointerDown(square('g6'), { pointerId: 1, clientX: 10, clientY: 10, button: 0 }) + fireEvent.pointerMove(square('g6'), { pointerId: 1, clientX: 13, clientY: 12 }) + expect(screen.queryByTestId('drag-ghost')).toBeNull() + fireEvent.pointerUp(square('g6'), { pointerId: 1, clientX: 13, clientY: 12 }) + fireEvent.click(square('g6')) + expect(square('g6')).toHaveAttribute('aria-pressed', 'true') + fireEvent.click(square('f7')) + expect(t.play).toHaveBeenCalledWith('g6f7') + }) + + it('a promotion dropped asks for its piece', () => { + const { t } = mountWith(view()) + drag('e7', 'e8') + expect(t.play).not.toHaveBeenCalled() + expect(screen.getByRole('group', { name: 'promote to' })).toBeInTheDocument() + }) + + it('dropped where it cannot go, nothing is played and the piece stays picked up', () => { + const { t } = mountWith(view()) + drag('g6', 'a1') + drag('g6', null) + expect(t.play).not.toHaveBeenCalled() + expect(square('g6')).toHaveAttribute('aria-pressed', 'true') + // and a tap finishes it + fireEvent.click(square('f7')) + expect(t.play).toHaveBeenCalledWith('g6f7') + }) + + it('off turn, or off the hub, a drag moves nothing', () => { + const { t, unmount } = mountWith(view(), {}, 'bob') + drag('g6', 'f7') + expect(screen.queryByTestId('drag-ghost')).toBeNull() + unmount() + mountWith(view(), t, 'alice', false) + drag('g6', 'f7') + expect(t.play).not.toHaveBeenCalled() + }) + + it('the viewer’s movable pieces take the touch from the page; nothing else does', () => { + mountWith(view()) + expect(square('g6')).toHaveAttribute('data-grab', 'true') + expect(square('h8')).not.toHaveAttribute('data-grab') + expect(square('a1')).not.toHaveAttribute('data-grab') + }) + }) + describe('what the table says', () => { it('names the opponent’s last move before the viewer’s turn', () => { mountWith(view({ moves: ['a1a2', 'h7h8'] })) From c8b9b4848d97f1e58a69dc739488cb7e81af1519 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 02:02:52 +0000 Subject: [PATCH 2/2] chess: capture the drag; draw the piece over the page --- .../chess/components/ChessTable.module.css | 8 ++--- src/apps/chess/components/ChessTable.tsx | 25 +++++++++++----- .../components/__tests__/ChessTable.test.tsx | 29 +++++++++++++++++++ 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/src/apps/chess/components/ChessTable.module.css b/src/apps/chess/components/ChessTable.module.css index c0f2090..c627bae 100644 --- a/src/apps/chess/components/ChessTable.module.css +++ b/src/apps/chess/components/ChessTable.module.css @@ -65,7 +65,6 @@ the squares past a tap. The pieces are sized from the board, not the screen, so they always fit their squares. */ .board { - position: relative; container-type: inline-size; display: grid; grid-template-columns: repeat(8, 1fr); @@ -291,7 +290,7 @@ } /* A drag: the viewer's movable pieces take the touch, so the page does not - scroll under it; the piece rides the pointer over the board, a size up, + scroll under it; the piece rides the pointer over the page, a size up, and its square holds a faint copy until it lands. */ .square[data-grab='true'] { touch-action: none; @@ -303,9 +302,8 @@ } .ghost { - position: absolute; - z-index: 3; - font-size: 11cqi; + position: fixed; + z-index: 1000; line-height: 1; transform: translate(-50%, -50%); pointer-events: none; diff --git a/src/apps/chess/components/ChessTable.tsx b/src/apps/chess/components/ChessTable.tsx index a9d1df2..fa5dc87 100644 --- a/src/apps/chess/components/ChessTable.tsx +++ b/src/apps/chess/components/ChessTable.tsx @@ -1,5 +1,6 @@ import { useEffect, useId, useRef, useState } from 'react' import type { PointerEvent as ReactPointerEvent } from 'react' +import { createPortal } from 'react-dom' import type { ChessTableActions } from '@/hooks/useChessTable' import felt from '@/apps/castle/components/CastleTable.module.css' import type { ChessColor, ChessView } from '../wire' @@ -154,7 +155,7 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => { // the piece picked up, for a tap to finish. const press = useRef<{ id: number; square: string; x: number; y: number; moving: boolean } | null>(null) const dragged = useRef(false) - const [ghost, setGhost] = useState<{ piece: string; x: number; y: number } | null>(null) + const [ghost, setGhost] = useState<{ piece: string; x: number; y: number; size: number } | null>(null) const squareAt = (x: number, y: number) => document.elementFromPoint(x, y)?.closest('[data-square]')?.dataset.square ?? null @@ -172,9 +173,13 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => { held.moving = true setPromoting(null) setPicked({ view, square: held.square }) + // Past the slop, not on the press: captured at once, a tap's click + // would land on the board rather than its square. + boardRef.current?.setPointerCapture?.(event.pointerId) } - const rect = boardRef.current?.getBoundingClientRect() - setGhost({ piece: board.get(held.square) ?? '', x: event.clientX - (rect?.left ?? 0), y: event.clientY - (rect?.top ?? 0) }) + // A size up from the board's own pieces, which are 9% of its width. + const width = boardRef.current?.getBoundingClientRect().width ?? 0 + setGhost({ piece: board.get(held.square) ?? '', x: event.clientX, y: event.clientY, size: width * 0.11 }) } const onPointerUp = (event: ReactPointerEvent) => { const held = press.current @@ -323,18 +328,22 @@ const ChessTable = ({ playerId, connected, view, table }: ChessTableProps) => { ) })} - {ghost !== null && ( +
+ {me?.color !== undefined && } + {/* On the page, not the board: the board clips its overflow, and a + captured drag may stray past its edge. */} + {ghost !== null && + createPortal( + , + document.body )} - - {me?.color !== undefined && } {pendingPromotion !== null && (
{ expect(screen.queryByTestId('drag-ghost')).toBeNull() }) + // A mouse is not held by the square it pressed: past the slop the board + // takes the pointer, so a release anywhere — off the board, off the + // page — still ends the drag. + it('once a drag, the board holds the pointer, and a release off it ends the drag', () => { + mountWith(view()) + const board = screen.getByRole('group', { name: 'board' }) + const capture = vi.fn() + board.setPointerCapture = capture + fireEvent.pointerDown(square('g6'), { pointerId: 7, clientX: 10, clientY: 10, button: 0 }) + expect(capture).not.toHaveBeenCalled() // a tap keeps its click + fireEvent.pointerMove(square('g6'), { pointerId: 7, clientX: 40, clientY: 40 }) + expect(capture).toHaveBeenCalledWith(7) + // Captured, the release comes to the board wherever it happens. + under(null) + fireEvent.pointerUp(board, { pointerId: 7, clientX: 900, clientY: 900 }) + expect(screen.queryByTestId('drag-ghost')).toBeNull() + expect(square('g6')).toHaveAttribute('aria-pressed', 'true') + }) + + it('the dragged piece is drawn outside the board, so its edge cannot clip it', () => { + mountWith(view()) + fireEvent.pointerDown(square('g6'), { pointerId: 1, clientX: 10, clientY: 10, button: 0 }) + fireEvent.pointerMove(square('g6'), { pointerId: 1, clientX: 40, clientY: 40 }) + const ghost = screen.getByTestId('drag-ghost') + expect(screen.getByRole('group', { name: 'board' }).contains(ghost)).toBe(false) + expect(ghost.style.left).toBe('40px') + expect(ghost.style.top).toBe('40px') + }) + // A finger never lands still: a press that barely moves is a tap. it('a press that wobbles a few pixels is still a tap', () => { const { t } = mountWith(view())