UX: improve touch, swipe, panning performance on mobile menus (#23775)

PERF: improve touch, swipe, panning performance on mobile menus
---

* stop event propagation on swipe events: other touch events were stealing a huge amount of time here. Stop event
propagation when handling pan events.
* animate with [web animations api](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API/Using_the_Web_Animations_API)
* prefer translate3d to hint for gpu rendering.
* query document for elements only on start move event, not on subsequent move
events
* remove unused calculations for directioned velocity and distance: all swipe/pan elements function in x/y direction only.
* re-implement scroll locking behavior.

re-implemented scroll lock behavior
---

With stop event propagation, we need to re-implement scroll locking on menu swipes.
Previously, this was using onTouchMove which was costly.

We may now use styling with overflow-y:hidden to lock scroll behavior.

overflow:hidden on html/body elements is now supported by iOS as of 2022
https://bugs.webkit.org/show_bug.cgi?id=153852
https://bugs.webkit.org/show_bug.cgi?id=220908

UX: improve swipe
---

Some improvements to get gestures and swipes feeling a little more polished.

This focuses on end gesture, and how we transfer it to a css animation to
complete a menu open/close action.

Multitouch: events may pan, scroll, and zoom - especially on iOS safari.
Cancelling the swipe event allows for a more pleasant zooming experience.

* ease-out on menus opening, linear on close
* calculate animation duration for opening and closing,
attempt to better transfer user swipe velocity to css animation.
* more timely close/open and cleanup from calculated animation timing.
* add animation to closing menus on cloak tap
* correctly animate menus with ease-in and ease-out
* add swipe cancel event on multitouch event

DEV
---

* lean on promises

js animations api gives us promises to listen to. Update test waiters
to use waitForPromise from @ember/test-waiters instead of reigster/unregister.

* convert swipe mixin to its own class.

Convert swipe callbacks to custom events on the element.
Move shared functions for max animation time and close logic to
new shared class.

swipe-events lib uses custom events to trigger callbacks, rather than assuming
implemented hard coded function from the mixin's base class. Custom events are
triggered from the bound element as swipestart, swipeend, swipe

Add shared convenience functions for swipe events so they can be more easily
shared.

A client receives an initial swipe event and can check some state to see if it
wants to handle the swipe event and if it doesn't, calling
`event.preventDefault();` will prevent `swipe` and `swipeend` events from firing
until another distinct swipestart event is fired. Swipe events will auto-cancel on multitouch.

The scroll lock has also exposed as its own utility class.
This commit is contained in:
Jeff Wong 2023-10-16 11:27:00 -07:00 committed by GitHub
parent d1ef6ab99f
commit d208396c5c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 550 additions and 447 deletions

View File

@ -1,35 +1,30 @@
import { DEBUG } from "@glimmer/env";
import { cancel, schedule } from "@ember/runloop";
import { registerWaiter, unregisterWaiter } from "@ember/test";
import { schedule } from "@ember/runloop";
import { waitForPromise } from "@ember/test-waiters";
import ItsATrap from "@discourse/itsatrap";
import MountWidget from "discourse/components/mount-widget";
import { topicTitleDecorators } from "discourse/components/topic-title";
import scrollLock from "discourse/lib/scroll-lock";
import SwipeEvents from "discourse/lib/swipe-events";
import Docking from "discourse/mixins/docking";
import PanEvents, {
SWIPE_DISTANCE_THRESHOLD,
SWIPE_VELOCITY_THRESHOLD,
} from "discourse/mixins/pan-events";
import RerenderOnDoNotDisturbChange from "discourse/mixins/rerender-on-do-not-disturb-change";
import { isTesting } from "discourse-common/config/environment";
import discourseLater from "discourse-common/lib/later";
import { bind, observes } from "discourse-common/utils/decorators";
const SiteHeaderComponent = MountWidget.extend(
Docking,
PanEvents,
RerenderOnDoNotDisturbChange,
{
widget: "header",
docAt: null,
dockedHeader: null,
_animate: false,
_isPanning: false,
_panMenuOrigin: "right",
_panMenuOffset: 0,
_scheduledRemoveAnimate: null,
_swipeMenuOrigin: "right",
_topic: null,
_itsatrap: null,
_applicationElement: null,
_PANEL_WIDTH: 320,
_swipeEvents: null,
@observes(
"currentUser.unread_notifications",
@ -57,55 +52,57 @@ const SiteHeaderComponent = MountWidget.extend(
}
},
_animateOpening(panel) {
let waiter;
if (DEBUG && isTesting()) {
waiter = () => false;
registerWaiter(waiter);
}
window.requestAnimationFrame(() => {
this._setAnimateOpeningProperties(panel);
if (DEBUG && isTesting()) {
unregisterWaiter(waiter);
}
});
},
_setAnimateOpeningProperties(panel) {
_animateOpening(panel, event = null) {
const headerCloak = document.querySelector(".header-cloak");
panel.classList.add("animate");
headerCloak.classList.add("animate");
this._scheduledRemoveAnimate = discourseLater(() => {
panel.classList.remove("animate");
headerCloak.classList.remove("animate");
}, 200);
panel.style.setProperty("--offset", 0);
headerCloak.style.setProperty("--opacity", 0.5);
this._panMenuOffset = 0;
let durationMs = this._swipeEvents.getMaxAnimationTimeMs();
if (event && this.pxClosed > 0) {
durationMs = this._swipeEvents.getMaxAnimationTimeMs(
this.pxClosed / Math.abs(event.velocityX)
);
}
const timing = {
duration: durationMs,
fill: "forwards",
easing: "ease-out",
};
panel
.animate([{ transform: `translate3d(0, 0, 0)` }], timing)
.finished.then(() => {
panel.classList.remove("animating");
});
headerCloak.animate([{ opacity: 1 }], timing);
this.pxClosed = null;
},
_animateClosing(panel, menuOrigin) {
_animateClosing(event, panel, menuOrigin) {
this._animate = true;
const headerCloak = document.querySelector(".header-cloak");
panel.classList.add("animate");
headerCloak.classList.add("animate");
if (menuOrigin === "left") {
panel.style.setProperty("--offset", `-100vw`);
} else {
panel.style.setProperty("--offset", `100vw`);
let durationMs = this._swipeEvents.getMaxAnimationTimeMs();
if (event && this.pxClosed > 0) {
const distancePx = this._PANEL_WIDTH - this.pxClosed;
durationMs = this._swipeEvents.getMaxAnimationTimeMs(
distancePx / Math.abs(event.velocityX)
);
}
const timing = {
duration: durationMs,
fill: "forwards",
};
headerCloak.style.setProperty("--opacity", 0);
this._scheduledRemoveAnimate = discourseLater(() => {
panel.classList.remove("animate");
headerCloak.classList.remove("animate");
schedule("afterRender", () => {
this.eventDispatched("dom:clean", "header");
this._panMenuOffset = 0;
let endPosition = -this._PANEL_WIDTH; //origin left
if (menuOrigin === "right") {
endPosition = this._PANEL_WIDTH;
}
panel
.animate([{ transform: `translate3d(${endPosition}px, 0, 0)` }], timing)
.finished.then(() => {
schedule("afterRender", () => {
this.eventDispatched("dom:clean", "header");
});
});
}, 200);
headerCloak.animate([{ opacity: 0 }], timing);
this.pxClosed = null;
},
_isRTL() {
@ -116,41 +113,11 @@ const SiteHeaderComponent = MountWidget.extend(
return this._isRTL() ? "user-menu" : "hamburger-panel";
},
_handlePanDone(event) {
const menuPanels = document.querySelectorAll(".menu-panel");
const menuOrigin = this._panMenuOrigin;
menuPanels.forEach((panel) => {
panel.classList.remove("moving");
if (this._shouldMenuClose(event, menuOrigin)) {
this._animateClosing(panel, menuOrigin);
} else {
this._animateOpening(panel);
}
});
},
_shouldMenuClose(e, menuOrigin) {
// menu should close after a pan either:
// if a user moved the panel closed past a threshold and away and is NOT swiping back open
// if a user swiped to close fast enough regardless of distance
if (menuOrigin === "right") {
return (
(e.deltaX > SWIPE_DISTANCE_THRESHOLD &&
e.velocityX > -SWIPE_VELOCITY_THRESHOLD) ||
e.velocityX > 0
);
} else {
return (
(e.deltaX < -SWIPE_DISTANCE_THRESHOLD &&
e.velocityX < SWIPE_VELOCITY_THRESHOLD) ||
e.velocityX < 0
);
}
},
panStart(e) {
@bind
onSwipeStart(event) {
const e = event.detail;
const center = e.center;
const panOverValidElement = document
const swipeOverValidElement = document
.elementsFromPoint(center.x, center.y)
.some(
(ele) =>
@ -158,49 +125,65 @@ const SiteHeaderComponent = MountWidget.extend(
ele.classList.contains("header-cloak")
);
if (
panOverValidElement &&
swipeOverValidElement &&
(e.direction === "left" || e.direction === "right")
) {
e.originalEvent.preventDefault();
this._isPanning = true;
const panel = document.querySelector(".menu-panel");
if (panel) {
panel.classList.add("moving");
this.movingElement = document.querySelector(".menu-panel");
this.cloakElement = document.querySelector(".header-cloak");
scrollLock(true, document.querySelector(".panel-body"));
} else {
event.preventDefault();
}
},
@bind
onSwipeEnd(event) {
const e = event.detail;
const menuPanels = document.querySelectorAll(".menu-panel");
const menuOrigin = this._swipeMenuOrigin;
scrollLock(false, document.querySelector(".panel-body"));
menuPanels.forEach((panel) => {
if (this._swipeEvents.shouldCloseMenu(e, menuOrigin)) {
this._animateClosing(e, panel, menuOrigin);
} else {
this._animateOpening(panel, e);
}
} else {
this._isPanning = false;
}
});
},
panEnd(e) {
if (!this._isPanning) {
return;
}
this._isPanning = false;
this._handlePanDone(e);
@bind
onSwipeCancel() {
const menuPanels = document.querySelectorAll(".menu-panel");
scrollLock(false, document.querySelector(".panel-body"));
menuPanels.forEach((panel) => {
this._animateOpening(panel);
});
},
panMove(e) {
if (!this._isPanning) {
return;
}
const panel = document.querySelector(".menu-panel");
const headerCloak = document.querySelector(".header-cloak");
if (this._panMenuOrigin === "right") {
const pxClosed = Math.min(0, -e.deltaX + this._panMenuOffset);
panel.style.setProperty("--offset", `${-pxClosed}px`);
headerCloak.style.setProperty(
"--opacity",
Math.min(0.5, (300 + pxClosed) / 600)
);
} else {
const pxClosed = Math.min(0, e.deltaX + this._panMenuOffset);
panel.style.setProperty("--offset", `${pxClosed}px`);
headerCloak.style.setProperty(
"--opacity",
Math.min(0.5, (300 + pxClosed) / 600)
);
@bind
onSwipe(event) {
const e = event.detail;
const panel = this.movingElement;
const headerCloak = this.cloakElement;
//origin left
this.pxClosed = Math.max(0, -e.deltaX);
let translation = -this.pxClosed;
if (this._swipeMenuOrigin === "right") {
this.pxClosed = Math.max(0, e.deltaX);
translation = this.pxClosed;
}
panel.animate([{ transform: `translate3d(${translation}px, 0, 0)` }], {
fill: "forwards",
});
headerCloak.animate(
[
{
opacity: (this._PANEL_WIDTH - this.pxClosed) / this._PANEL_WIDTH,
},
],
{ fill: "forwards" }
);
},
dockCheck() {
@ -333,8 +316,6 @@ const SiteHeaderComponent = MountWidget.extend(
this.currentUser.off("status-changed", this, "queueRerender");
}
cancel(this._scheduledRemoveAnimate);
this._itsatrap?.destroy();
this._itsatrap = null;
},
@ -374,35 +355,39 @@ const SiteHeaderComponent = MountWidget.extend(
menuPanels.forEach((panel) => {
const headerCloak = document.querySelector(".header-cloak");
let width = parseInt(panel.getAttribute("data-max-width"), 10) || 300;
if (this._panMenuOffset) {
this._panMenuOffset = -width;
}
panel.classList.remove("drop-down");
panel.classList.remove("slide-in");
panel.classList.add(viewMode);
if (this._animate || this._panMenuOffset !== 0) {
if (this._animate) {
let animationFinished = null;
let finalPosition = this._PANEL_WIDTH;
this._swipeMenuOrigin = "right";
if (
(this.site.mobileView || this.site.narrowDesktopView) &&
panel.parentElement.classList.contains(this._leftMenuClass())
) {
this._panMenuOrigin = "left";
panel.style.setProperty("--offset", `-100vw`);
} else {
this._panMenuOrigin = "right";
panel.style.setProperty("--offset", `100vw`);
this._swipeMenuOrigin = "left";
finalPosition = -this._PANEL_WIDTH;
}
headerCloak.style.setProperty("--opacity", 0);
panel.classList.add("animating");
animationFinished = panel.animate(
[{ transform: `translate3d(${finalPosition}px, 0, 0)` }],
{
fill: "forwards",
}
).finished;
if (isTesting()) {
waitForPromise(animationFinished);
}
headerCloak.animate([{ opacity: 0 }], { fill: "forwards" });
headerCloak.style.display = "block";
animationFinished.then(() => this._animateOpening(panel));
}
if (viewMode === "slide-in") {
headerCloak.style.display = "block";
}
if (this._animate) {
this._animateOpening(panel);
}
this._animate = false;
});
},
@ -494,6 +479,15 @@ export default SiteHeaderComponent.extend({
this._resizeObserver.observe(this.headerWrap);
}
this._swipeEvents = new SwipeEvents(this.element);
if (this.site.mobileView) {
this._swipeEvents.addTouchListeners();
this.element.addEventListener("swipestart", this.onSwipeStart);
this.element.addEventListener("swipeend", this.onSwipeEnd);
this.element.addEventListener("swipecancel", this.onSwipeCancel);
this.element.addEventListener("swipe", this.onSwipe);
}
},
willDestroyElement() {
@ -501,5 +495,12 @@ export default SiteHeaderComponent.extend({
window.removeEventListener("scroll", this.onScroll);
this._resizeObserver?.disconnect();
this.appEvents.off("site-header:force-refresh", this, "queueRerender");
if (this.site.mobileView) {
this.element.removeEventListener("swipestart", this.onSwipeStart);
this.element.removeEventListener("swipeend", this.onSwipeEnd);
this.element.removeEventListener("swipecancel", this.onSwipeCancel);
this.element.removeEventListener("swipe", this.onSwipe);
this._swipeEvents.removeTouchListeners();
}
},
});

View File

@ -3,10 +3,7 @@ import EmberObject from "@ember/object";
import { next } from "@ember/runloop";
import { inject as service } from "@ember/service";
import { headerOffset } from "discourse/lib/offset-calculator";
import PanEvents, {
SWIPE_DISTANCE_THRESHOLD,
SWIPE_VELOCITY_THRESHOLD,
} from "discourse/mixins/pan-events";
import SwipeEvents from "discourse/lib/swipe-events";
import discourseDebounce from "discourse-common/lib/debounce";
import discourseLater from "discourse-common/lib/later";
import { bind, observes } from "discourse-common/utils/decorators";
@ -15,7 +12,7 @@ import JumpToPost from "./modal/jump-to-post";
const MIN_WIDTH_TIMELINE = 925;
const MIN_HEIGHT_TIMELINE = 325;
export default Component.extend(PanEvents, {
export default Component.extend({
modal: service(),
classNameBindings: [
@ -24,9 +21,9 @@ export default Component.extend(PanEvents, {
],
composerOpen: null,
info: null,
isPanning: false,
canRender: true,
_lastTopicId: null,
_swipeEvents: null,
init() {
this._super(...arguments);
@ -110,7 +107,7 @@ export default Component.extend(PanEvents, {
this._checkSize();
},
_collapseFullscreen() {
_collapseFullscreen(delay = 500) {
if (this.get("info.topicProgressExpanded")) {
$(".timeline-fullscreen").removeClass("show");
discourseLater(() => {
@ -120,7 +117,7 @@ export default Component.extend(PanEvents, {
this.set("info.topicProgressExpanded", false);
this._checkSize();
}, 500);
}, delay);
}
},
@ -136,67 +133,80 @@ export default Component.extend(PanEvents, {
}
},
_handlePanDone(offset, event) {
const $timelineContainer = $(".timeline-container");
const maxOffset = parseInt($timelineContainer.css("height"), 10);
$timelineContainer.addClass("animate");
if (this._shouldPanClose(event)) {
$timelineContainer.css("--offset", `${maxOffset}px`);
discourseLater(() => {
this._collapseFullscreen();
$timelineContainer.removeClass("animate");
}, 200);
} else {
$timelineContainer.css("--offset", 0);
discourseLater(() => {
$timelineContainer.removeClass("animate");
}, 200);
}
},
_shouldPanClose(e) {
return (
(e.deltaY > SWIPE_DISTANCE_THRESHOLD &&
e.velocityY > -SWIPE_VELOCITY_THRESHOLD) ||
e.velocityY > SWIPE_VELOCITY_THRESHOLD
);
},
panStart(e) {
@bind
onSwipeStart(event) {
const e = event.detail;
const target = e.originalEvent.target;
if (
target.classList.contains("docked") ||
!target.closest(".timeline-container")
) {
event.preventDefault();
return;
}
e.originalEvent.preventDefault();
const centeredElement = document.elementFromPoint(e.center.x, e.center.y);
if (centeredElement.closest(".timeline-scrollarea-wrapper")) {
this.isPanning = false;
event.preventDefault();
} else if (e.direction === "up" || e.direction === "down") {
this.isPanning = true;
this.movingElement = document.querySelector(".timeline-container");
}
},
panEnd(e) {
if (!this.isPanning) {
return;
}
e.originalEvent.preventDefault();
this.isPanning = false;
this._handlePanDone(e.deltaY, e);
@bind
onSwipeCancel() {
let durationMs = this._swipeEvents.getMaxAnimationTimeMs();
const timelineContainer = document.querySelector(".timeline-container");
timelineContainer.animate([{ transform: `translate3d(0, 0, 0)` }], {
duration: durationMs,
fill: "forwards",
easing: "ease-out",
});
},
panMove(e) {
if (!this.isPanning) {
return;
@bind
onSwipeEnd(event) {
const e = event.detail;
const timelineContainer = document.querySelector(".timeline-container");
const maxOffset = timelineContainer.offsetHeight;
let durationMs = this._swipeEvents.getMaxAnimationTimeMs();
if (this._swipeEvents.shouldCloseMenu(e, "bottom")) {
const distancePx = maxOffset - this.pxClosed;
durationMs = this._swipeEvents.getMaxAnimationTimeMs(
distancePx / Math.abs(e.velocityY)
);
timelineContainer
.animate([{ transform: `translate3d(0, ${maxOffset}px, 0)` }], {
duration: durationMs,
fill: "forwards",
})
.finished.then(() => this._collapseFullscreen(0));
} else {
const distancePx = this.pxClosed;
durationMs = this._swipeEvents.getMaxAnimationTimeMs(
distancePx / Math.abs(e.velocityY)
);
timelineContainer.animate([{ transform: `translate3d(0, 0, 0)` }], {
duration: durationMs,
fill: "forwards",
easing: "ease-out",
});
}
},
@bind
onSwipe(event) {
const e = event.detail;
e.originalEvent.preventDefault();
$(".timeline-container").css("--offset", `${Math.max(0, e.deltaY)}px`);
this.pxClosed = Math.max(0, e.deltaY);
this.movingElement.animate(
[{ transform: `translate3d(0, ${this.pxClosed}px, 0)` }],
{ fill: "forwards" }
);
},
didInsertElement() {
@ -219,6 +229,14 @@ export default Component.extend(PanEvents, {
}
this._checkSize();
this._swipeEvents = new SwipeEvents(this.element);
if (this.site.mobileView) {
this._swipeEvents.addTouchListeners();
this.element.addEventListener("swipestart", this.onSwipeStart);
this.element.addEventListener("swipeend", this.onSwipeEnd);
this.element.addEventListener("swipecancel", this.onSwipeCancel);
this.element.addEventListener("swipe", this.onSwipe);
}
},
willDestroyElement() {
@ -238,5 +256,12 @@ export default Component.extend(PanEvents, {
this.appEvents.off("composer:closed", this, this.composerClosed);
$("#reply-control").off("div-resized", this._checkSize);
}
if (this.site.mobileView) {
this.element.removeEventListener("swipestart", this.onSwipeStart);
this.element.removeEventListener("swipeend", this.onSwipeEnd);
this.element.removeEventListener("swipecancel", this.onSwipeCancel);
this.element.removeEventListener("swipe", this.onSwipe);
this._swipeEvents.removeTouchListeners();
}
},
});

View File

@ -0,0 +1,29 @@
/**
lock scroll of an element using overflow:hidden
preserve gutter with scroll detection
*/
function lockScroll(element = document.scrollingElement) {
let scrollGap = 0;
//Add scroll gap if using default scrolling element
if (element === document.scrollingElement) {
scrollGap = Math.max(0, window.innerWidth - element.clientWidth);
} else {
scrollGap = element.offsetWidth - element.clientWidth;
}
element.style.setProperty("--scroll-gap", `${scrollGap}px`);
element.classList.add("scroll-lock");
}
function unlockScroll(element = document.scrollingElement) {
element.classList.remove("scroll-lock");
element.style.setProperty("--scroll-gap", null);
}
export default function scrollLock(lock, element = document.scrollingElement) {
if (lock) {
lockScroll(element);
} else {
unlockScroll(element);
}
}

View File

@ -0,0 +1,241 @@
import { isTesting } from "discourse-common/config/environment";
import { bind } from "discourse-common/utils/decorators";
/**
Swipe events is a class that allows components to detect and respond to swipe gestures
It sets up custom events for swipestart, swipeend, and swipe for beginning swipe, end swipe, and during swipe. Event returns detail.state with swipe state, and the original event.
Calling preventDefault() on the swipestart event stops swipe and swipeend events for the current gesture. it is re-enabled on future swipe events.
**/
export const SWIPE_DISTANCE_THRESHOLD = 50;
export const SWIPE_VELOCITY_THRESHOLD = 0.12;
export const MINIMUM_SWIPE_DISTANCE = 5;
export const MAX_ANIMATION_TIME = 200;
export default class SwipeEvents {
//velocity is pixels per ms
swipeState = null;
animationPending = false;
constructor(element) {
this.element = element;
}
@bind
touchStart(e) {
// multitouch cancels current swipe event
if (e.touches.length > 1) {
if (this.cancelled) {
return;
}
this.cancelled = true;
const event = new CustomEvent("swipecancel", {
detail: { originalEvent: e },
});
this.element.dispatchEvent(event);
return;
}
this.#swipeStart(e.touches[0]);
}
@bind
touchMove(e) {
const touchEvent = e.touches[0];
touchEvent.type = "pointermove";
this.#swipeMove(touchEvent, e);
}
@bind
touchEnd(e) {
this.#swipeMove({ type: "pointerup" }, e);
// only reset when no touches remain
if (e.touches.length === 0) {
this.cancelled = false;
}
}
@bind
touchCancel(e) {
this.#swipeMove({ type: "pointercancel" }, e);
if (e.touches.length === 0) {
this.cancelled = false;
}
}
addTouchListeners() {
const opts = {
passive: false,
};
this.element.addEventListener("touchstart", this.touchStart, opts);
this.element.addEventListener("touchmove", this.touchMove, opts);
this.element.addEventListener("touchend", this.touchEnd, opts);
this.element.addEventListener("touchcancel", this.touchCancel, opts);
}
// Remove touch listeners to be called by client on destory
removeTouchListeners() {
this.element.removeEventListener("touchstart", this.touchStart);
this.element.removeEventListener("touchmove", this.touchMove);
this.element.removeEventListener("touchend", this.touchEnd);
this.element.removeEventListener("touchcancel", this.touchCancel);
}
// common max animation time in ms for swipe events for swipe end
// prefers reduced motion and tests return 0
getMaxAnimationTimeMs(durationMs = MAX_ANIMATION_TIME) {
if (
isTesting() ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
return 0;
}
return Math.min(durationMs, MAX_ANIMATION_TIME);
}
//functions to calculate if a swipe should close
//based on origin of right, left, top, bottom
// menu should close after a swipe either:
// if a user moved the panel closed past a threshold and away and is NOT swiping back open
// if a user swiped to close fast enough regardless of distance
shouldCloseMenu(e, origin) {
if (origin === "right") {
return (
(e.deltaX > SWIPE_DISTANCE_THRESHOLD &&
e.velocityX > -SWIPE_VELOCITY_THRESHOLD) ||
e.velocityX > 0
);
} else if (origin === "left") {
return (
(e.deltaX < -SWIPE_DISTANCE_THRESHOLD &&
e.velocityX < SWIPE_VELOCITY_THRESHOLD) ||
e.velocityX < 0
);
} else if (origin === "bottom") {
return (
(e.deltaY > SWIPE_DISTANCE_THRESHOLD &&
e.velocityY > -SWIPE_VELOCITY_THRESHOLD) ||
e.velocityY > 0
);
} else if (origin === "top") {
return (
(e.deltaY < -SWIPE_DISTANCE_THRESHOLD &&
e.velocityY < SWIPE_VELOCITY_THRESHOLD) ||
e.velocityY < 0
);
}
return false;
}
#calculateDirection(oldState, deltaX, deltaY) {
if (oldState.start || !oldState.direction) {
if (Math.abs(deltaX) > Math.abs(deltaY)) {
return deltaX > 0 ? "right" : "left";
} else {
return deltaY > 0 ? "down" : "up";
}
}
return oldState.direction;
}
#calculateNewSwipeState(oldState, e) {
if (e.type === "pointerup" || e.type === "pointercancel") {
return oldState;
}
const newTimestamp = Date.now();
const timeDiffSeconds = newTimestamp - oldState.timestamp;
if (timeDiffSeconds === 0) {
return oldState;
}
//calculate delta x, y from START location
const deltaX = e.clientX - oldState.startLocation.x;
const deltaY = e.clientY - oldState.startLocation.y;
//calculate velocity from previous event center location
const eventDeltaX = e.clientX - oldState.center.x;
const eventDeltaY = e.clientY - oldState.center.y;
const velocityX = eventDeltaX / timeDiffSeconds;
const velocityY = eventDeltaY / timeDiffSeconds;
return {
startLocation: oldState.startLocation,
center: { x: e.clientX, y: e.clientY },
velocityX,
velocityY,
deltaX,
deltaY,
start: false,
timestamp: newTimestamp,
direction: this.#calculateDirection(oldState, deltaX, deltaY),
};
}
#swipeStart(e) {
const newState = {
center: { x: e.clientX, y: e.clientY },
startLocation: { x: e.clientX, y: e.clientY },
velocityX: 0,
velocityY: 0,
deltaX: 0,
deltaY: 0,
start: true,
timestamp: Date.now(),
direction: null,
};
this.swipeState = newState;
}
#swipeMove(e, originalEvent) {
if (this.cancelled) {
return;
}
if (!this.swipeState) {
this.#swipeStart(e);
return;
}
originalEvent.stopPropagation();
const previousState = this.swipeState;
const newState = this.#calculateNewSwipeState(previousState, e);
if (
previousState.start &&
Math.abs(newState.deltaX) < MINIMUM_SWIPE_DISTANCE &&
Math.abs(newState.deltaY) < MINIMUM_SWIPE_DISTANCE
) {
return;
}
this.swipeState = newState;
newState.originalEvent = originalEvent;
if (previousState.start) {
const event = new CustomEvent("swipestart", {
cancelable: true,
detail: newState,
});
this.cancelled = !this.element.dispatchEvent(event);
if (this.cancelled) {
return;
}
this.swiping = true;
} else if (
(e.type === "pointerup" || e.type === "pointercancel") &&
this.swiping
) {
this.swiping = false;
const event = new CustomEvent("swipeend", { detail: newState });
this.element.dispatchEvent(event);
} else if (e.type === "pointermove") {
if (this.animationPending) {
return;
}
this.animationPending = true;
window.requestAnimationFrame(() => {
if (!this.animationPending || !this.swiping || this.cancelled) {
this.animationPending = false;
return;
}
const event = new CustomEvent("swipe", { detail: newState });
this.element.dispatchEvent(event);
this.animationPending = false;
});
}
}
}

View File

@ -1,156 +0,0 @@
import Mixin from "@ember/object/mixin";
/**
Pan events is a mixin that allows components to detect and respond to swipe gestures
It fires callbacks for panStart, panEnd, panMove with the pan state, and the original event.
**/
export const SWIPE_DISTANCE_THRESHOLD = 50;
export const SWIPE_VELOCITY_THRESHOLD = 0.12;
export const MINIMUM_SWIPE_DISTANCE = 5;
export default Mixin.create({
//velocity is pixels per ms
_panState: null,
_animationPending: false,
didInsertElement() {
this._super(...arguments);
this.addTouchListeners(this.element);
},
willDestroyElement() {
this._super(...arguments);
this.removeTouchListeners(this.element);
},
addTouchListeners(element) {
if (this.site.mobileView) {
this.touchStart = (e) => e.touches && this._panStart(e.touches[0]);
this.touchMove = (e) => {
const touchEvent = e.touches[0];
touchEvent.type = "pointermove";
this._panMove(touchEvent, e);
};
this.touchEnd = (e) => this._panMove({ type: "pointerup" }, e);
this.touchCancel = (e) => this._panMove({ type: "pointercancel" }, e);
const opts = {
passive: false,
};
element.addEventListener("touchstart", this.touchStart, opts);
element.addEventListener("touchmove", this.touchMove, opts);
element.addEventListener("touchend", this.touchEnd, opts);
element.addEventListener("touchcancel", this.touchCancel, opts);
}
},
removeTouchListeners(element) {
if (this.site.mobileView) {
element.removeEventListener("touchstart", this.touchStart);
element.removeEventListener("touchmove", this.touchMove);
element.removeEventListener("touchend", this.touchEnd);
element.removeEventListener("touchcancel", this.touchCancel);
}
},
_calculateDirection(oldState, deltaX, deltaY) {
if (oldState.start || !oldState.direction) {
if (Math.abs(deltaX) > Math.abs(deltaY)) {
return deltaX > 0 ? "right" : "left";
} else {
return deltaY > 0 ? "down" : "up";
}
}
return oldState.direction;
},
_calculateNewPanState(oldState, e) {
if (e.type === "pointerup" || e.type === "pointercancel") {
return oldState;
}
const newTimestamp = Date.now();
const timeDiffSeconds = newTimestamp - oldState.timestamp;
if (timeDiffSeconds === 0) {
return oldState;
}
//calculate delta x, y, distance from START location
const deltaX = e.clientX - oldState.startLocation.x;
const deltaY = e.clientY - oldState.startLocation.y;
const distance = Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2));
//calculate velocity from previous event center location
const eventDeltaX = e.clientX - oldState.center.x;
const eventDeltaY = e.clientY - oldState.center.y;
const velocityX = eventDeltaX / timeDiffSeconds;
const velocityY = eventDeltaY / timeDiffSeconds;
const deltaDistance = Math.sqrt(
Math.pow(eventDeltaX, 2) + Math.pow(eventDeltaY, 2)
);
const velocity = deltaDistance / timeDiffSeconds;
return {
startLocation: oldState.startLocation,
center: { x: e.clientX, y: e.clientY },
velocity,
velocityX,
velocityY,
deltaX,
deltaY,
distance,
start: false,
timestamp: newTimestamp,
direction: this._calculateDirection(oldState, deltaX, deltaY),
};
},
_panStart(e) {
const newState = {
center: { x: e.clientX, y: e.clientY },
startLocation: { x: e.clientX, y: e.clientY },
velocity: 0,
velocityX: 0,
velocityY: 0,
deltaX: 0,
deltaY: 0,
distance: 0,
start: true,
timestamp: Date.now(),
direction: null,
};
this.set("_panState", newState);
},
_panMove(e, originalEvent) {
if (!this._panState) {
this._panStart(e);
return;
}
const previousState = this._panState;
const newState = this._calculateNewPanState(previousState, e);
if (previousState.start && newState.distance < MINIMUM_SWIPE_DISTANCE) {
return;
}
this.set("_panState", newState);
newState.originalEvent = originalEvent;
if (previousState.start && "panStart" in this) {
this.panStart(newState);
} else if (
(e.type === "pointerup" || e.type === "pointercancel") &&
"panEnd" in this
) {
this.panEnd(newState);
} else if (e.type === "pointermove" && "panMove" in this) {
if (this._animationPending) {
return;
}
this._animationPending = true;
window.requestAnimationFrame(() => {
if (!this._animationPending) {
return;
}
this.panMove(newState);
this._animationPending = false;
});
}
},
});

View File

@ -5,7 +5,6 @@ import { NotificationLevels } from "discourse/lib/notification-levels";
import DiscourseURL, { userPath } from "discourse/lib/url";
import { applyDecorators, createWidget } from "discourse/widgets/widget";
import getURL from "discourse-common/lib/get-url";
import discourseLater from "discourse-common/lib/later";
import I18n from "I18n";
const flatten = (array) => [].concat.apply([], array);
@ -361,36 +360,8 @@ export default createWidget("hamburger-menu", {
});
},
clickOutsideMobile(e) {
const centeredElement = document.elementFromPoint(e.clientX, e.clientY);
const parents = document
.elementsFromPoint(e.clientX, e.clientY)
.some((ele) => ele.classList.contains("panel"));
if (!centeredElement.classList.contains("header-cloak") && parents) {
this.sendWidgetAction("toggleHamburger");
} else {
const windowWidth = document.body.offsetWidth;
const panel = document.querySelector(".menu-panel");
panel.classList.add("animate");
let offsetDirection = this.site.mobileView ? -1 : 1;
offsetDirection =
document.querySelector("html").classList["direction"] === "rtl"
? -offsetDirection
: offsetDirection;
panel.style.setProperty("--offset", `${offsetDirection * windowWidth}px`);
const headerCloak = document.querySelector(".header-cloak");
headerCloak.classList.add("animate");
headerCloak.style.setProperty("--opacity", 0);
discourseLater(() => this.sendWidgetAction("toggleHamburger"), 200);
}
},
clickOutside(e) {
if (this.site.mobileView) {
this.clickOutsideMobile(e);
} else {
this.sendWidgetAction("toggleHamburger");
}
clickOutside() {
this.sendWidgetAction("toggleHamburger");
},
keyDown(e) {

View File

@ -3,6 +3,7 @@ import { hbs } from "ember-cli-htmlbars";
import { h } from "virtual-dom";
import { addExtraUserClasses } from "discourse/helpers/user-avatar";
import { wantsNewWindow } from "discourse/lib/intercept-click";
import scrollLock from "discourse/lib/scroll-lock";
import { logSearchLinkClick } from "discourse/lib/search";
import DiscourseURL from "discourse/lib/url";
import { scrollTop } from "discourse/mixins/scroll-top";
@ -347,8 +348,32 @@ createWidget("revamped-hamburger-menu-wrapper", {
}
},
clickOutside() {
this.sendWidgetAction("toggleHamburger");
clickOutside(e) {
if (
e.target.classList.contains("header-cloak") &&
!window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
const panel = document.querySelector(".menu-panel");
const headerCloak = document.querySelector(".header-cloak");
const finishPosition =
document.querySelector("html").classList["direction"] === "rtl"
? "320px"
: "-320px";
panel
.animate([{ transform: `translate3d(${finishPosition}, 0, 0)` }], {
duration: 200,
fill: "forwards",
easing: "ease-in",
})
.finished.then(() => this.sendWidgetAction("toggleHamburger"));
headerCloak.animate([{ opacity: 0 }], {
duration: 200,
fill: "forwards",
easing: "ease-in",
});
} else {
this.sendWidgetAction("toggleHamburger");
}
},
});
@ -374,8 +399,32 @@ createWidget("revamped-user-menu-wrapper", {
this.sendWidgetAction("toggleUserMenu");
},
clickOutside() {
this.closeUserMenu();
clickOutside(e) {
if (
e.target.classList.contains("header-cloak") &&
!window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
const panel = document.querySelector(".menu-panel");
const headerCloak = document.querySelector(".header-cloak");
const finishPosition =
document.querySelector("html").classList["direction"] === "rtl"
? "-320px"
: "320px";
panel
.animate([{ transform: `translate3d(${finishPosition}, 0, 0)` }], {
duration: 200,
fill: "forwards",
easing: "ease-in",
})
.finished.then(() => this.closeUserMenu());
headerCloak.animate([{ opacity: 0 }], {
duration: 200,
fill: "forwards",
easing: "ease-in",
});
} else {
this.closeUserMenu();
}
},
});
@ -600,44 +649,7 @@ export default createWidget("header", {
if (!this.site.mobileView) {
return;
}
if (bool) {
document.body.addEventListener("touchmove", this.preventDefault, {
passive: false,
});
} else {
document.body.removeEventListener("touchmove", this.preventDefault, {
passive: false,
});
}
},
preventDefault(e) {
const windowHeight = window.innerHeight;
// allow profile menu tabs to scroll if they're taller than the window
if (e.target.closest(".menu-panel .menu-tabs-container")) {
const topTabs = document.querySelector(".menu-panel .top-tabs");
const bottomTabs = document.querySelector(".menu-panel .bottom-tabs");
const profileTabsHeight =
topTabs?.offsetHeight + bottomTabs?.offsetHeight || 0;
if (profileTabsHeight > windowHeight) {
return;
}
}
// allow menu panels to scroll if contents are taller than the window
if (e.target.closest(".menu-panel")) {
const menuContentHeight =
document.querySelector(".menu-panel .panel-body-contents")
.offsetHeight || 0;
if (menuContentHeight > windowHeight) {
return;
}
}
e.preventDefault();
scrollLock(bool);
},
togglePageSearch() {

View File

@ -784,3 +784,8 @@ a#skip-link {
transition: top 0.15s ease-in;
}
}
.scroll-lock {
overflow: hidden !important;
margin-right: var(--scroll-gap, 0);
}

View File

@ -592,19 +592,11 @@
height: 100%;
width: 100%;
position: fixed;
background-color: black;
--opacity: 0.5;
opacity: var(--opacity);
background-color: rgba(0, 0, 0, 0.3);
top: var(--header-top);
left: 0;
display: none;
touch-action: pan-y pinch-zoom;
@media (prefers-reduced-motion: no-preference) {
&.animate {
transition: opacity 0.1s linear;
}
}
}
.menu-panel.slide-in {
@ -617,6 +609,7 @@
@supports (height: 100dvh) {
--100dvh: 100dvh;
}
box-shadow: 0px 0 30px -2px rgba(0, 0, 0, 0.5);
--base-height: calc(var(--100dvh) - var(--header-top));
@ -625,19 +618,4 @@
body.footer-nav-ipad & {
height: calc(var(--base-height) - var(--footer-nav-height));
}
transform: translateX(var(--offset));
@media (prefers-reduced-motion: no-preference) {
&.animate {
transition: transform 0.1s linear;
}
}
&.moving,
&.animate {
// PERF: only render first 20 items in a list to allow for smooth
// pan events
li:nth-child(n + 20) {
display: none;
}
}
}

View File

@ -37,11 +37,7 @@
}
&.timeline-fullscreen {
transform: translateY(var(--offset));
@media (prefers-reduced-motion: no-preference) {
&.animate {
transition: transform 0.1s linear;
}
transition: max-height 0.3s ease-in;
}
max-height: 0;

View File

@ -6,6 +6,7 @@ module PageObjects
class Sidebar < Base
def open_on_mobile
click_button("toggle-hamburger-menu")
page.has_no_css?("div.menu-panel.animating")
end
def visible?