From b5b7345308255ef1ba2c9daf0fa692ee0da2299c Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Tue, 8 Jun 2021 18:05:22 -0700 Subject: [PATCH] Improve touch handling (#1098) * Improve touch handling * Document touch event changes --- packages/webamp/CHANGELOG.md | 1 + packages/webamp/js/actionCreators/playlist.ts | 4 +- packages/webamp/js/components/Balance.tsx | 2 +- .../js/components/MainWindow/Marquee.tsx | 20 +++++++--- .../js/components/MainWindow/Position.tsx | 1 + .../components/PlaylistWindow/TrackCell.tsx | 26 ++++++++++++- .../components/PlaylistWindow/TrackList.tsx | 39 +++++++++++++++---- .../webamp/js/components/ResizeTarget.tsx | 29 +++++++++----- packages/webamp/js/components/Volume.tsx | 6 ++- .../webamp/js/components/WindowManager.tsx | 33 ++++++++++++---- packages/webamp/js/utils.ts | 30 ++++++++++++++ 11 files changed, 155 insertions(+), 36 deletions(-) diff --git a/packages/webamp/CHANGELOG.md b/packages/webamp/CHANGELOG.md index 639cb7e5..548cce09 100644 --- a/packages/webamp/CHANGELOG.md +++ b/packages/webamp/CHANGELOG.md @@ -3,6 +3,7 @@ ### Features - Support `.ani` cursors in skins [#1035](https://github.com/captbaritone/webamp/pull/1035), [blog post](https://jordaneldredge.com/blog/rendering-animated-ani-cursors-in-the-browser/). +- Improved support for mobile/touch screen interaction: volume, balance and position sliders, marquee, playlist and window resizing. [#1098](https://github.com/captbaritone/webamp/pull/1098) ### Bug Fixes diff --git a/packages/webamp/js/actionCreators/playlist.ts b/packages/webamp/js/actionCreators/playlist.ts index ea01dce1..8008f359 100644 --- a/packages/webamp/js/actionCreators/playlist.ts +++ b/packages/webamp/js/actionCreators/playlist.ts @@ -91,9 +91,7 @@ export function scrollNTracks(n: number): Thunk { }; } -export function scrollPlaylistByDelta( - e: React.WheelEvent -): Thunk { +export function scrollPlaylistByDelta(e: WheelEvent): Thunk { e.preventDefault(); return (dispatch, getState) => { const state = getState(); diff --git a/packages/webamp/js/components/Balance.tsx b/packages/webamp/js/components/Balance.tsx index 3b479ee5..3b8a4f9b 100644 --- a/packages/webamp/js/components/Balance.tsx +++ b/packages/webamp/js/components/Balance.tsx @@ -24,7 +24,7 @@ export default function Balance({ style, className, id }: Props) { max="100" step="1" value={balance} - style={style} + style={{ ...style, touchAction: "none" }} onChange={(e) => setBalance(Number(e.target.value))} onMouseDown={() => setFocus("balance")} onMouseUp={unsetFocus} diff --git a/packages/webamp/js/components/MainWindow/Marquee.tsx b/packages/webamp/js/components/MainWindow/Marquee.tsx index 51bf46d8..7a0c92c4 100644 --- a/packages/webamp/js/components/MainWindow/Marquee.tsx +++ b/packages/webamp/js/components/MainWindow/Marquee.tsx @@ -5,6 +5,7 @@ import CharacterString from "../CharacterString"; import * as Actions from "../../actionCreators"; import * as Selectors from "../../selectors"; import { useTypedSelector, useActionCreator } from "../../hooks"; +import * as Utils from "../../utils"; const SEPARATOR = " *** "; @@ -82,8 +83,8 @@ function useDragX() { return; } const xStart = mouseDownX; - const handleMouseMove = (ee: MouseEvent) => { - const diff = ee.clientX - xStart; + const handleMouseMove = (ee: MouseEvent | TouchEvent) => { + const diff = Utils.getX(ee) - xStart; setDragOffset(-diff); }; @@ -94,20 +95,26 @@ function useDragX() { return; } document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseUp", handleMouseUp); + document.removeEventListener("touchmove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.removeEventListener("touchend", handleMouseUp); setMouseDownX(null); cleanedUp = true; }; document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); + document.addEventListener("touchmove", handleMouseMove); + document.addEventListener("touseup", handleMouseUp); + document.addEventListener("touchend", handleMouseUp); return handleMouseUp; }, [mouseDownX]); const handleMouseDown = React.useCallback( - (e: React.MouseEvent): void => { - setMouseDownX(e.clientX); + ( + e: React.MouseEvent | React.TouchEvent + ): void => { + setMouseDownX(Utils.getX(e)); }, [] ); @@ -131,6 +138,7 @@ const Marquee = React.memo(() => { id="marquee" className="text" onMouseDown={handleMouseDown} + onTouchStart={handleMouseDown} title="Song Title" >
{ min="0" max="100" step="1" + style={{ touchAction: "none" }} value={displayedPosition} onInput={setPosition} onChange={ diff --git a/packages/webamp/js/components/PlaylistWindow/TrackCell.tsx b/packages/webamp/js/components/PlaylistWindow/TrackCell.tsx index d0e0175a..c28e64e3 100644 --- a/packages/webamp/js/components/PlaylistWindow/TrackCell.tsx +++ b/packages/webamp/js/components/PlaylistWindow/TrackCell.tsx @@ -16,7 +16,9 @@ import { interface Props { id: number; index: number; - handleMoveClick: (e: React.MouseEvent) => void; + handleMoveClick: ( + e: React.MouseEvent | React.TouchEvent + ) => void; children: ReactNode; } @@ -51,6 +53,27 @@ function TrackCell({ children, handleMoveClick, index, id }: Props) { [dispatch, handleMoveClick, index, selected] ); + const handleTouchStart = useCallback( + (e) => { + if (!selected) { + dispatch({ type: CLICKED_TRACK, index }); + } + handleMoveClick(e); + + // There's no touch equivalent of onDoubleClick, so we fake one: + function handleSecondTap() { + playTrackNow(id); + } + e.target.addEventListener("touchstart", handleSecondTap); + setTimeout(() => { + // Technically we might be unmounted here, but that's fine since you + // can't tap an unmounted element and we will clean up eventually. + e.target.removeEventListener("touchstart", handleSecondTap); + }, 250); + }, + [dispatch, handleMoveClick, id, index, playTrackNow, selected] + ); + const style: React.CSSProperties = { backgroundColor: selected ? skinPlaylistStyle.selectedbg : undefined, color: current ? skinPlaylistStyle.current : undefined, @@ -61,6 +84,7 @@ function TrackCell({ children, handleMoveClick, index, id }: Props) { style={style} onClick={(e) => e.stopPropagation()} onMouseDown={onMouseDown} + onTouchStart={handleTouchStart} onContextMenu={(e) => e.preventDefault()} onDoubleClick={() => playTrackNow(id)} > diff --git a/packages/webamp/js/components/PlaylistWindow/TrackList.tsx b/packages/webamp/js/components/PlaylistWindow/TrackList.tsx index 98253c89..edb672cf 100644 --- a/packages/webamp/js/components/PlaylistWindow/TrackList.tsx +++ b/packages/webamp/js/components/PlaylistWindow/TrackList.tsx @@ -1,12 +1,12 @@ import { useState, useEffect } from "react"; -import { getTimeStr } from "../../utils"; import * as Selectors from "../../selectors"; import { TRACK_HEIGHT } from "../../constants"; import * as Actions from "../../actionCreators"; import TrackCell from "./TrackCell"; import TrackTitle from "./TrackTitle"; import { useTypedSelector, useActionCreator } from "../../hooks"; +import * as Utils from "../../utils"; function getNumberLength(number: number): number { return number.toString().length; @@ -22,13 +22,15 @@ function TrackList() { const dragSelected = useActionCreator(Actions.dragSelected); const scrollPlaylistByDelta = useActionCreator(Actions.scrollPlaylistByDelta); - const [node, setNode] = useState(null); + const [node, setNode] = useState(null); const [moving, setMoving] = useState(false); const [mouseStartY, setMouseStartY] = useState(null); - const _handleMoveClick = (e: React.MouseEvent) => { + const _handleMoveClick = ( + e: React.MouseEvent | React.TouchEvent + ) => { setMoving(true); - setMouseStartY(e.clientY); + setMouseStartY(Utils.getY(e)); }; useEffect(() => { @@ -37,8 +39,9 @@ function TrackList() { } const { top, bottom, left, right } = node.getBoundingClientRect(); let lastDiff = 0; - const handleMouseMove = (ee: MouseEvent) => { - const { clientY: y, clientX: x } = ee; + const handleMouseMove = (ee: MouseEvent | TouchEvent) => { + const x = Utils.getX(ee); + const y = Utils.getY(ee); if (y < top || y > bottom || x < left || x > right) { // Mouse is outside the track list return; @@ -54,9 +57,13 @@ function TrackList() { const handleMouseUp = () => setMoving(false); window.addEventListener("mouseup", handleMouseUp); window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("touchend", handleMouseUp); + window.addEventListener("touchmove", handleMouseMove); return () => { window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("touchmove", handleMouseMove); window.removeEventListener("mouseup", handleMouseUp); + window.removeEventListener("touchend", handleMouseUp); }; // I'm not 100% sure how well this would work if it rebound mid drag, so // we'll just pretend it's okay that we have stale values in there. @@ -81,13 +88,29 @@ function TrackList() { const maxTrackNumberLength = getNumberLength(numberOfTracks); const paddedTrackNumForIndex = (i: number) => (i + 1 + offset).toString().padStart(maxTrackNumberLength, "\u00A0"); + + useEffect(() => { + if (node == null) { + return; + } + + // Chome changed wheel events to be passive be default. We need active (so + // we can prevent default) and React does not have a way to control this, so + // we must bind our own events. + // + // https://github.com/facebook/react/issues/14856#issuecomment-806052402 + node.addEventListener("wheel", scrollPlaylistByDelta, { passive: false }); + + return () => { + node.removeEventListener("wheel", scrollPlaylistByDelta); + }; + }, [node, scrollPlaylistByDelta]); return (
{_renderTracks((id, i) => ( @@ -95,7 +118,7 @@ function TrackList() { ))}
- {_renderTracks((id) => getTimeStr(tracks[id].duration))} + {_renderTracks((id) => Utils.getTimeStr(tracks[id].duration))}
); diff --git a/packages/webamp/js/components/ResizeTarget.tsx b/packages/webamp/js/components/ResizeTarget.tsx index ca98c943..182acfc2 100644 --- a/packages/webamp/js/components/ResizeTarget.tsx +++ b/packages/webamp/js/components/ResizeTarget.tsx @@ -3,6 +3,7 @@ import { WINDOW_RESIZE_SEGMENT_WIDTH, WINDOW_RESIZE_SEGMENT_HEIGHT, } from "../constants"; +import * as Utils from "../utils"; type Size = [number, number]; @@ -23,9 +24,9 @@ function ResizeTarget(props: Props) { return; } const [width, height] = currentSize; - const handleMove = (ee: MouseEvent) => { - const x = ee.clientX - mouseStart.x; - const y = ee.clientY - mouseStart.y; + const handleMove = (ee: MouseEvent | TouchEvent) => { + const x = Utils.getX(ee) - mouseStart.x; + const y = Utils.getY(ee) - mouseStart.y; const newWidth = Math.max( 0, @@ -42,28 +43,38 @@ function ResizeTarget(props: Props) { }; window.addEventListener("mousemove", handleMove); + window.addEventListener("touchmove", handleMove); const handleMouseUp = () => setMouseDown(false); window.addEventListener("mouseup", handleMouseUp); + window.addEventListener("touchend", handleMouseUp); return () => { window.removeEventListener("mousemove", handleMove); + window.removeEventListener("touchmove", handleMove); window.removeEventListener("mouseup", handleMouseUp); + window.removeEventListener("touchend", handleMouseUp); }; // We pruposefully close over the props from when the mouse went down // eslint-disable-next-line react-hooks/exhaustive-deps }, [mouseStart, mouseDown]); - const handleMouseDown = (e: React.MouseEvent) => { - // Prevent dragging from highlighting text. - e.preventDefault(); + const handleMouseDown = ( + e: React.MouseEvent | React.TouchEvent + ) => { setMouseStart({ - x: e.clientX, - y: e.clientY, + x: Utils.getX(e), + y: Utils.getY(e), }); setMouseDown(true); }; - return
; + return ( +
+ ); } export default memo(ResizeTarget); diff --git a/packages/webamp/js/components/Volume.tsx b/packages/webamp/js/components/Volume.tsx index 87c36323..70e51724 100644 --- a/packages/webamp/js/components/Volume.tsx +++ b/packages/webamp/js/components/Volume.tsx @@ -23,11 +23,15 @@ export default function Volume({ id, style, className }: Props) { max="100" step="1" value={volume} - style={style} + style={{ ...style, touchAction: "none" }} className={className} onChange={(e) => setVolume(Number(e.target.value))} onMouseDown={() => setFocus("volume")} + onTouchStart={() => { + setFocus("volume"); + }} onMouseUp={unsetFocus} + onTouchEnd={unsetFocus} title="Volume Bar" /> ); diff --git a/packages/webamp/js/components/WindowManager.tsx b/packages/webamp/js/components/WindowManager.tsx index 866dc042..a2c3f6cb 100644 --- a/packages/webamp/js/components/WindowManager.tsx +++ b/packages/webamp/js/components/WindowManager.tsx @@ -5,6 +5,7 @@ import * as Selectors from "../selectors"; import * as Actions from "../actionCreators"; import { WindowInfo, WindowId, Box, Point } from "../types"; import { useTypedSelector, useActionCreator } from "../hooks"; +import * as Utils from "../utils"; const abuts = (a: Box, b: Box) => { // TODO: This is kinda a hack. They should really be touching, not just within snapping distance. @@ -26,7 +27,10 @@ type DraggingState = { function useHandleMouseDown(propsWindows: { [windowId: string]: ReactNode; -}): (key: WindowId, e: React.MouseEvent) => void { +}): ( + key: WindowId, + e: React.MouseEvent | React.TouchEvent +) => void { const windowsInfo = useTypedSelector(Selectors.getWindowsInfo); const getWindowHidden = useTypedSelector(Selectors.getWindowHidden); const browserWindowSize = useTypedSelector(Selectors.getBrowserWindowSize); @@ -41,10 +45,10 @@ function useHandleMouseDown(propsWindows: { return; } const { boundingBox, moving, stationary, mouseStart } = draggingState; - const handleMouseMove = (ee: MouseEvent) => { + const handleMouseMove = (ee: MouseEvent | TouchEvent) => { const proposedDiff = { - x: ee.clientX - mouseStart.x, - y: ee.clientY - mouseStart.y, + x: Utils.getX(ee) - mouseStart.x, + y: Utils.getY(ee) - mouseStart.y, }; const proposedWindows = moving.map((node) => ({ @@ -86,21 +90,32 @@ function useHandleMouseDown(propsWindows: { } window.addEventListener("mouseup", handleMouseUp); - window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("touchend", handleMouseUp); + + window.addEventListener("mousemove", handleMouseMove, { passive: false }); + window.addEventListener("touchmove", handleMouseMove, { passive: false }); return () => { window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("touchmove", handleMouseMove); window.removeEventListener("mouseup", handleMouseUp); + window.removeEventListener("touchend", handleMouseUp); }; }, [browserWindowSize, draggingState, updateWindowPositions]); // Mouse down handler return useCallback( - (key: WindowId, e: React.MouseEvent) => { + ( + key: WindowId, + e: React.MouseEvent | React.TouchEvent + ) => { if (!(e.target as HTMLElement).classList.contains("draggable")) { return; } + const x = Utils.getX(e); + const y = Utils.getY(e); + if (getWindowHidden(key)) { // The user may be clicking on full screen Milkdrop. return; @@ -124,7 +139,7 @@ function useHandleMouseDown(propsWindows: { const stationary = windows.filter((w) => !movingSet.has(w)); const moving = Array.from(movingSet); - const mouseStart = { x: e.clientX, y: e.clientY }; + const mouseStart = { x, y }; const boundingBox = SnapUtils.boundingBox(moving); setDraggingState({ boundingBox, moving, stationary, mouseStart }); @@ -164,11 +179,15 @@ export default function WindowManager({ windows: propsWindows }: Props) { onMouseDown={(e: React.MouseEvent) => { handleMouseDown(w.key, e); }} + onTouchStart={(e: React.TouchEvent) => { + handleMouseDown(w.key, e); + }} style={{ position: "absolute", top: 0, left: 0, transform: `translate(${w.x}px, ${w.y}px)`, + touchAction: "none", }} > {propsWindows[w.key]} diff --git a/packages/webamp/js/utils.ts b/packages/webamp/js/utils.ts index 29a3a8ea..38654084 100644 --- a/packages/webamp/js/utils.ts +++ b/packages/webamp/js/utils.ts @@ -399,6 +399,36 @@ export function getScreenSize(): { width: number; height: number } { }; } +type PosEvent = + | MouseEvent + | TouchEvent + | React.MouseEvent + | React.TouchEvent; + +function getPos(e: PosEvent): { clientX: number; clientY: number } { + switch (e.type) { + case "touchstart": + case "touchmove": { + const touch = (e as TouchEvent).targetTouches[0]; + return touch; + } + case "mousedown": + case "mousemove": { + return e as MouseEvent; + } + default: + throw new Error(`Unexpected event type: ${e.type}`); + } +} + +export function getX(e: PosEvent) { + return getPos(e).clientX; +} + +export function getY(e: PosEvent) { + return getPos(e).clientY; +} + export function weakMapMemoize( func: (value: T) => R ): (value: T) => R {