@readium/navigator
Version:
Next generation SDK for publications in Web Apps
517 lines (451 loc) • 21.6 kB
text/typescript
import { Feature, Link, Locator, Publication, ReadingProgression, LocatorLocations } from "@readium/shared";
import { VisualNavigator, VisualNavigatorViewport, ProgressionRange } from "../Navigator";
import { Configurable } from "../preferences/Configurable";
import { WebPubFramePoolManager } from "./WebPubFramePoolManager";
import { BasicTextSelection, CommsEventKey, FrameClickEvent, ModuleLibrary, ModuleName, WebPubModules } from "@readium/navigator-html-injectables";
import * as path from "path-browserify";
import { WebPubFrameManager } from "./WebPubFrameManager";
import { ManagerEventKey } from "../epub/EpubNavigator";
import { WebPubCSS } from "./css/WebPubCSS";
import { WebUserProperties, WebRSProperties } from "./css/Properties";
import { IWebPubPreferences, WebPubPreferences } from "./preferences/WebPubPreferences";
import { IWebPubDefaults, WebPubDefaults } from "./preferences/WebPubDefaults";
import { WebPubSettings } from "./preferences/WebPubSettings";
import { IPreferencesEditor } from "../preferences/PreferencesEditor";
import { WebPubPreferencesEditor } from "./preferences/WebPubPreferencesEditor";
export interface WebPubNavigatorConfiguration {
preferences: IWebPubPreferences;
defaults: IWebPubDefaults;
}
export interface WebPubNavigatorListeners {
frameLoaded: (wnd: Window) => void;
positionChanged: (locator: Locator) => void;
tap: (e: FrameClickEvent) => boolean;
click: (e: FrameClickEvent) => boolean;
zoom: (scale: number) => void;
scroll: (delta: number) => void;
customEvent: (key: string, data: unknown) => void;
handleLocator: (locator: Locator) => boolean;
textSelected: (selection: BasicTextSelection) => void;
}
const defaultListeners = (listeners: WebPubNavigatorListeners): WebPubNavigatorListeners => ({
frameLoaded: listeners.frameLoaded || (() => {}),
positionChanged: listeners.positionChanged || (() => {}),
tap: listeners.tap || (() => false),
click: listeners.click || (() => false),
zoom: listeners.zoom || (() => {}),
scroll: listeners.scroll || (() => {}),
customEvent: listeners.customEvent || (() => {}),
handleLocator: listeners.handleLocator || (() => false),
textSelected: listeners.textSelected || (() => {})
})
export class WebPubNavigator extends VisualNavigator implements Configurable<WebPubSettings, WebPubPreferences> {
private readonly pub: Publication;
private readonly container: HTMLElement;
private readonly listeners: WebPubNavigatorListeners;
private framePool!: WebPubFramePoolManager;
private currentIndex: number = 0;
private currentLocation: Locator;
private _preferences: WebPubPreferences;
private _defaults: WebPubDefaults;
private _settings: WebPubSettings;
private _css: WebPubCSS;
private _preferencesEditor: WebPubPreferencesEditor | null = null;
private webViewport: VisualNavigatorViewport = {
readingOrder: [],
progressions: new Map(),
positions: null
};
constructor(container: HTMLElement, pub: Publication, listeners: WebPubNavigatorListeners, initialPosition: Locator | undefined = undefined, configuration: WebPubNavigatorConfiguration = { preferences: {}, defaults: {} }) {
super();
this.pub = pub;
this.container = container;
this.listeners = defaultListeners(listeners);
// Initialize preference system
this._preferences = new WebPubPreferences(configuration.preferences);
this._defaults = new WebPubDefaults(configuration.defaults);
this._settings = new WebPubSettings(this._preferences, this._defaults, this.hasDisplayTransformability);
this._css = new WebPubCSS({
rsProperties: new WebRSProperties({ experiments: this._settings.experiments || null }),
userProperties: new WebUserProperties({ zoom: this._settings.zoom })
});
// Initialize current location
if (initialPosition && typeof initialPosition.copyWithLocations === 'function') {
this.currentLocation = initialPosition;
// Update currentIndex to match the initial position
const index = this.pub.readingOrder.findIndexWithHref(initialPosition.href);
if (index >= 0) {
this.currentIndex = index;
}
} else {
this.currentLocation = this.createCurrentLocator();
}
}
public async load() {
await this.updateCSS(false);
const cssProperties = this.compileCSSProperties(this._css);
this.framePool = new WebPubFramePoolManager(this.container, cssProperties);
await this.apply();
}
// Configurable interface implementation
public get settings(): Readonly<WebPubSettings> {
return Object.freeze({ ...this._settings });
}
public get preferencesEditor(): IPreferencesEditor {
if (this._preferencesEditor === null) {
this._preferencesEditor = new WebPubPreferencesEditor(this._preferences, this.settings, this.pub.metadata);
}
return this._preferencesEditor;
}
public async submitPreferences(preferences: WebPubPreferences) {
this._preferences = this._preferences.merging(preferences) as WebPubPreferences;
await this.applyPreferences();
}
private async applyPreferences() {
this._settings = new WebPubSettings(this._preferences, this._defaults, this.hasDisplayTransformability);
if (this._preferencesEditor !== null) {
this._preferencesEditor = new WebPubPreferencesEditor(this._preferences, this.settings, this.pub.metadata);
}
// Apply preferences using CSS system like EPUB
await this.updateCSS(true);
}
private async updateCSS(commit: boolean) {
this._css.update(this._settings);
if (commit) await this.commitCSS(this._css);
};
private compileCSSProperties(css: WebPubCSS) {
const properties: { [key: string]: string } = {};
// Include RS properties (i.e. experiments)
for (const [key, value] of Object.entries(css.rsProperties.toCSSProperties())) {
properties[key] = value;
}
// Include user properties
for (const [key, value] of Object.entries(css.userProperties.toCSSProperties())) {
properties[key] = value;
}
return properties;
}
private async commitCSS(css: WebPubCSS) {
const properties = this.compileCSSProperties(css);
this.framePool.setCSSProperties(properties);
}
/**
* Exposed to the public to compensate for lack of implemented readium conveniences
* TODO remove when settings management is incorporated
*/
public get _cframes(): (WebPubFrameManager | undefined)[] {
return this.framePool.currentFrames;
}
private get hasDisplayTransformability(): boolean {
return this.pub.metadata?.accessibility?.feature?.some(
f => f.value === Feature.DISPLAY_TRANSFORMABILITY.value
) ?? false;
}
public eventListener(key: CommsEventKey | ManagerEventKey, data: unknown) {
switch (key) {
case "_pong":
this.listeners.frameLoaded(this.framePool.currentFrames[0]!.iframe.contentWindow!);
this.listeners.positionChanged(this.currentLocation);
break;
case "first_visible_locator":
const loc = Locator.deserialize(data as string);
if(!loc) break;
this.currentLocation = new Locator({
href: this.currentLocation.href,
type: this.currentLocation.type,
title: this.currentLocation.title,
locations: loc?.locations,
text: loc?.text
});
this.listeners.positionChanged(this.currentLocation);
break;
case "text_selected":
this.listeners.textSelected(data as BasicTextSelection);
break;
case "click":
case "tap":
const edata = data as FrameClickEvent;
if (edata.interactiveElement) {
const element = new DOMParser().parseFromString(
edata.interactiveElement,
"text/html"
).body.children[0];
if (
element.nodeType === element.ELEMENT_NODE &&
element.nodeName === "A" &&
element.hasAttribute("href")
) {
const origHref = element.attributes.getNamedItem("href")?.value!;
if (origHref.startsWith("#")) {
this.go(this.currentLocation.copyWithLocations({
fragments: [origHref.substring(1)]
}), false, () => { });
} else if(
origHref.startsWith("mailto:") ||
origHref.startsWith("tel:")
) {
this.listeners.handleLocator(new Link({
href: origHref,
}).locator);
} else {
// Handle internal links that should navigate within the WebPub
// This includes relative links and full URLs that might be in the readingOrder
try {
let hrefToCheck;
// If origHref is already a full URL, use it directly
if (origHref.startsWith("http://") || origHref.startsWith("https://")) {
hrefToCheck = origHref;
} else {
// For relative URLs, use different strategies based on base URL format
if (this.currentLocation.href.startsWith("http://") || this.currentLocation.href.startsWith("https://")) {
// Base URL is absolute, use URL constructor
const currentUrl = new URL(this.currentLocation.href);
const resolvedUrl = new URL(origHref, currentUrl);
hrefToCheck = resolvedUrl.href;
} else {
// Base URL is relative, use path operations
hrefToCheck = path.join(path.dirname(this.currentLocation.href), origHref);
}
}
const link = this.pub.readingOrder.findWithHref(hrefToCheck);
if (link) {
this.goLink(link, false, () => { });
} else {
console.warn(`Internal link not found in readingOrder: ${hrefToCheck}`);
this.listeners.handleLocator(new Link({
href: origHref,
}).locator);
}
} catch (error) {
console.warn(`Couldn't resolve internal link for ${origHref}: ${error}`);
this.listeners.handleLocator(new Link({
href: origHref,
}).locator);
}
}
} else console.log("Clicked on", element);
} else {
const handled = key === "click" ? this.listeners.click(edata) : this.listeners.tap(edata);
if(handled) break;
}
break;
case "scroll":
this.listeners.scroll(data as number);
break;
case "zoom":
this.listeners.zoom(data as number);
break;
case "progress":
this.syncLocation(data as ProgressionRange);
break;
case "log":
console.log(this.framePool.currentFrames[0]?.source?.split("/")[3], ...(data as any[]));
break;
default:
this.listeners.customEvent(key, data);
break;
}
}
private determineModules(): ModuleName[] {
let modules = Array.from(ModuleLibrary.keys()) as ModuleName[];
// For WebPub, use the predefined WebPubModules array and filter
return modules.filter((m) => WebPubModules.includes(m));
}
private attachListener() {
if (this.framePool.currentFrames[0]?.msg) {
this.framePool.currentFrames[0].msg.listener = (key: CommsEventKey | ManagerEventKey, value: unknown) => {
this.eventListener(key, value);
};
}
}
private async apply() {
await this.framePool.update(this.pub, this.currentLocation, this.determineModules());
this.attachListener();
const idx = this.pub.readingOrder.findIndexWithHref(this.currentLocation.href);
if (idx < 0)
throw Error("Link for " + this.currentLocation.href + " not found!");
}
public async destroy() {
await this.framePool?.destroy();
}
private async changeResource(relative: number): Promise<boolean> {
if (relative === 0) return false;
const curr = this.pub.readingOrder.findIndexWithHref(this.currentLocation.href);
const i = Math.max(
0,
Math.min(this.pub.readingOrder.items.length - 1, curr + relative)
);
if (i === curr) {
return false;
}
this.currentIndex = i;
this.currentLocation = this.createCurrentLocator();
await this.apply();
return true;
}
private updateViewport(progression: ProgressionRange) {
this.webViewport.readingOrder = [];
this.webViewport.progressions.clear();
this.webViewport.positions = null;
// Use the current position's href
if (this.currentLocation) {
this.webViewport.readingOrder.push(this.currentLocation.href);
this.webViewport.progressions.set(this.currentLocation.href, progression);
if (this.currentLocation.locations?.position !== undefined) {
this.webViewport.positions = [this.currentLocation.locations.position];
// WebPub doesn't have lastLocationInView like EPUB, so no second position
}
}
}
private async syncLocation(iframeProgress: ProgressionRange): Promise<void> {
const progression = iframeProgress;
if (this.currentLocation) {
this.currentLocation = this.currentLocation.copyWithLocations({
progression: progression.start
});
}
this.updateViewport(progression);
this.listeners.positionChanged(this.currentLocation);
await this.framePool.update(this.pub, this.currentLocation, this.determineModules());
}
goBackward(_animated: boolean, cb: (ok: boolean) => void): void {
this.changeResource(-1).then((success) => {
cb(success);
});
}
goForward(_animated: boolean, cb: (ok: boolean) => void): void {
this.changeResource(1).then((success) => {
cb(success);
});
}
get currentLocator(): Locator {
return this.currentLocation;
}
get viewport(): VisualNavigatorViewport {
return this.webViewport;
}
get isScrollStart(): boolean {
const firstHref = this.viewport.readingOrder[0];
const progression = this.viewport.progressions.get(firstHref);
return progression?.start === 0;
}
get isScrollEnd(): boolean {
const lastHref = this.viewport.readingOrder[this.viewport.readingOrder.length - 1];
const progression = this.viewport.progressions.get(lastHref);
return progression?.end === 1;
}
get canGoBackward(): boolean {
const firstResource = this.pub.readingOrder.items[0]?.href;
return !(this.viewport.progressions.has(firstResource) && this.viewport.progressions.get(firstResource)?.start === 0);
}
get canGoForward(): boolean {
const lastResource = this.pub.readingOrder.items[this.pub.readingOrder.items.length - 1]?.href;
return !(this.viewport.progressions.has(lastResource) && this.viewport.progressions.get(lastResource)?.end === 1);
}
get readingProgression(): ReadingProgression {
return this.pub.metadata.effectiveReadingProgression;
}
get publication(): Publication {
return this.pub;
}
private async loadLocator(locator: Locator, cb: (ok: boolean) => void) {
let done = false;
let cssSelector = (typeof locator.locations.getCssSelector === 'function') && locator.locations.getCssSelector();
if(locator.text?.highlight) {
done = await new Promise<boolean>((res, _) => {
// Attempt to go to a highlighted piece of text in the resource
this.framePool.currentFrames[0]!.msg!.send(
"go_text",
cssSelector ? [
locator.text?.serialize(),
cssSelector // Include CSS selector if it exists
] : locator.text?.serialize(),
(ok) => res(ok)
);
});
} else if(cssSelector) {
done = await new Promise<boolean>((res, _) => {
this.framePool.currentFrames[0]!.msg!.send(
"go_text",
[
"", // No text!
cssSelector // Just CSS selector
],
(ok) => res(ok)
);
});
}
if(done) {
cb(done);
return;
}
// This sanity check has to be performed because we're still passing non-locator class
// locator objects to this function. This is not good and should eventually be forbidden
// or the locator should be deserialized sometime before this function.
const hid = (typeof locator.locations.htmlId === 'function') && locator.locations.htmlId();
if(hid)
done = await new Promise<boolean>((res, _) => {
// Attempt to go to an HTML ID in the resource
this.framePool.currentFrames[0]!.msg!.send("go_id", hid, (ok) => res(ok));
});
if(done) {
cb(done);
return;
}
const progression = locator?.locations?.progression;
const hasProgression = progression && progression > 0;
if(hasProgression)
done = await new Promise<boolean>((res, _) => {
// Attempt to go to a progression in the resource
this.framePool.currentFrames[0]!.msg!.send("go_progression", progression, (ok) => res(ok));
});
else done = true;
cb(done);
}
public go(locator: Locator, _: boolean, cb: (ok: boolean) => void): void {
const href = locator.href.split("#")[0];
let link = this.pub.readingOrder.findWithHref(href);
if(!link) {
return cb(this.listeners.handleLocator(locator));
}
// Update currentIndex to point to the found link
const index = this.pub.readingOrder.findIndexWithHref(href);
if (index >= 0) {
this.currentIndex = index;
}
this.currentLocation = this.createCurrentLocator();
this.apply().then(() => this.loadLocator(locator, (ok) => cb(ok))).then(() => {
// Now that we've gone to the right locator, we can attach the listeners.
// Doing this only at this stage reduces janky UI with multiple locator updates.
this.attachListener();
});
}
public goLink(link: Link, animated: boolean, cb: (ok: boolean) => void): void {
return this.go(link.locator, animated, cb);
}
// Specifics to WebPub
// Util method
private createCurrentLocator(): Locator {
const readingOrder = this.pub.readingOrder;
const currentLink = readingOrder.items[this.currentIndex];
if (!currentLink) {
throw new Error("No current resource available");
}
// Check if we're on the same resource
const isSameResource = this.currentLocation && this.currentLocation.href === currentLink.href;
// Preserve progression if staying on same resource, otherwise start from beginning
const progression = isSameResource && this.currentLocation.locations.progression
? this.currentLocation.locations.progression
: 0;
return this.pub.manifest.locatorFromLink(currentLink) || new Locator({
href: currentLink.href,
type: currentLink.type || "text/html",
locations: new LocatorLocations({
fragments: [],
progression: progression,
position: this.currentIndex + 1
})
});
}
}
export const ExperimentalWebPubNavigator = WebPubNavigator;