feat(profile): add mouse drag for mobile horseshoe via pointer events with origin-tagged drag state and userSelect:none on viewports

This commit is contained in:
heaven 2026-05-11 02:22:28 +03:00
parent 2bbaf4dfcf
commit c992e910ee
2 changed files with 108 additions and 19 deletions

View file

@ -56,13 +56,18 @@ export const silhouette = style({
//
// `touchAction: pan-y` keeps the panel-internal scroll responsive;
// the drag-up close gesture preventDefault's explicitly inside the
// touchmove handler.
// touchmove handler. `userSelect: none` keeps mouse drag clean — on
// the pointer path a left-button drag would otherwise race the
// browser's native text-selection drag (selection highlight grows
// during the gesture and the user has to click once to deselect
// after release).
export const panelViewport = style({
position: 'relative',
width: '100%',
overflow: 'hidden',
willChange: 'height',
touchAction: 'pan-y',
userSelect: 'none',
});
export const panelContent = style({
@ -158,11 +163,14 @@ export const avatarFullFallback = style({
// bottom child. Same CSS-grid `1fr → 0fr` row-track trick the legacy
// `headerWrap` used so the row animates smoothly without measuring
// the header's intrinsic height in JS. The inner div clips the
// header content as the track collapses.
// header content as the track collapses. `userSelect: none` keeps a
// mouse drag-down-to-open gesture from racing native text-selection
// on the chat title — same rationale as on `panelViewport`.
export const headerViewport = style({
display: 'grid',
flexShrink: 0,
willChange: 'grid-template-rows',
userSelect: 'none',
});
export const headerViewportInner = style({

View file

@ -86,6 +86,16 @@ type RoomViewProfilePanelProps = {
type DragState = {
source: 'header' | 'panel';
// Origin tag — `touch` for finger drags, `pointer` for mouse/pen.
// Each path filters by this so an in-flight drag started by one
// input device can never be advanced or ended by the other. The
// failure mode it prevents: hybrid laptop (touchscreen + mouse)
// where a touchstart fires but its matching touchend gets dropped
// by the OS / browser, leaving `dragRef.current` populated; if the
// user then moves the mouse, the pointer-path handlers would
// otherwise apply mouse clientY against the touch's startY and
// commit a bogus close. Tagging makes each path self-isolating.
inputType: 'touch' | 'pointer';
startY: number;
deltaY: number;
};
@ -186,24 +196,11 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
return { x: 0, y: 0, width: 0, height: 0 };
};
const onHeaderTouchStart = (e: TouchEvent) => {
if (profileStateRef.current) return;
if (!headerDragEnabled) return;
const touch = e.touches[0];
setDrag({ source: 'header', startY: touch.clientY, deltaY: 0 });
};
const onPanelTouchStart = (e: TouchEvent) => {
if (!profileStateRef.current) return;
const touch = e.touches[0];
setDrag({ source: 'panel', startY: touch.clientY, deltaY: 0 });
};
const onTouchMove = (e: TouchEvent) => {
// Shared commit + cancel logic between touch and pointer paths.
const applyMove = (clientY: number, e: TouchEvent | PointerEvent) => {
const d = dragRef.current;
if (!d) return;
const touch = e.touches[0];
const rawDelta = touch.clientY - d.startY;
const rawDelta = clientY - d.startY;
let nextDelta = rawDelta;
if (d.source === 'header') {
nextDelta = Math.max(0, rawDelta);
@ -215,7 +212,7 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
setDrag({ ...d, deltaY: nextDelta });
};
const onTouchEnd = () => {
const applyEnd = () => {
const d = dragRef.current;
if (!d) return;
if (d.source === 'header' && d.deltaY > COMMIT_THRESHOLD_PX) {
@ -233,18 +230,97 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
setDrag(null);
};
// === Touch path === Unchanged from the touch-only implementation.
// Touch events have implicit pointer-capture semantics (touchmove
// keeps firing on the touchstart element until touchend) so we
// don't need to escalate to document-level listeners the way the
// mouse path below does. Kept separate from the pointer path so
// touch UX is bit-identical to the prior version.
const onHeaderTouchStart = (e: TouchEvent) => {
if (dragRef.current) return;
if (profileStateRef.current) return;
if (!headerDragEnabled) return;
const touch = e.touches[0];
setDrag({ source: 'header', inputType: 'touch', startY: touch.clientY, deltaY: 0 });
};
const onPanelTouchStart = (e: TouchEvent) => {
if (dragRef.current) return;
if (!profileStateRef.current) return;
const touch = e.touches[0];
setDrag({ source: 'panel', inputType: 'touch', startY: touch.clientY, deltaY: 0 });
};
const onTouchMove = (e: TouchEvent) => {
const d = dragRef.current;
if (!d || d.inputType !== 'touch') return;
applyMove(e.touches[0].clientY, e);
};
const onTouchEnd = () => {
const d = dragRef.current;
if (!d || d.inputType !== 'touch') return;
applyEnd();
};
// === Mouse / pen path === Pointer events gated on
// `pointerType !== 'touch'` so touch devices stay on the touch
// path above and never double-fire. pointermove + pointerup are
// attached to `document` (not the trigger element) so the drag
// keeps tracking when the mouse cursor wanders past the element
// bounds — common when the user yanks the rail up fast and
// overshoots the panel. The alternative (setPointerCapture on the
// trigger) would steal pointerup from inner interactive children
// (kebab popout button inside the user card, mention links) and
// break their click handlers — document-level listeners avoid
// that hijack entirely.
const onHeaderPointerDown = (e: PointerEvent) => {
if (e.pointerType === 'touch') return;
if (dragRef.current) return;
if (profileStateRef.current) return;
if (!headerDragEnabled) return;
if (e.button !== 0) return;
setDrag({ source: 'header', inputType: 'pointer', startY: e.clientY, deltaY: 0 });
};
const onPanelPointerDown = (e: PointerEvent) => {
if (e.pointerType === 'touch') return;
if (dragRef.current) return;
if (!profileStateRef.current) return;
if (e.button !== 0) return;
setDrag({ source: 'panel', inputType: 'pointer', startY: e.clientY, deltaY: 0 });
};
const onDocumentPointerMove = (e: PointerEvent) => {
if (e.pointerType === 'touch') return;
const d = dragRef.current;
if (!d || d.inputType !== 'pointer') return;
applyMove(e.clientY, e);
};
const onDocumentPointerEnd = (e: PointerEvent) => {
if (e.pointerType === 'touch') return;
const d = dragRef.current;
if (!d || d.inputType !== 'pointer') return;
applyEnd();
};
if (headerEl) {
headerEl.addEventListener('touchstart', onHeaderTouchStart, { passive: true });
headerEl.addEventListener('touchmove', onTouchMove, { passive: false });
headerEl.addEventListener('touchend', onTouchEnd, { passive: true });
headerEl.addEventListener('touchcancel', onTouchEnd, { passive: true });
headerEl.addEventListener('pointerdown', onHeaderPointerDown);
}
if (panelEl) {
panelEl.addEventListener('touchstart', onPanelTouchStart, { passive: true });
panelEl.addEventListener('touchmove', onTouchMove, { passive: false });
panelEl.addEventListener('touchend', onTouchEnd, { passive: true });
panelEl.addEventListener('touchcancel', onTouchEnd, { passive: true });
panelEl.addEventListener('pointerdown', onPanelPointerDown);
}
document.addEventListener('pointermove', onDocumentPointerMove, { passive: false });
document.addEventListener('pointerup', onDocumentPointerEnd, { passive: true });
document.addEventListener('pointercancel', onDocumentPointerEnd, { passive: true });
return () => {
if (headerEl) {
@ -252,13 +328,18 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
headerEl.removeEventListener('touchmove', onTouchMove);
headerEl.removeEventListener('touchend', onTouchEnd);
headerEl.removeEventListener('touchcancel', onTouchEnd);
headerEl.removeEventListener('pointerdown', onHeaderPointerDown);
}
if (panelEl) {
panelEl.removeEventListener('touchstart', onPanelTouchStart);
panelEl.removeEventListener('touchmove', onTouchMove);
panelEl.removeEventListener('touchend', onTouchEnd);
panelEl.removeEventListener('touchcancel', onTouchEnd);
panelEl.removeEventListener('pointerdown', onPanelPointerDown);
}
document.removeEventListener('pointermove', onDocumentPointerMove);
document.removeEventListener('pointerup', onDocumentPointerEnd);
document.removeEventListener('pointercancel', onDocumentPointerEnd);
};
}, [headerDragEnabled, headerDragPeer]);