diff --git a/app/assets/javascripts/discourse/components/site-header.js.es6 b/app/assets/javascripts/discourse/components/site-header.js.es6 index 970f3a83bf3..43938743a12 100644 --- a/app/assets/javascripts/discourse/components/site-header.js.es6 +++ b/app/assets/javascripts/discourse/components/site-header.js.es6 @@ -1,6 +1,11 @@ import MountWidget from "discourse/components/mount-widget"; import { observes } from "ember-addons/ember-computed-decorators"; import Docking from "discourse/mixins/docking"; +import PanEvents, { + SWIPE_VELOCITY, + SWIPE_DISTANCE_THRESHOLD, + SWIPE_VELOCITY_THRESHOLD +} from "discourse/mixins/pan-events"; const _flagProperties = []; function addFlagProperty(prop) { @@ -9,10 +14,20 @@ function addFlagProperty(prop) { const PANEL_BODY_MARGIN = 30; -const SiteHeaderComponent = MountWidget.extend(Docking, { +//android supports pulling in from the screen edges +const SCREEN_EDGE_MARGIN = 30; +const SCREEN_OFFSET = 300; + +const SiteHeaderComponent = MountWidget.extend(Docking, PanEvents, { widget: "header", docAt: null, dockedHeader: null, + _animate: false, + _isPanning: false, + _panMenuOrigin: "right", + _panMenuOffset: 0, + _scheduledMovingAnimation: null, + _scheduledRemoveAnimate: null, _topic: null, @observes( @@ -23,6 +38,141 @@ const SiteHeaderComponent = MountWidget.extend(Docking, { this.queueRerender(); }, + _animateOpening($panel) { + $panel.css({ right: "", left: "" }); + this._panMenuOffset = 0; + }, + + _animateClosing($panel, menuOrigin, windowWidth) { + $panel.css(menuOrigin, -windowWidth); + this._animate = true; + Ember.run.schedule("afterRender", () => { + this.eventDispatched("dom:clean", "header"); + this._panMenuOffset = 0; + }); + }, + + _handlePanDone(offset, event) { + const $window = $(window); + const windowWidth = parseInt($window.width()); + const $menuPanels = $(".menu-panel"); + const menuOrigin = this._panMenuOrigin; + this._shouldMenuClose(event, menuOrigin) + ? (offset += SWIPE_VELOCITY) + : (offset -= SWIPE_VELOCITY); + $menuPanels.each((idx, panel) => { + const $panel = $(panel); + const $headerCloak = $(".header-cloak"); + $panel.css(menuOrigin, -offset); + $headerCloak.css("opacity", Math.min(0.5, (300 - offset) / 600)); + if (offset > windowWidth) { + this._animateClosing($panel, menuOrigin, windowWidth); + } else if (offset <= 0) { + this._animateOpening($panel); + } else { + //continue to open or close menu + this._scheduledMovingAnimation = window.requestAnimationFrame(() => + this._handlePanDone(offset, event) + ); + } + }); + }, + + _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) { + const center = e.center; + const $centeredElement = $(document.elementFromPoint(center.x, center.y)); + const $window = $(window); + const windowWidth = parseInt($window.width()); + if ( + ($centeredElement.hasClass("panel-body") || + $centeredElement.hasClass("header-cloak") || + $centeredElement.parents(".panel-body").length) && + (e.direction === "left" || e.direction === "right") + ) { + e.originalEvent.preventDefault(); + this._isPanning = true; + } else if ( + center.x < SCREEN_EDGE_MARGIN && + !this.$(".menu-panel").length && + e.direction === "right" + ) { + this._animate = false; + this._panMenuOrigin = "left"; + this._panMenuOffset = -SCREEN_OFFSET; + this._isPanning = true; + $("header.d-header").removeClass("scroll-down scroll-up"); + this.eventDispatched("toggleHamburger", "header"); + } else if ( + windowWidth - center.x < SCREEN_EDGE_MARGIN && + !this.$(".menu-panel").length && + e.direction === "left" + ) { + this._animate = false; + this._panMenuOrigin = "right"; + this._panMenuOffset = -SCREEN_OFFSET; + this._isPanning = true; + $("header.d-header").removeClass("scroll-down scroll-up"); + this.eventDispatched("toggleUserMenu", "header"); + } else { + this._isPanning = false; + } + }, + + panEnd(e) { + if (!this._isPanning) { + return; + } + this._isPanning = false; + $(".menu-panel").each((idx, panel) => { + const $panel = $(panel); + let offset = $panel.css("right"); + if (this._panMenuOrigin === "left") { + offset = $panel.css("left"); + } + offset = Math.abs(parseInt(offset, 10)); + this._handlePanDone(offset, e); + }); + }, + + panMove(e) { + if (!this._isPanning) { + return; + } + const $menuPanels = $(".menu-panel"); + $menuPanels.each((idx, panel) => { + const $panel = $(panel); + const $headerCloak = $(".header-cloak"); + if (this._panMenuOrigin === "right") { + const pxClosed = Math.min(0, -e.deltaX + this._panMenuOffset); + $panel.css("right", pxClosed); + $headerCloak.css("opacity", Math.min(0.5, (300 + pxClosed) / 600)); + } else { + const pxClosed = Math.min(0, e.deltaX + this._panMenuOffset); + $panel.css("left", pxClosed); + $headerCloak.css("opacity", Math.min(0.5, (300 + pxClosed) / 600)); + } + }); + }, + dockCheck(info) { const $header = $("header.d-header"); @@ -47,6 +197,7 @@ const SiteHeaderComponent = MountWidget.extend(Docking, { }, setTopic(topic) { + this.eventDispatched("dom:clean", "header"); this._topic = topic; this.queueRerender(); }, @@ -74,6 +225,14 @@ const SiteHeaderComponent = MountWidget.extend(Docking, { this.eventDispatched("dom:clean", "header"); } }); + + if (this.site.mobileView) { + $("body") + .on("pointerdown", e => this._panStart(e)) + .on("pointermove", e => this._panMove(e)) + .on("pointerup", e => this._panMove(e)) + .on("pointercancel", e => this._panMove(e)); + } }, willDestroyElement() { @@ -84,6 +243,16 @@ const SiteHeaderComponent = MountWidget.extend(Docking, { this.appEvents.off("header:show-topic"); this.appEvents.off("header:hide-topic"); this.appEvents.off("dom:clean"); + + if (this.site.mobileView) { + $("body") + .off("pointerdown") + .off("pointerup") + .off("pointermove") + .off("pointercancel"); + } + Ember.run.cancel(this._scheduledRemoveAnimate); + window.cancelAnimationFrame(this._scheduledMovingAnimation); }, buildArgs() { @@ -100,6 +269,9 @@ const SiteHeaderComponent = MountWidget.extend(Docking, { afterRender() { const $menuPanels = $(".menu-panel"); if ($menuPanels.length === 0) { + if (this.site.mobileView) { + this._animate = true; + } return; } @@ -112,15 +284,29 @@ const SiteHeaderComponent = MountWidget.extend(Docking, { $menuPanels.each((idx, panel) => { const $panel = $(panel); + const $headerCloak = $(".header-cloak"); let width = parseInt($panel.attr("data-max-width") || 300); if (windowWidth - width < 50) { width = windowWidth - 50; } + if (this._panMenuOffset) { + this._panMenuOffset = -width; + } - $panel - .removeClass("drop-down") - .removeClass("slide-in") - .addClass(viewMode); + $panel.removeClass("drop-down slide-in").addClass(viewMode); + if (this._animate || this._panMenuOffset !== 0) { + $headerCloak.css("opacity", 0); + if ( + this.site.mobileView && + $panel.parent(".hamburger-panel").length > 0 + ) { + this._panMenuOrigin = "left"; + $panel.css("left", -windowWidth); + } else { + this._panMenuOrigin = "right"; + $panel.css("right", -windowWidth); + } + } const $panelBody = $(".panel-body", $panel); // 2 pixel fudge allows for firefox subpixel sizing stuff causing scrollbar @@ -150,7 +336,8 @@ const SiteHeaderComponent = MountWidget.extend(Docking, { if ( contentHeight + (offsetTop - scrollTop) + PANEL_BODY_MARGIN > - fullHeight + fullHeight || + this.site.mobileView ) { contentHeight = fullHeight - (offsetTop - scrollTop) - PANEL_BODY_MARGIN; @@ -160,11 +347,19 @@ const SiteHeaderComponent = MountWidget.extend(Docking, { } $("body").addClass("drop-down-mode"); } else { - const menuTop = headerHeight(); + if (this.site.mobileView) { + $headerCloak.show(); + } + + const menuTop = this.site.mobileView ? 0 : headerHeight(); let height; - const winHeight = $(window).height() - 16; - if (menuTop + contentHeight < winHeight) { + const winHeightOffset = 16; + let initialWinHeight = window.innerHeight + ? window.innerHeight + : $(window).height(); + const winHeight = initialWinHeight - winHeightOffset; + if (menuTop + contentHeight < winHeight && !this.site.mobileView) { height = contentHeight + "px"; } else { height = winHeight - menuTop; @@ -180,6 +375,17 @@ const SiteHeaderComponent = MountWidget.extend(Docking, { } $panel.width(width); + if (this._animate) { + $panel.addClass("animate"); + $headerCloak.addClass("animate"); + this._scheduledRemoveAnimate = Ember.run.later(() => { + $panel.removeClass("animate"); + $headerCloak.removeClass("animate"); + }, 200); + } + $panel.css({ right: "", left: "" }); + $headerCloak.css("opacity", 0.5); + this._animate = false; }); } }); diff --git a/app/assets/javascripts/discourse/components/topic-navigation.js.es6 b/app/assets/javascripts/discourse/components/topic-navigation.js.es6 index b4b840cb6bc..7880f82254c 100644 --- a/app/assets/javascripts/discourse/components/topic-navigation.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-navigation.js.es6 @@ -1,6 +1,10 @@ import { observes } from "ember-addons/ember-computed-decorators"; import showModal from "discourse/lib/show-modal"; -import PanEvents from "discourse/mixins/pan-events"; +import PanEvents, { + SWIPE_VELOCITY, + SWIPE_DISTANCE_THRESHOLD, + SWIPE_VELOCITY_THRESHOLD +} from "discourse/mixins/pan-events"; export default Ember.Component.extend(PanEvents, { composerOpen: null, @@ -117,10 +121,11 @@ export default Ember.Component.extend(PanEvents, { } }, - _panOpenClose(offset, velocity, direction) { + _handlePanDone(offset, event) { const $timelineContainer = $(".timeline-container"); - const maxOffset = parseInt($timelineContainer.css("height")); - direction === "close" ? (offset += velocity) : (offset -= velocity); + const maxOffset = parseInt($timelineContainer.css("height"), 10); + + this._shouldPanClose(event) ? (offset += SWIPE_VELOCITY) : (offset -= SWIPE_VELOCITY); $timelineContainer.css("bottom", -offset); if (offset > maxOffset) { @@ -129,42 +134,41 @@ export default Ember.Component.extend(PanEvents, { $timelineContainer.css("bottom", ""); } else { Ember.run.later( - () => this._panOpenClose(offset, velocity, direction), + () => this._handlePanDone(offset, event), 20 ); } }, _shouldPanClose(e) { - return (e.deltaY > 200 && e.velocityY > -0.15) || e.velocityY > 0.15; + return (e.deltaY > SWIPE_DISTANCE_THRESHOLD && e.velocityY > -SWIPE_VELOCITY_THRESHOLD) || e.velocityY > SWIPE_VELOCITY_THRESHOLD; }, panStart(e) { + e.originalEvent.preventDefault(); const center = e.center; const $centeredElement = $(document.elementFromPoint(center.x, center.y)); if ($centeredElement.parents(".timeline-scrollarea-wrapper").length) { - this.set("isPanning", false); + this.isPanning = false; } else if (e.direction === "up" || e.direction === "down") { - this.set("isPanning", true); + this.isPanning = true; } }, panEnd(e) { - if (!this.get("isPanning")) { + if (!this.isPanning) { return; } - this.set("isPanning", false); - if (this._shouldPanClose(e)) { - this._panOpenClose(e.deltaY, 40, "close"); - } else { - this._panOpenClose(e.deltaY, 40, "open"); - } + e.originalEvent.preventDefault(); + this.isPanning = false; + this._handlePanDone(e.deltaY, e); }, panMove(e) { - if (!this.get("isPanning")) { + if (!this.isPanning) { return; } + e.originalEvent.preventDefault(); $(".timeline-container").css("bottom", Math.min(0, -e.deltaY)); }, diff --git a/app/assets/javascripts/discourse/mixins/pan-events.js.es6 b/app/assets/javascripts/discourse/mixins/pan-events.js.es6 index b4247d861df..f3c1623ee64 100644 --- a/app/assets/javascripts/discourse/mixins/pan-events.js.es6 +++ b/app/assets/javascripts/discourse/mixins/pan-events.js.es6 @@ -1,3 +1,10 @@ +/** + 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_VELOCITY = 40; +export const SWIPE_DISTANCE_THRESHOLD = 50; +export const SWIPE_VELOCITY_THRESHOLD = 0.1; export default Ember.Mixin.create({ //velocity is pixels per ms @@ -7,11 +14,23 @@ export default Ember.Mixin.create({ this._super(); if (this.site.mobileView) { - this.$() - .on("pointerdown", e => this._panStart(e)) - .on("pointermove", e => this._panMove(e)) - .on("pointerup", e => this._panMove(e)) - .on("pointercancel", e => this._panMove(e)); + if ("onpointerdown" in document.documentElement) { + this.$() + .on("pointerdown", e => this._panStart(e)) + .on("pointermove", e => this._panMove(e, e)) + .on("pointerup", e => this._panMove(e, e)) + .on("pointercancel", e => this._panMove(e, e)); + } else if ("ontouchstart" in document.documentElement) { + this.$() + .on("touchstart", e => this._panStart(e.touches[0])) + .on("touchmove", e => { + const touchEvent = e.touches[0]; + touchEvent.type = "pointermove"; + this._panMove(touchEvent, e); + }) + .on("touchend", e => this._panMove({ type: "pointerup" }, e)) + .on("touchcancel", e => this._panMove({ type: "pointercancel" }, e)); + } } }, @@ -23,7 +42,11 @@ export default Ember.Mixin.create({ .off("pointerdown") .off("pointerup") .off("pointermove") - .off("pointercancel"); + .off("pointercancel") + .off("touchstart") + .off("touchmove") + .off("touchend") + .off("touchcancel"); } }, @@ -44,6 +67,9 @@ export default Ember.Mixin.create({ } const newTimestamp = new Date().getTime(); 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; @@ -93,7 +119,7 @@ export default Ember.Mixin.create({ this.set("_panState", newState); }, - _panMove(e) { + _panMove(e, originalEvent) { if (!this.get("_panState")) { this._panStart(e); return; @@ -104,6 +130,7 @@ export default Ember.Mixin.create({ return; } this.set("_panState", newState); + newState.originalEvent = originalEvent; if (previousState.start && "panStart" in this) { this.panStart(newState); } else if ( diff --git a/app/assets/javascripts/discourse/widgets/hamburger-menu.js.es6 b/app/assets/javascripts/discourse/widgets/hamburger-menu.js.es6 index 12b6dbea51d..c8bbb2979bb 100644 --- a/app/assets/javascripts/discourse/widgets/hamburger-menu.js.es6 +++ b/app/assets/javascripts/discourse/widgets/hamburger-menu.js.es6 @@ -323,7 +323,32 @@ export default createWidget("hamburger-menu", { }); }, - clickOutside() { - this.sendWidgetAction("toggleHamburger"); + clickOutsideMobile(e) { + const $centeredElement = $(document.elementFromPoint(e.clientX, e.clientY)); + if ( + $centeredElement.parents(".panel").length && + !$centeredElement.hasClass("header-cloak") + ) { + this.sendWidgetAction("toggleHamburger"); + } else { + const $window = $(window); + const windowWidth = parseInt($window.width(), 10); + const $panel = $(".menu-panel"); + $panel.addClass("animate"); + const panelOffsetDirection = this.site.mobileView ? "left" : "right"; + $panel.css(panelOffsetDirection, -windowWidth); + const $headerCloak = $(".header-cloak"); + $headerCloak.addClass("animate"); + $headerCloak.css("opacity", 0); + Ember.run.later(() => this.sendWidgetAction("toggleHamburger"), 200); + } + }, + + clickOutside(e) { + if (this.site.mobileView) { + this.clickOutsideMobile(e); + } else { + this.sendWidgetAction("toggleHamburger"); + } } }); diff --git a/app/assets/javascripts/discourse/widgets/header.js.es6 b/app/assets/javascripts/discourse/widgets/header.js.es6 index 078cecf5f45..87a294fae26 100644 --- a/app/assets/javascripts/discourse/widgets/header.js.es6 +++ b/app/assets/javascripts/discourse/widgets/header.js.es6 @@ -253,6 +253,15 @@ createWidget("header-buttons", { } }); +createWidget("header-cloak", { + tagName: "div.header-cloak", + html() { + return ""; + }, + click() {}, + scheduleRerender() {} +}); + const forceContextEnabled = ["category", "user", "private_messages"]; let additionalPanels = []; @@ -315,6 +324,9 @@ export default createWidget("header", { } else if (state.userVisible) { panels.push(this.attach("user-menu")); } + if (this.site.mobileView) { + panels.push(this.attach("header-cloak")); + } additionalPanels.map(panel => { if (this.state[panel.toggle]) { @@ -348,6 +360,7 @@ export default createWidget("header", { this.state.userVisible = false; this.state.hamburgerVisible = false; this.state.searchVisible = false; + this.toggleBodyScrolling(false); }, linkClickedEvent(attrs) { @@ -416,10 +429,32 @@ export default createWidget("header", { } this.state.userVisible = !this.state.userVisible; + this.toggleBodyScrolling(this.state.userVisible); }, toggleHamburger() { this.state.hamburgerVisible = !this.state.hamburgerVisible; + this.toggleBodyScrolling(this.state.hamburgerVisible); + }, + + toggleBodyScrolling(bool) { + 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) { + // prevent all scrollin on menu panels, except on overflow + const height = window.innerHeight + ? window.innerHeight + : $(window).height(); + if (!$(e.target).parents(".menu-panel").length || + $(".menu-panel .panel-body-contents").height() <= height) { + e.preventDefault(); + } }, togglePageSearch() { diff --git a/app/assets/javascripts/discourse/widgets/user-menu.js.es6 b/app/assets/javascripts/discourse/widgets/user-menu.js.es6 index 69d987db8f6..0053af435df 100644 --- a/app/assets/javascripts/discourse/widgets/user-menu.js.es6 +++ b/app/assets/javascripts/discourse/widgets/user-menu.js.es6 @@ -194,7 +194,31 @@ export default createWidget("user-menu", { }); }, - clickOutside() { - this.sendWidgetAction("toggleUserMenu"); + clickOutsideMobile(e) { + const $centeredElement = $(document.elementFromPoint(e.clientX, e.clientY)); + if ( + $centeredElement.parents(".panel").length && + !$centeredElement.hasClass("header-cloak") + ) { + this.sendWidgetAction("toggleUserMenu"); + } else { + const $window = $(window); + const windowWidth = parseInt($window.width(), 10); + const $panel = $(".menu-panel"); + $panel.addClass("animate"); + $panel.css("right", -windowWidth); + const $headerCloak = $(".header-cloak"); + $headerCloak.addClass("animate"); + $headerCloak.css("opacity", 0); + Ember.run.later(() => this.sendWidgetAction("toggleUserMenu"), 200); + } + }, + + clickOutside(e) { + if (this.site.mobileView) { + this.clickOutsideMobile(e); + } else { + this.sendWidgetAction("toggleUserMenu"); + } } }); diff --git a/app/assets/stylesheets/common/base/menu-panel.scss b/app/assets/stylesheets/common/base/menu-panel.scss index 5ac03f7445a..016986ba491 100644 --- a/app/assets/stylesheets/common/base/menu-panel.scss +++ b/app/assets/stylesheets/common/base/menu-panel.scss @@ -2,6 +2,9 @@ position: fixed; right: 0; box-shadow: shadow("header"); + &.animate { + transition: right 0.2s ease-out, left 0.2s ease-out; + } .panel-body { position: absolute; @@ -10,6 +13,9 @@ width: 97%; } } +.header-cloak { + display: none; +} .menu-panel.drop-down { position: absolute; @@ -42,6 +48,7 @@ } .panel-body { + touch-action: pan-y pinch-zoom; overflow-y: auto; overflow-x: hidden; } diff --git a/app/assets/stylesheets/mobile.scss b/app/assets/stylesheets/mobile.scss index 138123ecbbf..164d1cbbc8f 100644 --- a/app/assets/stylesheets/mobile.scss +++ b/app/assets/stylesheets/mobile.scss @@ -29,6 +29,7 @@ @import "mobile/admin_report"; @import "mobile/admin_report_table"; @import "mobile/admin_report_counters"; +@import "mobile/menu-panel"; // Import all component-specific files @import "mobile/components/*"; diff --git a/app/assets/stylesheets/mobile/menu-panel.scss b/app/assets/stylesheets/mobile/menu-panel.scss new file mode 100644 index 00000000000..77216504ffe --- /dev/null +++ b/app/assets/stylesheets/mobile/menu-panel.scss @@ -0,0 +1,18 @@ +.hamburger-panel .menu-panel.slide-in { + left: 0; +} +.header-cloak { + height: 100vh; + width: 100vw; + position: fixed; + background-color: black; + opacity: 0.5; + top: 0; + left: 0; + display: none; + touch-action: pan-y pinch-zoom; + &.animate { + transition: opacity 0.2s ease-out; + } +} +