Skip to content

Commit 0a2cf37

Browse files
committed
Fix
1 parent 390cb1e commit 0a2cf37

12 files changed

Lines changed: 519 additions & 13 deletions

File tree

e2e/specs/taurus-viewer.spec.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,14 @@ describe("TaurusViewer E2E Test Suite", () => {
169169
});
170170
}
171171

172+
async function pdfScrollTop(): Promise<number> {
173+
return browser.execute(() => {
174+
const first = document.querySelector('[data-page-index="0"]');
175+
const container = first?.closest(".overflow-auto") as HTMLElement | null;
176+
return container?.scrollTop ?? -1;
177+
});
178+
}
179+
172180
async function getContainerPosition(): Promise<number> {
173181
return browser.execute(() => {
174182
const v = document.querySelector("foliate-view") as any;
@@ -652,4 +660,85 @@ describe("TaurusViewer E2E Test Suite", () => {
652660
},
653661
);
654662
});
663+
664+
it("should open the command palette via the palette button", async () => {
665+
const paletteBtn = await $('button[aria-label="Open command palette"]');
666+
await paletteBtn.click();
667+
const paletteInput = await $('input[placeholder="Search open tabs or library documents..."]');
668+
await expect(paletteInput).toBeDisplayed();
669+
});
670+
671+
it("should open help via the help button and scroll it instead of the document", async () => {
672+
await openPdf();
673+
// Scroll the document a little so its position can be compared later.
674+
await browser.keys(["j"]);
675+
await browser.waitUntil(
676+
async () => (await pdfScrollTop()) > 0,
677+
{ timeout: 5000, timeoutMsg: "j did not scroll the PDF document" },
678+
);
679+
const documentScrollBefore = await pdfScrollTop();
680+
681+
const helpBtn = await $('button[aria-label="Help"]');
682+
await helpBtn.click();
683+
await $("*=Keyboard shortcuts").waitForDisplayed({ timeout: 5000 });
684+
685+
// G scrolls the help content to its bottom.
686+
await browser.keys(["G"]);
687+
await browser.waitUntil(
688+
async () =>
689+
(await browser.execute(() => {
690+
const el = document.querySelector("[data-help-scroll]");
691+
return el ? (el as HTMLElement).scrollTop : -1;
692+
})) > 0,
693+
{ timeout: 5000, timeoutMsg: "G did not scroll the help content" },
694+
);
695+
696+
// The document must not have moved while the help modal was open.
697+
expect(await pdfScrollTop()).toBe(documentScrollBefore);
698+
699+
// Close help and confirm the document scrolls again.
700+
await browser.keys(["Escape"]);
701+
await browser.waitUntil(
702+
async () => !(await $("*=Keyboard shortcuts").isExisting()),
703+
{ timeout: 5000, timeoutMsg: "help modal did not close on Escape" },
704+
);
705+
await browser.keys(["j"]);
706+
await browser.waitUntil(
707+
async () => (await pdfScrollTop()) > documentScrollBefore,
708+
{ timeout: 5000, timeoutMsg: "document did not resume scrolling after help closed" },
709+
);
710+
});
711+
712+
it("should jump to the first/last page with g/G in PDF PAGES mode", async () => {
713+
await openPdf();
714+
await browser.keys(["s"]);
715+
await browser.waitUntil(
716+
async () => (await statusBarText()).includes("pages"),
717+
{ timeout: 5000, timeoutMsg: "s did not switch to PAGES mode" },
718+
);
719+
await browser.keys(["G"]);
720+
await browser.waitUntil(
721+
async () => (await statusBarText()).includes("Page 12 / 12"),
722+
{ timeout: 10000, timeoutMsg: "G did not jump to the last page in PAGES mode" },
723+
);
724+
await browser.keys(["g"]);
725+
await browser.waitUntil(
726+
async () => (await statusBarText()).includes("Page 1 / 12"),
727+
{ timeout: 10000, timeoutMsg: "g did not jump to the first page in PAGES mode" },
728+
);
729+
});
730+
731+
it("should go to the top/bottom of a PDF with g/G in SCROLL mode", async () => {
732+
await openPdf();
733+
await browser.keys(["G"]);
734+
await browser.waitUntil(
735+
async () => (await statusBarText()).includes("Page 12 / 12"),
736+
{ timeout: 10000, timeoutMsg: "G did not scroll to the document bottom" },
737+
);
738+
await browser.keys(["g"]);
739+
await browser.waitUntil(
740+
async () => (await statusBarText()).includes("Page 1 / 12"),
741+
{ timeout: 10000, timeoutMsg: "g did not scroll to the document top" },
742+
);
743+
});
655744
});

src/components/HeaderBar.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { useState, useEffect } from "react";
2-
import { Columns2, LayoutList, Minus, Plus, ScrollText, Settings, Square } from "lucide-react";
2+
import { CircleHelp, Columns2, Command, LayoutList, Minus, Plus, ScrollText, Settings, Square } from "lucide-react";
33
import { useTabStore } from "@/features/tabs/TabStore";
44
import { useSettingsModalStore } from "./settingsModalStore";
5+
import { useCommandPaletteStore } from "@/features/command-mode/CommandPalette";
6+
import { useHelpModalStore } from "./HelpModal";
57
import { ThemeToggle } from "./theme-toggle";
68
import type { ColumnCount, ViewMode } from "../shared/types";
79
import {
@@ -93,6 +95,17 @@ export function HeaderBar() {
9395
{title}
9496
</span>
9597
<div className="flex shrink-0 items-center gap-2">
98+
{/* Command Palette (leftmost) */}
99+
<button
100+
type="button"
101+
onClick={() => useCommandPaletteStore.getState().open()}
102+
aria-label="Open command palette"
103+
title="Command palette (Ctrl+K)"
104+
className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-background text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
105+
>
106+
<Command size={15} />
107+
</button>
108+
96109
{/* View Mode Dropdown */}
97110
<DropdownMenu>
98111
<DropdownMenuTrigger asChild>
@@ -202,6 +215,17 @@ export function HeaderBar() {
202215

203216
{/* Theme Toggle (Right end) */}
204217
<ThemeToggle />
218+
219+
{/* Help (rightmost) */}
220+
<button
221+
type="button"
222+
onClick={() => useHelpModalStore.getState().open()}
223+
aria-label="Help"
224+
title="Help (?)"
225+
className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-background text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
226+
>
227+
<CircleHelp size={15} />
228+
</button>
205229
</div>
206230
</div>
207231
);

src/components/HelpModal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export function HelpModal() {
2020
<Dialog open={isOpen} onOpenChange={(open) => !open && close()}>
2121
<DialogContent className="max-w-lg">
2222
<DialogHeader><DialogTitle>Keyboard shortcuts</DialogTitle></DialogHeader>
23-
<div className="max-h-96 overflow-y-auto text-sm">
23+
<div data-help-scroll className="max-h-96 overflow-y-auto text-sm">
2424
{KEYBINDINGS.map((binding) => (
2525
<div key={`${binding.mode}-${binding.key}`} className="grid grid-cols-[7rem_1fr] gap-3 border-b py-2 last:border-0">
2626
<code>{binding.key}</code>

src/components/theme-toggle.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export function ThemeToggle() {
1818
type="button"
1919
aria-label="Toggle theme"
2020
title="Toggle theme"
21-
className="relative flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
21+
className="relative flex h-7 w-7 items-center justify-center rounded-md border border-border bg-background text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
2222
>
2323
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
2424
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />

src/features/epub-viewer/EpubViewerHandle.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
PageTarget,
1313
PageTurn,
1414
ScrollDelta,
15+
ScrollEdge,
1516
SearchHit,
1617
TabViewState,
1718
Unsubscribe,
@@ -32,6 +33,7 @@ import {
3233
} from "../../shared/overscroll";
3334
import type {
3435
DocumentViewerHandle,
36+
PanDirection,
3537
ViewerCapabilities,
3638
} from "../../shared/viewer-handle";
3739

@@ -69,6 +71,10 @@ type FoliateView = View & {
6971

7072
const FORWARDED_KEY_ATTRIBUTE = "data-taurus-key-forwarding";
7173
const FORWARDED_WHEEL_ATTRIBUTE = "data-taurus-wheel-forwarding";
74+
/** Initial vertical step size (px) for the j/k hold-to-pan gesture. */
75+
const EPUB_PAN_STEP = 240;
76+
/** Continuous pan speed in px/s while a pan key is held. */
77+
const EPUB_PAN_VELOCITY = 1200;
7278
const APP_KEYS = new Set([
7379
"ArrowDown",
7480
"ArrowLeft",
@@ -119,6 +125,9 @@ export class EpubViewerHandle implements DocumentViewerHandle {
119125
private viewModeListeners: Set<(mode: ViewMode) => void> = new Set();
120126
private columnListeners: Set<(cols: ColumnCount) => void> = new Set();
121127
private readonly overscroll = new OverscrollController();
128+
/** Active smooth pan loop (while a pan key is held). */
129+
private panLoop: { raf: number; last: number; direction: PanDirection } | null =
130+
null;
122131

123132
constructor(private filePath: string) {
124133
this.view = document.createElement("foliate-view") as FoliateView;
@@ -166,7 +175,42 @@ export class EpubViewerHandle implements DocumentViewerHandle {
166175
}
167176
}
168177

169-
navigate(target: PageTarget | ScrollDelta | PageTurn): void {
178+
startPan(direction: PanDirection): boolean {
179+
const renderer = this.view.renderer;
180+
if (!renderer || renderer.scrolled !== true) return false;
181+
if (direction === "left" || direction === "right") return false;
182+
if (renderer.containerPosition === undefined) return false;
183+
this.stopPan();
184+
const sign = direction === "up" ? -1 : 1;
185+
renderer.containerPosition += sign * EPUB_PAN_STEP;
186+
const last = performance.now();
187+
this.panLoop = {
188+
raf: requestAnimationFrame((now) => this.panFrame(direction, last, now)),
189+
last,
190+
direction,
191+
};
192+
return true;
193+
}
194+
195+
private panFrame(direction: PanDirection, last: number, now: number): void {
196+
const loop = this.panLoop;
197+
const renderer = this.view.renderer;
198+
if (!loop || !renderer || renderer.containerPosition === undefined) return;
199+
const dt = Math.min(50, now - last);
200+
const sign = direction === "up" ? -1 : 1;
201+
renderer.containerPosition += (sign * EPUB_PAN_VELOCITY * dt) / 1000;
202+
loop.last = now;
203+
loop.raf = requestAnimationFrame((t) => this.panFrame(direction, now, t));
204+
}
205+
206+
stopPan(): void {
207+
if (this.panLoop) {
208+
cancelAnimationFrame(this.panLoop.raf);
209+
this.panLoop = null;
210+
}
211+
}
212+
213+
navigate(target: PageTarget | ScrollDelta | PageTurn | ScrollEdge): void {
170214
debug(`[EpubViewerHandle] navigate: ${JSON.stringify(target)}`);
171215
console.log(`[EpubViewerHandle] NAVIGATE CALLED: ${JSON.stringify(target)}`);
172216
const renderer = this.view.renderer;
@@ -175,6 +219,18 @@ export class EpubViewerHandle implements DocumentViewerHandle {
175219
case "page":
176220
this.view.goTo({ fraction: target.index }).catch((err) => warn(`[EpubViewerHandle] goTo failed: ${err}`));
177221
break;
222+
case "edge":
223+
if (scrolled) {
224+
renderer!.containerPosition =
225+
target.edge === "start"
226+
? 0
227+
: Math.max(0, (renderer!.viewSize ?? 0) - (renderer!.size ?? 0));
228+
} else {
229+
this.view
230+
.goTo({ fraction: target.edge === "start" ? 0 : 1 })
231+
.catch((err) => warn(`[EpubViewerHandle] goTo failed: ${err}`));
232+
}
233+
break;
178234
case "scroll":
179235
if (scrolled && renderer.containerPosition !== undefined) {
180236
// A section is one long page: scroll it directly. Native clamping
@@ -532,6 +588,7 @@ export class EpubViewerHandle implements DocumentViewerHandle {
532588
}
533589

534590
dispose(): void {
591+
this.stopPan();
535592
try {
536593
if (this.view && typeof this.view.close === "function") {
537594
this.view.close();

src/features/library/LibraryView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ export function LibraryView() {
255255
</div>
256256
</div>
257257

258-
<div ref={contentRef} className="flex-1 overflow-y-auto p-4">
258+
<div ref={contentRef} data-library-scroll className="flex-1 overflow-y-auto p-4">
259259
{entries.length === 0 ? (
260260
<div className="flex h-64 flex-col items-center justify-center text-muted-foreground text-sm">
261261
<p className="mb-2">No documents in library.</p>

src/features/pdf-viewer/PdfViewerHandle.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ describe("PdfViewerHandle", () => {
179179
it("reports the true page index when windowed pages do not start at zero", async () => {
180180
const handle = await makeHandle(20);
181181
const container = document.createElement("div");
182+
Object.defineProperty(container, "clientHeight", { configurable: true, value: 1000 });
182183
handle.attachScrollContainer(container);
183184

184185
const positions: DocumentPosition[] = [];
@@ -200,4 +201,85 @@ describe("PdfViewerHandle", () => {
200201
expect(handle.getCurrentPosition()).toMatchObject({ pageIndex: 10 });
201202
expect(positions[positions.length - 1]).toMatchObject({ pageIndex: 10 });
202203
});
204+
205+
it("scrolls to the top/bottom edges in SCROLL mode", async () => {
206+
const handle = await makeHandle();
207+
const el = document.createElement("div");
208+
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 4000 });
209+
const scrollTopSetter = vi.fn();
210+
Object.defineProperty(el, "scrollTop", {
211+
configurable: true,
212+
set: scrollTopSetter,
213+
get: () => 0,
214+
});
215+
handle.attachScrollContainer(el);
216+
217+
handle.navigate({ kind: "edge", edge: "end" });
218+
expect(scrollTopSetter).toHaveBeenLastCalledWith(4000);
219+
handle.navigate({ kind: "edge", edge: "start" });
220+
expect(scrollTopSetter).toHaveBeenLastCalledWith(0);
221+
});
222+
223+
it("jumps to the first/last page in PAGES mode via edge navigation", async () => {
224+
const handle = await makeHandle();
225+
handle.setViewMode("pages");
226+
handle.navigate({ kind: "edge", edge: "end" });
227+
expect(handle.getCurrentPosition()).toMatchObject({ pageIndex: 4 });
228+
handle.navigate({ kind: "edge", edge: "start" });
229+
expect(handle.getCurrentPosition()).toMatchObject({ pageIndex: 0 });
230+
});
231+
232+
it("does not start a pan in PAGES mode", async () => {
233+
const handle = await makeHandle();
234+
handle.setViewMode("pages");
235+
const el = document.createElement("div");
236+
handle.attachScrollContainer(el);
237+
expect(handle.startPan?.("down")).toBe(false);
238+
expect(handle.startPan?.("right")).toBe(false);
239+
});
240+
241+
it("starts a horizontal pan only when the container overflows", async () => {
242+
const handle = await makeHandle();
243+
const overflow = document.createElement("div");
244+
Object.defineProperty(overflow, "scrollWidth", { configurable: true, value: 2000 });
245+
Object.defineProperty(overflow, "clientWidth", { configurable: true, value: 800 });
246+
const scrollBy = vi.fn();
247+
(overflow as unknown as { scrollBy: unknown }).scrollBy = scrollBy;
248+
handle.attachScrollContainer(overflow);
249+
expect(handle.startPan?.("right")).toBe(true);
250+
251+
const noOverflow = document.createElement("div");
252+
const scrollBy2 = vi.fn();
253+
(noOverflow as unknown as { scrollBy: unknown }).scrollBy = scrollBy2;
254+
handle.attachScrollContainer(noOverflow);
255+
expect(handle.startPan?.("right")).toBe(false);
256+
});
257+
258+
it("starts and stops a smooth vertical pan in SCROLL mode", async () => {
259+
vi.useFakeTimers();
260+
try {
261+
const handle = await makeHandle();
262+
const el = document.createElement("div");
263+
const scrollBy = vi.fn();
264+
(el as unknown as { scrollBy: unknown }).scrollBy = scrollBy;
265+
handle.attachScrollContainer(el);
266+
267+
expect(handle.startPan?.("down")).toBe(true);
268+
const callsAfterStart = scrollBy.mock.calls.length;
269+
expect(callsAfterStart).toBeGreaterThanOrEqual(1);
270+
271+
vi.advanceTimersByTime(200);
272+
const callsAfterAdvance = scrollBy.mock.calls.length;
273+
expect(callsAfterAdvance).toBeGreaterThan(callsAfterStart);
274+
const topDeltas = scrollBy.mock.calls.map((c) => c[0].top);
275+
expect(topDeltas.every((d) => typeof d === "number" && d >= 0)).toBe(true);
276+
277+
handle.stopPan?.();
278+
const callsAfterStop = scrollBy.mock.calls.length;
279+
vi.advanceTimersByTime(200);
280+
expect(scrollBy.mock.calls.length).toBe(callsAfterStop);
281+
} finally {
282+
vi.useRealTimers();
283+
}
284+
});
203285
});

0 commit comments

Comments
 (0)