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