diff --git a/navigator-html-injectables/src/comms/keys.ts b/navigator-html-injectables/src/comms/keys.ts
index 18b907ba..73ac3cbe 100644
--- a/navigator-html-injectables/src/comms/keys.ts
+++ b/navigator-html-injectables/src/comms/keys.ts
@@ -50,7 +50,8 @@ export type CommsCommandKey =
"peripherals_protection" |
"keyboard_peripherals" |
"scroll_protection" |
- "print_protection";
+ "print_protection" |
+ "timeline_entries";
;
export type SuspiciousActivityType =
diff --git a/navigator-html-injectables/src/modules/snapper/CJKVerticalSnapper.ts b/navigator-html-injectables/src/modules/snapper/CJKVerticalSnapper.ts
index e4c8c532..4f5ec1b2 100644
--- a/navigator-html-injectables/src/modules/snapper/CJKVerticalSnapper.ts
+++ b/navigator-html-injectables/src/modules/snapper/CJKVerticalSnapper.ts
@@ -68,6 +68,13 @@ export class CJKVerticalSnapper extends Snapper {
return Math.max(0, this.doc().scrollWidth - this.wnd.innerWidth);
}
+ protected hasScrolledPast(el: Element): boolean {
+ const rect = el.getBoundingClientRect();
+ // vertical-rl: content flows right→left; leading edge is right, scrolled past when off-screen right.
+ // vertical-lr: content flows left→right; leading edge is left, scrolled past when off-screen left.
+ return this.verticalLR ? rect.right <= 0 : rect.left >= this.wnd.innerWidth;
+ }
+
private reportProgress() {
if (!this.comms.ready) return;
const scrollWidth = this.doc().scrollWidth;
@@ -82,7 +89,8 @@ export class CJKVerticalSnapper extends Snapper {
this.comms.send("progress", {
start: progress,
- end: viewportEnd
+ end: viewportEnd,
+ fragmentId: this.currentTimelineFragment()
});
}
@@ -150,6 +158,7 @@ export class CJKVerticalSnapper extends Snapper {
mount(wnd: ReadiumWindow, comms: Comms): boolean {
this.wnd = wnd;
this.comms = comms;
+ this.setupTimelineObserver();
this.initialScrollHandled = false;
this.lastScrollLeft = 0;
@@ -311,6 +320,12 @@ export class CJKVerticalSnapper extends Snapper {
ack(true);
});
+ comms.register("timeline_entries", CJKVerticalSnapper.moduleName, (data, ack) => {
+ this.cachedFragmentIds = Array.isArray(data) ? data as string[] : [];
+ this.observeTimelineElements(wnd);
+ ack(true);
+ });
+
comms.log("CJKVerticalSnapper Mounted");
return true;
}
@@ -327,6 +342,11 @@ export class CJKVerticalSnapper extends Snapper {
this.isScrollProtectionEnabled = false;
}
+ this.timelineObserver?.disconnect();
+ this.timelineObserver = null;
+ this.visibleFragmentIds.clear();
+ this.timelineEntries.clear();
+
comms.log("CJKVerticalSnapper Unmounted");
return true;
}
diff --git a/navigator-html-injectables/src/modules/snapper/ColumnSnapper.ts b/navigator-html-injectables/src/modules/snapper/ColumnSnapper.ts
index 78adc946..99c52807 100644
--- a/navigator-html-injectables/src/modules/snapper/ColumnSnapper.ts
+++ b/navigator-html-injectables/src/modules/snapper/ColumnSnapper.ts
@@ -72,6 +72,43 @@ export class ColumnSnapper extends Snapper {
return this.rtl ? Math.abs(raw) : Math.max(0, this.wnd.scrollX > 0 ? this.wnd.scrollX : raw);
}
+ protected hasScrolledPast(el: Element): boolean {
+ const rect = el.getBoundingClientRect();
+ return this.rtl ? rect.left >= this.wnd.innerWidth : rect.right <= 0;
+ }
+
+ /**
+ * IntersectionObserver is unreliable in CSS multi-column layout (underspecified
+ * in the spec, inconsistently implemented). Use rect-based detection instead:
+ * walk elements in DOM order and return the last one whose leading edge has
+ * entered or passed the viewport — that is the current section.
+ */
+ protected currentTimelineFragment(): string | undefined {
+ if (this.timelineEntries.size === 0) return undefined;
+
+ const inDomOrder = (a: string, b: string): number => {
+ const ea = this.timelineEntries.get(a);
+ const eb = this.timelineEntries.get(b);
+ if (!ea || !eb) return 0;
+ const cmp = ea.compareDocumentPosition(eb);
+ return cmp & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
+ };
+
+ const sorted = Array.from(this.timelineEntries.keys()).sort(inDomOrder);
+ const vw = this.wnd.innerWidth;
+ let result: string | undefined;
+ for (const id of sorted) {
+ const el = this.timelineEntries.get(id)!;
+ const rect = el.getBoundingClientRect();
+ // LTR: element has started when its left edge is before the viewport's right.
+ // RTL: element has started when its right edge is past the viewport's left.
+ const started = this.rtl ? rect.right > 0 : rect.left < vw;
+ if (started) result = id;
+ else break;
+ }
+ return result;
+ }
+
reportProgress() {
const scrollWidth = this.cachedScrollWidth;
const viewportWidth = this.wnd.innerWidth;
@@ -83,7 +120,8 @@ export class ColumnSnapper extends Snapper {
const viewportEnd = Math.max(0, Math.min(1, (norm + viewportWidth) / scrollWidth));
this.comms.send("progress", {
start: progress,
- end: viewportEnd
+ end: viewportEnd,
+ fragmentId: this.currentTimelineFragment()
});
}
@@ -608,6 +646,17 @@ export class ColumnSnapper extends Snapper {
ack(true);
});
+ comms.register("timeline_entries", ColumnSnapper.moduleName, (data, ack) => {
+ this.cachedFragmentIds = Array.isArray(data) ? data as string[] : [];
+ this.timelineEntries.clear();
+ for (const id of this.cachedFragmentIds) {
+ const el = wnd.document.getElementById(id);
+ if (el) this.timelineEntries.set(id, el);
+ }
+ wnd.requestAnimationFrame(() => this.reportProgress());
+ ack(true);
+ });
+
// Add interaction listeners
wnd.addEventListener("touchstart", this.onTouchStarter, { passive: true });
wnd.addEventListener("touchend", this.onTouchEnder, { passive: true });
@@ -641,6 +690,8 @@ export class ColumnSnapper extends Snapper {
wnd.document.getElementById(COLUMN_SNAPPER_STYLE_ID)?.remove();
+ this.timelineEntries.clear();
+
comms.log("ColumnSnapper Unmounted");
return super.unmount(wnd, comms);
}
diff --git a/navigator-html-injectables/src/modules/snapper/ScrollSnapper.ts b/navigator-html-injectables/src/modules/snapper/ScrollSnapper.ts
index c0724c5b..2bf82de2 100644
--- a/navigator-html-injectables/src/modules/snapper/ScrollSnapper.ts
+++ b/navigator-html-injectables/src/modules/snapper/ScrollSnapper.ts
@@ -37,6 +37,10 @@ export class ScrollSnapper extends Snapper {
return this.wnd.document.scrollingElement as HTMLElement;
}
+ protected hasScrolledPast(el: Element): boolean {
+ return el.getBoundingClientRect().bottom <= 0;
+ }
+
private reportProgress() {
if (!this.comms.ready) return;
// We have to round up the scroll position because
@@ -50,7 +54,8 @@ export class ScrollSnapper extends Snapper {
this.comms.send("progress", {
start: progress,
- end: viewportEnd
+ end: viewportEnd,
+ fragmentId: this.currentTimelineFragment()
});
}
@@ -124,6 +129,7 @@ export class ScrollSnapper extends Snapper {
mount(wnd: ReadiumWindow, comms: Comms): boolean {
this.wnd = wnd;
this.comms = comms;
+ this.setupTimelineObserver();
this.initialScrollHandled = false;
this.lastScrollTop = 0;
@@ -289,6 +295,12 @@ export class ScrollSnapper extends Snapper {
ack(true);
});
+ comms.register("timeline_entries", ScrollSnapper.moduleName, (data, ack) => {
+ this.cachedFragmentIds = Array.isArray(data) ? data as string[] : [];
+ this.observeTimelineElements(wnd);
+ ack(true);
+ });
+
comms.log("ScrollSnapper Mounted");
return true;
}
@@ -298,6 +310,10 @@ export class ScrollSnapper extends Snapper {
this.resizeObserver.disconnect();
if (this.handleScroll) wnd.removeEventListener("scroll", this.handleScroll);
wnd.document.getElementById(SCROLL_SNAPPER_STYLE_ID)?.remove();
+ this.timelineObserver?.disconnect();
+ this.timelineObserver = null;
+ this.visibleFragmentIds.clear();
+ this.timelineEntries.clear();
if (this.patternAnalyzer) {
this.patternAnalyzer.clear();
diff --git a/navigator-html-injectables/src/modules/snapper/Snapper.ts b/navigator-html-injectables/src/modules/snapper/Snapper.ts
index dd9fa0d4..e7124362 100644
--- a/navigator-html-injectables/src/modules/snapper/Snapper.ts
+++ b/navigator-html-injectables/src/modules/snapper/Snapper.ts
@@ -10,6 +10,80 @@ export abstract class Snapper extends Module {
private protected = false;
+ // Timeline fragment tracking
+ protected timelineObserver: IntersectionObserver | null = null;
+ protected timelineEntries: Map = new Map();
+ protected visibleFragmentIds: Set = new Set();
+ protected cachedFragmentIds: string[] = [];
+
+ protected setupTimelineObserver(): void {
+ if (this.timelineObserver) this.timelineObserver.disconnect();
+ this.timelineObserver = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ if (entry.isIntersecting)
+ this.visibleFragmentIds.add((entry.target as HTMLElement).id);
+ else
+ this.visibleFragmentIds.delete((entry.target as HTMLElement).id);
+ }
+ },
+ { threshold: [0.01] }
+ );
+ }
+
+ protected observeTimelineElements(wnd: ReadiumWindow): void {
+ if (!this.timelineObserver) return;
+ this.timelineObserver.disconnect();
+ this.visibleFragmentIds.clear();
+ this.timelineEntries.clear();
+ for (const id of this.cachedFragmentIds) {
+ const el = wnd.document.getElementById(id);
+ if (el) {
+ this.timelineEntries.set(id, el);
+ this.timelineObserver.observe(el);
+ }
+ }
+ }
+
+ /**
+ * Returns the ID of the currently active timeline fragment:
+ * 1. Primary — IntersectionObserver: returns the first visible element in DOM (reading) order.
+ * This is direction-agnostic; works for LTR, RTL, and vertical writing modes.
+ * 2. Fallback — rect scan: used when IntersectionObserver hasn't fired yet (e.g. programmatic
+ * navigation). Iterates elements in DOM order, returns the last one whose leading edge has
+ * already been scrolled past. Subclasses implement `hasScrolledPast` for their layout.
+ */
+ protected currentTimelineFragment(): string | undefined {
+ const inDomOrder = (a: string, b: string): number => {
+ const ea = this.timelineEntries.get(a);
+ const eb = this.timelineEntries.get(b);
+ if (!ea || !eb) return 0;
+ const cmp = ea.compareDocumentPosition(eb);
+ return cmp & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
+ };
+
+ if (this.visibleFragmentIds.size > 0) {
+ return Array.from(this.visibleFragmentIds).sort(inDomOrder)[0];
+ }
+
+ // Fallback: last element in DOM order whose leading edge has been scrolled past
+ const sorted = Array.from(this.timelineEntries.keys()).sort(inDomOrder);
+ let nearestId: string | undefined;
+ for (const id of sorted) {
+ const el = this.timelineEntries.get(id)!;
+ if (this.hasScrolledPast(el)) nearestId = id;
+ else break;
+ }
+ return nearestId;
+ }
+
+ /**
+ * Returns true if the element's leading edge (in reading direction) has been scrolled past
+ * — i.e. the element is before the current viewport position.
+ * Subclasses implement this for their specific scroll axis and reading direction.
+ */
+ protected abstract hasScrolledPast(el: Element): boolean;
+
buildStyles() {
return `
html, body {
diff --git a/navigator-html-injectables/src/modules/snapper/WebPubSnapper.ts b/navigator-html-injectables/src/modules/snapper/WebPubSnapper.ts
index 7ad427a9..54b9cc8a 100644
--- a/navigator-html-injectables/src/modules/snapper/WebPubSnapper.ts
+++ b/navigator-html-injectables/src/modules/snapper/WebPubSnapper.ts
@@ -31,6 +31,10 @@ export class WebPubSnapper extends Snapper {
return this.wnd.document.scrollingElement as HTMLElement;
}
+ protected hasScrolledPast(el: Element): boolean {
+ return el.getBoundingClientRect().bottom <= 0;
+ }
+
private reportProgress() {
if (!this.comms.ready) return;
@@ -42,7 +46,8 @@ export class WebPubSnapper extends Snapper {
this.comms.send("progress", {
start: progress,
- end: viewportEnd
+ end: viewportEnd,
+ fragmentId: this.currentTimelineFragment()
});
}
@@ -115,6 +120,7 @@ export class WebPubSnapper extends Snapper {
mount(wnd: ReadiumWindow, comms: Comms): boolean {
this.wnd = wnd;
this.comms = comms;
+ this.setupTimelineObserver();
this.initialScrollHandled = false;
this.lastScrollTop = 0;
@@ -259,6 +265,12 @@ export class WebPubSnapper extends Snapper {
ack(true);
});
+ comms.register("timeline_entries", WebPubSnapper.moduleName, (data, ack) => {
+ this.cachedFragmentIds = Array.isArray(data) ? data as string[] : [];
+ this.observeTimelineElements(wnd);
+ ack(true);
+ });
+
comms.log("WebPubSnapper Mounted");
return true;
}
@@ -274,6 +286,11 @@ export class WebPubSnapper extends Snapper {
this.isScrollProtectionEnabled = false;
}
+ this.timelineObserver?.disconnect();
+ this.timelineObserver = null;
+ this.visibleFragmentIds.clear();
+ this.timelineEntries.clear();
+
comms.log("WebPubSnapper Unmounted");
return true;
}
diff --git a/navigator/docs/Timeline.md b/navigator/docs/Timeline.md
new file mode 100644
index 00000000..09e60f60
--- /dev/null
+++ b/navigator/docs/Timeline.md
@@ -0,0 +1,419 @@
+# Timeline
+
+> **This API is in active development and will evolve.** Behaviour, method signatures, and the shape of `TimelineItem` are subject to change without notice until this notice is removed.
+
+A `Timeline` gives reading applications a unified, format-agnostic view of a publication's structure. It answers questions like "What chapter am I in right now?", "Where does the next chapter begin?", and "Which section does this search result belong to?" — consistently across EPUBs, audiobooks, PDFs, and web publications.
+
+## Concepts
+
+### TimelineItem
+
+A `TimelineItem` represents one structural entry in the publication:
+
+```ts
+interface TimelineItem {
+ title: string; // Display title of this entry
+ references: string[]; // Hrefs with optional fragments that identify where this entry starts
+ role?: string[]; // Structural roles, e.g. ["chapter"], ["part"]
+ position?: string; // Display-ready position label: page number or formatted timestamp
+ scroll?: number; // Scroll progression (0–1) for entries that start mid-resource
+ children?: TimelineItem[];
+}
+```
+
+`references` holds one or more hrefs identifying where this entry starts in the reading order. Examples:
+- Audio: `["track1.mp3#t=1620"]` (NPT time fragment)
+- EPUB: `["chapter3.html#section-2"]` (HTML id fragment)
+- PDF: `["#page=42"]` (page fragment)
+
+### Timeline
+
+`Timeline` is the container built once from a publication's reading order and table of contents. It is a lazy singleton on `Publication`: the first access builds it and the result is cached.
+
+### How it is built
+
+**The reading order is the source of truth.** Every item in the reading order becomes exactly one top-level `TimelineItem`. The TOC is consulted only to enrich those items — never to introduce new top-level entries.
+
+**Title resolution** (per reading order item, first match wins):
+1. The reading order item's own `title`.
+2. A TOC entry whose href points to the start of that resource (bare href, or `#t=0` for audio).
+3. If exactly one fragment-based TOC entry references this resource, its title.
+4. A positional placeholder (`Resource N`) when no title can be reliably derived.
+
+**Children:** all TOC entries that reference a reading order resource become flat children of the corresponding timeline item, in TOC declaration order. No parent-child relationships within the TOC are reconstructed — that inference requires role context and is planned for a future iteration.
+
+TOC entries that do not match any reading order item are ignored entirely.
+
+### Depth
+
+The `depth` option controls how many levels deep into the TOC hierarchy the timeline looks, for both title resolution and child collection. Level 1 means only top-level TOC entries; level 2 adds their children; and so on. `undefined` means no limit.
+
+Depth can be set at build time (via `Timeline.build`) or adjusted at runtime via the `depth` setter. Setting it at runtime trims the cached tree without rebuilding from scratch. Setting the same value again is a no-op.
+
+## Access
+
+```ts
+publication.timeline // lazy, cached on first access
+navigator.timeline // delegates to publication.timeline (may also augment with additional data)
+```
+
+Prefer `navigator.timeline` when a navigator is available: the EPUB navigator augments the timeline with additional data e.g. positions list, populating `TimelineItem.position` (page number) and `TimelineItem.scroll` (scroll progression within a resource). The audio navigator augments with formatted timestamps.
+
+```ts
+publication.timeline.depth = 2; // limit visible tree depth at runtime
+```
+
+## Observing changes
+
+Both `EpubNavigatorListeners` and `AudioNavigatorListeners` expose a `timelineItemChanged` callback. It fires whenever the active `TimelineItem` changes — not on every position tick, only when the item actually changes. It receives `undefined` when no item is active.
+
+```ts
+// EPUB
+const listeners: EpubNavigatorListeners = {
+ timelineItemChanged(item: TimelineItem | undefined): void {
+ chapterTitle.textContent = item?.title ?? '';
+ },
+ // …
+};
+
+// Audio
+const listeners: AudioNavigatorListeners = {
+ timelineItemChanged(item: TimelineItem | undefined): void {
+ chapterTitle.textContent = item?.title ?? '';
+ },
+ // …
+};
+```
+
+Use this to keep chapter titles, breadcrumbs, or previous/next navigation in sync without polling. See `positionChanged` if you need every position tick.
+
+## Methods
+
+### `locate(locator)`
+
+Returns the most specific `TimelineItem` covering the given locator's position.
+
+- **Audio:** matches on the `#t=` NPT time fragment, returning the entry whose start time is ≤ the current time and closest to it.
+- **EPUB (paginated):** matches on the HTML id fragment if present, then falls back to the best scroll progression match.
+- **Fallback:** bare href match, returning the top-level item for the resource.
+
+```ts
+const item = navigator.timeline.locate(navigator.currentLocator);
+console.log(item?.title);
+```
+
+### `adjacentTo(item)`
+
+Returns `{ previous, next }` relative to the given item in the flattened timeline. Both values are `undefined` at the boundaries.
+
+```ts
+const { previous, next } = navigator.timeline.adjacentTo(currentItem);
+prevButton.disabled = !previous;
+nextButton.disabled = !next;
+prevLabel.textContent = previous?.title ?? '';
+nextLabel.textContent = next?.title ?? '';
+```
+
+### `segmentsForHref(href)`
+
+Returns the timeline segments within a reading order resource: the item's children if it has any, otherwise the item itself. Use this to render a labelled progress bar.
+
+```ts
+const segments = navigator.timeline.segmentsForHref(locator.href);
+renderProgressBar(segments);
+```
+
+### `itemAtProgression(href, progression)`
+
+Returns the item that best corresponds to a progression value (0–1) within a resource. The resolution strategy is derived automatically from the publication's profile and manifest:
+
+- **Audiobook:** the track's `duration` from the manifest is used to convert the progression to an absolute time, matched against NPT fragments.
+- **EPUB:** uses `TimelineItem.scroll` values if present, otherwise divides children evenly by index.
+
+Use this for hover inference on a progress bar.
+
+```ts
+progressBar.addEventListener('mousemove', (e) => {
+ const fraction = e.offsetX / progressBar.clientWidth;
+ const hovered = navigator.timeline.itemAtProgression(currentLocator.href, fraction);
+ tooltip.textContent = hovered?.title ?? '';
+});
+```
+
+### `ancestors(item)`
+
+Returns the ordered ancestor path from root to the immediate parent of `item`. Empty array if the item is top-level.
+
+```ts
+const path = navigator.timeline.ancestors(currentItem);
+breadcrumb.textContent = [...path, currentItem].map(a => a.title).join(' › ');
+```
+
+### `linkFor(item)`
+
+Returns the source `Link` from the publication manifest that this item was built from. Useful when you need to navigate to the item's starting position.
+
+```ts
+const link = navigator.timeline.linkFor(item);
+if (link) {
+ const locator = publication.locatorFromLink(link);
+ navigator.go(locator);
+}
+```
+
+## Use Cases
+
+### Running header (current chapter title)
+
+Keep a header in sync with reading position using `timelineItemChanged`:
+
+```ts
+const listeners = {
+ timelineItemChanged(item: TimelineItem | undefined): void {
+ document.getElementById('chapter-header').textContent = item?.title ?? '';
+ },
+};
+```
+
+### Previous / Next chapter navigation
+
+```ts
+let currentItem: TimelineItem | undefined;
+
+const listeners = {
+ timelineItemChanged(item: TimelineItem | undefined): void {
+ currentItem = item;
+ if (!item) return;
+
+ const { previous, next } = navigator.timeline.adjacentTo(item);
+ prevChapterButton.disabled = !previous;
+ nextChapterButton.disabled = !next;
+ prevChapterLabel.textContent = previous?.title ?? '';
+ nextChapterLabel.textContent = next?.title ?? '';
+ },
+};
+
+prevChapterButton.addEventListener('click', () => {
+ if (!currentItem) return;
+ const { previous } = navigator.timeline.adjacentTo(currentItem);
+ if (!previous) return;
+ const link = navigator.timeline.linkFor(previous);
+ if (link) navigator.go(publication.locatorFromLink(link));
+});
+```
+
+### Progress bar with chapter segments
+
+Render chapter boundaries as tick marks on a progress bar, and show which chapter the user would land in when they hover:
+
+```ts
+function renderProgressBar(locator: Locator, duration: number) {
+ const segments = navigator.timeline.segmentsForHref(locator.href);
+ const container = document.getElementById('progress-bar');
+
+ // Render chapter ticks
+ container.innerHTML = '';
+ segments.forEach((segment, i) => {
+ const tick = document.createElement('div');
+ tick.className = 'chapter-tick';
+
+ if (duration > 0) {
+ // Audio: use start time
+ const link = navigator.timeline.linkFor(segment);
+ const time = parseTimeFromHref(link?.href ?? '');
+ tick.style.left = `${(time / duration) * 100}%`;
+ } else {
+ // EPUB: evenly spaced (or use segment.scroll if available)
+ tick.style.left = `${(i / segments.length) * 100}%`;
+ }
+
+ container.appendChild(tick);
+ });
+}
+
+// Hover tooltip
+progressBar.addEventListener('mousemove', (e) => {
+ const fraction = e.offsetX / progressBar.clientWidth;
+ const hovered = navigator.timeline.itemAtProgression(
+ currentLocator.href,
+ fraction,
+ audioDuration,
+ );
+ tooltip.textContent = hovered?.title ?? '';
+ tooltip.style.left = `${e.offsetX}px`;
+});
+```
+
+### Breadcrumb
+
+Show the full structural path to the current item:
+
+```ts
+const listeners = {
+ timelineItemChanged(item: TimelineItem | undefined): void {
+ if (!item) {
+ breadcrumb.textContent = '';
+ return;
+ }
+ const path = navigator.timeline.ancestors(item);
+ breadcrumb.textContent = [...path, item].map(a => a.title).join(' › ');
+ },
+};
+```
+
+### Grouping search results by chapter
+
+```ts
+function groupResultsByChapter(locators: Locator[]): Map {
+ const groups = new Map();
+
+ for (const locator of locators) {
+ const item = navigator.timeline.locate(locator);
+ const key = item?.title ?? 'Unknown';
+ const existing = groups.get(key) ?? [];
+ existing.push(locator);
+ groups.set(key, existing);
+ }
+
+ return groups;
+}
+```
+
+### Contextualizing bookmarks and highlights
+
+Attach chapter context when saving annotations:
+
+```ts
+function saveHighlight(locator: Locator, text: string) {
+ const item = navigator.timeline.locate(locator);
+ const chapterTitle = item?.title;
+ const chapterPosition = item?.position;
+
+ db.saveHighlight({ locator, text, chapterTitle, chapterPosition });
+}
+```
+
+## Complete Example — EPUB Chapter Navigation UI
+
+```ts
+import { EpubNavigator, EpubNavigatorListeners, Locator, TimelineItem } from "@readium/navigator";
+
+let currentItem: TimelineItem | undefined;
+
+const listeners: EpubNavigatorListeners = {
+ positionChanged(locator: Locator): void {
+ const progression = locator.locations.progression ?? 0;
+ (document.getElementById('progress') as HTMLInputElement).value = String(progression);
+ },
+
+ timelineItemChanged(item: TimelineItem | undefined): void {
+ currentItem = item;
+
+ // Running header
+ document.getElementById('chapter-title')!.textContent = item?.title ?? '';
+
+ if (!item) return;
+
+ // Previous / Next labels
+ const { previous, next } = navigator.timeline.adjacentTo(item);
+ (document.getElementById('prev-chapter') as HTMLButtonElement).disabled = !previous;
+ (document.getElementById('next-chapter') as HTMLButtonElement).disabled = !next;
+ document.getElementById('prev-label')!.textContent = previous?.title ?? '';
+ document.getElementById('next-label')!.textContent = next?.title ?? '';
+
+ // Breadcrumb
+ const path = navigator.timeline.ancestors(item);
+ document.getElementById('breadcrumb')!.textContent =
+ [...path, item].map(a => a.title).join(' › ');
+ },
+
+ // …other required listeners
+};
+
+const navigator = new EpubNavigator(container, publication, listeners, positions);
+
+// Chapter navigation
+document.getElementById('prev-chapter')!.addEventListener('click', () => {
+ if (!currentItem) return;
+ const { previous } = navigator.timeline.adjacentTo(currentItem);
+ if (!previous) return;
+ const link = navigator.timeline.linkFor(previous);
+ if (link) navigator.go(publication.locatorFromLink(link));
+});
+
+document.getElementById('next-chapter')!.addEventListener('click', () => {
+ if (!currentItem) return;
+ const { next } = navigator.timeline.adjacentTo(currentItem);
+ if (!next) return;
+ const link = navigator.timeline.linkFor(next);
+ if (link) navigator.go(publication.locatorFromLink(link));
+});
+```
+
+## Complete Example — Audio Chapter Segments
+
+```ts
+import { AudioNavigator, AudioNavigatorListeners, Locator, TimelineItem } from "@readium/navigator";
+
+let audioDuration = 0;
+let currentLocator: Locator | undefined;
+
+const listeners: AudioNavigatorListeners = {
+ metadataLoaded(metadata): void {
+ audioDuration = metadata.duration;
+ if (currentLocator) renderSegments(currentLocator.href);
+ },
+
+ positionChanged(locator: Locator): void {
+ currentLocator = locator;
+ const progression = locator.locations.progression ?? 0;
+ (document.getElementById('progress') as HTMLInputElement).value = String(progression);
+ },
+
+ timelineItemChanged(item: TimelineItem | undefined): void {
+ document.getElementById('chapter-title')!.textContent = item?.title ?? '';
+
+ if (!item) return;
+ const { previous, next } = navigator.timeline.adjacentTo(item);
+ document.getElementById('prev-label')!.textContent = previous?.title ?? '';
+ document.getElementById('next-label')!.textContent = next?.title ?? '';
+ },
+
+ // …other required listeners
+};
+
+const navigator = new AudioNavigator(publication, listeners);
+
+function renderSegments(href: string) {
+ const segments = navigator.timeline.segmentsForHref(href);
+ const bar = document.getElementById('chapter-ticks')!;
+ bar.innerHTML = '';
+
+ segments.forEach(segment => {
+ const tick = document.createElement('div');
+ tick.className = 'tick';
+ const link = navigator.timeline.linkFor(segment);
+ if (link) {
+ const t = parseNptFromHref(link.href);
+ if (t !== undefined && audioDuration > 0) {
+ tick.style.left = `${(t / audioDuration) * 100}%`;
+ tick.title = segment.title;
+ bar.appendChild(tick);
+ }
+ }
+ });
+}
+
+// Hover tooltip
+document.getElementById('progress')!.addEventListener('mousemove', (e) => {
+ if (!currentLocator) return;
+ const el = e.currentTarget as HTMLInputElement;
+ const fraction = e.offsetX / el.clientWidth;
+ const hovered = navigator.timeline.itemAtProgression(
+ currentLocator.href,
+ fraction,
+ audioDuration,
+ );
+ document.getElementById('hover-tooltip')!.textContent = hovered?.title ?? '';
+});
+```
diff --git a/navigator/src/Navigator.ts b/navigator/src/Navigator.ts
index 250a8b85..f99b2916 100644
--- a/navigator/src/Navigator.ts
+++ b/navigator/src/Navigator.ts
@@ -1,4 +1,4 @@
-import { Link, Locator, Publication, ReadingProgression } from "@readium/shared";
+import { Link, Locator, Publication, ReadingProgression, Timeline } from "@readium/shared";
import {
ContentProtectionConfig,
PrintProtectionConfig,
@@ -24,6 +24,7 @@ export type IKeyboardPeripheralsConfig = Array
export interface ProgressionRange {
start: number;
end: number;
+ fragmentId?: string; // DOM element ID of the currently active timeline anchor
}
export interface VisualNavigatorViewport {
@@ -42,6 +43,8 @@ export abstract class Navigator {
abstract get publication(): Publication; // Publication rendered by this navigator.
abstract get currentLocator(): Locator; // Current position (detailed) in the publication. Can be used to save a bookmark to the current position.
+ abstract get timeline(): Timeline;
+
/**
* Moves to the position in the publication corresponding to the given {Locator}.
*/
diff --git a/navigator/src/epub/EpubNavigator.ts b/navigator/src/epub/EpubNavigator.ts
index 3ed2cf3a..e74e1e7f 100644
--- a/navigator/src/epub/EpubNavigator.ts
+++ b/navigator/src/epub/EpubNavigator.ts
@@ -1,4 +1,4 @@
-import { Layout, Link, Locator, LocatorText, Profile, Publication, ReadingProgression } from "@readium/shared";
+import { Layout, Link, Locator, LocatorText, Profile, Publication, ReadingProgression, Timeline, TimelineItem } from "@readium/shared";
import { Configurable, ConfigurableSettings, LineLengths, ProgressionRange, VisualNavigator, VisualNavigatorViewport } from "../index.ts";
import { FramePoolManager } from "./frame/FramePoolManager.ts";
import { FXLFramePoolManager } from "./fxl/FXLFramePoolManager.ts";
@@ -36,6 +36,7 @@ export interface EpubNavigatorConfiguration {
export interface EpubNavigatorListeners {
frameLoaded: (wnd: Window) => void;
positionChanged: (locator: Locator) => void;
+ timelineItemChanged: (item: TimelineItem | undefined) => void;
tap: (e: FrameClickEvent) => boolean; // Return true to prevent handling here
click: (e: FrameClickEvent) => boolean; // Return true to prevent handling here
zoom: (scale: number) => void;
@@ -53,6 +54,7 @@ export interface EpubNavigatorListeners {
const defaultListeners = (listeners: EpubNavigatorListeners): EpubNavigatorListeners => ({
frameLoaded: listeners.frameLoaded || (() => {}),
positionChanged: listeners.positionChanged || (() => {}),
+ timelineItemChanged: listeners.timelineItemChanged || (() => {}),
tap: listeners.tap || (() => false),
click: listeners.click || (() => false),
zoom: listeners.zoom || (() => {}),
@@ -73,6 +75,8 @@ export class EpubNavigator extends VisualNavigator implements Configurable this.pub.timeline.segmentsForHref(href)
+ .flatMap(item => item.references)
+ .map(ref => { const h = ref.indexOf('#'); return h >= 0 ? ref.slice(h + 1) : ''; })
+ .filter(Boolean)
);
}
@@ -436,6 +444,7 @@ export class EpubNavigator extends VisualNavigator implements Configurable !!f) as (FrameManager | FXLFrameManager)[];
const i = frames.indexOf(sourceFrame);
@@ -456,6 +465,7 @@ export class EpubNavigator extends VisualNavigator implements Configurable {
+ const hashIndex = link.href.indexOf('#');
+ const bare = hashIndex >= 0 ? link.href.slice(0, hashIndex) : link.href;
+ const fragment = hashIndex >= 0 ? link.href.slice(hashIndex + 1) : undefined;
+ const entries = positions.filter(p => p.href === bare);
+ if (!entries.length) return {};
+ const atFragment = fragment
+ ? entries.find(p => p.locations.fragments[0] === fragment)
+ : undefined;
+ const candidate = atFragment ?? entries.reduce((min, p) =>
+ (p.locations.position ?? Infinity) < (min.locations.position ?? Infinity) ? p : min
+ );
+ return {
+ position: candidate.locations.position !== undefined
+ ? String(candidate.locations.position) : undefined,
+ scroll: atFragment?.locations.progression,
+ };
+ });
+ this._timelineAugmented = true;
+ }
+ return t;
+ }
+
private async loadLocator(locator: Locator, cb: (ok: boolean) => void) {
let done = false;
let cssSelector = (typeof locator.locations.getCssSelector === "function") && locator.locations.getCssSelector();
@@ -1113,4 +1155,12 @@ export class EpubNavigator extends VisualNavigator implements Configurable void): void {
return this.go(link.locator, animated, cb);
}
+
+ private _notifyTimelineChange(locator: Locator): void {
+ const item = this.timeline.locate(locator);
+ if (item !== this._currentTimelineItem) {
+ this._currentTimelineItem = item;
+ this.listeners.timelineItemChanged(item);
+ }
+ }
}
diff --git a/navigator/src/epub/frame/FrameManager.ts b/navigator/src/epub/frame/FrameManager.ts
index 507f98be..7581fb2f 100644
--- a/navigator/src/epub/frame/FrameManager.ts
+++ b/navigator/src/epub/frame/FrameManager.ts
@@ -21,7 +21,8 @@ export class FrameManager {
constructor(
source: string,
contentProtectionConfig: IContentProtectionConfig = {},
- keyboardPeripheralsConfig: IKeyboardPeripheralsConfig = []
+ keyboardPeripheralsConfig: IKeyboardPeripheralsConfig = [],
+ private readonly timelineFragmentIds: string[] = []
) {
this.frame = document.createElement("iframe");
this.frame.sandbox.value = "allow-same-origin allow-scripts";
@@ -141,6 +142,10 @@ export class FrameManager {
// Apply content protection synchronously
this.applyContentProtection();
+ // Send timeline fragment IDs so the snapper can set up observers
+ if (this.timelineFragmentIds.length > 0)
+ this.comms?.send("timeline_entries", this.timelineFragmentIds);
+
const remove = () => {
this.frame.style.removeProperty("visibility");
this.frame.style.removeProperty("aria-hidden");
diff --git a/navigator/src/epub/frame/FramePoolManager.ts b/navigator/src/epub/frame/FramePoolManager.ts
index 0363641b..ce54d56d 100644
--- a/navigator/src/epub/frame/FramePoolManager.ts
+++ b/navigator/src/epub/frame/FramePoolManager.ts
@@ -21,6 +21,7 @@ export class FramePoolManager {
private readonly injector: Injector | null = null;
private readonly contentProtectionConfig: IContentProtectionConfig;
private readonly keyboardPeripheralsConfig: IKeyboardPeripheralsConfig;
+ private readonly getFragmentIds: (href: string) => string[];
constructor(
container: HTMLElement,
@@ -28,7 +29,8 @@ export class FramePoolManager {
cssProperties?: { [key: string]: string },
injector?: Injector | null,
contentProtectionConfig?: IContentProtectionConfig,
- keyboardPeripheralsConfig?: IKeyboardPeripheralsConfig
+ keyboardPeripheralsConfig?: IKeyboardPeripheralsConfig,
+ getFragmentIds?: (href: string) => string[]
) {
this.container = container;
this.positions = positions;
@@ -36,6 +38,7 @@ export class FramePoolManager {
this.injector = injector ?? null;
this.contentProtectionConfig = contentProtectionConfig || {};
this.keyboardPeripheralsConfig = keyboardPeripheralsConfig || [];
+ this.getFragmentIds = getFragmentIds ?? (() => []);
}
async destroy() {
@@ -171,7 +174,7 @@ export class FramePoolManager {
}
// Create