- 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>
76 lines
2.1 KiB
TypeScript
76 lines
2.1 KiB
TypeScript
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 };
|
|
}
|