feat(epub): support continuous scroll and spread layout for EPUBs, closes #3419 and closes #1745 (#3546)

This commit is contained in:
Huang Xin
2026-03-17 01:24:07 +08:00
committed by GitHub
parent f7b2a2432c
commit 1af55e24da
15 changed files with 1616 additions and 23 deletions
@@ -0,0 +1,501 @@
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
import { DocumentLoader } from '@/libs/document';
import type { BookDoc } from '@/libs/document';
import type { FoliateView } from '@/types/view';
// Vite serves fixture files; fetch the EPUB at runtime in the browser.
const EPUB_URL = new URL('../fixtures/data/sample-alice.epub', import.meta.url).href;
interface PaginatorElement extends HTMLElement {
open: (book: BookDoc) => void;
goTo: (target: {
index: number;
anchor?: number | (() => number);
select?: boolean;
}) => Promise<void>;
prev: () => Promise<void>;
next: () => Promise<void>;
destroy: () => void;
getContents: () => Array<{ index: number; doc: Document; overlayer: unknown }>;
setStyles: (styles: string | [string, string]) => void;
render: () => void;
primaryIndex: number;
pages: number;
page: number;
size: number;
viewSize: number;
scrolled: boolean;
columnCount: number;
sections: Array<{ linear?: string; load: () => Promise<string> }>;
}
let book: BookDoc;
const loadEPUB = async () => {
const resp = await fetch(EPUB_URL);
const buffer = await resp.arrayBuffer();
const file = new File([buffer], 'sample-alice.epub', { type: 'application/epub+zip' });
const loader = new DocumentLoader(file);
const { book } = await loader.open();
return book;
};
/**
* Wait for the paginator to emit 'stabilized'.
* MUST be called BEFORE the action that triggers stabilization (e.g. goTo),
* because #display dispatches 'stabilized' synchronously before returning.
*/
const waitForStabilized = (el: HTMLElement, timeout = 10000) =>
new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('stabilized timeout')), timeout);
el.addEventListener(
'stabilized',
() => {
clearTimeout(timer);
resolve();
},
{ once: true },
);
});
/** Wait until `getContents().length >= n` or timeout. */
const waitForViews = async (el: PaginatorElement, n: number, timeout = 10000) => {
const start = Date.now();
while (Date.now() - start < timeout) {
if (el.getContents().length >= n) return;
await new Promise((r) => setTimeout(r, 100));
}
};
/** Wait for fill to complete by polling until getContents count stabilizes. */
const waitForFillComplete = async (el: PaginatorElement, timeout = 10000) => {
const start = Date.now();
let lastCount = -1;
let stableFor = 0;
while (Date.now() - start < timeout) {
const count = el.getContents().length;
if (count === lastCount) {
stableFor += 100;
if (stableFor >= 500) return;
} else {
stableFor = 0;
lastCount = count;
}
await new Promise((r) => setTimeout(r, 100));
}
};
describe('Paginator multi-view architecture (browser)', () => {
let paginator: PaginatorElement;
beforeAll(async () => {
book = await loadEPUB();
await import('foliate-js/paginator.js');
}, 30000);
const createPaginator = () => {
const el = document.createElement('foliate-paginator') as PaginatorElement;
// The paginator needs non-zero dimensions for layout calculations
Object.assign(el.style, {
width: '800px',
height: '600px',
position: 'absolute',
left: '0',
top: '0',
});
document.body.appendChild(el);
return el;
};
/** Create paginator, open book, navigate to section, wait for stabilized. */
const setupAt = async (index: number) => {
paginator = createPaginator();
paginator.open(book);
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index });
await stabilized;
};
afterEach(() => {
if (paginator) {
try {
paginator.destroy();
} catch {
/* iframe body may already be torn down */
}
paginator.remove();
}
});
describe('Initial state', () => {
it('should expose primaryIndex as -1 before any section is loaded', () => {
paginator = createPaginator();
paginator.open(book);
expect(paginator.primaryIndex).toBe(-1);
});
it('should have no pages before loading a section', () => {
paginator = createPaginator();
paginator.open(book);
expect(paginator.pages).toBe(0);
});
it('should return empty contents before loading', () => {
paginator = createPaginator();
paginator.open(book);
expect(paginator.getContents()).toEqual([]);
});
});
describe('Single section display', () => {
it('should set primaryIndex after goTo', async () => {
const firstLinear = book.sections!.findIndex((s) => s.linear !== 'no');
await setupAt(firstLinear);
expect(paginator.primaryIndex).toBe(firstLinear);
});
it('should have positive page count after loading', async () => {
const firstLinear = book.sections!.findIndex((s) => s.linear !== 'no');
await setupAt(firstLinear);
expect(paginator.pages).toBeGreaterThan(0);
});
it('should emit stabilized event after display', async () => {
paginator = createPaginator();
paginator.open(book);
const firstLinear = book.sections!.findIndex((s) => s.linear !== 'no');
let stabilizedFired = false;
paginator.addEventListener('stabilized', () => {
stabilizedFired = true;
});
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: firstLinear });
await stabilized;
expect(stabilizedFired).toBe(true);
});
it('should have at least one entry in getContents after loading', async () => {
const firstLinear = book.sections!.findIndex((s) => s.linear !== 'no');
await setupAt(firstLinear);
const contents = paginator.getContents();
expect(contents.length).toBeGreaterThanOrEqual(1);
expect(contents.find((c) => c.index === firstLinear)).toBeDefined();
});
it('should have a valid document in getContents entries', async () => {
const firstLinear = book.sections!.findIndex((s) => s.linear !== 'no');
await setupAt(firstLinear);
const contents = paginator.getContents();
const primary = contents.find((c) => c.index === firstLinear);
expect(primary).toBeDefined();
// Iframe documents have a different Document constructor than the
// test's global scope, so use nodeType check instead of instanceof.
expect(primary!.doc.nodeType).toBe(Node.DOCUMENT_NODE);
expect(primary!.doc.body).toBeTruthy();
});
});
describe('Multi-view filling', () => {
it('should load adjacent sections after display', async () => {
const firstLinear = book.sections!.findIndex((s) => s.linear !== 'no');
await setupAt(firstLinear);
await waitForViews(paginator, 2);
const contents = paginator.getContents();
expect(contents.length).toBeGreaterThan(1);
});
it('should return contents sorted by section index', async () => {
const firstLinear = book.sections!.findIndex((s) => s.linear !== 'no');
await setupAt(firstLinear);
await waitForViews(paginator, 2);
const contents = paginator.getContents();
const indices = contents.map((c) => c.index);
expect(indices).toEqual([...indices].sort((a, b) => a - b));
});
it('should include the primary section in getContents', async () => {
const firstLinear = book.sections!.findIndex((s) => s.linear !== 'no');
await setupAt(firstLinear);
await waitForViews(paginator, 2);
const contents = paginator.getContents();
const primary = contents.find((c) => c.index === firstLinear);
expect(primary).toBeDefined();
});
});
describe('Navigation between sections', () => {
it('should update primaryIndex when navigating to a different section', async () => {
const linearSections = book
.sections!.map((s, i) => ({ s, i }))
.filter(({ s }) => s.linear !== 'no');
expect(linearSections.length).toBeGreaterThan(1);
const first = linearSections[0]!.i;
await setupAt(first);
expect(paginator.primaryIndex).toBe(first);
// Second goTo may reuse an already-loaded view (no 'stabilized' event).
// Just await the goTo promise directly.
const second = linearSections[1]!.i;
await paginator.goTo({ index: second });
await new Promise((r) => setTimeout(r, 200));
expect(paginator.primaryIndex).toBe(second);
});
it('should navigate to a section by fraction anchor', async () => {
const linearSections = book
.sections!.map((s, i) => ({ s, i }))
.filter(({ s }) => s.linear !== 'no' && s.size > 2000);
const idx = linearSections[0]!.i;
paginator = createPaginator();
paginator.open(book);
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: idx, anchor: 0.5 });
await stabilized;
expect(paginator.primaryIndex).toBe(idx);
// When anchored at 0.5 in a multi-page section, page > 0
expect(paginator.page).toBeGreaterThan(0);
});
it('should navigate to a later section and back', async () => {
const linearSections = book
.sections!.map((s, i) => ({ s, i }))
.filter(({ s }) => s.linear !== 'no');
expect(linearSections.length).toBeGreaterThan(2);
const later = linearSections[Math.min(5, linearSections.length - 1)]!.i;
await setupAt(later);
expect(paginator.primaryIndex).toBe(later);
// Navigate back to first — far enough that views won't be reused
const first = linearSections[0]!.i;
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: first });
await stabilized;
expect(paginator.primaryIndex).toBe(first);
});
});
describe('Relocate events', () => {
it('should emit relocate with fraction data after display', async () => {
paginator = createPaginator();
paginator.open(book);
const relocates: Array<{ index: number; fraction: number; reason: string }> = [];
paginator.addEventListener('relocate', ((e: CustomEvent) => {
relocates.push(e.detail);
}) as EventListener);
const firstLinear = book.sections!.findIndex((s) => s.linear !== 'no');
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: firstLinear });
await stabilized;
// Give time for afterScroll to fire
await new Promise((r) => setTimeout(r, 500));
expect(relocates.length).toBeGreaterThan(0);
const last = relocates[relocates.length - 1]!;
expect(last.index).toBe(firstLinear);
expect(typeof last.fraction).toBe('number');
expect(last.fraction).toBeGreaterThanOrEqual(0);
expect(last.fraction).toBeLessThanOrEqual(1);
});
it('should report fraction=0 when anchored at start of section', async () => {
paginator = createPaginator();
paginator.open(book);
const relocates: Array<{ index: number; fraction: number }> = [];
paginator.addEventListener('relocate', ((e: CustomEvent) => {
relocates.push(e.detail);
}) as EventListener);
const firstLinear = book.sections!.findIndex((s) => s.linear !== 'no');
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: firstLinear, anchor: 0 });
await stabilized;
await new Promise((r) => setTimeout(r, 500));
const last = relocates[relocates.length - 1]!;
expect(last.fraction).toBe(0);
});
});
describe('Two-column layout at 800×600', () => {
// At 800px width, Math.ceil(800/720) = 2 and max-column-count defaults
// to 2, so the paginator uses a two-column (spread) layout.
it('should use columnCount=2 at 800px width', async () => {
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
await setupAt(idx);
expect(paginator.columnCount).toBe(2);
});
it('should have viewSize >= size for a section with content', async () => {
const idx = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 2000);
await setupAt(idx);
// viewSize includes padding + content columns
expect(paginator.viewSize).toBeGreaterThanOrEqual(paginator.size);
});
it('should compute pages = ceil(viewSize / size)', async () => {
const idx = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 2000);
await setupAt(idx);
const expectedPages = Math.ceil(paginator.viewSize / paginator.size);
expect(paginator.pages).toBe(expectedPages);
});
it('should advance fraction by 2 columns per page turn', async () => {
paginator = createPaginator();
paginator.open(book);
// Pick a section large enough to span multiple spreads
const idx = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 5000);
const relocates: Array<{ fraction: number; size: number }> = [];
paginator.addEventListener('relocate', ((e: CustomEvent) => {
relocates.push({ fraction: e.detail.fraction, size: e.detail.size });
}) as EventListener);
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: idx, anchor: 0 });
await stabilized;
await waitForFillComplete(paginator);
await new Promise((r) => setTimeout(r, 300));
const fractionAtStart = relocates[relocates.length - 1]?.fraction ?? 0;
expect(fractionAtStart).toBe(0);
// Page forward once — should advance by 2 columns worth of content
await paginator.next();
await new Promise((r) => setTimeout(r, 500));
const fractionAfterNext = relocates[relocates.length - 1]!.fraction;
expect(fractionAfterNext).toBeGreaterThan(fractionAtStart);
// The fraction step per page should equal columnCount / textPages.
// With 2 columns, each page turn advances by exactly `size` (detail.size).
const lastSize = relocates[relocates.length - 1]!.size;
expect(lastSize).toBeGreaterThan(0);
expect(lastSize).toBeLessThanOrEqual(1);
// fraction advanced by approximately `size` (2/textPages)
const step = fractionAfterNext - fractionAtStart;
expect(step).toBeCloseTo(lastSize, 1);
});
it('should report two header/footer cells for two-column layout', async () => {
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
await setupAt(idx);
// In 2-column layout, the paginator creates 2 header and 2 footer cells
const header = paginator.shadowRoot?.getElementById('header');
const footer = paginator.shadowRoot?.getElementById('footer');
expect(header?.children.length).toBe(2);
expect(footer?.children.length).toBe(2);
});
it('should use columnCount=1 in scrolled mode regardless of width', async () => {
paginator = createPaginator();
paginator.open(book);
paginator.setAttribute('flow', 'scrolled');
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: idx });
await stabilized;
// Scrolled mode always uses 1 column
expect(paginator.columnCount).toBe(1);
});
});
describe('Adjacent index skipping non-linear sections', () => {
it('should skip non-linear sections during navigation', async () => {
paginator = createPaginator();
paginator.open(book);
const sections = book.sections!;
const nonLinearIdx = sections.findIndex((s) => s.linear === 'no');
// If there are non-linear sections, navigating past them should work
if (nonLinearIdx >= 0) {
const prevLinear = sections.slice(0, nonLinearIdx).findLastIndex((s) => s.linear !== 'no');
if (prevLinear >= 0) {
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: prevLinear });
await stabilized;
await waitForFillComplete(paginator);
// Next should skip non-linear and go to next linear
await paginator.next();
await new Promise((r) => setTimeout(r, 500));
expect(paginator.primaryIndex).not.toBe(nonLinearIdx);
expect(sections[paginator.primaryIndex]!.linear).not.toBe('no');
}
}
// If no non-linear sections, the test passes trivially
expect(true).toBe(true);
});
});
describe('Scrolled mode', () => {
it('should support flow=scrolled attribute', async () => {
paginator = createPaginator();
paginator.open(book);
paginator.setAttribute('flow', 'scrolled');
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: idx });
await stabilized;
expect(paginator.scrolled).toBe(true);
expect(paginator.primaryIndex).toBe(idx);
});
});
});
describe('FoliateView CFI navigation (browser)', () => {
let view: FoliateView;
beforeAll(async () => {
book = await loadEPUB();
// Import both view.js (registers foliate-view) and paginator.js
await import('foliate-js/view.js');
await import('foliate-js/paginator.js');
}, 30000);
const createView = () => {
const el = document.createElement('foliate-view') as FoliateView;
Object.assign(el.style, {
width: '800px',
height: '600px',
position: 'absolute',
left: '0',
top: '0',
});
document.body.appendChild(el);
return el;
};
afterEach(async () => {
if (view) {
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
try {
view.close();
} catch {
/* iframe body may already be torn down */
}
view.remove();
}
});
it('should navigate to CFI and render Chapter 5', async () => {
view = createView();
await view.open(book);
const cfi = 'epubcfi(/6/16!/4,,/2/2[chapter_466]/4/18/1:70)';
const { index } = view.resolveCFI(cfi);
// Wait for the renderer (paginator) to stabilize after goTo
const stabilized = new Promise<void>((resolve) => {
view.renderer.addEventListener('stabilized', () => resolve(), { once: true });
});
await view.goTo(cfi);
await stabilized;
// The primary section should contain "Chapter 5"
const contents = view.renderer.getContents();
const primary = contents.find((c) => c.index === index);
expect(primary).toBeDefined();
const headings = primary!.doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
const chapterHeading = Array.from(headings).find((h) => h.textContent?.includes('Chapter 5'));
expect(chapterHeading).toBeDefined();
expect(chapterHeading!.textContent).toContain('Chapter 5');
});
});
@@ -0,0 +1,297 @@
import { describe, it, expect, vi } from 'vitest';
// Mock the paginator module to avoid custom element registration conflicts
vi.mock('foliate-js/paginator.js', () => ({}));
describe('Paginator multi-view architecture', () => {
describe('View padding configuration', () => {
it('should have default padding of {before:1, after:1}', () => {
// The View class has #padding defaulting to {before:1, after:1}
// This is verified by checking that paginator.pages includes 2 padding pages
// when there's a single view (pages = contentPages + 2)
expect(true).toBe(true); // placeholder - tested via integration
});
});
describe('Paginator primaryIndex getter', () => {
it('should expose primaryIndex on the paginator element', async () => {
// Register a stub custom element if not already registered
if (!customElements.get('foliate-paginator')) {
// Use a simplified stub that mirrors the real Paginator's public interface
customElements.define(
'foliate-paginator',
class extends HTMLElement {
#primaryIndex = -1;
get primaryIndex() {
return this.#primaryIndex;
}
},
);
}
const el = document.createElement('foliate-paginator') as HTMLElement & {
primaryIndex: number;
};
expect(el.primaryIndex).toBe(-1);
});
});
describe('getContents returns all loaded views', () => {
it('should return contents sorted by section index', () => {
// With multi-view, getContents() returns entries from all loaded views
// sorted by section index. Each entry has { doc, index, overlayer }.
// This is verified by the fact that consumers can .find() by index.
const contents = [
{ doc: {} as Document, index: 3, overlayer: null },
{ doc: {} as Document, index: 4, overlayer: null },
{ doc: {} as Document, index: 5, overlayer: null },
];
const primaryIndex = 4;
const primary = contents.find((x) => x.index === primaryIndex) ?? contents[0];
expect(primary?.index).toBe(4);
});
it('should fall back to first entry when primaryIndex not found', () => {
const contents = [{ doc: {} as Document, index: 3, overlayer: null }];
const primaryIndex = 99; // not in contents
const primary = contents.find((x) => x.index === primaryIndex) ?? contents[0];
expect(primary?.index).toBe(3);
});
it('should handle empty contents gracefully', () => {
const contents: { doc: Document; index: number; overlayer: unknown }[] = [];
const primaryIndex = 0;
const primary = contents.find((x) => x.index === primaryIndex) ?? contents[0];
expect(primary).toBeUndefined();
});
});
describe('View padding assignment logic', () => {
it('should assign {before:1, after:1} for single view', () => {
const views = [{ index: 5, padding: { before: 0, after: 0 } }];
// Simulate #updateViewPadding
if (views.length === 1) {
views[0]!.padding = { before: 1, after: 1 };
}
expect(views[0]!.padding).toEqual({ before: 1, after: 1 });
});
it('should assign correct padding for multiple views', () => {
const views = [
{ index: 3, padding: { before: 0, after: 0 } },
{ index: 4, padding: { before: 0, after: 0 } },
{ index: 5, padding: { before: 0, after: 0 } },
];
// Simulate #updateViewPadding
for (let i = 0; i < views.length; i++) {
const before = i === 0 ? 1 : 0;
const after = i === views.length - 1 ? 1 : 0;
views[i]!.padding = { before, after };
}
expect(views[0]!.padding).toEqual({ before: 1, after: 0 });
expect(views[1]!.padding).toEqual({ before: 0, after: 0 });
expect(views[2]!.padding).toEqual({ before: 0, after: 1 });
});
it('should assign correct padding for two views', () => {
const views = [
{ index: 3, padding: { before: 0, after: 0 } },
{ index: 4, padding: { before: 0, after: 0 } },
];
for (let i = 0; i < views.length; i++) {
const before = i === 0 ? 1 : 0;
const after = i === views.length - 1 ? 1 : 0;
views[i]!.padding = { before, after };
}
expect(views[0]!.padding).toEqual({ before: 1, after: 0 });
expect(views[1]!.padding).toEqual({ before: 0, after: 1 });
});
});
describe('Page counting across views (column-level sizing)', () => {
// contentPages is now in column units. View element sizes are:
// contentColumns * columnSize + (padding.before + padding.after) * spreadSize
// where columnSize = spreadSize / columnCount
const spreadSize = 800;
const columnCount = 2;
const columnSize = spreadSize / columnCount; // 400
type ViewInfo = {
index: number;
contentPages: number;
padding: { before: number; after: number };
};
const getViewElementSize = (view: ViewInfo) => {
return (
view.contentPages * columnSize + (view.padding.before + view.padding.after) * spreadSize
);
};
const getViewOffset = (views: ViewInfo[], targetIndex: number) => {
let offset = 0;
for (const view of views) {
if (view.index === targetIndex) return offset;
offset += getViewElementSize(view);
}
return offset;
};
// #getPagesBeforeView uses pixel offsets divided by spread size (Math.floor)
const getPagesBeforeView = (views: ViewInfo[], targetIndex: number) => {
return Math.floor(getViewOffset(views, targetIndex) / spreadSize);
};
it('should count spread pages before a given view using pixel offsets', () => {
const views: ViewInfo[] = [
{ index: 3, contentPages: 5, padding: { before: 1, after: 0 } },
{ index: 4, contentPages: 2, padding: { before: 0, after: 0 } },
{ index: 5, contentPages: 8, padding: { before: 0, after: 1 } },
];
expect(getPagesBeforeView(views, 3)).toBe(0);
// View 3: 5*400 + 800 = 2800px → floor(2800/800) = 3
expect(getPagesBeforeView(views, 4)).toBe(3);
// View 3+4: 2800 + 2*400 = 3600 → floor(3600/800) = 4
expect(getPagesBeforeView(views, 5)).toBe(4);
});
it('should compute total pages using Math.ceil of viewSize / spreadSize', () => {
const views: ViewInfo[] = [
{ index: 0, contentPages: 5, padding: { before: 1, after: 0 } },
{ index: 1, contentPages: 2, padding: { before: 0, after: 0 } },
{ index: 2, contentPages: 8, padding: { before: 0, after: 1 } },
];
const totalViewSize = views.reduce((sum, v) => sum + getViewElementSize(v), 0);
// 5*400+800 + 2*400 + 8*400+800 = 2800 + 800 + 4000 = 7600
const totalPages = Math.ceil(totalViewSize / spreadSize);
expect(totalPages).toBe(Math.ceil(7600 / 800)); // 10
});
it('should place 1-column section at first column with next section after', () => {
// Image page (1 col) + normal section (10 cols)
const views: ViewInfo[] = [
{ index: 0, contentPages: 1, padding: { before: 1, after: 0 } },
{ index: 1, contentPages: 10, padding: { before: 0, after: 1 } },
];
// View 0: 1*400 + 800 = 1200. View 1: 10*400 + 800 = 4800
const totalViewSize = views.reduce((sum, v) => sum + getViewElementSize(v), 0);
expect(totalViewSize).toBe(6000);
expect(Math.ceil(totalViewSize / spreadSize)).toBe(8);
// Pages before view 1: floor(1200/800) = 1
expect(getPagesBeforeView(views, 1)).toBe(1);
});
it('should handle single-column layout (columnCount=1) same as before', () => {
const singleColSpread = 800;
const singleColSize = singleColSpread; // columnCount=1
// contentPages=3 means 3 columns, but with columnCount=1, each column IS a spread
const viewSize = 3 * singleColSize + 2 * singleColSpread;
expect(viewSize).toBe(4000);
expect(Math.ceil(viewSize / singleColSpread)).toBe(5); // 3 content + 2 padding
});
});
describe('Fraction and anchor calculation with column-level sizing', () => {
const columnCount = 2;
it('should compute fraction as localColumn / textPages', () => {
// Simulate #afterScroll fraction calculation
const textPages = 6; // 6 content columns
// At spread 0 (first content spread)
const localPage0 = 0;
const localColumn0 = localPage0 * columnCount; // 0
const fraction0 = Math.max(0, Math.min(1, localColumn0 / textPages));
expect(fraction0).toBe(0);
// At spread 1 (second content spread)
const localPage1 = 1;
const localColumn1 = localPage1 * columnCount; // 2
const fraction1 = Math.max(0, Math.min(1, localColumn1 / textPages));
expect(fraction1).toBeCloseTo(1 / 3);
// At spread 2 (third content spread, last)
const localPage2 = 2;
const localColumn2 = localPage2 * columnCount; // 4
const fraction2 = Math.max(0, Math.min(1, localColumn2 / textPages));
expect(fraction2).toBeCloseTo(2 / 3);
});
it('should convert anchor fraction to spread page for scrolling', () => {
const textPages = 5; // 5 content columns
// anchor=0 → column 0 → spread 0
expect(Math.floor(Math.round(0 * (textPages - 1)) / columnCount)).toBe(0);
// anchor=0.5 → column 2 → spread 1
expect(Math.floor(Math.round(0.5 * (textPages - 1)) / columnCount)).toBe(1);
// anchor=1.0 → column 4 → spread 2
expect(Math.floor(Math.round(1.0 * (textPages - 1)) / columnCount)).toBe(2);
});
});
describe('Primary section detection', () => {
it('should detect primary view as first visible view', () => {
// Simulate #detectPrimaryView — uses visibleStart, not midpoint
const views = [
{ index: 3, offset: 0, size: 100 },
{ index: 4, offset: 100, size: 50 },
{ index: 5, offset: 150, size: 200 },
];
const detectPrimary = (visibleStart: number) => {
for (const view of views) {
if (visibleStart < view.offset + view.size) {
return view.index;
}
}
return views[views.length - 1]?.index;
};
expect(detectPrimary(0)).toBe(3); // start in first view
expect(detectPrimary(50)).toBe(3); // still in first view
expect(detectPrimary(100)).toBe(4); // at boundary → second view
expect(detectPrimary(125)).toBe(4); // in second view
expect(detectPrimary(150)).toBe(5); // at boundary → third view
expect(detectPrimary(300)).toBe(5); // in third view
});
});
describe('Adjacent index with fromIndex parameter', () => {
it('should find next linear section from a given index', () => {
const sections = [
{ linear: 'yes' }, // 0
{ linear: 'no' }, // 1 (non-linear)
{ linear: 'yes' }, // 2
{ linear: 'yes' }, // 3
{ linear: 'no' }, // 4 (non-linear)
{ linear: 'yes' }, // 5
];
const adjacentIndex = (dir: number, fromIndex: number) => {
for (let index = fromIndex + dir; index >= 0 && index < sections.length; index += dir) {
if (sections[index]?.linear !== 'no') return index;
}
return undefined;
};
expect(adjacentIndex(1, 0)).toBe(2); // skip non-linear 1
expect(adjacentIndex(1, 2)).toBe(3);
expect(adjacentIndex(1, 3)).toBe(5); // skip non-linear 4
expect(adjacentIndex(-1, 5)).toBe(3); // skip non-linear 4
expect(adjacentIndex(-1, 2)).toBe(0); // skip non-linear 1
expect(adjacentIndex(1, 5)).toBeUndefined(); // end of book
expect(adjacentIndex(-1, 0)).toBeUndefined(); // start of book
});
});
describe('Trim distant views', () => {
it('should keep max 4 views (1 before + primary + 2 after)', () => {
// Simulate #trimDistantViews
const viewIndices = [1, 3, 5, 7, 9, 11];
const primaryIndex = 5;
const sorted = [...viewIndices].sort((a, b) => a - b);
const primaryPos = sorted.indexOf(primaryIndex);
const keep = new Set([primaryIndex]);
if (primaryPos > 0) keep.add(sorted[primaryPos - 1]!);
for (let d = 1; d <= 2; d++) {
if (primaryPos + d < sorted.length) keep.add(sorted[primaryPos + d]!);
}
const remaining = viewIndices.filter((i) => keep.has(i));
expect(remaining).toEqual([3, 5, 7, 9]);
expect(remaining.length).toBeLessThanOrEqual(4);
});
});
});
@@ -0,0 +1,450 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { DocumentLoader } from '@/libs/document';
import type { BookDoc } from '@/libs/document';
// Vite serves fixture files; fetch the EPUB at runtime in the browser.
const EPUB_URL = new URL('../fixtures/data/sample-alice.epub', import.meta.url).href;
interface PaginatorElement extends HTMLElement {
open: (book: BookDoc) => void;
goTo: (target: {
index: number;
anchor?: number | (() => number);
select?: boolean;
}) => Promise<void>;
prev: () => Promise<void>;
next: () => Promise<void>;
destroy: () => void;
getContents: () => Array<{ index: number; doc: Document; overlayer: unknown }>;
setStyles: (styles: string | [string, string]) => void;
render: () => void;
scrollToAnchor: (anchor: number | Range, select?: boolean, smooth?: boolean) => Promise<void>;
primaryIndex: number;
pages: number;
page: number;
size: number;
viewSize: number;
scrolled: boolean;
columnCount: number;
sections: Array<{ linear?: string; load: () => Promise<string> }>;
}
let book: BookDoc;
const loadEPUB = async () => {
const resp = await fetch(EPUB_URL);
const buffer = await resp.arrayBuffer();
const file = new File([buffer], 'sample-alice.epub', { type: 'application/epub+zip' });
const loader = new DocumentLoader(file);
const { book } = await loader.open();
return book;
};
/**
* Wait for the paginator to emit 'stabilized'.
* MUST be called BEFORE the action that triggers stabilization (e.g. goTo),
* because #display dispatches 'stabilized' synchronously before returning.
*/
const waitForStabilized = (el: HTMLElement, timeout = 10000) =>
new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('stabilized timeout')), timeout);
el.addEventListener(
'stabilized',
() => {
clearTimeout(timer);
resolve();
},
{ once: true },
);
});
/** Wait until `getContents().length >= n` or timeout. */
const waitForViews = async (el: PaginatorElement, n: number, timeout = 10000) => {
const start = Date.now();
while (Date.now() - start < timeout) {
if (el.getContents().length >= n) return;
await new Promise((r) => setTimeout(r, 100));
}
};
/** Wait for fill to complete by polling until getContents count stabilizes. */
const waitForFillComplete = async (el: PaginatorElement, timeout = 10000) => {
const start = Date.now();
let lastCount = -1;
let stableFor = 0;
while (Date.now() - start < timeout) {
const count = el.getContents().length;
if (count === lastCount) {
stableFor += 100;
if (stableFor >= 500) return;
} else {
stableFor = 0;
lastCount = count;
}
await new Promise((r) => setTimeout(r, 100));
}
};
describe('Paginator stabilization (browser)', () => {
let paginator: PaginatorElement;
// Suppress unhandled errors from paginator's #replaceBackground firing
// after views are destroyed (queued iframe loads from rapid navigation).
// This is a known paginator cleanup race, not a test failure.
const suppressHandler = (e: ErrorEvent) => {
if (e.message?.includes('getComputedStyle')) e.preventDefault();
};
beforeAll(async () => {
window.addEventListener('error', suppressHandler);
book = await loadEPUB();
await import('foliate-js/paginator.js');
}, 30000);
afterAll(() => {
window.removeEventListener('error', suppressHandler);
});
const createPaginator = () => {
const el = document.createElement('foliate-paginator') as PaginatorElement;
Object.assign(el.style, {
width: '800px',
height: '600px',
position: 'absolute',
left: '0',
top: '0',
});
document.body.appendChild(el);
return el;
};
/** Create paginator, open book, navigate to section, wait for stabilized. */
const setupAt = async (index: number) => {
paginator = createPaginator();
paginator.open(book);
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index });
await stabilized;
};
afterEach(async () => {
if (paginator) {
// Flush pending RAFs and iframe load callbacks before destroying.
// Rapid navigation tests can leave queued callbacks that reference
// views — destroying immediately would cause unhandled errors in
// paginator.js (#replaceBackground accessing destroyed documents).
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
try {
paginator.destroy();
} catch {
/* iframe body may already be torn down */
}
paginator.remove();
}
});
describe('Stabilized event lifecycle', () => {
it('should emit stabilized event after goTo completes', async () => {
paginator = createPaginator();
paginator.open(book);
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
let stabilizedCount = 0;
paginator.addEventListener('stabilized', () => {
stabilizedCount++;
});
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: idx });
await stabilized;
expect(stabilizedCount).toBeGreaterThanOrEqual(1);
});
it('should emit stabilized for each navigation to a new section', async () => {
paginator = createPaginator();
paginator.open(book);
const linearSections = book
.sections!.map((s, i) => ({ s, i }))
.filter(({ s }) => s.linear !== 'no');
expect(linearSections.length).toBeGreaterThan(1);
let stabilizedCount = 0;
paginator.addEventListener('stabilized', () => {
stabilizedCount++;
});
const s1 = waitForStabilized(paginator);
await paginator.goTo({ index: linearSections[0]!.i });
await s1;
const countAfterFirst = stabilizedCount;
expect(countAfterFirst).toBeGreaterThanOrEqual(1);
// Navigate to a far section so view is not reused
const farIdx = linearSections[Math.min(5, linearSections.length - 1)]!.i;
const s2 = waitForStabilized(paginator);
await paginator.goTo({ index: farIdx });
await s2;
expect(stabilizedCount).toBeGreaterThan(countAfterFirst);
});
it('should have content visible (opacity=1) after stabilized', async () => {
paginator = createPaginator();
paginator.open(book);
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
let containerOpacity = '';
paginator.addEventListener('stabilized', () => {
// Access shadow DOM container to check opacity
const container = paginator.shadowRoot?.getElementById('container');
containerOpacity = container?.style.opacity ?? '';
});
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: idx });
await stabilized;
// After stabilized, opacity should be '1' (visible)
expect(containerOpacity).toBe('1');
});
});
describe('Container opacity during stabilization', () => {
it('should set opacity to 0 at start and 1 at end of display', async () => {
paginator = createPaginator();
paginator.open(book);
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
const opacities: string[] = [];
// Observe the shadow DOM container for style changes
const container = paginator.shadowRoot?.getElementById('container');
if (container) {
const observer = new MutationObserver(() => {
opacities.push(container.style.opacity);
});
observer.observe(container, { attributes: true, attributeFilter: ['style'] });
}
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: idx });
await stabilized;
// Should have seen opacity transitions: 0 → 1
expect(opacities).toContain('0');
expect(opacities).toContain('1');
// The last opacity should be '1' (content visible)
const lastOpacity = opacities[opacities.length - 1];
expect(lastOpacity).toBe('1');
});
});
describe('Font loading integration', () => {
it('should load view even when document has no custom fonts', async () => {
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
await setupAt(idx);
// View should be loaded with a valid document
const contents = paginator.getContents();
const primary = contents.find((c) => c.index === idx);
expect(primary).toBeDefined();
expect(primary!.doc.fonts).toBeDefined();
});
});
describe('Render micro-stabilization', () => {
it('should emit stabilized when render is called on a loaded paginator', async () => {
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
await setupAt(idx);
// Wait for fill to fully complete so #stabilizing is false
await waitForFillComplete(paginator);
const stabilizedFromRender = waitForStabilized(paginator);
paginator.render();
// render() dispatches stabilized in a RAF
await stabilizedFromRender;
// If we get here, stabilized was emitted
expect(true).toBe(true);
});
it('should preserve primaryIndex across re-renders', async () => {
const idx = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 1000);
await setupAt(idx);
await waitForFillComplete(paginator);
const indexBefore = paginator.primaryIndex;
const stabilized = waitForStabilized(paginator);
paginator.render();
await stabilized;
expect(paginator.primaryIndex).toBe(indexBefore);
});
});
describe('Stabilization suppresses scroll-to-anchor', () => {
it('should not emit extra relocate events during initial fill', async () => {
paginator = createPaginator();
paginator.open(book);
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
const relocates: Array<{ index: number; fraction: number; reason: string }> = [];
paginator.addEventListener('relocate', ((e: CustomEvent) => {
relocates.push(e.detail);
}) as EventListener);
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: idx });
await stabilized;
// Wait for fill to complete
await waitForFillComplete(paginator);
// All relocate events should reference valid section indices
for (const r of relocates) {
expect(r.index).toBeGreaterThanOrEqual(0);
expect(r.index).toBeLessThan(book.sections!.length);
}
// Primary index should still be the one we navigated to
expect(paginator.primaryIndex).toBe(idx);
});
});
describe('Fill visible area completes after stabilized', () => {
it('should have more views after fill completes than at stabilized time', async () => {
paginator = createPaginator();
paginator.open(book);
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
let viewsAtStabilized = 0;
paginator.addEventListener(
'stabilized',
() => {
viewsAtStabilized = paginator.getContents().length;
},
{ once: true },
);
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: idx });
await stabilized;
// Wait for fill to complete
await waitForFillComplete(paginator);
const viewsAfterFill = paginator.getContents().length;
// Fill should have loaded additional sections beyond what was
// available at stabilized time (or at least the same count if
// the book has very few sections)
expect(viewsAfterFill).toBeGreaterThanOrEqual(viewsAtStabilized);
});
it('should block backward loading while stabilizing', async () => {
const linearSections = book
.sections!.map((s, i) => ({ s, i }))
.filter(({ s }) => s.linear !== 'no');
// Navigate to a middle section so there are sections before
const midIdx = linearSections[Math.min(3, linearSections.length - 1)]!.i;
await setupAt(midIdx);
// After stabilized, primary should still be the target
expect(paginator.primaryIndex).toBe(midIdx);
});
});
describe('Scrolled mode stabilization', () => {
it('should stabilize correctly in scrolled mode', async () => {
paginator = createPaginator();
paginator.open(book);
paginator.setAttribute('flow', 'scrolled');
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: idx });
await stabilized;
expect(paginator.scrolled).toBe(true);
expect(paginator.primaryIndex).toBe(idx);
expect(paginator.getContents().length).toBeGreaterThanOrEqual(1);
});
it('should pre-load previous section in scrolled mode', async () => {
paginator = createPaginator();
paginator.open(book);
paginator.setAttribute('flow', 'scrolled');
const linearSections = book
.sections!.map((s, i) => ({ s, i }))
.filter(({ s }) => s.linear !== 'no');
// Navigate to second linear section with anchor=0 (top)
if (linearSections.length > 1) {
const secondIdx = linearSections[1]!.i;
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: secondIdx, anchor: 0 });
await stabilized;
// In scrolled mode, previous section is pre-loaded in #display
await waitForViews(paginator, 2);
const contents = paginator.getContents();
const indices = contents.map((c) => c.index);
// Should have loaded the previous section
const firstIdx = linearSections[0]!.i;
expect(indices).toContain(firstIdx);
}
});
});
describe('Attribute-triggered re-render', () => {
it('should re-stabilize when flow attribute changes', async () => {
const idx = book.sections!.findIndex((s) => s.linear !== 'no');
await setupAt(idx);
await waitForFillComplete(paginator);
// Switch to scrolled mode — should trigger render() and stabilization
const stabilizedFromSwitch = waitForStabilized(paginator);
paginator.setAttribute('flow', 'scrolled');
await stabilizedFromSwitch;
expect(paginator.scrolled).toBe(true);
});
it('should preserve primaryIndex when switching flow modes', async () => {
const idx = book.sections!.findIndex((s) => s.linear !== 'no' && s.size > 1000);
await setupAt(idx);
await waitForFillComplete(paginator);
const indexBefore = paginator.primaryIndex;
// Switch to scrolled
const s1 = waitForStabilized(paginator);
paginator.setAttribute('flow', 'scrolled');
await s1;
expect(paginator.primaryIndex).toBe(indexBefore);
// Wait for fill to complete before switching back
await waitForFillComplete(paginator);
// Switch back to paginated
const s2 = waitForStabilized(paginator);
paginator.removeAttribute('flow');
await s2;
expect(paginator.primaryIndex).toBe(indexBefore);
});
});
describe('Multiple rapid navigations', () => {
it('should settle on the last navigation target after rapid goTo calls', async () => {
paginator = createPaginator();
paginator.open(book);
const linearSections = book
.sections!.map((s, i) => ({ s, i }))
.filter(({ s }) => s.linear !== 'no');
expect(linearSections.length).toBeGreaterThan(2);
// Fire multiple navigations rapidly
const first = linearSections[0]!.i;
const second = linearSections[1]!.i;
const third = linearSections[Math.min(2, linearSections.length - 1)]!.i;
// Don't await intermediate — fire them all
paginator.goTo({ index: first });
paginator.goTo({ index: second });
await paginator.goTo({ index: third });
// Wait long enough for all background RAF callbacks to flush
// (setStyles triggers RAF → #replaceBackground which reads computed styles)
await waitForFillComplete(paginator);
// The paginator should have settled on a valid section
expect(paginator.primaryIndex).toBeGreaterThanOrEqual(0);
expect(paginator.getContents().length).toBeGreaterThanOrEqual(1);
});
});
});
@@ -0,0 +1,309 @@
import { describe, it, expect, vi } from 'vitest';
// Mock the paginator module to avoid custom element registration conflicts
vi.mock('foliate-js/paginator.js', () => ({}));
describe('Paginator stabilization', () => {
describe('View.fontReady property', () => {
it('should default to a resolved promise', async () => {
// View.fontReady should initialize to Promise.resolve()
// so awaiting it never blocks when no doc is loaded
const fontReady = Promise.resolve();
await expect(fontReady).resolves.toBeUndefined();
});
it('should be set from doc.fonts.ready in load()', () => {
// After View.load(), fontReady should be assigned from doc.fonts.ready.then(...)
// Simulating the pattern: this.fontReady = doc.fonts.ready.then(() => this.expand())
const expandFn = vi.fn();
const fontsReady = Promise.resolve().then(() => expandFn());
const view = { fontReady: fontsReady };
expect(view.fontReady).toBeInstanceOf(Promise);
});
});
describe('Stabilization flag suppresses scroll-to-anchor', () => {
it('should not call scrollToAnchor during stabilization on expand', () => {
// In #createView, the onExpand callback should check both #filling and #stabilizing
// Simulate: if (!this.#filling && !this.#stabilizing) this.#scrollToAnchor(...)
const scrollToAnchor = vi.fn();
const filling = false;
const stabilizing = true;
// onExpand callback
if (!filling && !stabilizing) scrollToAnchor();
expect(scrollToAnchor).not.toHaveBeenCalled();
});
it('should call scrollToAnchor when not filling and not stabilizing', () => {
const scrollToAnchor = vi.fn();
const filling = false;
const stabilizing = false;
if (!filling && !stabilizing) scrollToAnchor();
expect(scrollToAnchor).toHaveBeenCalledOnce();
});
it('should not call scrollToAnchor when filling', () => {
const scrollToAnchor = vi.fn();
const filling = true;
const stabilizing = false;
if (!filling && !stabilizing) scrollToAnchor();
expect(scrollToAnchor).not.toHaveBeenCalled();
});
});
describe('Font timeout mechanism', () => {
it('should resolve within timeout even if fonts never load', async () => {
const neverResolves = new Promise<void>(() => {
/* intentionally never resolves */
});
const timeout = 50; // shorter for test
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const start = Date.now();
await Promise.race([Promise.all([neverResolves]), wait(timeout)]);
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(timeout + 50);
});
it('should resolve immediately if fonts are already loaded', async () => {
const alreadyLoaded = Promise.resolve();
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const start = Date.now();
await Promise.race([Promise.all([alreadyLoaded]), wait(3000)]);
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(50);
});
});
describe('Stabilization lifecycle in #display()', () => {
it('should set opacity to 0 at start and 1 at end', () => {
const container = { style: { opacity: '' } };
// Start of display
container.style.opacity = '0';
expect(container.style.opacity).toBe('0');
// End of display (after fillVisibleArea)
container.style.opacity = '1';
expect(container.style.opacity).toBe('1');
});
it('should dispatch stabilized event after completing', () => {
const events: string[] = [];
const dispatchEvent = (event: { type: string }) => {
events.push(event.type);
};
// Simulate end of #display()
dispatchEvent({ type: 'stabilized' });
expect(events).toContain('stabilized');
});
});
describe('Stabilizing stays true until fill completes', () => {
it('should keep stabilizing true after display until fillPromise resolves', async () => {
let stabilizing = true;
let fillResolve: () => void;
const fillPromise = new Promise<void>((resolve) => {
fillResolve = resolve;
});
// Simulate #display end: dispatch stabilized but don't clear #stabilizing
// Instead, defer clearing to fillPromise completion
fillPromise.then(() => {
stabilizing = false;
});
// stabilizing should still be true before fill completes
expect(stabilizing).toBe(true);
// Resolve fill
fillResolve!();
await fillPromise;
expect(stabilizing).toBe(false);
});
it('should block backward loading while stabilizing', () => {
const loadPrevSection = vi.fn();
const filling = false;
const stabilizing = true;
const scrollStart = 50; // near top
const viewportSize = 800;
// Debounced scroll handler check
if (!filling && !stabilizing && scrollStart < viewportSize) {
loadPrevSection();
}
expect(loadPrevSection).not.toHaveBeenCalled();
});
it('should allow backward loading after stabilizing clears', () => {
const loadPrevSection = vi.fn();
const filling = false;
const stabilizing = false;
const scrollStart = 50;
const viewportSize = 800;
if (!filling && !stabilizing && scrollStart < viewportSize) {
loadPrevSection();
}
expect(loadPrevSection).toHaveBeenCalledOnce();
});
});
describe('Debounced scroll handler skips during stabilization', () => {
function simulateDebouncedHandler(state: {
stabilizing: boolean;
justAnchored: boolean;
filling: boolean;
start: number;
size: number;
}) {
const afterScroll = vi.fn();
const loadPrevSection = vi.fn();
if (state.stabilizing) return { afterScroll, loadPrevSection };
if (state.justAnchored) state.justAnchored = false;
else afterScroll();
if (!state.filling && state.start < state.size) {
loadPrevSection();
}
return { afterScroll, loadPrevSection };
}
it('should skip entirely while stabilizing', () => {
const state = { stabilizing: true, justAnchored: true, filling: false, start: 0, size: 800 };
const { afterScroll, loadPrevSection } = simulateDebouncedHandler(state);
expect(afterScroll).not.toHaveBeenCalled();
expect(loadPrevSection).not.toHaveBeenCalled();
expect(state.justAnchored).toBe(true); // preserved
});
it('should run normally after stabilizing clears', () => {
const state = {
stabilizing: false,
justAnchored: false,
filling: false,
start: 50,
size: 800,
};
const { afterScroll, loadPrevSection } = simulateDebouncedHandler(state);
expect(afterScroll).toHaveBeenCalledOnce();
expect(loadPrevSection).toHaveBeenCalledOnce();
});
it('should not load backward when prev section already loaded (views.has check)', () => {
// When prev section is pre-loaded in #display, views.has(prevIdx) is true
// so the debounced handler's backward loading is a no-op.
// This test verifies the pre-load prevents cascade.
const prevAlreadyLoaded = true;
const loadPrevSection = vi.fn();
if (!prevAlreadyLoaded) loadPrevSection();
expect(loadPrevSection).not.toHaveBeenCalled();
});
});
describe('Pre-load previous section in #display for scrolled mode', () => {
it('should pre-load prev section when anchor <= 0.5 in scrolled mode', () => {
const scrolled = true;
const anchor = 0; // beginning of section
const contentPages = 10;
const columnCount = 2;
const needsPrev =
(contentPages > 0 && contentPages < columnCount) ||
(scrolled && typeof anchor === 'number' && anchor <= 0.5);
expect(needsPrev).toBe(true);
});
it('should pre-load prev section when anchor = 0.5 in scrolled mode', () => {
const scrolled = true;
const anchor = 0.5;
const contentPages = 10;
const columnCount = 2;
const needsPrev =
(contentPages > 0 && contentPages < columnCount) ||
(scrolled && typeof anchor === 'number' && anchor <= 0.5);
expect(needsPrev).toBe(true);
});
it('should NOT pre-load prev section when anchor > 0.5 in scrolled mode', () => {
const scrolled = true;
const anchor = 0.8;
const contentPages = 10;
const columnCount = 2;
const needsPrev =
(contentPages > 0 && contentPages < columnCount) ||
(scrolled && typeof anchor === 'number' && anchor <= 0.5);
expect(needsPrev).toBe(false);
});
it('should still pre-load for short primary alignment regardless of mode', () => {
const scrolled = false;
const anchor = 0.8;
const contentPages = 1; // shorter than one spread
const columnCount = 2;
const needsPrev =
(contentPages > 0 && contentPages < columnCount) ||
(scrolled && typeof anchor === 'number' && anchor <= 0.5);
expect(needsPrev).toBe(true);
});
it('should NOT pre-load in paginated mode with anchor=0 and normal section', () => {
const scrolled = false;
const anchor = 0;
const contentPages = 10;
const columnCount = 2;
const needsPrev =
(contentPages > 0 && contentPages < columnCount) ||
(scrolled && typeof anchor === 'number' && anchor <= 0.5);
expect(needsPrev).toBe(false);
});
});
describe('Render micro-stabilization', () => {
it('should only micro-stabilize when not already in stabilization', () => {
let stabilizing = false;
const container = { style: { opacity: '' } };
// When not already stabilizing
const needsStabilize = !stabilizing;
if (needsStabilize) {
stabilizing = true;
container.style.opacity = '0';
}
expect(needsStabilize).toBe(true);
expect(stabilizing).toBe(true);
expect(container.style.opacity).toBe('0');
});
it('should not micro-stabilize when already stabilizing', () => {
let stabilizing = true;
const container = { style: { opacity: '0' } };
const needsStabilize = !stabilizing;
if (needsStabilize) {
stabilizing = true;
container.style.opacity = '0';
}
expect(needsStabilize).toBe(false);
// stabilizing remains true, container opacity unchanged (already 0)
expect(stabilizing).toBe(true);
});
});
});
@@ -195,8 +195,10 @@ const FoliateViewer: React.FC<{
}, [getView, getProgress, bookKey]);
const docLoadHandler = (event: Event) => {
setLoading(false);
docLoaded.current = true;
if (bookDoc.rendition?.layout === 'pre-paginated') {
setLoading(false); // Fixed layout doesn't emit 'stabilized' event
}
const detail = (event as CustomEvent).detail;
console.log('doc index loaded:', detail.index);
if (detail.doc) {
@@ -292,6 +294,10 @@ const FoliateViewer: React.FC<{
}
};
const stabilizedHandler = useCallback(() => {
setLoading(false);
}, []);
const docRelocateHandler = (event: Event) => {
const detail = (event as CustomEvent).detail;
if (detail.reason !== 'scroll' && detail.reason !== 'page') return;
@@ -413,6 +419,7 @@ const FoliateViewer: React.FC<{
useFoliateEvents(viewRef.current, {
onLoad: docLoadHandler,
onStabilized: stabilizedHandler,
onRelocate: progressRelocateHandler,
onRendererRelocate: docRelocateHandler,
});
@@ -421,7 +428,7 @@ const FoliateViewer: React.FC<{
if (isViewCreated.current) return;
isViewCreated.current = true;
setTimeout(() => setLoading(true), 200);
setLoading(true);
const openBook = async () => {
console.log('Opening book', bookKey);
@@ -3,6 +3,7 @@ import { FoliateView } from '@/types/view';
type FoliateEventHandler = {
onLoad?: (event: Event) => void;
onStabilized?: (event: Event) => void;
onRelocate?: (event: Event) => void;
onLinkClick?: (event: Event) => void;
onRendererRelocate?: (event: Event) => void;
@@ -13,6 +14,7 @@ type FoliateEventHandler = {
export const useFoliateEvents = (view: FoliateView | null, handlers?: FoliateEventHandler) => {
const onLoad = handlers?.onLoad;
const onStabilized = handlers?.onStabilized;
const onRelocate = handlers?.onRelocate;
const onLinkClick = handlers?.onLinkClick;
const onRendererRelocate = handlers?.onRendererRelocate;
@@ -23,6 +25,7 @@ export const useFoliateEvents = (view: FoliateView | null, handlers?: FoliateEve
useEffect(() => {
if (!view) return;
if (onLoad) view.addEventListener('load', onLoad);
if (onStabilized) view.renderer.addEventListener('stabilized', onStabilized);
if (onRelocate) view.addEventListener('relocate', onRelocate);
if (onLinkClick) view.addEventListener('link', onLinkClick);
if (onRendererRelocate) view.renderer.addEventListener('relocate', onRendererRelocate);
@@ -32,6 +35,7 @@ export const useFoliateEvents = (view: FoliateView | null, handlers?: FoliateEve
return () => {
if (onLoad) view.removeEventListener('load', onLoad);
if (onStabilized) view.renderer.removeEventListener('stabilized', onStabilized);
if (onRelocate) view.removeEventListener('relocate', onRelocate);
if (onLinkClick) view.removeEventListener('link', onLinkClick);
if (onRendererRelocate) view.renderer.removeEventListener('relocate', onRendererRelocate);
@@ -52,12 +52,12 @@ export const useKOSync = (bookKey: string) => {
const generateKOProgress = useCallback(() => {
const progress = getProgress(bookKey);
const book = getBookData(bookKey)?.book;
if (!progress || !book) return null;
const bookData = getBookData(bookKey);
if (!progress || !bookData) return null;
let koProgress = '';
let percentage: number;
if (FIXED_LAYOUT_FORMATS.has(book.format)) {
if (bookData.isFixedLayout) {
const page = progress.section?.current ?? 0;
const totalPages = progress.section?.total ?? 0;
koProgress = page.toString();
@@ -67,7 +67,9 @@ export const useKOSync = (bookKey: string) => {
const cfi = progress.location;
if (!view || !cfi) return null;
try {
const content = view.renderer.getContents()[0];
const koContents = view.renderer.getContents();
const koPrimaryIdx = view.renderer.primaryIndex;
const content = koContents.find((x) => x.index === koPrimaryIdx) ?? koContents[0];
if (content) {
const { doc, index: spineIndex } = content;
const converter = new XCFI(doc, spineIndex || 0);
@@ -95,7 +97,9 @@ export const useKOSync = (bookKey: string) => {
} else {
if (!remote.progress?.startsWith('/body')) return;
try {
const content = view?.renderer.getContents()[0];
const apContents = view?.renderer.getContents() ?? [];
const apPrimaryIdx = view?.renderer.primaryIndex;
const content = apContents.find((x) => x.index === apPrimaryIdx) ?? apContents[0];
const koProgress = normalizeProgressXPointer(remote.progress);
const cfi = await getCFIFromXPointer(koProgress, content?.doc, content?.index, bookDoc);
view?.goTo(cfi);
@@ -3,7 +3,7 @@ import { useEnv } from '@/context/EnvContext';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { throttle } from '@/utils/throttle';
import { debounce } from '@/utils/debounce';
export const useProgressAutoSave = (bookKey: string) => {
const { envConfig } = useEnv();
@@ -13,13 +13,13 @@ export const useProgressAutoSave = (bookKey: string) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
const saveBookConfig = useCallback(
throttle(() => {
debounce(() => {
setTimeout(async () => {
const config = getConfig(bookKey)!;
const settings = useSettingsStore.getState().settings;
await saveConfig(envConfig, bookKey, config, settings);
}, 5000);
}, 10000),
}, 500);
}, 1000),
[],
);
@@ -55,7 +55,9 @@ export const useProgressSync = (bookKey: string) => {
const book = getBookData(bookKey)?.book;
if (config && view && book && config.progress && config.progress[0] > 0) {
try {
const content = view.renderer.getContents()[0];
const contents = view.renderer.getContents();
const primaryIndex = view.renderer.primaryIndex;
const content = contents.find((x) => x.index === primaryIndex) ?? contents[0];
if (content && !FIXED_LAYOUT_FORMATS.has(book.format)) {
const { doc, index } = content;
const xpointerResult = await getXPointerFromCFI(config.location!, doc, index || 0);
@@ -126,7 +128,9 @@ export const useProgressSync = (bookKey: string) => {
const bookData = getBookData(bookKey);
const view = getView(bookKey);
if (xPointer && view && bookData && bookData.bookDoc) {
const content = view.renderer.getContents()[0];
const pContents = view.renderer.getContents();
const pIdx = view.renderer.primaryIndex;
const content = pContents.find((x) => x.index === pIdx) ?? pContents[0];
const koProgress = normalizeProgressXPointer(xPointer);
const candidateCFI = await getCFIFromXPointer(
koProgress,
@@ -131,7 +131,10 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
return;
}
const { doc, index: viewSectionIndex } = view.renderer.getContents()[0] as {
const hlContents = view.renderer.getContents();
const hlPrimaryIdx = view.renderer.primaryIndex;
const { doc, index: viewSectionIndex } = (hlContents.find((x) => x.index === hlPrimaryIdx) ??
hlContents[0]) as {
doc: Document;
index?: number;
};
+1
View File
@@ -130,6 +130,7 @@ export const useTheme = ({
const colorScheme = isDarkMode ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', `${themeColor}-${colorScheme}`);
document.documentElement.style.setProperty('color-scheme', colorScheme);
document.documentElement.style.setProperty('--scroll-bg-opacity', isBwEink ? '1.0' : '0.5');
document.documentElement.style.setProperty(
'--overlayer-highlight-opacity',
isBwEink ? '1.0' : '0.3',
@@ -97,13 +97,23 @@ export class TTSController extends EventTarget {
this.ttsEdgeVoices = await this.ttsEdgeClient.getAllVoices();
}
#getPrimaryContent() {
const contents = this.view.renderer.getContents();
const primaryIndex = this.view.renderer.primaryIndex;
return (contents.find((x) => x.index === primaryIndex) ?? contents[0]) as
| {
doc: Document;
index?: number;
overlayer?: Overlayer;
}
| undefined;
}
#getHighlighter() {
return (range: Range) => {
const { doc, index, overlayer } = this.view.renderer.getContents()[0] as {
doc: Document;
index?: number;
overlayer?: Overlayer;
};
const content = this.#getPrimaryContent();
if (!content) return;
const { doc, index, overlayer } = content;
if (!doc || index === undefined || index !== this.#ttsSectionIndex) {
return;
}
@@ -120,7 +130,8 @@ export class TTSController extends EventTarget {
}
#clearHighlighter() {
const { overlayer } = (this.view.renderer.getContents()?.[0] || {}) as { overlayer: Overlayer };
const content = this.#getPrimaryContent();
const overlayer = content?.overlayer as Overlayer | undefined;
overlayer?.remove(HIGHLIGHT_KEY);
}
@@ -131,7 +142,7 @@ export class TTSController extends EventTarget {
async initViewTTS(index?: number) {
if (this.#ttsSectionIndex === -1) {
const fromSectionIndex = (index || this.view.renderer.getContents()[0]?.index) ?? 0;
const fromSectionIndex = (index || this.#getPrimaryContent()?.index) ?? 0;
await this.#initTTSForSection(fromSectionIndex);
}
}
@@ -149,7 +160,7 @@ export class TTSController extends EventTarget {
this.#ttsSectionIndex = sectionIndex;
const currentSection = this.view.renderer.getContents()[0];
const currentSection = this.#getPrimaryContent();
if (currentSection?.index !== sectionIndex) {
await this.onSectionChange?.(sectionIndex);
}
+1
View File
@@ -24,6 +24,7 @@
overscroll-behavior: none;
background-color: transparent;
--scroll-bg-color: theme('colors.base-200');
--safe-area-inset-top: env(safe-area-inset-top);
--safe-area-inset-right: env(safe-area-inset-right);
--safe-area-inset-bottom: env(safe-area-inset-bottom);
+1
View File
@@ -80,6 +80,7 @@ export interface FoliateView extends HTMLElement {
prevSection?: () => Promise<void>;
goTo?: (params: { index: number; anchor?: number | RangeAnchor }) => void;
setStyles?: (css: string) => void;
primaryIndex: number;
getContents: () => { doc: Document; index?: number; overlayer?: unknown }[];
scrollToAnchor?: (anchor: number | Range, reason?: string, smooth?: boolean) => void;
addEventListener: (