Group six more clusters out of web/src/lib
Takes the flat module count from 66 to 42, continuing what admin/ and
calendar/ started.
lib/mailbox/ archiveDate, emptyFolder, folderMove, labelTree,
mailboxName, mailboxRoute
lib/sieve/ sieve, sieveApply, sieveFolders
lib/input/ keyboard, swipe, touch, listSelection, dropUpload
lib/notify/ notify, webpush, webpushEnable
lib/sw/ swCache, swFacts, staleBuild
lib/text/ html, markdown, text, emlName
FOUR THINGS THE FILENAMES GET WRONG, each checked by reading the file
rather than trusting what it is called:
- appFolder is not a mailbox. It is the `ihasmail` folder in JMAP
*Files*, where the client keeps signature images and synced settings.
It stays flat.
- format holds no formatting of text. It re-exports the date and clock
formatters, so it belongs with dates/datetime, not with text/.
- preview is the file viewer deciding what it can show without
downloading, and source is where to point someone asking for this
instance's AGPL source. Neither is about text.
- notify is not Web Push. It is the tab title, the favicon badge and
the new-mail sound -- in-app notification, which is why it sits with
webpush rather than under sw/ with the service worker's own concerns.
threadScroll stays flat too: it decides where a conversation opens, which
is view state rather than a gesture, and input/ is honest only if
everything in it interprets something the reader did.
No behavior change. Almost every reference was on the @/ alias; eight
relative imports in files that did not move, or that moved away from a
sibling, needed rewriting by hand.
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { foldersNeeded, hasDirectory, planUpload } from "@/lib/input/dropUpload";
|
||||
|
||||
/**
|
||||
* Dropping a folder in, reduced to the two things the DataTransfer entry API
|
||||
* gets wrong if you take it at face value.
|
||||
*
|
||||
* `readEntries` answers with *up to* some number of entries and signals the end
|
||||
* of a directory with an empty array, so a single call quietly loses everything
|
||||
* past the first batch — a real folder of a few hundred files would upload the
|
||||
* first hundred and look like it had finished. And a directory tree that cycles
|
||||
* has to stop somewhere the tab is still alive.
|
||||
*/
|
||||
|
||||
const file = (name: string) => new File([name], name);
|
||||
|
||||
/** A directory whose contents arrive a batch at a time, as a real one does. */
|
||||
const dir = (name: string, children: unknown[], batch = 2) => {
|
||||
let at = 0;
|
||||
return {
|
||||
isFile: false,
|
||||
isDirectory: true,
|
||||
name,
|
||||
createReader: () => ({
|
||||
readEntries: (cb: (e: never[]) => void) => {
|
||||
const slice = children.slice(at, at + batch);
|
||||
at += slice.length;
|
||||
cb(slice as never[]);
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const leaf = (name: string) => ({
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
name,
|
||||
file: (cb: (f: File) => void) => cb(file(name)),
|
||||
});
|
||||
|
||||
describe("walking a dropped folder", () => {
|
||||
it("reads a directory across as many batches as it takes", async () => {
|
||||
// Five children, two per readEntries call: a single read would find two.
|
||||
const plan = await planUpload([dir("docs", ["a", "b", "c", "d", "e"].map(leaf))] as never[]);
|
||||
expect(plan.map((p) => p.file.name)).toEqual(["a", "b", "c", "d", "e"]);
|
||||
expect(plan.every((p) => p.path.join("/") === "docs")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the folder each file came from", async () => {
|
||||
const plan = await planUpload([dir("outer", [leaf("top"), dir("inner", [leaf("deep")])])] as never[]);
|
||||
expect(plan.map((p) => [p.path.join("/"), p.file.name])).toEqual([
|
||||
["outer", "top"],
|
||||
["outer/inner", "deep"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("puts a loose file at the drop itself", async () => {
|
||||
const plan = await planUpload([leaf("loose")] as never[]);
|
||||
expect(plan).toEqual([expect.objectContaining({ path: [] })]);
|
||||
});
|
||||
|
||||
it("stops rather than following a cycle for ever", async () => {
|
||||
const loop: Record<string, unknown> = {};
|
||||
Object.assign(loop, dir("loop", []));
|
||||
(loop as { createReader: () => unknown }).createReader = () => ({
|
||||
readEntries: (cb: (e: unknown[]) => void) => cb([loop]),
|
||||
});
|
||||
// Terminating at all is the assertion; the caps decide where. Both are set
|
||||
// low so the test does not have to read twenty thousand phantom entries.
|
||||
const plan = await planUpload([loop] as never[], { maxDepth: 4, maxEntries: 50 });
|
||||
expect(plan).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the folders a plan needs", () => {
|
||||
it("lists parents before their children", () => {
|
||||
const needed = foldersNeeded([
|
||||
{ file: file("x"), path: ["a", "b", "c"] },
|
||||
{ file: file("y"), path: ["a"] },
|
||||
]);
|
||||
expect(needed).toEqual([["a"], ["a", "b"], ["a", "b", "c"]]);
|
||||
});
|
||||
|
||||
it("names each folder once, however many files are in it", () => {
|
||||
const needed = foldersNeeded([
|
||||
{ file: file("x"), path: ["a"] },
|
||||
{ file: file("y"), path: ["a"] },
|
||||
]);
|
||||
expect(needed).toEqual([["a"]]);
|
||||
});
|
||||
|
||||
it("asks for nothing when everything lands at the drop", () => {
|
||||
expect(foldersNeeded([{ file: file("x"), path: [] }])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("spotting a folder in the drop", () => {
|
||||
it("is true when any entry is a directory", () => {
|
||||
expect(hasDirectory([leaf("a"), dir("d", [])] as never[])).toBe(true);
|
||||
expect(hasDirectory([leaf("a")] as never[])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { rowClick, type RowClick } from "@/lib/input/listSelection";
|
||||
|
||||
const IDS = ["a", "b", "c", "d", "e"];
|
||||
const click = (over: Partial<Parameters<typeof rowClick>[0]> = {}): RowClick =>
|
||||
rowClick({
|
||||
rowId: "c", ids: IDS, anchor: null, selected: {},
|
||||
modifiers: { shift: false, ctrl: false }, isMobile: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("a plain click", () => {
|
||||
it("opens the message rather than selecting it", () => {
|
||||
expect(click()).toEqual({ kind: "open" });
|
||||
});
|
||||
|
||||
it("opens it even when another message is already open", () => {
|
||||
expect(click({ anchor: "a" })).toEqual({ kind: "open" });
|
||||
});
|
||||
|
||||
it("goes on selecting on a touchscreen once a selection exists", () => {
|
||||
// There is no modifier to hold on a phone, and opening a message in the
|
||||
// middle of picking several is almost never what the tap meant.
|
||||
expect(click({ isMobile: true, selected: { a: true } })).toEqual({ kind: "select", ids: ["c"], on: true, moveAnchor: true });
|
||||
});
|
||||
|
||||
it("still opens on a touchscreen when nothing is selected", () => {
|
||||
expect(click({ isMobile: true })).toEqual({ kind: "open" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctrl-clicking", () => {
|
||||
it("takes the message that was already current with it", () => {
|
||||
// Issue #186: this used to select only the row clicked, leaving the open
|
||||
// message highlighted but unticked, so actions applied to one of two.
|
||||
expect(click({ anchor: "a", modifiers: { shift: false, ctrl: true } }))
|
||||
.toEqual({ kind: "select", ids: ["a", "c"], on: true, moveAnchor: true });
|
||||
});
|
||||
|
||||
it("toggles one row once there is a selection, and leaves the rest alone", () => {
|
||||
expect(click({ anchor: "a", selected: { a: true, c: true }, modifiers: { shift: false, ctrl: true } }))
|
||||
.toEqual({ kind: "select", ids: ["c"], on: false, moveAnchor: true });
|
||||
expect(click({ anchor: "a", selected: { a: true }, modifiers: { shift: false, ctrl: true } }))
|
||||
.toEqual({ kind: "select", ids: ["c"], on: true, moveAnchor: true });
|
||||
});
|
||||
|
||||
it("selects just the row when there is nothing current to bring along", () => {
|
||||
expect(click({ anchor: null, modifiers: { shift: false, ctrl: true } }))
|
||||
.toEqual({ kind: "select", ids: ["c"], on: true, moveAnchor: true });
|
||||
});
|
||||
|
||||
it("does not bring along a row that has scrolled out of the list", () => {
|
||||
// The anchor can name a message from a folder that is no longer shown.
|
||||
expect(click({ anchor: "gone", modifiers: { shift: false, ctrl: true } }))
|
||||
.toEqual({ kind: "select", ids: ["c"], on: true, moveAnchor: true });
|
||||
});
|
||||
|
||||
it("does not pair a row with itself", () => {
|
||||
expect(click({ rowId: "a", anchor: "a", modifiers: { shift: false, ctrl: true } }))
|
||||
.toEqual({ kind: "select", ids: ["a"], on: true, moveAnchor: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("shift-clicking", () => {
|
||||
it("takes the whole run, including the row it started from", () => {
|
||||
expect(click({ rowId: "d", anchor: "b", modifiers: { shift: true, ctrl: false } }))
|
||||
.toEqual({ kind: "select", ids: ["b", "c", "d"], on: true, moveAnchor: false });
|
||||
});
|
||||
|
||||
it("works the same way backwards", () => {
|
||||
expect(click({ rowId: "b", anchor: "d", modifiers: { shift: true, ctrl: false } }))
|
||||
.toEqual({ kind: "select", ids: ["b", "c", "d"], on: true, moveAnchor: false });
|
||||
});
|
||||
|
||||
it("leaves the anchor where it is, so the range grows from one place", () => {
|
||||
const first = click({ rowId: "c", anchor: "a", modifiers: { shift: true, ctrl: false } });
|
||||
expect(first).toMatchObject({ moveAnchor: false });
|
||||
// Extending again still starts at "a" rather than at "c".
|
||||
expect(click({ rowId: "e", anchor: "a", modifiers: { shift: true, ctrl: false } }))
|
||||
.toMatchObject({ ids: ["a", "b", "c", "d", "e"] });
|
||||
});
|
||||
|
||||
it("falls back to opening when there is nothing to extend from", () => {
|
||||
expect(click({ anchor: null, modifiers: { shift: true, ctrl: false } })).toEqual({ kind: "open" });
|
||||
});
|
||||
|
||||
it("falls back when the anchor is no longer in the list", () => {
|
||||
expect(click({ anchor: "gone", modifiers: { shift: true, ctrl: false } })).toEqual({ kind: "open" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the two rules agree with each other", () => {
|
||||
it("both include the row the selection started from", () => {
|
||||
// The bug was that only one of them did. Whatever else changes, a modifier
|
||||
// click that begins a selection has to contain the anchor.
|
||||
const withCtrl = click({ rowId: "d", anchor: "b", modifiers: { shift: false, ctrl: true } });
|
||||
const withShift = click({ rowId: "d", anchor: "b", modifiers: { shift: true, ctrl: false } });
|
||||
for (const result of [withCtrl, withShift]) {
|
||||
expect(result.kind, JSON.stringify(result)).toBe("select");
|
||||
expect((result as { ids: string[] }).ids).toContain("b");
|
||||
expect((result as { ids: string[] }).ids).toContain("d");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SWIPE_CHOICES, describeSwipe, type SwipeAction } from "../swipe";
|
||||
|
||||
/**
|
||||
* A swipe names what it is about to do on a colored strip the reader sees for
|
||||
* about a third of a second before letting go. These check that the name is
|
||||
* true in the folder it is being read in — which is the whole reason the
|
||||
* descriptor exists rather than a fixed label per setting.
|
||||
*/
|
||||
|
||||
const inbox = { role: "inbox", unread: false, starred: false };
|
||||
|
||||
describe("describeSwipe", () => {
|
||||
it("offers nothing for a direction turned off", () => {
|
||||
expect(describeSwipe("none", inbox)).toBe(null);
|
||||
});
|
||||
|
||||
it("refuses to archive out of the archive", () => {
|
||||
expect(describeSwipe("archive", { ...inbox, role: "archive" })).toBe(null);
|
||||
expect(describeSwipe("archive", inbox)).toMatchObject({ label: "Archive", removes: true });
|
||||
});
|
||||
|
||||
it("says out loud that a delete from Deleted Items is permanent", () => {
|
||||
expect(describeSwipe("delete", inbox)?.label).toBe("Delete");
|
||||
expect(describeSwipe("delete", { ...inbox, role: "trash" })?.label).toBe("Delete forever");
|
||||
});
|
||||
|
||||
it("turns the spam action around inside the junk folder", () => {
|
||||
expect(describeSwipe("spam", inbox)).toMatchObject({ label: "Report spam", icon: "spam" });
|
||||
expect(describeSwipe("spam", { ...inbox, role: "junk" })).toMatchObject({ label: "Not spam", icon: "not-spam" });
|
||||
});
|
||||
|
||||
it("has no opinion on whether your own mail is spam", () => {
|
||||
expect(describeSwipe("spam", { ...inbox, role: "drafts" })).toBe(null);
|
||||
expect(describeSwipe("spam", { ...inbox, role: "sent" })).toBe(null);
|
||||
});
|
||||
|
||||
it("names the state a toggle is about to set, and carries it", () => {
|
||||
expect(describeSwipe("read", { ...inbox, unread: true })).toMatchObject({ label: "Mark as read", icon: "read", on: true });
|
||||
expect(describeSwipe("read", { ...inbox, unread: false })).toMatchObject({ label: "Mark as unread", icon: "unread", on: false });
|
||||
expect(describeSwipe("star", { ...inbox, starred: false })).toMatchObject({ label: "Add star", on: true });
|
||||
expect(describeSwipe("star", { ...inbox, starred: true })).toMatchObject({ label: "Remove star", on: false });
|
||||
});
|
||||
|
||||
it("brings a row home for the actions that open something instead", () => {
|
||||
// The row has to be back under the finger before the folder picker covers
|
||||
// the list, or it is still hanging half-open when the picker closes again.
|
||||
expect(describeSwipe("move", inbox)).toMatchObject({ label: "Move to…", removes: false });
|
||||
for (const action of ["archive", "delete", "spam"] as const) {
|
||||
expect(describeSwipe(action, inbox)?.removes).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("can describe everything the settings picker offers", () => {
|
||||
// A choice the picker offers and the list cannot describe is a direction
|
||||
// that silently does nothing — the one failure nobody would report.
|
||||
for (const { value } of SWIPE_CHOICES) {
|
||||
const d = describeSwipe(value as SwipeAction, inbox);
|
||||
if (value === "none") expect(d).toBe(null);
|
||||
else expect(d?.label).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AXIS_SLOP, PULL_MAX, PULL_TRIGGER, lockAxis, pullDistance, swipeOffset, swipeThreshold } from "../touch";
|
||||
|
||||
/**
|
||||
* The arithmetic behind the touch gestures, checked without a touchscreen.
|
||||
*
|
||||
* These are the numbers that decide whether a finger meant to scroll the list
|
||||
* or to act on a message, and getting them wrong is not a crash — it is an app
|
||||
* that deletes mail when someone tried to scroll past it. Worth pinning down.
|
||||
*/
|
||||
|
||||
describe("lockAxis", () => {
|
||||
it("stays undecided until the finger has committed", () => {
|
||||
expect(lockAxis(0, 0)).toBe(null);
|
||||
expect(lockAxis(AXIS_SLOP - 1, AXIS_SLOP - 1)).toBe(null);
|
||||
});
|
||||
|
||||
it("reads a clearly sideways drag as a swipe", () => {
|
||||
expect(lockAxis(40, 4)).toBe("x");
|
||||
expect(lockAxis(-40, 4)).toBe("x");
|
||||
});
|
||||
|
||||
it("gives a diagonal to the scroller, not the swipe", () => {
|
||||
// 45 degrees is more sideways than not, and is still a scroll: someone
|
||||
// flicking down a list does not travel straight down the glass.
|
||||
expect(lockAxis(30, 30)).toBe("y");
|
||||
expect(lockAxis(30, 25)).toBe("y");
|
||||
});
|
||||
|
||||
it("counts distance on either axis toward committing", () => {
|
||||
expect(lockAxis(0, AXIS_SLOP)).toBe("y");
|
||||
expect(lockAxis(AXIS_SLOP, 0)).toBe("x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("swipeThreshold", () => {
|
||||
it("scales with the row but never off either end", () => {
|
||||
expect(swipeThreshold(300)).toBeCloseTo(84); // a phone: a share of the row
|
||||
expect(swipeThreshold(160)).toBe(56); // a narrow row: a fixed floor
|
||||
expect(swipeThreshold(2000)).toBe(96); // a tablet: not the whole reach
|
||||
});
|
||||
});
|
||||
|
||||
describe("swipeOffset", () => {
|
||||
const width = 360;
|
||||
const limit = swipeThreshold(width);
|
||||
|
||||
it("follows the finger exactly until the action would fire", () => {
|
||||
expect(swipeOffset(20, width)).toBe(20);
|
||||
expect(swipeOffset(-20, width)).toBe(-20);
|
||||
expect(swipeOffset(limit, width)).toBe(limit);
|
||||
});
|
||||
|
||||
it("resists past the threshold, in both directions", () => {
|
||||
const over = swipeOffset(limit + 100, width);
|
||||
expect(over).toBeGreaterThan(limit);
|
||||
expect(over).toBeLessThan(limit + 100);
|
||||
expect(swipeOffset(-(limit + 100), width)).toBeCloseTo(-over);
|
||||
});
|
||||
|
||||
it("never travels further than the row is wide", () => {
|
||||
// Past the row's own width there is nothing left to reveal, so a hard
|
||||
// flick stops there rather than accumulating travel with nowhere to show.
|
||||
expect(Math.abs(swipeOffset(2000, width))).toBe(width);
|
||||
expect(Math.abs(swipeOffset(-2000, width))).toBe(width);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pullDistance", () => {
|
||||
it("ignores an upward drag", () => {
|
||||
expect(pullDistance(0)).toBe(0);
|
||||
expect(pullDistance(-50)).toBe(0);
|
||||
});
|
||||
|
||||
it("asks for a deliberate pull, not the overscroll at the top of a list", () => {
|
||||
expect(pullDistance(40)).toBeLessThan(PULL_TRIGGER);
|
||||
expect(pullDistance(60)).toBeLessThan(PULL_TRIGGER);
|
||||
expect(pullDistance(140)).toBeGreaterThanOrEqual(PULL_TRIGGER);
|
||||
});
|
||||
|
||||
it("stops coming down however hard it is pulled", () => {
|
||||
expect(pullDistance(400)).toBe(PULL_MAX);
|
||||
expect(pullDistance(4000)).toBe(PULL_MAX);
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Gmail-style keyboard shortcut manager with two-key sequences ("g i").
|
||||
* Handlers are registered in scopes; the most recently pushed scope wins.
|
||||
*/
|
||||
export type KeyHandler = (e: KeyboardEvent) => void | boolean;
|
||||
|
||||
interface Binding {
|
||||
keys: string; // e.g. "j", "shift+i", "g i", "mod+enter"
|
||||
handler: KeyHandler;
|
||||
description: string;
|
||||
group: string;
|
||||
allowInInput?: boolean;
|
||||
}
|
||||
|
||||
interface Scope {
|
||||
name: string;
|
||||
bindings: Binding[];
|
||||
}
|
||||
|
||||
class Keyboard {
|
||||
private scopes: Scope[] = [];
|
||||
private pendingPrefix: string | null = null;
|
||||
private prefixTimer: number | null = null;
|
||||
enabled = true;
|
||||
|
||||
constructor() {
|
||||
if (typeof window !== "undefined") window.addEventListener("keydown", this.onKeyDown, true);
|
||||
}
|
||||
|
||||
pushScope(name: string, bindings: Binding[]): () => void {
|
||||
const scope = { name, bindings };
|
||||
this.scopes.push(scope);
|
||||
return () => {
|
||||
this.scopes = this.scopes.filter((s) => s !== scope);
|
||||
};
|
||||
}
|
||||
|
||||
/** All bindings with descriptions, for the help overlay. */
|
||||
list(): Array<{ group: string; keys: string; description: string }> {
|
||||
const seen = new Set<string>();
|
||||
const out: Array<{ group: string; keys: string; description: string }> = [];
|
||||
for (const s of [...this.scopes].reverse()) {
|
||||
for (const b of s.bindings) {
|
||||
if (!b.description || seen.has(b.keys)) continue;
|
||||
seen.add(b.keys);
|
||||
out.push({ group: b.group, keys: b.keys, description: b.description });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private onKeyDown = (e: KeyboardEvent) => {
|
||||
if (!this.enabled) return;
|
||||
// Let modal dialogs and popovers handle their own keys (Escape, arrows, ...).
|
||||
if (document.querySelector(".dialog-backdrop, .popover")) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
const inInput = isTextEntry(target);
|
||||
const combo = comboOf(e);
|
||||
if (!combo) return;
|
||||
|
||||
// Try sequence completion first.
|
||||
const candidates: Binding[] = [];
|
||||
for (let i = this.scopes.length - 1; i >= 0; i--) {
|
||||
for (const b of this.scopes[i]!.bindings) candidates.push(b);
|
||||
}
|
||||
if (this.pendingPrefix) {
|
||||
const seq = `${this.pendingPrefix} ${combo}`;
|
||||
const b = candidates.find((x) => x.keys === seq && (!inInput || x.allowInInput));
|
||||
this.clearPrefix();
|
||||
if (b) {
|
||||
const r = b.handler(e);
|
||||
if (r !== false) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Is this combo the first half of any sequence?
|
||||
if (!inInput && candidates.some((x) => x.keys.startsWith(`${combo} `))) {
|
||||
this.pendingPrefix = combo;
|
||||
this.prefixTimer = window.setTimeout(() => this.clearPrefix(), 1200);
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const b = candidates.find((x) => x.keys === combo && (!inInput || x.allowInInput));
|
||||
if (b) {
|
||||
const r = b.handler(e);
|
||||
if (r !== false) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private clearPrefix() {
|
||||
this.pendingPrefix = null;
|
||||
if (this.prefixTimer) window.clearTimeout(this.prefixTimer);
|
||||
this.prefixTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the focused element somewhere the reader is typing?
|
||||
*
|
||||
* This guard exists so that pressing "a" in the search box searches for "a"
|
||||
* rather than archiving the message behind it. The test used to be
|
||||
* `tagName === "INPUT"`, which is true of a checkbox — and a checkbox keeps
|
||||
* focus after you click it, so ticking "select all" silently disabled every
|
||||
* shortcut until the reader clicked somewhere else (#260). Nothing about a
|
||||
* checkbox swallows a keystroke: space toggles it and the browser handles
|
||||
* that before this listener ever runs.
|
||||
*
|
||||
* So the question is not "is this an input" but "does this input take text".
|
||||
* A `<select>` does, in the sense that matters here: typing a letter jumps to
|
||||
* the option beginning with it, which a shortcut would steal.
|
||||
*/
|
||||
const TEXT_ENTRY_TYPES = new Set([
|
||||
"text", "search", "email", "url", "tel", "password", "number",
|
||||
"date", "datetime-local", "month", "time", "week",
|
||||
]);
|
||||
|
||||
export function isTextEntry(el: Element | null): boolean {
|
||||
if (!el) return false;
|
||||
const node = el as HTMLElement;
|
||||
if (node.isContentEditable) return true;
|
||||
const tag = node.tagName;
|
||||
if (tag === "TEXTAREA" || tag === "SELECT") return true;
|
||||
if (tag !== "INPUT") return false;
|
||||
// An <input> with no type attribute is a text field.
|
||||
const type = (node as HTMLInputElement).type?.toLowerCase() || "text";
|
||||
return TEXT_ENTRY_TYPES.has(type);
|
||||
}
|
||||
|
||||
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
|
||||
export function comboOf(e: KeyboardEvent): string | null {
|
||||
const key = e.key;
|
||||
// Chrome's password autofill dispatches a plain Event named "keydown" when a
|
||||
// saved login is picked: no key, nothing to match, and reading its length
|
||||
// threw on every sign-in.
|
||||
if (!key) return null;
|
||||
if (key === "Shift" || key === "Control" || key === "Alt" || key === "Meta") return null;
|
||||
const parts: string[] = [];
|
||||
const mod = isMac ? e.metaKey : e.ctrlKey;
|
||||
if (mod) parts.push("mod");
|
||||
if (e.altKey) parts.push("alt");
|
||||
if (e.shiftKey && key.length > 1) parts.push("shift");
|
||||
let k = key;
|
||||
if (k === " ") k = "space";
|
||||
else if (k === "Escape") k = "esc";
|
||||
else if (k.length === 1) {
|
||||
// Single chars: shift is encoded by the character itself (e.g. "#", "!").
|
||||
k = k.length === 1 && !e.shiftKey ? k.toLowerCase() : k;
|
||||
} else k = k.toLowerCase();
|
||||
parts.push(k);
|
||||
return parts.join("+");
|
||||
}
|
||||
|
||||
export function formatKeys(keys: string): string {
|
||||
return keys
|
||||
.split(" ")
|
||||
.map((k) =>
|
||||
k
|
||||
.split("+")
|
||||
.map((p) => (p === "mod" ? (isMac ? "⌘" : "Ctrl") : p === "shift" ? "⇧" : p === "alt" ? (isMac ? "⌥" : "Alt") : p === "enter" ? "↵" : p === "esc" ? "Esc" : p === "space" ? "Space" : p === "arrowup" ? "↑" : p === "arrowdown" ? "↓" : p === "arrowleft" ? "←" : p === "arrowright" ? "→" : p.length === 1 ? p : p[0]!.toUpperCase() + p.slice(1)))
|
||||
.join(isMac ? "" : "+"),
|
||||
)
|
||||
.join(" then ");
|
||||
}
|
||||
|
||||
export const keyboard = new Keyboard();
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Id } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
* What a click on a message row means.
|
||||
*
|
||||
* Lifted out of the list so the rules sit together and can be tested. They had
|
||||
* drifted apart while they were two branches of one handler: shift-click
|
||||
* selected the whole range including the row it started from, and ctrl-click
|
||||
* selected only the row clicked, leaving the message you had open highlighted
|
||||
* but unticked. Both looked picked; one was. That is issue #186, and the reason
|
||||
* this is a function rather than a comment asking the next person to be careful.
|
||||
*/
|
||||
|
||||
export type RowClick =
|
||||
| { kind: "open" }
|
||||
| { kind: "select"; ids: Id[]; on: boolean; moveAnchor: boolean };
|
||||
|
||||
export function rowClick(opts: {
|
||||
/** The row clicked. */
|
||||
rowId: Id;
|
||||
/** Every row on screen, in the order they are shown. */
|
||||
ids: Id[];
|
||||
/** The row a range would extend from: the last one clicked without shift. */
|
||||
anchor: Id | null;
|
||||
selected: Record<Id, boolean>;
|
||||
modifiers: { shift: boolean; ctrl: boolean };
|
||||
isMobile: boolean;
|
||||
}): RowClick {
|
||||
const { rowId, ids, anchor, selected, modifiers, isMobile } = opts;
|
||||
const selectedCount = Object.keys(selected).length;
|
||||
|
||||
// A range, from the anchor to here, inclusive at both ends.
|
||||
if (modifiers.shift && anchor) {
|
||||
const from = ids.indexOf(anchor);
|
||||
const to = ids.indexOf(rowId);
|
||||
if (from >= 0 && to >= 0) {
|
||||
const [start, end] = from < to ? [from, to] : [to, from];
|
||||
// The anchor stays where it is, so extending the range again grows it
|
||||
// from the same place rather than from wherever it last reached.
|
||||
return { kind: "select", ids: ids.slice(start, end + 1), on: true, moveAnchor: false };
|
||||
}
|
||||
}
|
||||
|
||||
if (modifiers.ctrl) {
|
||||
/*
|
||||
* The row that was already current joins the selection.
|
||||
*
|
||||
* Opening a message does not select it -- it is highlighted because it is
|
||||
* the one being read, which is a different state -- so picking a second one
|
||||
* with ctrl used to select only the second, and every action that followed
|
||||
* quietly applied to half of what the screen showed.
|
||||
*
|
||||
* Only while nothing is selected yet. Once there is a selection, ctrl-click
|
||||
* toggles exactly one row, which is the whole point of it.
|
||||
*/
|
||||
if (!selectedCount && anchor && anchor !== rowId && ids.includes(anchor)) {
|
||||
return { kind: "select", ids: [anchor, rowId], on: true, moveAnchor: true };
|
||||
}
|
||||
return { kind: "select", ids: [rowId], on: !selected[rowId], moveAnchor: true };
|
||||
}
|
||||
|
||||
// On a touchscreen, once anything is selected a plain tap goes on selecting:
|
||||
// there is no modifier to hold, and opening a message mid-selection is almost
|
||||
// never what the tap meant.
|
||||
if (selectedCount > 0 && isMobile) {
|
||||
return { kind: "select", ids: [rowId], on: !selected[rowId], moveAnchor: true };
|
||||
}
|
||||
|
||||
return { kind: "open" };
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* What a swipe on a message row does, and what it should say it is about to do.
|
||||
*
|
||||
* The reader picks one action for each direction in Settings, but an action is
|
||||
* not a fixed thing: "delete" out of Deleted Items is permanent, "report spam"
|
||||
* inside Junk Mail is the opposite request, and "archive" while looking at the
|
||||
* archive is nothing at all. The strip revealed behind the row has to name the
|
||||
* thing that will actually happen, in the folder it is happening in -- a row
|
||||
* that slides open to reveal the word "Archive" and then does nothing is worse
|
||||
* than one that does not slide.
|
||||
*
|
||||
* So a direction with no meaning here resolves to `null`, and a null direction
|
||||
* is one the row simply will not move in.
|
||||
*/
|
||||
|
||||
export type SwipeAction = "archive" | "delete" | "spam" | "read" | "star" | "move" | "none";
|
||||
|
||||
export interface SwipeContext {
|
||||
/** The role of the folder on screen, where it has one. */
|
||||
role?: string | null;
|
||||
/** Whether the row is unread — "mark as read" is a toggle, and says so. */
|
||||
unread: boolean;
|
||||
starred: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which glyph the strip shows. Named for the state it is offering rather than
|
||||
* the setting it came from: "mark as unread" and "not spam" are the same two
|
||||
* settings as their opposites but nothing like the same icon, and a strip that
|
||||
* says "Not spam" beside a spam icon is asking to be misread at a glance.
|
||||
*/
|
||||
export type SwipeIcon = "archive" | "delete" | "spam" | "not-spam" | "read" | "unread" | "star" | "unstar" | "move";
|
||||
|
||||
export interface SwipeDescriptor {
|
||||
action: Exclude<SwipeAction, "none">;
|
||||
label: string;
|
||||
icon: SwipeIcon;
|
||||
/** Which color the strip behind the row takes. */
|
||||
tone: "danger" | "warn" | "accent" | "neutral";
|
||||
/**
|
||||
* Whether firing it takes the row out of the list. Those slide the rest of
|
||||
* the way off before they fire, so the message is gone from under the finger
|
||||
* rather than snapping home and then vanishing a frame later.
|
||||
*/
|
||||
removes: boolean;
|
||||
/**
|
||||
* For the two actions that are toggles, the state this swipe sets — so the
|
||||
* caller fires exactly what the strip promised. Read back off the row
|
||||
* instead and a slow finger can invert it: the strip that said "Mark as
|
||||
* read" would mark as unread if a push update landed mid-gesture.
|
||||
*/
|
||||
on?: boolean;
|
||||
}
|
||||
|
||||
export function describeSwipe(action: SwipeAction, ctx: SwipeContext): SwipeDescriptor | null {
|
||||
switch (action) {
|
||||
case "archive":
|
||||
// Archiving out of the archive is the one no-op worth refusing outright.
|
||||
return ctx.role === "archive" ? null : { action, label: "Archive", icon: "archive", tone: "accent", removes: true };
|
||||
case "delete":
|
||||
return { action, label: ctx.role === "trash" ? "Delete forever" : "Delete", icon: "delete", tone: "danger", removes: true };
|
||||
case "spam":
|
||||
// Nothing you wrote is spam you received, so the gesture stays inert in
|
||||
// the two folders that hold your own mail.
|
||||
if (ctx.role === "drafts" || ctx.role === "sent") return null;
|
||||
return ctx.role === "junk"
|
||||
? { action, label: "Not spam", icon: "not-spam", tone: "warn", removes: true }
|
||||
: { action, label: "Report spam", icon: "spam", tone: "warn", removes: true };
|
||||
case "read":
|
||||
return { action, label: ctx.unread ? "Mark as read" : "Mark as unread", icon: ctx.unread ? "read" : "unread", tone: "neutral", removes: false, on: ctx.unread };
|
||||
case "star":
|
||||
return { action, label: ctx.starred ? "Remove star" : "Add star", icon: ctx.starred ? "unstar" : "star", tone: "warn", removes: false, on: !ctx.starred };
|
||||
case "move":
|
||||
// The folder picker opens over the list, so the row comes home first.
|
||||
return { action, label: "Move to…", icon: "move", tone: "accent", removes: false };
|
||||
case "none":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The Settings picker's options, in the order they are offered. */
|
||||
export const SWIPE_CHOICES: ReadonlyArray<{ value: SwipeAction; label: string }> = [
|
||||
{ value: "archive", label: "Archive" },
|
||||
{ value: "delete", label: "Delete" },
|
||||
{ value: "read", label: "Mark as read / unread" },
|
||||
{ value: "star", label: "Star / unstar" },
|
||||
{ value: "spam", label: "Report spam / not spam" },
|
||||
{ value: "move", label: "Move to…" },
|
||||
{ value: "none", label: "Nothing" },
|
||||
];
|
||||
@@ -0,0 +1,618 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import type { PointerEvent as ReactPointerEvent, MouseEvent as ReactMouseEvent } from "react";
|
||||
|
||||
/**
|
||||
* The gestures a phone expects, and the arithmetic behind them.
|
||||
*
|
||||
* ihasmail's mail list was built for a mouse: a row is clicked, right-clicked
|
||||
* and dragged into a folder. None of those exist on a phone, which instead has
|
||||
* three conventions so settled that their absence reads as the app being
|
||||
* broken -- swipe a row to act on it, hold a row to select it, and pull the
|
||||
* top of a list to refresh it.
|
||||
*
|
||||
* The numbers and the decisions live here rather than in the components so
|
||||
* they can be tested without a touchscreen, and so the three gestures agree
|
||||
* with each other: the same slop that says "this finger is holding still"
|
||||
* decides whether a long press survives, and the same axis lock keeps a swipe
|
||||
* from stealing a scroll.
|
||||
*
|
||||
* Everything here is touch-only by design. A mouse keeps drag-to-folder, which
|
||||
* shares the same pointer stream and would otherwise be fighting a swipe for
|
||||
* every drag.
|
||||
*/
|
||||
|
||||
/** A press held this long, with the finger still, is a long press. */
|
||||
export const LONG_PRESS_MS = 450;
|
||||
|
||||
/**
|
||||
* How far a finger may drift and still count as holding still.
|
||||
*
|
||||
* A thumb resting on glass wanders a few pixels on its own, so zero would mean
|
||||
* a long press almost never fires; much more than this and a slow deliberate
|
||||
* drag starts opening a selection instead of moving the row.
|
||||
*/
|
||||
export const PRESS_SLOP = 10;
|
||||
|
||||
/** How far a drag travels before it commits to being horizontal or vertical. */
|
||||
export const AXIS_SLOP = 12;
|
||||
|
||||
export type Axis = "x" | "y" | null;
|
||||
|
||||
/**
|
||||
* Which way a drag has committed, once it has moved far enough to tell.
|
||||
*
|
||||
* Deliberately biased toward the vertical. Scrolling is what a finger on a
|
||||
* message list is doing almost every time, and a scroll misread as a swipe
|
||||
* grabs the list out from under the reader, while a swipe misread as a scroll
|
||||
* costs them a second attempt. So `x` has to win clearly -- a drag that is
|
||||
* merely more sideways than not stays a scroll.
|
||||
*/
|
||||
export function lockAxis(dx: number, dy: number): Axis {
|
||||
const ax = Math.abs(dx);
|
||||
const ay = Math.abs(dy);
|
||||
if (Math.max(ax, ay) < AXIS_SLOP) return null;
|
||||
return ax > ay * 1.3 ? "x" : "y";
|
||||
}
|
||||
|
||||
/**
|
||||
* How far past the row's edge a swipe must reach before letting go fires it.
|
||||
*
|
||||
* A share of the row rather than a fixed distance, so the gesture feels the
|
||||
* same on a phone and on a tablet, but bounded at both ends: on a narrow
|
||||
* screen a percentage is a flick nobody meant, and on a wide one it is a
|
||||
* reach across the whole device.
|
||||
*/
|
||||
export function swipeThreshold(width: number): number {
|
||||
return Math.max(56, Math.min(96, width * 0.28));
|
||||
}
|
||||
|
||||
/**
|
||||
* How far the row actually moves for a finger that has traveled `dx`.
|
||||
*
|
||||
* One-to-one until the action would fire, and increasingly reluctant after
|
||||
* that. The resistance is the only thing that tells a thumb, without the
|
||||
* reader looking down at the exact moment, that it has gone far enough.
|
||||
*
|
||||
* Stopped dead at the row's own width, because there is nothing past it: the
|
||||
* row is already fully off screen, and a curve with no ceiling would go on
|
||||
* accumulating travel that has nowhere to show. That only bites on a flick
|
||||
* that outruns the screen, which is exactly when the row is moving too fast
|
||||
* for anyone to see it stop.
|
||||
*/
|
||||
export function swipeOffset(dx: number, width: number): number {
|
||||
const limit = swipeThreshold(width);
|
||||
const over = Math.abs(dx) - limit;
|
||||
if (over <= 0) return dx;
|
||||
return Math.sign(dx) * Math.min(width, limit + over * 0.35);
|
||||
}
|
||||
|
||||
/** Pull-to-refresh: how far the list comes down before letting go refreshes. */
|
||||
export const PULL_TRIGGER = 64;
|
||||
/** Where the list rests while the refresh it asked for is running. */
|
||||
export const PULL_REST = 48;
|
||||
/** As far as the list will ever come down, however hard it is pulled. */
|
||||
export const PULL_MAX = 110;
|
||||
|
||||
/**
|
||||
* How far the list follows a finger that has pulled down `dy`.
|
||||
*
|
||||
* Under half, so the trigger sits at about 116px of travel: far enough that
|
||||
* the overscroll at the top of a list -- which happens constantly, to nobody's
|
||||
* intent -- does not keep firing refreshes.
|
||||
*/
|
||||
export function pullDistance(dy: number): number {
|
||||
if (dy <= 0) return 0;
|
||||
return Math.min(PULL_MAX, dy * 0.55);
|
||||
}
|
||||
|
||||
/**
|
||||
* A short tap of the vibration motor, where there is one.
|
||||
*
|
||||
* Confirmation that a gesture landed, for the hand rather than the eye: a
|
||||
* swipe fires at the moment the finger crosses a threshold it cannot see, and
|
||||
* without this the only feedback arrives after the row has already gone.
|
||||
*
|
||||
* iOS supports none of this and never has, so this is silently nothing there
|
||||
* rather than something to apologize for. Wrapped because a vibration inside
|
||||
* a cross-origin iframe throws rather than returning false.
|
||||
*/
|
||||
export function haptic(pattern: number | number[] = 8): void {
|
||||
try {
|
||||
navigator.vibrate?.(pattern);
|
||||
} catch {
|
||||
/* A device that will not buzz is not a failure worth reporting. */
|
||||
}
|
||||
}
|
||||
|
||||
export interface RowGesture {
|
||||
/** Whether to listen at all — false on a mouse, and while a menu is open. */
|
||||
enabled: boolean;
|
||||
/**
|
||||
* The finger held still long enough, on `target` — handed over rather than
|
||||
* left to the caller's own ref, because the element a press lands on is not
|
||||
* always one a ref can reach. A router's `Link` renders the anchor itself
|
||||
* and forwards nothing.
|
||||
*/
|
||||
onLongPress?: (target: Element) => void;
|
||||
/** Whether a swipe that way leads anywhere; a `false` here never starts one. */
|
||||
canSwipe?: (dir: -1 | 1) => boolean;
|
||||
/** The row should now sit `dx` from home. Fired continuously while dragging. */
|
||||
onSwipeMove?: (dx: number, dir: -1 | 1, armed: boolean) => void;
|
||||
/** Let go: `dir` is the direction to act on, or 0 to snap back untouched. */
|
||||
onSwipeEnd?: (dir: -1 | 1 | 0) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Long press and horizontal swipe over one pointer stream.
|
||||
*
|
||||
* One hook rather than two because they are the same gesture until they are
|
||||
* not: a press that moves is no longer a press, and a swipe that does not move
|
||||
* is a press. Splitting them meant both hooks watching the same events and
|
||||
* disagreeing at the boundary.
|
||||
*
|
||||
* The element needs `touch-action: pan-y`, which is what makes this possible
|
||||
* without breaking the list: the browser keeps handling vertical scrolling
|
||||
* itself, at its own frame rate, and hands us the horizontal movement it now
|
||||
* knows it is not going to use.
|
||||
*/
|
||||
export function useTouchRow({ enabled, onLongPress, canSwipe, onSwipeMove, onSwipeEnd }: RowGesture) {
|
||||
const start = useRef<{ x: number; y: number; width: number; id: number; target: Element } | null>(null);
|
||||
const axis = useRef<Axis>(null);
|
||||
const dir = useRef<-1 | 1>(1);
|
||||
const armed = useRef(false);
|
||||
const timer = useRef<number | null>(null);
|
||||
/*
|
||||
* A gesture that did anything must not also be a tap. The row's click
|
||||
* handler opens the conversation, and a swipe or a long press both end with
|
||||
* the finger lifting off the row -- which is a click as far as the browser is
|
||||
* concerned, arriving after every pointer event we could cancel from.
|
||||
*/
|
||||
const swallowClick = useRef(false);
|
||||
const fromTouch = useRef(false);
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timer.current !== null) window.clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
};
|
||||
useEffect(() => clearTimer, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
clearTimer();
|
||||
start.current = null;
|
||||
axis.current = null;
|
||||
armed.current = false;
|
||||
}, []);
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(e: ReactPointerEvent) => {
|
||||
fromTouch.current = e.pointerType === "touch";
|
||||
if (!enabled || e.pointerType !== "touch") return;
|
||||
// Read out now: `currentTarget` is only meaningful during dispatch, and
|
||||
// the long-press timer runs long after this handler has returned.
|
||||
const target = e.currentTarget;
|
||||
start.current = { x: e.clientX, y: e.clientY, width: target.getBoundingClientRect().width, id: e.pointerId, target };
|
||||
axis.current = null;
|
||||
armed.current = false;
|
||||
swallowClick.current = false;
|
||||
if (onLongPress) {
|
||||
timer.current = window.setTimeout(() => {
|
||||
timer.current = null;
|
||||
// Still here, still not moving: nothing has canceled us.
|
||||
if (!start.current || axis.current) return;
|
||||
swallowClick.current = true;
|
||||
onLongPress(start.current.target);
|
||||
}, LONG_PRESS_MS);
|
||||
}
|
||||
},
|
||||
[enabled, onLongPress],
|
||||
);
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(e: ReactPointerEvent) => {
|
||||
const s = start.current;
|
||||
if (!s || e.pointerId !== s.id) return;
|
||||
const dx = e.clientX - s.x;
|
||||
const dy = e.clientY - s.y;
|
||||
|
||||
if (axis.current === null) {
|
||||
if (Math.abs(dx) > PRESS_SLOP || Math.abs(dy) > PRESS_SLOP) clearTimer();
|
||||
const locked = lockAxis(dx, dy);
|
||||
if (!locked) return;
|
||||
/*
|
||||
* A vertical drag is the browser's, and it has already started
|
||||
* scrolling with it. Letting go of the whole gesture here -- rather
|
||||
* than remembering that we lost -- matters, because the finger will go
|
||||
* on to travel a long way sideways during a diagonal flick, and this
|
||||
* row would otherwise catch up with it mid-scroll.
|
||||
*/
|
||||
if (locked === "y" || !onSwipeMove) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
const d: -1 | 1 = dx < 0 ? -1 : 1;
|
||||
if (canSwipe && !canSwipe(d)) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
axis.current = "x";
|
||||
dir.current = d;
|
||||
swallowClick.current = true;
|
||||
try {
|
||||
// Throws if the pointer is already gone -- a flick fast enough to
|
||||
// have lifted between this event being queued and being handled.
|
||||
// The gesture works perfectly well without the capture.
|
||||
e.currentTarget.setPointerCapture(s.id);
|
||||
} catch {
|
||||
/* nothing left to capture */
|
||||
}
|
||||
}
|
||||
|
||||
const d: -1 | 1 = dx < 0 ? -1 : 1;
|
||||
/*
|
||||
* Crossing back the other way mid-gesture. The direction is re-read
|
||||
* rather than held from the lock, so a reader who overshoots, thinks
|
||||
* better of it and drags back past center gets the other action offered
|
||||
* instead of the row refusing to move.
|
||||
*/
|
||||
if (d !== dir.current) {
|
||||
if (canSwipe && !canSwipe(d)) {
|
||||
onSwipeMove?.(0, dir.current, false);
|
||||
return;
|
||||
}
|
||||
dir.current = d;
|
||||
}
|
||||
const offset = swipeOffset(dx, s.width);
|
||||
const nowArmed = Math.abs(dx) >= swipeThreshold(s.width);
|
||||
if (nowArmed !== armed.current) {
|
||||
armed.current = nowArmed;
|
||||
if (nowArmed) haptic();
|
||||
}
|
||||
onSwipeMove?.(offset, d, nowArmed);
|
||||
},
|
||||
[canSwipe, onSwipeMove, reset],
|
||||
);
|
||||
|
||||
const onPointerUp = useCallback(
|
||||
(e: ReactPointerEvent) => {
|
||||
const s = start.current;
|
||||
clearTimer();
|
||||
if (!s || e.pointerId !== s.id) return;
|
||||
if (axis.current === "x") onSwipeEnd?.(armed.current ? dir.current : 0);
|
||||
reset();
|
||||
},
|
||||
[onSwipeEnd, reset],
|
||||
);
|
||||
|
||||
const onPointerCancel = useCallback(
|
||||
(e: ReactPointerEvent) => {
|
||||
if (start.current && e.pointerId !== start.current.id) return;
|
||||
if (axis.current === "x") onSwipeEnd?.(0);
|
||||
reset();
|
||||
},
|
||||
[onSwipeEnd, reset],
|
||||
);
|
||||
|
||||
const onClickCapture = useCallback((e: ReactMouseEvent) => {
|
||||
if (!swallowClick.current) return;
|
||||
swallowClick.current = false;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
/*
|
||||
* Android fires `contextmenu` for a long press of its own, a little after
|
||||
* ours, and would open the desktop right-click menu on top of whatever the
|
||||
* long press just did. The desktop handler stays untouched for an actual
|
||||
* right-click, which is the only thing that reaches it now.
|
||||
*/
|
||||
const onContextMenuCapture = useCallback(
|
||||
(e: ReactMouseEvent) => {
|
||||
if (!enabled || !fromTouch.current) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
[enabled],
|
||||
);
|
||||
|
||||
return { onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onClickCapture, onContextMenuCapture };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the top of a scroller down to refresh it.
|
||||
*
|
||||
* Native listeners rather than React props because the move handler has to be
|
||||
* able to call `preventDefault`, and React attaches its own passively. Bound
|
||||
* to the scroll container itself so that everything inside it -- a virtualized
|
||||
* list included -- comes down with the pull without knowing about it.
|
||||
*/
|
||||
export function usePullToRefresh(
|
||||
el: HTMLElement | null,
|
||||
onRefresh: () => Promise<void> | void,
|
||||
{ enabled, onPull }: { enabled: boolean; onPull: (distance: number, armed: boolean, live: boolean) => void },
|
||||
) {
|
||||
const refresh = useRef(onRefresh);
|
||||
refresh.current = onRefresh;
|
||||
const pull = useRef(onPull);
|
||||
pull.current = onPull;
|
||||
|
||||
useEffect(() => {
|
||||
if (!el || !enabled) return;
|
||||
let startY: number | null = null;
|
||||
let distance = 0;
|
||||
let armed = false;
|
||||
let running = false;
|
||||
|
||||
const onStart = (e: TouchEvent) => {
|
||||
// Only from a list already at the top, and only one finger: a pinch that
|
||||
// happens to begin near the top is not a pull.
|
||||
if (running || e.touches.length !== 1 || el.scrollTop > 0) return;
|
||||
startY = e.touches[0]!.clientY;
|
||||
distance = 0;
|
||||
armed = false;
|
||||
};
|
||||
|
||||
const onMove = (e: TouchEvent) => {
|
||||
if (startY === null || e.touches.length !== 1) return;
|
||||
const dy = e.touches[0]!.clientY - startY;
|
||||
if (dy <= 0) {
|
||||
// Pulled back up, or the gesture was a scroll all along.
|
||||
if (distance > 0) pull.current((distance = 0), (armed = false), true);
|
||||
if (el.scrollTop > 0) startY = null;
|
||||
return;
|
||||
}
|
||||
distance = pullDistance(dy);
|
||||
const nowArmed = distance >= PULL_TRIGGER;
|
||||
if (nowArmed !== armed) {
|
||||
armed = nowArmed;
|
||||
if (nowArmed) haptic();
|
||||
}
|
||||
/*
|
||||
* Only once the list is visibly following the finger. Calling this on
|
||||
* the first pixel would cancel the tap that starts every scroll, and
|
||||
* `cancelable` is false once the browser has already committed the
|
||||
* gesture to scrolling -- calling it then is a console warning and
|
||||
* nothing else.
|
||||
*/
|
||||
if (distance > 2 && e.cancelable) e.preventDefault();
|
||||
pull.current(distance, armed, true);
|
||||
};
|
||||
|
||||
const onEnd = () => {
|
||||
if (startY === null) return;
|
||||
startY = null;
|
||||
if (!armed) {
|
||||
if (distance > 0) pull.current((distance = 0), false, false);
|
||||
return;
|
||||
}
|
||||
running = true;
|
||||
armed = false;
|
||||
pull.current(PULL_REST, true, false);
|
||||
void Promise.resolve(refresh.current()).finally(() => {
|
||||
running = false;
|
||||
distance = 0;
|
||||
pull.current(0, false, false);
|
||||
});
|
||||
};
|
||||
|
||||
el.addEventListener("touchstart", onStart, { passive: true });
|
||||
el.addEventListener("touchmove", onMove, { passive: false });
|
||||
el.addEventListener("touchend", onEnd);
|
||||
el.addEventListener("touchcancel", onEnd);
|
||||
return () => {
|
||||
el.removeEventListener("touchstart", onStart);
|
||||
el.removeEventListener("touchmove", onMove);
|
||||
el.removeEventListener("touchend", onEnd);
|
||||
el.removeEventListener("touchcancel", onEnd);
|
||||
};
|
||||
}, [el, enabled]);
|
||||
}
|
||||
|
||||
/** How far in from the left edge a drag must start to count as going back. */
|
||||
export const EDGE_ZONE = 28;
|
||||
|
||||
/**
|
||||
* Drag in from the left edge to go back, the way every phone does it.
|
||||
*
|
||||
* Only from the edge. A back gesture that started anywhere would fight the
|
||||
* horizontal scrolling that wide HTML mail needs, and mail is exactly the
|
||||
* content nobody controls the width of.
|
||||
*/
|
||||
export function useEdgeBack(el: HTMLElement | null, onBack: () => void, enabled: boolean) {
|
||||
const back = useRef(onBack);
|
||||
back.current = onBack;
|
||||
|
||||
useEffect(() => {
|
||||
if (!el || !enabled) return;
|
||||
let startX: number | null = null;
|
||||
let startY = 0;
|
||||
let live = false;
|
||||
|
||||
const settle = (offset: number, animate: boolean) => {
|
||||
el.style.transition = animate ? "transform .18s var(--ease, ease)" : "";
|
||||
el.style.transform = offset ? `translateX(${offset}px)` : "";
|
||||
};
|
||||
|
||||
const onStart = (e: TouchEvent) => {
|
||||
if (e.touches.length !== 1) return;
|
||||
const t = e.touches[0]!;
|
||||
if (t.clientX - el.getBoundingClientRect().left > EDGE_ZONE) return;
|
||||
startX = t.clientX;
|
||||
startY = t.clientY;
|
||||
live = false;
|
||||
};
|
||||
|
||||
const onMove = (e: TouchEvent) => {
|
||||
if (startX === null || e.touches.length !== 1) return;
|
||||
const t = e.touches[0]!;
|
||||
const dx = t.clientX - startX;
|
||||
const dy = t.clientY - startY;
|
||||
if (!live) {
|
||||
if (lockAxis(dx, dy) === "y") {
|
||||
startX = null;
|
||||
return;
|
||||
}
|
||||
if (lockAxis(dx, dy) !== "x" || dx < 0) return;
|
||||
live = true;
|
||||
}
|
||||
if (e.cancelable) e.preventDefault();
|
||||
settle(Math.max(0, dx * 0.9), false);
|
||||
};
|
||||
|
||||
const onEnd = () => {
|
||||
if (startX === null) return;
|
||||
const offset = parseFloat(el.style.transform.replace(/[^\d.-]/g, "")) || 0;
|
||||
startX = null;
|
||||
if (!live) return;
|
||||
live = false;
|
||||
// A third of the way across is enough: a back gesture is a flick, and
|
||||
// asking for half the screen makes it feel like the app is resisting.
|
||||
if (offset > el.clientWidth / 3) {
|
||||
haptic();
|
||||
settle(0, false);
|
||||
back.current();
|
||||
} else {
|
||||
settle(0, true);
|
||||
window.setTimeout(() => (el.style.transition = ""), 200);
|
||||
}
|
||||
};
|
||||
|
||||
el.addEventListener("touchstart", onStart, { passive: true });
|
||||
el.addEventListener("touchmove", onMove, { passive: false });
|
||||
el.addEventListener("touchend", onEnd);
|
||||
el.addEventListener("touchcancel", onEnd);
|
||||
return () => {
|
||||
el.removeEventListener("touchstart", onStart);
|
||||
el.removeEventListener("touchmove", onMove);
|
||||
el.removeEventListener("touchend", onEnd);
|
||||
el.removeEventListener("touchcancel", onEnd);
|
||||
el.style.transform = "";
|
||||
el.style.transition = "";
|
||||
};
|
||||
}, [el, enabled]);
|
||||
}
|
||||
|
||||
/**
|
||||
* How far a horizontal drag must travel before it moves the calendar to
|
||||
* another day or month.
|
||||
*
|
||||
* Further than a row swipe, and not because the consequence is bigger --
|
||||
* stepping a calendar is undone by stepping back, while a swiped row has
|
||||
* already been archived. It is because this gesture has no way to change its
|
||||
* mind. A row slides open as it goes, so the strip underneath names what is
|
||||
* about to happen and letting go early calls it off, and a toast offers Undo
|
||||
* afterwards. Stepping the calendar shows nothing on the way and offers
|
||||
* nothing after, so the distance is the only chance to not mean it.
|
||||
*/
|
||||
export function navSwipeThreshold(width: number): number {
|
||||
return Math.max(80, Math.min(180, width * 0.3));
|
||||
}
|
||||
|
||||
/**
|
||||
* Which way a finished drag sends the view: -1 back, +1 forward, 0 nowhere.
|
||||
*
|
||||
* Dragging left pulls the next period in from the right, which is how paper,
|
||||
* phones and every other calendar behave. (It would need mirroring for a
|
||||
* right-to-left interface; there is not one yet, and the day there is, this is
|
||||
* one of the places that has to know.)
|
||||
*/
|
||||
export function swipeNavDirection(dx: number, width: number): -1 | 0 | 1 {
|
||||
const threshold = navSwipeThreshold(width);
|
||||
if (dx <= -threshold) return 1;
|
||||
if (dx >= threshold) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swipe sideways across a calendar to step it a period at a time.
|
||||
*
|
||||
* Three things it deliberately does not do:
|
||||
*
|
||||
* - **No visual drag.** The row swipe slides the row open because the strip
|
||||
* underneath has to name which of six actions is about to happen. Stepping
|
||||
* a calendar has two outcomes and the direction of the finger already says
|
||||
* which, so there is nothing to reveal -- and translating the grid would
|
||||
* break the sticky day header, since a transform makes a containing block.
|
||||
* The threshold is reported by the vibration motor instead, which is what
|
||||
* the haptics are for: a swipe fires as the finger passes a line it cannot
|
||||
* see.
|
||||
* - **It does not start on an event.** A drag beginning on an event chip is
|
||||
* left alone, so that moving an event by dragging it stays available to be
|
||||
* built without having to be untangled from this first. Which gesture is
|
||||
* meant is decidable at the moment the finger lands, and that is the only
|
||||
* moment it can be decided cleanly.
|
||||
* - **It does not start on the toolbar.** Buttons live there.
|
||||
*
|
||||
* The axis lock is the shared one, so it keeps the same bias toward the
|
||||
* vertical: the day grid scrolls through the hours, and a scroll misread as a
|
||||
* swipe throws the reader into another day.
|
||||
*/
|
||||
export function useSwipeNav(
|
||||
el: HTMLElement | null,
|
||||
opts: { onStep: (n: -1 | 1) => void; enabled: boolean; ignore?: string },
|
||||
) {
|
||||
const step = useRef(opts.onStep);
|
||||
step.current = opts.onStep;
|
||||
const { enabled, ignore } = opts;
|
||||
|
||||
useEffect(() => {
|
||||
if (!el || !enabled) return;
|
||||
let startX: number | null = null;
|
||||
let startY = 0;
|
||||
let axis: Axis = null;
|
||||
let fired = false;
|
||||
|
||||
const onStart = (e: TouchEvent) => {
|
||||
if (e.touches.length !== 1) return;
|
||||
const t = e.touches[0]!;
|
||||
if (ignore && (t.target as Element | null)?.closest?.(ignore)) return;
|
||||
startX = t.clientX;
|
||||
startY = t.clientY;
|
||||
axis = null;
|
||||
fired = false;
|
||||
};
|
||||
|
||||
const onMove = (e: TouchEvent) => {
|
||||
if (startX === null || e.touches.length !== 1) return;
|
||||
const t = e.touches[0]!;
|
||||
const dx = t.clientX - startX;
|
||||
const dy = t.clientY - startY;
|
||||
if (!axis) {
|
||||
axis = lockAxis(dx, dy);
|
||||
// Committed to scrolling: stay out of the way for the rest of the drag.
|
||||
if (axis === "y") startX = null;
|
||||
return;
|
||||
}
|
||||
if (axis !== "x") return;
|
||||
// Once sideways, the browser must not also scroll.
|
||||
if (e.cancelable) e.preventDefault();
|
||||
if (!fired && swipeNavDirection(dx, el.clientWidth || window.innerWidth) !== 0) {
|
||||
fired = true;
|
||||
haptic();
|
||||
}
|
||||
};
|
||||
|
||||
const onEnd = (e: TouchEvent) => {
|
||||
if (startX === null) return;
|
||||
const t = e.changedTouches[0];
|
||||
const dx = t ? t.clientX - startX : 0;
|
||||
const wasX = axis === "x";
|
||||
startX = null;
|
||||
axis = null;
|
||||
fired = false;
|
||||
if (!wasX) return;
|
||||
const dir = swipeNavDirection(dx, el.clientWidth || window.innerWidth);
|
||||
if (dir !== 0) step.current(dir);
|
||||
};
|
||||
|
||||
el.addEventListener("touchstart", onStart, { passive: true });
|
||||
el.addEventListener("touchmove", onMove, { passive: false });
|
||||
el.addEventListener("touchend", onEnd);
|
||||
el.addEventListener("touchcancel", onEnd);
|
||||
return () => {
|
||||
el.removeEventListener("touchstart", onStart);
|
||||
el.removeEventListener("touchmove", onMove);
|
||||
el.removeEventListener("touchend", onEnd);
|
||||
el.removeEventListener("touchcancel", onEnd);
|
||||
};
|
||||
}, [el, enabled, ignore]);
|
||||
}
|
||||
Reference in New Issue
Block a user