Improve touch handling (#1098)

* Improve touch handling

* Document touch event changes
This commit is contained in:
Jordan Eldredge 2021-06-08 18:05:22 -07:00 committed by GitHub
parent e1305819bd
commit b5b7345308
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 155 additions and 36 deletions

View file

@ -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

View file

@ -91,9 +91,7 @@ export function scrollNTracks(n: number): Thunk {
};
}
export function scrollPlaylistByDelta(
e: React.WheelEvent<HTMLDivElement>
): Thunk {
export function scrollPlaylistByDelta(e: WheelEvent): Thunk {
e.preventDefault();
return (dispatch, getState) => {
const state = getState();

View file

@ -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}

View file

@ -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<HTMLDivElement>): void => {
setMouseDownX(e.clientX);
(
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
): void => {
setMouseDownX(Utils.getX(e));
},
[]
);
@ -131,6 +138,7 @@ const Marquee = React.memo(() => {
id="marquee"
className="text"
onMouseDown={handleMouseDown}
onTouchStart={handleMouseDown}
title="Song Title"
>
<div

View file

@ -65,6 +65,7 @@ const Position = memo(() => {
min="0"
max="100"
step="1"
style={{ touchAction: "none" }}
value={displayedPosition}
onInput={setPosition}
onChange={

View file

@ -16,7 +16,9 @@ import {
interface Props {
id: number;
index: number;
handleMoveClick: (e: React.MouseEvent<HTMLDivElement>) => void;
handleMoveClick: (
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
) => 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)}
>

View file

@ -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<Element | null>(null);
const [node, setNode] = useState<HTMLElement | null>(null);
const [moving, setMoving] = useState(false);
const [mouseStartY, setMouseStartY] = useState<number | null>(null);
const _handleMoveClick = (e: React.MouseEvent<HTMLDivElement>) => {
const _handleMoveClick = (
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
) => {
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 (
<div
ref={setNode}
className="playlist-tracks"
style={{ height: "100%" }}
onClick={selectZero}
onWheel={scrollPlaylistByDelta}
>
<div className="playlist-track-titles">
{_renderTracks((id, i) => (
@ -95,7 +118,7 @@ function TrackList() {
))}
</div>
<div className="playlist-track-durations">
{_renderTracks((id) => getTimeStr(tracks[id].duration))}
{_renderTracks((id) => Utils.getTimeStr(tracks[id].duration))}
</div>
</div>
);

View file

@ -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<HTMLDivElement>) => {
// Prevent dragging from highlighting text.
e.preventDefault();
const handleMouseDown = (
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
) => {
setMouseStart({
x: e.clientX,
y: e.clientY,
x: Utils.getX(e),
y: Utils.getY(e),
});
setMouseDown(true);
};
return <div onMouseDown={handleMouseDown} {...passThroughProps} />;
return (
<div
onMouseDown={handleMouseDown}
onTouchStart={handleMouseDown}
{...passThroughProps}
/>
);
}
export default memo(ResizeTarget);

View file

@ -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"
/>
);

View file

@ -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<HTMLDivElement>) => void {
}): (
key: WindowId,
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
) => 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<HTMLDivElement>) => {
(
key: WindowId,
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
) => {
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<HTMLDivElement>) => {
handleMouseDown(w.key, e);
}}
onTouchStart={(e: React.TouchEvent<HTMLDivElement>) => {
handleMouseDown(w.key, e);
}}
style={{
position: "absolute",
top: 0,
left: 0,
transform: `translate(${w.x}px, ${w.y}px)`,
touchAction: "none",
}}
>
{propsWindows[w.key]}

View file

@ -399,6 +399,36 @@ export function getScreenSize(): { width: number; height: number } {
};
}
type PosEvent =
| MouseEvent
| TouchEvent
| React.MouseEvent<HTMLElement>
| React.TouchEvent<HTMLElement>;
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<T extends object, R>(
func: (value: T) => R
): (value: T) => R {