From 7ca7c828fa659cba47ebad7e0ef4be5e26d2eaec Mon Sep 17 00:00:00 2001 From: Kai Wagner Date: Tue, 10 Feb 2026 08:57:38 +0100 Subject: [PATCH] =?UTF-8?q?native=E2=80=91feeling=20swipe=20back/forward?= =?UTF-8?q?=20for=20all=20touch=20devices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Kai Wagner --- .../controllers/swipe_nav_controller.js | 93 +++++++++++++++++++ app/views/layouts/application.html.slim | 2 +- 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 app/javascript/controllers/swipe_nav_controller.js diff --git a/app/javascript/controllers/swipe_nav_controller.js b/app/javascript/controllers/swipe_nav_controller.js new file mode 100644 index 0000000..8f0272c --- /dev/null +++ b/app/javascript/controllers/swipe_nav_controller.js @@ -0,0 +1,93 @@ +import { Controller } from "@hotwired/stimulus" + +const SWIPE_DISTANCE_PX = 60 +const SWIPE_AXIS_RATIO = 1.2 +const SWIPE_TIME_MS = 700 + +export default class extends Controller { + connect() { + this.onPointerDown = this.onPointerDown.bind(this) + this.onPointerMove = this.onPointerMove.bind(this) + this.onPointerUp = this.onPointerUp.bind(this) + window.addEventListener("pointerdown", this.onPointerDown, { passive: true }) + } + + disconnect() { + window.removeEventListener("pointerdown", this.onPointerDown, { passive: true }) + this.detachTrackingListeners() + } + + onPointerDown(event) { + if (!event.isPrimary) return + if (event.pointerType !== "touch" && event.pointerType !== "pen") return + if (event.button && event.button !== 0) return + if (this.shouldIgnoreTarget(event.target)) return + + this.tracking = true + this.pointerId = event.pointerId + this.startX = event.clientX + this.startY = event.clientY + this.startTime = performance.now() + this.lastX = event.clientX + this.lastY = event.clientY + + window.addEventListener("pointermove", this.onPointerMove, { passive: true }) + window.addEventListener("pointerup", this.onPointerUp, { passive: true }) + window.addEventListener("pointercancel", this.onPointerUp, { passive: true }) + } + + onPointerMove(event) { + if (!this.tracking || event.pointerId !== this.pointerId) return + this.lastX = event.clientX + this.lastY = event.clientY + } + + onPointerUp(event) { + if (!this.tracking || event.pointerId !== this.pointerId) return + + const elapsed = performance.now() - this.startTime + const deltaX = this.lastX - this.startX + const deltaY = this.lastY - this.startY + + this.tracking = false + this.pointerId = null + this.detachTrackingListeners() + + if (elapsed > SWIPE_TIME_MS) return + if (Math.abs(deltaX) < SWIPE_DISTANCE_PX) return + if (Math.abs(deltaX) < Math.abs(deltaY) * SWIPE_AXIS_RATIO) return + + if (deltaX > 0) { + window.history.back() + } else { + window.history.forward() + } + } + + detachTrackingListeners() { + window.removeEventListener("pointermove", this.onPointerMove, { passive: true }) + window.removeEventListener("pointerup", this.onPointerUp, { passive: true }) + window.removeEventListener("pointercancel", this.onPointerUp, { passive: true }) + } + + shouldIgnoreTarget(target) { + if (!target) return true + if (target.closest("input, textarea, select, [contenteditable='true']")) return true + if (this.isInHorizontalScrollableArea(target)) return true + return false + } + + isInHorizontalScrollableArea(target) { + let el = target + while (el && el !== document.body) { + const style = window.getComputedStyle(el) + const overflowX = style.overflowX + if ((overflowX === "auto" || overflowX === "scroll" || overflowX === "overlay") && + el.scrollWidth > el.clientWidth + 1) { + return true + } + el = el.parentElement + } + return false + } +} diff --git a/app/views/layouts/application.html.slim b/app/views/layouts/application.html.slim index d788fa9..11c01f9 100644 --- a/app/views/layouts/application.html.slim +++ b/app/views/layouts/application.html.slim @@ -37,7 +37,7 @@ html data-theme="light" - if ENV["UMAMI_WEBSITE_ID"].present? - umami_host = ENV["UMAMI_HOST"].presence || "https://umami.hackorum.dev" script[async defer data-website-id=ENV["UMAMI_WEBSITE_ID"] src="#{umami_host}/script.js"] - body class=(content_for?(:sidebar) ? "has-sidebar" : nil) data-controller="sidebar" + body class=(content_for?(:sidebar) ? "has-sidebar" : nil) data-controller="sidebar swipe-nav" - if user_signed_in? && current_user.username.blank? .global-warning span Please set a username in Settings.