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:
parent
2bbaf4dfcf
commit
c992e910ee
2 changed files with 108 additions and 19 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue