From c992e910eeef9db19e9ed93096a4ef9d5cfdb8c1 Mon Sep 17 00:00:00 2001 From: heaven Date: Mon, 11 May 2026 02:22:28 +0300 Subject: [PATCH] feat(profile): add mouse drag for mobile horseshoe via pointer events with origin-tagged drag state and userSelect:none on viewports --- .../features/room/RoomViewProfilePanel.css.ts | 12 +- .../features/room/RoomViewProfilePanel.tsx | 115 +++++++++++++++--- 2 files changed, 108 insertions(+), 19 deletions(-) diff --git a/src/app/features/room/RoomViewProfilePanel.css.ts b/src/app/features/room/RoomViewProfilePanel.css.ts index e1741977..3831f24f 100644 --- a/src/app/features/room/RoomViewProfilePanel.css.ts +++ b/src/app/features/room/RoomViewProfilePanel.css.ts @@ -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({ diff --git a/src/app/features/room/RoomViewProfilePanel.tsx b/src/app/features/room/RoomViewProfilePanel.tsx index 56236769..97cac006 100644 --- a/src/app/features/room/RoomViewProfilePanel.tsx +++ b/src/app/features/room/RoomViewProfilePanel.tsx @@ -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]);