From d71b53b3c55ab0d58754d1838c166def60f26fa6 Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 11 Aug 2026 20:05:02 -0400 Subject: [PATCH 01/38] feat: add themeable design tokens with cookie-backed SSR --- frontend/src/app.css | 154 +++++---- frontend/src/app.d.ts | 4 +- frontend/src/app.html | 1 + frontend/src/hooks.server.ts | 27 +- frontend/src/lib/theme/presets.ts | 346 +++++++++++++++++++ frontend/src/lib/theme/theme.svelte.ts | 133 +++++++ frontend/src/routes/(root)/+layout.server.ts | 6 +- frontend/src/routes/(root)/+layout.svelte | 29 +- 8 files changed, 629 insertions(+), 71 deletions(-) create mode 100644 frontend/src/lib/theme/presets.ts create mode 100644 frontend/src/lib/theme/theme.svelte.ts diff --git a/frontend/src/app.css b/frontend/src/app.css index 3a6fb71..1ea6b25 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -8,74 +8,100 @@ :root { --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.129 0.042 264.695); - --card: oklch(1 0 0); - --card-foreground: oklch(0.129 0.042 264.695); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.129 0.042 264.695); - --primary: oklch(0.208 0.042 265.755); - --primary-foreground: oklch(0.984 0.003 247.858); - --secondary: oklch(0.968 0.007 247.896); - --secondary-foreground: oklch(0.208 0.042 265.755); - --muted: oklch(0.968 0.007 247.896); - --muted-foreground: oklch(0.554 0.046 257.417); - --accent: oklch(0.968 0.007 247.896); - --accent-foreground: oklch(0.208 0.042 265.755); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.929 0.013 255.508); - --input: oklch(0.929 0.013 255.508); - --ring: oklch(0.704 0.04 256.788); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.984 0.003 247.858); - --sidebar-foreground: oklch(0.129 0.042 264.695); - --sidebar-primary: oklch(0.208 0.042 265.755); - --sidebar-primary-foreground: oklch(0.984 0.003 247.858); - --sidebar-accent: oklch(0.968 0.007 247.896); - --sidebar-accent-foreground: oklch(0.208 0.042 265.755); - --sidebar-border: oklch(0.929 0.013 255.508); - --sidebar-ring: oklch(0.704 0.04 256.788); + + /* Typography — system stacks, so nothing depends on a CDN or a webfont build. */ + --app-font-sans: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --app-font-serif: Georgia, 'Iowan Old Style', 'Times New Roman', serif; + --app-font-mono: ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace; + + /* Reading Room — light */ + --background: #e9ebee; + --foreground: #1b1f24; + --card: #fbfbfc; + --card-foreground: #1b1f24; + --popover: #fbfbfc; + --popover-foreground: #1b1f24; + --primary: #1f5f5b; + --primary-foreground: #f2f7f6; + --secondary: #dfe3e8; + --secondary-foreground: #1b1f24; + --muted: #e2e5e9; + --muted-foreground: #7d858f; + --accent: #d7e5e3; + --accent-foreground: #164743; + --destructive: #a6402f; + --border: #d2d6dc; + --input: #d2d6dc; + --ring: #1f5f5b; + + /* Semantic — deliberately not the accent, so state never reads as branding. */ + --success: #2f7d4f; + --success-foreground: #f2f7f6; + --flag: #b08a1e; + --star: #c79a25; + + --chart-1: #1f5f5b; + --chart-2: #2f7d4f; + --chart-3: #b08a1e; + --chart-4: #4c6b8a; + --chart-5: #a6402f; + + --sidebar: #e2e5e9; + --sidebar-foreground: #1b1f24; + --sidebar-primary: #1f5f5b; + --sidebar-primary-foreground: #f2f7f6; + --sidebar-accent: #d7e5e3; + --sidebar-accent-foreground: #164743; + --sidebar-border: #d2d6dc; + --sidebar-ring: #1f5f5b; } .dark { - --background: oklch(0.129 0.042 264.695); - --foreground: oklch(0.984 0.003 247.858); - --card: oklch(0.208 0.042 265.755); - --card-foreground: oklch(0.984 0.003 247.858); - --popover: oklch(0.208 0.042 265.755); - --popover-foreground: oklch(0.984 0.003 247.858); - --primary: oklch(0.929 0.013 255.508); - --primary-foreground: oklch(0.208 0.042 265.755); - --secondary: oklch(0.279 0.041 260.031); - --secondary-foreground: oklch(0.984 0.003 247.858); - --muted: oklch(0.279 0.041 260.031); - --muted-foreground: oklch(0.704 0.04 256.788); - --accent: oklch(0.279 0.041 260.031); - --accent-foreground: oklch(0.984 0.003 247.858); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.551 0.027 264.364); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.208 0.042 265.755); - --sidebar-foreground: oklch(0.984 0.003 247.858); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.984 0.003 247.858); - --sidebar-accent: oklch(0.279 0.041 260.031); - --sidebar-accent-foreground: oklch(0.984 0.003 247.858); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.551 0.027 264.364); + /* Reading Room — dark */ + --background: #15191b; + --foreground: #e6eae9; + --card: #1d2226; + --card-foreground: #e6eae9; + --popover: #1d2226; + --popover-foreground: #e6eae9; + --primary: #6fbab0; + --primary-foreground: #0e1a19; + --secondary: #232a2d; + --secondary-foreground: #e6eae9; + --muted: #232a2d; + --muted-foreground: #78868a; + --accent: #1b3330; + --accent-foreground: #9fd8d0; + --destructive: #e0796b; + --border: #2a3034; + --input: #2a3034; + --ring: #6fbab0; + + --success: #4fa97a; + --success-foreground: #0e1a19; + --flag: #d4a63a; + --star: #e5b84b; + + --chart-1: #6fbab0; + --chart-2: #4fa97a; + --chart-3: #d4a63a; + --chart-4: #7f9dc0; + --chart-5: #e0796b; + + --sidebar: #111517; + --sidebar-foreground: #e6eae9; + --sidebar-primary: #6fbab0; + --sidebar-primary-foreground: #0e1a19; + --sidebar-accent: #1b3330; + --sidebar-accent-foreground: #9fd8d0; + --sidebar-border: #2a3034; + --sidebar-ring: #6fbab0; } @theme inline { + --font-sans: var(--app-font-sans); + --font-serif: var(--app-font-serif); + --font-mono: var(--app-font-mono); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); @@ -95,6 +121,10 @@ --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-flag: var(--flag); + --color-star: var(--star); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index 6cafeb6..8b24ae2 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -2,7 +2,8 @@ // for information about these interfaces import type { ApiClient } from '$lib/server/api'; -import type { User } from 'lucide-svelte'; +import type { User } from '$lib/server/auth'; +import type { ThemeConfig } from '$lib/theme/presets'; declare global { namespace App { @@ -11,6 +12,7 @@ declare global { authToken: string | null; api: ApiClient; user: User; + theme: ThemeConfig; } // interface PageData {} // interface PageState {} diff --git a/frontend/src/app.html b/frontend/src/app.html index 06a6722..9a35db9 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -4,6 +4,7 @@ %sveltekit.head% +
%sveltekit.body%
diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index f7ac519..af0d5c5 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -1,5 +1,6 @@ import { ApiClient } from '$lib/server/api'; import { validateToken } from '$lib/server/auth'; +import { THEME_COOKIE, parseThemeCookie, themeToCss } from '$lib/theme/presets'; import { redirect, type Handle } from '@sveltejs/kit'; import { sequence } from '@sveltejs/kit/hooks'; @@ -34,4 +35,28 @@ const protectedRoutesHandle: Handle = async ({ event, resolve }) => { return resolve(event); }; -export const handle = sequence(authHandle, protectedRoutesHandle); +/** + * Inline the stored theme into the document head. + * + * The palette has to be in the very first byte of HTML the browser paints, + * otherwise every page load flashes the default theme before hydration swaps + * it. The `` placeholder in app.html is the injection point. + */ +const themeHandle: Handle = async ({ event, resolve }) => { + const config = parseThemeCookie(event.cookies.get(THEME_COOKIE)); + event.locals.theme = config; + + return resolve(event, { + // The placeholder comment is kept, not replaced. Svelte 5 uses HTML + // comments as hydration markers, so SvelteKit warns when a chunk comes + // back with fewer comments than it went in with — removing this one is + // enough to trip that check. + transformPageChunk: ({ html }) => + html.replace( + '', + `` + ) + }); +}; + +export const handle = sequence(themeHandle, authHandle, protectedRoutesHandle); diff --git a/frontend/src/lib/theme/presets.ts b/frontend/src/lib/theme/presets.ts new file mode 100644 index 0000000..c2a4c8b --- /dev/null +++ b/frontend/src/lib/theme/presets.ts @@ -0,0 +1,346 @@ +/** + * Theme presets and the definition of what the editor may change. + * + * A preset supplies a full palette for both modes. The editor writes sparse + * overrides on top, per mode, so customising the dark palette never disturbs + * the light one. + */ + +export type Mode = 'light' | 'dark'; + +/** CSS custom properties the editor exposes, in the order it shows them. */ +export const COLOR_TOKENS = [ + { key: 'background', label: 'Background', hint: 'Page behind everything' }, + { key: 'foreground', label: 'Text', hint: 'Default body text' }, + { key: 'card', label: 'Surface', hint: 'Cards, rows, popovers' }, + { key: 'primary', label: 'Primary', hint: 'Buttons and active states' }, + { key: 'muted-foreground', label: 'Muted text', hint: 'Counts, captions, metadata' }, + { key: 'border', label: 'Border', hint: 'Rules and outlines' }, + { key: 'sidebar', label: 'Sidebar', hint: 'Sidebar and toolbar ground' }, + { key: 'success', label: 'Finished', hint: 'Completed reading progress' }, + { key: 'flag', label: 'Active filter', hint: 'The dot on Filter and Sort' }, + { key: 'star', label: 'Favourite', hint: 'Stars and selection rings' }, + { key: 'destructive', label: 'Destructive', hint: 'Delete actions' } +] as const; + +export type ColorTokenKey = (typeof COLOR_TOKENS)[number]['key']; + +export const FONT_TOKENS = [ + { key: 'app-font-sans', label: 'Interface' }, + { key: 'app-font-serif', label: 'Titles' }, + { key: 'app-font-mono', label: 'Numbers and labels' } +] as const; + +export type FontTokenKey = (typeof FONT_TOKENS)[number]['key']; + +/** System stacks only — no webfont fetch, so nothing can silently fall back. */ +export const FONT_STACKS: { label: string; value: string }[] = [ + { + label: 'System UI', + value: "system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif" + }, + { label: 'Georgia', value: "Georgia, 'Iowan Old Style', 'Times New Roman', serif" }, + { label: 'Times', value: "'Times New Roman', Times, serif" }, + { label: 'Palatino', value: "'Palatino Linotype', Palatino, 'Book Antiqua', serif" }, + { label: 'Helvetica', value: "'Helvetica Neue', Helvetica, Arial, sans-serif" }, + { label: 'Verdana', value: 'Verdana, Geneva, sans-serif' }, + { label: 'Monospace', value: "ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace" } +]; + +export type Palette = Record; + +export interface Preset { + id: string; + name: string; + description: string; + radius: string; + fonts: Record; + light: Palette; + dark: Palette; +} + +const SANS = FONT_STACKS[0].value; +const SERIF = FONT_STACKS[1].value; +const MONO = FONT_STACKS[6].value; + +export const READING_ROOM: Preset = { + id: 'reading-room', + name: 'Reading Room', + description: 'Cool grey paper, one teal accent, serif titles.', + radius: '0.625rem', + fonts: { 'app-font-sans': SANS, 'app-font-serif': SERIF, 'app-font-mono': MONO }, + light: { + background: '#e9ebee', + foreground: '#1b1f24', + card: '#fbfbfc', + 'card-foreground': '#1b1f24', + popover: '#fbfbfc', + 'popover-foreground': '#1b1f24', + primary: '#1f5f5b', + 'primary-foreground': '#f2f7f6', + secondary: '#dfe3e8', + 'secondary-foreground': '#1b1f24', + muted: '#e2e5e9', + 'muted-foreground': '#7d858f', + accent: '#d7e5e3', + 'accent-foreground': '#164743', + destructive: '#a6402f', + border: '#d2d6dc', + input: '#d2d6dc', + ring: '#1f5f5b', + success: '#2f7d4f', + 'success-foreground': '#f2f7f6', + flag: '#b08a1e', + star: '#c79a25', + sidebar: '#e2e5e9', + 'sidebar-foreground': '#1b1f24', + 'sidebar-primary': '#1f5f5b', + 'sidebar-primary-foreground': '#f2f7f6', + 'sidebar-accent': '#d7e5e3', + 'sidebar-accent-foreground': '#164743', + 'sidebar-border': '#d2d6dc', + 'sidebar-ring': '#1f5f5b' + }, + dark: { + background: '#15191b', + foreground: '#e6eae9', + card: '#1d2226', + 'card-foreground': '#e6eae9', + popover: '#1d2226', + 'popover-foreground': '#e6eae9', + primary: '#6fbab0', + 'primary-foreground': '#0e1a19', + secondary: '#232a2d', + 'secondary-foreground': '#e6eae9', + muted: '#232a2d', + 'muted-foreground': '#78868a', + accent: '#1b3330', + 'accent-foreground': '#9fd8d0', + destructive: '#e0796b', + border: '#2a3034', + input: '#2a3034', + ring: '#6fbab0', + success: '#4fa97a', + 'success-foreground': '#0e1a19', + flag: '#d4a63a', + star: '#e5b84b', + sidebar: '#111517', + 'sidebar-foreground': '#e6eae9', + 'sidebar-primary': '#6fbab0', + 'sidebar-primary-foreground': '#0e1a19', + 'sidebar-accent': '#1b3330', + 'sidebar-accent-foreground': '#9fd8d0', + 'sidebar-border': '#2a3034', + 'sidebar-ring': '#6fbab0' + } +}; + +export const STACKS: Preset = { + id: 'stacks', + name: 'Stacks', + description: 'Warm near-black with brass. Covers do the talking.', + radius: '0.25rem', + fonts: { 'app-font-sans': SANS, 'app-font-serif': SERIF, 'app-font-mono': MONO }, + light: { + ...READING_ROOM.light, + background: '#f2efe9', + foreground: '#221e1a', + card: '#fbf9f5', + 'card-foreground': '#221e1a', + popover: '#fbf9f5', + 'popover-foreground': '#221e1a', + primary: '#8a6a15', + 'primary-foreground': '#fbf9f5', + secondary: '#e7e1d6', + 'secondary-foreground': '#221e1a', + muted: '#e7e1d6', + 'muted-foreground': '#7d7266', + accent: '#efe6d2', + 'accent-foreground': '#5c4708', + border: '#ddd5c7', + input: '#ddd5c7', + ring: '#8a6a15', + flag: '#8a6a15', + star: '#a8801d', + sidebar: '#ebe6dc', + 'sidebar-foreground': '#221e1a', + 'sidebar-primary': '#8a6a15', + 'sidebar-primary-foreground': '#fbf9f5', + 'sidebar-accent': '#efe6d2', + 'sidebar-accent-foreground': '#5c4708', + 'sidebar-border': '#ddd5c7', + 'sidebar-ring': '#8a6a15' + }, + dark: { + ...READING_ROOM.dark, + background: '#141210', + foreground: '#ede7de', + card: '#221e1a', + 'card-foreground': '#ede7de', + popover: '#221e1a', + 'popover-foreground': '#ede7de', + primary: '#c9a227', + 'primary-foreground': '#17130a', + secondary: '#2c2621', + 'secondary-foreground': '#ede7de', + muted: '#2c2621', + 'muted-foreground': '#7d7266', + accent: '#352c14', + 'accent-foreground': '#e6c452', + border: '#2c2621', + input: '#2c2621', + ring: '#c9a227', + flag: '#c9a227', + star: '#e6c452', + sidebar: '#100e0c', + 'sidebar-foreground': '#ede7de', + 'sidebar-primary': '#c9a227', + 'sidebar-primary-foreground': '#17130a', + 'sidebar-accent': '#352c14', + 'sidebar-accent-foreground': '#e6c452', + 'sidebar-border': '#2c2621', + 'sidebar-ring': '#c9a227' + } +}; + +export const SLATE: Preset = { + id: 'slate', + name: 'Slate', + description: 'The original shadcn palette, kept for comparison.', + radius: '0.625rem', + fonts: { 'app-font-sans': SANS, 'app-font-serif': SANS, 'app-font-mono': MONO }, + light: { + ...READING_ROOM.light, + background: '#ffffff', + foreground: '#020617', + card: '#ffffff', + 'card-foreground': '#020617', + popover: '#ffffff', + 'popover-foreground': '#020617', + primary: '#1e293b', + 'primary-foreground': '#f8fafc', + secondary: '#f1f5f9', + 'secondary-foreground': '#1e293b', + muted: '#f1f5f9', + 'muted-foreground': '#64748b', + accent: '#f1f5f9', + 'accent-foreground': '#1e293b', + destructive: '#dc2626', + border: '#e2e8f0', + input: '#e2e8f0', + ring: '#94a3b8', + success: '#16a34a', + flag: '#eab308', + star: '#fde047', + sidebar: '#f8fafc', + 'sidebar-foreground': '#020617', + 'sidebar-primary': '#1e293b', + 'sidebar-primary-foreground': '#f8fafc', + 'sidebar-accent': '#f1f5f9', + 'sidebar-accent-foreground': '#1e293b', + 'sidebar-border': '#e2e8f0', + 'sidebar-ring': '#94a3b8' + }, + dark: { + ...READING_ROOM.dark, + background: '#020617', + foreground: '#f8fafc', + card: '#1e293b', + 'card-foreground': '#f8fafc', + popover: '#1e293b', + 'popover-foreground': '#f8fafc', + primary: '#e2e8f0', + 'primary-foreground': '#1e293b', + secondary: '#334155', + 'secondary-foreground': '#f8fafc', + muted: '#334155', + 'muted-foreground': '#94a3b8', + accent: '#334155', + 'accent-foreground': '#f8fafc', + destructive: '#f87171', + border: '#334155', + input: '#334155', + ring: '#64748b', + success: '#4ade80', + flag: '#eab308', + star: '#fde047', + sidebar: '#1e293b', + 'sidebar-foreground': '#f8fafc', + 'sidebar-primary': '#60a5fa', + 'sidebar-primary-foreground': '#f8fafc', + 'sidebar-accent': '#334155', + 'sidebar-accent-foreground': '#f8fafc', + 'sidebar-border': '#334155', + 'sidebar-ring': '#64748b' + } +}; + +export const PRESETS: Preset[] = [READING_ROOM, STACKS, SLATE]; + +export const DEFAULT_PRESET_ID = READING_ROOM.id; + +export function getPreset(id: string | undefined): Preset { + return PRESETS.find((p) => p.id === id) ?? READING_ROOM; +} + +/** What we persist in the cookie. Sparse by design — presets carry the rest. */ +export interface ThemeConfig { + preset: string; + radius?: string; + fonts?: Partial>; + light?: Palette; + dark?: Palette; +} + +export const THEME_COOKIE = 'chitai-theme'; + +export function parseThemeCookie(raw: string | undefined | null): ThemeConfig { + if (!raw) return { preset: DEFAULT_PRESET_ID }; + try { + const parsed = JSON.parse(decodeURIComponent(raw)); + if (!parsed || typeof parsed !== 'object') return { preset: DEFAULT_PRESET_ID }; + return { ...parsed, preset: typeof parsed.preset === 'string' ? parsed.preset : DEFAULT_PRESET_ID }; + } catch { + return { preset: DEFAULT_PRESET_ID }; + } +} + +/** Merge a config over its preset to get the palette actually in force. */ +export function resolvePalette(config: ThemeConfig, mode: Mode): Palette { + const preset = getPreset(config.preset); + return { ...preset[mode], ...(config[mode] ?? {}) }; +} + +export function resolveRadius(config: ThemeConfig): string { + return config.radius ?? getPreset(config.preset).radius; +} + +export function resolveFonts(config: ThemeConfig): Record { + return { ...getPreset(config.preset).fonts, ...(config.fonts ?? {}) }; +} + +/** + * Render a theme as CSS. Used on the server to inline the theme into the + * document head, so the first paint is already correct. + * + * The doubled `:root:root` is deliberate. app.css declares the same custom + * properties on plain `:root`, which has identical specificity, so whichever + * stylesheet comes last would win — and in dev Vite appends app.css to the end + * of , after this tag. Doubling the selector raises specificity to + * (0,2,0) so the stored theme wins on merit rather than on load order. + */ +export function themeToCss(config: ThemeConfig): string { + const decl = (palette: Palette) => + Object.entries(palette) + .map(([k, v]) => `--${k}:${v};`) + .join(''); + + const fonts = Object.entries(resolveFonts(config)) + .map(([k, v]) => `--${k}:${v};`) + .join(''); + + return ( + `:root:root{--radius:${resolveRadius(config)};${fonts}${decl(resolvePalette(config, 'light'))}}` + + `:root:root.dark{${decl(resolvePalette(config, 'dark'))}}` + ); +} diff --git a/frontend/src/lib/theme/theme.svelte.ts b/frontend/src/lib/theme/theme.svelte.ts new file mode 100644 index 0000000..3495df5 --- /dev/null +++ b/frontend/src/lib/theme/theme.svelte.ts @@ -0,0 +1,133 @@ +import { getContext, setContext } from 'svelte'; +import { browser } from '$app/environment'; +import { mode } from 'mode-watcher'; +import { + DEFAULT_PRESET_ID, + THEME_COOKIE, + getPreset, + resolveFonts, + resolvePalette, + resolveRadius, + type ColorTokenKey, + type FontTokenKey, + type Mode, + type ThemeConfig +} from './presets'; + +/** + * Live theme state. + * + * The server has already inlined the stored theme into the document head, so + * this class only takes over once the user edits something: it writes the + * changed custom properties onto and persists the config to a cookie. + */ +export class ThemeState { + config = $state({ preset: DEFAULT_PRESET_ID }); + + /** Which palette the editor is currently writing to. */ + readonly mode = $derived(mode.current === 'dark' ? 'dark' : 'light'); + + readonly preset = $derived(getPreset(this.config.preset)); + readonly palette = $derived(resolvePalette(this.config, this.mode)); + readonly radius = $derived(resolveRadius(this.config)); + readonly fonts = $derived(resolveFonts(this.config)); + + readonly isCustomised = $derived( + Boolean( + this.config.radius || + this.config.fonts || + Object.keys(this.config.light ?? {}).length || + Object.keys(this.config.dark ?? {}).length + ) + ); + + /** + * True once this session has written inline custom properties onto . + * Until then the server-injected stylesheet is the only source of theme, and + * it already covers both modes — so we touch nothing and there is no repaint. + */ + private touched = false; + + constructor(config: ThemeConfig) { + this.config = config; + } + + setPreset(id: string) { + // Switching preset discards overrides; keeping them across palettes + // produces colour combinations nobody chose. + this.config = { preset: id }; + this.apply(); + } + + setColor(token: ColorTokenKey | string, value: string) { + const mode = this.mode; + this.config = { ...this.config, [mode]: { ...(this.config[mode] ?? {}), [token]: value } }; + this.apply(); + } + + setRadius(value: string) { + this.config = { ...this.config, radius: value }; + this.apply(); + } + + setFont(token: FontTokenKey, value: string) { + this.config = { ...this.config, fonts: { ...(this.config.fonts ?? {}), [token]: value } }; + this.apply(); + } + + reset() { + this.config = { preset: this.config.preset }; + this.apply(); + } + + /** Push the resolved theme onto the document and persist it. */ + apply() { + if (!browser) return; + this.touched = true; + + const root = document.documentElement; + root.style.setProperty('--radius', this.radius); + + for (const [key, value] of Object.entries(this.fonts)) { + root.style.setProperty(`--${key}`, value); + } + + // Inline styles sit on :root and so apply in both modes. Write the + // palette for the mode being displayed and rewrite it on mode change. + for (const [key, value] of Object.entries(this.palette)) { + root.style.setProperty(`--${key}`, value); + } + + this.persist(); + } + + /** + * Re-apply after a light/dark switch, since inline vars are mode-blind. + * No-op until something has actually been edited — see `touched`. + */ + syncMode() { + if (!browser || !this.touched) return; + const palette = resolvePalette(this.config, this.mode); + for (const [key, value] of Object.entries(palette)) { + document.documentElement.style.setProperty(`--${key}`, value); + } + } + + private persist() { + if (!browser) return; + const value = encodeURIComponent(JSON.stringify(this.config)); + // One year, root path, lax — same shape as the auth cookie minus httpOnly, + // because the editor has to be able to read and rewrite it client-side. + document.cookie = `${THEME_COOKIE}=${value};path=/;max-age=31536000;samesite=lax`; + } +} + +const THEME_KEY = Symbol('THEME'); + +export function setThemeState(config: ThemeConfig) { + return setContext(THEME_KEY, new ThemeState(config)); +} + +export function getThemeState() { + return getContext>(THEME_KEY); +} diff --git a/frontend/src/routes/(root)/+layout.server.ts b/frontend/src/routes/(root)/+layout.server.ts index 15cdb65..3e700f6 100644 --- a/frontend/src/routes/(root)/+layout.server.ts +++ b/frontend/src/routes/(root)/+layout.server.ts @@ -1,9 +1,11 @@ import { listLibraries } from '$lib/api/library.remote.js'; -export const load = async ({ depends }) => { +export const load = async ({ depends, locals }) => { depends('app:libraries'); return { - libraries: await listLibraries({ pageSize: 100 }) + libraries: await listLibraries({ pageSize: 100 }), + theme: locals.theme, + user: locals.user }; }; diff --git a/frontend/src/routes/(root)/+layout.svelte b/frontend/src/routes/(root)/+layout.svelte index a8d216e..6e7a831 100644 --- a/frontend/src/routes/(root)/+layout.svelte +++ b/frontend/src/routes/(root)/+layout.svelte @@ -8,17 +8,36 @@ import { setBookOperationsState } from '$lib/state/bookOperations.svelte'; import { setBookshelfState } from '$lib/state/bookshelf.svelte'; import { setLibraryState } from '$lib/state/library.svelte.js'; + import { setThemeState } from '$lib/theme/theme.svelte'; + import type { ThemeConfig } from '$lib/theme/presets'; import { ModeWatcher } from 'mode-watcher'; - import type { Snippet } from 'svelte'; + import { untrack, type Snippet } from 'svelte'; import { Toaster } from 'svelte-sonner'; - let { data, children }: { data: { libraries: PaginatedResponse }; children: Snippet } = - $props(); + let { + data, + children + }: { + data: { libraries: PaginatedResponse; theme: ThemeConfig; user: { email: string } }; + children: Snippet; + } = $props(); - const libraryState = setLibraryState(data.libraries.items); + // These states are seeded once and then own their own data — LibraryState has + // updateLibraries() for later syncing, and ThemeState owns the config after + // init. `untrack` says that explicitly, instead of silently capturing the + // initial value and warning about it. + const libraryState = setLibraryState(untrack(() => data.libraries.items)); const bookshelfState = setBookshelfState(); const bookOps = setBookOperationsState(libraryState.activeLibrary!.id); + const theme = setThemeState(untrack(() => data.theme)); + + // Inline custom properties live on :root and so are mode-blind. When the + // light/dark switch flips, rewrite them for the mode now showing. + $effect(() => { + theme.mode; + theme.syncMode(); + }); @@ -27,7 +46,7 @@
- +
From f36aa144634a64ee70914ac1bb258b5fc2161e14 Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 11 Aug 2026 20:05:02 -0400 Subject: [PATCH 02/38] feat: add theme editor in settings --- .../src/routes/(root)/settings/+layout.svelte | 6 +- .../(root)/settings/appearance/+page.svelte | 170 ++++++++++++++++++ 2 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 frontend/src/routes/(root)/settings/appearance/+page.svelte diff --git a/frontend/src/routes/(root)/settings/+layout.svelte b/frontend/src/routes/(root)/settings/+layout.svelte index 8cc3f16..1845d51 100644 --- a/frontend/src/routes/(root)/settings/+layout.svelte +++ b/frontend/src/routes/(root)/settings/+layout.svelte @@ -7,6 +7,10 @@ title: 'Account', url: '/settings/account' }, + { + title: 'Appearance', + url: '/settings/appearance' + }, { title: 'Libraries', url: '/settings/libraries' @@ -19,7 +23,7 @@
-

Settings

+

Settings

diff --git a/frontend/src/lib/components/view/book-grid.svelte b/frontend/src/lib/components/view/book-grid.svelte index 683abf8..712be1b 100644 --- a/frontend/src/lib/components/view/book-grid.svelte +++ b/frontend/src/lib/components/view/book-grid.svelte @@ -31,8 +31,8 @@ selectedState.toggleSelection(book); }} class="{selectedState.isSelected(book.id) - ? 'scale-110 text-yellow-300' - : 'scale-75 text-white'} transition-all hover:scale-110 hover:cursor-pointer hover:text-yellow-300" + ? 'scale-110 text-star' + : 'scale-75 text-white'} transition-all hover:scale-110 hover:cursor-pointer hover:text-star" />
{#if selectedState.isSelected(book.id)} @@ -42,7 +42,7 @@ : 'opacity-0'} transition-opacity group-hover:opacity-100" >
{/if} @@ -54,7 +54,7 @@ @@ -89,7 +89,7 @@ class="absolute right-2 bottom-20 z-50 inline-flex items-center opacity-0 transition-opacity group-hover:opacity-100" > { bookOps.bookToEdit = book; bookOps.editDialogOpen = true; diff --git a/frontend/src/lib/components/view/book-thumbnail.svelte b/frontend/src/lib/components/view/book-thumbnail.svelte index 2cd3a9b..6075867 100644 --- a/frontend/src/lib/components/view/book-thumbnail.svelte +++ b/frontend/src/lib/components/view/book-thumbnail.svelte @@ -25,31 +25,33 @@ + + + {#if book.progress?.percentage && !selected && !darkened} + + + + {/if} - {#if book.progress?.percentage && !selected && !darkened} - - {/if} - {book.title} diff --git a/frontend/src/lib/components/view/filter-button.svelte b/frontend/src/lib/components/view/filter-button.svelte index a0485ea..43069c8 100644 --- a/frontend/src/lib/components/view/filter-button.svelte +++ b/frontend/src/lib/components/view/filter-button.svelte @@ -20,7 +20,7 @@ > {#if bookCollection.hasActiveFilters} -
+
{/if} diff --git a/frontend/src/lib/components/view/filters.svelte b/frontend/src/lib/components/view/filters.svelte index 83c9807..2dc9ca3 100644 --- a/frontend/src/lib/components/view/filters.svelte +++ b/frontend/src/lib/components/view/filters.svelte @@ -19,7 +19,7 @@ {filter.name} {#if bookCollection.filters[filter.value].length !== 0} -
+
{/if} {#if bookCollection.hasActiveSort}
{/if} From cb070336c656afb39de09b3bc48bef560c7f458d Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 11 Aug 2026 20:05:17 -0400 Subject: [PATCH 04/38] feat: replace the sidebar logo with a book mark --- .../src/lib/components/icons/UnjsDb0.svelte | 73 ------------------- .../lib/components/icons/chitai-mark.svelte | 29 ++++++++ 2 files changed, 29 insertions(+), 73 deletions(-) delete mode 100644 frontend/src/lib/components/icons/UnjsDb0.svelte create mode 100644 frontend/src/lib/components/icons/chitai-mark.svelte diff --git a/frontend/src/lib/components/icons/UnjsDb0.svelte b/frontend/src/lib/components/icons/UnjsDb0.svelte deleted file mode 100644 index c58508f..0000000 --- a/frontend/src/lib/components/icons/UnjsDb0.svelte +++ /dev/null @@ -1,73 +0,0 @@ - diff --git a/frontend/src/lib/components/icons/chitai-mark.svelte b/frontend/src/lib/components/icons/chitai-mark.svelte new file mode 100644 index 0000000..5427699 --- /dev/null +++ b/frontend/src/lib/components/icons/chitai-mark.svelte @@ -0,0 +1,29 @@ + + + + From adddcfeeed94cabb2e71e8fa1de2d87468a5ce47 Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 11 Aug 2026 20:05:24 -0400 Subject: [PATCH 05/38] feat: add account menu to the sidebar footer --- .../lib/components/layout/app-sidebar.svelte | 44 ++++----- .../src/lib/components/layout/nav-user.svelte | 94 +++++++++++++++++++ .../components/ui/avatar/avatar-badge.svelte | 26 +++++ .../ui/avatar/avatar-fallback.svelte | 20 ++++ .../ui/avatar/avatar-group-count.svelte | 23 +++++ .../components/ui/avatar/avatar-group.svelte | 23 +++++ .../components/ui/avatar/avatar-image.svelte | 17 ++++ .../lib/components/ui/avatar/avatar.svelte | 26 +++++ .../src/lib/components/ui/avatar/index.ts | 22 +++++ 9 files changed, 270 insertions(+), 25 deletions(-) create mode 100644 frontend/src/lib/components/layout/nav-user.svelte create mode 100644 frontend/src/lib/components/ui/avatar/avatar-badge.svelte create mode 100644 frontend/src/lib/components/ui/avatar/avatar-fallback.svelte create mode 100644 frontend/src/lib/components/ui/avatar/avatar-group-count.svelte create mode 100644 frontend/src/lib/components/ui/avatar/avatar-group.svelte create mode 100644 frontend/src/lib/components/ui/avatar/avatar-image.svelte create mode 100644 frontend/src/lib/components/ui/avatar/avatar.svelte create mode 100644 frontend/src/lib/components/ui/avatar/index.ts diff --git a/frontend/src/lib/components/layout/app-sidebar.svelte b/frontend/src/lib/components/layout/app-sidebar.svelte index 9dae4da..096e989 100644 --- a/frontend/src/lib/components/layout/app-sidebar.svelte +++ b/frontend/src/lib/components/layout/app-sidebar.svelte @@ -1,45 +1,48 @@ - + + {#snippet child({ props })} - - {header.title} + + {header.title} {/snippet} @@ -50,15 +53,6 @@ - - {#snippet child({ props })} - - {#if footer.icon} - - {/if} - {footer.title} - - {/snippet} - + diff --git a/frontend/src/lib/components/layout/nav-user.svelte b/frontend/src/lib/components/layout/nav-user.svelte new file mode 100644 index 0000000..c1ec24f --- /dev/null +++ b/frontend/src/lib/components/layout/nav-user.svelte @@ -0,0 +1,94 @@ + + + + + + + {#snippet child({ props })} + + + + {initials} + + +
+ {handle} + {user?.email} +
+ +
+ {/snippet} +
+ + + +
+ + + {initials} + + +
+ {handle} + {user?.email} +
+
+
+ + + + + goto('/settings/account')}> + + Settings + + goto('/settings/appearance')}> + + Appearance + + + + + + { + await logout(); + await goto('/login'); + }} + > + + Log out + +
+
+
+
diff --git a/frontend/src/lib/components/ui/avatar/avatar-badge.svelte b/frontend/src/lib/components/ui/avatar/avatar-badge.svelte new file mode 100644 index 0000000..2df847a --- /dev/null +++ b/frontend/src/lib/components/ui/avatar/avatar-badge.svelte @@ -0,0 +1,26 @@ + + +svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...restProps} +> + {@render children?.()} + diff --git a/frontend/src/lib/components/ui/avatar/avatar-fallback.svelte b/frontend/src/lib/components/ui/avatar/avatar-fallback.svelte new file mode 100644 index 0000000..62f28d9 --- /dev/null +++ b/frontend/src/lib/components/ui/avatar/avatar-fallback.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/ui/avatar/avatar-group-count.svelte b/frontend/src/lib/components/ui/avatar/avatar-group-count.svelte new file mode 100644 index 0000000..0a0aeb4 --- /dev/null +++ b/frontend/src/lib/components/ui/avatar/avatar-group-count.svelte @@ -0,0 +1,23 @@ + + +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 relative flex shrink-0 items-center justify-center ring-2 ring-background", + className + )} + {...restProps} +> + {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/avatar/avatar-group.svelte b/frontend/src/lib/components/ui/avatar/avatar-group.svelte new file mode 100644 index 0000000..e558356 --- /dev/null +++ b/frontend/src/lib/components/ui/avatar/avatar-group.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/frontend/src/lib/components/ui/avatar/avatar-image.svelte b/frontend/src/lib/components/ui/avatar/avatar-image.svelte new file mode 100644 index 0000000..f41cf8f --- /dev/null +++ b/frontend/src/lib/components/ui/avatar/avatar-image.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/avatar/avatar.svelte b/frontend/src/lib/components/ui/avatar/avatar.svelte new file mode 100644 index 0000000..7464798 --- /dev/null +++ b/frontend/src/lib/components/ui/avatar/avatar.svelte @@ -0,0 +1,26 @@ + + + diff --git a/frontend/src/lib/components/ui/avatar/index.ts b/frontend/src/lib/components/ui/avatar/index.ts new file mode 100644 index 0000000..963a923 --- /dev/null +++ b/frontend/src/lib/components/ui/avatar/index.ts @@ -0,0 +1,22 @@ +import Badge from "./avatar-badge.svelte"; +import Fallback from "./avatar-fallback.svelte"; +import GroupCount from "./avatar-group-count.svelte"; +import Group from "./avatar-group.svelte"; +import Image from "./avatar-image.svelte"; +import Root from "./avatar.svelte"; + +export { + Root, + Image, + Fallback, + Badge, + Group, + GroupCount, + // + Root as Avatar, + Image as AvatarImage, + Fallback as AvatarFallback, + Badge as AvatarBadge, + Group as AvatarGroup, + GroupCount as AvatarGroupCount, +}; From 87fff20d725e2e97bba88bdb733dc53a9715c11a Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 11 Aug 2026 20:05:24 -0400 Subject: [PATCH 06/38] feat: widen search and label the upload button --- .../lib/components/layout/search-form.svelte | 10 ++++---- .../lib/components/layout/site-header.svelte | 23 ++++++++--------- .../components/layout/upload-button.svelte | 25 +++++-------------- 3 files changed, 21 insertions(+), 37 deletions(-) diff --git a/frontend/src/lib/components/layout/search-form.svelte b/frontend/src/lib/components/layout/search-form.svelte index 617de42..99bfb99 100644 --- a/frontend/src/lib/components/layout/search-form.svelte +++ b/frontend/src/lib/components/layout/search-form.svelte @@ -59,14 +59,14 @@ -
-
+ +
- + - + @@ -108,7 +108,7 @@ class="w-24 rounded object-cover shadow-lg" />
- {book.title} + {book.title} {book.subtitle} {#if book.authors.length > 0} diff --git a/frontend/src/lib/components/layout/site-header.svelte b/frontend/src/lib/components/layout/site-header.svelte index f9bdb1d..26c8879 100644 --- a/frontend/src/lib/components/layout/site-header.svelte +++ b/frontend/src/lib/components/layout/site-header.svelte @@ -11,21 +11,18 @@
-
-
-
- - -
+
+ + - + + -
- - -
+
+ +
diff --git a/frontend/src/lib/components/layout/upload-button.svelte b/frontend/src/lib/components/layout/upload-button.svelte index 65a6c6d..522f225 100644 --- a/frontend/src/lib/components/layout/upload-button.svelte +++ b/frontend/src/lib/components/layout/upload-button.svelte @@ -1,29 +1,16 @@ - - - { - bookOps.uploadDialogOpen = true; - }} - class="{buttonVariants({ variant: 'ghost', size: 'icon' })} scale-110" - > - - - - -

Upload Book

-
-
-
+ + From 75360ff603cb8d784a8cfdf778f76b8a8d6ec41e Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 11 Aug 2026 20:06:23 -0400 Subject: [PATCH 07/38] feat: add book cover component and detail-card list view --- .../components/view/book-actions-menu.svelte | 99 ++++++++ .../src/lib/components/view/book-cover.svelte | 84 +++++++ .../src/lib/components/view/book-rows.svelte | 231 ++++++++++++++++++ 3 files changed, 414 insertions(+) create mode 100644 frontend/src/lib/components/view/book-actions-menu.svelte create mode 100644 frontend/src/lib/components/view/book-cover.svelte create mode 100644 frontend/src/lib/components/view/book-rows.svelte diff --git a/frontend/src/lib/components/view/book-actions-menu.svelte b/frontend/src/lib/components/view/book-actions-menu.svelte new file mode 100644 index 0000000..ebdd3ff --- /dev/null +++ b/frontend/src/lib/components/view/book-actions-menu.svelte @@ -0,0 +1,99 @@ + + + + + + + + + + + {#if book.files.length > 0} + openInReader(book.files[0])}> + + Read + + bookOps.downloadBookFile(book.id, book.files[0].id, book.files[0].filename)} + > + + Download + + + {/if} + + { + bookOps.bookToEdit = book; + bookOps.editDialogOpen = true; + }} + > + + Edit + + + { + if (!book.progress?.completed) await bookOps.markBooksAsComplete([book.id]); + else await bookOps.markBooksAsIncomplete([book.id]); + }} + > + + {book.progress?.completed ? 'Mark as unfinished' : 'Mark as finished'} + + + + + { + bookOps.deleteDialogTitle = `Delete "${book.title}"?`; + bookOps.deleteFn = async (deleteFiles: boolean) => { + await bookOps.deleteBooks([book.id], deleteFiles); + libraryState.activeLibrary!.total!--; + bookshelfState.deletedBooks([book]); + }; + bookOps.deleteDialogOpen = true; + }} + > + + Delete + + + + diff --git a/frontend/src/lib/components/view/book-cover.svelte b/frontend/src/lib/components/view/book-cover.svelte new file mode 100644 index 0000000..903a4b9 --- /dev/null +++ b/frontend/src/lib/components/view/book-cover.svelte @@ -0,0 +1,84 @@ + + + + + {#if src && !failed} + + + Cover of {book.title} (failed = true)} + class="h-full w-auto object-contain" + style="max-width: {Math.round(height * 0.95)}px;" + /> + + {#if book.progress?.percentage} + + + + {/if} + + {:else} + + + {book.title} + {#if authors} + {authors} + {/if} + + {/if} + diff --git a/frontend/src/lib/components/view/book-rows.svelte b/frontend/src/lib/components/view/book-rows.svelte new file mode 100644 index 0000000..a30bcce --- /dev/null +++ b/frontend/src/lib/components/view/book-rows.svelte @@ -0,0 +1,231 @@ + + + +
+ {#each books as book (book.id)} + {@const selected = selectionState.isSelected(book.id)} + {@const state = readState(book)} + + +
handleCardClick(event, book)} + class="group flex gap-3.5 rounded-lg border bg-card p-3 transition-colors {selected + ? 'border-primary bg-accent' + : 'border-border/60 hover:border-border'} {selectionState.selectionModeActive + ? 'cursor-pointer' + : ''}" + > + + + + +
+
+
+ + {book.title} + +

{authors(book)}

+
+ + + +
+ +
+ {#if year(book)} + + {year(book)} + + {/if} + {#if book.files.length > 0} + {formats(book)} + {/if} + {#each book.tags.slice(0, TAG_LIMIT) as tag (tag.id)} + {tag.name} + {/each} + {#if book.tags.length > TAG_LIMIT} + + +{book.tags.length - TAG_LIMIT} + + {/if} +
+ +
+ +
+ {#if book.files.length > 0} + + + openInReader(book, book.files[0])} + > + + +

Read

+
+
+ + + + + bookOps.downloadBookFile(book.id, book.files[0].id, book.files[0].filename)} + > + + +

Download

+
+
+ {/if} + + +
+ + {#if state.text} + + {state.text} + + {/if} +
+
+
+ {/each} +
From f2b8f337f7c16351d2f1930aae23826bc72283d8 Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 11 Aug 2026 20:06:23 -0400 Subject: [PATCH 08/38] feat: rebuild the table view with sortable, toggleable columns --- .../src/lib/components/view/book-table.svelte | 338 +++++++++++++++--- 1 file changed, 283 insertions(+), 55 deletions(-) diff --git a/frontend/src/lib/components/view/book-table.svelte b/frontend/src/lib/components/view/book-table.svelte index fb45508..ab0d16f 100644 --- a/frontend/src/lib/components/view/book-table.svelte +++ b/frontend/src/lib/components/view/book-table.svelte @@ -1,66 +1,294 @@ - - - - - - Cover - Title - Authors - Publisher - Published Date - Pages - Tags - Identifiers - - - - {#each books as book (book.id)} +
+ +
+ + {books.length} shown + +
+ + + + Columns + + + + + Show columns + + {#each columns as column (column.key)} + + { + if (!column.fixed) column.on = value; + }} + > + {column.label} + + {/each} + + + +
+
+ + + + - selectedState.toggleSelection(book)} - /> - - - - {book.title} - - {#each book.authors as author} - {author.name}   - {/each} - - {book.publisher} - {book.published_date} - {book.pages} - - {#each book.tags as tag} - {tag.name} - {/each} - - - {#each Object.entries(book.identifiers) as [name, value] (value)} - {name}   - {/each} - + + { + if (allSelected) selectionState.deselectAll(); + else selectionState.selectAll(books); + }} + /> + + + + {#each visible as column (column.key)} + + {#if column.sort} + + + {:else} + {column.label} + {/if} + + {/each} - {/each} - - + + + + {#each books as book (book.id)} + handleRowClick(event, book)} + class={selectionState.selectionModeActive ? 'cursor-pointer' : ''} + > + + selectionState.toggleSelection(book)} + /> + + + + + + {#each visible as column (column.key)} + {#if column.key === 'title'} + + {book.title} + + {:else if column.key === 'authors'} + + {#each book.authors as author (author.id)} + {author.name}   + {/each} + + {:else if column.key === 'series'} + + {book.series ? book.series.title : '—'} + + {:else if column.key === 'publisher'} + + {book.publisher ? book.publisher.name : '—'} + + {:else if column.key === 'published'} + + {book.published_date ? new Date(book.published_date).getFullYear() : '—'} + + {:else if column.key === 'pages'} + + {book.pages ?? '—'} + + {:else if column.key === 'format'} + {formats(book)} + {:else if column.key === 'size'} + + {formatFileSize(totalSize(book))} + + {:else if column.key === 'added'} + + {addedOn(book)} + + {:else if column.key === 'progress'} + + {#if book.progress?.completed} + Finished + {:else if book.progress?.percentage} + + + + + + {Math.round(book.progress.percentage * 100)}% + + + {:else} + Unread + {/if} + + {:else if column.key === 'missing'} + + {#each missing(book) as gap (gap)} + + {gap} + + {:else} + complete + {/each} + + {/if} + {/each} + + + + + + {/each} + + +
From 4c3fd66a56275856ba355a4b95e91bedd833f83a Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 11 Aug 2026 20:06:23 -0400 Subject: [PATCH 09/38] feat: rebuild the book page with the jacket layout --- frontend/src/lib/utils.ts | 46 + .../(library)/book/[bookId]/+page.svelte | 821 +++++++++--------- 2 files changed, 479 insertions(+), 388 deletions(-) diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 638d1d0..e200420 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -58,6 +58,52 @@ export function formatFileSize(bytes: number, decimals = 1, binary = true) { return `${formattedSize} ${units[exponent]}`; } +/** + * Presentation for a book identifier. + * + * The backend stores identifiers as free-form name/value pairs, so this only + * decides how to label and link the ones we recognise. Anything unknown falls + * back to the stored name and renders as plain text. + */ +const IDENTIFIER_TYPES: Record string }> = { + 'isbn-13': { label: 'ISBN-13', url: (v) => `https://openlibrary.org/isbn/${isbnDigits(v)}` }, + 'isbn-10': { label: 'ISBN-10', url: (v) => `https://openlibrary.org/isbn/${isbnDigits(v)}` }, + isbn: { label: 'ISBN', url: (v) => `https://openlibrary.org/isbn/${isbnDigits(v)}` }, + doi: { label: 'DOI', url: (v) => `https://doi.org/${encodeURI(v.trim())}` }, + asin: { label: 'ASIN', url: (v) => `https://www.amazon.com/dp/${encodeURIComponent(v.trim())}` } +}; + +/** Order the ones worth reading first; everything else keeps its own order. */ +const IDENTIFIER_ORDER = ['isbn-13', 'isbn-10', 'isbn', 'doi', 'asin']; + +function isbnDigits(value: string) { + return value.replace(/[^0-9Xx]/g, ''); +} + +function identifierKey(name: string) { + return name.trim().toLowerCase().replace(/[\s_]+/g, '-'); +} + +export function describeIdentifier(name: string, value: string) { + const known = IDENTIFIER_TYPES[identifierKey(name)]; + + return { + label: known?.label ?? name.toUpperCase(), + href: known?.url?.(value) + }; +} + +export function sortIdentifiers(identifiers: Record) { + return Object.entries(identifiers).sort(([a], [b]) => { + const ia = IDENTIFIER_ORDER.indexOf(identifierKey(a)); + const ib = IDENTIFIER_ORDER.indexOf(identifierKey(b)); + if (ia === -1 && ib === -1) return 0; + if (ia === -1) return 1; + if (ib === -1) return -1; + return ia - ib; + }); +} + export function getFileType(filename: string) { const parts = filename.split('.'); if (parts.length < 2) return 'UNKNOWN'; diff --git a/frontend/src/routes/(root)/(library)/book/[bookId]/+page.svelte b/frontend/src/routes/(root)/(library)/book/[bookId]/+page.svelte index 3502e99..1baa965 100644 --- a/frontend/src/routes/(root)/(library)/book/[bookId]/+page.svelte +++ b/frontend/src/routes/(root)/(library)/book/[bookId]/+page.svelte @@ -5,24 +5,24 @@ import * as Tooltip from '$lib/components/ui/tooltip/index'; import * as Field from '$lib/components/ui/field/index.js'; import { Checkbox } from '$lib/components/ui/checkbox/index.js'; - import { Button, buttonVariants } from '$lib/components/ui/button/index.js'; - import BookImage from '$lib/components/view/book-image.svelte'; + import { buttonVariants } from '$lib/components/ui/button/index.js'; + import BookCover from '$lib/components/view/book-cover.svelte'; import type { BookFile } from '$lib/schema'; import { Album, BookOpenCheck, BookOpenText, - CalendarDays, Download, - Link, - NotebookPen, - NotebookText, Pencil, PlusIcon, - Tags, Trash2 } from '@lucide/svelte'; - import { formatFileSize, getFileType } from '$lib/utils.js'; + import { + describeIdentifier, + formatFileSize, + getFileType, + sortIdentifiers + } from '$lib/utils.js'; import { getBookOperationsState } from '$lib/state/bookOperations.svelte.js'; import { getBookshelfState } from '$lib/state/bookshelf.svelte.js'; import { getLibraryState } from '$lib/state/library.svelte.js'; @@ -31,10 +31,13 @@ import * as Table from '$lib/components/ui/table/index.js'; import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js'; import ShelfCreateDialog from '$lib/components/forms/shelf-create-dialog.svelte'; + import { untrack } from 'svelte'; let { data } = $props(); - let book = $state(data.book); + // Seeded once, then kept in sync by the effect below — untrack says so + // explicitly rather than capturing the initial value and warning about it. + let book = $state(untrack(() => data.book)); $effect(() => { book = data.book; @@ -47,7 +50,20 @@ let deleteFiles = $state(true); let fileDeleteDialogOpen = $state(false); let fileToDelete = $state(); - let createShelfDialogOpen = $state(false) + let createShelfDialogOpen = $state(false); + + const percent = $derived( + book.progress?.percentage ? Math.round(book.progress.percentage * 100) : 0 + ); + + // The primary action states what it will actually do. + const readLabel = $derived( + book.progress?.completed + ? 'Read again' + : percent > 0 + ? `Continue reading · ${percent}%` + : 'Read' + ); function openBookInReader(file: BookFile) { if (getFileType(file.filename) === 'EPUB') @@ -56,53 +72,271 @@ if (getFileType(file.filename) === 'PDF') window.open(`/book/${book.id}/read/pdf/${file.id}`, '_blank', 'noopener,noreferrer'); } + + // On the band, the accent is the ground — invert the buttons against it. + const bandPrimary = + 'inline-flex items-center gap-2 rounded-lg bg-primary-foreground px-4 py-2 text-sm font-semibold text-primary tabular-nums transition-colors hover:bg-primary-foreground/90'; + const bandGhost = + 'inline-flex items-center gap-2 rounded-lg border border-primary-foreground/35 px-4 py-2 text-sm font-medium transition-colors hover:bg-primary-foreground/10'; -
-
- -
@@ -517,11 +560,13 @@ - { - const bookshelf = await bookshelfState.addBookshelf(name, libraryState.activeLibrary!.id, [book.id]) - book.lists.push(bookshelf) - createShelfDialogOpen = false + const bookshelf = await bookshelfState.addBookshelf(name, libraryState.activeLibrary!.id, [ + book.id + ]); + book.lists.push(bookshelf); + createShelfDialogOpen = false; }} /> From 0139f6f5eb66f7a9f0fd21b42d72bfc8c24d676c Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 11 Aug 2026 20:06:39 -0400 Subject: [PATCH 10/38] feat: extract built-in view presets and add filter chips --- .../lib/components/view/preset-chips.svelte | 27 +++++++ frontend/src/lib/presets.ts | 79 +++++++++++++++++++ .../library/[libraryId]/+page.svelte | 11 +-- .../(library)/library/[libraryId]/+page.ts | 46 +++++------ 4 files changed, 131 insertions(+), 32 deletions(-) create mode 100644 frontend/src/lib/components/view/preset-chips.svelte create mode 100644 frontend/src/lib/presets.ts diff --git a/frontend/src/lib/components/view/preset-chips.svelte b/frontend/src/lib/components/view/preset-chips.svelte new file mode 100644 index 0000000..a0319d9 --- /dev/null +++ b/frontend/src/lib/components/view/preset-chips.svelte @@ -0,0 +1,27 @@ + + + +
+ {#each BOOK_PRESETS as preset (preset.id)} + {@const active = bookCollection.isPresetActive(preset)} + + {/each} +
diff --git a/frontend/src/lib/presets.ts b/frontend/src/lib/presets.ts new file mode 100644 index 0000000..71d05a0 --- /dev/null +++ b/frontend/src/lib/presets.ts @@ -0,0 +1,79 @@ +/** + * Built-in views over a library. + * + * These are navigation, not content — a fixed set of lenses every library has, + * as distinct from shelves, which are named collections a user owns. Nothing + * here is persisted or user-editable. + * + * Defined once and consumed twice: the home route renders them as shelves, and + * the library view renders them as chips. Previously each home shelf was a + * hand-written query string in the loader, which is how `create_at` (missing a + * `d`) went unnoticed — that sort silently did nothing. + */ + +export interface BookPreset { + id: string; + label: string; + /** Shown as the shelf heading on the home page. */ + heading: string; + /** Filter values, keyed the way the books endpoint expects them. */ + filters: Record; + /** Backend sort field; omitted means "leave the current ordering alone". */ + orderBy?: string; + /** asc, desc, or random — the API's CustomOrderBy accepts all three. */ + sortOrder?: string; +} + +export const BOOK_PRESETS: BookPreset[] = [ + { + id: 'continue-reading', + label: 'Reading', + heading: 'Continue Reading', + filters: { progress: ['in_progress'] }, + orderBy: 'last_accessed', + sortOrder: 'desc' + }, + { + id: 'recently-added', + label: 'Recently added', + heading: 'Recently Added', + filters: { progress: ['unread'] }, + orderBy: 'created_at', + sortOrder: 'desc' + }, + { + id: 'discover', + label: 'Discover', + heading: 'Discover', + filters: { progress: ['unread'] }, + sortOrder: 'random' + }, + { + id: 'read-again', + label: 'Read again', + heading: 'Read Again', + filters: { progress: ['read'] }, + sortOrder: 'random' + } +]; + +export function getPreset(id: string) { + return BOOK_PRESETS.find((preset) => preset.id === id); +} + +/** + * Query string for the books endpoint, used by the home route's loader. + */ +export function presetQuery(preset: BookPreset, libraryId: string | number) { + const params = new URLSearchParams(); + params.set('libraries', String(libraryId)); + + for (const [key, values] of Object.entries(preset.filters)) { + for (const value of values) params.append(key, value); + } + + if (preset.orderBy) params.set('orderBy', preset.orderBy); + if (preset.sortOrder) params.set('sortOrder', preset.sortOrder); + + return params.toString(); +} diff --git a/frontend/src/routes/(root)/(library)/library/[libraryId]/+page.svelte b/frontend/src/routes/(root)/(library)/library/[libraryId]/+page.svelte index 7fbab30..a5ddd4b 100644 --- a/frontend/src/routes/(root)/(library)/library/[libraryId]/+page.svelte +++ b/frontend/src/routes/(root)/(library)/library/[libraryId]/+page.svelte @@ -14,13 +14,14 @@
- - - - + + {#each data.presets as preset (preset.id)} + + {/each}
- {#if data.recentlyAdded.length === 0 } + + {#if data.presets.every((preset) => data.shelves[preset.id].length === 0)}
diff --git a/frontend/src/routes/(root)/(library)/library/[libraryId]/+page.ts b/frontend/src/routes/(root)/(library)/library/[libraryId]/+page.ts index c7c8cac..c0c9c07 100644 --- a/frontend/src/routes/(root)/(library)/library/[libraryId]/+page.ts +++ b/frontend/src/routes/(root)/(library)/library/[libraryId]/+page.ts @@ -1,40 +1,32 @@ +import { BOOK_PRESETS, presetQuery } from '$lib/presets'; + export async function load({ params, fetch }) { try { - const continueReadingResponse = await fetch( - `/api/books?libraries=${params.libraryId}&progress=in_progress&orderBy=last_accessed&sortOrder=desc` + // The shelves are the built-in presets, fetched in parallel rather than + // one after another as before. + const responses = await Promise.all( + BOOK_PRESETS.map(async (preset) => { + const response = await fetch(`/api/books?${presetQuery(preset, params.libraryId)}`); + const result = await response.json(); + return [preset.id, result['items'] ?? []] as const; + }) ); - let results = await continueReadingResponse.json(); - let continueReading = results['items']; - const discoverResponse = await fetch( - `/api/books?libraries=${params.libraryId}&progress=unread&sortOrder=random` - ); - results = await discoverResponse.json(); - let discover = results['items']; - - const readAgainResponse = await fetch( - `/api/books?libraries=${params.libraryId}&progress=read&sortOrder=random` - ); - results = await readAgainResponse.json(); - let readAgain = results['items']; - - const recentlyAddedResponse = await fetch( - `/api/books?libraries=${params.libraryId}&orderBy=create_at&sortOrder=desc&progress=unread` - ); - results = await recentlyAddedResponse.json(); - let recentlyAdded = results['items']; + const shelves = Object.fromEntries(responses); return { - continueReading, - discover, - readAgain, - recentlyAdded + presets: BOOK_PRESETS, + shelves }; } catch (error) { console.error('Error fetching books from library: ', error); + + // Same shape as the success path, so the page renders its empty state + // instead of the caller having to guard every field. The previous + // {status, error} return was consumed by nothing. return { - status: error.status || 500, - error: error.message + presets: BOOK_PRESETS, + shelves: Object.fromEntries(BOOK_PRESETS.map((preset) => [preset.id, []])) }; } } From c09b8365fa0b1da53e5bbfc9681569c090548d95 Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 11 Aug 2026 20:06:39 -0400 Subject: [PATCH 11/38] feat: put the library view mode in the url via shallow routing --- .../lib/components/view/book-browser.svelte | 16 +++- .../lib/components/view/view-toggle.svelte | 80 ++++++++++-------- .../src/lib/state/bookCollection.svelte.ts | 82 +++++++++++++++++++ 3 files changed, 142 insertions(+), 36 deletions(-) diff --git a/frontend/src/lib/components/view/book-browser.svelte b/frontend/src/lib/components/view/book-browser.svelte index 665a744..3658ab0 100644 --- a/frontend/src/lib/components/view/book-browser.svelte +++ b/frontend/src/lib/components/view/book-browser.svelte @@ -12,7 +12,9 @@ import FilterButton from './filter-button.svelte'; import SortButton from './sort-button.svelte'; import ViewToggle from './view-toggle.svelte'; + import PresetChips from './preset-chips.svelte'; import BookTable from './book-table.svelte'; + import BookRows from './book-rows.svelte'; import BatchOperationsToolbar from './batch-operations-toolbar.svelte'; import { getBookCollectionState } from '$lib/state/bookCollection.svelte'; import { getBookSelectionState } from '$lib/state/bookSelection.svelte'; @@ -22,7 +24,6 @@ const bookCollection = getBookCollectionState(); const bookOps = getBookOperationsState(); - let view = $state('grid'); let sentinel = $state(); let scrollContainer = $state(null); @@ -66,7 +67,14 @@
{#if !selectionState.selectionModeActive} - + bookCollection.setView(next)} + /> + +
+ +
@@ -92,8 +100,10 @@
{:else if bookCollection.books.length > 0} - {#if view === 'grid'} + {#if bookCollection.view === 'grid'} + {:else if bookCollection.view === 'list'} + {:else} {/if} diff --git a/frontend/src/lib/components/view/view-toggle.svelte b/frontend/src/lib/components/view/view-toggle.svelte index c54d372..dca2b06 100644 --- a/frontend/src/lib/components/view/view-toggle.svelte +++ b/frontend/src/lib/components/view/view-toggle.svelte @@ -1,40 +1,54 @@ - - - - - - {#snippet child({ props })} - - - - {/snippet} - - -

Grid view

-
-
-
- - - - - {#snippet child({ props })} - - - - {/snippet} - - -

List view

-
-
-
+ { + // The group emits '' when the active item is pressed again; keep the + // current view rather than leaving the browser with nothing to render. + if (next) onValueChange(next as BookView); + }} + class={className} +> + {#each BOOK_VIEWS as view (view)} + {@const Icon = ICONS[view]} + + + + {#snippet child({ props })} + + + + {/snippet} + + +

{LABELS[view]}

+
+
+
+ {/each}
diff --git a/frontend/src/lib/state/bookCollection.svelte.ts b/frontend/src/lib/state/bookCollection.svelte.ts index 8064db6..8622564 100644 --- a/frontend/src/lib/state/bookCollection.svelte.ts +++ b/frontend/src/lib/state/bookCollection.svelte.ts @@ -3,10 +3,22 @@ import { goto, pushState, replaceState } from '$app/navigation'; import type { Book, DynamicFilterData, FilterOption, PaginatedResponse } from '$lib/schema'; import { page } from '$app/state'; import { BookOperationsState } from './bookOperations.svelte'; +import type { BookPreset } from '$lib/presets'; + +/** The browse views, shared with view-toggle.svelte so the two cannot drift. */ +export const BOOK_VIEWS = ['grid', 'list', 'table'] as const; + +export type BookView = (typeof BOOK_VIEWS)[number]; + +/** Anything unrecognised falls back to grid rather than blanking the page. */ +export function parseView(value: string | null | undefined): BookView { + return BOOK_VIEWS.includes(value as BookView) ? (value as BookView) : 'grid'; +} export class BookCollectionState { public sortOrder = $state(''); public orderBy = $state(''); + public view = $state('grid'); public filters = $state>({}); readonly hasActiveSort = $derived(this.orderBy !== 'title' || this.sortOrder !== 'asc'); @@ -58,6 +70,10 @@ export class BookCollectionState { this.orderBy = page.url.searchParams.get('orderBy') || 'title'; this.sortOrder = page.url.searchParams.get('sortOrder') || 'asc'; + // Read during SSR too, so a shared ?view=table link renders the table on + // the server rather than flashing the grid first. + this.view = parseView(page.url.searchParams.get('view')); + // Construct initial filters based on the page data this.filters = { authors: page.url.searchParams.getAll('authors'), @@ -68,6 +84,24 @@ export class BookCollectionState { }; } + /** + * Shallow routing: replaceState updates the URL and page.url without running + * any load function. Switching view changes presentation only, so it must not + * take the updateSearchParams path — that calls goto() and refetches the list. + * + * replaceState rather than pushState because a view is a preference, not a + * destination; Back should leave the page, not step through view changes. + */ + setView(next: BookView) { + if (next === this.view) return; + + this.view = next; + + const url = new URL(page.url); + url.searchParams.set('view', next); + replaceState(url, page.state); + } + updateSort(sortValue: string) { if (sortValue === this.orderBy) { this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc'; @@ -209,6 +243,54 @@ export class BookCollectionState { this.updateSearchParams(); } + /** + * A preset is a whole view, not an extra filter — applying one replaces the + * filters and the sort. Accumulating them instead produces empty results the + * moment two overlap (Reading plus Unread returns nothing) with no visible + * reason why. + */ + applyPreset(preset: BookPreset) { + Object.values(this.filters).forEach((val) => (val.length = 0)); + + for (const [key, values] of Object.entries(preset.filters)) { + this.filters[key] = [...values]; + } + + this.orderBy = preset.orderBy ?? 'title'; + this.sortOrder = preset.sortOrder ?? 'asc'; + + this.updateSearchParams(); + } + + /** Back to the unfiltered, title-sorted default. */ + clearView() { + Object.values(this.filters).forEach((val) => (val.length = 0)); + this.orderBy = 'title'; + this.sortOrder = 'asc'; + this.updateSearchParams(); + } + + /** + * Active only on an exact match. Adding a tag on top of a preset deselects + * the chip while keeping the filters — the chip stops claiming to describe a + * view it no longer describes. + */ + isPresetActive(preset: BookPreset) { + if (this.orderBy !== (preset.orderBy ?? 'title')) return false; + if (this.sortOrder !== (preset.sortOrder ?? 'asc')) return false; + + const active = Object.entries(this.filters).filter(([, values]) => values.length > 0); + const wanted = Object.entries(preset.filters); + if (active.length !== wanted.length) return false; + + return wanted.every(([key, values]) => { + const current = this.filters[key] ?? []; + return ( + current.length === values.length && values.every((value) => current.includes(value)) + ); + }); + } + isFilterSelected(filter: string, value: string) { return this.filters[filter]?.includes(value) || false; } From ef8e5e7fba79f87fd3222b4e9aa9ea529bf204d0 Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 11 Aug 2026 20:08:16 -0400 Subject: [PATCH 12/38] fix: stop icons remounting and shelves collapsing into nothing --- .../src/lib/components/layout/nav-main.svelte | 94 +++++++++++-------- .../lib/components/layout/theme-toggle.svelte | 23 ++--- .../src/lib/components/view/book-list.svelte | 57 ++++++----- 3 files changed, 99 insertions(+), 75 deletions(-) diff --git a/frontend/src/lib/components/layout/nav-main.svelte b/frontend/src/lib/components/layout/nav-main.svelte index 9f28114..71e101e 100644 --- a/frontend/src/lib/components/layout/nav-main.svelte +++ b/frontend/src/lib/components/layout/nav-main.svelte @@ -3,58 +3,74 @@ import * as Collapsible from '$lib/components/ui/collapsible/index.js'; import * as Sidebar from '$lib/components/ui/sidebar/index.js'; import { Badge } from "$lib/components/ui/badge/index.js"; + import { useSidebar } from '$lib/components/ui/sidebar/index.js'; import { getBookshelfState } from '$lib/state/bookshelf.svelte'; import { getLibraryState } from '$lib/state/library.svelte'; import { House, LibraryBig, Rows3, ChevronRightIcon } from '@lucide/svelte'; const libraryState = getLibraryState(); const bookshelfState = getBookshelfState(); + const sidebar = useSidebar(); - let items = $derived( - [ - { - title: 'Home', - url: `/library/${libraryState.activeLibrary?.id}`, - icon: House - }, - { - title: 'Library', - url: `/library/${libraryState.activeLibrary?.id}/view`, - icon: LibraryBig - }, - { - title: 'Shelves', - url: '#', - icon: Rows3, - shelves: [] - } - ].map((item) => ({ - ...item, - isActive: page.url.pathname === item.url - })) - ); + // Owned here so the collapsed rail can force it open when it expands the + // sidebar. Previously open={isActive}, which was always false — the Shelves + // item has no route of its own, so pathname never matched. + let shelvesOpen = $state(false); + + // Deliberately a plain const, not $derived. These objects hold component + // references, and `` is a dynamic component — if the array were + // rebuilt on every navigation the icons would remount, flashing and shifting + // layout. Only the url and active state need to be reactive, so they are + // computed per-item in the markup instead. + const items = [ + { title: 'Home', icon: House, path: (id?: number) => `/library/${id}` }, + { title: 'Library', icon: LibraryBig, path: (id?: number) => `/library/${id}/view` }, + { title: 'Shelves', icon: Rows3, path: () => '#', shelves: [] } + ]; +{#snippet shelvesTrigger(item: { title: string; icon: typeof House })} + + {item.title} + +{/snippet} + {#each items as item (item.title)} + {@const url = item.path(libraryState.activeLibrary?.id)} + {@const isActive = page.url.pathname === url} {#if 'shelves' in item} - + {#snippet child({ props })} - - {#snippet child({ props })} - - {#if item.icon} - - {/if} - {item.title} - - - {/snippet} - + {#if sidebar.state === 'collapsed'} + + { + sidebar.setOpen(true); + shelvesOpen = true; + }} + > + {@render shelvesTrigger(item)} + + {:else} + + {#snippet child({ props })} + + {@render shelvesTrigger(item)} + + {/snippet} + + {/if} {#each bookshelfState.getBookshelves(libraryState.activeLibrary!.id) ?? [] as shelf (shelf.id)} @@ -85,9 +101,9 @@ {:else} - + {#snippet child({ props })} - + {#if item.icon} {/if} diff --git a/frontend/src/lib/components/layout/theme-toggle.svelte b/frontend/src/lib/components/layout/theme-toggle.svelte index 66c3a62..75c339e 100644 --- a/frontend/src/lib/components/layout/theme-toggle.svelte +++ b/frontend/src/lib/components/layout/theme-toggle.svelte @@ -1,5 +1,5 @@ + - {#if mode.current === 'light'} - - {:else} - - {/if} + + - {#if mode.current === 'light'} -

Dark Mode

- {:else} -

Light Mode

- {/if} +

Dark Mode

+
diff --git a/frontend/src/lib/components/view/book-list.svelte b/frontend/src/lib/components/view/book-list.svelte index 022f55f..cffcffc 100644 --- a/frontend/src/lib/components/view/book-list.svelte +++ b/frontend/src/lib/components/view/book-list.svelte @@ -23,8 +23,10 @@ // Start observing resizeObserver.observe(scrollContainer); - // Initial check with delay - setTimeout(updateScrollState, 0); + // Measure synchronously. Reading scrollWidth/clientWidth forces layout, so + // the numbers are already accurate here — deferring to a macrotask just + // guaranteed one painted frame with the arrows in the wrong state. + updateScrollState(); // Cleanup return () => { @@ -33,12 +35,14 @@ }); const scrollLeft = () => { + if (!scrollContainer) return; scrollContainer.scrollBy({ left: -scrollContainer.clientWidth, behavior: 'smooth' }); // Update state after scroll setTimeout(updateScrollState, 100); }; const scrollRight = () => { + if (!scrollContainer) return; scrollContainer.scrollBy({ left: scrollContainer.clientWidth, behavior: 'smooth' }); // Update state after scroll setTimeout(updateScrollState, 100); @@ -61,29 +65,32 @@ {#if books.length > 0}
-

{title}

- {#if needsScroll} -
- - -
- {/if} +

{title}

+ +
+ + +
Date: Tue, 11 Aug 2026 20:08:16 -0400 Subject: [PATCH 13/38] fix: silence state_referenced_locally in vendored ui components --- .../ui/file-drop-zone/file-drop-zone.svelte | 14 +++++++++----- .../components/ui/sidebar/sidebar-provider.svelte | 3 ++- .../lib/components/ui/sidebar/sidebar-rail.svelte | 4 +++- .../components/ui/toggle-group/toggle-group.svelte | 11 +++++++++-- 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/frontend/src/lib/components/ui/file-drop-zone/file-drop-zone.svelte b/frontend/src/lib/components/ui/file-drop-zone/file-drop-zone.svelte index 03af46e..f046849 100644 --- a/frontend/src/lib/components/ui/file-drop-zone/file-drop-zone.svelte +++ b/frontend/src/lib/components/ui/file-drop-zone/file-drop-zone.svelte @@ -3,6 +3,7 @@ --> diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-rail.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-rail.svelte index d59c4be..789d101 100644 --- a/frontend/src/lib/components/ui/sidebar/sidebar-rail.svelte +++ b/frontend/src/lib/components/ui/sidebar/sidebar-rail.svelte @@ -2,6 +2,7 @@ import { cn, type WithElementRef } from '$lib/utils.js'; import type { HTMLAttributes } from 'svelte/elements'; import { useSidebar } from './context.svelte.js'; + import { untrack } from 'svelte'; let { ref = $bindable(null), @@ -13,7 +14,8 @@ contextKey?: string; } = $props(); - const sidebar = useSidebar(contextKey); + // contextKey selects which sidebar this rail drives; fixed per instance. + const sidebar = useSidebar(untrack(() => contextKey));
diff --git a/frontend/src/lib/components/view/batch-operations-toolbar.svelte b/frontend/src/lib/components/view/batch-operations-toolbar.svelte index db93256..76e4366 100644 --- a/frontend/src/lib/components/view/batch-operations-toolbar.svelte +++ b/frontend/src/lib/components/view/batch-operations-toolbar.svelte @@ -59,8 +59,7 @@ Shelves - - {#each bookshelfState.getBookshelves(libraryState.activeLibrary!.id) as shelf} + {#each bookshelfState.getBookshelves(libraryState.activeLibrary!.id) ?? [] as shelf (shelf.id)} book.lists.findIndex((sh) => sh.id === shelf.id) !== -1 From d8f8a0ab9577ab7907acca5891c42e9c53008431 Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 11 Aug 2026 20:08:16 -0400 Subject: [PATCH 15/38] docs: add TODO.md --- TODO.md | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 TODO.md diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..7989dea --- /dev/null +++ b/TODO.md @@ -0,0 +1,84 @@ +# TODO + +Known issues and deferred work. Agent-facing notes belong in the `AGENTS.md` files; +this is for things that are broken or missing and not yet scheduled. + +## Backend + +### Identifier extraction drops most ISBNs + +`backend/src/chitai/services/metadata_extractor.py` — `EpubExtractor._extract_identifiers` + +The EPUB path validates `DC:identifier` values verbatim: + +```python +for id in epub.get_metadata("DC", "identifier"): + if is_valid_isbn(id[0]): + ... +``` + +`is_valid_isbn` branches on `len(isbn)` being exactly 10 or 13, so anything carrying +formatting fails. Two consequences: + +- **Hyphenated ISBNs are silently dropped.** `978-0-486-28211-4` is 17 characters, so it + never reaches the checksum. Most EPUBs write ISBNs hyphenated, so the majority are lost. + The PDF path already does this correctly — `_extract_isbns` calls + `match.replace("-", "")` before validating. +- **`urn:isbn:` prefixes are dropped** for the same reason. This is a common EPUB form. + +Fix: normalise before validating — strip a leading `urn:isbn:`, then remove everything +that isn't `0-9` or `X`. Reuse the PDF path's approach rather than duplicating it. + +Note this only runs at upload, so fixing it changes nothing for books already imported. +A backfill would need to re-read the files on disk. + +### Non-ISBN identifiers are discarded + +Same function. Anything that isn't a valid ISBN is thrown away, including values EPUBs +routinely carry: `urn:uuid:…`, `calibre:…`, Google Books volume IDs and ASINs. + +The `Identifier` model is already generic (`name` + `value`, unique per book), so storing +them needs no schema change — only the extractor decides what survives. The frontend +already renders ISBN, ASIN and DOI as links and shows unknown types as plain values, so +anything stored will display sensibly. + +Worth adding at the same time: + +- A DOI regex (`10.\d{4,9}/\S+`) alongside the ISBN scan in `PdfExtractor._extract_isbns` + — academic PDFs carry one and it is the most useful identifier they have. +- Deriving ISBN-10 from ISBN-13 when only the latter is present. It is a pure checksum + conversion and doubles the chance of an external lookup matching. + +## Frontend + +### Cover dimensions are unknown until load + +`book-cover.svelte` renders covers at a fixed height with natural width so nothing is +cropped or distorted. Because the intrinsic size is not known ahead of time, the text +beside the cover settles once when the image loads. `min-width` bounds the movement but +does not remove it. + +Removing it properly means storing cover dimensions at ingest and emitting them as +`width`/`height` attributes so the browser reserves exact space. That is a model change +plus a migration. + +### No series navigation + +`Book` has `series` and `series_position`, and the detail page shows both, but there is no +way to reach the other volumes. The books list endpoint filters by author, publisher, tag, +shelf and progress — a `series` filter would need adding in +`backend/src/chitai/services/filters/book.py` and wiring through +`services/dependencies.py`, following the existing `AuthorFilter` pattern. + +### Book pages are sparse for most books + +EPUB metadata is thin: most imports arrive with a title, an author and nothing else. Two +independent directions, neither started: + +- **Use what exists.** "More by this author" (the list endpoint already accepts + `authors=`), and exposing `Book.created_at` and `BookProgress.updated_at` — both are in + the database, neither is on `BookRead` / `BookProgressRead`. +- **Fetch from outside.** Open Library or Google Books lookup by ISBN for descriptions and + covers. This is what actually fixes the sparseness, but it needs outbound requests, rate + limiting, a manual-vs-automatic decision, and a rule for not clobbering hand-edited + metadata. From d2892353cb199032052b116395c285462ebc52bf Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 11 Aug 2026 20:23:31 -0400 Subject: [PATCH 16/38] fix: reset the edit dialog when switching books --- .../lib/components/forms/edit-book/edit-book.svelte | 10 ++++++++++ .../lib/components/forms/edit-book/edit-cover.svelte | 3 ++- .../components/forms/edit-book/edit-metadata.svelte | 10 ++++++---- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/components/forms/edit-book/edit-book.svelte b/frontend/src/lib/components/forms/edit-book/edit-book.svelte index 1b0a5e0..cfcc300 100644 --- a/frontend/src/lib/components/forms/edit-book/edit-book.svelte +++ b/frontend/src/lib/components/forms/edit-book/edit-book.svelte @@ -9,8 +9,17 @@ let { book, open = $bindable() }: { book?: Book; open: boolean } = $props(); + {#if book} + {#key book.id} @@ -35,5 +44,6 @@ + {/key} {/if} diff --git a/frontend/src/lib/components/forms/edit-book/edit-cover.svelte b/frontend/src/lib/components/forms/edit-book/edit-cover.svelte index db2578c..86b31f4 100644 --- a/frontend/src/lib/components/forms/edit-book/edit-cover.svelte +++ b/frontend/src/lib/components/forms/edit-book/edit-cover.svelte @@ -18,7 +18,8 @@ let { book, open = $bindable() }: { book: Book; open: boolean } = $props(); let formEl = $state(); - let coverImagePreview = $state(`/api/${book.cover_image}`); + // Seeded once per mount — see the key in edit-book.svelte + let coverImagePreview = $state(untrack(() => `/api/${book.cover_image}`)); let autoUploadOnDrop = $state(true); const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => { diff --git a/frontend/src/lib/components/forms/edit-book/edit-metadata.svelte b/frontend/src/lib/components/forms/edit-book/edit-metadata.svelte index 7b935be..5345d6c 100644 --- a/frontend/src/lib/components/forms/edit-book/edit-metadata.svelte +++ b/frontend/src/lib/components/forms/edit-book/edit-metadata.svelte @@ -14,10 +14,12 @@ let { book, open = $bindable() }: { book: Book; open: boolean } = $props(); - let authors = $state(book.authors.map((author) => author.name) || []); - let tags = $state(book.tags.map((tag) => tag.name) || []); - let identifierKeys = $state(Object.keys(book.identifiers)); - let identifierValues = $state(Object.values(book.identifiers)); + // Seeded once per mount; edit-book.svelte keys this form on book.id so a + // different book gets a fresh form rather than the previous book's values. + let authors = $state(untrack(() => book.authors.map((author) => author.name) || [])); + let tags = $state(untrack(() => book.tags.map((tag) => tag.name) || [])); + let identifierKeys = $state(untrack(() => Object.keys(book.identifiers))); + let identifierValues = $state(untrack(() => Object.values(book.identifiers))); function handleAddIdentifier() { // Add empty strings to both arrays From 2aba90f910a5165e658bc7c846958fefbfc2fa30 Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 11 Aug 2026 20:23:31 -0400 Subject: [PATCH 17/38] chore: clear epubjs type import and empty server load --- frontend/src/lib/components/reader/epub-reader.svelte | 2 +- frontend/src/routes/(root)/settings/libraries/+page.server.ts | 0 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 frontend/src/routes/(root)/settings/libraries/+page.server.ts diff --git a/frontend/src/lib/components/reader/epub-reader.svelte b/frontend/src/lib/components/reader/epub-reader.svelte index cb88d22..84a630a 100644 --- a/frontend/src/lib/components/reader/epub-reader.svelte +++ b/frontend/src/lib/components/reader/epub-reader.svelte @@ -4,7 +4,7 @@ import { browser } from '$app/environment'; import { onDestroy, onMount } from 'svelte'; - import { Book, Rendition } from 'epubjs'; + import { Book, type Rendition } from 'epubjs'; import '../../../app.css'; import * as Sidebar from '$lib/components/ui/sidebar/index'; diff --git a/frontend/src/routes/(root)/settings/libraries/+page.server.ts b/frontend/src/routes/(root)/settings/libraries/+page.server.ts deleted file mode 100644 index e69de29..0000000 From 49f94f9ee158ce5214f89adafef36a424588dd6d Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 11 Aug 2026 21:46:50 -0400 Subject: [PATCH 18/38] fix: mount ModeWatcher and Toaster at the root layout The reader and login pages use +layout@ breakouts to escape the (root) shell, so anything mounted in (root)/+layout.svelte never reaches them. ModeWatcher was mounted there and duplicated in login/+layout@.svelte, which left the reader's theme toggle inert: it flipped the store but nothing wrote .dark onto . Mount both once at the top-level +layout.svelte instead, which every route inherits regardless of breakouts. --- frontend/src/routes/(root)/+layout.svelte | 4 ---- frontend/src/routes/+layout.svelte | 10 ++++++++++ frontend/src/routes/login/+layout@.svelte | 4 ---- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/frontend/src/routes/(root)/+layout.svelte b/frontend/src/routes/(root)/+layout.svelte index 6e7a831..dd34fd2 100644 --- a/frontend/src/routes/(root)/+layout.svelte +++ b/frontend/src/routes/(root)/+layout.svelte @@ -11,9 +11,7 @@ import { setThemeState } from '$lib/theme/theme.svelte'; import type { ThemeConfig } from '$lib/theme/presets'; - import { ModeWatcher } from 'mode-watcher'; import { untrack, type Snippet } from 'svelte'; - import { Toaster } from 'svelte-sonner'; let { data, @@ -40,8 +38,6 @@ }); - -
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 1cbf3fe..4e5d13d 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,6 +1,8 @@ @@ -9,6 +11,14 @@ + + + +
diff --git a/frontend/src/routes/login/+layout@.svelte b/frontend/src/routes/login/+layout@.svelte index 376b8d7..5d2becf 100644 --- a/frontend/src/routes/login/+layout@.svelte +++ b/frontend/src/routes/login/+layout@.svelte @@ -1,8 +1,6 @@ - - - - - Chapters - - - {#each chapters as chapter} - - - {#snippet child({ props })} - {chapter.label} - {/snippet} - - - {/each} - - - - - - diff --git a/frontend/src/routes/(root)/(library)/book/[bookId]/read/+layout@.svelte b/frontend/src/routes/(root)/(library)/book/[bookId]/read/+layout@.svelte index 01e30c7..914b4c5 100644 --- a/frontend/src/routes/(root)/(library)/book/[bookId]/read/+layout@.svelte +++ b/frontend/src/routes/(root)/(library)/book/[bookId]/read/+layout@.svelte @@ -1,7 +1,4 @@ diff --git a/frontend/src/routes/(root)/(library)/book/[bookId]/read/pdf/[fileId]/+page.svelte b/frontend/src/routes/(root)/(library)/book/[bookId]/read/pdf/[fileId]/+page.svelte index c3c1b27..635dbc0 100644 --- a/frontend/src/routes/(root)/(library)/book/[bookId]/read/pdf/[fileId]/+page.svelte +++ b/frontend/src/routes/(root)/(library)/book/[bookId]/read/pdf/[fileId]/+page.svelte @@ -1,42 +1,99 @@ -
- -
+ + {data?.title ? `${data.title} — Chitai` : 'Reader — Chitai'} + - +
+
diff --git a/frontend/src/routes/(root)/(library)/book/[bookId]/read/pdf/[fileId]/+page.ts b/frontend/src/routes/(root)/(library)/book/[bookId]/read/pdf/[fileId]/+page.ts new file mode 100644 index 0000000..055de42 --- /dev/null +++ b/frontend/src/routes/(root)/(library)/book/[bookId]/read/pdf/[fileId]/+page.ts @@ -0,0 +1,13 @@ +import { error } from '@sveltejs/kit'; + +export async function load({ fetch, params }) { + const response = await fetch(`/api/books/${params.bookId}`); + + if (!response.ok) { + error(response.status, 'This book could not be loaded.'); + } + + const book = await response.json(); + + return { title: book.title }; +} From ee46f5568c21b34b1f668d3cded56597f90908e5 Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 11 Aug 2026 21:47:12 -0400 Subject: [PATCH 20/38] fix: resume epub progress from the fields the API returns The load function read progress.epub_loc and progress.progress, neither of which BookProgressRead carries, so resuming never worked and every open started at page one. Read epub_cfi and percentage instead. Fail loudly when the book cannot be fetched. The previous version caught everything and returned {status, error} that nothing consumed, so a missing book produced a reader sitting on its spinner forever. Check response.ok before parsing too: fetch resolves on a 404, and arrayBuffer() happily returns the error page, which only surfaced later as an opaque epub parse error. Give the reader a working sidebar, a theme that survives the book's own CSS, and an error state with a retry. The theme is registered rather than applied as bare overrides because most EPUBs ship their own body colours and win otherwise, which is why the page stayed white against a dark UI. --- .../lib/components/reader/epub-reader.svelte | 487 +++++++++++------- .../[bookId]/read/epub/[fileId]/+page.svelte | 29 +- .../book/[bookId]/read/epub/[fileId]/+page.ts | 40 +- 3 files changed, 329 insertions(+), 227 deletions(-) diff --git a/frontend/src/lib/components/reader/epub-reader.svelte b/frontend/src/lib/components/reader/epub-reader.svelte index 84a630a..f197745 100644 --- a/frontend/src/lib/components/reader/epub-reader.svelte +++ b/frontend/src/lib/components/reader/epub-reader.svelte @@ -1,272 +1,311 @@ - - + + +

Chapters

+
- Chapters - {#each chapters as chapter} + {#each chapters as chapter (chapter.href)} {#if chapter.subitems.length > 0}
- await navigateToChapter(chapter)} - > + navigateToChapter(chapter)}> {chapter.label}
- {#each chapter.subitems as subchapter} + {#each chapter.subitems as subchapter (subchapter.href)} await navigateToChapter(subchapter)} + onclick={() => navigateToChapter(subchapter)} > - {subchapter.label} + + {subchapter.label} + {/each} @@ -275,10 +314,7 @@
{:else} - await navigateToChapter(chapter)} - > + navigateToChapter(chapter)}> {chapter.label} @@ -286,68 +322,119 @@ {/each}
-
-
-
- {#if browser} - {#if !isReaderVisible} -
- -
+ +
+ +
+ + + (isSidebarOpen = !isSidebarOpen)} + disabled={!isReaderVisible} + > + + +

Chapters

+
+
+ +
+ {title || 'Reader'} + + + {#if isReaderVisible} + {/if} -
-
- -
+ +
-
- -
- + {#if loadError} + +
+
+ +

This book wouldn't open

+

{loadError}

+
+ + + Back to book + +
+ {:else if !isReaderVisible} +
+ + Opening… +
{/if} + + +
+ + +
+ + +
+
+
+ +
+ +
+ ` + + this.#top = this.#root.getElementById('top') + this.#background = this.#root.getElementById('background') + this.#container = this.#root.getElementById('container') + this.#header = this.#root.getElementById('header') + this.#footer = this.#root.getElementById('footer') + + this.#observer.observe(this.#container) + const debouncedScroll = debounce(() => { + if (this.scrolled && !this.#isAnimating) { + // Skip entirely while stabilizing — preserve #justAnchored + // so the first post-stabilization fire still sees it. + if (this.#stabilizing) return + if (this.#justAnchored) this.#justAnchored = false + else this.#afterScroll('scroll') + // Backward preloading is handled eagerly in the (non-debounced) + // scroll listener below, mirroring the forward buffer. + } else if (!this.scrolled) { + this.#afterScroll('container-scroll') + } + }, 250) + this.#container.addEventListener('scroll', () => { + if (!this.#isAnimating) this.dispatchEvent(new Event('scroll')) + // Keep the per-view backgrounds glued to the content while a swipe + // drag scrolls the container (no animation runs then). During the + // snap animation #isAnimating is set and the destination background + // is already in place, so the rebuild is skipped. + if (!this.scrolled && !this.#isAnimating) this.#replaceBackground() + // Preload forward when fewer than minPages ahead. Skip while a finger + // drag is in progress: loading a section runs columnize/expand on the + // main thread, which drops frames mid-swipe (readest#4785). The buffer + // is still 4+ pages deep during a one-page drag, and the scroll that + // settles the gesture re-fires this with the finger already up, so the + // top-up just moves off the active drag instead of being skipped. + if (!this.noPreload && !this.noContinuousScroll && !this.#filling + && !this.#stabilizing && !this.#touchScrolled) { + const minPages = 5 + const pagesAhead = this.size > 0 + ? Math.floor((this.#renderedViewSize - this.#renderedEnd) / this.size) + : 0 + if (pagesAhead < minPages) { + const sorted = this.#sortedViews + const lastIndex = sorted[sorted.length - 1]?.[0] + if (lastIndex != null) { + const nextIdx = this.#adjacentIndex(1, lastIndex) + if (nextIdx != null && !this.#views.has(nextIdx) && this.#isSameDirection(nextIdx)) { + this.#filling = true + this.#loadAdjacentSection(nextIdx) + .finally(() => { + this.#filling = false + this.dispatchEvent(new Event('stabilized')) + }) + } + } + } + } + // Preload backward when fewer than minPages behind, mirroring the + // forward buffer so scrolling up never dead-ends at the top with the + // previous section unloaded (readest/readest#4112). The + // #loadAdjacentSection scroll compensation keeps the viewport + // anchored as the section is inserted above. + if (this.scrolled && !this.noPreload && !this.noContinuousScroll + && !this.#filling && !this.#stabilizing) { + const minPages = 5 + const pagesBehind = this.size > 0 + ? Math.floor(this.#renderedStart / this.size) + : 0 + if (pagesBehind < minPages) { + const sorted = this.#sortedViews + const firstIndex = sorted[0]?.[0] + if (firstIndex != null) { + const prevIdx = this.#adjacentIndex(-1, firstIndex) + if (prevIdx != null && !this.#views.has(prevIdx) && this.#isSameDirection(prevIdx)) { + this.#filling = true + this.#loadAdjacentSection(prevIdx) + .finally(() => { + this.#filling = false + this.dispatchEvent(new Event('stabilized')) + }) + } + } + } + } + debouncedScroll() + }) + + const opts = { passive: false } + this.addEventListener('touchstart', this.#onTouchStart.bind(this), opts) + this.addEventListener('touchmove', this.#onTouchMove.bind(this), opts) + this.addEventListener('touchend', this.#onTouchEnd.bind(this)) + this.addEventListener('touchcancel', this.#onTouchCancel.bind(this)) + this.addEventListener('load', ({ detail: { doc } }) => { + doc.addEventListener('touchstart', this.#onTouchStart.bind(this), opts) + doc.addEventListener('touchmove', this.#onTouchMove.bind(this), opts) + doc.addEventListener('touchend', this.#onTouchEnd.bind(this)) + doc.addEventListener('touchcancel', this.#onTouchCancel.bind(this)) + }) + + this.addEventListener('relocate', ({ detail }) => { + if (detail.reason === 'selection') setSelectionTo(this.#anchor, 0) + else if (detail.reason === 'navigation') { + if (this.#anchor === 1) setSelectionTo(detail.range, 1) + else if (typeof this.#anchor === 'number') + setSelectionTo(detail.range, -1) + else setSelectionTo(this.#anchor, -1) + } + }) + const checkPointerSelection = debounce((range, sel) => { + if (!sel.rangeCount) return + const selRange = sel.getRangeAt(0) + const backward = selectionIsBackward(sel) + if (backward && selRange.compareBoundaryPoints(Range.START_TO_START, range) < 0) + this.prev() + else if (!backward && selRange.compareBoundaryPoints(Range.END_TO_END, range) > 0) + this.next() + }, 700) + this.addEventListener('load', ({ detail: { doc } }) => { + let isPointerSelecting = false + doc.addEventListener('pointerdown', () => isPointerSelecting = true) + doc.addEventListener('pointerup', () => isPointerSelecting = false) + let isKeyboardSelecting = false + doc.addEventListener('keydown', () => isKeyboardSelecting = true) + doc.addEventListener('keyup', () => isKeyboardSelecting = false) + doc.addEventListener('selectionchange', () => { + if (this.scrolled) return + const range = this.#lastVisibleRange + if (!range) return + const sel = doc.getSelection() + if (!sel.rangeCount) return + // FIXME: this won't work on Android WebView, disable for now + if (!isPointerSelecting && isPointerSelecting && sel.type === 'Range') + checkPointerSelection(range, sel) + else if (isKeyboardSelecting) { + const selRange = sel.getRangeAt(0).cloneRange() + const backward = selectionIsBackward(sel) + if (!backward) selRange.collapse() + this.#scrollToAnchor(selRange) + } + }) + doc.addEventListener('focusin', e => { + if (this.scrolled) return null + if (this.#container && this.#container.contains(e.target)) { + // NOTE: `requestAnimationFrame` is needed in WebKit + requestAnimationFrame(() => this.#scrollToAnchor(e.target)) + } + }) + }) + + this.#mediaQueryListener = () => { + const view = this.#primaryView + if (!view) return + this.#replaceBackground() + } + this.#mediaQuery.addEventListener('change', this.#mediaQueryListener) + } + get #primaryView() { + return this.#views.get(this.#primaryIndex) + } + get #sortedViews() { + return [...this.#views.entries()].sort(([a], [b]) => a - b) + } + get primaryIndex() { + return this.#primaryIndex + } + setAttribute(name, value) { + // The scrolled-mode scroll handler is debounced, so #anchor and + // #primaryIndex can lag behind the user's actual viewport by up to + // ~250ms. Toggling out of scrolled mode within that window made + // render() restore the stale anchor — reverting the position to a + // previously visible section. Flush the pending scroll state here, + // before the attribute change so the layout is still in scrolled + // mode and `this.scrolled` (which reads the attribute) is still true. + if (name === 'flow' + && this.scrolled + && String(value) !== 'scrolled' + && this.#views.size > 0) { + this.#flushScrolledState() + } + super.setAttribute(name, value) + } + #flushScrolledState() { + if (this.#views.size > 1) this.#detectPrimaryView() + const result = this.#getVisibleRange() + if (result?.range && !result.range.collapsed) this.#anchor = result.range + } + attributeChangedCallback(name, _, value) { + switch (name) { + case 'flow': + this.render() + break + case 'gap': + case 'margin-top': + case 'margin-bottom': + case 'margin-left': + case 'margin-right': + case 'max-block-size': + case 'max-column-count': + this.#top.style.setProperty('--_' + name, value) + this.render() + break + case 'max-inline-size': + // needs explicit `render()` as it doesn't necessarily resize + this.#top.style.setProperty('--_' + name, value) + this.render() + break + case 'no-continuous-scroll': + if (this.noContinuousScroll) { + for (const [i] of this.#views) { + if (i !== this.#primaryIndex) this.#destroyView(i) + } + } + break + } + } + open(book) { + this.bookDir = book.dir + this.sections = book.sections + book.transformTarget?.addEventListener('data', ({ detail }) => { + if (detail.type !== 'text/css') return + detail.data = Promise.resolve(detail.data).then(data => data + // unprefix as most of the props are (only) supported unprefixed + .replace(/([{\s;])-epub-/gi, '$1') + // `page-break-*` unsupported in columns; replace with `column-break-*` + .replace(/page-break-(after|before|inside)\s*:/gi, (_, x) => + `-webkit-column-break-${x}:`) + .replace(/break-(after|before|inside)\s*:\s*(avoid-)?page/gi, (_, x, y) => + `break-${x}: ${y ?? ''}column`)) + }) + } + #createView(index) { + // Destroy existing view for this index if any + const existing = this.#views.get(index) + if (existing) { + existing.destroy() + this.#container.removeChild(existing.element) + this.#views.delete(index) + } + const view = new View({ + container: this, + onExpand: () => { + // Only the primary view's resize should adjust scroll; + // non-primary views (preloaded/adjacent) must not scroll + if (this.#filling || this.#stabilizing || this.scrolled) return + if (this.#primaryIndex === index) + this.#scrollToAnchor(this.#anchor) + }, + }) + this.#views.set(index, view) + const sorted = this.#sortedViews + const myPos = sorted.findIndex(([i]) => i === index) + const nextEntry = sorted[myPos + 1] + if (nextEntry) this.#container.insertBefore(view.element, nextEntry[1].element) + else this.#container.append(view.element) + this.#syncA11y() + return view + } + // Hide off-screen pre-loaded views from the accessibility tree so + // screen-reader swipe-next does not wander into them (which would land + // several pages into the next section instead of its first paragraph). + // + // Only `aria-hidden` is used — `inert` would also block pointer events + // and text selection, which breaks visible non-primary views such as + // the right column of a dual-page spread when each column belongs to + // a different section (readest/readest#4243, readest/readest#4259). + // + // Visible non-primary views stay exposed to assistive tech because a + // sighted user can read them on the same spread. + #syncA11y() { + const containerRect = this.#container.getBoundingClientRect() + for (const [index, view] of this.#views) { + const isPrimary = index === this.#primaryIndex + const isVisible = isPrimary + || isViewVisibleInContainer( + view.element.getBoundingClientRect(), containerRect) + if (isVisible) view.element.removeAttribute('aria-hidden') + else view.element.setAttribute('aria-hidden', 'true') + } + } + #destroyView(index) { + const view = this.#views.get(index) + if (!view) return + view.destroy() + this.#container.removeChild(view.element) + this.#views.delete(index) + this.sections[index]?.unload?.() + } + #destroyAllViews() { + for (const [index] of this.#views) this.#destroyView(index) + } + #clearViewsExcept(keepIndices) { + for (const [index] of this.#views) { + if (!keepIndices.has(index)) this.#destroyView(index) + } + } + // Check if a section has the same writing direction as current primary. + // Returns true if same or unknown (not yet cached). + #isSameDirection(index) { + if (!this.#directionCache.has(index)) return true + return this.#directionCache.get(index) === this.#vertical + } + // Read the theme/texture style off the primary section's and return a + // resolver that maps a view's raw background onto the active theme. This is a + // forced style read (getComputedStyle), so callers in the animation hot path + // do it once via #computePaginatedBgContext rather than every frame. + #readBackgroundStyle(doc) { + const htmlStyle = doc.defaultView.getComputedStyle(doc.documentElement) + const themeBgColor = htmlStyle.getPropertyValue('--theme-bg-color') + const overrideColor = htmlStyle.getPropertyValue('--override-color') === 'true' + const bgTextureId = htmlStyle.getPropertyValue('--bg-texture-id') + const isDarkMode = htmlStyle.getPropertyValue('color-scheme') === 'dark' + const fallbackBg = themeBgColor || '' + const hasTexture = !!bgTextureId && bgTextureId !== 'none' + + const resolveBackground = (background) => { + if (!background) return fallbackBg + if (themeBgColor) { + const parsed = background.split(/\s(?=(?:url|rgb|hsl|#[0-9a-fA-F]{3,6}))/) + if ((isDarkMode || overrideColor) && (bgTextureId === 'none' || !bgTextureId)) { + parsed[0] = themeBgColor + } + return parsed.join(' ') + } + return background + } + return { fallbackBg, hasTexture, resolveBackground } + } + // Snapshot every input #paintPaginatedBackground needs that stays constant for + // the duration of a scroll animation: the theme/texture style, the + // background+container geometry, and each rendered view's size and resolved + // background. Returns null when there is nothing to paint (no primary doc, + // backgrounds disabled, or scrolled mode). Built once per animation so the + // per-frame repaint never re-runs getComputedStyle or one + // getBoundingClientRect per view — those forced reads, multiplied by the views + // preloaded at a chapter boundary, are what dropped frames mid-swipe + // (readest#4785). + #computePaginatedBgContext() { + const doc = this.#primaryView?.document + if (!doc?.documentElement) return null + if (this.noBackground) return null + if (this.scrolled) return null + const { fallbackBg, hasTexture, resolveBackground } = this.#readBackgroundStyle(doc) + const bgRect = this.#background.getBoundingClientRect() + const containerRect = this.#container.getBoundingClientRect() + const startEdge = this.#vertical ? 'top' : 'left' + const bgSize = bgRect[this.sideProp] + const inset = containerRect[startEdge] - bgRect[startEdge] + const containerSize = containerRect[this.sideProp] + const views = this.#sortedViews.map(([, view]) => ({ + size: view.element.getBoundingClientRect()[this.sideProp], + bg: textureAwareBackground(resolveBackground(view.docBackground), hasTexture), + })) + return { fallbackBg, hasTexture, bgSize, inset, containerSize, views } + } + // Paint one full-bleed background segment per rendered view from a previously + // computed context, positioned so each tracks its content on screen at the + // given scroll position. Rebuilding on every scroll keeps the backgrounds + // glued to the content during a swipe drag — so when two sections with + // different backgrounds are both visible, each half shows its own colour + // instead of one flat colour flashing across the viewport. Only writes layout + // (no reads), so it is safe to run every animation frame. + #paintPaginatedBackground(ctx, atPosition) { + // Reset any inline backgrounds left over from a previous mode so the + // host's texture isn't occluded after toggling. + this.#background.style.background = '' + for (const [, view] of this.#sortedViews) { + view.element.style.background = '' + } + const scrollPos = Math.abs(atPosition ?? this.#renderedStart) + const segments = computeBackgroundSegments( + ctx.views, scrollPos, ctx.bgSize, ctx.inset, ctx.containerSize) + + this.#background.innerHTML = '' + this.#background.style.display = '' + // Under a texture, leave the container transparent so the host texture + // shows through the gaps a transparent page no longer fills (readest#4399). + this.#background.style.background = ctx.hasTexture ? '' : ctx.fallbackBg + + const posProp = this.#vertical ? 'top' : 'left' + const sizeProp = this.#vertical ? 'height' : 'width' + const crossPosProp = this.#vertical ? 'left' : 'top' + const crossSizeProp = this.#vertical ? 'width' : 'height' + for (const { start, size, bg } of segments) { + const seg = document.createElement('div') + seg.style.position = 'absolute' + seg.style[posProp] = `${start}px` + seg.style[sizeProp] = `${size}px` + seg.style[crossPosProp] = '0' + seg.style[crossSizeProp] = '100%' + seg.style.background = bg + seg.style.backgroundAttachment = 'initial' + this.#background.appendChild(seg) + } + } + // Update the #background grid so each column shows the correct section's + // background. Pass atPosition to pre-compute for a destination scroll + // position (e.g. before an animation starts). During a scroll animation the + // invariant context is snapshotted in #bgAnimContext and reused here so the + // per-frame repaint stays read-free. + #replaceBackground(atPosition) { + const doc = this.#primaryView?.document + if (!doc?.documentElement) return + if (this.noBackground) return + + if (this.scrolled) { + // In scrolled mode, set background directly on each view element + // so it scrolls with the content. The static #background provides + // the fallback color for margins and gaps between views. + const { fallbackBg, hasTexture, resolveBackground } = this.#readBackgroundStyle(doc) + this.#background.style.background = '' + this.#background.innerHTML = '' + this.#background.style.display = '' + this.#background.style.background = hasTexture ? '' : fallbackBg + for (const [, view] of this.#sortedViews) { + const resolved = resolveBackground(view.docBackground) + view.element.style.background = textureAwareBackground(resolved, hasTexture) + } + return + } + + const ctx = this.#bgAnimContext ?? this.#computePaginatedBgContext() + if (!ctx) return + this.#paintPaginatedBackground(ctx, atPosition) + } + #beforeRender({ vertical, rtl }) { + // If writing-mode is about to change, destroy all non-primary + // views BEFORE updating global state. This prevents stale views + // with the wrong direction from remaining in the container while + // flex-direction / scrollProp / sideProp flip. + if (this.#rendered && vertical !== this.#vertical) { + for (const [i] of this.#views) { + if (i !== this.#primaryIndex) this.#destroyView(i) + } + } + this.#vertical = vertical + this.#rtl = rtl + this.#top.classList.toggle('vertical', vertical) + this.#container.classList.toggle('vertical', vertical) + + const style = getComputedStyle(this.#top) + const maxInlineSize = parseFloat(style.getPropertyValue('--_max-inline-size')) + const maxColumnCount = parseInt(style.getPropertyValue('--_max-column-count-spread')) + const marginTop = parseFloat(style.getPropertyValue('--_margin-top')) + const marginRight = parseFloat(style.getPropertyValue('--_margin-right')) + const marginBottom = parseFloat(style.getPropertyValue('--_margin-bottom')) + const marginLeft = parseFloat(style.getPropertyValue('--_margin-left')) + this.#marginTop = marginTop + this.#marginBottom = marginBottom + + // Compute the column count from the host (Paginator) size rather than + // the #container size. The container width depends on --_column-count + // via the grid template (the outer 1fr tracks have a non-zero min for + // multi-column spreads), so deriving the column count from container + // size at threshold widths creates a feedback loop where the layout + // oscillates between 1 and 2 columns on resize. + const flow = this.getAttribute('flow') + const hostRect = this.getBoundingClientRect() + const hostSize = vertical ? hostRect.height : hostRect.width + const divisor = flow === 'scrolled' + ? 1 + : Math.min( + maxColumnCount + (vertical ? 1 : 0), + Math.ceil(Math.floor(hostSize) / Math.floor(maxInlineSize)), + ) + // Set --_column-count BEFORE measuring the container so the read + // below reflects the grid template that will actually be used. + this.#top.style.setProperty('--_column-count', divisor) + + const { width, height } = this.#container.getBoundingClientRect() + const size = vertical ? height : width + + const g = parseFloat(style.getPropertyValue('--_gap')) / 100 + // The gap will be a percentage of the #container, not the whole view. + // This means the outer padding will be bigger than the column gap. Let + // `a` be the gap percentage. The actual percentage for the column gap + // will be (1 - a) * a. Let us call this `b`. + // + // To make them the same, we start by shrinking the outer padding + // setting to `b`, but keep the column gap setting the same at `a`. Then + // the actual size for the column gap will be (1 - b) * a. Repeating the + // process again and again, we get the sequence + // x₁ = (1 - b) * a + // x₂ = (1 - x₁) * a + // ... + // which converges to x = (1 - x) * a. Solving for x, x = a / (1 + a). + // So to make the spacing even, we must shrink the outer padding with + // f(x) = x / (1 + x). + // But we want to keep the outer padding, and make the inner gap bigger. + // So we apply the inverse, f⁻¹ = -x / (x - 1) to the column gap. + const gap = -g / (g - 1) * size + + if (flow === 'scrolled') { + // FIXME: vertical-rl only, not -lr + this.setAttribute('dir', vertical ? 'rtl' : 'ltr') + this.#top.style.padding = '0' + const columnWidth = maxInlineSize + + this.heads = null + this.feet = null + this.#header.replaceChildren() + this.#footer.replaceChildren() + + this.columnCount = 1 + this.#replaceBackground() + + const layout = { width, height, flow, marginTop, marginRight, marginBottom, marginLeft, gap, columnWidth, columnCount: 1 } + this.#lastLayout = layout + return layout + } + + const columnWidth = vertical + ? (size / divisor - marginTop * 1.5 - marginBottom * 1.5) + : (size / divisor - gap - marginRight / 2 - marginLeft / 2) + // `dir` mirrors the horizontal scroll coordinates (negative scrollLeft + // for RTL). Vertical books page along scrollTop, which never flips, so + // an RTL writing mode must not reverse the host grid there. + this.setAttribute('dir', rtl && !vertical ? 'rtl' : 'ltr') + + // set background to `doc` background + // this is needed because the iframe does not fill the whole element + this.columnCount = divisor + this.#replaceBackground() + + const marginalDivisor = vertical + ? Math.min(2, Math.ceil(Math.floor(width) / Math.floor(maxInlineSize))) + : divisor + const marginalStyle = { + gridTemplateColumns: `repeat(${marginalDivisor}, 1fr)`, + gap: `${gap}px`, + direction: this.bookDir === 'rtl' ? 'rtl' : 'ltr', + } + Object.assign(this.#header.style, marginalStyle) + Object.assign(this.#footer.style, marginalStyle) + const heads = makeMarginals(marginalDivisor, 'head') + const feet = makeMarginals(marginalDivisor, 'foot') + this.heads = heads.map(el => el.children[0]) + this.feet = feet.map(el => el.children[0]) + this.#header.replaceChildren(...heads) + this.#footer.replaceChildren(...feet) + + const layout = { width, height, marginTop, marginRight, marginBottom, marginLeft, gap, columnWidth, columnCount: divisor } + this.#lastLayout = layout + return layout + } + render() { + if (this.#views.size === 0) return + const primaryView = this.#primaryView + if (!primaryView) return + this.#stabilizing = true + const layout = this.#beforeRender({ + vertical: this.#vertical, + rtl: this.#rtl, + }) + for (const [, view] of this.#views) { + if (view.document) view.render(layout) + } + // Scroll synchronously to prevent visible layout shift during resize. + // RAF deferral is only needed for initial display and mode switches + // (handled by #display), not for resize re-renders. + this.#scrollToAnchor(this.#anchor) + this.#stabilizing = false + this.dispatchEvent(new Event('stabilized')) + } + get scrolled() { + return this.getAttribute('flow') === 'scrolled' + } + get noPreload() { + return this.hasAttribute('no-preload') + } + get noBackground() { + return this.hasAttribute('no-background') + } + get noContinuousScroll() { + return this.scrolled && this.hasAttribute('no-continuous-scroll') + } + // The layered turn styles (slide/curl, readest#555) rasterize the outgoing + // page with the View Transitions API; when the engine lacks it the caller + // falls through to the push/two-phase animations, so old WebViews keep + // working page turns. + get #layeredTurn() { + const style = this.getAttribute('turn-style') + return (style === 'slide' || style === 'curl') + && typeof document.startViewTransition === 'function' + ? style : null + } + get scrollProp() { + const { scrolled } = this + return this.#vertical ? (scrolled ? 'scrollLeft' : 'scrollTop') + : scrolled ? 'scrollTop' : 'scrollLeft' + } + get sideProp() { + const { scrolled } = this + return this.#vertical ? (scrolled ? 'width' : 'height') + : scrolled ? 'height' : 'width' + } + get size() { + return this.#container.getBoundingClientRect()[this.sideProp] + } + get viewSize() { + const primaryView = this.#primaryView + if (!primaryView) return 0 + return primaryView.element.getBoundingClientRect()[this.sideProp] + } + get start() { + return this.#renderedStart - this.#getViewOffset(this.#primaryIndex) + } + get end() { + return this.#renderedEnd - this.#getViewOffset(this.#primaryIndex) + } + get page() { + return Math.floor(((this.start + this.end) / 2) / this.size) + } + get pages() { + const primaryView = this.#primaryView + if (!primaryView) return 0 + const viewSize = primaryView.element.getBoundingClientRect()[this.sideProp] + return Math.round(viewSize / this.size) + } + get containerPosition() { + return this.#container[this.scrollProp] + } + get isOverflowX() { + return false + } + get isOverflowY() { + return false + } + get #renderedViewSize() { + if (this.#views.size === 0) return 0 + let total = 0 + for (const [, view] of this.#views) + total += view.element.getBoundingClientRect()[this.sideProp] + return total + } + get #renderedStart() { + return Math.abs(this.#container[this.scrollProp]) + } + get #renderedEnd() { + return this.#renderedStart + this.size + } + get #renderedPage() { + return Math.floor(((this.#renderedStart + this.#renderedEnd) / 2) / this.size) + } + get #renderedPages() { + return Math.round(this.#renderedViewSize / this.size) + } + set containerPosition(newVal) { + this.#container[this.scrollProp] = newVal + } + get scrollLocked() { + return this.#scrollLocked + } + set scrollLocked(value) { + this.#scrollLocked = value + } + + scrollBy(dx, dy) { + // #scrollBounds is populated by #scrollToPage and stays unset until + // the first page settles. A swipe that lands before that happens + // (for example a fast swipe right after the reader mounts, or + // before a section has finished loading) would otherwise blow up + // on the destructuring below — bail out and let the next settled + // scroll re-enable swipe-driven motion. + if (!this.#scrollBounds) return + const delta = this.#vertical ? dy : dx + const [offset, a, b] = this.#scrollBounds + // RTL flips the forward/backward allowances only on the horizontal + // scroll axis (negative scrollLeft); vertical books page along + // scrollTop where forward is always positive. + const rtl = this.#rtl && !this.#vertical + const min = rtl ? offset - b : offset - a + const max = rtl ? offset + a : offset + b + this.containerPosition = Math.max(min, Math.min(max, + this.containerPosition + delta)) + } + + // vx, vy: velocity at the end of the swipe (pixels per ms) + // dx, dy: total distance swiped + // dt: total time of the swipe (ms) + snap(vx, vy, dx, dy, dt) { + // Same guard as scrollBy: an early swipe whose touchend fires + // before the first #scrollToPage seeds #scrollBounds would crash + // on the destructuring. Skip the snap; the next settled scroll + // populates the bounds and subsequent swipes work normally. + if (!this.#scrollBounds) return + // Page-turn swipes are horizontal in every writing mode: vertical-rl + // books turn pages right-to-left like printed Japanese books + // (readest#624), vertical-lr left-to-right. A predominantly vertical + // swipe on a vertical book still pages along the block axis so the + // legacy gesture keeps working. + const horizontal = Math.abs(vx) * 2 > Math.abs(vy) + const useHorizontal = horizontal || !this.#vertical + const pages = this.#renderedPages + let page + if (this.#vertical && useHorizontal && !this.#layeredTurn + && this.hasAttribute('animated') && !this.hasAttribute('eink')) { + // Drag-follow gestures on vertical books (readest#624): the views + // tracked the finger, so judge the turn like a paged carousel by + // where the drag ended plus the release flick, instead of the + // displacement heuristic below (which over-commits once content + // visibly follows the finger). + const width = this.#container.getBoundingClientRect().width + const forwardSign = this.#rtl ? 1 : -1 + const dragged = this.#dragTranslateX + // Flick direction in translate space: a rightward finger (vx < 0) + // drags the views right. + const flick = Math.abs(vx) > 0.3 ? -Math.sign(vx) : 0 + let turn + if (Math.abs(dragged) > width / 2) { + // Past halfway: commit unless flicked back the other way. + turn = flick === -Math.sign(dragged) ? 0 : Math.sign(dragged) + } else if (flick && (!dragged || flick === Math.sign(dragged))) { + turn = flick + } else { + turn = 0 + } + page = this.#renderedPage + turn * forwardSign + } else { + const velocity = useHorizontal ? vx : vy + const avgVelocity = useHorizontal ? dx / dt : dy / dt + // Without drag-follow (eink, animation off, block-axis swipes, + // layered turn styles) the scroll position never moves with the + // finger; judge the whole gesture by displacement (avgVelocity) + // like the eink path. + const snapping = this.hasAttribute('animated') && !this.hasAttribute('eink') + && !this.#vertical && !this.#layeredTurn + // Drag-follow releases are judged by the release flick, so their + // alignment uses the flick (last-sample) velocities. Displacement- + // judged releases weigh the WHOLE gesture and their alignment must + // too: the last-sample ratio is lift-off jitter — a vertical swipe + // whose finger hooks sideways in its final milliseconds read as + // horizontal, and the displacement heuristic amplified the tiny + // net x-drift into a random page turn (layered slide on Android). + const aligned = useHorizontal + ? (snapping ? horizontal : Math.abs(dx) > Math.abs(dy)) + : true + // Horizontal swipes advance against the page progression (RTL: + // next page is to the left); block-axis swipes always advance + // with the scroll axis. + const sign = useHorizontal && this.#rtl ? -1 : 1 + const [offset, a, b] = this.#scrollBounds + const size = this.size + const start = this.#renderedStart + const end = this.#renderedEnd + const min = Math.abs(offset) - a + const max = Math.abs(offset) + b + const v = snapping ? velocity : avgVelocity + const d = v * sign * size * (aligned ? 1 : 0) + const snapOffset = (isNaN(d) ? 0 : snapping ? d * 2 : d * 10) + page = Math.floor(Math.max(min, Math.min(max, (start + end) / 2 + snapOffset)) / size) + } + const dir = page < 0 ? -1 : page >= pages ? 1 : null + const doGoTo = () => { + if (!dir) return + const sorted = this.#sortedViews + const edgeIndex = dir < 0 + ? sorted[0]?.[0] ?? this.#primaryIndex + : sorted[sorted.length - 1]?.[0] ?? this.#primaryIndex + return this.#goTo({ + index: this.#adjacentIndex(dir, edgeIndex), + anchor: dir < 0 ? () => 1 : () => 0, + }) + } + // Out of range — skip animation, go straight to adjacent section + if (dir) { + if (this.#vertical) { + const sorted = this.#sortedViews + const edgeIndex = dir < 0 + ? sorted[0]?.[0] ?? this.#primaryIndex + : sorted[sorted.length - 1]?.[0] ?? this.#primaryIndex + // Book boundary: nowhere to go, settle the drag back. + if (this.#adjacentIndex(dir, edgeIndex) == null) return this.#settleDrag() + this.#settleDrag(true) + } + return doGoTo() + } + this.#scrollToPage(page, 'snap') + } + #onTouchStart(e) { + const previousState = this.#touchState + const replacementTouch = Boolean(previousState?.active) + if (replacementTouch) this.#rejectLayeredGesture(previousState) + const multiTouch = e.touches.length > 1 + const touch = e.changedTouches[0] + const currentTarget = e.currentTarget + const isInnerDocument = currentTarget?.nodeType === 9 + const bounds = isInnerDocument + ? { left: 0, width: currentTarget.documentElement?.clientWidth ?? 0 } + : this.getBoundingClientRect() + const localX = (touch?.clientX ?? 0) - bounds.left + // Hosts can reserve a left-side control strip (for example, a vertical + // brightness gesture) without disabling horizontal pagination there. + // Only the low-slop fast paths are withheld; the normal fallback stays. + const reservedLeftRatio = Math.max(0, Math.min(0.5, + Number(this.getAttribute('turn-gesture-left-inset')) || 0)) + const earlyClaimBlocked = bounds.width > 0 + && localX <= bounds.width * reservedLeftRatio + const edgeDirection = bounds.width > 0 + ? !earlyClaimBlocked && localX <= bounds.width * LAYERED_EDGE_REGION ? -1 + : localX >= bounds.width * (1 - LAYERED_EDGE_REGION) ? 1 : 0 + : 0 + const blocked = Boolean(multiTouch || this.#vtFinishing || this.#vtProgrammatic) + this.#touchState = { + x: touch?.screenX, y: touch?.screenY, + t: e.timeStamp, + vx: 0, xy: 0, + dx: 0, dy: 0, + dt: 0, + releaseSamples: [{ distance: 0, time: e.timeStamp }], + lastMovementTime: e.timeStamp, + active: true, + blocked, + layeredGesture: blocked ? 'rejected' : 'pending', + layeredEarlyClaimBlocked: earlyClaimBlocked, + layeredEdgeDirection: edgeDirection, + layeredHorizontalDirection: 0, + layeredHorizontalSamples: 0, + layeredHorizontalSampleTime: null, + } + if (replacementTouch || multiTouch) this.#touchScrolled = false + // Hint to browser that scrolling will occur for better GPU layer management + const pv = this.#primaryView + if (pv?.element) { + pv.element.style.willChange = 'transform' + } + // A touch on a vertical book takes over any in-flight page-turn slide + // or settle: freeze the views where they are and let the drag continue + // from that offset. Also re-syncs the drag offset to the rendered + // transform so a stale value can never leak into the next gesture. + if (this.#vertical && !this.scrolled && !this.#layeredTurn) { + this.#slideTurnId++ + this.#isAnimating = false + const children = [...this.#container.children] + const transform = children[0] && getComputedStyle(children[0]).transform + const m41 = transform && transform !== 'none' ? new DOMMatrix(transform).m41 : 0 + this.#dragTranslateX = m41 + for (const el of children) { + if (!m41 && !el.style.transform && !el.style.transition) continue + el.style.transition = 'none' + el.style.transform = m41 ? `translateX(${m41}px)` : '' + } + } + // Snapshot the invariant background paint inputs for the whole drag. The + // layout is settled at the start of a gesture and only the scroll offset + // changes while the finger moves, so every per-move #replaceBackground() + // reuses this instead of forcing a fresh style+layout read each frame + // (readest#4785). Cleared in #onTouchEnd; the snap that follows rebuilds + // its own. + this.#bgAnimContext = this.scrolled ? null : this.#computePaginatedBgContext() + } + #onTouchMove(e) { + const state = this.#touchState + if (!state?.active || state.blocked) return + if (e.touches.length > 1) { + if (this.#touchScrolled) e.preventDefault() + this.#rejectLayeredGesture(state) + return + } + if (state.pinched) { + this.#rejectLayeredGesture(state) + return + } + state.pinched = globalThis.visualViewport.scale > 1 + if (state.pinched) { + this.#rejectLayeredGesture(state) + return + } + if (this.scrolled) return + // When the host opts out of swipe-to-paginate, let touch events reach + // native behavior (text selection, etc.) without us tracking or + // pre-empting them. + if (this.hasAttribute('no-swipe')) { + this.#rejectLayeredGesture(state) + return + } + const doc = this.#primaryView?.document + const selection = doc?.getSelection() + if (selection && selection.rangeCount > 0 && !selection.isCollapsed) { + this.#rejectLayeredGesture(state) + return + } + const touch = e.changedTouches[0] + const isStylus = touch.touchType === 'stylus' + if (!isStylus) e.preventDefault() + if (this.#scrollLocked) { + this.#rejectLayeredGesture(state) + return + } + const x = touch.screenX, y = touch.screenY + const dx = state.x - x, dy = state.y - y + const dt = e.timeStamp - state.t + state.x = x + state.y = y + state.t = e.timeStamp + state.vx = dx / dt + state.vy = dy / dt + state.dx += dx + state.dy += dy + state.dt += dt + updateReleaseSample(state, state.dx, e.timeStamp) + this.#touchScrolled = true + if (!this.hasAttribute('animated') || this.hasAttribute('eink')) return + // Layered turn styles track the finger by scrubbing a paused view + // transition: the outgoing snapshot follows the drag over the still + // incoming page, then commits or reverses on release. + if (this.#layeredTurn) { + if (!this.#vtDrag) this.#layeredDragStart(state, dx, dy) + const drag = this.#vtDrag + if (drag) { + // Net finger travel along the forward direction (rtl books + // advance with a rightward finger). + const along = this.#rtl ? -state.dx : state.dx + const totalDistance = drag.forward ? along : -along + // Slide begins visually flat at the point where the gesture + // arena awards ownership. The consumed recognition distance + // still contributes to release intent below, but no longer + // appears as a first-frame jump. Curl preserves its existing + // touchstart-relative fold. + const visualDistance = drag.style === 'slide' + ? totalDistance - drag.visualOriginDistance + : totalDistance + drag.progress = Math.max(0, Math.min(1, + visualDistance / drag.width)) + this.#vtDragScrub() + } + return + } + if (!this.#vertical && Math.abs(state.dx) >= Math.abs(state.dy) && !this.hasAttribute('eink') && (!isStylus || Math.abs(dx) > 1)) { + this.scrollBy(dx, 0) + } else if (this.#vertical && Math.abs(state.dx) >= Math.abs(state.dy) && (!isStylus || Math.abs(dx) > 1)) { + // Vertical books track a horizontal finger by translating the + // views sideways: their pages stack along the vertical scroll + // axis, so the scroll offset itself cannot follow the finger + // (readest#624). The turn commits or settles on release in snap(). + this.#dragBy(dx) + } + } + // Prepare the document for a layered page turn: choreography classes on + // the root and the view-transition-name on the turn boundary. The host + // app can mark a wider boundary with [data-view-transition-root] (e.g. + // a wrapper that also contains its page header and footer, so the + // furniture turns with the page in both layers); otherwise the outermost + // shadow host in the document tree is named, since shadow-internal names + // are tree-scoped away from the document-level pseudo selectors. + #vtSetup(style, forward, scrubbing = false) { + injectViewTransitionStyles() + const html = document.documentElement + const side = this.#rtl ? 'right' : 'left' + // Which side the curl fold consumes the old page from: the outer + // edge going forward, the spine going backward. + const eatSide = (forward !== this.#rtl) ? 'right' : 'left' + const classes = ['foliate-vt', `foliate-vt-${style}`, + forward ? 'foliate-vt-forward' : 'foliate-vt-backward', + `foliate-vt-${side}`, `foliate-vt-eat-${eatSide}`] + if (scrubbing) classes.push('foliate-vt-scrub') + let namedHost = this + while (namedHost.getRootNode() instanceof ShadowRoot) { + namedHost = namedHost.getRootNode().host + } + namedHost = namedHost.closest('[data-view-transition-root]') ?? namedHost + namedHost.style.viewTransitionName = 'foliate-turn' + this.#vtNamedHost = namedHost + // Back the turn layers with the page colour: snapshots of books or + // themes without an opaque background (e.g. textured themes) would + // otherwise blend the two pages instead of occluding. + const doc = this.#primaryView?.document + const themeBg = doc?.documentElement + ? doc.defaultView.getComputedStyle(doc.documentElement) + .getPropertyValue('--theme-bg-color').trim() + : '' + html.style.setProperty('--foliate-vt-bg', themeBg || 'Canvas') + html.classList.remove(...VIEW_TRANSITION_CLASSES) + html.classList.add(...classes) + return namedHost + } + #vtCleanup() { + const html = document.documentElement + html.classList.remove(...VIEW_TRANSITION_CLASSES) + html.style.removeProperty('--foliate-vt-bg') + this.#vtNamedHost?.style.removeProperty('view-transition-name') + this.#vtNamedHost = null + } + // Permanently yield this touch sequence to another gesture owner. If the + // layered snapshot already exists, cancel it through the normal lifecycle + // instead of letting later samples (or a replacement finger) scrub it. + #rejectLayeredGesture(state = this.#touchState) { + if (state) { + state.layeredGesture = 'rejected' + state.layeredHorizontalDirection = 0 + state.layeredHorizontalSamples = 0 + state.layeredHorizontalSampleTime = null + } + const drag = this.#vtDrag + if (!drag) return + this.#vtDrag = null + this.#finishLayeredDrag(drag, false) + } + // Begin a finger-tracked layered turn (readest#555). Edge-originated + // gestures can claim on their first clear inward move. In the middle, two + // consecutive horizontal samples can claim after 6px; a vertical gesture + // locks out the turn before a landing wobble can become a page animation. + // Ambiguous trajectories retain the established 24px + 1.5x fallback. + // Once claimed, the existing `before-capture` lifecycle event explicitly + // transfers ownership to the layered turn. + #layeredDragStart(state, dx, dy) { + if (this.#vtDrag || this.#vtFinishing || this.#vtProgrammatic || !this.#scrollBounds) return + const style = this.#layeredTurn + if (!style) return + if (state.layeredGesture !== 'pending') return + + const absDx = Math.abs(state.dx) + const absDy = Math.abs(state.dy) + if (absDy >= LAYERED_VERTICAL_REJECT_PX && absDy > absDx) { + state.layeredGesture = 'rejected' + return + } + + const direction = Math.sign(dx) + const locallyHorizontal = direction !== 0 && Math.abs(dx) > Math.abs(dy) + const clearlyHorizontal = locallyHorizontal + && Math.abs(dx) >= Math.abs(dy) * LAYERED_FALLBACK_DOMINANCE + const cumulativelyHorizontal = absDx + >= absDy * LAYERED_FALLBACK_DOMINANCE + if (locallyHorizontal && cumulativelyHorizontal) { + const recentSample = state.layeredHorizontalSampleTime != null + && state.t - state.layeredHorizontalSampleTime + <= LAYERED_EARLY_SAMPLE_INTERVAL_MS + if (state.layeredHorizontalDirection === direction && recentSample) { + state.layeredHorizontalSamples++ + } else { + state.layeredHorizontalDirection = direction + state.layeredHorizontalSamples = 1 + } + state.layeredHorizontalSampleTime = state.t + } else { + state.layeredHorizontalDirection = 0 + state.layeredHorizontalSamples = 0 + state.layeredHorizontalSampleTime = null + } + + const edgeClaim = state.layeredEdgeDirection !== 0 + && !state.layeredEarlyClaimBlocked + && direction === state.layeredEdgeDirection + && Math.sign(state.dx) === state.layeredEdgeDirection + && clearlyHorizontal + && cumulativelyHorizontal + const earlyCenterClaim = state.layeredEdgeDirection === 0 + && !state.layeredEarlyClaimBlocked + && state.layeredHorizontalSamples >= 2 + && absDx >= LAYERED_EARLY_CLAIM_PX + && Math.sign(state.dx) === state.layeredHorizontalDirection + const fallbackClaim = absDx >= LAYERED_FALLBACK_CLAIM_PX + && absDx >= absDy * LAYERED_FALLBACK_DOMINANCE + if (!edgeClaim && !earlyCenterClaim && !fallbackClaim) return + + // Finger travel along the forward direction decides which neighbor + // page gets snapshotted. + const along = this.#rtl ? -state.dx : state.dx + const forward = along > 0 + state.layeredGesture = 'claimed' + // Gesture ownership is independent of whether an adjacent page exists. + // At a book boundary the turn cannot start, but the host must still + // suppress the browser's synthesized click for this horizontal drag. + // Keep this separate from layered-turn-state so the established + // snapshot lifecycle remains unchanged. + this.dispatchEvent(new CustomEvent('layered-turn-gesture-claimed', { + detail: { style, forward }, + })) + const pages = this.#renderedPages + const target = this.#renderedPage + (forward ? 1 : -1) + if (target < 0 || target >= pages) return + const offset = this.size * (this.#rtl && !this.#vertical ? -target : target) + ++this.#slideTurnId + this.#isAnimating = true + this.dispatchEvent(new CustomEvent('layered-turn-state', { + detail: { phase: 'before-capture', style, forward }, + })) + const startPosition = this.containerPosition + let turnRoot + let transition + try { + turnRoot = this.#vtSetup(style, forward, true) + transition = document.startViewTransition(() => { + this.containerPosition = offset + if (!this.scrolled) { + this.#bgAnimContext = null + this.#replaceBackground() + } + // The old snapshot now owns the visible toolbar. Let the host hide + // the live copy synchronously before the new snapshot is captured, + // so its regular opacity transition cannot run beside the page. + this.dispatchEvent(new CustomEvent('layered-turn-state', { + detail: { phase: 'covered', style, forward }, + })) + }) + } catch { + // A synchronous setup/capture failure must release both the global + // View Transition styling and the host's before-capture ownership. + // Reject the rest of this touch so touchend cannot reinterpret it + // as a legacy snap after the layered lifecycle has already ended. + state.layeredGesture = 'rejected' + this.containerPosition = startPosition + this.#vtCleanup() + this.#isAnimating = false + this.dispatchEvent(new CustomEvent('layered-turn-state', { + detail: { phase: 'finished', style, forward, committed: false }, + })) + return + } + const drag = { + transition, offset, startPosition, forward, + style, progress: 0, anims: null, + visualOriginDistance: style === 'slide' + ? Math.max(0, forward ? along : -along) : 0, + // Progress must use the width of the actual named snapshot. The + // inner content container can be narrower because of page margins; + // using it makes the sheet gradually outrun the finger. + width: turnRoot.getBoundingClientRect().width + || this.#container.getBoundingClientRect().width, + } + this.#vtDrag = drag + transition.ready.then(() => { + if (this.#vtDrag !== drag && this.#vtFinishing !== drag) return + const anims = document.getAnimations().filter(a => + a.effect?.pseudoElement?.includes('(foliate-turn)')) + for (const a of anims) { + // CSS is authoritative for linear scrubbing. Keep this as a + // best-effort fallback for engines that expose mutable pseudo + // animation effects. + try { a.effect.updateTiming({ easing: 'linear' }) } catch { /* UA animation */ } + a.pause() + } + drag.anims = anims + this.#vtDragScrub(drag) + this.dispatchEvent(new CustomEvent('layered-turn-state', { + detail: { phase: 'ready', style, forward }, + })) + }, () => { + // Capture failed or was skipped; release falls back to snap(). + if (this.#vtDrag === drag || this.#vtFinishing === drag) drag.failed = true + }) + } + #vtDragScrub(drag = this.#vtDrag) { + if (!drag?.anims) return + for (const a of drag.anims) { + const duration = a.effect.getTiming().duration + a.currentTime = drag.progress * 0.999 + * (typeof duration === 'number' ? duration : 300) + } + } + // Resolve a finger-tracked layered turn: play the paused animations to + // the end to commit, or reverse them and put the live content back to + // cancel. The scroll offset already sits on the target page during the + // drag (the snapshot hides it), so cancel restores it under the overlay + // before the transition is skipped. + async #finishLayeredDrag(drag, commit, playbackRate = 1) { + if (this.#vtFinishing === drag) return + this.#vtFinishing = drag + const { transition, offset, startPosition, style, forward } = drag + const { size } = this + const id = ++this.#slideTurnId + this.#isAnimating = true + try { + // The update callback owns the old/new snapshot boundary. Await it + // before any terminal event so `covered` can never arrive after a + // very fast release has already announced cancellation. + try { await transition.updateCallbackDone } catch { /* skipped */ } + try { await transition.ready } catch { /* capture failed */ } + if (id !== this.#slideTurnId) return + + const anims = drag.anims + if (anims) for (const a of anims) updatePlaybackRate(a, playbackRate) + if (commit) { + if (anims) for (const a of anims) a.play() + try { + await transition.finished + } catch { /* skipped */ } + } else { + if (anims) { + for (const a of anims) a.reverse() + try { + await Promise.all(anims.map(a => a.finished)) + } catch { /* superseded */ } + } + if (id !== this.#slideTurnId) return + // Restore the pre-turn page under the overlay, then drop it. + this.containerPosition = startPosition + this.dispatchEvent(new CustomEvent('layered-turn-state', { + detail: { phase: 'cancelled', style, forward, committed: false }, + })) + // Give the host two rendering opportunities to paint restored + // chrome underneath the now-flat snapshot before it is removed. + await new Promise(resolve => requestAnimationFrame(() => + requestAnimationFrame(resolve))) + if (id !== this.#slideTurnId) return + try { transition.skipTransition() } catch { /* already done */ } + try { + await transition.finished + } catch { /* skipped */ } + } + if (id !== this.#slideTurnId) return + this.#vtCleanup() + this.#isAnimating = false + const finalPosition = commit ? offset : startPosition + this.containerPosition = finalPosition + this.#scrollBounds = [ + finalPosition, + this.atStart ? 0 : size, + this.atEnd ? 0 : size, + ] + this.#afterScroll('snap') + this.dispatchEvent(new CustomEvent('layered-turn-state', { + detail: { phase: 'finished', style, forward, committed: commit }, + })) + } finally { + if (this.#vtFinishing === drag) this.#vtFinishing = null + } + } + #dragBy(dx) { + if (!this.#scrollBounds) return + const [, a, b] = this.#scrollBounds + const width = this.#container.getBoundingClientRect().width + // Exit direction of a forward turn: vertical-rl pages exit right. + const forwardSign = this.#rtl ? 1 : -1 + const max = (forwardSign > 0 ? b : a) > 0 ? width : 0 + const min = (forwardSign > 0 ? a : b) > 0 ? -width : 0 + this.#dragTranslateX = Math.max(min, Math.min(max, this.#dragTranslateX - dx)) + for (const el of this.#container.children) { + el.style.transition = 'none' + el.style.transform = `translateX(${this.#dragTranslateX}px)` + } + } + // Animate a released drag back to rest when the page did not turn, and + // drop the inline drag styles once the views are back in place. Pass + // instant=true to drop them without the settle animation. + #settleDrag(instant) { + const startX = this.#dragTranslateX + this.#dragTranslateX = 0 + const children = [...this.#container.children] + if (!children.some(el => el.style.transform)) return + const cleanup = () => { + for (const el of children) { + el.style.willChange = '' + el.style.transition = '' + el.style.transform = '' + } + } + if (!startX || instant) return cleanup() + const id = ++this.#slideTurnId + for (const el of children) { + el.style.transition = 'transform 150ms ease-out' + el.style.transform = 'translateX(0px)' + } + setTimeout(() => { + if (id !== this.#slideTurnId) return + cleanup() + }, 170) + } + #onTouchEnd(e) { + // Remove will-change hint to free GPU resources + // if (this.#view?.element) { + // this.#view.element.style.willChange = 'auto' + // } + + // The drag is over: drop the drag-time paint snapshot so the snap + // animation (or any other repaint) rebuilds a fresh context. + this.#bgAnimContext = null + const state = this.#touchState + if (state) state.active = false + if (state?.blocked) { + this.#touchScrolled = false + return + } + + if (!this.#touchScrolled) { + // A tap that never dragged may still have taken over a mid-flight + // transform in #onTouchStart; put the views back to rest. + if (this.#vertical && !this.scrolled && this.#dragTranslateX) this.#settleDrag() + return + } + this.#touchScrolled = false + if (this.scrolled) return + if (this.hasAttribute('no-swipe')) return + const layeredRejected = this.#layeredTurn + && state?.layeredGesture === 'rejected' + // Horizontal books have no block-axis page gesture to preserve. + if (layeredRejected && !this.#vertical) return + + // A finger that rested before lifting has no flick momentum; the + // last touchmove velocity is stale by the rest duration. + if (state && e && e.timeStamp - state.t > RELEASE_PAUSE_THRESHOLD_MS) { + state.vx = 0 + state.vy = 0 + } + const releaseTouch = e?.changedTouches?.[0] + let releaseDx = state?.dx ?? 0 + let releaseDy = state?.dy ?? 0 + if (state && releaseTouch) { + // A quick lift can carry the final sample only in changedTouches. + // Use that point consistently for Slide's velocity, progress, and + // whole-gesture horizontal-intent guard. + releaseDx += state.x - releaseTouch.screenX + releaseDy += state.y - releaseTouch.screenY + } + // Also stamp an unchanged final position. Besides making a deliberate + // pause velocity-free, this is defensive against non-standard UAs + // that omit touchend.changedTouches. + if (state) updateReleaseSample(state, releaseDx, e.timeStamp) + + // A finger-tracked layered turn resolves here. Slide projects recent + // release velocity onto its current progress; Curl keeps the existing + // last-move flick-or-halfway rule. A page-turn commit also requires + // the WHOLE gesture to be predominantly horizontal: a finger + // landing with a sideways wobble can start the drag before any + // vertical distance accumulates, and the lift-off flick velocity is + // jitter — judged alone they turned the page randomly on vertical + // toolbar-toggle swipes (Android WebView report). + const drag = this.#vtDrag + if (drag) { + this.#vtDrag = null + // Keep Curl's established whole-gesture guard exactly as-is; + // Slide uses the actual lift-off point required by its projection. + const gestureDx = drag.style === 'slide' ? releaseDx : (state?.dx ?? 0) + const gestureDy = drag.style === 'slide' ? releaseDy : (state?.dy ?? 0) + const gestureAligned = state + ? Math.abs(gestureDx) > Math.abs(gestureDy) : true + const alongV = this.#rtl ? -(state?.vx ?? 0) : (state?.vx ?? 0) + const recentVx = state ? getReleaseVelocity(state) : 0 + const recentAlongV = this.#rtl ? -recentVx : recentVx + const progressVelocity = recentAlongV * (drag.forward ? 1 : -1) + const releaseAlong = this.#rtl ? -releaseDx : releaseDx + const releaseDistance = drag.forward ? releaseAlong : -releaseAlong + const releaseProgress = Math.max(0, Math.min(1, + releaseDistance / drag.width)) + const releaseVisualProgress = Math.max(0, Math.min(1, + (releaseDistance - drag.visualOriginDistance) / drag.width)) + const projectedProgress = releaseProgress + + progressVelocity * SLIDE_RELEASE_PROJECTION_MS / drag.width + const curlFlick = Math.abs(alongV) > 0.3 + ? Math.sign(alongV) * (drag.forward ? 1 : -1) : 0 + const commit = gestureAligned && (drag.style === 'slide' + ? projectedProgress > 0.5 + : curlFlick > 0 ? true : curlFlick < 0 ? false : drag.progress > 0.5) + // Do not scrub a gesture that ended vertically: flashing its final + // horizontal component here would defeat the intent guard. A valid + // Slide release settles from the actual lift-off position; Curl's + // existing progress and commit mapping remain unchanged. + if (gestureAligned && drag.style === 'slide') { + drag.progress = releaseVisualProgress + this.#vtDragScrub(drag) + } + const targetDirection = (drag.forward ? 1 : -1) * (commit ? 1 : -1) + // Settle pacing uses a short release window; the decision above + // uses it for the lighter Slide gesture while Curl keeps its + // original last-sample commit rule. + const releaseSpeed = recentAlongV * targetDirection + const playbackRate = layeredSettlePlaybackRate(drag.style, releaseSpeed) + this.#finishLayeredDrag(drag, commit, playbackRate) + return + } + + // XXX: Firefox seems to report scale as 1... sometimes...? + // at this point I'm basically throwing `requestAnimationFrame` at + // anything that doesn't work + const snapState = state + requestAnimationFrame(() => { + if (globalThis.visualViewport.scale === 1 && snapState + && this.#touchState === snapState) { + const { vx, vy, dx, dy, dt } = snapState + // Direction ownership is final for this touch sequence. Once + // vertical wins the layered arena, discard later horizontal + // hooks while preserving block-axis paging in vertical books. + this.snap(layeredRejected ? 0 : vx, vy, + layeredRejected ? 0 : dx, dy, dt) + } + }) + } + #onTouchCancel() { + this.#bgAnimContext = null + const state = this.#touchState + if (state) state.active = false + if (state?.blocked) { + this.#touchScrolled = false + return + } + + const drag = this.#vtDrag + if (drag) { + this.#vtDrag = null + this.#touchScrolled = false + this.#finishLayeredDrag(drag, false) + return + } + + const wasScrolled = this.#touchScrolled + this.#touchScrolled = false + if (this.scrolled || this.hasAttribute('no-swipe')) return + if (this.#layeredTurn && state?.layeredGesture === 'rejected' + && !this.#vertical) return + if (this.#vertical) { + this.#settleDrag() + } else if (wasScrolled && this.#scrollBounds) { + this.#scrollTo(this.#scrollBounds[0], 'snap') + } + } + // allows one to process rects as if they were LTR and horizontal + #getRectMapper(view) { + if (this.scrolled) { + const size = view ? view.element.getBoundingClientRect()[this.sideProp] : this.#renderedViewSize + const marginTop = this.#marginTop + const marginBottom = this.#marginBottom + return this.#vertical + ? ({ left, right }) => + ({ left: size - right - marginTop, right: size - left - marginBottom }) + : ({ top, bottom }) => ({ left: top - marginTop, right: bottom - marginBottom }) + } + // For RTL the mapper mirrors a rect within the iframe-local + // coordinate space of the *target view* (each view is a separate + // document with its own column layout), not across the whole + // container. Using `#renderedPages * size` (= total width of all + // loaded views) was correct only when a single view was loaded; + // once #fillVisibleArea pre-loads adjacent sections the total + // width grows but the per-view rect coordinates do not change, + // so the mapper would scroll the same anchor to a different + // (further-right) container offset on every re-anchor — driving + // the page off the user's saved position. Use the supplied + // view's width when available, falling back to the primary view. + const targetView = view ?? this.#primaryView + const viewSize = targetView + ? targetView.element.getBoundingClientRect()[this.sideProp] + : this.#renderedViewSize + // Vertical books map the block axis (top/bottom) onto the scroll + // axis regardless of page progression: vertical-rl is RTL but its + // scrollTop still grows forward, so the RTL mirror below only + // applies to horizontal writing. + return this.#vertical + ? ({ top, bottom }) => ({ left: top, right: bottom }) + : this.#rtl + ? ({ left, right }) => + ({ left: viewSize - right, right: viewSize - left }) + : f => f + } + async #scrollToRect(rect, reason) { + if (this.scrolled) { + // rect is in iframe-local coordinates; add view offset + // to convert to container scroll coordinates + const localOffset = this.#getRectMapper()(rect).left - 3 + const viewOffset = this.#getViewOffset(this.#primaryIndex) + return this.#scrollTo(viewOffset + localOffset, reason) + } + // rect is in iframe-local coordinates. Convert to container + // coordinates by adding the primary view's offset. + const localOffset = this.#getRectMapper()(rect).left + const viewOffset = this.#getViewOffset(this.#primaryIndex) + const containerOffset = viewOffset + localOffset + return this.#scrollToPage(Math.floor(containerOffset / this.size + 0.01), reason) + } + async #scrollTo(offset, reason, smooth) { + const { size } = this + // Near-equality, not exact: on fractional device-pixel-ratio screens + // (e.g. 2.75) the container scroll rests a sub-pixel off the page + // offset, and an exact check made every same-page settle miss this + // short-circuit and run a full animation — with the layered turn + // styles, a visible full-page view-transition flash on every + // vertical toolbar-toggle swipe. + if (Math.abs(this.containerPosition - offset) < 1) { + this.#scrollBounds = [offset, this.atStart ? 0 : size, this.atEnd ? 0 : size] + // A released drag that stays on the same page settles back to rest. + if (this.#vertical && !this.scrolled) this.#settleDrag() + this.#afterScroll(reason) + return + } + // FIXME: vertical-rl only, not -lr + if (this.scrolled && this.#vertical) offset = -offset + if ((reason === 'snap' || smooth) && this.hasAttribute('animated') && !this.hasAttribute('eink')) { + // Layered turn styles: snapshot the outgoing page and animate it + // over the live, stationary incoming page (readest#555). Works + // for every writing mode since the snapshot is axis-agnostic — + // but only for actual page changes: a sub-page settle must not + // snapshot and re-slide the page it is already resting on. + const turning = Math.abs(offset - this.containerPosition) > size / 2 + const layered = !this.scrolled && turning ? this.#layeredTurn : null + if (layered) return this.#viewTransitionTurn(offset, reason, layered) + const startPosition = this.containerPosition + this.#isAnimating = true + // Vertical paginated books page along scrollTop but read + // horizontally, so a scroll-axis slide would move perpendicular to + // the page turn. Run the two-phase horizontal slide instead: + // vertical-rl turns forward exit to the right (page progression), + // vertical-lr to the left (readest#624). + if (!this.scrolled && this.#vertical) { + this.#bgAnimContext = null + // Oversized sections would composite as one giant layer to + // animate the transform (the freeze rafAnimateScroll exists to + // avoid), and a native scroll animation cannot move + // horizontally here, so swap instantly. + if (!this.hasAttribute('gpu-composite') + && this.#renderedViewSize > RAF_ANIMATE_SCROLL_THRESHOLD) { + this.#isAnimating = false + this.#settleDrag(true) + this.containerPosition = offset + this.#scrollBounds = [offset, this.atStart ? 0 : size, this.atEnd ? 0 : size] + this.#afterScroll(reason) + return + } + const id = ++this.#slideTurnId + const forward = offset > startPosition + const exitSign = (this.#rtl ? 1 : -1) * (forward ? 1 : -1) + const width = this.#container.getBoundingClientRect().width + // Continue from a finger drag already in progress. + const dragStartX = this.#dragTranslateX + this.#dragTranslateX = 0 + return slideTurnAnimation( + this.#container, this.scrollProp, offset, exitSign, width, 300, + () => id !== this.#slideTurnId, + // Reposition the background segments for the new scroll + // offset while both pages are off-screen. + () => this.#replaceBackground(), + dragStartX, + ).then(() => { + if (id !== this.#slideTurnId) return + this.#isAnimating = false + this.#scrollBounds = [offset, this.atStart ? 0 : size, this.atEnd ? 0 : size] + this.#afterScroll(reason) + }) + } + // Snapshot the invariant paint inputs once; every per-frame + // #replaceBackground below reuses this instead of forcing a fresh + // style+layout read each frame (readest#4785). + this.#bgAnimContext = this.scrolled ? null : this.#computePaginatedBgContext() + // For a large section the CSS-transform animation blocks the UI while + // Blink composites the oversized layer; animate the native scroll + // offset instead (incremental/tiled, like a swipe), keeping the + // per-page backgrounds synced each frame. Hosts that composite large + // layers without that freeze (Apple WebKit, via the gpu-composite + // opt-in) skip this main-thread fallback and keep the smooth GPU + // cssAnimateScroll path even for large sections (readest#4768). + if (!this.hasAttribute('gpu-composite') + && this.#renderedViewSize > RAF_ANIMATE_SCROLL_THRESHOLD) { + return rafAnimateScroll(startPosition, offset, 300, easeOutQuad, x => { + this.#container[this.scrollProp] = x + if (!this.scrolled) this.#replaceBackground() + }).then(() => { + this.#isAnimating = false + this.#bgAnimContext = null + this.#scrollBounds = [offset, this.atStart ? 0 : size, this.atEnd ? 0 : size] + this.#afterScroll(reason) + }) + } + // Slide the per-view backgrounds in lockstep with the content. The + // content animates via a transform on each view; we re-sync the + // backgrounds to that animated offset every frame so each page's + // colour stays glued to its content as it slides. Pre-setting the + // destination instead made the outgoing page lose its background the + // instant the animation started, flashing the wrong colour across + // the part of the screen it still covered until it slid off. + if (!this.scrolled) { + this.#replaceBackground(startPosition) + const child = this.#container.children[0] + const syncBackground = () => { + if (!this.#isAnimating) return + const transform = child && getComputedStyle(child).transform + const tx = transform && transform !== 'none' + ? new DOMMatrix(transform)[this.#vertical ? 'm42' : 'm41'] : 0 + this.#replaceBackground(startPosition - tx) + requestAnimationFrame(syncBackground) + } + requestAnimationFrame(syncBackground) + } + // Use GPU-accelerated scroll animation for smoother experience on high refresh rate screens + return cssAnimateScroll( + this.#container, + this.scrollProp, + startPosition, + offset, + 300, + ).then(() => { + this.#isAnimating = false + this.#bgAnimContext = null + this.#scrollBounds = [offset, this.atStart ? 0 : size, this.atEnd ? 0 : size] + this.#afterScroll(reason) + }) + } else { + if (this.#vertical && !this.scrolled) this.#settleDrag(true) + this.containerPosition = offset + this.#scrollBounds = [offset, this.atStart ? 0 : size, this.atEnd ? 0 : size] + this.#afterScroll(reason) + } + } + // Turn the page with a View Transitions snapshot (readest#555): the live + // content jumps to the destination inside the transition callback, then + // the rasterized outgoing page slides away or curls open ON TOP of it, so + // the incoming page stays perfectly still underneath (Apple Books style). + // Forward turns move the old snapshot out; backward turns bring the new + // snapshot in over the still page. The choreography is selected with + // classes on the document root, where the ::view-transition pseudo tree + // lives. + async #viewTransitionTurn(offset, reason, style) { + // A layered transition has exclusive ownership of the document-level + // pseudo tree. Ignore overlapping navigation until its lifecycle has + // reached cleanup; otherwise the newer generation strands the older + // turn before it can dispatch `finished`. + if (this.#vtDrag || this.#vtFinishing || this.#vtProgrammatic) return + // An accepted keyboard/tap turn owns navigation for the remainder of + // any finger that is still down. Permanently reject a pending arena + // candidate so its later move cannot reuse the pre-turn start point + // and launch a second layered turn after this transition finishes. + if (this.#touchState?.active) this.#rejectLayeredGesture(this.#touchState) + const { size } = this + const startPosition = this.containerPosition + // RTL horizontal scroll coordinates are negative; compare magnitudes. + const forward = Math.abs(offset) > Math.abs(startPosition) + const id = ++this.#slideTurnId + this.#isAnimating = true + this.#settleDrag(true) + this.#vtSetup(style, forward) + const transition = document.startViewTransition(() => { + this.containerPosition = offset + if (!this.scrolled) { + this.#bgAnimContext = null + this.#replaceBackground() + } + }) + const active = { transition, id } + this.#vtProgrammatic = active + try { + try { + await transition.finished + } catch { + // Interrupted or skipped; the newer turn owns the cleanup. + } + if (id !== this.#slideTurnId) return + this.#vtCleanup() + this.#isAnimating = false + // A neighbor view finishing its load mid-transition re-anchors the + // container to the stale pre-turn anchor; re-assert the destination + // like the push animation does at its end. + this.containerPosition = offset + this.#scrollBounds = [offset, this.atStart ? 0 : size, this.atEnd ? 0 : size] + this.#afterScroll(reason) + } finally { + if (this.#vtProgrammatic === active) this.#vtProgrammatic = null + } + } + async #scrollToPage(page, reason, smooth) { + // Negative offsets are an artifact of RTL horizontal scroll + // coordinates; vertical books page along scrollTop, always positive. + const offset = this.size * (this.#rtl && !this.#vertical ? -page : page) + return this.#scrollTo(offset, reason, smooth) + } + async scrollToAnchor(anchor, select, smooth) { + return this.#scrollToAnchor(anchor, select ? 'selection' : 'navigation', smooth) + } + async #scrollToAnchor(anchor, reason = 'anchor', smooth = false) { + this.#anchor = anchor + const rects = uncollapse(anchor)?.getClientRects?.() + // if anchor is an element or a range + if (rects) { + // when the start of the range is immediately after a hyphen in the + // previous column, there is an extra zero width rect in that column + const rect = Array.from(rects) + .find(r => r.width > 0 && r.height > 0 && r.x >= 0 && r.y >= 0) || rects[0] + // An anchor can have no client rects at all: a range over content + // that is entirely absolutely positioned (e.g. a Duokan fullscreen + // cover pins its only image) has no in-flow boxes. Bailing without + // scrolling would leave #scrollBounds unseeded, and scrollBy/snap + // then silently drop every swipe on the page (#5263). Settle on + // the primary section's start instead. + if (!rect) return this.scrolled + ? this.#scrollTo(this.#getViewOffset(this.#primaryIndex), reason) + : this.#scrollToPage(this.#getPagesBeforeView(this.#primaryIndex), reason) + await this.#scrollToRect(rect, reason) + // focus the element when navigating with keyboard or screen reader + if (reason === 'navigation') { + let node = anchor.focus ? anchor : undefined + if (!node && anchor.startContainer) { + node = anchor.startContainer + if (node.nodeType === Node.TEXT_NODE) { + node = node.parentElement + } + } + if (node && node.focus) { + node.tabIndex = -1 + node.style.outline = 'none' + node.focus({ preventScroll: true }) + } + } + return + } + // if anchor is a fraction + if (this.scrolled) { + // In scrolled mode with multi-view, offset to the primary view's position + const primaryOffset = this.#getViewOffset(this.#primaryIndex) + const primaryView = this.#primaryView + const primarySize = primaryView + ? primaryView.element.getBoundingClientRect()[this.sideProp] : this.#renderedViewSize + await this.#scrollTo(primaryOffset + anchor * primarySize, reason, smooth) + return + } + // In paginated mode, account for pages before the primary section + const primaryView = this.#primaryView + if (!primaryView) return + const pagesBeforePrimary = this.#getPagesBeforeView(this.#primaryIndex) + const textPages = primaryView.contentPages + // Same as the rect-less bail above: a section that measured zero + // content pages must still settle so #scrollBounds gets seeded. + if (!textPages) return this.#scrollToPage(pagesBeforePrimary, reason) + // textPages is in column units; convert to spread page for scrolling + const newColumn = Math.round(anchor * (textPages - 1)) + const newSpreadPage = Math.floor(newColumn / this.columnCount) + await this.#scrollToPage(pagesBeforePrimary + newSpreadPage, reason, smooth) + } + // Get the pixel offset of a view within the container + #getViewOffset(index) { + let offset = 0 + for (const [i, view] of this.#sortedViews) { + if (i === index) return offset + offset += view.element.getBoundingClientRect()[this.sideProp] + } + return offset + } + // Get number of full pages (spreads) before a given view. + // Uses floor so the view's first column is always on or after + // the returned page — never rounded past it. The 0.01 tolerance + // absorbs sub-pixel drift on fractional-DPR devices where + // getBoundingClientRect() accumulates ~0.0001px errors. + #getPagesBeforeView(index) { + return Math.floor(this.#getViewOffset(index) / this.size + 0.01) + } + #getVisibleRange() { + const targetView = this.#primaryView + if (!targetView?.document) return + const viewOffset = this.#getViewOffset(this.#primaryIndex) + if (this.scrolled) { + // In scrolled mode several sections can share the viewport at a + // section boundary, and the primary view may even be scrolled out + // of view. Prefer the view that covers the viewport centre — that + // is the section the reader is actually reading, so its title is + // the one to show. Falling back to the first overlapping view (the + // old behaviour) would report a thin sliver at the top edge, whose + // chapter title no longer matches the dominant content + // (readest#4436). Keep that first valid range as a fallback for + // when no loaded view covers the centre (e.g. at the very top or + // bottom of the book). + const center = this.#renderedStart + this.size / 2 + let fallback + for (const [index, v] of this.#sortedViews) { + if (!v.document) continue + const off = this.#getViewOffset(index) + const vSize = v.element.getBoundingClientRect()[this.sideProp] + // Skip views entirely outside the viewport + if (off + vSize <= this.#renderedStart || off >= this.#renderedEnd) continue + const range = getVisibleRange(v.document, + this.#renderedStart - off, this.#renderedEnd - off, + this.#getRectMapper(v)) + if (!range || range.collapsed) continue + if (center >= off && center < off + vSize) return { range, index } + fallback ??= { range, index } + } + return fallback + } + const range = getVisibleRange(targetView.document, + this.#renderedStart - viewOffset, + this.#renderedEnd - viewOffset, + this.#getRectMapper(targetView)) + return range ? { range, index: this.#primaryIndex } : undefined + } + // Determine which view is primary based on scroll position + #detectPrimaryView() { + if (this.#views.size <= 1) return + const visibleStart = this.#renderedStart + let offset = 0 + for (const [index, view] of this.#sortedViews) { + const viewSize = view.element.getBoundingClientRect()[this.sideProp] + if (visibleStart < offset + viewSize - 1) { + if (index !== this.#primaryIndex) { + this.#primaryIndex = index + this.#syncA11y() + this.#trimDistantViews() + this.#replaceBackground() + this.#fillPromise = this.#preloadNext() + } + return + } + offset += viewSize + } + } + // Pre-load adjacent sections from the current primary so the + // next/prev sections are ready when the user paginates. + // Does NOT re-scroll to avoid fighting with the user's current + // scroll position. + async #preloadNext() { + if (this.noPreload || this.noContinuousScroll) return + this.#filling = true + try { + const { size } = this + const minPages = 5 + const maxSections = 8 + // Load forward sections until we have enough pages ahead + let iterations = 0 + while (this.#views.size < maxSections && iterations < maxSections) { + iterations++ + const pagesAhead = size > 0 + ? Math.floor((this.#renderedViewSize - this.#renderedEnd) / size) + : 0 + if (pagesAhead >= minPages) break + const sorted = this.#sortedViews + const lastIndex = sorted[sorted.length - 1]?.[0] + if (lastIndex == null) break + const nextIdx = this.#adjacentIndex(1, lastIndex) + if (nextIdx == null) break + // Stop preloading at writing-mode boundaries + if (!this.#isSameDirection(nextIdx)) break + await this.#loadAdjacentSection(nextIdx) + if (!this.#views.has(nextIdx)) break + } + // Wait a frame so ResizeObserver callbacks fire while + // #filling is still true, preventing onExpand from + // re-scrolling to a stale anchor position. + await new Promise(r => requestAnimationFrame(r)) + } finally { + this.#filling = false + this.dispatchEvent(new Event('stabilized')) + } + } + #afterScroll(reason) { + // In multi-view, detect which section is primary + if (this.#views.size > 1 && reason !== 'anchor' && reason !== 'navigation') { + this.#detectPrimaryView() + // Scrolling can bring a previously off-screen view into the + // viewport (e.g. the next section's first column joining the + // current section's last column in a dual-page spread) without + // changing which view is primary. Re-sync a11y attributes so + // a newly visible view stops being aria-hidden. + this.#syncA11y() + } + const { range, index: visibleIndex } = this.#getVisibleRange() || {} + if (!range) return + this.#lastVisibleRange = range + // don't set new anchor if relocation was to scroll to anchor + if (reason !== 'selection' && reason !== 'navigation' && reason !== 'anchor') + this.#anchor = range + else this.#justAnchored = true + + const index = visibleIndex ?? this.#primaryIndex + const primaryView = this.#primaryView + const detail = { reason, range, index } + if (this.scrolled) { + // The relocated index may differ from #primaryIndex (the centre of + // the viewport can sit in a different view than its top edge), so + // size the fraction against the relocated view to keep it in sync. + const indexView = this.#views.get(index) ?? primaryView + const primaryOffset = this.#getViewOffset(index) + const primarySize = indexView + ? indexView.element.getBoundingClientRect()[this.sideProp] : this.#renderedViewSize + detail.fraction = primarySize > 0 + ? Math.max(0, Math.min(1, (this.#renderedStart - primaryOffset) / primarySize)) : 0 + } else if (this.#renderedPages > 0 && primaryView) { + const page = this.#renderedPage + const pagesBeforePrimary = this.#getPagesBeforeView(index) + const textPages = primaryView.contentPages + this.#header.style.visibility = page > 0 ? 'visible' : 'hidden' + // page is in spread units, textPages is in column units + const localPage = page - pagesBeforePrimary + const localColumn = localPage * this.columnCount + detail.fraction = textPages > 0 ? Math.max(0, Math.min(1, localColumn / textPages)) : 0 + detail.size = textPages > 0 ? this.columnCount / textPages : 1 + if (reason === 'container-scroll' && localPage === 0) return + } + // Update per-column backgrounds for the current scroll position + if (!this.scrolled) this.#replaceBackground() + this.dispatchEvent(new CustomEvent('relocate', { detail })) + } + async #display(promise) { + this.#stabilizing = true + this.#container.style.opacity = '0' + const { index, src, data, anchor, onLoad, select } = await promise + this.#primaryIndex = index + this.#syncA11y() + const hasFocus = this.#primaryView?.document?.hasFocus() + if (src) { + const view = this.#createView(index) + const afterLoad = doc => { + if (doc.head) { + const $styleBefore = doc.createElement('style') + doc.head.prepend($styleBefore) + const $style = doc.createElement('style') + doc.head.append($style) + this.sections[index].spineProperties?.forEach( + prop => doc.documentElement.setAttribute('data-' + prop, '')) + this.#styleMap.set(doc, [$styleBefore, $style]) + } + onLoad?.({ doc, index }) + } + const beforeRender = this.#beforeRender.bind(this) + await view.load(src, data, afterLoad, beforeRender) + if (!view.document?.documentElement || !view.document.body) { + this.#destroyView(index) + this.#primaryIndex = this.#sortedViews[0]?.[0] ?? -1 + this.#container.style.opacity = '1' + this.#stabilizing = false + this.dispatchEvent(new Event('stabilized')) + return + } + // Cache direction for future preload boundary checks + if (view.document) { + const dir = getDirection(view.document) + this.#directionCache.set(index, dir.vertical) + } + this.dispatchEvent(new CustomEvent('create-overlayer', { + detail: { + doc: view.document, index, + attach: overlayer => view.overlayer = overlayer, + }, + })) + } + // Pre-load previous section when needed: + // - Short primary alignment (section shorter than one spread) + // - Scrolled mode with anchor in top half — so the user can + // scroll backward into the previous section immediately + const primaryView = this.#primaryView + if (!this.noPreload && !this.noContinuousScroll && primaryView) { + const needsPrev = (primaryView.contentPages > 0 && primaryView.contentPages < this.columnCount) + if (needsPrev || this.scrolled) { + const sorted = this.#sortedViews + const firstIndex = sorted[0]?.[0] + if (firstIndex != null) { + const prevIdx = this.#adjacentIndex(-1, firstIndex) + if (prevIdx != null && this.#isSameDirection(prevIdx)) { + await this.#loadAdjacentSection(prevIdx) + } + } + } + } + const resolvedAnchor = (typeof anchor === 'function' + ? anchor(primaryView.document) : anchor) ?? 0 + await this.scrollToAnchor(resolvedAnchor, select) + if (hasFocus) this.focusView() + // Reveal content now that primary section is positioned + this.#container.style.opacity = '1' + this.#rendered = true + // Emit stabilized so listeners can react, but keep #stabilizing + // true until fill completes to prevent the debounced scroll + // handler from loading backward sections during rapid DOM changes. + this.dispatchEvent(new Event('stabilized')) + // Load remaining adjacent sections progressively (non-blocking). + // In scrolled mode, skip reanchor — browser scroll anchoring + // preserves position when content is added above/below. + this.#fillPromise = this.#fillVisibleArea( + { reanchor: !this.scrolled }) + this.#fillPromise.then(() => { this.#stabilizing = false }) + } + // Load an adjacent section without changing primary index + async #loadAdjacentSection(index) { + if (this.#views.has(index) || !this.#canGoToIndex(index)) return + const section = this.sections[index] + if (!section || section.linear === 'no') return + // Detect a prepend: a section being inserted *above* every currently + // loaded view in scrolled mode. The browser suppresses scroll + // anchoring while scrollTop is 0, so the inserted section would push + // the visible content down and the viewport would drift into the + // previous section (readest/readest#4112). Capture the scroll position + // before the insertion so it can be restored once the view renders. + const firstIndex = this.#sortedViews[0]?.[0] + const isPrepend = this.scrolled && firstIndex != null && index < firstIndex + const startBefore = isPrepend ? this.#renderedStart : 0 + try { + const src = await section.load() + const data = await section.loadContent?.() + const view = this.#createView(index) + const afterLoad = doc => { + if (doc.head) { + const $styleBefore = doc.createElement('style') + doc.head.prepend($styleBefore) + const $style = doc.createElement('style') + doc.head.append($style) + section.spineProperties?.forEach( + prop => doc.documentElement.setAttribute('data-' + prop, '')) + this.#styleMap.set(doc, [$styleBefore, $style]) + } + this.setStyles(this.#styles) + this.dispatchEvent(new CustomEvent('load', { detail: { doc, index } })) + } + // Adjacent sections reuse the primary view's cached layout + // — they must NOT call #beforeRender, which would modify + // global state (direction, CSS classes, dir attribute, etc.). + const cachedLayout = this.#lastLayout + const beforeRender = () => cachedLayout + await view.load(src, data, afterLoad, beforeRender) + if (!view.document?.documentElement || !view.document.body) { + this.#destroyView(index) + return + } + // Cache direction for future preload boundary checks + if (view.document) { + const dir = getDirection(view.document) + this.#directionCache.set(index, dir.vertical) + // Destroy views with a different writing-mode immediately. + // Mixed-direction views corrupt scroll/page calculations. + if (dir.vertical !== this.#vertical) { + this.#destroyView(index) + return + } + } + // Keep the previously visible content anchored: the new view added + // `addedSize` px above it, so the scroll position must grow by the + // same amount. This corrects the browser's scroll-anchoring + // suppression at scrollTop 0 and is a no-op when anchoring already + // handled the shift (correction ≈ 0). + if (isPrepend) { + const addedSize = view.element.getBoundingClientRect()[this.sideProp] + const correction = startBefore + addedSize - this.#renderedStart + if (Math.abs(correction) > 0.5) + this.containerPosition += (this.#vertical ? -1 : 1) * correction + } + this.dispatchEvent(new CustomEvent('create-overlayer', { + detail: { + doc: view.document, index, + attach: overlayer => view.overlayer = overlayer, + }, + })) + } catch (e) { + console.warn(e) + console.warn(new Error(`Failed to load adjacent section ${index}`)) + } + } + // Fill adjacent sections until at least `minPages` pages exist + // beyond the current viewport in each direction (forward always, + // backward only when the primary section is short). + // When reanchor is false (background pre-loading), skip re-scrolling + // to avoid fighting with the user's current scroll position. + async #fillVisibleArea({ reanchor = true } = {}) { + if (this.noPreload || this.noContinuousScroll || this.#filling) return + this.#filling = true + try { + const { size } = this + if (!size) return + const minPages = 5 + const maxSections = 8 + + // If the primary section is shorter than one spread and + // there's no section already loaded before it, load the + // previous section to fill the leading columns + const primaryView = this.#primaryView + if (primaryView && primaryView.contentPages > 0 + && primaryView.contentPages < this.columnCount) { + const sorted = this.#sortedViews + const firstIndex = sorted[0]?.[0] + if (firstIndex != null && firstIndex >= this.#primaryIndex) { + const prevIdx = this.#adjacentIndex(-1, firstIndex) + if (prevIdx != null && this.#isSameDirection(prevIdx)) { + await this.#loadAdjacentSection(prevIdx) + } + } + } + + // Load forward sections until we have enough pages ahead + let iterations = 0 + while (this.#views.size < maxSections && iterations < maxSections) { + iterations++ + const pagesAhead = Math.floor( + (this.#renderedViewSize - this.#renderedEnd) / size) + if (pagesAhead >= minPages) break + const sorted = this.#sortedViews + const lastIndex = sorted[sorted.length - 1]?.[0] + if (lastIndex == null) break + const nextIdx = this.#adjacentIndex(1, lastIndex) + if (nextIdx == null) break + // Stop at writing-mode boundaries + if (!this.#isSameDirection(nextIdx)) break + await this.#loadAdjacentSection(nextIdx) + if (!this.#views.has(nextIdx)) break + } + if (reanchor) this.#scrollToAnchor(this.#anchor) + } finally { + this.#filling = false + // Emit stabilized so post-layout processing (e.g. warichu) + // runs for newly loaded adjacent sections. + this.dispatchEvent(new Event('stabilized')) + } + } + // Trim views whose content is entirely more than 10 pages away + // from the current viewport. Only removes views AFTER the primary + // — removing views before would shift scroll position. + #trimDistantViews() { + const { size } = this + if (!size) return + const maxDistance = size * 10 + const viewportEnd = this.#renderedEnd + for (const [index, view] of this.#sortedViews) { + if (index <= this.#primaryIndex) continue + const offset = this.#getViewOffset(index) + if (offset - viewportEnd > maxDistance) { + this.#destroyView(index) + } + } + } + #canGoToIndex(index) { + return index >= 0 && index <= this.sections.length - 1 + } + async #goTo({ index, anchor, select }) { + const section = this.sections[index] + if (!section) return + // Check if the target section has a different writing-mode. + // If direction changes, we must destroy all views and do a full + // rebuild via #display — mixed-direction views cannot coexist. + let directionChanged = false + if (this.#views.has(index)) { + const view = this.#views.get(index) + if (view?.document) { + const { vertical } = getDirection(view.document) + directionChanged = vertical !== this.#vertical + } + } else if (this.#directionCache.has(index)) { + directionChanged = this.#directionCache.get(index) !== this.#vertical + } + // When direction is unknown (not cached), #beforeRender will + // detect and clean up stale views if a change actually occurs. + + if (this.#views.has(index) && !directionChanged) { + // View already loaded — reuse it without + // clearing/reloading. Just change primary and scroll. + this.#stabilizing = true + // Continuous scrolled mode keeps the target view rendered, so we + // scroll straight to it without fading the container — fading + // produced a hard blank-screen flash on adjacent navigation + // (readest/readest#4112 follow-up). Paginated mode and discrete + // no-continuous-scroll still fade to hide the page reposition. + const blank = !this.scrolled || this.noContinuousScroll + if (blank) this.#container.style.opacity = '0' + const hasFocus = this.#primaryView?.document?.hasFocus() + this.#primaryIndex = index + this.#syncA11y() + this.#trimDistantViews() + // In noContinuousScroll mode, destroy all non-primary views + if (this.noContinuousScroll) { + for (const [i] of this.#views) { + if (i !== index) this.#destroyView(i) + } + } + const primaryView = this.#primaryView + const resolvedAnchor = (typeof anchor === 'function' + ? anchor(primaryView.document) : anchor) ?? 0 + // Pre-load the previous section so the user can move backward right + // away: a short paginated primary needs it to fill the leading + // columns; scrolled mode needs it so scrolling up reveals the + // previous section instead of dead-ending at the top (the debounced + // backward-preload can't cover this — it bails while navigation is + // stabilizing). Paginated must load it before revealing; scrolled + // mode loads it after the scroll so the transition stays instant, + // with #loadAdjacentSection compensation keeping the viewport + // anchored as the section is inserted above. + const needsPrev = primaryView && primaryView.contentPages > 0 + && primaryView.contentPages < this.columnCount + const loadPrev = async () => { + if (this.noPreload || this.noContinuousScroll) return + if (!(needsPrev || this.scrolled)) return + const firstIndex = this.#sortedViews[0]?.[0] + if (firstIndex == null) return + const prevIdx = this.#adjacentIndex(-1, firstIndex) + if (prevIdx != null && this.#isSameDirection(prevIdx)) + await this.#loadAdjacentSection(prevIdx) + } + if (!this.scrolled) await loadPrev() + await this.scrollToAnchor(resolvedAnchor, select) + if (this.scrolled) await loadPrev() + if (blank) this.#container.style.opacity = '1' + if (hasFocus) this.focusView() + // Load remaining adjacent sections progressively; + // keep #stabilizing true until fill completes + this.#fillPromise = this.#fillVisibleArea() + this.#fillPromise.then(() => { this.#stabilizing = false }) + } else { + // When direction changes, clear ALL views — no reuse possible + // across writing-mode boundaries. When direction is unknown + // (not yet cached), keep nearby views; #beforeRender will + // clean up if the loaded section turns out to differ. + if (directionChanged) { + this.#destroyAllViews() + } else { + const keep = new Set([index]) + if (!this.noContinuousScroll) { + for (const [i] of this.#views) { + if (Math.abs(i - index) <= 2) keep.add(i) + } + } + this.#clearViewsExcept(keep) + } + const oldIndex = this.#primaryIndex + const onLoad = detail => { + if (oldIndex >= 0 && !this.#views.has(oldIndex)) + this.sections[oldIndex]?.unload?.() + this.setStyles(this.#styles) + this.dispatchEvent(new CustomEvent('load', { detail })) + } + await this.#display(Promise.resolve(section.load()) + .then(async src => { + const data = await section.loadContent?.() + return { index, src, data, anchor, onLoad, select } + }).catch(e => { + console.warn(e) + console.warn(new Error(`Failed to load section ${index}`)) + return {} + })) + } + } + async goTo(target) { + if (this.#locked) return + const resolved = await target + if (this.#canGoToIndex(resolved.index)) return this.#goTo(resolved) + } + #scrollPrev(distance) { + if (this.#views.size === 0) return true + if (this.scrolled) { + if (this.#renderedStart > 0) return this.#scrollTo( + Math.max(0, this.#renderedStart - (distance ?? this.size)), null, true) + return !this.atStart + } + if (this.atStart) return + const page = this.#renderedPage - 1 + // Out of range — skip animation, go straight to previous section + if (page < 0) return true + return this.#scrollToPage(page, 'page', true) + } + #scrollNext(distance) { + if (this.#views.size === 0) return true + if (this.scrolled) { + if (this.#renderedViewSize - this.#renderedEnd > 2) return this.#scrollTo( + Math.min(this.#renderedViewSize, distance ? this.#renderedStart + distance : this.#renderedEnd), null, true) + return !this.atEnd + } + if (this.atEnd) return + const page = this.#renderedPage + 1 + const pages = this.#renderedPages + // Out of range — skip animation, go straight to next section + if (page >= pages) return true + return this.#scrollToPage(page, 'page', true) + } + get atStart() { + const sorted = this.#sortedViews + const firstIndex = sorted[0]?.[0] ?? this.#primaryIndex + if (this.scrolled) return this.#adjacentIndex(-1, firstIndex) == null && this.#renderedStart <= 0 + return this.#adjacentIndex(-1, firstIndex) == null && this.#renderedPage <= 0 + } + get atEnd() { + const sorted = this.#sortedViews + const lastIndex = sorted[sorted.length - 1]?.[0] ?? this.#primaryIndex + if (this.scrolled) return this.#adjacentIndex(1, lastIndex) == null && this.#renderedViewSize - this.#renderedEnd <= 2 + return this.#adjacentIndex(1, lastIndex) == null && this.#renderedPage >= this.#renderedPages - 1 + } + #adjacentIndex(dir, fromIndex) { + if (fromIndex === undefined) fromIndex = this.#primaryIndex + for (let index = fromIndex + dir; this.#canGoToIndex(index); index += dir) + if (this.sections[index]?.linear !== 'no') return index + } + async #turnPage(dir, distance) { + if (this.#locked) return + this.#locked = true + const prev = dir === -1 + const shouldGo = await (prev ? this.#scrollPrev(distance) : this.#scrollNext(distance)) + if (shouldGo) { + // Wait for any in-progress background pre-loading to complete — + // it may already be loading the section we need, so awaiting + // it lets #goTo reuse the view instead of loading from scratch + if (this.#fillPromise) await this.#fillPromise + const sorted = this.#sortedViews + const edgeIndex = prev + ? sorted[0]?.[0] ?? this.#primaryIndex + : sorted[sorted.length - 1]?.[0] ?? this.#primaryIndex + await this.#goTo({ + index: this.#adjacentIndex(dir, edgeIndex), + anchor: prev ? () => 1 : () => 0, + }) + } + if (shouldGo || !this.hasAttribute('animated')) await wait(100) + this.#locked = false + } + async prev(distance) { + return await this.#turnPage(-1, distance) + } + async next(distance) { + return await this.#turnPage(1, distance) + } + async pan(dx, dy) { + if (this.#locked) return + this.#locked = true + this.scrollBy(dx, dy) + this.#locked = false + } + prevSection() { + return this.goTo({ index: this.#adjacentIndex(-1) }) + } + nextSection() { + return this.goTo({ index: this.#adjacentIndex(1) }) + } + firstSection() { + const index = this.sections.findIndex(section => section.linear !== 'no') + return this.goTo({ index }) + } + lastSection() { + const index = this.sections.findLastIndex(section => section.linear !== 'no') + return this.goTo({ index }) + } + getContents() { + const contents = [] + for (const [index, view] of this.#sortedViews) { + if (view.document) contents.push({ + index, + overlayer: view.overlayer, + doc: view.document, + }) + } + return contents + } + setStyles(styles) { + this.#styles = styles + for (const [, view] of this.#views) { + const $$styles = this.#styleMap.get(view.document) + if (!$$styles) continue + const [$beforeStyle, $style] = $$styles + if (Array.isArray(styles)) { + const [beforeStyle, style] = styles + $beforeStyle.textContent = beforeStyle + $style.textContent = style + } else $style.textContent = styles + + // needed because the resize observer doesn't work in Firefox + view.document?.fonts?.ready?.then(() => view.expand()) + } + + // NOTE: needs `requestAnimationFrame` in Chromium + const primaryView = this.#primaryView + if (primaryView) { + requestAnimationFrame(() => this.#replaceBackground()) + } + } + focusView() { + this.#primaryView?.document?.defaultView?.focus() + } + showLoupe(winX, winY, { isVertical, color, gap, margin, radius, magnification }) { + this.#primaryView?.showLoupe(winX, winY, { isVertical, color, gap, margin, radius, magnification }) + } + hideLoupe() { + this.#primaryView?.hideLoupe() + } + destroyLoupe() { + this.#primaryView?.destroyLoupe() + } + destroy() { + const transition = (this.#vtDrag ?? this.#vtFinishing)?.transition + ?? this.#vtProgrammatic?.transition + this.#vtDrag = null + this.#vtFinishing = null + this.#vtProgrammatic = null + this.#slideTurnId++ + if (transition || this.#vtNamedHost) { + transition?.ready?.catch(() => {}) + transition?.updateCallbackDone?.catch(() => {}) + try { transition?.skipTransition() } catch { /* already done */ } + this.#vtCleanup() + this.#isAnimating = false + } + this.#observer.unobserve(this) + this.#destroyAllViews() + this.#mediaQuery.removeEventListener('change', this.#mediaQueryListener) + } +} + +customElements.define('foliate-paginator', Paginator) diff --git a/frontend/src/lib/vendor/foliate-js/pdf.js b/frontend/src/lib/vendor/foliate-js/pdf.js new file mode 100644 index 0000000..09e2dee --- /dev/null +++ b/frontend/src/lib/vendor/foliate-js/pdf.js @@ -0,0 +1,11 @@ +// NOT upstream foliate-js. See README.chitai.md. +// +// Chitai renders PDFs with the pdf.js viewer vendored at static/pdfjs/, so +// foliate's PDF backend is not vendored. view.js still references this module +// from makeBook via a static-string dynamic import, which Rollup resolves at +// build time regardless of whether it executes — so the file has to exist. +// +// Throwing at module scope surfaces a legible message in the reader's error +// card if a PDF is ever routed to the EPUB reader by mistake, rather than a +// TypeError from `globalThis.pdfjsLib` being undefined. +throw new Error('foliate-js PDF rendering is not enabled in Chitai'); diff --git a/frontend/src/lib/vendor/foliate-js/progress.js b/frontend/src/lib/vendor/foliate-js/progress.js new file mode 100644 index 0000000..ab0edc1 --- /dev/null +++ b/frontend/src/lib/vendor/foliate-js/progress.js @@ -0,0 +1,224 @@ +// assign a unique ID for each TOC item +const assignIDs = toc => { + let id = 0 + const assignID = item => { + item.id = id++ + if (item.subitems) for (const subitem of item.subitems) assignID(subitem) + } + for (const item of toc) assignID(item) + return toc +} + +const flatten = items => items + .map(item => item.subitems?.length + ? [item, flatten(item.subitems)].flat() + : item) + .flat() + +export class TOCProgress { + async init({ toc, ids, splitHref, getFragment }) { + assignIDs(toc) + const items = flatten(toc) + const grouped = new Map() + for (const [i, item] of items.entries()) { + const [id, fragment] = await splitHref(item?.href) ?? [] + const value = { fragment, item } + if (grouped.has(id)) grouped.get(id).items.push(value) + else grouped.set(id, { prev: items[i - 1], items: [value] }) + } + const map = new Map() + for (const [i, id] of ids.entries()) { + if (grouped.has(id)) map.set(id, grouped.get(id)) + else map.set(id, map.get(ids[i - 1])) + } + this.ids = ids + this.map = map + this.getFragment = getFragment + } + getProgress(index, range) { + if (!this.ids) return + const id = this.ids[index] + const obj = this.map.get(id) + if (!obj) return null + const { prev, items } = obj + if (!items) return prev + if (!range || items.length === 1 && !items[0].fragment) return items[0].item + + const doc = range.startContainer.getRootNode() + for (const [i, { fragment }] of items.entries()) { + const el = this.getFragment(doc, fragment) + if (!el) continue + if (range.comparePoint(el, 0) > 0) + return (items[i - 1]?.item ?? prev) + } + return items[items.length - 1].item + } +} + +export class PageProgress { + #book + #cache = new Map() + #resolveNavigation + + constructor(book, resolveNavigation) { + this.#book = book + this.#resolveNavigation = resolveNavigation + } + + async #getCache(index) { + let cached = this.#cache.get(index) + if (cached) return cached + + const section = this.#book.sections[index] + if (!section?.createDocument) return null + + const doc = await section.createDocument() + const root = doc.body ?? doc.documentElement + const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT) + const nodes = [] + const offsets = [] + let total = 0 + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const len = node.nodeValue?.length ?? 0 + nodes.push(node) + offsets.push(total) + total += len + } + + cached = { doc, nodes, offsets, total } + this.#cache.set(index, cached) + return cached + } + + async getProgress(cfi) { + try { + const nav = this.#resolveNavigation(cfi) + if (!nav) return null + + const { index, anchor } = nav + if (index == null || !anchor) return null + + const cached = await this.#getCache(index) + if (!cached) return null + + const { doc, nodes, offsets, total } = cached + const frag = anchor(doc) + if (!frag) return null + + const isRange = frag instanceof Range + const range = isRange ? frag : doc.createRange() + if (!isRange) range.selectNodeContents(frag) + + const offset = this.#findOffset(range, nodes, offsets, total) + return { + fraction: total > 0 ? offset / total : 0, + index, + } + } catch (e) { + console.error(e) + return null + } + } + + #findOffset(range, nodes, offsets, total) { + if (!nodes.length) return 0 + const container = range.startContainer + // fast path: startContainer is a text node in the index + if (container.nodeType === Node.TEXT_NODE) { + const i = this.#bsearchNode(container, nodes) + if (i >= 0) return offsets[i] + range.startOffset + } + // element container: collapse to start and binary search + const collapsed = range.cloneRange() + collapsed.collapse(true) + const i = this.#bsearchCollapsed(collapsed, nodes) + return i >= 0 ? offsets[i] : total + } + + // binary search for an exact text node by document position + #bsearchNode(target, nodes) { + let low = 0, high = nodes.length - 1 + while (low <= high) { + const mid = (low + high) >> 1 + const node = nodes[mid] + if (node === target) return mid + const pos = node.compareDocumentPosition(target) + if (pos & Node.DOCUMENT_POSITION_FOLLOWING) low = mid + 1 + else high = mid - 1 + } + return -1 + } + + // binary search for the first text node at or after a collapsed range point + // collapsed.comparePoint returns: -1 = node is before, 1 = node is at/after + #bsearchCollapsed(collapsed, nodes) { + let low = 0, high = nodes.length - 1, result = -1 + while (low <= high) { + const mid = (low + high) >> 1 + if (collapsed.comparePoint(nodes[mid], 0) > 0) { + result = mid + high = mid - 1 + } else { + low = mid + 1 + } + } + return result + } +} + +export class SectionProgress { + constructor(sections, sizePerLoc, sizePerTimeUnit) { + this.sizes = sections.map(s => s.linear != 'no' && s.size > 0 ? s.size : 0) + this.sizePerLoc = sizePerLoc + this.sizePerTimeUnit = sizePerTimeUnit + this.sizeTotal = this.sizes.reduce((a, b) => a + b, 0) + this.sectionFractions = this.#getSectionFractions() + } + #getSectionFractions() { + const { sizeTotal } = this + const results = [0] + let sum = 0 + for (const size of this.sizes) results.push((sum += size) / sizeTotal) + return results + } + // get progress given index of and fractions within a section + getProgress(index, fractionInSection, pageFraction = 0) { + const { sizes, sizePerLoc, sizePerTimeUnit, sizeTotal } = this + const sizeInSection = sizes[index] ?? 0 + const sizeBefore = sizes.slice(0, index).reduce((a, b) => a + b, 0) + const size = sizeBefore + fractionInSection * sizeInSection + const nextSize = size + pageFraction * sizeInSection + const remainingTotal = sizeTotal - size + const remainingSection = (1 - fractionInSection) * sizeInSection + return { + fraction: nextSize / sizeTotal, + section: { + current: index, + total: sizes.length, + }, + location: { + current: Math.floor(size / sizePerLoc), + next: Math.floor(nextSize / sizePerLoc), + total: Math.ceil(sizeTotal / sizePerLoc), + }, + time: { + section: remainingSection / sizePerTimeUnit, + total: remainingTotal / sizePerTimeUnit, + }, + } + } + // the inverse of `getProgress` + // get index of and fraction in section based on total fraction + getSection(fraction) { + if (fraction <= 0) return [0, 0] + if (fraction >= 1) return [this.sizes.length - 1, 1] + fraction = fraction + Number.EPSILON + const { sizeTotal } = this + let index = this.sectionFractions.findIndex(x => x > fraction) - 1 + if (index < 0) return [0, 0] + while (!this.sizes[index]) index++ + const fractionInSection = (fraction - this.sectionFractions[index]) + / (this.sizes[index] / sizeTotal) + return [index, fractionInSection] + } +} diff --git a/frontend/src/lib/vendor/foliate-js/search.js b/frontend/src/lib/vendor/foliate-js/search.js new file mode 100644 index 0000000..affc9bc --- /dev/null +++ b/frontend/src/lib/vendor/foliate-js/search.js @@ -0,0 +1,317 @@ +// length for context in excerpts +const CONTEXT_LENGTH = 50 + +const normalizeWhitespace = str => str.replace(/\s+/g, ' ') + +// Gather context preceding the match by walking back across text nodes until we +// have enough for the excerpt. A match can sit in its own text node (e.g. a word +// wrapped in //), leaving no context within the start node itself. +const collectBefore = (strs, index, offset) => { + let str = strs[index].slice(0, offset) + for (let i = index - 1; i >= 0 && normalizeWhitespace(str).trim().length < CONTEXT_LENGTH; i--) + str = strs[i] + str + return str +} + +const collectAfter = (strs, index, offset) => { + let str = strs[index].slice(offset) + for (let i = index + 1; i < strs.length && normalizeWhitespace(str).trim().length < CONTEXT_LENGTH; i++) + str += strs[i] + return str +} + +const makeExcerpt = (strs, { startIndex, startOffset, endIndex, endOffset }) => { + const start = strs[startIndex] + const end = strs[endIndex] + const match = startIndex === endIndex + ? start.slice(startOffset, endOffset) + : start.slice(startOffset) + + strs.slice(startIndex + 1, endIndex).join('') + + end.slice(0, endOffset) + const trimmedStart = normalizeWhitespace(collectBefore(strs, startIndex, startOffset)).trimStart() + const trimmedEnd = normalizeWhitespace(collectAfter(strs, endIndex, endOffset)).trimEnd() + const ellipsisPre = trimmedStart.length < CONTEXT_LENGTH ? '' : '…' + const ellipsisPost = trimmedEnd.length < CONTEXT_LENGTH ? '' : '…' + const pre = `${ellipsisPre}${trimmedStart.slice(-CONTEXT_LENGTH)}` + const post = `${trimmedEnd.slice(0, CONTEXT_LENGTH)}${ellipsisPost}` + return { pre, match, post } +} + +// Cumulative character offsets of the joined `strs`, so a flat offset into +// `strs.join('')` can be mapped back to a (node index, in-node offset) pair. +const buildCum = strs => { + const cum = [0] + for (let i = 0; i < strs.length; i++) cum.push(cum[i] + strs[i].length) + return cum +} + +// Largest node i with cum[i] <= offset; clamps the end-of-text offset to the +// last node's end. Works for both range start and (exclusive) end positions. +const nodeAt = (cum, offset) => { + let lo = 0, hi = cum.length - 2 + if (hi < 0) return { index: 0, offset: 0 } + while (lo < hi) { + const mid = (lo + hi + 1) >> 1 + if (cum[mid] <= offset) lo = mid + else hi = mid - 1 + } + return { index: lo, offset: offset - cum[lo] } +} + +const rangeFromFlat = (cum, start, end) => { + const s = nodeAt(cum, start) + const e = nodeAt(cum, end) + return { startIndex: s.index, startOffset: s.offset, endIndex: e.index, endOffset: e.offset } +} + +const simpleSearch = function* (strs, query, options = {}) { + const { locales = 'en', sensitivity } = options + const matchCase = sensitivity === 'variant' + const haystack = strs.join('') + const lowerHaystack = matchCase ? haystack : haystack.toLocaleLowerCase(locales) + const needle = matchCase ? query : query.toLocaleLowerCase(locales) + const needleLength = needle.length + let index = -1 + let strIndex = -1 + let sum = 0 + do { + index = lowerHaystack.indexOf(needle, index + 1) + if (index > -1) { + while (sum <= index) sum += strs[++strIndex].length + const startIndex = strIndex + const startOffset = index - (sum - strs[strIndex].length) + const end = index + needleLength + while (sum <= end) sum += strs[++strIndex].length + const endIndex = strIndex + const endOffset = end - (sum - strs[strIndex].length) + const range = { startIndex, startOffset, endIndex, endOffset } + yield { range, excerpt: makeExcerpt(strs, range) } + } + } while (index > -1) +} + +const segmenterSearch = function* (strs, query, options = {}) { + const { locales = 'en', granularity = 'word', sensitivity = 'base' } = options + let segmenter, collator + try { + segmenter = new Intl.Segmenter(locales, { usage: 'search', granularity }) + collator = new Intl.Collator(locales, { sensitivity }) + } catch (e) { + console.warn(e) + segmenter = new Intl.Segmenter('en', { usage: 'search', granularity }) + collator = new Intl.Collator('en', { sensitivity }) + } + const queryLength = Array.from(segmenter.segment(query)).length + + const substrArr = [] + let strIndex = 0 + let segments = segmenter.segment(strs[strIndex])[Symbol.iterator]() + main: while (strIndex < strs.length) { + while (substrArr.length < queryLength) { + const { done, value } = segments.next() + if (done) { + // the current string is exhausted + // move on to the next string + strIndex++ + if (strIndex < strs.length) { + segments = segmenter.segment(strs[strIndex])[Symbol.iterator]() + continue + } else break main + } + const { index, segment } = value + // ignore formatting characters + if (!/[^\p{Format}]/u.test(segment)) continue + // normalize whitespace + if (/\s/u.test(segment)) { + if (!/\s/u.test(substrArr[substrArr.length - 1]?.segment)) + substrArr.push({ strIndex, index, segment: ' ' }) + continue + } + value.strIndex = strIndex + substrArr.push(value) + } + const substr = substrArr.map(x => x.segment).join('') + if (collator.compare(query, substr) === 0) { + const endIndex = strIndex + const lastSeg = substrArr[substrArr.length - 1] + const endOffset = lastSeg.index + lastSeg.segment.length + const startIndex = substrArr[0].strIndex + const startOffset = substrArr[0].index + const range = { startIndex, startOffset, endIndex, endOffset } + yield { range, excerpt: makeExcerpt(strs, range) } + } + substrArr.shift() + } +} + +// Calibre-parity regex mode (#4560). Runs a JS RegExp over the joined text and +// maps each match back to a node range. Note: RegExp.exec is synchronous, so a +// catastrophic pattern can still stall this pass — true interruption (a Web +// Worker) is left to a follow-up; here we only cap match count and reject +// invalid patterns. The caller surfaces INVALID_REGEX as a calm inline error. +const MAX_REGEX_MATCHES = 10000 +const regexSearch = function* (strs, query, options = {}) { + const { matchCase } = options + const flags = matchCase ? 'g' : 'gi' + let re + try { + re = new RegExp(query, flags + 'u') + } catch { + // Some patterns are valid only without the unicode flag; fall back. + try { + re = new RegExp(query, flags) + } catch (e) { + const err = new Error(`Invalid regular expression: ${e.message}`) + err.code = 'INVALID_REGEX' + throw err + } + } + const haystack = strs.join('') + const cum = buildCum(strs) + let count = 0 + let m + while ((m = re.exec(haystack)) !== null) { + if (m[0].length === 0) { + // zero-width match: advance to avoid an infinite loop + re.lastIndex = m.index + 1 + continue + } + const range = rangeFromFlat(cum, m.index, m.index + m[0].length) + yield { range, excerpt: makeExcerpt(strs, range) } + if (++count >= MAX_REGEX_MATCHES) break + } +} + +// Segmented excerpt for nearby-words: emphasizes only the matched words inside +// the cluster window, leaving the gap text un-emphasized. `pre`/`match`/`post` +// stay populated for consumers that don't render segments. +const makeNearbyExcerpt = (haystack, matched) => { + const clusterStart = matched[0].start + const clusterEnd = matched[matched.length - 1].end + const trimmedStart = normalizeWhitespace(haystack.slice(0, clusterStart)).trimStart() + const trimmedEnd = normalizeWhitespace(haystack.slice(clusterEnd)).trimEnd() + const ellipsisPre = trimmedStart.length < CONTEXT_LENGTH ? '' : '…' + const ellipsisPost = trimmedEnd.length < CONTEXT_LENGTH ? '' : '…' + const pre = `${ellipsisPre}${trimmedStart.slice(-CONTEXT_LENGTH)}` + const post = `${trimmedEnd.slice(0, CONTEXT_LENGTH)}${ellipsisPost}` + const segments = [] + let cursor = clusterStart + for (const o of matched) { + if (o.start > cursor) { + const gap = normalizeWhitespace(haystack.slice(cursor, o.start)) + if (gap) segments.push({ text: gap, emphasized: false }) + } + segments.push({ text: normalizeWhitespace(haystack.slice(o.start, o.end)), emphasized: true }) + cursor = o.end + } + const match = normalizeWhitespace(haystack.slice(clusterStart, clusterEnd)) + return { pre, match, post, segments } +} + +// Calibre-parity nearby-words mode (#4560): matches places where all of the +// query's distinct whole words occur within `nearbyWords` words of each other. +// Distance is measured in words (not characters) and comes from the option, not +// from the query string, so trailing numbers stay literal search words. +const nearbyWordsSearch = function* (strs, query, options = {}) { + const { locales = 'en', sensitivity = 'base', nearbyWords = 10 } = options + const queryWords = [] + for (const w of query.split(/\s+/).filter(Boolean)) if (!queryWords.includes(w)) queryWords.push(w) + if (queryWords.length < 2) { + const err = new Error('Nearby words search needs at least two words') + err.code = 'NEARBY_NEEDS_TWO_WORDS' + throw err + } + let segmenter, collator + try { + segmenter = new Intl.Segmenter(locales, { usage: 'search', granularity: 'word' }) + collator = new Intl.Collator(locales, { sensitivity }) + } catch (e) { + console.warn(e) + segmenter = new Intl.Segmenter('en', { usage: 'search', granularity: 'word' }) + collator = new Intl.Collator('en', { sensitivity }) + } + const haystack = strs.join('') + const cum = buildCum(strs) + const K = queryWords.length + + // Word occurrences of any query word, tagged with a global word index. + const occ = [] + let wordIndex = -1 + for (const seg of segmenter.segment(haystack)) { + if (!seg.isWordLike) continue + wordIndex++ + for (let q = 0; q < K; q++) { + if (collator.compare(queryWords[q], seg.segment) === 0) { + occ.push({ wordIndex, qIdx: q, start: seg.index, end: seg.index + seg.segment.length }) + break + } + } + } + + // Smallest window covering all K distinct query words, two-pointer scan. + const have = new Array(K).fill(0) + let distinct = 0 + let lo = 0 + const windows = [] + for (let hi = 0; hi < occ.length; hi++) { + if (have[occ[hi].qIdx]++ === 0) distinct++ + let minimal = null + while (distinct === K) { + minimal = { lo, hi } + if (--have[occ[lo].qIdx] === 0) distinct-- + lo++ + } + if (minimal && occ[minimal.hi].wordIndex - occ[minimal.lo].wordIndex <= nearbyWords) + windows.push(minimal) + } + + // One cluster per window; suppress windows overlapping an already-emitted one. + let lastHi = -1 + for (const w of windows) { + if (w.lo <= lastHi) continue + lastHi = w.hi + const matched = occ.slice(w.lo, w.hi + 1) + const excerpt = makeNearbyExcerpt(haystack, matched) + const range = rangeFromFlat(cum, matched[0].start, matched[matched.length - 1].end) + const subRanges = matched.map(o => rangeFromFlat(cum, o.start, o.end)) + yield { range, excerpt, subRanges } + } +} + +export const search = (strs, query, options) => { + const { mode } = options + if (mode === 'regex') return regexSearch(strs, query, options) + if (mode === 'nearby-words') return nearbyWordsSearch(strs, query, options) + const { granularity = 'grapheme', sensitivity = 'base' } = options + if (!Intl?.Segmenter || granularity === 'grapheme' + && (sensitivity === 'variant' || sensitivity === 'accent')) + return simpleSearch(strs, query, options) + return segmenterSearch(strs, query, options) +} + +export const searchMatcher = (textWalker, opts) => { + const { defaultLocale, matchCase, matchDiacritics, matchWholeWords, mode, nearbyWords, acceptNode } = opts + const effectiveMode = mode ?? (matchWholeWords ? 'whole-words' : 'contains') + return function* (doc, query) { + const iter = textWalker(doc, function* (strs, makeRange) { + for (const result of search(strs, query, { + mode: effectiveMode, + nearbyWords, + matchCase, + locales: doc.body.lang || doc.documentElement.lang || defaultLocale || 'en', + granularity: effectiveMode === 'whole-words' ? 'word' : 'grapheme', + sensitivity: matchDiacritics && matchCase ? 'variant' + : matchDiacritics && !matchCase ? 'accent' + : !matchDiacritics && matchCase ? 'case' + : 'base', + })) { + const { startIndex, startOffset, endIndex, endOffset } = result.range + result.range = makeRange(startIndex, startOffset, endIndex, endOffset) + if (result.subRanges) result.subRanges = result.subRanges.map( + r => makeRange(r.startIndex, r.startOffset, r.endIndex, r.endOffset)) + yield result + } + }, acceptNode) + for (const result of iter) yield result + } +} diff --git a/frontend/src/lib/vendor/foliate-js/text-walker.js b/frontend/src/lib/vendor/foliate-js/text-walker.js new file mode 100644 index 0000000..c1249b7 --- /dev/null +++ b/frontend/src/lib/vendor/foliate-js/text-walker.js @@ -0,0 +1,43 @@ +const walkRange = (range, walker) => { + const nodes = [] + for (let node = walker.currentNode; node; node = walker.nextNode()) { + const compare = range.comparePoint(node, 0) + if (compare === 0) nodes.push(node) + else if (compare > 0) break + } + return nodes +} + +const walkDocument = (_, walker) => { + const nodes = [] + for (let node = walker.nextNode(); node; node = walker.nextNode()) + nodes.push(node) + return nodes +} + +const filter = NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT + | NodeFilter.SHOW_CDATA_SECTION + +const acceptNode = node => { + if (node.nodeType === 1) { + const name = node.tagName.toLowerCase() + if (name === 'script' || name === 'style') return NodeFilter.FILTER_REJECT + return NodeFilter.FILTER_SKIP + } + return NodeFilter.FILTER_ACCEPT +} + +export const textWalker = function* (x, func, filterFunc) { + const root = x.commonAncestorContainer ?? x.body ?? x + const walker = document.createTreeWalker(root, filter, { acceptNode: filterFunc || acceptNode }) + const walk = x.commonAncestorContainer ? walkRange : walkDocument + const nodes = walk(x, walker) + const strs = nodes.map(node => node.nodeValue ?? '') + const makeRange = (startIndex, startOffset, endIndex, endOffset) => { + const range = document.createRange() + range.setStart(nodes[startIndex], startOffset) + range.setEnd(nodes[endIndex], endOffset) + return range + } + for (const match of func(strs, makeRange)) yield match +} diff --git a/frontend/src/lib/vendor/foliate-js/tts.js b/frontend/src/lib/vendor/foliate-js/tts.js new file mode 100644 index 0000000..05c9b5d --- /dev/null +++ b/frontend/src/lib/vendor/foliate-js/tts.js @@ -0,0 +1,502 @@ +const NS = { + XML: 'http://www.w3.org/XML/1998/namespace', + SSML: 'http://www.w3.org/2001/10/synthesis', +} + +const blockTags = new Set([ + 'article', 'aside', 'audio', 'blockquote', 'caption', + 'details', 'dialog', 'div', 'dl', 'dt', 'dd', + 'figure', 'footer', 'form', 'figcaption', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'li', + 'main', 'math', 'nav', 'ol', 'p', 'pre', 'section', 'tr', +]) + +const getLang = el => { + const x = el.lang || el?.getAttributeNS?.(NS.XML, 'lang') + return x ? x : el.parentElement ? getLang(el.parentElement) : null +} + +const getAlphabet = el => { + const x = el?.getAttributeNS?.(NS.XML, 'lang') + return x ? x : el.parentElement ? getAlphabet(el.parentElement) : null +} + +const getSegmenter = (lang, granularity = 'word') => { + const segmenter = new Intl.Segmenter(lang || undefined, { granularity }) + const granularityIsWord = granularity === 'word' + return function* (strs, makeRange) { + const str = strs.join('').replace(/\r\n/g, ' ').replace(/\r/g, ' ').replace(/\n/g, ' ') + let name = 0 + let strIndex = -1 + let sum = 0 + const rawSegments = Array.from(segmenter.segment(str)) + const mergedSegments = [] + for (let i = 0, j = 0; i < rawSegments.length; i++) { + const current = rawSegments[i] + const segment = ' ' + current.segment + const endsWithAbbr = /\s([A-Z]{1,2}[a-z]{0,5}|[a-z]{1,3})\.\s*$/.test(segment) + if (!endsWithAbbr || i >= (rawSegments.length-1)) { + const mergedSegment = { + index: rawSegments[j].index, + segment: '', + isWordLike: (i == j) ? current.isWordLike : true, + } + while (j <= i) { + mergedSegment.segment += rawSegments[j++].segment + } + mergedSegments.push(mergedSegment) + } + } + + for (const { index, segment, isWordLike } of mergedSegments) { + if (granularityIsWord && !isWordLike) continue + while (sum <= index) sum += strs[++strIndex].length + const startIndex = strIndex + const startOffset = index - (sum - strs[strIndex].length) + const end = index + segment.length - 1 + if (end < str.length) while (sum <= end) sum += strs[++strIndex].length + const endIndex = strIndex + const endOffset = end - (sum - strs[strIndex].length) + 1 + yield [(name++).toString(), + makeRange(startIndex, startOffset, endIndex, endOffset)] + } + } +} + +const fragmentToSSML = (fragment, nodeFilter, inherited) => { + const ssml = document.implementation.createDocument(NS.SSML, 'speak') + const { lang } = inherited + if (lang) ssml.documentElement.setAttributeNS(NS.XML, 'lang', lang) + + const convert = (node, parent, inheritedAlphabet) => { + if (!node) return + // Text nodes go through the filter too: the text walker that produces + // the marks already honours it, and content that is skipped there (a + // bare ruby base inside , say) must not reach the speech either, + // or the two disagree about what is being read. + if (node.nodeType === 3 || node.nodeType === 4) { + if (nodeFilter && nodeFilter(node) === NodeFilter.FILTER_REJECT) return + return node.nodeType === 3 + ? ssml.createTextNode(node.textContent) + : ssml.createCDATASection(node.textContent) + } + if (node.nodeType !== 1 && node.nodeType !== 11) return + if (nodeFilter && nodeFilter(node) === NodeFilter.FILTER_REJECT) return + + let el + const nodeName = node.nodeName.toLowerCase() + if (nodeName === 'foliate-mark') { + el = ssml.createElementNS(NS.SSML, 'mark') + el.setAttribute('name', node.dataset.name) + } + else if (nodeName === 'br') + el = ssml.createElementNS(NS.SSML, 'break') + else if (nodeName === 'em' || nodeName === 'strong') + el = ssml.createElementNS(NS.SSML, 'emphasis') + + const lang = node.lang || node.getAttributeNS?.(NS.XML, 'lang') + if (lang) { + if (!el) el = ssml.createElementNS(NS.SSML, 'lang') + el.setAttributeNS(NS.XML, 'lang', lang) + } + + const alphabet = node.getAttributeNS?.(NS.SSML, 'alphabet') || inheritedAlphabet + if (!el) { + const ph = node.getAttributeNS?.(NS.SSML, 'ph') + if (ph) { + el = ssml.createElementNS(NS.SSML, 'phoneme') + if (alphabet) el.setAttribute('alphabet', alphabet) + el.setAttribute('ph', ph) + } + } + + if (!el) el = parent + + let child = node.firstChild + while (child) { + const childEl = convert(child, el, alphabet) + if (childEl && el !== childEl) el.append(childEl) + child = child.nextSibling + } + return el + } + convert(fragment, ssml.documentElement, inherited.alphabet) + return ssml +} + +const getFragmentWithMarks = (range, textWalker, nodeFilter, granularity) => { + const lang = getLang(range.commonAncestorContainer) + const alphabet = getAlphabet(range.commonAncestorContainer) + + const segmenter = getSegmenter(lang, granularity) + const fragment = range.cloneContents() + + // we need ranges on both the original document (for highlighting) + // and the document fragment (for inserting marks) + // so unfortunately need to do it twice, as you can't copy the ranges + const entries = [...textWalker(range, segmenter, nodeFilter)] + const fragmentEntries = [...textWalker(fragment, segmenter, nodeFilter)] + + for (const [name, range] of fragmentEntries) { + const mark = document.createElement('foliate-mark') + mark.dataset.name = name + range.insertNode(mark) + } + const ssml = fragmentToSSML(fragment, nodeFilter, { lang, alphabet }) + return { entries, ssml } +} + +const rangeIsEmpty = range => !range.toString().trim() + +// For PDF text layers, split content into sentence-level blocks so TTS +// reads one sentence at a time instead of the whole page in one block. +// Text nodes are split at sentence boundaries so that every block range +// aligns with node edges — this prevents the text walker from including +// text outside the sentence in word marks. +function* getPDFSentenceBlocks(doc, textLayer) { + const collectNodes = () => { + const w = doc.createTreeWalker(textLayer, NodeFilter.SHOW_TEXT) + const res = [] + for (let n = w.nextNode(); n; n = w.nextNode()) res.push(n) + return res + } + + let nodes = collectNodes() + if (!nodes.length) return + + const fullText = nodes.map(n => n.nodeValue).join('') + if (!fullText.trim()) return + + // Find sentence boundary positions + const lang = getLang(textLayer) || undefined + const segmenter = new Intl.Segmenter(lang, { granularity: 'sentence' }) + const boundaries = new Set() + for (const { index } of segmenter.segment(fullText)) + if (index > 0) boundaries.add(index) + + // Split text nodes at sentence boundaries so ranges align with node edges. + // Process in reverse order to preserve earlier character positions. + let cum = 0 + const nodeStarts = nodes.map(n => { const s = cum; cum += n.nodeValue.length; return s }) + + for (const pos of [...boundaries].sort((a, b) => b - a)) { + for (let i = 0; i < nodes.length; i++) { + const start = nodeStarts[i] + const end = start + nodes[i].nodeValue.length + if (pos > start && pos < end) { + nodes[i].splitText(pos - start) + break + } + } + } + + // Re-collect nodes after splits and group into sentence blocks + nodes = collectNodes() + cum = 0 + let groupStart = 0 + let blockCount = 0 + + for (let i = 0; i < nodes.length; i++) { + cum += nodes[i].nodeValue.length + const isEnd = i === nodes.length - 1 || boundaries.has(cum) + if (isEnd) { + const range = doc.createRange() + range.setStart(nodes[groupStart], 0) + range.setEnd(nodes[i], nodes[i].nodeValue.length) + if (!rangeIsEmpty(range)) { + blockCount++ + yield range + } + groupStart = i + 1 + } + } +} + +function* getBlocks(doc, nodeFilter) { + const root = doc.body + ?? doc.querySelector('body') + ?? doc.documentElement + + // For PDF text layers, yield sentence-level blocks + const textLayer = root.querySelector?.('.textLayer') + if (textLayer) { + yield* getPDFSentenceBlocks(doc, textLayer) + return + } + + let last + let sawBlock = false + let sawSkipped = false + const walker = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT) + let node = walker.nextNode() + while (node) { + const name = node.tagName.toLowerCase() + // A rejected block element (e.g. a footnote/endnote aside) must not be + // read: skip its whole subtree and end the preceding block before it + // so its text doesn't leak into the adjacent block. Inline rejects are + // left to the text walker in getFragmentWithMarks(). + if (blockTags.has(name) + && nodeFilter?.(node) === NodeFilter.FILTER_REJECT) { + sawSkipped = true + if (last) { + last.setEndBefore(node) + if (!rangeIsEmpty(last)) yield last + last = null + } + const skipped = node + do node = walker.nextNode() + while (node && (skipped.compareDocumentPosition(node) + & Node.DOCUMENT_POSITION_CONTAINED_BY)) + continue + } + if (blockTags.has(name)) { + if (last) { + last.setEndBefore(node) + if (!rangeIsEmpty(last)) yield last + } + last = doc.createRange() + last.setStart(node, 0) + sawBlock = true + } + node = walker.nextNode() + } + if (last) { + last.setEndAfter(root.lastChild ?? root) + if (!rangeIsEmpty(last)) yield last + } else if (!sawBlock && !sawSkipped) { + last = doc.createRange() + last.setStart(root.firstChild ?? root, 0) + last.setEndAfter(root.lastChild ?? root) + if (!rangeIsEmpty(last)) yield last + } +} + +// Enumerate every TTS segment of the document in order without touching any +// TTS instance state. blockIndex/markName match what a TTS instance produces +// for the same granularity, so callers (e.g. a playback timeline) can +// correlate the enumeration with live marks and use each range with from(). +export function* getSentences(doc, textWalker, nodeFilter, granularity = 'sentence') { + let blockIndex = 0 + for (const range of getBlocks(doc, nodeFilter)) { + const lang = getLang(range.commonAncestorContainer) + const segmenter = getSegmenter(lang, granularity) + for (const [name, segRange] of textWalker(range, segmenter, nodeFilter)) + yield { blockIndex, markName: name, range: segRange } + blockIndex++ + } +} + +class ListIterator { + #arr = [] + #iter + #index = -1 + #f + constructor(iter, f = x => x) { + this.#iter = iter + this.#f = f + } + current() { + if (this.#arr[this.#index]) return this.#f(this.#arr[this.#index]) + } + first() { + const newIndex = 0 + if (this.#arr[newIndex]) { + this.#index = newIndex + return this.#f(this.#arr[newIndex]) + } + } + prev() { + const newIndex = this.#index - 1 + if (this.#arr[newIndex]) { + this.#index = newIndex + return this.#f(this.#arr[newIndex]) + } + } + next() { + const newIndex = this.#index + 1 + if (this.#arr[newIndex]) { + this.#index = newIndex + return this.#f(this.#arr[newIndex]) + } + while (true) { + const { done, value } = this.#iter.next() + if (done) break + this.#arr.push(value) + if (this.#arr[newIndex]) { + this.#index = newIndex + return this.#f(this.#arr[newIndex]) + } + } + } + find(f) { + const index = this.#arr.findIndex(x => f(x)) + if (index > -1) { + this.#index = index + return this.#f(this.#arr[index]) + } + while (true) { + const { done, value } = this.#iter.next() + if (done) break + this.#arr.push(value) + if (f(value)) { + this.#index = this.#arr.length - 1 + return this.#f(value) + } + } + } +} + +export class TTS { + #list + #ranges + #lastMark + #serializer = new XMLSerializer() + constructor(doc, textWalker, nodeFilter, highlight, granularity) { + this.doc = doc + this.highlight = highlight + this.#list = new ListIterator(getBlocks(doc, nodeFilter), range => { + const { entries, ssml } = getFragmentWithMarks(range, textWalker, nodeFilter, granularity) + this.#ranges = new Map(entries) + return [ssml, range] + }) + } + #getMarkElement(doc, mark) { + if (!mark) return null + return doc.querySelector(`mark[name="${CSS.escape(mark)}"`) + } + #speak(doc, getNode) { + if (!doc) return + if (!getNode) return this.#serializer.serializeToString(doc) + const ssml = document.implementation.createDocument(NS.SSML, 'speak') + ssml.documentElement.replaceWith(ssml.importNode(doc.documentElement, true)) + let node = getNode(ssml)?.previousSibling + while (node) { + const next = node.previousSibling ?? node.parentNode?.previousSibling + node.parentNode.removeChild(node) + node = next + } + const ssmlStr = this.#serializer.serializeToString(ssml) + return ssmlStr + } + start() { + this.#lastMark = null + const [doc] = this.#list.first() ?? [] + if (!doc) return this.next() + return this.#speak(doc, ssml => this.#getMarkElement(ssml, this.#lastMark)) + } + resume() { + const [doc] = this.#list.current() ?? [] + if (!doc) return this.next() + return this.#speak(doc, ssml => this.#getMarkElement(ssml, this.#lastMark)) + } + prev(paused) { + this.#lastMark = null + const [doc, range] = this.#list.prev() ?? [] + if (paused && range) this.highlight(range.cloneRange()) + return this.#speak(doc) + } + next(paused) { + this.#lastMark = null + const [doc, range] = this.#list.next() ?? [] + if (paused && range) this.highlight(range.cloneRange()) + return this.#speak(doc) + } + prevMark(paused) { + const marks = Array.from(this.#ranges.keys()) + if (marks.length === 0) return + + const currentIndex = this.#lastMark ? marks.indexOf(this.#lastMark) : -1 + if (currentIndex > 0) { + const prevMarkName = marks[currentIndex - 1] + const range = this.#ranges.get(prevMarkName) + if (range) { + this.#lastMark = prevMarkName + if (paused) this.highlight(range.cloneRange()) + + const [doc] = this.#list.current() ?? [] + return this.#speak(doc, ssml => this.#getMarkElement(ssml, prevMarkName)) + } + } else { + const [doc, range] = this.#list.prev() ?? [] + if (doc && range) { + const prevMarks = Array.from(this.#ranges.keys()) + if (prevMarks.length > 0) { + const lastMarkName = prevMarks[prevMarks.length - 1] + const lastMarkRange = this.#ranges.get(lastMarkName) + if (lastMarkRange) { + this.#lastMark = lastMarkName + if (paused) this.highlight(lastMarkRange.cloneRange()) + return this.#speak(doc, ssml => this.#getMarkElement(ssml, lastMarkName)) + } + } else { + this.#lastMark = null + if (paused) this.highlight(range.cloneRange()) + return this.#speak(doc) + } + } + } + } + nextMark(paused) { + const marks = Array.from(this.#ranges.keys()) + if (marks.length === 0) return + + const currentIndex = this.#lastMark ? marks.indexOf(this.#lastMark) : -1 + if (currentIndex >= 0 && currentIndex < marks.length - 1) { + const nextMarkName = marks[currentIndex + 1] + const range = this.#ranges.get(nextMarkName) + if (range) { + this.#lastMark = nextMarkName + if (paused) this.highlight(range.cloneRange()) + const [doc] = this.#list.current() ?? [] + return this.#speak(doc, ssml => this.#getMarkElement(ssml, nextMarkName)) + } + } else { + const [doc, range] = this.#list.next() ?? [] + if (doc && range) { + const nextMarks = Array.from(this.#ranges.keys()) + if (nextMarks.length > 0) { + const firstMarkName = nextMarks[0] + const firstMarkRange = this.#ranges.get(firstMarkName) + if (firstMarkRange) { + this.#lastMark = firstMarkName + if (paused) this.highlight(firstMarkRange.cloneRange()) + return this.#speak(doc, ssml => this.#getMarkElement(ssml, firstMarkName)) + } + } else { + this.#lastMark = null + if (paused) this.highlight(range.cloneRange()) + return this.#speak(doc) + } + } + } + } + from(range) { + this.#lastMark = null + const [doc] = this.#list.find(range_ => + range.compareBoundaryPoints(Range.END_TO_START, range_) <= 0) + // Pick the mark whose sentence contains the selection: the last mark + // that begins at or before it. Taking the first mark beginning at or + // after the selection skipped to the next sentence whenever the + // selected word was not its sentence's first word. + let mark + for (const [name, range_] of this.#ranges.entries()) { + if (range.compareBoundaryPoints(Range.START_TO_START, range_) < 0) break + mark = name + } + return this.#speak(doc, ssml => this.#getMarkElement(ssml, mark)) + } + getLastRange() { + if (this.#lastMark) { + const range = this.#ranges.get(this.#lastMark) + if (range) return range.cloneRange() + } + } + setMark(mark) { + const range = this.#ranges.get(mark) + if (range) { + this.#lastMark = mark + this.highlight(range.cloneRange()) + return range + } + } +} diff --git a/frontend/src/lib/vendor/foliate-js/vendor/fflate.js b/frontend/src/lib/vendor/foliate-js/vendor/fflate.js new file mode 100644 index 0000000..f275976 --- /dev/null +++ b/frontend/src/lib/vendor/foliate-js/vendor/fflate.js @@ -0,0 +1 @@ +var r=Uint8Array,a=Uint16Array,e=Int32Array,n=new r([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),i=new r([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),t=new r([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),f=function(r,n){for(var i=new a(31),t=0;t<31;++t)i[t]=n+=1<>1|(21845&d)<<1;w=(61680&(w=(52428&w)>>2|(13107&w)<<2))>>4|(3855&w)<<4,c[d]=((65280&w)>>8|(255&w)<<8)>>1}var b=function(r,e,n){for(var i=r.length,t=0,f=new a(e);t>l]=u}else for(o=new a(i),t=0;t>15-r[t]);return o},s=new r(288);for(d=0;d<144;++d)s[d]=8;for(d=144;d<256;++d)s[d]=9;for(d=256;d<280;++d)s[d]=7;for(d=280;d<288;++d)s[d]=8;var h=new r(32);for(d=0;d<32;++d)h[d]=5;var y=b(s,9,1),g=b(h,5,1),p=function(r){for(var a=r[0],e=1;ea&&(a=r[e]);return a},k=function(r,a,e){var n=a/8|0;return(r[n]|r[n+1]<<8)>>(7&a)&e},m=function(r,a){var e=a/8|0;return(r[e]|r[e+1]<<8|r[e+2]<<16)>>(7&a)},x=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],T=function(r,a,e){var n=new Error(a||x[r]);if(n.code=r,Error.captureStackTrace&&Error.captureStackTrace(n,T),!e)throw n;return n},E=function(a,e,f,o){var l=a.length,c=o?o.length:0;if(!l||e.f&&!e.l)return f||new r(0);var d=!f,w=d||2!=e.i,s=e.i;d&&(f=new r(3*l));var h=function(a){var e=f.length;if(a>e){var n=new r(Math.max(2*e,a));n.set(f),f=n}},x=e.f||0,E=e.p||0,z=e.b||0,A=e.l,U=e.d,D=e.m,F=e.n,M=8*l;do{if(!A){x=k(a,E,1);var S=k(a,E+1,3);if(E+=3,!S){var I=a[(N=4+((E+7)/8|0))-4]|a[N-3]<<8,O=N+I;if(O>l){s&&T(0);break}w&&h(z+I),f.set(a.subarray(N,O),z),e.b=z+=I,e.p=E=8*O,e.f=x;continue}if(1==S)A=y,U=g,D=9,F=5;else if(2==S){var j=k(a,E,31)+257,q=k(a,E+10,15)+4,B=j+k(a,E+5,31)+1;E+=14;for(var C=new r(B),G=new r(19),H=0;H>4)<16)C[H++]=N;else{var Q=0,R=0;for(16==N?(R=3+k(a,E,3),E+=2,Q=C[H-1]):17==N?(R=3+k(a,E,7),E+=3):18==N&&(R=11+k(a,E,127),E+=7);R--;)C[H++]=Q}}var V=C.subarray(0,j),W=C.subarray(j);D=p(V),F=p(W),A=b(V,D,1),U=b(W,F,1)}else T(1);if(E>M){s&&T(0);break}}w&&h(z+131072);for(var X=(1<>4;if((E+=15&Q)>M){s&&T(0);break}if(Q||T(2),$<256)f[z++]=$;else{if(256==$){Z=E,A=null;break}var _=$-254;if($>264){var rr=n[H=$-257];_=k(a,E,(1<>4;ar||T(3),E+=15&ar;W=u[er];if(er>3){rr=i[er];W+=m(a,E)&(1<M){s&&T(0);break}w&&h(z+131072);var nr=z+_;if(za.length)&&(n=a.length),new r(a.subarray(e,n))}(f,0,z):f.subarray(0,z)},z=new r(0);function A(r,a){return E(r.subarray((e=r,n=a&&a.dictionary,(8!=(15&e[0])||e[0]>>4>7||(e[0]<<8|e[1])%31)&&T(6,"invalid zlib data"),(e[1]>>5&1)==+!n&&T(6,"invalid zlib data: "+(32&e[1]?"need":"unexpected")+" dictionary"),2+(e[1]>>3&4)),-4),{i:2},a&&a.out,a&&a.dictionary);var e,n}var U="undefined"!=typeof TextDecoder&&new TextDecoder;try{U.decode(z,{stream:!0})}catch(r){}export{A as unzlibSync}; diff --git a/frontend/src/lib/vendor/foliate-js/vendor/zip.js b/frontend/src/lib/vendor/foliate-js/vendor/zip.js new file mode 100644 index 0000000..a65fd86 --- /dev/null +++ b/frontend/src/lib/vendor/foliate-js/vendor/zip.js @@ -0,0 +1 @@ +const e=-2,t=-3,n=-5,i=[0,1,3,7,15,31,63,127,255,511,1023,2047,4095,8191,16383,32767,65535],r=[96,7,256,0,8,80,0,8,16,84,8,115,82,7,31,0,8,112,0,8,48,0,9,192,80,7,10,0,8,96,0,8,32,0,9,160,0,8,0,0,8,128,0,8,64,0,9,224,80,7,6,0,8,88,0,8,24,0,9,144,83,7,59,0,8,120,0,8,56,0,9,208,81,7,17,0,8,104,0,8,40,0,9,176,0,8,8,0,8,136,0,8,72,0,9,240,80,7,4,0,8,84,0,8,20,85,8,227,83,7,43,0,8,116,0,8,52,0,9,200,81,7,13,0,8,100,0,8,36,0,9,168,0,8,4,0,8,132,0,8,68,0,9,232,80,7,8,0,8,92,0,8,28,0,9,152,84,7,83,0,8,124,0,8,60,0,9,216,82,7,23,0,8,108,0,8,44,0,9,184,0,8,12,0,8,140,0,8,76,0,9,248,80,7,3,0,8,82,0,8,18,85,8,163,83,7,35,0,8,114,0,8,50,0,9,196,81,7,11,0,8,98,0,8,34,0,9,164,0,8,2,0,8,130,0,8,66,0,9,228,80,7,7,0,8,90,0,8,26,0,9,148,84,7,67,0,8,122,0,8,58,0,9,212,82,7,19,0,8,106,0,8,42,0,9,180,0,8,10,0,8,138,0,8,74,0,9,244,80,7,5,0,8,86,0,8,22,192,8,0,83,7,51,0,8,118,0,8,54,0,9,204,81,7,15,0,8,102,0,8,38,0,9,172,0,8,6,0,8,134,0,8,70,0,9,236,80,7,9,0,8,94,0,8,30,0,9,156,84,7,99,0,8,126,0,8,62,0,9,220,82,7,27,0,8,110,0,8,46,0,9,188,0,8,14,0,8,142,0,8,78,0,9,252,96,7,256,0,8,81,0,8,17,85,8,131,82,7,31,0,8,113,0,8,49,0,9,194,80,7,10,0,8,97,0,8,33,0,9,162,0,8,1,0,8,129,0,8,65,0,9,226,80,7,6,0,8,89,0,8,25,0,9,146,83,7,59,0,8,121,0,8,57,0,9,210,81,7,17,0,8,105,0,8,41,0,9,178,0,8,9,0,8,137,0,8,73,0,9,242,80,7,4,0,8,85,0,8,21,80,8,258,83,7,43,0,8,117,0,8,53,0,9,202,81,7,13,0,8,101,0,8,37,0,9,170,0,8,5,0,8,133,0,8,69,0,9,234,80,7,8,0,8,93,0,8,29,0,9,154,84,7,83,0,8,125,0,8,61,0,9,218,82,7,23,0,8,109,0,8,45,0,9,186,0,8,13,0,8,141,0,8,77,0,9,250,80,7,3,0,8,83,0,8,19,85,8,195,83,7,35,0,8,115,0,8,51,0,9,198,81,7,11,0,8,99,0,8,35,0,9,166,0,8,3,0,8,131,0,8,67,0,9,230,80,7,7,0,8,91,0,8,27,0,9,150,84,7,67,0,8,123,0,8,59,0,9,214,82,7,19,0,8,107,0,8,43,0,9,182,0,8,11,0,8,139,0,8,75,0,9,246,80,7,5,0,8,87,0,8,23,192,8,0,83,7,51,0,8,119,0,8,55,0,9,206,81,7,15,0,8,103,0,8,39,0,9,174,0,8,7,0,8,135,0,8,71,0,9,238,80,7,9,0,8,95,0,8,31,0,9,158,84,7,99,0,8,127,0,8,63,0,9,222,82,7,27,0,8,111,0,8,47,0,9,190,0,8,15,0,8,143,0,8,79,0,9,254,96,7,256,0,8,80,0,8,16,84,8,115,82,7,31,0,8,112,0,8,48,0,9,193,80,7,10,0,8,96,0,8,32,0,9,161,0,8,0,0,8,128,0,8,64,0,9,225,80,7,6,0,8,88,0,8,24,0,9,145,83,7,59,0,8,120,0,8,56,0,9,209,81,7,17,0,8,104,0,8,40,0,9,177,0,8,8,0,8,136,0,8,72,0,9,241,80,7,4,0,8,84,0,8,20,85,8,227,83,7,43,0,8,116,0,8,52,0,9,201,81,7,13,0,8,100,0,8,36,0,9,169,0,8,4,0,8,132,0,8,68,0,9,233,80,7,8,0,8,92,0,8,28,0,9,153,84,7,83,0,8,124,0,8,60,0,9,217,82,7,23,0,8,108,0,8,44,0,9,185,0,8,12,0,8,140,0,8,76,0,9,249,80,7,3,0,8,82,0,8,18,85,8,163,83,7,35,0,8,114,0,8,50,0,9,197,81,7,11,0,8,98,0,8,34,0,9,165,0,8,2,0,8,130,0,8,66,0,9,229,80,7,7,0,8,90,0,8,26,0,9,149,84,7,67,0,8,122,0,8,58,0,9,213,82,7,19,0,8,106,0,8,42,0,9,181,0,8,10,0,8,138,0,8,74,0,9,245,80,7,5,0,8,86,0,8,22,192,8,0,83,7,51,0,8,118,0,8,54,0,9,205,81,7,15,0,8,102,0,8,38,0,9,173,0,8,6,0,8,134,0,8,70,0,9,237,80,7,9,0,8,94,0,8,30,0,9,157,84,7,99,0,8,126,0,8,62,0,9,221,82,7,27,0,8,110,0,8,46,0,9,189,0,8,14,0,8,142,0,8,78,0,9,253,96,7,256,0,8,81,0,8,17,85,8,131,82,7,31,0,8,113,0,8,49,0,9,195,80,7,10,0,8,97,0,8,33,0,9,163,0,8,1,0,8,129,0,8,65,0,9,227,80,7,6,0,8,89,0,8,25,0,9,147,83,7,59,0,8,121,0,8,57,0,9,211,81,7,17,0,8,105,0,8,41,0,9,179,0,8,9,0,8,137,0,8,73,0,9,243,80,7,4,0,8,85,0,8,21,80,8,258,83,7,43,0,8,117,0,8,53,0,9,203,81,7,13,0,8,101,0,8,37,0,9,171,0,8,5,0,8,133,0,8,69,0,9,235,80,7,8,0,8,93,0,8,29,0,9,155,84,7,83,0,8,125,0,8,61,0,9,219,82,7,23,0,8,109,0,8,45,0,9,187,0,8,13,0,8,141,0,8,77,0,9,251,80,7,3,0,8,83,0,8,19,85,8,195,83,7,35,0,8,115,0,8,51,0,9,199,81,7,11,0,8,99,0,8,35,0,9,167,0,8,3,0,8,131,0,8,67,0,9,231,80,7,7,0,8,91,0,8,27,0,9,151,84,7,67,0,8,123,0,8,59,0,9,215,82,7,19,0,8,107,0,8,43,0,9,183,0,8,11,0,8,139,0,8,75,0,9,247,80,7,5,0,8,87,0,8,23,192,8,0,83,7,51,0,8,119,0,8,55,0,9,207,81,7,15,0,8,103,0,8,39,0,9,175,0,8,7,0,8,135,0,8,71,0,9,239,80,7,9,0,8,95,0,8,31,0,9,159,84,7,99,0,8,127,0,8,63,0,9,223,82,7,27,0,8,111,0,8,47,0,9,191,0,8,15,0,8,143,0,8,79,0,9,255],a=[80,5,1,87,5,257,83,5,17,91,5,4097,81,5,5,89,5,1025,85,5,65,93,5,16385,80,5,3,88,5,513,84,5,33,92,5,8193,82,5,9,90,5,2049,86,5,129,192,5,24577,80,5,2,87,5,385,83,5,25,91,5,6145,81,5,7,89,5,1537,85,5,97,93,5,24577,80,5,4,88,5,769,84,5,49,92,5,12289,82,5,13,90,5,3073,86,5,193,192,5,24577],s=[3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99,115,131,163,195,227,258,0,0],o=[0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,112,112],l=[1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193,257,385,513,769,1025,1537,2049,3073,4097,6145,8193,12289,16385,24577],c=[0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13],u=15;function d(){let e,i,r,a,d,f;function h(e,i,s,o,l,c,h,w,_,b,p){let m,g,y,x,k,v,S,z,A,U,D,E,T,F,O;U=0,k=s;do{r[e[i+U]]++,U++,k--}while(0!==k);if(r[0]==s)return h[0]=-1,w[0]=0,0;for(z=w[0],v=1;v<=u&&0===r[v];v++);for(S=v,zk&&(z=k),w[0]=z,F=1<E+z;){if(x++,E+=z,O=y-E,O=O>z?z:O,(g=1<<(v=S-E))>m+1&&(g-=m+1,T=S,v1440)return t;d[x]=D=b[0],b[0]+=O,0!==x?(f[x]=k,a[0]=v,a[1]=z,v=k>>>E-z,a[2]=D-d[x-1]-v,_.set(a,3*(d[x-1]+v))):h[0]=D}for(a[1]=S-E,U>=s?a[0]=192:p[U]>>E;v>>=1)k^=v;for(k^=v,A=(1<257?(g==t?m.msg="oversubscribed distance tree":g==n?(m.msg="incomplete distance tree",g=t):-4!=g&&(m.msg="empty distance tree with lengths",g=t),g):0)}}d.inflate_trees_fixed=function(e,t,n,i){return e[0]=9,t[0]=5,n[0]=r,i[0]=a,0};function f(){const n=this;let r,a,s,o,l=0,c=0,u=0,d=0,f=0,h=0,w=0,_=0,b=0,p=0;function m(e,n,r,a,s,o,l,c){let u,d,f,h,w,_,b,p,m,g,y,x,k,v,S,z;b=c.next_in_index,p=c.avail_in,w=l.bitb,_=l.bitk,m=l.write,g=m>=d[z+1],_-=d[z+1],16&h){for(h&=15,k=d[z+2]+(w&i[h]),w>>=h,_-=h;_<15;)p--,w|=(255&c.read_byte(b++))<<_,_+=8;for(u=w&x,d=s,f=o,z=3*(f+u),h=d[z];;){if(w>>=d[z+1],_-=d[z+1],16&h){for(h&=15;_>=h,_-=h,g-=k,m>=v)S=m-v,m-S>0&&2>m-S?(l.win[m++]=l.win[S++],l.win[m++]=l.win[S++],k-=2):(l.win.set(l.win.subarray(S,S+2),m),m+=2,S+=2,k-=2);else{S=m-v;do{S+=l.end}while(S<0);if(h=l.end-S,k>h){if(k-=h,m-S>0&&h>m-S)do{l.win[m++]=l.win[S++]}while(0!=--h);else l.win.set(l.win.subarray(S,S+h),m),m+=h,S+=h,h=0;S=0}}if(m-S>0&&k>m-S)do{l.win[m++]=l.win[S++]}while(0!=--k);else l.win.set(l.win.subarray(S,S+k),m),m+=k,S+=k,k=0;break}if(64&h)return c.msg="invalid distance code",k=c.avail_in-p,k=_>>3>3:k,p+=k,b-=k,_-=k<<3,l.bitb=w,l.bitk=_,c.avail_in=p,c.total_in+=b-c.next_in_index,c.next_in_index=b,l.write=m,t;u+=d[z+2],u+=w&i[h],z=3*(f+u),h=d[z]}break}if(64&h)return 32&h?(k=c.avail_in-p,k=_>>3>3:k,p+=k,b-=k,_-=k<<3,l.bitb=w,l.bitk=_,c.avail_in=p,c.total_in+=b-c.next_in_index,c.next_in_index=b,l.write=m,1):(c.msg="invalid literal/length code",k=c.avail_in-p,k=_>>3>3:k,p+=k,b-=k,_-=k<<3,l.bitb=w,l.bitk=_,c.avail_in=p,c.total_in+=b-c.next_in_index,c.next_in_index=b,l.write=m,t);if(u+=d[z+2],u+=w&i[h],z=3*(f+u),0===(h=d[z])){w>>=d[z+1],_-=d[z+1],l.win[m++]=d[z+2],g--;break}}else w>>=d[z+1],_-=d[z+1],l.win[m++]=d[z+2],g--}while(g>=258&&p>=10);return k=c.avail_in-p,k=_>>3>3:k,p+=k,b-=k,_-=k<<3,l.bitb=w,l.bitk=_,c.avail_in=p,c.total_in+=b-c.next_in_index,c.next_in_index=b,l.write=m,0}n.init=function(e,t,n,i,l,c){r=0,w=e,_=t,s=n,b=i,o=l,p=c,a=null},n.proc=function(n,g,y){let x,k,v,S,z,A,U,D=0,E=0,T=0;for(T=g.next_in_index,S=g.avail_in,D=n.bitb,E=n.bitk,z=n.write,A=z=258&&S>=10&&(n.bitb=D,n.bitk=E,g.avail_in=S,g.total_in+=T-g.next_in_index,g.next_in_index=T,n.write=z,y=m(w,_,s,b,o,p,n,g),T=g.next_in_index,S=g.avail_in,D=n.bitb,E=n.bitk,z=n.write,A=z>>=a[k+1],E-=a[k+1],v=a[k],0===v){d=a[k+2],r=6;break}if(16&v){f=15&v,l=a[k+2],r=2;break}if(!(64&v)){u=v,c=k/3+a[k+2];break}if(32&v){r=7;break}return r=9,g.msg="invalid literal/length code",y=t,n.bitb=D,n.bitk=E,g.avail_in=S,g.total_in+=T-g.next_in_index,g.next_in_index=T,n.write=z,n.inflate_flush(g,y);case 2:for(x=f;E>=x,E-=x,u=_,a=o,c=p,r=3;case 3:for(x=u;E>=a[k+1],E-=a[k+1],v=a[k],16&v){f=15&v,h=a[k+2],r=4;break}if(!(64&v)){u=v,c=k/3+a[k+2];break}return r=9,g.msg="invalid distance code",y=t,n.bitb=D,n.bitk=E,g.avail_in=S,g.total_in+=T-g.next_in_index,g.next_in_index=T,n.write=z,n.inflate_flush(g,y);case 4:for(x=f;E>=x,E-=x,r=5;case 5:for(U=z-h;U<0;)U+=n.end;for(;0!==l;){if(0===A&&(z==n.end&&0!==n.read&&(z=0,A=z7&&(E-=8,S++,T--),n.write=z,y=n.inflate_flush(g,y),z=n.write,A=ze.avail_out&&(i=e.avail_out),0!==i&&t==n&&(t=0),e.avail_out-=i,e.total_out+=i,e.next_out.set(s.win.subarray(a,a+i),r),r+=i,a+=i,a==s.end&&(a=0,s.write==s.end&&(s.write=0),i=s.write-a,i>e.avail_out&&(i=e.avail_out),0!==i&&t==n&&(t=0),e.avail_out-=i,e.total_out+=i,e.next_out.set(s.win.subarray(a,a+i),r),r+=i,a+=i),e.next_out_index=r,s.read=a,t},s.proc=function(n,r){let a,f,x,k,v,S,z,A;for(k=n.next_in_index,v=n.avail_in,f=s.bitb,x=s.bitk,S=s.write,z=S>>1){case 0:f>>>=3,x-=3,a=7&x,f>>>=a,x-=a,l=1;break;case 1:U=[],D=[],E=[[]],T=[[]],d.inflate_trees_fixed(U,D,E,T),p.init(U[0],D[0],E[0],0,T[0],0),f>>>=3,x-=3,l=6;break;case 2:f>>>=3,x-=3,l=3;break;case 3:return f>>>=3,x-=3,l=9,n.msg="invalid block type",r=t,s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,s.inflate_flush(n,r)}break;case 1:for(;x<32;){if(0===v)return s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,s.inflate_flush(n,r);r=0,v--,f|=(255&n.read_byte(k++))<>>16&65535)!=(65535&f))return l=9,n.msg="invalid stored block lengths",r=t,s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,s.inflate_flush(n,r);c=65535&f,f=x=0,l=0!==c?2:0!==m?7:0;break;case 2:if(0===v)return s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,s.inflate_flush(n,r);if(0===z&&(S==s.end&&0!==s.read&&(S=0,z=Sv&&(a=v),a>z&&(a=z),s.win.set(n.read_buf(k,a),S),k+=a,v-=a,S+=a,z-=a,0!=(c-=a))break;l=0!==m?7:0;break;case 3:for(;x<14;){if(0===v)return s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,s.inflate_flush(n,r);r=0,v--,f|=(255&n.read_byte(k++))<29||(a>>5&31)>29)return l=9,n.msg="too many length or distance symbols",r=t,s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,s.inflate_flush(n,r);if(a=258+(31&a)+(a>>5&31),!o||o.length>>=14,x-=14,w=0,l=4;case 4:for(;w<4+(u>>>10);){for(;x<3;){if(0===v)return s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,s.inflate_flush(n,r);r=0,v--,f|=(255&n.read_byte(k++))<>>=3,x-=3}for(;w<19;)o[h[w++]]=0;if(_[0]=7,a=y.inflate_trees_bits(o,_,b,g,n),0!=a)return(r=a)==t&&(o=null,l=9),s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,s.inflate_flush(n,r);w=0,l=5;case 5:for(;a=u,!(w>=258+(31&a)+(a>>5&31));){let e,c;for(a=_[0];x>>=a,x-=a,o[w++]=c;else{for(A=18==c?7:c-14,e=18==c?11:3;x>>=a,x-=a,e+=f&i[A],f>>>=A,x-=A,A=w,a=u,A+e>258+(31&a)+(a>>5&31)||16==c&&A<1)return o=null,l=9,n.msg="invalid bit length repeat",r=t,s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,s.inflate_flush(n,r);c=16==c?o[A-1]:0;do{o[A++]=c}while(0!=--e);w=A}}if(b[0]=-1,F=[],O=[],C=[],W=[],F[0]=9,O[0]=6,a=u,a=y.inflate_trees_dynamic(257+(31&a),1+(a>>5&31),o,F,O,C,W,g,n),0!=a)return a==t&&(o=null,l=9),r=a,s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,s.inflate_flush(n,r);p.init(F[0],O[0],g,C[0],g,W[0]),l=6;case 6:if(s.bitb=f,s.bitk=x,n.avail_in=v,n.total_in+=k-n.next_in_index,n.next_in_index=k,s.write=S,1!=(r=p.proc(s,n,r)))return s.inflate_flush(n,r);if(r=0,p.free(n),k=n.next_in_index,v=n.avail_in,f=s.bitb,x=s.bitk,S=s.write,z=S15?(i.inflateEnd(t),e):(i.wbits=n,t.istate.blocks=new w(t,1<>4)>o.wbits){o.mode=_,i.msg="invalid win size",o.marker=5;break}o.mode=1;case 1:if(0===i.avail_in)return a;if(a=r,i.avail_in--,i.total_in++,s=255&i.read_byte(i.next_in_index++),((o.method<<8)+s)%31!=0){o.mode=_,i.msg="incorrect header check",o.marker=5;break}if(!(32&s)){o.mode=7;break}o.mode=2;case 2:if(0===i.avail_in)return a;a=r,i.avail_in--,i.total_in++,o.need=(255&i.read_byte(i.next_in_index++))<<24&4278190080,o.mode=3;case 3:if(0===i.avail_in)return a;a=r,i.avail_in--,i.total_in++,o.need+=(255&i.read_byte(i.next_in_index++))<<16&16711680,o.mode=4;case 4:if(0===i.avail_in)return a;a=r,i.avail_in--,i.total_in++,o.need+=(255&i.read_byte(i.next_in_index++))<<8&65280,o.mode=5;case 5:return 0===i.avail_in?a:(a=r,i.avail_in--,i.total_in++,o.need+=255&i.read_byte(i.next_in_index++),o.mode=6,2);case 6:return o.mode=_,i.msg="need dictionary",o.marker=0,e;case 7:if(a=o.blocks.proc(i,a),a==t){o.mode=_,o.marker=0;break}if(0==a&&(a=r),1!=a)return a;a=r,o.blocks.reset(i,o.was),o.mode=12;case 12:return i.avail_in=0,1;case _:return t;default:return e}},i.inflateSetDictionary=function(t,n,i){let r=0,a=i;if(!t||!t.istate||6!=t.istate.mode)return e;const s=t.istate;return a>=1<>>1^3988292384:t>>>=1;C[e]=t}class W{constructor(e){this.crc=e||-1}append(e){let t=0|this.crc;for(let n=0,i=0|e.length;n>>8^C[255&(t^e[n])];this.crc=t}get(){return~this.crc}}class j extends TransformStream{constructor(){let e;const t=new W;super({transform(e,n){t.append(e),n.enqueue(e)},flush(){const n=new Uint8Array(4);new DataView(n.buffer).setUint32(0,t.get()),e.value=n}}),e=this}}const M={concat(e,t){if(0===e.length||0===t.length)return e.concat(t);const n=e[e.length-1],i=M.getPartial(n);return 32===i?e.concat(t):M._shiftRight(t,i,0|n,e.slice(0,e.length-1))},bitLength(e){const t=e.length;if(0===t)return 0;const n=e[t-1];return 32*(t-1)+M.getPartial(n)},clamp(e,t){if(32*e.length0&&t&&(e[n-1]=M.partial(t,e[n-1]&2147483648>>t-1,1)),e},partial:(e,t,n)=>32===e?t:(n?0|t:t<<32-e)+1099511627776*e,getPartial:e=>Math.round(e/1099511627776)||32,_shiftRight(e,t,n,i){for(void 0===i&&(i=[]);t>=32;t-=32)i.push(n),n=0;if(0===t)return i.concat(e);for(let r=0;r>>t),n=e[r]<<32-t;const r=e.length?e[e.length-1]:0,a=M.getPartial(r);return i.push(M.partial(t+a&31,t+a>32?n:i.pop(),1)),i}},L={bytes:{fromBits(e){const t=M.bitLength(e)/8,n=new Uint8Array(t);let i;for(let r=0;r>>24,i<<=8;return n},toBits(e){const t=[];let n,i=0;for(n=0;n9007199254740991)throw new Error("Cannot hash more than 2^53 - 1 bits");const a=new Uint32Array(n);let s=0;for(let e=t.blockSize+i-(t.blockSize+i&t.blockSize-1);e<=r;e+=t.blockSize)t._block(a.subarray(16*s,16*(s+1))),s+=1;return n.splice(0,16*s),t}finalize(){const e=this;let t=e._buffer;const n=e._h;t=M.concat(t,[M.partial(1,1)]);for(let e=t.length+2;15&e;e++)t.push(0);for(t.push(Math.floor(e._length/4294967296)),t.push(0|e._length);t.length;)e._block(t.splice(0,16));return e.reset(),n}_f(e,t,n,i){return e<=19?t&n|~t&i:e<=39?t^n^i:e<=59?t&n|t&i|n&i:e<=79?t^n^i:void 0}_S(e,t){return t<>>32-e}_block(e){const t=this,n=t._h,i=Array(80);for(let t=0;t<16;t++)i[t]=e[t];let r=n[0],a=n[1],s=n[2],o=n[3],l=n[4];for(let e=0;e<=79;e++){e>=16&&(i[e]=t._S(1,i[e-3]^i[e-8]^i[e-14]^i[e-16]));const n=t._S(5,r)+t._f(e,a,s,o)+l+i[e]+t._key[Math.floor(e/20)]|0;l=o,o=s,s=t._S(30,a),a=r,r=n}n[0]=n[0]+r|0,n[1]=n[1]+a|0,n[2]=n[2]+s|0,n[3]=n[3]+o|0,n[4]=n[4]+l|0}}},R={aes:class{constructor(e){const t=this;t._tables=[[[],[],[],[],[]],[[],[],[],[],[]]],t._tables[0][0][0]||t._precompute();const n=t._tables[0][4],i=t._tables[1],r=e.length;let a,s,o,l=1;if(4!==r&&6!==r&&8!==r)throw new Error("invalid aes key size");for(t._key=[s=e.slice(0),o=[]],a=r;a<4*r+28;a++){let e=s[a-1];(a%r==0||8===r&&a%r==4)&&(e=n[e>>>24]<<24^n[e>>16&255]<<16^n[e>>8&255]<<8^n[255&e],a%r==0&&(e=e<<8^e>>>24^l<<24,l=l<<1^283*(l>>7))),s[a]=s[a-r]^e}for(let e=0;a;e++,a--){const t=s[3&e?a:a-4];o[e]=a<=4||e<4?t:i[0][n[t>>>24]]^i[1][n[t>>16&255]]^i[2][n[t>>8&255]]^i[3][n[255&t]]}}encrypt(e){return this._crypt(e,0)}decrypt(e){return this._crypt(e,1)}_precompute(){const e=this._tables[0],t=this._tables[1],n=e[4],i=t[4],r=[],a=[];let s,o,l,c;for(let e=0;e<256;e++)a[(r[e]=e<<1^283*(e>>7))^e]=e;for(let u=s=0;!n[u];u^=o||1,s=a[s]||1){let a=s^s<<1^s<<2^s<<3^s<<4;a=a>>8^255&a^99,n[u]=a,i[a]=u,c=r[l=r[o=r[u]]];let d=16843009*c^65537*l^257*o^16843008*u,f=257*r[a]^16843008*a;for(let n=0;n<4;n++)e[n][u]=f=f<<24^f>>>8,t[n][a]=d=d<<24^d>>>8}for(let n=0;n<5;n++)e[n]=e[n].slice(0),t[n]=t[n].slice(0)}_crypt(e,t){if(4!==e.length)throw new Error("invalid aes block size");const n=this._key[t],i=n.length/4-2,r=[0,0,0,0],a=this._tables[t],s=a[0],o=a[1],l=a[2],c=a[3],u=a[4];let d,f,h,w=e[0]^n[0],_=e[t?3:1]^n[1],b=e[2]^n[2],p=e[t?1:3]^n[3],m=4;for(let e=0;e>>24]^o[_>>16&255]^l[b>>8&255]^c[255&p]^n[m],f=s[_>>>24]^o[b>>16&255]^l[p>>8&255]^c[255&w]^n[m+1],h=s[b>>>24]^o[p>>16&255]^l[w>>8&255]^c[255&_]^n[m+2],p=s[p>>>24]^o[w>>16&255]^l[_>>8&255]^c[255&b]^n[m+3],m+=4,w=d,_=f,b=h;for(let e=0;e<4;e++)r[t?3&-e:e]=u[w>>>24]<<24^u[_>>16&255]<<16^u[b>>8&255]<<8^u[255&p]^n[m++],d=w,w=_,_=b,b=p,p=d;return r}}},B={getRandomValues(e){const t=new Uint32Array(e.buffer),n=e=>{let t=987654321;const n=4294967295;return function(){t=36969*(65535&t)+(t>>16)&n;return(((t<<16)+(e=18e3*(65535&e)+(e>>16)&n)&n)/4294967296+.5)*(Math.random()>.5?1:-1)}};for(let i,r=0;r>24))e+=1<<24;else{let t=e>>16&255,n=e>>8&255,i=255&e;255===t?(t=0,255===n?(n=0,255===i?i=0:++i):++n):++t,e=0,e+=t<<16,e+=n<<8,e+=i}return e}incCounter(e){0===(e[0]=this.incWord(e[0]))&&(e[1]=this.incWord(e[1]))}calculate(e,t,n){let i;if(!(i=t.length))return[];const r=M.bitLength(t);for(let r=0;rnew N.hmacSha1(L.bytes.toBits(e)),pbkdf2(e,t,n,i){if(n=n||1e4,i<0||n<0)throw new Error("invalid params to pbkdf2");const r=1+(i>>5)<<2;let a,s,o,l,c;const u=new ArrayBuffer(r),d=new DataView(u);let f=0;const h=M;for(t=L.bytes.toBits(t),c=1;f<(r||1);c++){for(a=s=e.encrypt(h.concat(t,[c])),o=1;or&&(e=(new n).update(e).finalize());for(let t=0;tthis.resolveReady=e)),password:be(e,t),signed:n,strength:i-1,pending:new Uint8Array})},async transform(e,t){const n=this,{password:i,strength:a,resolveReady:s,ready:o}=n;i?(await async function(e,t,n,i){const r=await _e(e,t,n,me(i,0,$[t])),a=me(i,$[t]);if(r[0]!=a[0]||r[1]!=a[1])throw new Error(q)}(n,a,i,me(e,0,$[a]+2)),e=me(e,$[a]+2),r?t.error(new Error(K)):s()):await o;const l=new Uint8Array(e.length-te-(e.length-te)%G);t.enqueue(we(n,e,l,0,te,!0))},async flush(e){const{signed:t,ctr:n,hmac:i,pending:r,ready:a}=this;if(i&&n){await a;const s=me(r,0,r.length-te),o=me(r,r.length-te);let l=new Uint8Array;if(s.length){const e=ye(se,s);i.update(e);const t=n.update(e);l=ge(se,t)}if(t){const e=me(ge(se,i.digest()),0,te);for(let t=0;tthis.resolveReady=e)),password:be(e,t),strength:n-1,pending:new Uint8Array})},async transform(e,t){const n=this,{password:i,strength:r,resolveReady:a,ready:s}=n;let o=new Uint8Array;i?(o=await async function(e,t,n){const i=Z(new Uint8Array($[t])),r=await _e(e,t,n,i);return pe(i,r)}(n,r,i),a()):await s;const l=new Uint8Array(o.length+e.length-e.length%G);l.set(o,0),t.enqueue(we(n,e,l,o.length,0))},async flush(e){const{ctr:t,hmac:n,pending:r,ready:a}=this;if(n&&t){await a;let s=new Uint8Array;if(r.length){const e=t.update(ye(se,r));n.update(e),s=ge(se,e)}i.signature=ge(se,n.digest()).slice(0,te),e.enqueue(pe(s,i.signature))}}}),i=this}}function we(e,t,n,i,r,a){const{ctr:s,hmac:o,pending:l}=e,c=t.length-r;let u;for(l.length&&(t=pe(l,t),n=function(e,t){if(t&&t>e.length){const n=e;(e=new Uint8Array(t)).set(n,0)}return e}(n,c-c%G)),u=0;u<=c-G;u+=G){const e=ye(se,me(t,u,u+G));a&&o.update(e);const r=s.update(e);a||o.update(r),n.set(ge(se,r),u+i)}return e.pending=me(t,u),n}async function _e(e,t,n,i){e.password=null;const r=await async function(e,t,n,i,r){if(!ue)return N.importKey(t);try{return await re.importKey(e,t,n,i,r)}catch(e){return ue=!1,N.importKey(t)}}("raw",n,Q,!1,Y),a=await async function(e,t,n){if(!de)return N.pbkdf2(t,e.salt,X.iterations,n);try{return await re.deriveBits(e,t,n)}catch(i){return de=!1,N.pbkdf2(t,e.salt,X.iterations,n)}}(Object.assign({salt:i},X),r,8*(2*ee[t]+2)),s=new Uint8Array(a),o=ye(se,me(s,0,ee[t])),l=ye(se,me(s,ee[t],2*ee[t])),c=me(s,2*ee[t]);return Object.assign(e,{keys:{key:o,authentication:l,passwordVerification:c},ctr:new le(new oe(o),Array.from(ne)),hmac:new ce(l)}),c}function be(e,t){return t===S?function(e){if(typeof TextEncoder==z){e=unescape(encodeURIComponent(e));const t=new Uint8Array(e.length);for(let n=0;n>>24]),r=~e.crcKey2.get(),e.keys=[n,i,r]}function De(e){const t=2|e.keys[2];return Ee(Math.imul(t,1^t)>>>8)}function Ee(e){return 255&e}function Te(e){return 4294967295&e}const Fe="deflate-raw";class Oe extends TransformStream{constructor(e,{chunkSize:t,CompressionStream:n,CompressionStreamNative:i}){super({});const{compressed:r,encrypted:a,useCompressionStream:s,zipCrypto:o,signed:l,level:c}=e,u=this;let d,f,h=We(super.readable);a&&!o||!l||(d=new j,h=Le(h,d)),r&&(h=Me(h,s,{level:c,chunkSize:t},i,n)),a&&(o?h=Le(h,new ve(e)):(f=new he(e),h=Le(h,f))),je(u,h,(()=>{let e;a&&!o&&(e=f.signature),a&&!o||!l||(e=new DataView(d.value.buffer).getUint32(0)),u.signature=e}))}}class Ce extends TransformStream{constructor(e,{chunkSize:t,DecompressionStream:n,DecompressionStreamNative:i}){super({});const{zipCrypto:r,encrypted:a,signed:s,signature:o,compressed:l,useCompressionStream:c}=e;let u,d,f=We(super.readable);a&&(r?f=Le(f,new ke(e)):(d=new fe(e),f=Le(f,d))),l&&(f=Me(f,c,{chunkSize:t},i,n)),a&&!r||!s||(u=new j,f=Le(f,u)),je(this,f,(()=>{if((!a||r)&&s){const e=new DataView(u.value.buffer);if(o!=e.getUint32(0,!1))throw new Error(H)}}))}}function We(e){return Le(e,new TransformStream({transform(e,t){e&&e.length&&t.enqueue(e)}}))}function je(e,t,n){t=Le(t,new TransformStream({flush:n})),Object.defineProperty(e,"readable",{get:()=>t})}function Me(e,t,n,i,r){try{e=Le(e,new(t&&i?i:r)(Fe,n))}catch(i){if(!t)return e;try{e=Le(e,new r(Fe,n))}catch(t){return e}}return e}function Le(e,t){return e.pipeThrough(t)}const Pe="message",Re="start",Be="pull",Ie="data",Ne="close",Ve="inflate";class qe extends TransformStream{constructor(e,t){super({});const n=this,{codecType:i}=e;let r;i.startsWith("deflate")?r=Oe:i.startsWith(Ve)&&(r=Ce);let a=0,s=0;const o=new r(e,t),l=super.readable,c=new TransformStream({transform(e,t){e&&e.length&&(s+=e.length,t.enqueue(e))},flush(){Object.assign(n,{inputSize:s})}}),u=new TransformStream({transform(e,t){e&&e.length&&(a+=e.length,t.enqueue(e))},flush(){const{signature:e}=o;Object.assign(n,{signature:e,outputSize:a,inputSize:s})}});Object.defineProperty(n,"readable",{get:()=>l.pipeThrough(c).pipeThrough(o).pipeThrough(u)})}}class He extends TransformStream{constructor(e){let t;super({transform:function n(i,r){if(t){const e=new Uint8Array(t.length+i.length);e.set(t),e.set(i,t.length),i=e,t=null}i.length>e?(r.enqueue(i.slice(0,e)),n(i.slice(e),r)):t=i},flush(e){t&&t.length&&e.enqueue(t)}})}}let Ke=typeof Worker!=z;class Ze{constructor(e,{readable:t,writable:n},{options:i,config:r,streamOptions:a,useWebWorkers:s,transferStreams:o,scripts:l},c){const{signal:u}=a;return Object.assign(e,{busy:!0,readable:t.pipeThrough(new He(r.chunkSize)).pipeThrough(new Ge(t,a),{signal:u}),writable:n,options:Object.assign({},i),scripts:l,transferStreams:o,terminate:()=>new Promise((t=>{const{worker:n,busy:i}=e;n?(i?e.resolveTerminated=t:(n.terminate(),t()),e.interface=null):t()})),onTaskFinished(){const{resolveTerminated:t}=e;t&&(e.resolveTerminated=null,e.terminated=!0,e.worker.terminate(),t()),e.busy=!1,c(e)}}),(s&&Ke?Xe:Qe)(e,r)}}class Ge extends TransformStream{constructor(e,{onstart:t,onprogress:n,size:i,onend:r}){let a=0;super({async start(){t&&await Je(t,i)},async transform(e,t){a+=e.length,n&&await Je(n,a,i),t.enqueue(e)},async flush(){e.size=a,r&&await Je(r,a)}})}}async function Je(e,...t){try{await e(...t)}catch(e){}}function Qe(e,t){return{run:()=>async function({options:e,readable:t,writable:n,onTaskFinished:i},r){try{const i=new qe(e,r);await t.pipeThrough(i).pipeTo(n,{preventClose:!0,preventAbort:!0});const{signature:a,inputSize:s,outputSize:o}=i;return{signature:a,inputSize:s,outputSize:o}}finally{i()}}(e,t)}}function Xe(e,t){const{baseURL:n,chunkSize:i}=t;if(!e.interface){let r;try{r=function(e,t,n){const i={type:"module"};let r,a;typeof e==A&&(e=e());try{r=new URL(e,t)}catch(t){r=e}if(Ye)try{a=new Worker(r)}catch(e){Ye=!1,a=new Worker(r,i)}else a=new Worker(r,i);return a.addEventListener(Pe,(e=>async function({data:e},t){const{type:n,value:i,messageId:r,result:a,error:s}=e,{reader:o,writer:l,resolveResult:c,rejectResult:u,onTaskFinished:d}=t;try{if(s){const{message:e,stack:t,code:n,name:i}=s,r=new Error(e);Object.assign(r,{stack:t,code:n,name:i}),f(r)}else{if(n==Be){const{value:e,done:n}=await o.read();et({type:Ie,value:e,done:n,messageId:r},t)}n==Ie&&(await l.ready,await l.write(new Uint8Array(i)),et({type:"ack",messageId:r},t)),n==Ne&&f(null,a)}}catch(s){et({type:Ne,messageId:r},t),f(s)}function f(e,t){e?u(e):c(t),l&&l.releaseLock(),d()}}(e,n))),a}(e.scripts[0],n,e)}catch(n){return Ke=!1,Qe(e,t)}Object.assign(e,{worker:r,interface:{run:()=>async function(e,t){let n,i;const r=new Promise(((e,t)=>{n=e,i=t}));Object.assign(e,{reader:null,writer:null,resolveResult:n,rejectResult:i,result:r});const{readable:a,options:s,scripts:o}=e,{writable:l,closed:c}=function(e){let t;const n=new Promise((e=>t=e)),i=new WritableStream({async write(t){const n=e.getWriter();await n.ready,await n.write(t),n.releaseLock()},close(){t()},abort:t=>e.getWriter().abort(t)});return{writable:i,closed:n}}(e.writable),u=et({type:Re,scripts:o.slice(1),options:s,config:t,readable:a,writable:l},e);u||Object.assign(e,{reader:a.getReader(),writer:l.getWriter()});const d=await r;u||await l.getWriter().close();return await c,d}(e,{chunkSize:i})}})}return e.interface}let Ye=!0,$e=!0;function et(e,{worker:t,writer:n,onTaskFinished:i,transferStreams:r}){try{let{value:n,readable:i,writable:a}=e;const s=[];if(n&&(n.byteLength!e.busy));if(n)return at(n),new Ze(n,e,t,w);if(tt.lengthnt.push({resolve:n,stream:e,workerOptions:t})))}()).run();function w(e){if(nt.length){const[{resolve:t,stream:n,workerOptions:i}]=nt.splice(0,1);t(new Ze(e,n,i,w))}else e.worker?(at(e),function(e,t){const{config:n}=t,{terminateWorkerTimeout:i}=n;Number.isFinite(i)&&i>=0&&(e.terminated?e.terminated=!1:e.terminateTimeout=setTimeout((async()=>{tt=tt.filter((t=>t!=e));try{await e.terminate()}catch(e){}}),i))}(e,t)):tt=tt.filter((t=>t!=e))}}function at(e){const{terminateTimeout:t}=e;t&&(clearTimeout(t),e.terminateTimeout=null)}const st=65536,ot="writable";class lt{constructor(){this.size=0}init(){this.initialized=!0}}class ct extends lt{get readable(){const e=this,{chunkSize:t=st}=e,n=new ReadableStream({start(){this.chunkOffset=0},async pull(i){const{offset:r=0,size:a,diskNumberStart:s}=n,{chunkOffset:o}=this;i.enqueue(await pt(e,r+o,Math.min(t,a-o),s)),o+t>a?i.close():this.chunkOffset+=t}});return n}}class ut extends ct{constructor(e){super(),Object.assign(this,{blob:e,size:e.size})}async readUint8Array(e,t){const n=this,i=e+t,r=e||it&&(a=a.slice(e,i)),new Uint8Array(a)}}class dt extends lt{constructor(e){super();const t=new TransformStream,n=[];e&&n.push(["Content-Type",e]),Object.defineProperty(this,ot,{get:()=>t.writable}),this.blob=new Response(t.readable,{headers:n}).blob()}getData(){return this.blob}}class ft extends dt{constructor(e){super(e),Object.assign(this,{encoding:e,utf8:!e||"utf-8"==e.toLowerCase()})}async getData(){const{encoding:e,utf8:t}=this,n=await super.getData();if(n.text&&t)return n.text();{const t=new FileReader;return new Promise(((i,r)=>{Object.assign(t,{onload:({target:e})=>i(e.result),onerror:()=>r(t.error)}),t.readAsText(n,e)}))}}}class ht extends ct{constructor(e){super(),this.readers=e}async init(){const e=this,{readers:t}=e;e.lastDiskNumber=0,e.lastDiskOffset=0,await Promise.all(t.map((async(n,i)=>{await n.init(),i!=t.length-1&&(e.lastDiskOffset+=n.size),e.size+=n.size}))),super.init()}async readUint8Array(e,t,n=0){const i=this,{readers:r}=this;let a,s=n;-1==s&&(s=r.length-1);let o=e;for(;o>=r[s].size;)o-=r[s].size,s++;const l=r[s],c=l.size;if(o+t<=c)a=await pt(l,o,t);else{const r=c-o;a=new Uint8Array(t),a.set(await pt(l,o,r)),a.set(await i.readUint8Array(e+r,t-r,n),r)}return i.lastDiskNumber=Math.max(s,i.lastDiskNumber),a}}class wt extends lt{constructor(e,t=4294967295){super();const n=this;let i,r,a;Object.assign(n,{diskNumber:0,diskOffset:0,size:0,maxSize:t,availableSize:t});const s=new WritableStream({async write(t){const{availableSize:s}=n;if(a)t.length>=s?(await o(t.slice(0,s)),await l(),n.diskOffset+=i.size,n.diskNumber++,a=null,await this.write(t.slice(s))):await o(t);else{const{value:s,done:o}=await e.next();if(o&&!s)throw new Error("Writer iterator completed too soon");i=s,i.size=0,i.maxSize&&(n.maxSize=i.maxSize),n.availableSize=n.maxSize,await _t(i),r=s.writable,a=r.getWriter(),await this.write(t)}},async close(){await a.ready,await l()}});async function o(e){const t=e.length;t&&(await a.ready,await a.write(e),i.size+=t,n.size+=t,n.availableSize-=t)}async function l(){r.size=i.size,await a.close()}Object.defineProperty(n,ot,{get:()=>s})}}async function _t(e,t){if(!e.init||e.initialized)return Promise.resolve();await e.init(t)}function bt(e){return Array.isArray(e)&&(e=new ht(e)),e instanceof ReadableStream&&(e={readable:e}),e}function pt(e,t,n,i){return e.readUint8Array(t,n,i)}const mt="\0☺☻♥♦♣♠•◘○◙♂♀♪♫☼►◄↕‼¶§▬↨↑↓→←∟↔▲▼ !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~⌂ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜ¢£¥₧ƒáíóúñѪº¿⌐¬½¼¡«»░▒▓│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀αßΓπΣσµτΦΘΩδ∞φε∩≡±≥≤⌠⌡÷≈°∙·√ⁿ²■ ".split(""),gt=256==mt.length;function yt(e,t){return t&&"cp437"==t.trim().toLowerCase()?function(e){if(gt){let t="";for(let n=0;nthis[t]=e[t]))}}const Lt="File format is not recognized",Pt="Zip64 extra field not found",Rt="Compression method not supported",Bt="Split zip file",It="utf-8",Nt="cp437",Vt=[[zt,g],[At,g],[Ut,g],[Dt,y]],qt={[y]:{getValue:tn,bytes:4},[g]:{getValue:nn,bytes:8}};class Ht{constructor(e,t={}){Object.assign(this,{reader:bt(e),options:t,config:T})}async*getEntriesGenerator(e={}){const t=this;let{reader:n}=t;const{config:i}=t;if(await _t(n),n.size!==S&&n.readUint8Array||(n=new ut(await new Response(n.readable).blob()),await _t(n)),n.size=0;e--)if(s[e]==a[0]&&s[e+1]==a[1]&&s[e+2]==a[2]&&s[e+3]==a[3])return{offset:r+e,buffer:s.slice(e,e+i).buffer}}}(n,101010256,n.size,v,1048560);if(!r){throw 134695760==tn(rn(await pt(n,0,4)))?new Error(Bt):new Error("End of central directory not found")}const a=rn(r);let s=tn(a,12),o=tn(a,16);const l=r.offset,c=en(a,20),u=l+v+c;let d=en(a,4);const f=n.lastDiskNumber||0;let h=en(a,6),w=en(a,8),_=0,b=0;if(o==g||s==g||w==y||h==y){const e=rn(await pt(n,r.offset-20,20));if(117853008==tn(e,0)){o=nn(e,8);let t=await pt(n,o,56,-1),i=rn(t);const a=r.offset-20-56;if(tn(i,0)!=k&&o!=a){const e=o;o=a,_=o-e,t=await pt(n,o,56,-1),i=rn(t)}if(tn(i,0)!=k)throw new Error("End of Zip64 central directory locator not found");d==y&&(d=tn(i,16)),h==y&&(h=tn(i,20)),w==y&&(w=nn(i,32)),s==g&&(s=nn(i,40)),o-=s}}if(o>=n.size&&(_=n.size-o-s-v,o=n.size-s-v),f!=d)throw new Error(Bt);if(o<0)throw new Error(Lt);let p=0,m=await pt(n,o,s,h),z=rn(m);if(s){const e=r.offset-s;if(tn(z,p)!=x&&o!=e){const t=o;o=e,_+=o-t,m=await pt(n,o,s,h),z=rn(m)}}const A=r.offset-o-(n.lastDiskOffset||0);if(s!=A&&A>=0&&(s=A,m=await pt(n,o,s,h),z=rn(m)),o<0||o>=n.size)throw new Error(Lt);const U=Qt(t,e,"filenameEncoding"),D=Qt(t,e,"commentEncoding");for(let r=0;ra.getData(e,j,t),p=g;const{onprogress:M}=e;if(M)try{await M(r+1,w,new Mt(a))}catch(e){}yield j}const E=Qt(t,e,"extractPrependedData"),T=Qt(t,e,"extractAppendedData");return E&&(t.prependedData=b>0?await pt(n,0,b):new Uint8Array),t.comment=c?await pt(n,l+v,c):new Uint8Array,T&&(t.appendedData=u>>8&255:d>>>24&255),signature:d,compressed:0!=l&&!g,encrypted:i.encrypted&&!g,useWebWorkers:Qt(i,n,"useWebWorkers"),useCompressionStream:Qt(i,n,"useCompressionStream"),transferStreams:Qt(i,n,"transferStreams"),checkPasswordOnly:D},config:c,streamOptions:{signal:U,size:v,onstart:T,onprogress:F,onend:O}};let W=0;try{({outputSize:W}=await rt({readable:z,writable:E},C))}catch(e){if(!D||e.message!=K)throw e}finally{const e=Qt(i,n,"preventClose");E.size+=W,e||E.locked||await E.getWriter().close()}return D?S:e.getData?e.getData():E}}function Zt(e,t,n){const i=e.rawBitFlag=en(t,n+2),r=!(1&~i),a=tn(t,n+6);Object.assign(e,{encrypted:r,version:en(t,n),bitFlag:{level:(6&i)>>1,dataDescriptor:!(8&~i),languageEncodingFlag:!(2048&~i)},rawLastModDate:a,lastModDate:Xt(a),filenameLength:en(t,n+22),extraFieldLength:en(t,n+24)})}async function Gt(e,t,n,i,r){const{rawExtraField:a}=t,s=t.extraField=new Map,o=rn(new Uint8Array(a));let l=0;try{for(;lt[e]==n));for(let r=0,a=0;r=5&&(a.push(Et),s.push(Tt));let o=1;a.forEach(((n,r)=>{if(e.data.length>=o+4){const a=tn(i,o);t[n]=e[n]=new Date(1e3*a);const l=s[r];e[l]=a}o+=4}))}(_,t,r),t.extraFieldExtendedTimestamp=_);const b=s.get(6534);b&&(t.extraFieldUSDZ=b)}async function Jt(e,t,n,i,r){const a=rn(e.data),s=new W;s.append(r[n]);const o=rn(new Uint8Array(4));o.setUint32(0,s.get(),!0);const l=tn(a,1);Object.assign(e,{version:$t(a,0),[t]:yt(e.data.subarray(5)),valid:!r.bitFlag.languageEncodingFlag&&l==tn(o,0)}),e.valid&&(i[t]=e[t],i[t+"UTF8"]=!0)}function Qt(e,t,n){return t[n]===S?e.options[n]:t[n]}function Xt(e){const t=(4294901760&e)>>16,n=65535&e;try{return new Date(1980+((65024&t)>>9),((480&t)>>5)-1,31&t,(63488&n)>>11,(2016&n)>>5,2*(31&n),0)}catch(e){}}function Yt(e){return new Date(Number(e/BigInt(1e4)-BigInt(116444736e5)))}function $t(e,t){return e.getUint8(t)}function en(e,t){return e.getUint16(t,!0)}function tn(e,t){return e.getUint32(t,!0)}function nn(e,t){return Number(e.getBigUint64(t,!0))}function rn(e){return new DataView(e.buffer)}F({Inflate:function(e){const t=new m,i=e&&e.chunkSize?Math.floor(2*e.chunkSize):131072,r=new Uint8Array(i);let a=!1;t.inflateInit(),t.next_out=r,this.append=function(e,s){const o=[];let l,c,u=0,d=0,f=0;if(0!==e.length){t.next_in_index=0,t.next_in=e,t.avail_in=e.length;do{if(t.next_out_index=0,t.avail_out=i,0!==t.avail_in||a||(t.next_in_index=0,a=!0),l=t.inflate(0),a&&l===n){if(0!==t.avail_in)throw new Error("inflating: bad input")}else if(0!==l&&1!==l)throw new Error("inflating: "+t.msg);if((a||1===l)&&t.avail_in===e.length)throw new Error("inflating: bad input");t.next_out_index&&(t.next_out_index===i?o.push(new Uint8Array(r)):o.push(r.subarray(0,t.next_out_index))),f+=t.next_out_index,s&&t.next_in_index>0&&t.next_in_index!=u&&(s(t.next_in_index),u=t.next_in_index)}while(t.avail_in>0||0===t.avail_out);return o.length>1?(c=new Uint8Array(f),o.forEach((function(e){c.set(e,d),d+=e.length}))):c=o[0]?new Uint8Array(o[0]):new Uint8Array,c}},this.flush=function(){t.inflateEnd()}}});export{ut as BlobReader,dt as BlobWriter,ft as TextWriter,Ht as ZipReader,F as configure}; diff --git a/frontend/src/lib/vendor/foliate-js/view.js b/frontend/src/lib/vendor/foliate-js/view.js new file mode 100644 index 0000000..2443fa3 --- /dev/null +++ b/frontend/src/lib/vendor/foliate-js/view.js @@ -0,0 +1,704 @@ +import * as CFI from './epubcfi.js' +import { TOCProgress, SectionProgress, PageProgress } from './progress.js' +import { Overlayer } from './overlayer.js' +import { textWalker } from './text-walker.js' + +const SEARCH_PREFIX = 'foliate-search:' + +const NOTE_PREFIX = 'foliate-note:' + +const isZip = async file => { + const arr = new Uint8Array(await file.slice(0, 4).arrayBuffer()) + return arr[0] === 0x50 && arr[1] === 0x4b && arr[2] === 0x03 && arr[3] === 0x04 +} + +const isPDF = async file => { + const arr = new Uint8Array(await file.slice(0, 5).arrayBuffer()) + return arr[0] === 0x25 + && arr[1] === 0x50 && arr[2] === 0x44 && arr[3] === 0x46 + && arr[4] === 0x2d +} + +const isCBZ = ({ name, type }) => + type === 'application/vnd.comicbook+zip' || name.endsWith('.cbz') + +const isFB2 = ({ name, type }) => + type === 'application/x-fictionbook+xml' || name.endsWith('.fb2') + +const isFBZ = ({ name, type }) => + type === 'application/x-zip-compressed-fb2' + || name.endsWith('.fb2.zip') || name.endsWith('.fbz') + +const makeZipLoader = async file => { + const { configure, ZipReader, BlobReader, TextWriter, BlobWriter } = + await import('./vendor/zip.js') + configure({ useWebWorkers: false }) + const reader = new ZipReader(new BlobReader(file)) + const entries = await reader.getEntries() + const map = new Map(entries.map(entry => [entry.filename, entry])) + const load = f => (name, ...args) => + map.has(name) ? f(map.get(name), ...args) : null + const loadText = load(entry => entry.getData(new TextWriter())) + const loadBlob = load((entry, type) => entry.getData(new BlobWriter(type))) + const getSize = name => map.get(name)?.uncompressedSize ?? 0 + return { entries, loadText, loadBlob, getSize } +} + +const getFileEntries = async entry => entry.isFile ? entry + : (await Promise.all(Array.from( + await new Promise((resolve, reject) => entry.createReader() + .readEntries(entries => resolve(entries), error => reject(error))), + getFileEntries))).flat() + +const makeDirectoryLoader = async entry => { + const entries = await getFileEntries(entry) + const files = await Promise.all( + entries.map(entry => new Promise((resolve, reject) => + entry.file(file => resolve([file, entry.fullPath]), + error => reject(error))))) + const map = new Map(files.map(([file, path]) => + [path.replace(entry.fullPath + '/', ''), file])) + const decoder = new TextDecoder() + const decode = x => x ? decoder.decode(x) : null + const getBuffer = name => map.get(name)?.arrayBuffer() ?? null + const loadText = async name => decode(await getBuffer(name)) + const loadBlob = name => map.get(name) + const getSize = name => map.get(name)?.size ?? 0 + return { loadText, loadBlob, getSize } +} + +export class ResponseError extends Error {} +export class NotFoundError extends Error {} +export class UnsupportedTypeError extends Error {} + +const fetchFile = async url => { + const res = await fetch(url) + if (!res.ok) throw new ResponseError( + `${res.status} ${res.statusText}`, { cause: res }) + return new File([await res.blob()], new URL(res.url).pathname) +} + +export const makeBook = async file => { + if (typeof file === 'string') file = await fetchFile(file) + let book + if (file.isDirectory) { + const loader = await makeDirectoryLoader(file) + const { EPUB } = await import('./epub.js') + book = await new EPUB(loader).init() + } + else if (!file.size) throw new NotFoundError('File not found') + else if (await isZip(file)) { + const loader = await makeZipLoader(file) + if (isCBZ(file)) { + const { makeComicBook } = await import('./comic-book.js') + book = makeComicBook(loader, file) + } + else if (isFBZ(file)) { + const { makeFB2 } = await import('./fb2.js') + const { entries } = loader + const entry = entries.find(entry => entry.filename.endsWith('.fb2')) + const blob = await loader.loadBlob((entry ?? entries[0]).filename) + book = await makeFB2(blob) + } + else { + const { EPUB } = await import('./epub.js') + book = await new EPUB(loader).init() + } + } + else if (await isPDF(file)) { + const { makePDF } = await import('./pdf.js') + book = await makePDF(file) + } + else { + const { isMOBI, MOBI } = await import('./mobi.js') + if (await isMOBI(file)) { + const fflate = await import('./vendor/fflate.js') + book = await new MOBI({ unzlib: fflate.unzlibSync }).open(file) + } + else if (isFB2(file)) { + const { makeFB2 } = await import('./fb2.js') + book = await makeFB2(file) + } + } + if (!book) throw new UnsupportedTypeError('File type not supported') + return book +} + +class CursorAutohider { + #timeout + #el + #check + #state + constructor(el, check, state = {}) { + this.#el = el + this.#check = check + this.#state = state + if (this.#state.hidden) this.hide() + this.#el.addEventListener('mousemove', ({ screenX, screenY }) => { + // check if it actually moved + if (screenX === this.#state.x && screenY === this.#state.y) return + this.#state.x = screenX, this.#state.y = screenY + this.show() + if (this.#timeout) clearTimeout(this.#timeout) + if (check()) this.#timeout = setTimeout(this.hide.bind(this), 1000) + }, false) + } + cloneFor(el) { + return new CursorAutohider(el, this.#check, this.#state) + } + #hasSelection() { + const selection = this.#el.ownerDocument?.getSelection() + return selection ? !selection.isCollapsed : false + } + hide() { + // The pointer is what the reader aims a selection with, so leave it + // alone while one stands: a paused drag, or a double-click word + // select (which fires no mousemove at all), would otherwise blank it. + if (this.#hasSelection()) return + this.#el.style.cursor = 'none' + this.#state.hidden = true + } + show() { + this.#el.style.removeProperty('cursor') + this.#state.hidden = false + } +} + +class History extends EventTarget { + #arr = [] + #index = -1 + pushState(x) { + const last = this.#arr[this.#index] + if (last === x || last?.fraction && last.fraction === x.fraction) return + this.#arr[++this.#index] = x + this.#arr.length = this.#index + 1 + this.dispatchEvent(new Event('index-change')) + } + replaceState(x) { + const index = this.#index + this.#arr[index] = x + } + back() { + const index = this.#index + if (index <= 0) return + const detail = { state: this.#arr[index - 1] } + this.#index = index - 1 + this.dispatchEvent(new CustomEvent('popstate', { detail })) + this.dispatchEvent(new Event('index-change')) + } + forward() { + const index = this.#index + if (index >= this.#arr.length - 1) return + const detail = { state: this.#arr[index + 1] } + this.#index = index + 1 + this.dispatchEvent(new CustomEvent('popstate', { detail })) + this.dispatchEvent(new Event('index-change')) + } + get canGoBack() { + return this.#index > 0 + } + get canGoForward() { + return this.#index < this.#arr.length - 1 + } + clear() { + this.#arr = [] + this.#index = -1 + } +} + +const languageInfo = lang => { + if (!lang) return {} + try { + const canonical = Intl.getCanonicalLocales(lang)[0] + const locale = new Intl.Locale(canonical) + const isCJK = ['zh', 'ja', 'kr'].includes(locale.language) + const direction = (locale.getTextInfo?.() ?? locale.textInfo)?.direction + return { canonical, locale, isCJK, direction } + } catch (e) { + console.warn(e) + return {} + } +} + +export class View extends HTMLElement { + #root = this.attachShadow({ mode: 'open' }) + #sectionProgress + #tocProgress + #pageProgress + #cfiProgress + #searchResults = new Map() + #cursorAutohider = new CursorAutohider(this, () => + this.hasAttribute('autohide-cursor')) + isFixedLayout = false + lastLocation + history = new History() + constructor() { + super() + this.history.addEventListener('popstate', ({ detail }) => { + const resolved = this.resolveNavigation(detail.state) + this.renderer.goTo(resolved) + }) + } + async open(book) { + if (typeof book === 'string' + || typeof book.arrayBuffer === 'function' + || book.isDirectory) book = await makeBook(book) + this.book = book + this.language = languageInfo(book.metadata?.language) + + if (book.splitTOCHref && book.getTOCFragment) { + const ids = book.sections.map(s => s.id) + this.#sectionProgress = new SectionProgress(book.sections, 1500, 1600) + const splitHref = book.splitTOCHref.bind(book) + const getFragment = book.getTOCFragment.bind(book) + this.#tocProgress = new TOCProgress() + await this.#tocProgress.init({ + toc: book.toc ?? [], ids, splitHref, getFragment }) + this.#pageProgress = new TOCProgress() + await this.#pageProgress.init({ + toc: book.pageList ?? [], ids, splitHref, getFragment }) + } + this.#cfiProgress = new PageProgress(book, this.resolveNavigation.bind(this)) + + this.isFixedLayout = this.book.rendition?.layout === 'pre-paginated' + if (this.isFixedLayout) { + await import('./fixed-layout.js') + this.renderer = document.createElement('foliate-fxl') + } else { + await import('./paginator.js') + this.renderer = document.createElement('foliate-paginator') + } + this.renderer.setAttribute('exportparts', 'head,foot,filter,container') + this.renderer.addEventListener('load', e => this.#onLoad(e.detail)) + this.renderer.addEventListener('relocate', e => this.#onRelocate(e.detail)) + this.renderer.addEventListener('create-overlayer', e => + e.detail.attach(this.#createOverlayer(e.detail))) + this.renderer.open(book) + this.#root.append(this.renderer) + + if (book.sections.some(section => section.mediaOverlay)) { + const activeClass = book.media.activeClass + const playbackActiveClass = book.media.playbackActiveClass + this.mediaOverlay = book.getMediaOverlay() + let lastActive + this.mediaOverlay.addEventListener('highlight', e => { + const resolved = this.resolveNavigation(e.detail.text) + this.renderer.goTo(resolved) + .then(() => { + const { doc } = this.renderer.getContents() + .find(x => x.index = resolved.index) + const el = resolved.anchor(doc) + el.classList.add(activeClass) + if (playbackActiveClass) el.ownerDocument + .documentElement.classList.add(playbackActiveClass) + lastActive = new WeakRef(el) + }) + }) + this.mediaOverlay.addEventListener('unhighlight', () => { + const el = lastActive?.deref() + if (el) { + el.classList.remove(activeClass) + if (playbackActiveClass) el.ownerDocument + .documentElement.classList.remove(playbackActiveClass) + } + }) + } + } + close() { + this.renderer?.destroy() + this.renderer?.remove() + this.#sectionProgress = null + this.#tocProgress = null + this.#pageProgress = null + this.#cfiProgress = null + this.#searchResults = new Map() + this.lastLocation = null + this.history.clear() + this.tts = null + this.mediaOverlay = null + } + goToTextStart() { + return this.goTo(this.book.landmarks + ?.find(m => m.type.includes('bodymatter') || m.type.includes('text')) + ?.href ?? this.book.sections.findIndex(s => s.linear !== 'no')) + } + async init({ lastLocation, showTextStart }) { + const resolved = lastLocation ? this.resolveNavigation(lastLocation) : null + if (resolved) { + await this.renderer.goTo(resolved) + this.history.pushState(lastLocation) + } + else if (showTextStart) await this.goToTextStart() + else { + this.history.pushState(0) + await this.next() + } + } + #emit(name, detail, cancelable) { + return this.dispatchEvent(new CustomEvent(name, { detail, cancelable })) + } + #onRelocate({ reason, range, index, fraction, size }) { + const progress = this.#sectionProgress?.getProgress(index, fraction, size) ?? {} + const tocItem = this.#tocProgress?.getProgress(index, range) + const pageItem = this.#pageProgress?.getProgress(index, range) + const cfi = this.getCFI(index, range) + this.lastLocation = { ...progress, tocItem, pageItem, cfi, range } + if (reason === 'snap' || reason === 'page' || reason === 'scroll') + this.history.replaceState(cfi) + this.#emit('relocate', this.lastLocation) + } + #onLoad({ doc, index }) { + // set language and dir if not already set + doc.documentElement.lang ||= this.language.canonical ?? '' + if (!this.language.isCJK) + doc.documentElement.dir ||= this.language.direction ?? '' + + this.#handleLinks(doc, index) + this.#cursorAutohider.cloneFor(doc.documentElement) + + this.#emit('load', { doc, index }) + } + #handleLinks(doc, index) { + const { book } = this + const section = book.sections[index] + doc.addEventListener('click', e => { + const a = e.target.closest('a[href]') + if (!a) return + e.preventDefault() + const href_ = a.getAttribute('href') + const href = section?.resolveHref?.(href_) ?? href_ + if (book?.isExternal?.(href)) + Promise.resolve(this.#emit('external-link', { a, href }, true)) + .then(x => x ? globalThis.open(href, '_blank') : null) + .catch(e => console.error(e)) + else { + let internalHref = href + if (!book.resolveHref(href)) { + const hashIndex = href_.indexOf('#') + if (hashIndex >= 0) { + const hash = href_.slice(hashIndex) + internalHref = section?.resolveHref?.(hash) ?? href + } + } + Promise.resolve(this.#emit('link', { a, href: internalHref }, true)) + .then(x => x ? this.goTo(internalHref) : null) + .catch(e => console.error(e)) + } + }) + } + async addAnnotation(annotation, remove) { + const { value } = annotation + if (value.startsWith(SEARCH_PREFIX)) { + const cfi = value.replace(SEARCH_PREFIX, '') + const { index, anchor } = await this.resolveNavigation(cfi) + const obj = this.#getOverlayer(index) + if (obj) { + const { overlayer, doc } = obj + if (remove) { + overlayer.remove(value) + return + } + const range = doc ? anchor(doc) : anchor + if (range) overlayer.add(value, range, Overlayer.outline) + } + return + } else if (value.startsWith(NOTE_PREFIX)) { + const cfi = value.replace(NOTE_PREFIX, '') + const { index, anchor } = await this.resolveNavigation(cfi) + const obj = this.#getOverlayer(index) + if (obj) { + const { overlayer, doc } = obj + if (remove) { + overlayer.remove(value) + return + } + const range = doc ? anchor(doc) : anchor + if (range) { + const draw = (func, opts) => overlayer.add(value, range, func, opts) + this.#emit('draw-annotation', { draw, annotation, doc, range }) + } + } + return + } + const { index, anchor } = await this.resolveNavigation(value) + const obj = this.#getOverlayer(index) + if (obj) { + const { overlayer, doc } = obj + overlayer.remove(value) + if (!remove) { + const range = doc ? anchor(doc) : anchor + if (range) { + const draw = (func, opts) => overlayer.add(value, range, func, opts) + this.#emit('draw-annotation', { draw, annotation, doc, range }) + } + } + } + const label = this.#tocProgress?.getProgress(index)?.label ?? '' + return { index, label } + } + deleteAnnotation(annotation) { + return this.addAnnotation(annotation, true) + } + #getOverlayer(index) { + return this.renderer.getContents() + .find(x => x.index === index && x.overlayer) + } + #createOverlayer({ doc, index }) { + const overlayer = new Overlayer(doc) + doc.addEventListener('click', e => { + const [value, range, rect] = overlayer.hitTest(e) + if (value && !value.startsWith(SEARCH_PREFIX)) { + this.#emit('show-annotation', { value, index, range, rect }) + } + }, false) + + let lastHitTestTime = 0 + const THROTTLE_MS = 200 + const isAndroid = /Android/i.test(navigator.userAgent) + + doc.addEventListener('mousemove', (e) => { + if (isAndroid) return + const now = performance.now() + if (now - lastHitTestTime < THROTTLE_MS) return + lastHitTestTime = now + const [value] = overlayer.hitTest(e) + if (value && !value.startsWith(SEARCH_PREFIX)) { + doc.body.style.cursor = 'pointer' + } else { + doc.body.style.cursor = '' + } + }) + + const list = this.#searchResults.get(index) + if (list) for (const item of list) this.addAnnotation(item) + + this.#emit('create-overlay', { index }) + return overlayer + } + async showAnnotation(annotation) { + const { value } = annotation + const resolved = await this.goTo(value) + if (resolved) { + const { index, anchor } = resolved + const { doc } = this.#getOverlayer(index) + const range = anchor(doc) + this.#emit('show-annotation', { value, index, range }) + } + } + getCFI(index, range) { + const baseCFI = this.book.sections[index].cfi ?? CFI.fake.fromIndex(index) + if (!range) return baseCFI + return CFI.joinIndir(baseCFI, CFI.fromRange(range)) + } + resolveCFI(cfi) { + if (this.book.resolveCFI) + return this.book.resolveCFI(cfi) + else { + const parts = CFI.parse(cfi) + const index = CFI.fake.toIndex((parts.parent ?? parts).shift()) + const anchor = doc => CFI.toRange(doc, parts) + return { index, anchor } + } + } + resolveNavigation(target) { + try { + if (typeof target === 'number') return { index: target } + if (typeof target.fraction === 'number') { + const [index, anchor] = this.#sectionProgress.getSection(target.fraction) + return { index, anchor } + } + if (CFI.isCFI.test(target)) return this.resolveCFI(target) + return this.book.resolveHref(target) + } catch (e) { + console.error(e) + console.error(`Could not resolve target ${target}`) + } + } + async goTo(target) { + const resolved = this.resolveNavigation(target) + try { + await this.renderer.goTo(resolved) + this.history.pushState(target) + return resolved + } catch(e) { + console.error(e) + console.error(`Could not go to ${target}`) + } + } + async goToFraction(frac) { + const [index, anchor] = this.#sectionProgress.getSection(frac) + await this.renderer.goTo({ index, anchor }) + this.history.pushState({ fraction: frac }) + } + async select(target) { + try { + const obj = await this.resolveNavigation(target) + await this.renderer.goTo({ ...obj, select: true }) + this.history.pushState(target) + } catch(e) { + console.error(e) + console.error(`Could not go to ${target}`) + } + } + deselect() { + for (const { doc } of this.renderer.getContents()) + doc.defaultView.getSelection().removeAllRanges() + } + getSectionFractions() { + return (this.#sectionProgress?.sectionFractions ?? []) + .map(x => x + Number.EPSILON) + } + getProgressOf(index, range) { + const tocItem = this.#tocProgress?.getProgress(index, range) + const pageItem = this.#pageProgress?.getProgress(index, range) + return { tocItem, pageItem } + } + async getCFIProgress(cfi) { + const progress = await this.#cfiProgress?.getProgress(cfi) + if (!progress || progress.index === -1) return null + return this.#sectionProgress?.getProgress(progress.index, progress.fraction) + } + async getTOCItemOf(target) { + try { + const { index, anchor } = await this.resolveNavigation(target) + const doc = await this.book.sections[index].createDocument() + const frag = anchor(doc) + const isRange = frag instanceof Range + const range = isRange ? frag : doc.createRange() + if (!isRange) range.selectNodeContents(frag) + return this.#tocProgress?.getProgress(index, range) + } catch(e) { + console.error(e) + console.error(`Could not get ${target}`) + } + } + async prev(distance) { + await this.renderer.prev(distance) + } + async next(distance) { + await this.renderer.next(distance) + } + async pan(dx, dy) { + await this.renderer.pan(dx, dy) + } + isOverflowX() { + return this.renderer.isOverflowX + } + isOverflowY() { + return this.renderer.isOverflowY + } + goLeft() { + return this.book.dir === 'rtl' ? this.next() : this.prev() + } + goRight() { + return this.book.dir === 'rtl' ? this.prev() : this.next() + } + // A matcher result carries a primary `range` (nav/excerpt anchor) and, for + // nearby-words, per-word `subRanges` to highlight each matched word. + #toSearchMatch(index, { range, excerpt, subRanges }) { + const cfi = this.getCFI(index, range) + if (subRanges?.length) + return { cfi, cfis: subRanges.map(r => this.getCFI(index, r)), excerpt } + return { cfi, excerpt } + } + async * #searchSection(matcher, query, index) { + const doc = await this.book.sections[index].createDocument() + for (const match of matcher(doc, query)) + yield this.#toSearchMatch(index, match) + } + async * #searchBook(matcher, query) { + const { sections } = this.book + for (const [index, { createDocument }] of sections.entries()) { + if (!createDocument) continue + const doc = await createDocument() + const subitems = Array.from(matcher(doc, query), match => this.#toSearchMatch(index, match)) + const progress = (index + 1) / sections.length + yield { progress } + if (subitems.length) yield { index, subitems } + } + } + async * search(opts) { + this.clearSearch() + const { searchMatcher } = await import('./search.js') + const { sections } = this.book + const { query, index, results } = opts + const matcher = searchMatcher(textWalker, + { defaultLocale: this.language, ...opts }) + + const iter = results?.length + ? (async function* () { + for (const result of results) { + if (result.subitems) { + const progress = (result.index + 1) / sections.length + yield { progress } + yield { index: result.index, subitems: result.subitems } + } else { + yield { cfi: result.cfi, cfis: result.cfis, excerpt: result.excerpt } + } + } + })() + : index != null + ? this.#searchSection(matcher, query, index) + : this.#searchBook(matcher, query) + + const list = [] + const seen = new Set() + this.#searchResults.set(index, list) + // Add one annotation per unique CFI (a nearby-words match carries several + // via `cfis`); dedupe so overlapping CFIs don't collide in #searchResults. + const addHighlights = (cfis, sink, sinkSeen) => { + for (const cfi of cfis) { + if (sinkSeen.has(cfi)) continue + sinkSeen.add(cfi) + const item = { value: SEARCH_PREFIX + cfi } + sink.push(item) + this.addAnnotation(item) + } + } + + for await (const result of iter) { + if (result.subitems){ + const sectionList = [] + const sectionSeen = new Set() + for (const item of result.subitems) + addHighlights(item.cfis ?? [item.cfi], sectionList, sectionSeen) + this.#searchResults.set(result.index, sectionList) + yield { + index: result.index, + label: this.#tocProgress?.getProgress(result.index)?.label ?? '', + subitems: result.subitems, + } + } + else { + if (result.cfi) addHighlights(result.cfis ?? [result.cfi], list, seen) + yield result + } + } + yield 'done' + } + clearSearch() { + for (const list of this.#searchResults.values()) + for (const item of list) this.deleteAnnotation(item) + this.#searchResults.clear() + } + async initTTS(granularity = 'word', nodeFilter, highlighter) { + const contents = this.renderer.getContents() + const primaryIndex = this.renderer.primaryIndex + const primary = contents.find(x => x.index === primaryIndex) ?? contents[0] + const doc = primary?.doc + if (!doc) return + if (this.tts && this.tts.doc === doc) return + const { TTS } = await import('./tts.js') + this.tts = new TTS(doc, textWalker, nodeFilter, highlighter || (range => + this.renderer.scrollToAnchor(range, true)), granularity) + } + startMediaOverlay() { + const contents = this.renderer.getContents() + const primaryIndex = this.renderer.primaryIndex + const primary = contents.find(x => x.index === primaryIndex) ?? contents[0] + const { index } = primary ?? {} + return this.mediaOverlay.start(index) + } +} + +customElements.define('foliate-view', View) From c946b6d71c1992f201a41d69212d36c99b0d8474 Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 11 Aug 2026 22:04:50 -0400 Subject: [PATCH 23/38] chore: wire vendored foliate-js into the build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit $foliate is a Vite-only alias, deliberately absent from kit.alias and tsconfig paths: TypeScript cannot resolve it, so the ambient declaration in src/lib/reader/foliate.d.ts is the only candidate and svelte-check never walks the ~11k lines of untyped vendored JS. The generated tsconfig includes src/**/*.js and checkJs is on, so src/lib/vendor is excluded as well — `exclude` replaces rather than merges, hence the repeated service-worker entries. optimizeDeps.include for construct-style-sheets-polyfill: it sits behind a dynamic import in fixed-layout.js, so Vite would otherwise discover it mid-session and force a page reload on the first fixed-layout EPUB. Verified with a temporary import that the graph resolves: view.js and paginator.js are emitted as chunks, and the bundled pdf.js is our stub rather than upstream's bare `import '@pdfjs/pdf.min.mjs'`. --- frontend/src/lib/reader/foliate.d.ts | 90 ++++++++++++++++++++++++++++ frontend/src/lib/reader/foliate.ts | 32 ++++++++++ frontend/tsconfig.json | 16 ++++- frontend/vite.config.ts | 18 +++++- 4 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 frontend/src/lib/reader/foliate.d.ts create mode 100644 frontend/src/lib/reader/foliate.ts diff --git a/frontend/src/lib/reader/foliate.d.ts b/frontend/src/lib/reader/foliate.d.ts new file mode 100644 index 0000000..cb52e99 --- /dev/null +++ b/frontend/src/lib/reader/foliate.d.ts @@ -0,0 +1,90 @@ +/** + * Hand-written types for the vendored foliate-js (src/lib/vendor/foliate-js). + * + * `$foliate` is a Vite-only alias, so TypeScript cannot resolve it and this + * ambient declaration is the only candidate — which is the point: svelte-check + * never walks the untyped vendored JS. Only the surface Chitai calls is typed. + * When you start using a new method, add it here rather than casting to `any`. + */ +declare module '$foliate/view.js' { + /** A TOC entry. `id` is assigned by foliate's `assignIDs`, not by the book. */ + export interface FoliateTocItem { + id: number; + label: string; + href: string; + subitems?: FoliateTocItem[]; + } + + /** `event.detail` of the `relocate` event. Shape from SectionProgress.getProgress. */ + export interface FoliateRelocateDetail { + /** Overall progress through the book, 0–1. Chitai stores this as `percentage`. */ + fraction: number; + section: { current: number; total: number }; + location: { current: number; next: number; total: number }; + /** Estimated remaining reading time, in minutes. */ + time: { section: number; total: number }; + /** Null when the book has no TOC entry covering this position. */ + tocItem?: FoliateTocItem | null; + pageItem?: { label: string; href: string } | null; + cfi: string; + range?: Range; + } + + /** `event.detail` of the `load` event. `doc` is the section's iframe document. */ + export interface FoliateLoadDetail { + doc: Document; + index: number; + } + + export interface FoliateRenderer extends HTMLElement { + /** + * A single string sets the stylesheet appended to , which wins over the + * book's own CSS on equal specificity. A [before, after] tuple also sets one + * prepended to , which the book's CSS overrides. Re-applied by the + * paginator on every section load. Absent on the fixed-layout renderer. + */ + setStyles?(styles: string | [before: string, after: string]): void; + getContents(): { doc: Document; index: number }[]; + destroy(): void; + } + + export interface FoliateBook { + toc?: FoliateTocItem[]; + /** 'rtl' for right-to-left books; drives goLeft/goRight. */ + dir?: string; + metadata?: Record; + rendition?: { layout?: string }; + } + + /** Accepted by goTo/select/init: a CFI or href, a spine index, or a fraction. */ + export type FoliateTarget = string | number | { fraction: number }; + + export class View extends HTMLElement { + book: FoliateBook; + renderer: FoliateRenderer; + /** True when the book is pre-paginated; the renderer is then foliate-fxl. */ + isFixedLayout: boolean; + lastLocation: FoliateRelocateDetail | null; + + open(book: File | string | FoliateBook): Promise; + close(): void; + init(opts: { lastLocation?: FoliateTarget | null; showTextStart?: boolean }): Promise; + + /** Returns undefined on failure — it logs and swallows. Check before relying on it. */ + resolveNavigation(target: FoliateTarget): { index: number; anchor?: unknown } | undefined; + goTo(target: FoliateTarget): Promise<{ index: number } | undefined>; + goToFraction(fraction: number): Promise; + + prev(distance?: number): Promise; + next(distance?: number): Promise; + /** Direction-aware: inverts against prev/next for rtl books. */ + goLeft(): Promise; + goRight(): Promise; + } + + export function makeBook(file: File | string): Promise; + + export class ResponseError extends Error {} + export class NotFoundError extends Error {} + export class UnsupportedTypeError extends Error {} +} diff --git a/frontend/src/lib/reader/foliate.ts b/frontend/src/lib/reader/foliate.ts new file mode 100644 index 0000000..735c71f --- /dev/null +++ b/frontend/src/lib/reader/foliate.ts @@ -0,0 +1,32 @@ +import type { View } from '$foliate/view.js'; + +let pending: Promise | undefined; + +/** + * Loads foliate-js and registers its custom elements. + * + * view.js calls customElements.define() at module scope and subclasses + * HTMLElement, and its class fields construct DOM helpers eagerly — so importing + * it on the server throws. This module is safe to import anywhere because it + * touches nothing at module scope; the vendored code is only pulled in when + * loadFoliate() is called, which must be from onMount or a browser guard. + * + * The promise is cached so concurrent callers share one load. ESM already dedupes + * module evaluation; this mainly makes the "define runs once" contract explicit. + * Editing a file under src/lib/vendor invalidates the module and re-runs + * customElements.define, which throws — hard-refresh after re-vendoring. + */ +export function loadFoliate(): Promise { + return (pending ??= import('$foliate/view.js').then(() => undefined)); +} + +/** + * Creates a . Call only after loadFoliate() has resolved. + * + * Built imperatively rather than written in markup so SSR never emits an unknown + * element for Svelte to hydrate, and so svelte-check has no unknown attributes to + * complain about. + */ +export function createFoliateView(): View { + return document.createElement('foliate-view') as View; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index a5567ee..114ad9f 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -10,7 +10,21 @@ "sourceMap": true, "strict": true, "moduleResolution": "bundler" - } + }, + // `exclude` replaces the generated list rather than merging with it, so the + // service-worker entries from .svelte-kit/tsconfig.json are repeated here. + // src/lib/vendor is added: it is vendored third-party JS and checkJs is on, + // so without this svelte-check reports thousands of errors from foliate-js. + "exclude": [ + "node_modules/**", + "src/service-worker.js", + "src/service-worker/**/*.js", + "src/service-worker.ts", + "src/service-worker/**/*.ts", + "src/service-worker.d.ts", + "src/service-worker/**/*.d.ts", + "src/lib/vendor/**" + ] // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files // diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 2d35c4f..ea0da9c 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,7 +1,23 @@ import tailwindcss from '@tailwindcss/vite'; import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; +import { fileURLToPath } from 'node:url'; export default defineConfig({ - plugins: [tailwindcss(), sveltekit()] + plugins: [tailwindcss(), sveltekit()], + resolve: { + alias: { + // Deliberately a Vite-only alias, and deliberately not in kit.alias or + // tsconfig paths. TypeScript cannot resolve `$foliate/*`, so the ambient + // declaration in src/lib/reader/foliate.d.ts is the only candidate and + // svelte-check never walks the ~11k lines of untyped vendored JS. + $foliate: fileURLToPath(new URL('./src/lib/vendor/foliate-js', import.meta.url)) + } + }, + optimizeDeps: { + // Behind a dynamic import in foliate's fixed-layout.js, so Vite would + // otherwise discover it mid-session and force a full page reload the first + // time someone opens a fixed-layout EPUB. + include: ['construct-style-sheets-polyfill'] + } }); From 783f4d226d43002696223c54a929c36f84d2e90d Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 11 Aug 2026 22:08:58 -0400 Subject: [PATCH 24/38] feat: add reader settings state and stylesheet generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings are validated on read, not only on write: several go straight onto foliate's renderer as custom-element attributes, where a bad value wedges the layout silently rather than throwing. Unknown keys are stripped and missing ones filled from the defaults, so settings added later do not invalidate what a reader already has stored. The stylesheet is built as foliate's [before, after] pair. The paginator prepends the first to the section's and appends the second, so the book's own CSS overrides the first and loses to the second. Typography goes in the first — a book that styles its own headings still gets to, and font size lands on without !important so rem/em headings scale rather than being flattened. Colour goes in the second, because most EPUBs set a body background and would otherwise leave the page white against a dark UI. Font choices are reused from the app theme's FONT_STACKS rather than duplicated, so the reader cannot drift from the rest of the UI. Rename the ambient declaration to foliate-js.d.ts: as foliate.d.ts beside foliate.ts, TypeScript takes it for that file's emitted declaration, drops it from the program, and every $foliate import fails with TS2307. --- .../reader/{foliate.d.ts => foliate-js.d.ts} | 4 + frontend/src/lib/reader/settings.ts | 83 +++++++++++ frontend/src/lib/reader/stylesheet.ts | 136 ++++++++++++++++++ frontend/src/lib/schema/reader.ts | 35 +++++ .../src/lib/state/reader-settings.svelte.ts | 43 ++++++ 5 files changed, 301 insertions(+) rename frontend/src/lib/reader/{foliate.d.ts => foliate-js.d.ts} (93%) create mode 100644 frontend/src/lib/reader/settings.ts create mode 100644 frontend/src/lib/reader/stylesheet.ts create mode 100644 frontend/src/lib/schema/reader.ts create mode 100644 frontend/src/lib/state/reader-settings.svelte.ts diff --git a/frontend/src/lib/reader/foliate.d.ts b/frontend/src/lib/reader/foliate-js.d.ts similarity index 93% rename from frontend/src/lib/reader/foliate.d.ts rename to frontend/src/lib/reader/foliate-js.d.ts index cb52e99..ae47cdd 100644 --- a/frontend/src/lib/reader/foliate.d.ts +++ b/frontend/src/lib/reader/foliate-js.d.ts @@ -5,6 +5,10 @@ * ambient declaration is the only candidate — which is the point: svelte-check * never walks the untyped vendored JS. Only the surface Chitai calls is typed. * When you start using a new method, add it here rather than casting to `any`. + * + * The filename must not match a sibling .ts. As foliate.d.ts next to foliate.ts, + * TypeScript takes it for that file's emitted declaration and drops it from the + * program, and every $foliate import fails with TS2307. */ declare module '$foliate/view.js' { /** A TOC entry. `id` is assigned by foliate's `assignIDs`, not by the book. */ diff --git a/frontend/src/lib/reader/settings.ts b/frontend/src/lib/reader/settings.ts new file mode 100644 index 0000000..a058a8a --- /dev/null +++ b/frontend/src/lib/reader/settings.ts @@ -0,0 +1,83 @@ +import { FONT_STACKS } from '$lib/theme/presets'; +import { readerSettingsSchema, type ReaderSettings } from '$lib/schema/reader'; + +export const READER_SETTINGS_STORAGE_KEY = 'chitai:reader-settings'; + +/** Georgia — the app's own title face, and a reasonable default for long prose. */ +export const DEFAULT_READER_SETTINGS: ReaderSettings = { + fontFamily: FONT_STACKS[1].value, + fontSize: 18, + fontWeight: 400, + lineHeight: 1.6, + letterSpacing: 0, + margin: 48, + gap: 6, + maxInlineSize: 720, + maxColumnCount: 2, + flow: 'paginated', + justify: true, + hyphenate: true +}; + +/** Reused from the app theme: system stacks only, so nothing silently falls back. */ +export const READER_FONT_OPTIONS = FONT_STACKS; + +/** Slider bounds, kept next to the schema they mirror. */ +export const READER_BOUNDS = { + fontSize: { min: 12, max: 32, step: 1 }, + fontWeight: { min: 300, max: 700, step: 100 }, + lineHeight: { min: 1, max: 2.5, step: 0.05 }, + letterSpacing: { min: -0.05, max: 0.2, step: 0.01 }, + margin: { min: 0, max: 120, step: 4 }, + gap: { min: 0, max: 15, step: 1 }, + maxInlineSize: { min: 480, max: 1400, step: 20 } +} as const; + +/** + * Reads stored settings, falling back to defaults on anything unusable. + * + * Unknown keys are stripped and missing ones filled, so settings added in a + * later release do not invalidate what a reader already has stored. + */ +export function loadReaderSettings(): ReaderSettings { + try { + const raw = localStorage.getItem(READER_SETTINGS_STORAGE_KEY); + if (!raw) return DEFAULT_READER_SETTINGS; + const parsed = readerSettingsSchema.safeParse({ + ...DEFAULT_READER_SETTINGS, + ...JSON.parse(raw) + }); + return parsed.success ? parsed.data : DEFAULT_READER_SETTINGS; + } catch { + return DEFAULT_READER_SETTINGS; + } +} + +export function saveReaderSettings(settings: ReaderSettings) { + try { + localStorage.setItem(READER_SETTINGS_STORAGE_KEY, JSON.stringify(settings)); + } catch (error) { + // A full quota must not stop anyone reading. + console.warn('Could not save reader settings', error); + } +} + +/** + * The half of the settings that foliate takes as renderer attributes. + * + * The renderer has no JS property API — these must be set with setAttribute. + * Note there is no `margin` shorthand upstream, and no `spread`: on a reflowable + * book a two-page spread is max-column-count 2. + */ +export function toRendererAttributes(s: ReaderSettings): Record { + return { + flow: s.flow, + gap: `${s.gap}%`, + 'margin-top': `${s.margin}px`, + 'margin-bottom': `${s.margin}px`, + 'margin-left': `${s.margin}px`, + 'margin-right': `${s.margin}px`, + 'max-inline-size': `${s.maxInlineSize}px`, + 'max-column-count': String(s.maxColumnCount) + }; +} diff --git a/frontend/src/lib/reader/stylesheet.ts b/frontend/src/lib/reader/stylesheet.ts new file mode 100644 index 0000000..067360e --- /dev/null +++ b/frontend/src/lib/reader/stylesheet.ts @@ -0,0 +1,136 @@ +import type { ReaderSettings } from '$lib/schema/reader'; + +export interface ReaderPalette { + bg: string; + fg: string; + muted: string; + link: string; + dark: boolean; +} + +/** + * Reads the app palette out of the live document. + * + * The reader route uses a +layout@ breakout, so it never runs (root)/+layout and + * cannot reach the theme context. It does not need to: hooks.server.ts inlines + * diff --git a/frontend/src/lib/components/reader/foliate-view.svelte b/frontend/src/lib/components/reader/foliate-view.svelte new file mode 100644 index 0000000..55e82ff --- /dev/null +++ b/frontend/src/lib/components/reader/foliate-view.svelte @@ -0,0 +1,230 @@ + + + + +
+ + diff --git a/frontend/src/lib/components/reader/reader-toc.svelte b/frontend/src/lib/components/reader/reader-toc.svelte new file mode 100644 index 0000000..0029884 --- /dev/null +++ b/frontend/src/lib/components/reader/reader-toc.svelte @@ -0,0 +1,86 @@ + + + + +

Chapters

+
+ + + + {#each toc as chapter (chapter.id)} + {#if chapter.subitems?.length} + +
+ + onnavigate(chapter.href)} + > + {chapter.label} + + + + + +
+ + + {#each chapter.subitems ?? [] as subchapter (subchapter.id)} + + onnavigate(subchapter.href)} + > + + {subchapter.label} + + + + {/each} + + +
+ {:else} + + onnavigate(chapter.href)} + > + {chapter.label} + + + {/if} + {/each} +
+
+
+
diff --git a/frontend/src/lib/reader/legacy-cache.ts b/frontend/src/lib/reader/legacy-cache.ts new file mode 100644 index 0000000..d609a41 --- /dev/null +++ b/frontend/src/lib/reader/legacy-cache.ts @@ -0,0 +1,29 @@ +const PURGED_FLAG = 'chitai:locations-purged'; + +/** Keys the epub.js reader wrote: `${bookId}-locations`. */ +const LEGACY_KEY = /^\d+-locations$/; + +/** + * Drops the epub.js locations cache, once per browser. + * + * foliate computes progress from section byte sizes at open time, so there is no + * locations pre-pass and nothing to cache. The old entries are not small — a + * few hundred KB of JSON per long book against a 5–10 MB origin quota — and a + * heavy reader sitting near the cap would make the new settings write throw + * QuotaExceededError. + * + * Removable once deployments have had a release to run it; see TODO.md. + */ +export function purgeLegacyLocationCache() { + try { + if (localStorage.getItem(PURGED_FLAG)) return; + + for (const key of Object.keys(localStorage)) { + if (LEGACY_KEY.test(key)) localStorage.removeItem(key); + } + + localStorage.setItem(PURGED_FLAG, '1'); + } catch (error) { + console.warn('Could not purge the legacy locations cache', error); + } +} diff --git a/frontend/src/lib/reader/progress.ts b/frontend/src/lib/reader/progress.ts new file mode 100644 index 0000000..db2d44d --- /dev/null +++ b/frontend/src/lib/reader/progress.ts @@ -0,0 +1,114 @@ +interface PendingProgress { + percentage: number; + epub_cfi: string; + completed: boolean; +} + +/** Treat the last stretch as finished: fraction is a float and never lands on 1. */ +const COMPLETE_AT = 0.99; + +const SAVE_DEBOUNCE_MS = 3000; + +/** + * Debounced reading-progress writer. + * + * Progress goes through the catch-all proxy rather than a remote function. That + * looks like a convention violation but is the documented exception: the browser + * itself must send this, and the closing write uses sendBeacon, which needs a + * plain URL and body rather than a remote command's envelope. The httpOnly + * authToken cookie rides along and the proxy attaches the bearer header. + */ +export class ProgressReporter { + #url: string; + #timer: ReturnType | undefined; + #pending: PendingProgress | null = null; + #listening = false; + + constructor(bookId: string | number) { + this.#url = `/api/books/progress/${bookId}`; + } + + /** + * Starts flushing on the way out. + * + * pagehide and a hidden visibilitychange, not beforeunload: mobile Safari + * fires beforeunload unreliably and it blocks the bfcache. + */ + listen() { + if (this.#listening || typeof document === 'undefined') return; + document.addEventListener('visibilitychange', this.#onVisibilityChange); + window.addEventListener('pagehide', this.#onPageHide); + this.#listening = true; + } + + record(percentage: number, epubCfi: string) { + this.#pending = { + percentage, + epub_cfi: epubCfi, + completed: percentage >= COMPLETE_AT + }; + + clearTimeout(this.#timer); + this.#timer = setTimeout(() => void this.flush(), SAVE_DEBOUNCE_MS); + } + + /** Sends anything outstanding and waits for it. */ + async flush() { + const body = this.#take(); + if (!body) return; + + try { + const response = await fetch(this.#url, { + method: 'POST', + // Without this the browser stamps text/plain and the proxy forwards it + // verbatim. Litestar decodes it anyway, which is why it went unnoticed. + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + keepalive: true + }); + + // A failed save must not interrupt reading, but should not be silent either. + if (!response.ok) console.error('Could not save reading progress', response.status); + } catch (error) { + console.error('Could not save reading progress', error); + } + } + + /** + * Fire-and-forget flush for teardown. + * + * The old reader cleared its debounce timer on destroy without flushing, so a + * page turn within three seconds of leaving was dropped — and leaving is + * exactly when someone turns a last page. + */ + flushSync() { + const body = this.#take(); + if (!body) return; + + const blob = new Blob([JSON.stringify(body)], { type: 'application/json' }); + if (!navigator.sendBeacon?.(this.#url, blob)) void this.flush(); + } + + dispose() { + this.flushSync(); + clearTimeout(this.#timer); + if (!this.#listening) return; + document.removeEventListener('visibilitychange', this.#onVisibilityChange); + window.removeEventListener('pagehide', this.#onPageHide); + this.#listening = false; + } + + /** Claims the pending write so it cannot be sent twice. */ + #take(): PendingProgress | null { + const body = this.#pending; + this.#pending = null; + clearTimeout(this.#timer); + return body; + } + + #onVisibilityChange = () => { + if (document.visibilityState === 'hidden') this.flushSync(); + }; + + #onPageHide = () => this.flushSync(); +} diff --git a/frontend/src/lib/reader/stylesheet.ts b/frontend/src/lib/reader/stylesheet.ts index 067360e..74d0dc9 100644 --- a/frontend/src/lib/reader/stylesheet.ts +++ b/frontend/src/lib/reader/stylesheet.ts @@ -16,9 +16,11 @@ export interface ReaderPalette { * From 5305d3bb5e9f0eb6b4062705e899f4670776fa3d Mon Sep 17 00:00:00 2001 From: patrick Date: Wed, 12 Aug 2026 14:21:10 -0400 Subject: [PATCH 37/38] docs: correct the CSP entry in TODO.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scripted EPUBs are a regression from the foliate migration, not a pre-existing gap: epub.js sandboxed without allow-scripts by default, foliate sets it unconditionally. Records grimmory's fix — CSP on per-entry responses rather than app-wide — which also sidesteps the mode-watcher blocker, plus the whole-file buffering it would remove. --- TODO.md | 81 ++++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 63 insertions(+), 18 deletions(-) diff --git a/TODO.md b/TODO.md index 7a94b31..17bc31e 100644 --- a/TODO.md +++ b/TODO.md @@ -51,28 +51,73 @@ Worth adding at the same time: ## Frontend -### No CSP, so scripted EPUBs run against the app origin +### Scripted EPUBs run against the app origin -EPUB files may contain JavaScript. foliate-js renders each section in an iframe from a -**same-origin** `blob:` URL and cannot sandbox it — `allow-scripts` is required, and -blob URLs inherit the embedder's origin — so script inside a book can reach `/api/*` -with the session cookie attached. foliate's own README says not to use it without a -Content Security Policy blocking scripts. +**This is a regression from the foliate-js migration, not a pre-existing gap.** -The obvious policy is `kit.csp` in `svelte.config.js` with `script-src: ['self']`, and -deliberately no `default-src` (it would also cover `style-src`/`img-src`/`font-src` and -kill both the book's own blob: assets and the inline `