Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 94 additions & 94 deletions packages/app-expo/assets/reader/reader.html

Large diffs are not rendered by default.

138 changes: 138 additions & 0 deletions packages/app-expo/src/lib/reader/paginator-touch-navigation.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import {
PaginatorTouchTracker,
SelectionPositionGuard,
hasActiveTextSelection,
} from "../../../../foliate-js/paginator-touch.js";

const touch = { screenX: 430, screenY: 520 };

describe("paginator touch navigation ownership", () => {
it("restores the gesture start after movement becomes text selection", () => {
const tracker = new PaginatorTouchTracker();
tracker.start(touch, 100, 624);
tracker.markScrolled();

expect(
hasActiveTextSelection([
{
doc: {
getSelection: () => ({ isCollapsed: false, toString: () => "selected text" }),
},
},
]),
).toBe(true);
expect(tracker.cancel()).toBe(624);
expect(tracker.state).toBeUndefined();
expect(tracker.scrolled).toBe(false);
});

it("does not request restoration before the paginator moves", () => {
const tracker = new PaginatorTouchTracker();
tracker.start(touch, 100, 624);

expect(tracker.cancel()).toBeNull();
});

it("retains the aligned touch start when selection takes ownership before swipe movement", () => {
const tracker = new PaginatorTouchTracker();
tracker.start(touch, 100, 1080);

expect(tracker.takeSelectionStart(1410)).toBe(1080);
expect(tracker.state).toBeUndefined();
expect(tracker.scrolled).toBe(false);
});

it("falls back to the current position when selection starts without tracked touch state", () => {
expect(new PaginatorTouchTracker().takeSelectionStart(1080)).toBe(1080);
});

it("returns the completed gesture state, including scroll inertia samples", () => {
const tracker = new PaginatorTouchTracker();
const state = tracker.start(touch, 100, 624);
state.dx = 180;
state.dt = 300;
state.scrollSamples.push({ velocity: 0.25, time: 250 });
tracker.markScrolled();

expect(tracker.finish()).toMatchObject({
dx: 180,
dt: 300,
startPosition: 624,
scrollSamples: [{ velocity: 0.25, time: 250 }],
});
expect(tracker.state).toBeUndefined();
expect(tracker.scrolled).toBe(false);
});

it("ignores collapsed and whitespace-only selections", () => {
expect(
hasActiveTextSelection([
{ doc: { getSelection: () => ({ isCollapsed: true, toString: () => "text" }) } },
{ doc: { getSelection: () => ({ isCollapsed: false, toString: () => " " }) } },
]),
).toBe(false);
});
});

describe("native selection position ownership", () => {
it("restores unowned native selection drift but ignores sub-pixel noise", () => {
const guard = new SelectionPositionGuard();
guard.begin(1080);
guard.begin(1410);

expect(guard.correctionFor(1410)).toBe(1080);
expect(guard.correctionFor(1080.4)).toBeNull();
});

it("allows explicit edge navigation and rebases protection afterward", () => {
const guard = new SelectionPositionGuard();
guard.begin(1080);
guard.beginNavigation();

expect(guard.correctionFor(2160)).toBeNull();
guard.finishNavigation(2160);
expect(guard.correctionFor(2450)).toBe(2160);
});

it("releases position ownership when selection ends", () => {
const guard = new SelectionPositionGuard();
guard.begin(1080);
guard.end();

expect(guard.active).toBe(false);
expect(guard.correctionFor(1410)).toBeNull();
});
});

const paginatorSource = readFileSync(
new URL("../../../../foliate-js/paginator.js", import.meta.url),
"utf8",
);

describe("paginator selection cancellation wiring", () => {
it("guards native selection drift while preserving explicit edge navigation and inertia", () => {
expect(paginatorSource).toContain(
"doc.addEventListener('selectstart', () => this.#beginTextSelection())",
);
expect(
paginatorSource.match(
/if \(this\.#hasActiveTextSelection\(\)\) \{\s*this\.#beginTextSelection\(\)\s*return\s*\}/g,
),
).toHaveLength(3);
expect(paginatorSource).toMatch(
/this\.#container\.addEventListener\('scroll', \(\) => \{\s*const restorePosition = this\.#selectionPosition\.correctionFor\(this\.containerPosition\)/,
);
expect(paginatorSource).toMatch(
/#beginTextSelection\(\) \{\s*if \(this\.scrolled\) \{\s*this\.#cancelTouchNavigation\(\)\s*return/,
);
expect(paginatorSource).toContain("this.#selectionPosition.end()");
expect(paginatorSource).toMatch(
/this\.#selectionPosition\.beginNavigation\(\)\s*try \{\s*if \(direction === 'backward'\) await this\.prev\(\)\s*else await this\.next\(\)[\s\S]*?finally \{\s*this\.#selectionPosition\.finishNavigation\(this\.containerPosition\)/,
);
expect(paginatorSource).toContain(
"if (!this.#navigationLocked) this.#startScrollInertia(state)",
);
});
});
93 changes: 93 additions & 0 deletions packages/foliate-js/paginator-touch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
export const hasActiveTextSelection = (contents) => {
for (const { doc } of contents ?? []) {
const selection = doc?.getSelection?.();
if (selection && !selection.isCollapsed && selection.toString().trim()) return true;
}
return false;
};

export class PaginatorTouchTracker {
state;
scrolled = false;

start(touch, timeStamp, startPosition) {
this.state = {
x: touch?.screenX,
y: touch?.screenY,
t: timeStamp,
vx: 0,
xy: 0,
dx: 0,
dy: 0,
dt: 0,
scrollVelocity: 0,
scrollSamples: [],
startX: touch?.screenX,
startY: touch?.screenY,
startPosition,
didPreventDefault: false,
};
this.scrolled = false;
return this.state;
}

markScrolled() {
this.scrolled = true;
}

cancel() {
const restorePosition =
this.scrolled && Number.isFinite(this.state?.startPosition) ? this.state.startPosition : null;
this.state = undefined;
this.scrolled = false;
return restorePosition;
}

takeSelectionStart(currentPosition) {
const position = Number.isFinite(this.state?.startPosition)
? this.state.startPosition
: currentPosition;
this.state = undefined;
this.scrolled = false;
return position;
}

finish() {
const state = this.scrolled ? (this.state ?? null) : null;
this.state = undefined;
this.scrolled = false;
return state;
}
}

export class SelectionPositionGuard {
position;
navigating = false;

get active() {
return Number.isFinite(this.position);
}

begin(position) {
if (!this.active && Number.isFinite(position)) this.position = position;
}

correctionFor(currentPosition) {
if (!this.active || this.navigating || !Number.isFinite(currentPosition)) return null;
return Math.abs(currentPosition - this.position) > 0.5 ? this.position : null;
}

beginNavigation() {
if (this.active) this.navigating = true;
}

finishNavigation(position) {
if (this.active && Number.isFinite(position)) this.position = position;
this.navigating = false;
}

end() {
this.position = undefined;
this.navigating = false;
}
}
Loading