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;
|
// `touchAction: pan-y` keeps the panel-internal scroll responsive;
|
||||||
// the drag-up close gesture preventDefault's explicitly inside the
|
// 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({
|
export const panelViewport = style({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
willChange: 'height',
|
willChange: 'height',
|
||||||
touchAction: 'pan-y',
|
touchAction: 'pan-y',
|
||||||
|
userSelect: 'none',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const panelContent = style({
|
export const panelContent = style({
|
||||||
|
|
@ -158,11 +163,14 @@ export const avatarFullFallback = style({
|
||||||
// bottom child. Same CSS-grid `1fr → 0fr` row-track trick the legacy
|
// bottom child. Same CSS-grid `1fr → 0fr` row-track trick the legacy
|
||||||
// `headerWrap` used so the row animates smoothly without measuring
|
// `headerWrap` used so the row animates smoothly without measuring
|
||||||
// the header's intrinsic height in JS. The inner div clips the
|
// 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({
|
export const headerViewport = style({
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
willChange: 'grid-template-rows',
|
willChange: 'grid-template-rows',
|
||||||
|
userSelect: 'none',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const headerViewportInner = style({
|
export const headerViewportInner = style({
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,16 @@ type RoomViewProfilePanelProps = {
|
||||||
|
|
||||||
type DragState = {
|
type DragState = {
|
||||||
source: 'header' | 'panel';
|
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;
|
startY: number;
|
||||||
deltaY: number;
|
deltaY: number;
|
||||||
};
|
};
|
||||||
|
|
@ -186,24 +196,11 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
|
||||||
return { x: 0, y: 0, width: 0, height: 0 };
|
return { x: 0, y: 0, width: 0, height: 0 };
|
||||||
};
|
};
|
||||||
|
|
||||||
const onHeaderTouchStart = (e: TouchEvent) => {
|
// Shared commit + cancel logic between touch and pointer paths.
|
||||||
if (profileStateRef.current) return;
|
const applyMove = (clientY: number, e: TouchEvent | PointerEvent) => {
|
||||||
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) => {
|
|
||||||
const d = dragRef.current;
|
const d = dragRef.current;
|
||||||
if (!d) return;
|
if (!d) return;
|
||||||
const touch = e.touches[0];
|
const rawDelta = clientY - d.startY;
|
||||||
const rawDelta = touch.clientY - d.startY;
|
|
||||||
let nextDelta = rawDelta;
|
let nextDelta = rawDelta;
|
||||||
if (d.source === 'header') {
|
if (d.source === 'header') {
|
||||||
nextDelta = Math.max(0, rawDelta);
|
nextDelta = Math.max(0, rawDelta);
|
||||||
|
|
@ -215,7 +212,7 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
|
||||||
setDrag({ ...d, deltaY: nextDelta });
|
setDrag({ ...d, deltaY: nextDelta });
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTouchEnd = () => {
|
const applyEnd = () => {
|
||||||
const d = dragRef.current;
|
const d = dragRef.current;
|
||||||
if (!d) return;
|
if (!d) return;
|
||||||
if (d.source === 'header' && d.deltaY > COMMIT_THRESHOLD_PX) {
|
if (d.source === 'header' && d.deltaY > COMMIT_THRESHOLD_PX) {
|
||||||
|
|
@ -233,18 +230,97 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
|
||||||
setDrag(null);
|
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) {
|
if (headerEl) {
|
||||||
headerEl.addEventListener('touchstart', onHeaderTouchStart, { passive: true });
|
headerEl.addEventListener('touchstart', onHeaderTouchStart, { passive: true });
|
||||||
headerEl.addEventListener('touchmove', onTouchMove, { passive: false });
|
headerEl.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||||
headerEl.addEventListener('touchend', onTouchEnd, { passive: true });
|
headerEl.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||||
headerEl.addEventListener('touchcancel', onTouchEnd, { passive: true });
|
headerEl.addEventListener('touchcancel', onTouchEnd, { passive: true });
|
||||||
|
headerEl.addEventListener('pointerdown', onHeaderPointerDown);
|
||||||
}
|
}
|
||||||
if (panelEl) {
|
if (panelEl) {
|
||||||
panelEl.addEventListener('touchstart', onPanelTouchStart, { passive: true });
|
panelEl.addEventListener('touchstart', onPanelTouchStart, { passive: true });
|
||||||
panelEl.addEventListener('touchmove', onTouchMove, { passive: false });
|
panelEl.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||||
panelEl.addEventListener('touchend', onTouchEnd, { passive: true });
|
panelEl.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||||
panelEl.addEventListener('touchcancel', 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 () => {
|
return () => {
|
||||||
if (headerEl) {
|
if (headerEl) {
|
||||||
|
|
@ -252,13 +328,18 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
|
||||||
headerEl.removeEventListener('touchmove', onTouchMove);
|
headerEl.removeEventListener('touchmove', onTouchMove);
|
||||||
headerEl.removeEventListener('touchend', onTouchEnd);
|
headerEl.removeEventListener('touchend', onTouchEnd);
|
||||||
headerEl.removeEventListener('touchcancel', onTouchEnd);
|
headerEl.removeEventListener('touchcancel', onTouchEnd);
|
||||||
|
headerEl.removeEventListener('pointerdown', onHeaderPointerDown);
|
||||||
}
|
}
|
||||||
if (panelEl) {
|
if (panelEl) {
|
||||||
panelEl.removeEventListener('touchstart', onPanelTouchStart);
|
panelEl.removeEventListener('touchstart', onPanelTouchStart);
|
||||||
panelEl.removeEventListener('touchmove', onTouchMove);
|
panelEl.removeEventListener('touchmove', onTouchMove);
|
||||||
panelEl.removeEventListener('touchend', onTouchEnd);
|
panelEl.removeEventListener('touchend', onTouchEnd);
|
||||||
panelEl.removeEventListener('touchcancel', onTouchEnd);
|
panelEl.removeEventListener('touchcancel', onTouchEnd);
|
||||||
|
panelEl.removeEventListener('pointerdown', onPanelPointerDown);
|
||||||
}
|
}
|
||||||
|
document.removeEventListener('pointermove', onDocumentPointerMove);
|
||||||
|
document.removeEventListener('pointerup', onDocumentPointerEnd);
|
||||||
|
document.removeEventListener('pointercancel', onDocumentPointerEnd);
|
||||||
};
|
};
|
||||||
}, [headerDragEnabled, headerDragPeer]);
|
}, [headerDragEnabled, headerDragPeer]);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue