diff --git a/apps/web/src/reader/ReaderShell.tsx b/apps/web/src/reader/ReaderShell.tsx index e132dbd..a764188 100644 --- a/apps/web/src/reader/ReaderShell.tsx +++ b/apps/web/src/reader/ReaderShell.tsx @@ -1,7 +1,8 @@ import { ArrowLeft, ArrowRight } from "lucide-react"; -import { useEffect, type ReactNode } from "react"; +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { ErrorRibbon } from "../components/ui"; import { navigate } from "../router"; +import { enterElementFullscreen, exitElementFullscreen, isElementFullscreen, readerArrowAction, shouldHandleReaderArrowKey, supportsElementFullscreen } from "./readerFullscreen"; import { readerZoomLabel, READER_ZOOM_DEFAULT, READER_ZOOM_MAX, READER_ZOOM_MIN } from "./readerLayout"; export type ReaderControls = { @@ -29,31 +30,72 @@ type ReaderShellProps = { children: ReactNode; }; -function isFormField(target: EventTarget | null) { - const element = target as HTMLElement | null; - const tagName = element?.tagName.toLowerCase(); - return Boolean(element?.isContentEditable || tagName === "input" || tagName === "textarea" || tagName === "select"); -} - export function ReaderShell({ title, backHref, error, onRetry, controls, zoomControls, children }: ReaderShellProps) { + const readerRef = useRef(null); + const [nativeFullscreen, setNativeFullscreen] = useState(false); + const [fallbackFullscreen, setFallbackFullscreen] = useState(false); + const fullscreenActive = nativeFullscreen || fallbackFullscreen; + + const toggleFullscreen = useCallback(async () => { + const readerElement = readerRef.current; + if (!readerElement) return; + + if (isElementFullscreen(readerElement, document)) { + await exitElementFullscreen(document); + return; + } + + if (fallbackFullscreen) { + setFallbackFullscreen(false); + return; + } + + if (supportsElementFullscreen(readerElement)) { + try { + await enterElementFullscreen(readerElement); + return; + } catch { + setFallbackFullscreen(true); + return; + } + } + + setFallbackFullscreen(true); + }, [fallbackFullscreen]); + + useEffect(() => { + const syncFullscreen = () => { + const readerElement = readerRef.current; + setNativeFullscreen(isElementFullscreen(readerElement, document)); + }; + syncFullscreen(); + document.addEventListener("fullscreenchange", syncFullscreen); + return () => document.removeEventListener("fullscreenchange", syncFullscreen); + }, []); + useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { - if (isFormField(event.target)) return; - if (event.key === "ArrowLeft" && controls.canPrevious) { + if (event.key === "Escape" && fallbackFullscreen) { + setFallbackFullscreen(false); + return; + } + if (!shouldHandleReaderArrowKey(event, readerRef.current)) return; + const action = readerArrowAction(event); + if (action === "previous" && controls.canPrevious) { event.preventDefault(); controls.onPrevious(); } - if (event.key === "ArrowRight" && controls.canNext) { + if (action === "next" && controls.canNext) { event.preventDefault(); controls.onNext(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [controls]); + }, [controls, fallbackFullscreen]); return ( -
+
)} + {error && (
diff --git a/apps/web/src/reader/readerFullscreen.ts b/apps/web/src/reader/readerFullscreen.ts new file mode 100644 index 0000000..bb8de3a --- /dev/null +++ b/apps/web/src/reader/readerFullscreen.ts @@ -0,0 +1,74 @@ +const INTERACTIVE_SELECTOR = [ + "a[href]", + "button", + "input", + "textarea", + "select", + "summary", + "[contenteditable='true']", + "[role='button']", + "[role='checkbox']", + "[role='combobox']", + "[role='dialog']", + "[role='listbox']", + "[role='menu']", + "[role='menuitem']", + "[role='option']", + "[role='radio']", + "[role='slider']", + "[role='spinbutton']", + "[role='switch']", + "[role='textbox']", + "[aria-modal='true']", + "[popover]" +].join(","); + +type ReaderArrowEvent = { + key: string; + defaultPrevented?: boolean; + altKey?: boolean; + ctrlKey?: boolean; + metaKey?: boolean; +}; + +export function readerArrowAction(event: ReaderArrowEvent): "previous" | "next" | null { + if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return null; + if (event.key === "ArrowLeft") return "previous"; + if (event.key === "ArrowRight") return "next"; + return null; +} + +export function fullscreenElement(documentRef: Document = document) { + return documentRef.fullscreenElement; +} + +export function supportsElementFullscreen(element: HTMLElement | null) { + return Boolean(element?.requestFullscreen); +} + +export function isElementFullscreen(element: HTMLElement | null, documentRef: Document = document) { + return Boolean(element && fullscreenElement(documentRef) === element); +} + +export async function enterElementFullscreen(element: HTMLElement) { + await element.requestFullscreen(); +} + +export async function exitElementFullscreen(documentRef: Document = document) { + if (documentRef.fullscreenElement) await documentRef.exitFullscreen(); +} + +export function isReaderInteractiveTarget(target: EventTarget | null) { + if (typeof Element === "undefined") return false; + if (!(target instanceof Element)) return false; + return Boolean(target.closest(INTERACTIVE_SELECTOR)); +} + +export function shouldHandleReaderArrowKey(event: KeyboardEvent, readerElement: HTMLElement | null) { + if (!readerArrowAction(event)) return false; + if (isReaderInteractiveTarget(event.target)) return false; + if (typeof Node === "undefined" || !readerElement || !(event.target instanceof Node)) return true; + const documentRef = readerElement.ownerDocument; + if (event.target === documentRef.body || event.target === documentRef.documentElement) return true; + return readerElement.contains(event.target); +} diff --git a/apps/web/src/reader/readerRuntime.test.ts b/apps/web/src/reader/readerRuntime.test.ts index b2c442a..5f4f1a6 100644 --- a/apps/web/src/reader/readerRuntime.test.ts +++ b/apps/web/src/reader/readerRuntime.test.ts @@ -15,6 +15,7 @@ import { zoomReaderSize } from "./readerLayout"; import { classifyPdfCanvas, pdfCanvasHasVisibleContent, pdfCanvasVisible, pdfRenderScale } from "./pdfRender"; +import { isElementFullscreen, isReaderInteractiveTarget, readerArrowAction, shouldHandleReaderArrowKey, supportsElementFullscreen } from "./readerFullscreen"; import { configurePdfWorker, pdfWorkerSrc } from "./pdfWorker"; import { readerErrorMessage } from "./ReaderError"; @@ -163,4 +164,89 @@ describe("reader runtime helpers", () => { expect(nextReaderPage(8, 12)).toBe(9); expect(nextReaderPage(12, 12)).toBe(12); }); + + it("only maps unmodified horizontal reader arrow shortcuts", () => { + expect(readerArrowAction({ key: "ArrowLeft" })).toBe("previous"); + expect(readerArrowAction({ key: "ArrowRight" })).toBe("next"); + expect(readerArrowAction({ key: "ArrowUp" })).toBeNull(); + expect(readerArrowAction({ key: "ArrowDown" })).toBeNull(); + expect(readerArrowAction({ key: "ArrowRight", defaultPrevented: true })).toBeNull(); + expect(readerArrowAction({ key: "ArrowRight", ctrlKey: true })).toBeNull(); + }); + + it("detects fullscreen support and active fullscreen element", () => { + const fullscreenElement = { requestFullscreen: () => Promise.resolve() } as unknown as HTMLElement; + const regularElement = {} as HTMLElement; + const documentRef = { fullscreenElement } as unknown as Document; + + expect(supportsElementFullscreen(fullscreenElement)).toBe(true); + expect(supportsElementFullscreen(regularElement)).toBe(false); + expect(isElementFullscreen(fullscreenElement, documentRef)).toBe(true); + expect(isElementFullscreen(regularElement, documentRef)).toBe(false); + }); + + it("keeps reader arrow shortcuts away from interactive or external targets", () => { + const originalElement = globalThis.Element; + const originalNode = globalThis.Node; + let fakeDocument: { body: FakeNode; documentElement: FakeNode }; + + class FakeNode { + get ownerDocument() { + return fakeDocument; + } + + addEventListener() { + return undefined; + } + + dispatchEvent() { + return true; + } + + removeEventListener() { + return undefined; + } + } + class FakeElement extends FakeNode { + constructor( + private readonly interactive: boolean, + private readonly children: FakeNode[] = [] + ) { + super(); + } + + closest() { + return this.interactive ? this : null; + } + + contains(target: EventTarget | null) { + return (target as unknown) === this || this.children.includes(target as unknown as FakeNode); + } + } + fakeDocument = { + body: new FakeNode(), + documentElement: new FakeNode() + }; + + try { + Object.defineProperty(globalThis, "Node", { configurable: true, value: FakeNode }); + Object.defineProperty(globalThis, "Element", { configurable: true, value: FakeElement }); + + const readerChild = new FakeElement(false); + const readerElement = new FakeElement(false, [readerChild]) as unknown as HTMLElement; + const externalElement = new FakeElement(false); + const interactiveElement = new FakeElement(true); + const keyEvent = (event: object) => event as unknown as KeyboardEvent; + + expect(isReaderInteractiveTarget(interactiveElement as unknown as EventTarget)).toBe(true); + expect(shouldHandleReaderArrowKey(keyEvent({ key: "ArrowRight", target: interactiveElement }), readerElement)).toBe(false); + expect(shouldHandleReaderArrowKey(keyEvent({ key: "ArrowRight", target: externalElement }), readerElement)).toBe(false); + expect(shouldHandleReaderArrowKey(keyEvent({ key: "ArrowRight", target: readerChild }), readerElement)).toBe(true); + expect(shouldHandleReaderArrowKey(keyEvent({ key: "ArrowLeft", target: fakeDocument.body }), readerElement)).toBe(true); + expect(shouldHandleReaderArrowKey(keyEvent({ key: "ArrowRight", ctrlKey: true, target: readerChild }), readerElement)).toBe(false); + } finally { + Object.defineProperty(globalThis, "Node", { configurable: true, value: originalNode }); + Object.defineProperty(globalThis, "Element", { configurable: true, value: originalElement }); + } + }); }); diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index 716eda6..e4848b1 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -877,6 +877,21 @@ main.app-main-reader .reader-page { min-height: 0; } +.reader-page:fullscreen, +.reader-fullscreen-fallback { + width: 100vw; + height: 100vh; + min-height: 100vh; + padding: 10px; + background: #120e0b; +} + +.reader-fullscreen-fallback { + position: fixed; + inset: 0; + z-index: 1000; +} + .reader-topbar { grid-row: 1; display: flex; @@ -933,6 +948,10 @@ main.app-main-reader .reader-page { white-space: nowrap; } +.reader-fullscreen-toggle { + margin-left: auto; +} + .reader-status { grid-row: 2; display: flex;