feat(mobile): single-pane stack + long-press tab menu + swipe-to-close
- Workspace: on mobile renders only panes[activePaneIdx] (rest skipped).
When panes.length > 1, adds a horizontal pane-navigator strip built
from SwipeablePaneTab above the active pane. URL state ?pane=<paneId>
written by switchActivePane (user-initiated only) and read on URL
change (back-button + deep-link). Bare URL resets activePaneIdx to 0.
- useLongPress: 500ms touchstart timer; on fire, dispatches a synthetic
contextmenu event on target.closest('[data-tab-id]') so the existing
Radix ContextMenuTrigger opens at the touch coordinates. Suppresses
the synthetic click that follows touchend. Cancels on touchmove /
touchend / touchcancel.
- ChatTabBar: each tab gets data-tab-id, touch handlers wired to
useLongPress, and WebkitTouchCallout: 'none' to disable iOS Safari's
text-selection callout.
- SwipeablePaneTab: tracks horizontal drag; bails if vertical delta
exceeds 30px (so vertical scroll still works); past 60px on release
fires onClose (removePane), else snaps back. Opacity fades 1->0.4
approaching the threshold. Hand-rolled per spec.
- Pane drag-and-drop disabled on mobile (HTML5 drag is broken on touch
anyway; mobile uses the navigator + swipe-to-close instead).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
75
apps/web/src/hooks/useLongPress.ts
Normal file
75
apps/web/src/hooks/useLongPress.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import type { TouchEvent } from 'react';
|
||||
|
||||
interface LongPressHandlers {
|
||||
onTouchStart: (e: TouchEvent) => void;
|
||||
onTouchMove: (e: TouchEvent) => void;
|
||||
onTouchEnd: (e: TouchEvent) => void;
|
||||
onTouchCancel: (e: TouchEvent) => void;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
ms?: number;
|
||||
// Suppress the synthetic click that follows touchend when long-press fired.
|
||||
suppressClickOnFire?: boolean;
|
||||
}
|
||||
|
||||
// Hand-rolled long-press detector. Starts a timer on touchstart; cancels on
|
||||
// touchmove or early touchend; fires the callback on timer expiry. Caller is
|
||||
// expected to suppress text-selection callout via CSS (-webkit-touch-callout).
|
||||
export function useLongPress(
|
||||
callback: (touch: { clientX: number; clientY: number; target: EventTarget | null }) => void,
|
||||
{ ms = 500, suppressClickOnFire = true }: Options = {},
|
||||
): LongPressHandlers {
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const firedRef = useRef(false);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
if (timerRef.current !== null) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onTouchStart = useCallback(
|
||||
(e: TouchEvent) => {
|
||||
firedRef.current = false;
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
const x = touch.clientX;
|
||||
const y = touch.clientY;
|
||||
const target = e.target;
|
||||
clear();
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
firedRef.current = true;
|
||||
callback({ clientX: x, clientY: y, target });
|
||||
}, ms);
|
||||
},
|
||||
[callback, ms, clear],
|
||||
);
|
||||
|
||||
const onTouchMove = useCallback(() => {
|
||||
clear();
|
||||
}, [clear]);
|
||||
|
||||
const onTouchEnd = useCallback(
|
||||
(e: TouchEvent) => {
|
||||
clear();
|
||||
if (firedRef.current && suppressClickOnFire) {
|
||||
// Block the synthetic click that follows touchend; the long-press
|
||||
// already handled the gesture.
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
[clear, suppressClickOnFire],
|
||||
);
|
||||
|
||||
const onTouchCancel = useCallback(
|
||||
(_e: TouchEvent) => {
|
||||
clear();
|
||||
},
|
||||
[clear],
|
||||
);
|
||||
|
||||
return { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel };
|
||||
}
|
||||
Reference in New Issue
Block a user