mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
Pulling the mail list down from its top, with a finger below md, runs the topbar's sync. A shared touch-gesture module decides what a touch is and who owns it, for the row swipes and long-press to come. Mouse, trackpad and wider windows are unchanged. Takes over #115. Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
344 lines
13 KiB
JavaScript
344 lines
13 KiB
JavaScript
import { Controller } from "@hotwired/stimulus";
|
|
import { motionIsOff } from "../../motion.js";
|
|
import { Intent, claimSequence, createSequence, releaseSequence } from "../../touch_gesture.js";
|
|
|
|
/**
|
|
* Pull the mail list down from its top to sync, on a phone.
|
|
*
|
|
* The list is the only thing that moves, and it does not move: the indicator is
|
|
* what slides down with the finger, so no row is touched while a pull is in
|
|
* progress. Release past the threshold runs the topbar's sync button, through its
|
|
* own controller, and the indicator rests in view with a spinning icon while that
|
|
* button reports a run in progress, whichever control started it.
|
|
*
|
|
* Mounted on #message-list, outside the list frame, so it survives a frame swap.
|
|
* Touches are read on that element, and the rows scroller is found at touchstart
|
|
* with closest(), because a frame visit replaces the scroller itself.
|
|
*
|
|
* Touch only, below md (invariant 1). Under a mouse nothing here runs.
|
|
*
|
|
* ┌─────────────────┬─────────────────────────────────────────────────────────┐
|
|
* │ Targets │ indicator (the slide with the finger), icon (spins) │
|
|
* ├─────────────────┼─────────────────────────────────────────────────────────┤
|
|
* │ Reaches │ the topbar's mail--sync-now controller, by lookup, to │
|
|
* │ │ call run(): its running guard and toast, not a click │
|
|
* ├─────────────────┼─────────────────────────────────────────────────────────┤
|
|
* │ Listens for │ mail--sync-now:started / :stopped on the document │
|
|
* ├─────────────────┼─────────────────────────────────────────────────────────┤
|
|
* │ Routes │ none: the sync is the sync button's own POST │
|
|
* └─────────────────┴─────────────────────────────────────────────────────────┘
|
|
*
|
|
* Constants, all derived from the owner-confirmed defaults:
|
|
* PULL_RESISTANCE = 0.5. The indicator shows finger travel x 0.5.
|
|
* RELEASE_DISPLAYED_PX = 70. Letting go past it syncs. Finger travel that
|
|
* reaches it is 70 / 0.5 = 140px. So a finger of 180px releases (displayed
|
|
* 90) and one of 120px does not (displayed 60).
|
|
* SYNC_RESTING_PX = 48. Where the indicator rests while a run is open.
|
|
*
|
|
* Thresholds are measured on finger displacement from touchstart, never on the
|
|
* rendered translation, so easing does not move them.
|
|
*
|
|
* A pull ends in one of three ways, and only the first can sync:
|
|
* - touchend, of the finger that was pulling: release past the threshold syncs;
|
|
* - touchcancel (the system took the touch), or a second finger landing during
|
|
* the pull: the pull is ABORTED. The indicator returns to idle, the claim is
|
|
* released, and nothing syncs;
|
|
* - no touchend or touchcancel ever arrives: the element the finger was on was
|
|
* removed mid-pull, so events stop reaching this element. The next one-finger
|
|
* touchstart abandons the stale pull the same way an abort does, and nothing syncs.
|
|
*
|
|
* Touch moves are listened to with passive: false only while a sequence might be
|
|
* a pull: the listener is added at touchstart when the list is at its top, and
|
|
* removed as soon as the sequence is decided against a pull, or when it ends. A
|
|
* touchmove that is part of a pull is cancelable, and preventDefault is called
|
|
* only then, so a scroll that is not a pull is left to the browser.
|
|
*
|
|
* Reduced motion: the indicator's slide is removed under motionIsOff(), so a
|
|
* release snaps back rather than animating. Thresholds are unchanged.
|
|
*
|
|
* What is NOT here: the row-swipe and sheet gestures (later changes). They share
|
|
* the arbiter in touch_gesture.js, and this controller claims its sequence through
|
|
* claimSequence("pull"), so a claimed or open-surface sequence is never pulled.
|
|
*/
|
|
|
|
/** Displayed distance per pixel of finger travel. A pull resists by half. */
|
|
const PULL_RESISTANCE = 0.5;
|
|
|
|
/** Displayed distance at which letting go starts a sync. */
|
|
const RELEASE_DISPLAYED_PX = 70;
|
|
|
|
/**
|
|
* Where the indicator rests while a run is open, measured from the top of the list.
|
|
* The pill is 25px tall (6px padding each side around a 13px line), so its top is
|
|
* 48 - 25 = 23px below the list's top: in view, clear of the edge, and below the
|
|
* topbar's own line. Resting at 0 would leave it hidden, which is what this fixes.
|
|
*/
|
|
const SYNC_RESTING_PX = 48;
|
|
|
|
/** The list's scroller, as the list frame draws it. */
|
|
const ROWS = '[data-list-region="rows"]';
|
|
|
|
/** The topbar's sync button. Its controller's run() is the sync, not a click on the button. */
|
|
const SYNC_BUTTON = "[data-controller~='mail--sync-now']";
|
|
|
|
/** Below md: 48rem, the mobile_search_controller's query. */
|
|
const MOBILE = "(width < 48rem)";
|
|
|
|
const STATE = Object.freeze({
|
|
IDLE: "idle",
|
|
PULLING: "pulling",
|
|
RELEASE: "release",
|
|
SYNCING: "syncing",
|
|
});
|
|
|
|
/**
|
|
* Where the indicator rests, as a transform. Hidden when idle; at the pull's own
|
|
* distance while pulling; at SYNC_RESTING_PX while a run is open, so the spinner is
|
|
* in view for as long as the sync is.
|
|
*/
|
|
function restingTransform(state, displayed) {
|
|
if (state === STATE.IDLE) {
|
|
return "";
|
|
}
|
|
|
|
const travel = state === STATE.SYNCING ? SYNC_RESTING_PX : displayed;
|
|
|
|
return `translateY(calc(-100% + ${travel}px))`;
|
|
}
|
|
|
|
export default class extends Controller {
|
|
static targets = ["indicator", "icon"];
|
|
|
|
connect() {
|
|
this._syncing = false;
|
|
this._pulling = false;
|
|
this._claimed = false;
|
|
this._sequence = null;
|
|
this._rows = null;
|
|
this._displayed = 0;
|
|
|
|
this._onStart = this._touchStart.bind(this);
|
|
this._onMove = this._touchMove.bind(this);
|
|
this._onEnd = this._touchEnd.bind(this);
|
|
this._onCancel = this._touchCancel.bind(this);
|
|
this._onStarted = () => this._setSyncing(true);
|
|
this._onStopped = () => this._setSyncing(false);
|
|
|
|
this.element.addEventListener("touchstart", this._onStart, { passive: true });
|
|
this.element.addEventListener("touchend", this._onEnd, { passive: true });
|
|
this.element.addEventListener("touchcancel", this._onCancel, { passive: true });
|
|
document.addEventListener("mail--sync-now:started", this._onStarted);
|
|
document.addEventListener("mail--sync-now:stopped", this._onStopped);
|
|
|
|
this._render();
|
|
}
|
|
|
|
disconnect() {
|
|
this._abort();
|
|
|
|
this.element.removeEventListener("touchstart", this._onStart);
|
|
this.element.removeEventListener("touchend", this._onEnd);
|
|
this.element.removeEventListener("touchcancel", this._onCancel);
|
|
document.removeEventListener("mail--sync-now:started", this._onStarted);
|
|
document.removeEventListener("mail--sync-now:stopped", this._onStopped);
|
|
}
|
|
|
|
// ── Private ───────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* A sequence may become a pull only if it starts on the rows scroller, while
|
|
* the list is at its very top. The scrollTop test is in one place, here, so
|
|
* the decision and the arming cannot disagree about it.
|
|
*/
|
|
_mayStart() {
|
|
return this._rows !== null && this._rows.scrollTop === 0;
|
|
}
|
|
|
|
_touchStart(event) {
|
|
// A second finger, or more, ends any pull in progress. It is never a pull, and
|
|
// whatever the first finger does afterwards cannot sync it.
|
|
if (event.touches.length !== 1) {
|
|
this._abort();
|
|
|
|
return;
|
|
}
|
|
|
|
// A one-finger touchstart begins a new sequence, so whatever this controller still
|
|
// holds is stale: its touchend or touchcancel may never reach us, because the
|
|
// element the finger was on was removed mid-gesture. Abandon it, with no sync.
|
|
this._abort();
|
|
|
|
this._sequence = null;
|
|
this._rows = null;
|
|
|
|
if (window.matchMedia(MOBILE).matches !== true) {
|
|
return;
|
|
}
|
|
|
|
const rows = event.target.closest?.(ROWS);
|
|
|
|
if (!rows || !this.element.contains(rows)) {
|
|
return;
|
|
}
|
|
|
|
const touch = event.touches[0];
|
|
|
|
this._rows = rows;
|
|
this._origin = { x: touch.clientX, y: touch.clientY, at: event.timeStamp };
|
|
this._sequence = createSequence();
|
|
|
|
if (this._mayStart()) {
|
|
// Armed for this sequence only. See the class docblock on why this is
|
|
// added here and not permanently.
|
|
this.element.addEventListener("touchmove", this._onMove, { passive: false });
|
|
}
|
|
}
|
|
|
|
_touchMove(event) {
|
|
if (this._sequence === null || event.touches.length !== 1) {
|
|
return;
|
|
}
|
|
|
|
const touch = event.touches[0];
|
|
const dx = touch.clientX - this._origin.x;
|
|
const dy = touch.clientY - this._origin.y;
|
|
|
|
if (this._pulling === false) {
|
|
const intent = this._sequence.move(dx, dy, event.timeStamp - this._origin.at);
|
|
|
|
if (intent === Intent.UNDECIDED) {
|
|
return;
|
|
}
|
|
|
|
// Decided, and not a pull: a vertical move downward, from the top, with
|
|
// nothing else claiming the sequence. Anything else is left to the browser.
|
|
if (intent !== Intent.VERTICAL || dy <= 0 || this._mayStart() === false || claimSequence("pull") === false) {
|
|
this._disarm();
|
|
|
|
return;
|
|
}
|
|
|
|
this._claimed = true;
|
|
this._pulling = true;
|
|
}
|
|
|
|
if (event.cancelable) {
|
|
event.preventDefault();
|
|
}
|
|
|
|
this._displayed = Math.max(0, dy * PULL_RESISTANCE);
|
|
this._render();
|
|
}
|
|
|
|
/**
|
|
* touchend: the finger lifted. Only a pull this controller holds can sync, and
|
|
* only past the threshold. A touchend for a sequence that never claimed releases
|
|
* nothing: the claim, if any, belongs to someone else.
|
|
*/
|
|
_touchEnd() {
|
|
this._disarm();
|
|
|
|
if (this._claimed === false) {
|
|
return;
|
|
}
|
|
|
|
// The rows scroller was replaced during the pull (a frame swap, or a refresh that
|
|
// rebuilt the list): the pull is no longer over the list it started on, so it aborts.
|
|
if (this._rows.isConnected === false) {
|
|
this._abort();
|
|
|
|
return;
|
|
}
|
|
|
|
const released = this._displayed >= RELEASE_DISPLAYED_PX;
|
|
|
|
this._release();
|
|
|
|
if (released) {
|
|
this._syncNow();
|
|
}
|
|
|
|
this._displayed = 0;
|
|
this._render();
|
|
}
|
|
|
|
/** touchcancel: the system took the touch. The pull is aborted, and nothing syncs. */
|
|
_touchCancel() {
|
|
this._abort();
|
|
}
|
|
|
|
/**
|
|
* The button's own run(): its running guard and its toast decide, so a pull
|
|
* during a sync is refused exactly as a click would be. A disabled button is
|
|
* not a guard here, because nothing clicks it.
|
|
*/
|
|
_syncNow() {
|
|
const button = document.querySelector(SYNC_BUTTON);
|
|
|
|
if (button === null) {
|
|
return;
|
|
}
|
|
|
|
this.application.getControllerForElementAndIdentifier(button, "mail--sync-now")?.run();
|
|
}
|
|
|
|
/** Stop listening for this sequence's moves, and forget it. */
|
|
_disarm() {
|
|
this.element.removeEventListener("touchmove", this._onMove);
|
|
this._sequence = null;
|
|
}
|
|
|
|
/** Give back this controller's claim, if it holds one. Never another consumer's. */
|
|
_release() {
|
|
if (this._claimed) {
|
|
releaseSequence("pull");
|
|
this._claimed = false;
|
|
}
|
|
|
|
this._pulling = false;
|
|
}
|
|
|
|
/** A pull that did not end in a release: listeners off, claim released, indicator home. No sync. */
|
|
_abort() {
|
|
this._disarm();
|
|
this._release();
|
|
this._displayed = 0;
|
|
this._render();
|
|
}
|
|
|
|
_setSyncing(on) {
|
|
this._syncing = on;
|
|
this._render();
|
|
}
|
|
|
|
_state() {
|
|
if (this._syncing) {
|
|
return STATE.SYNCING;
|
|
}
|
|
|
|
if (this._pulling) {
|
|
return this._displayed >= RELEASE_DISPLAYED_PX ? STATE.RELEASE : STATE.PULLING;
|
|
}
|
|
|
|
return STATE.IDLE;
|
|
}
|
|
|
|
_render() {
|
|
if (this.hasIndicatorTarget === false) {
|
|
return;
|
|
}
|
|
|
|
const state = this._state();
|
|
const indicator = this.indicatorTarget;
|
|
|
|
indicator.dataset.state = state;
|
|
indicator.style.transform = restingTransform(state, this._displayed);
|
|
// Under reduced motion the slide back is a jump, not an animation.
|
|
indicator.style.transition = motionIsOff() ? "none" : "";
|
|
|
|
if (this.hasIconTarget) {
|
|
this.iconTarget.classList.toggle("fa-spin", state === STATE.SYNCING);
|
|
}
|
|
}
|
|
}
|