1 | # NodeJS / TypeScript Readium-2 "navigator" component
|
2 |
|
3 | NodeJS implementation (written in TypeScript) for the navigator module of the Readium2 architecture ( https://github.com/readium/architecture/ ).
|
4 |
|
5 | [![License](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](/LICENSE)
|
6 |
|
7 | ## Build status
|
8 |
|
9 | [![NPM](https://img.shields.io/npm/v/r2-navigator-js.svg)](https://www.npmjs.com/package/r2-navigator-js) [![David](https://david-dm.org/readium/r2-navigator-js/status.svg)](https://david-dm.org/readium/r2-navigator-js)
|
10 |
|
11 | [Changelog](/CHANGELOG.md)
|
12 |
|
13 | ## Prerequisites
|
14 |
|
15 | 1) https://nodejs.org NodeJS >= 8, NPM >= 5 (check with command line `node --version` and `npm --version`)
|
16 | 2) OPTIONAL: https://yarnpkg.com Yarn >= 1.0 (check with command line `yarn --version`)
|
17 |
|
18 | ## GitHub repository
|
19 |
|
20 | https://github.com/readium/r2-navigator-js
|
21 |
|
22 | There is no [github.io](https://readium.github.io/r2-navigator-js) site for this project (no [gh-pages](https://github.com/readium/r2-navigator-js/tree/gh-pages) branch).
|
23 |
|
24 | ## NPM package
|
25 |
|
26 | https://www.npmjs.com/package/r2-navigator-js
|
27 |
|
28 | Command line install:
|
29 |
|
30 | `npm install r2-navigator-js`
|
31 | OR
|
32 | `yarn add r2-navigator-js`
|
33 |
|
34 | ...or manually add in your `package.json`:
|
35 | ```json
|
36 | "dependencies": {
|
37 | "r2-navigator-js": "latest"
|
38 | }
|
39 | ```
|
40 |
|
41 | The JavaScript code distributed in the NPM package is usable as-is (no transpilation required), as it is automatically-generated from the TypeScript source.
|
42 |
|
43 | Several ECMAScript flavours are provided out-of-the-box: ES5, ES6-2015, ES7-2016, ES8-2017:
|
44 |
|
45 | https://unpkg.com/r2-navigator-js/dist/
|
46 |
|
47 | (alternatively, GitHub mirror with semantic-versioning release tags: https://github.com/edrlab/r2-navigator-js-dist/tree/develop/dist/ )
|
48 |
|
49 | The JavaScript code is not bundled, and it uses `require()` statement for imports (NodeJS style).
|
50 |
|
51 | More information about NodeJS compatibility:
|
52 |
|
53 | http://node.green
|
54 |
|
55 | Note that web-browser Javascript is currently not supported (only NodeJS runtimes).
|
56 |
|
57 | The type definitions (aka "typings") are included as `*.d.ts` files in `./node_modules/r2-navigator-js/dist/**`, so this package can be used directly in a TypeScript project.
|
58 |
|
59 | Example usage:
|
60 |
|
61 | ```javascript
|
62 | // currently no index file
|
63 | // import { * } from "r2-navigator-js";
|
64 |
|
65 | // ES5 import (assuming node_modules/r2-navigator-js/):
|
66 | import { trackBrowserWindow } from "r2-navigator-js/dist/es5/src/electron/main/browser-window-tracker";
|
67 |
|
68 | // ... or alternatively using a convenient path alias in the TypeScript config (+ WebPack etc.):
|
69 | import { trackBrowserWindow } from "@r2-navigator-js/electron/main/browser-window-tracker";
|
70 | ```
|
71 |
|
72 | ## Dependencies
|
73 |
|
74 | https://david-dm.org/readium/r2-navigator-js
|
75 |
|
76 | A [package-lock.json](https://github.com/readium/r2-navigator-js/blob/develop/package-lock.json) is provided (modern NPM replacement for `npm-shrinkwrap.json`).
|
77 |
|
78 | A [yarn.lock](https://github.com/readium/r2-navigator-js/blob/develop/yarn.lock) file is currently *not* provided at the root of the source tree.
|
79 |
|
80 | ## Continuous Integration
|
81 |
|
82 | TODO (unit tests?)
|
83 | https://travis-ci.org/readium/r2-navigator-js
|
84 |
|
85 | Badge: `[![Travis](https://travis-ci.org/readium/r2-navigator-js.svg?branch=develop)](https://travis-ci.org/readium/r2-navigator-js)`
|
86 |
|
87 | ## Version(s), Git revision(s)
|
88 |
|
89 | NPM package (latest published):
|
90 |
|
91 | https://unpkg.com/r2-navigator-js/dist/gitrev.json
|
92 |
|
93 | Alternatively, GitHub mirror with semantic-versioning release tags:
|
94 |
|
95 | https://raw.githack.com/edrlab/r2-navigator-js-dist/develop/dist/gitrev.json
|
96 |
|
97 | ## Developer Primer
|
98 |
|
99 | ### Quick Start
|
100 |
|
101 | Command line steps (NPM, but similar with YARN):
|
102 |
|
103 | 1) `cd r2-navigator-js`
|
104 | 2) `git status` (please ensure there are no local changes, especially in `package-lock.json` and the dependency versions in `package.json`)
|
105 | 3) `rm -rf node_modules` (to start from a clean slate)
|
106 | 4) `npm install`, or alternatively `npm ci` (both commands initialize the `node_modules` tree of package dependencies, based on the strict `package-lock.json` definition)
|
107 | 5) `npm run build:all` (invoke the main build script: clean, lint, compile)
|
108 | 6) `ls dist` (that's the build output which gets published as NPM package)
|
109 |
|
110 | ### Local Workflow (NPM packages not published yet)
|
111 |
|
112 | Strictly-speaking, a developer needs to clone only the GitHub repository he/she wants to modify code in.
|
113 | However, for this documentation let's assume that all `r2-xxx-js` GitHub repositories are cloned, as siblings within the same parent folder.
|
114 | The dependency chain is as follows: `r2-utils-js` - `r2-lcp-js` - `r2-shared-js` - `r2-opds-js` - `r2-streamer-js` - `r2-navigator-js` - `r2-testapp-js`
|
115 | (for example, this means that `r2-shared-js` depends on `r2-utils-js` and `r2-lcp-js` to compile and function properly).
|
116 | Note that `readium-desktop` can be cloned in there too, and this project has the same level as `r2-testapp-js` in terms of its `r2-XXX-js` dependencies.
|
117 |
|
118 | 1) `cd MY_CODE_FOLDER`
|
119 | 2) `git clone https://github.com/readium/r2-XXX-js.git` (replace `XXX` for each repository name mentioned above)
|
120 | 3) `cd r2-XXX-js && npm install && npm run build:all` (the order of the `XXX` repositories does not matter here, as we are just pulling dependencies from NPM, and testing that the build works)
|
121 | 4) Now change some code in `r2-navigator-js` (for example), and invoke `npm run build` (for a quick ES8-2017 build) or `npm run build:all` (for all ECMAScript variants).
|
122 | 5) To update the `r2-navigator-js` package in `r2-testapp-js` without having to publish an official package with strict semantic versioning, simply invoke `npm run copydist`. This command is available in each `r2-XXX-js` package to "propagate" (compiled) code changes into all dependants.
|
123 | 6) From time to time, an `r2-XXX-js` package will have new package dependencies in `node_modules` (for example when `npm install --save` is used to fetch a new utility library). In this case ; using the `r2-navigator-js` example above ; the new package dependencies must be manually copied into the `node_modules` folder of `r2-testapp-js`, as these are not known and therefore not handled by the `npm run copydist` command (which only cares about propagating code changes specific to `r2-XXX-js` packages). Such mismatch may typically occur when working from the `develop` branch, as the formal `package-lock.json` definition of dependencies has not yet been published to NPM.
|
124 | 7) Lastly, once the `r2-navigator-js` code changes are built and copied across into `r2-testapp-js`, simply invoke `npm run electron PATH_TO_EPUB` to launch the test app and check your code modifications (or with `readium-desktop` use `npm run start:dev`).
|
125 |
|
126 | ## Programmer Documentation
|
127 |
|
128 | An Electron app has one `main` process, and potentially several `renderer` processes (one per `BrowserWindow`).
|
129 | In addition, there is a separate runtime for each `webview` embedded inside each `BrowserWindow`
|
130 | (this qualifies as a `renderer` process too).
|
131 | Communication between processes occurs via Electron's IPC asynchronous messaging system,
|
132 | as the runtime contexts are otherwise isolated from each other.
|
133 | Each process launches its own Javascript code bundle. There may be identical / shared code between bundles,
|
134 | but the current state of a given context may differ from the state of another.
|
135 | Internally, state synchronisation between isolated runtimes is performed using IPC,
|
136 | or sometimes by passing URL parameters as this achieves a more instant / synchronous behaviour.
|
137 |
|
138 | Most of the navigator API surface (i.e. exposed functions) relies on the fact that each process is effectively
|
139 | a runtime "singleton", with a ongoing state during its lifecycle. For example, from the moment a `BrowserWindow`
|
140 | is opened (e.g. the "reader" view for a given publication), a `renderer` process is spawned,
|
141 | and this singleton runtime maintains the internal state of the navigator "instance"
|
142 | (this includes the DOM `window` itself).
|
143 | This explains why there is no object model in the navigator design pattern,
|
144 | i.e. no `const nav = new Navigator()` calls.
|
145 |
|
146 | ### Electron main process
|
147 |
|
148 | #### Session initialization
|
149 |
|
150 | ```javascript
|
151 | // ES5 import (assuming node_modules/r2-navigator-js/):
|
152 | import { initSessions, secureSessions } from "r2-navigator-js/dist/es5/src/electron/main/sessions";
|
153 |
|
154 | // ... or alternatively using a convenient path alias in the TypeScript config (+ WebPack etc.):
|
155 | import { initSessions, secureSessions } from "@r2-navigator-js/electron/main/sessions";
|
156 |
|
157 | // Calls Electron APIs to setup sessions/partitions so that local storage (etc.)
|
158 | // for webviews are sandboxed adequately, and to ensure resources are freed when the
|
159 | // application shuts down.
|
160 | // Also initializes the custom URL protocol / scheme used under the hood to ensure
|
161 | // that individual publications have unique origins (to avoid inadvertantly sharing
|
162 | // localStorage, IndexedDB, etc.)
|
163 | // See `convertCustomSchemeToHttpUrl()` and `convertHttpUrlToCustomScheme()` below.
|
164 | initSessions(); // uses app.on("ready", () => {}) internally
|
165 |
|
166 | app.on("ready", () => {
|
167 |
|
168 | // `streamerServer` is an instance of the Server class, see:
|
169 | // https://github.com/readium/r2-streamer-js/blob/develop/README.md
|
170 | const streamerServer = new Server( ... ); // r2-streamer-js
|
171 |
|
172 | // This sets up the Electron hooks that protect the transport layer by adding encrypted headers
|
173 | // in every request (see `streamerServer.getSecureHTTPHeader()`).
|
174 | // This relies on the private encryption key managed by `r2-streamer-js` (in secure mode),
|
175 | // which is also used for the self-signed HTTPS certificate.
|
176 | if (streamerServer.isSecured()) {
|
177 | secureSessions(streamerServer);
|
178 | }
|
179 | }
|
180 | ```
|
181 |
|
182 | #### URL scheme conversion
|
183 |
|
184 | ```javascript
|
185 | import { READIUM2_ELECTRON_HTTP_PROTOCOL, convertHttpUrlToCustomScheme, convertCustomSchemeToHttpUrl }
|
186 | from "@r2-navigator-js/electron/common/sessions";
|
187 |
|
188 | if (url.startsWith(READIUM2_ELECTRON_HTTP_PROTOCOL)) {
|
189 |
|
190 | // These functions convert back and forth between regular HTTP
|
191 | // (for example: https://127.0.0.1:3000/pub/PUB_ID/manifest.json)
|
192 | // and the custom URL protocol / scheme used under the hood to ensure
|
193 | // that individual publications have unique origins (to avoid inadvertantly sharing
|
194 | // localStorage, IndexedDB, etc.)
|
195 | const urlHTTP = convertCustomSchemeToHttpUrl(urlCustom);
|
196 | } else {
|
197 |
|
198 | // Note that it is crucial that the passed URL has a properly-encoded base64 "PUB_ID",
|
199 | // which typically escapes `/` and `=` charaecters that are problematic in URL path component
|
200 | // as well as domain/authority (which the custom URL scheme leverages to create unique PUB_ID origins).
|
201 | const urlCustom = convertHttpUrlToCustomScheme(urlHTTP);
|
202 | }
|
203 | ```
|
204 |
|
205 | #### Electron browser window tracking
|
206 |
|
207 | ```javascript
|
208 | import { trackBrowserWindow } from "@r2-navigator-js/electron/main/browser-window-tracker";
|
209 |
|
210 | app.on("ready", () => {
|
211 |
|
212 | const electronBrowserWindow = new BrowserWindow({
|
213 | // ...
|
214 | });
|
215 | // This tracks created Electron browser windows in order to
|
216 | // intercept / hijack clicked hyperlinks in embedded content webviews
|
217 | // (e.g. user navigation inside EPUB documents)
|
218 | // Note that hyperlinking can work without this low-level,
|
219 | // Electron-specific mechanism (using DOM events),
|
220 | // but this is a powerful "native" interceptor that will listen to
|
221 | // navigation events triggered by non-interactive links (e.g. scripted / programmatic redirections)
|
222 | trackBrowserWindow(electronBrowserWindow);
|
223 | }
|
224 | ```
|
225 |
|
226 | #### Readium CSS configuration (streamer-level injection)
|
227 |
|
228 | ```javascript
|
229 | import { IEventPayload_R2_EVENT_READIUMCSS } from "@r2-navigator-js/electron/common/events";
|
230 | import {
|
231 | readiumCSSDefaults,
|
232 | } from "@r2-navigator-js/electron/common/readium-css-settings";
|
233 | import { setupReadiumCSS } from "@r2-navigator-js/electron/main/readium-css";
|
234 |
|
235 | app.on("ready", () => {
|
236 | // `streamerServer` is an instance of the Server class, see:
|
237 | // https://github.com/readium/r2-streamer-js/blob/develop/README.md
|
238 | const streamerServer = new Server( ... ); // r2-streamer-js
|
239 |
|
240 | // `readiumCSSPath` is a local filesystem path where the folder that contains
|
241 | // ReadiumCSS assets can be found. `path.join(process.cwd(), ...)` or `path.join(__dirname, ...)`
|
242 | // can be used depending on integration / bundling context. The HTTP server will create a static hosting
|
243 | // route to this folder.
|
244 | setupReadiumCSS(streamerServer, readiumCSSPath, getReadiumCss);
|
245 | // `getReadiumCss` is a function "pointer" that will be called when the navigator
|
246 | // needs to obtain an up-to-date ReadiumCSS configuration (this is for initial injection in HTML documents,
|
247 | // via the streamer/server). Note that subsequent requests will originate from the renderer process,
|
248 | // whenever the webview needs to (see further below):
|
249 | const getReadiumCss = (publication: Publication, link: Link | undefined): IEventPayload_R2_EVENT_READIUMCSS => {
|
250 |
|
251 | // The built-in default values.
|
252 | // The ReadiumCSS app-level settings would typically be persistent in a store.
|
253 | const readiumCssKeys = Object.keys(readiumCSSDefaults);
|
254 | readiumCssKeys.forEach((key: string) => {
|
255 | const value = (readiumCSSDefaults as any)[key];
|
256 | console.log(key, " => ", value);
|
257 | // fetch values from app store ...
|
258 | });
|
259 |
|
260 | // See `electron/common/readium-css-settings.ts` for more information,
|
261 | // including links to the ReadiumCSS exhaustive documentation.
|
262 | return {
|
263 | setCSS: {
|
264 | ...
|
265 | fontSize: "100%",
|
266 | ...
|
267 | textAlign: readiumCSSDefaults.textAlign,
|
268 | ...
|
269 | } // setCSS can actually be undefined, in which case this disables ReadiumCSS completely.
|
270 | };
|
271 | };
|
272 | }
|
273 | ```
|
274 |
|
275 | ### Electron renderer process(es), for each Electron BrowserWindow
|
276 |
|
277 | #### Navigator initial injection
|
278 |
|
279 | ```javascript
|
280 | import {
|
281 | installNavigatorDOM,
|
282 | } from "@r2-navigator-js/electron/renderer/index";
|
283 |
|
284 | // This function attaches the navigator HTML DOM and associated functionality
|
285 | // to the app-controlled Electron `BrowserWindow`.
|
286 | installNavigatorDOM(
|
287 | publication, // Publication object (see below for an example of how to create it)
|
288 | publicationURL, // For example: "https://127.0.0.1:3000/PUB_ID/manifest.json"
|
289 | rootHtmlElementID, // For example: "rootdiv", assuming <div id="rootdiv"></div> in the BrowserWindow HTML
|
290 | preloadPath, // See below.
|
291 | location); // A `Locator` object representing the initial reading bookmark (can be undefined/null)
|
292 |
|
293 | // The string parameter `preloadPath` is the path to the JavaScript bundle created for
|
294 | // `r2-navigator-js/src/electron/renderer/webview/preload.ts`,
|
295 | // for example in development mode this could be (assuming EcmaScript-6 / ES-2015 is used):
|
296 | // "node_modules/r2-navigator-js/dist/es6-es2015/src/electron/renderer/webview/preload.js",
|
297 | // whereas in production mode this would be the copied JavaScript bundle inside the Electron app's ASAR:
|
298 | // `"file://" + path.normalize(path.join((global as any).__dirname, preload.js))`
|
299 | // (typically, the final application package contains the following JavaScript bundles:
|
300 | // `main.js` alonside `renderer.js` and `preload.js`)
|
301 |
|
302 | // Here is a typical example of how the Publication object (passed as first parameter) is created:
|
303 | import { Publication } from "@r2-shared-js/models/publication";
|
304 |
|
305 | import { TaJsonDeserialize } from "@r2-lcp-js/serializable";
|
306 |
|
307 | const response = await fetch(publicationURL);
|
308 | const publicationJSON = await response.json();
|
309 | const publication = TaJsonDeserialize<Publication>(publicationJSON, Publication);
|
310 | ```
|
311 |
|
312 | #### Readium CSS configuration (after stream-level injection)
|
313 |
|
314 | ```javascript
|
315 | import {
|
316 | setReadiumCssJsonGetter
|
317 | } from "@r2-navigator-js/electron/renderer/index";
|
318 | import {
|
319 | readiumCSSDefaults
|
320 | } from "@r2-navigator-js/electron/common/readium-css-settings";
|
321 | import {
|
322 | IEventPayload_R2_EVENT_READIUMCSS,
|
323 | } from "@r2-navigator-js/electron/common/events";
|
324 |
|
325 | // `getReadiumCss` is a function "pointer" (callback) that will be called when the navigator
|
326 | // needs to obtain an up-to-date ReadiumCSS configuration ("pull" design pattern).
|
327 | // Unlike the equivalent function in the main process
|
328 | // (which is used for the initial injection in HTML documents, via the streamer/server), this one handles
|
329 | // requests by the actual content viewport, in an on-demand fashion:
|
330 | const getReadiumCss = (publication: Publication, link: Link | undefined): IEventPayload_R2_EVENT_READIUMCSS => {
|
331 |
|
332 | // The built-in default values.
|
333 | // The ReadiumCSS app-level settings would typically be persistent in a store.
|
334 | const readiumCssKeys = Object.keys(readiumCSSDefaults);
|
335 | readiumCssKeys.forEach((key: string) => {
|
336 | const value = (readiumCSSDefaults as any)[key];
|
337 | console.log(key, " => ", value);
|
338 | // fetch values from app store ...
|
339 | });
|
340 |
|
341 | // See `electron/common/readium-css-settings.ts` for more information,
|
342 | // including links to the ReadiumCSS exhaustive documentation.
|
343 | return {
|
344 |
|
345 | // `streamerServer` is an instance of the Server class, see:
|
346 | // https://github.com/readium/r2-streamer-js/blob/develop/README.md
|
347 | // This is actually an optional field (i.e. can be undefined/null),
|
348 | // if not provided, `urlRoot` defaults to `window.location.origin`
|
349 | // (typically, https://127.0.0.1:3000 or whatever the port number happens to be):
|
350 | urlRoot: streamerServer.serverUrl(),
|
351 |
|
352 | setCSS: {
|
353 | ...
|
354 | fontSize: "100%",
|
355 | ...
|
356 | textAlign: readiumCSSDefaults.textAlign,
|
357 | ...
|
358 | } // setCSS can actually be undefined, in which case this disables ReadiumCSS completely.
|
359 | };
|
360 | };
|
361 | setReadiumCssJsonGetter(getReadiumCss);
|
362 | ```
|
363 |
|
364 | ```javascript
|
365 | import {
|
366 | readiumCssOnOff,
|
367 | } from "@r2-navigator-js/electron/renderer/index";
|
368 |
|
369 | // This function simply tells the navigator that the ReadiumCSS settings have changed
|
370 | // (for example when the user used the configuration panel to choose a font size).
|
371 | // Following this call (in an asynchronous manner) the navigator will trigger a call
|
372 | // to the function previously registered via `setReadiumCssJsonGetter()` (see above).
|
373 | readiumCssOnOff();
|
374 | ```
|
375 |
|
376 | #### EPUB reading system information
|
377 |
|
378 | ```javascript
|
379 | import {
|
380 | setEpubReadingSystemInfo
|
381 | } from "@r2-navigator-js/electron/renderer/index";
|
382 |
|
383 | // This sets the EPUB3 `navigator.epubReadingSystem` object with the provided `name` and `version` values:
|
384 | setEpubReadingSystemInfo({ name: "My R2 Application", version: "0.0.1-alpha.1" });
|
385 | ```
|
386 |
|
387 | #### Logging, redirection from web console to shell
|
388 |
|
389 | ```javascript
|
390 | // This should not be called explicitly on the application side,
|
391 | // as this is already handled inside the navigator context! (this is currently not configurable)
|
392 | // This automatically copies web console messages from the renderer process
|
393 | // into the shell ouput (where logging messages from the main process are emitted):
|
394 | import { consoleRedirect } from "@r2-navigator-js/electron/renderer/console-redirect";
|
395 |
|
396 | // By default, the navigator calls the console redirector in both embedded webviews (iframes)
|
397 | // and the central index (renderer process):
|
398 | const releaseConsoleRedirect = consoleRedirect(loggingTag, process.stdout, process.stderr, true);
|
399 | // loggingTag ===
|
400 | // "r2:navigator#electron/renderer/webview/preload"
|
401 | // and
|
402 | // "r2:navigator#electron/renderer/index"
|
403 | ```
|
404 |
|
405 | #### Reading location, linking with locators
|
406 |
|
407 | ```javascript
|
408 | import {
|
409 | LocatorExtended,
|
410 | getCurrentReadingLocation,
|
411 | setReadingLocationSaver
|
412 | } from "@r2-navigator-js/electron/renderer/index";
|
413 |
|
414 | // `saveReadingLocation` is a function "pointer" (callback) that will be called when the navigator
|
415 | // needs to notify the host app that the user's reading location has changed ("push" design pattern).
|
416 | // This function call is debounced inside the navigator, to avoid flooding the application with
|
417 | // many calls (and consequently putting unnecessary strain on the store/messaging system required to
|
418 | // handle the renderer/main process communication).
|
419 | // A typical example of when this function is called is when the user scrolls the viewport using the mouse
|
420 | // (debouncing is every 250ms on the trailing edge,
|
421 | // so there is always a 250ms delay before the first notification).
|
422 | const saveReadingLocation = (location: LocatorExtended) => {
|
423 | // Use an application store to make `location` persistent
|
424 | // ...
|
425 | };
|
426 | setReadingLocationSaver(saveReadingLocation);
|
427 |
|
428 | // This returns the last reading location as a `LocatorExtended` object (can be undefined).
|
429 | // This is equal to the last object received in the `setReadingLocationSaver()` callback (see above)
|
430 | const loc = getCurrentReadingLocation();
|
431 | ```
|
432 |
|
433 | ```javascript
|
434 | // Typically, the `LocatorExtended` data structure will be used to store "bookmarks",
|
435 | // to render a user interface that provides information about the document (e.g. page "numbers"),
|
436 | // or to display an interactive "timeline" / linear scrub bar
|
437 | // to rapidely navigate the publication spine / reading order.
|
438 |
|
439 | // Here is a typical usage example for LocatorExtended.locator.href:
|
440 | // (null/undefined sanity checks removed, for brevity)
|
441 |
|
442 | let _publication: Publication; // set somewhere else
|
443 | const locatorExtended = getCurrentReadingLocation();
|
444 |
|
445 | // That's the HTML <title /> (inside the <head />)
|
446 | console.log(locatorExtended.locator.title);
|
447 |
|
448 | let foundLink = _publication.Spine.find((link, i) => {
|
449 | return link.Href === locatorExtended.locator.href;
|
450 | });
|
451 | if (!foundLink) {
|
452 | // a publication document is not necessarily the spine / reading order
|
453 | foundLink = _publication.Resources.find((link) => {
|
454 | return link.Href === locatorExtended.locator.href;
|
455 | });
|
456 | }
|
457 | // then, use `foundLink` as needed ...
|
458 | ```
|
459 |
|
460 | ```javascript
|
461 | // `LocatorExtended.locations.cssSelector` is a CSS Selector that points to an HTML element,
|
462 | // (i.e. the reading location / bookmarked reference)
|
463 | // and it can be used as-is to restore this using the `handleLinkLocator()` function (see below).
|
464 |
|
465 | // `LocatorExtended.locations.cfi` is provided as "read-only" information,
|
466 | // in the sense that it is not used when ingested back into the navigator via `handleLinkLocator()`.
|
467 | // In other words, setting the CFI field to undefined or another string has no effects when passing the parameter.
|
468 |
|
469 | // `LocatorExtended.locations.position` is not currently supported / implemented,
|
470 | // and as with the CFI field, it can be ignored when feeding back into the navigator API.
|
471 |
|
472 | // `LocatorExtended.locations.progression` is a percentage (floating point number [0, 1])
|
473 | // representing the reading location inside a single document,
|
474 | // so for fixed layout this has no effect. However, reflowable documents are either scrolled or paginated,
|
475 | // so the progression percentage represents how much vertical scrolling / horizontal panning there is.
|
476 | // This progression field can be used to ask the navigator to set a specific reading placement
|
477 | // using `handleLinkLocator()` (see further below).
|
478 | // Typically, for paginated reflowable documents,
|
479 | // the calculation of a desired progression could be mapped to "page" information (columns). See below.
|
480 | ```
|
481 |
|
482 | ```javascript
|
483 | // When a reflowable document is currently presented in a paginated view,
|
484 | // `LocatorExtended.paginationInfo` reports the current `totalColumns` (number of single "pages"),
|
485 | // `currentColumn` (a zero-based index between [0, totalColumns-1]),
|
486 | // and if `isTwoPageSpread` is true, then `spreadIndex` reports the zero-based index
|
487 | // of the currently-visible two-page spread.
|
488 | ```
|
489 |
|
490 | ```javascript
|
491 | // `LocatorExtended.docInfo` reports `isFixedLayout`, `isRightToLeft` and `isVerticalWritingMode`
|
492 | // which are self-explanatory.
|
493 | ```
|
494 |
|
495 | ```javascript
|
496 | // `LocatorExtended.docInfo` reports `isFixedLayout`, `isRightToLeft` and `isVerticalWritingMode`
|
497 | // which are self-explanatory.
|
498 | ```
|
499 |
|
500 | ```javascript
|
501 | // Note that `LocatorExtended.selectionInfo` is currently a prototype concept, not a stable API.
|
502 | // However, this already provides an accurate representation of user selection / character ranges,
|
503 | // which will ; in a future release of r2-navigator-js ; be connected to a highlights / annotations
|
504 | // subsystem (i.e. minimal, but stable / robust functionality).
|
505 | ```
|
506 |
|
507 | ```javascript
|
508 | // For convenience, here is the fully-expanded `LocatorExtended` data structure:
|
509 | interface LocatorExtended {
|
510 | locator { //Locator
|
511 | href: string;
|
512 | title?: string;
|
513 | text?: { //LocatorLocations
|
514 | before?: string;
|
515 | highlight?: string;
|
516 | after?: string;
|
517 | };
|
518 | locations { //LocatorLocations
|
519 | cfi?: string;
|
520 | cssSelector?: string;
|
521 | position?: number;
|
522 | progression?: number;
|
523 | };
|
524 | };
|
525 | paginationInfo { //IPaginationInfo
|
526 | totalColumns: number | undefined;
|
527 | currentColumn: number | undefined;
|
528 | isTwoPageSpread: boolean | undefined;
|
529 | spreadIndex: number | undefined;
|
530 | };
|
531 | docInfo { //IDocInfo
|
532 | isFixedLayout: boolean;
|
533 | isRightToLeft: boolean;
|
534 | isVerticalWritingMode: boolean;
|
535 | };
|
536 | selectionInfo { //ISelectionInfo
|
537 | rangeInfo { //IRangeInfo
|
538 | startContainerElementCssSelector: string;
|
539 | startContainerChildTextNodeIndex: number;
|
540 | startOffset: number;
|
541 |
|
542 | endContainerElementCssSelector: string;
|
543 | endContainerChildTextNodeIndex: number;
|
544 | endOffset: number;
|
545 |
|
546 | cfi: string | undefined;
|
547 | };
|
548 | cleanText: string;
|
549 | rawText: string;
|
550 | };
|
551 | }
|
552 | ```
|
553 |
|
554 | ```javascript
|
555 | import {
|
556 | handleLinkLocator,
|
557 | handleLinkUrl
|
558 | } from "@r2-navigator-js/electron/renderer/index";
|
559 |
|
560 | // The `handleLinkUrl` function is used to instruct the navigator to load
|
561 | // an absolute URL, either internal to the current publication,
|
562 | // or external. For example:
|
563 | // const href = "https://127.0.0.1:3000/PUB_ID/contents/chapter1.html";
|
564 | // or:
|
565 | // const href = "https://external-domain.org/out-link";
|
566 | handleLinkUrl(href);
|
567 |
|
568 | // A typical use-case is the publication's Table Of Contents.
|
569 | // Each spine/readingOrder item is a `Link` object with a relative href (see `r2-shared-js` models).
|
570 | // The final absolute URL may be computed simply by concatenating the publication's manifest.json URL:
|
571 | // (although it is recommended to use a URL/URI library in order to handle query parameters, etc.)
|
572 | const href = publicationURL + "/../" + link.Href;
|
573 | // For example:
|
574 | // publicationURL === "https://127.0.0.1:3000/PUB_ID/manifest.json"
|
575 | // link.Href === "contents/chapter1.html"
|
576 |
|
577 | // This can be used to restore a bookmark previously saved via `getCurrentReadingLocation()` (see above).
|
578 | handleLinkLocator(locator);
|
579 |
|
580 | // `locator.href` is obviously required.
|
581 | // `locator.locations.cssSelector` can be used as-is (as provided by a prior call to `getCurrentReadingLocation()`)
|
582 | // in order to restore a saved reading location.
|
583 | // Alternatively, `locator.locations.progression` (percentage) can be used to pan/shift to a desired reading location,
|
584 | // based on pagination / scroll information (see description of `LocatorExtended.paginationInfo`, above).
|
585 | ```
|
586 |
|
587 | ```javascript
|
588 | import {
|
589 | getCurrentReadingLocation,
|
590 | isLocatorVisible
|
591 | } from "@r2-navigator-js/electron/renderer/index";
|
592 |
|
593 | const locEx = getCurrentReadingLocation();
|
594 |
|
595 | // This function returns true (async promise) if the locator
|
596 | // is fully or partially visible inside the viewport (scrolled or paginated).
|
597 | // Always returns true for fixed layout publications / documents.
|
598 | try {
|
599 | const visible = await isLocatorVisible(locEx.locator);
|
600 | } catch (err) {
|
601 | console.log(err); // promise rejection
|
602 | }
|
603 | ```
|
604 |
|
605 | #### Navigating using arrow keys
|
606 |
|
607 | ```javascript
|
608 | import {
|
609 | navLeftOrRight
|
610 | } from "@r2-navigator-js/electron/renderer/index";
|
611 |
|
612 | // This function instructs the navigator to "turn the page" left or right.
|
613 | // This is litterally in relation to the left-side or right-side of the display.
|
614 | // In other words, the navigator automatically handles the fact that with Right-To-Left content,
|
615 | // the left-hand-side "page turn" button (or associated left arrow keyboard key) means "progress forward".
|
616 | // For example (no need to explicitly handle RTL conditions in this app code):
|
617 | window.document.addEventListener("keydown", (ev: KeyboardEvent) => {
|
618 | if (ev.keyCode === 37) { // left
|
619 | navLeftOrRight(true);
|
620 | } else if (ev.keyCode === 39) { // right
|
621 | navLeftOrRight(false);
|
622 | }
|
623 | });
|
624 | ```
|
625 |
|
626 | #### Selection Highlighting
|
627 |
|
628 | ```javascript
|
629 | // The navigator maintains an ordered (visually-stacked) list of character-level highlights,
|
630 | // during the lifespan of a loaded / rendered publication document. The app is responsible for instructing
|
631 | // the navigator to instantiate these highlights, whenever a document is (re)loaded.
|
632 | // There is no persistence at the level of the navigator, the state is constrained to the lifecycle
|
633 | // of individual HTML documents. The navigator handles redrawing at the appropriate optimal times,
|
634 | // for example when changing the font size. Highlights emit mouse click events which the app can listen to.
|
635 |
|
636 | import {
|
637 | IHighlight,
|
638 | IHighlightDefinition,
|
639 | } from "@r2-navigator-js/electron/common/highlight";
|
640 | import {
|
641 | highlightsClickListen,
|
642 | highlightsCreate,
|
643 | highlightsRemove,
|
644 | } from "@r2-navigator-js/electron/renderer/index";
|
645 |
|
646 | // Use the setReadingLocationSaver() notification to detect when the user creates a new selection:
|
647 | const saveReadingLocation = (location: LocatorExtended) => {
|
648 |
|
649 | if (location.selectionInfo && location.selectionIsNew) {
|
650 | // Note that a RGB `color` can be optionally specified in IHighlightDefinition (default is red-ish):
|
651 | const highlightToCreate = { selectionInfo: location.selectionInfo } as IHighlightDefinition;
|
652 |
|
653 | let createdHighlights: Array<IHighlight | null> | undefined;
|
654 | try {
|
655 | // The highlightsCreate() function takes an array of highlight definitions,
|
656 | // here we just pass a single one, derived from the user selection:
|
657 | createdHighlights = await highlightsCreate(location.locator.href, [highlightToCreate]);
|
658 | } catch (err) {
|
659 | console.log(err);
|
660 | }
|
661 | if (createdHighlights) {
|
662 | createdHighlights.forEach((highlight) => {
|
663 | if (highlight) {
|
664 | // ...
|
665 | // The visual highlight created in the navigator can be saved here in the app,
|
666 | // so that it can be restored at a later stage, typically when reloading the document (href).
|
667 | }
|
668 | });
|
669 | }
|
670 | }
|
671 | };
|
672 | setReadingLocationSaver(saveReadingLocation);
|
673 |
|
674 | // TIP: the app can detect when a new document has been loaded,
|
675 | // in which case the saved / stored highlights (inside the app's persistence layer)
|
676 | // must be re-instantiated inside the navigator:
|
677 | let _lastSavedReadingLocationHref: string | undefined;
|
678 | const saveReadingLocation = async (location: LocatorExtended) => {
|
679 | const hrefHasChanged = _lastSavedReadingLocationHref !== location.locator.href;
|
680 | _lastSavedReadingLocationHref = location.locator.href;
|
681 |
|
682 | // ...
|
683 | // here, invoke highlightsCreate() with the saved / stored highlights for this particular document (href)
|
684 | };
|
685 |
|
686 | // here we listen to mouse click events,
|
687 | // and we destroy the clicked highlight:
|
688 | highlightsClickListen((href: string, highlight: IHighlight) => {
|
689 | highlightsRemove(href, [highlight.id]);
|
690 | // ...
|
691 | // remove the persistent / stored / saved copy too!
|
692 | });
|
693 |
|
694 | ```
|
695 |
|
696 | #### Read aloud, TTS (Text To Speech), Synthetic Speech
|
697 |
|
698 | ```javascript
|
699 | import {
|
700 | TTSStateEnum,
|
701 | ttsClickEnable,
|
702 | ttsListen,
|
703 | ttsNext,
|
704 | ttsPause,
|
705 | ttsPlay,
|
706 | ttsPrevious,
|
707 | ttsResume,
|
708 | ttsStop,
|
709 | } from "@r2-navigator-js/electron/renderer/index";
|
710 |
|
711 | // When true, mouse clicks on text inside publication documents
|
712 | // trigger TTS readaloud playback at the pointed location.
|
713 | // The default is false. Once set to true, this setting persists
|
714 | // for any new loading document within the same publication.
|
715 | // This resets to false for any newly opened publication.
|
716 | // The ALT key modifier triggers playback for the pointed DOM fragment only.
|
717 | ttsClickEnable(false);
|
718 |
|
719 | // Starts playing TTS read aloud for the entire document, from the begining of the document,
|
720 | // or from the last-known reading location (i.e. currently visible in the viewport).
|
721 | // This does not automatically move to the next document.
|
722 | // If called when already playing, stops and starts again from the current location.
|
723 | // Highlighted word-by-word synthetic speech is rendered inside a modal overlay
|
724 | // with basic previous/next and timeline scrubber controls (which has instant text preview).
|
725 | // The textual popup overlay receives mouse clicks to pause/resume playback.
|
726 | // The main document text (in the background of the modal overlay) keeps track
|
727 | // of the current playback position, and the top-level spoken fragment is highlighted.
|
728 | // Note that long paragraphs/sections of text are automatically sentence-fragmented
|
729 | // in order to generate short speech utterances.
|
730 | // Also note that the engine parses DOM information in order to assign the correct language
|
731 | // to utterances, thereby providing support for multilingual documents.
|
732 | ttsPlay();
|
733 |
|
734 | // Stops playback whilst maintaining the read aloud popup overlay,
|
735 | // ready for playback to be resumed.
|
736 | ttsPause();
|
737 |
|
738 | // Resumes from a paused state, plays from the begining of the last-played utterance, and onwards.
|
739 | ttsResume();
|
740 |
|
741 | // Stops any ongoing playback and discards the popup modal TTS overlay.
|
742 | // Cleans-up allocated resources.
|
743 | ttsStops();
|
744 |
|
745 | // Navigate backward / forward inside the stream of utterances scheduled for the current TTS playback.
|
746 | // Equivalent to the command buttons left/right of the timeline scrubber located at the bottom of the read aloud overlay.
|
747 | ttsPrevious();
|
748 | ttsNext();
|
749 |
|
750 | // Sets up a callback for event notifications from the read aloud state machine.
|
751 | // Currently: TTS paused, stopped, and playing.
|
752 | ttsListen((ttsState: TTSStateEnum) => {
|
753 | if (ttsState === TTSStateEnum.PAUSED) {
|
754 | // ...
|
755 | } else if (ttsState === TTSStateEnum.STOPPED) {
|
756 | // ...
|
757 | } else if (ttsState === TTSStateEnum.PLAYING) {
|
758 | // ...
|
759 | }
|
760 | });
|
761 | ```
|
762 |
|
763 | At this stage there are missing features: voice selection (depending on languages), volume, speech rate and pitch.
|
764 |
|
765 | #### LCP
|
766 |
|
767 | ```javascript
|
768 | import { setLcpNativePluginPath } from "@r2-lcp-js/parser/epub/lcp";
|
769 |
|
770 | // Registers the filesystem location of the native LCP library
|
771 | const lcpPluginPath = path.join(process.cwd(), "LCP", "lcp.node");
|
772 | setLcpNativePluginPath(lcpPluginPath);
|
773 | ```
|
774 |
|
775 | ```javascript
|
776 | import { IDeviceIDManager } from "@r2-lcp-js/lsd/deviceid-manager";
|
777 | import { launchStatusDocumentProcessing } from "@r2-lcp-js/lsd/status-document-processing";
|
778 | import { lsdLcpUpdateInject } from "@r2-navigator-js/electron/main/lsd-injectlcpl";
|
779 |
|
780 | // App-level implementation of the LSD (License Status Document)
|
781 | const deviceIDManager: IDeviceIDManager = {
|
782 |
|
783 | async checkDeviceID(key: string): Promise<string | undefined> {
|
784 | //...
|
785 | },
|
786 |
|
787 | async getDeviceID(): Promise<string> {
|
788 | //...
|
789 | },
|
790 |
|
791 | async getDeviceNAME(): Promise<string> {
|
792 | //...
|
793 | },
|
794 |
|
795 | async recordDeviceID(key: string): Promise<void> {
|
796 | //...
|
797 | },
|
798 | };
|
799 |
|
800 | // Assumes a `Publication` object already prepared in memory,
|
801 | // loaded from `publicationFilePath`.
|
802 | // This performs the LCP-compliant background operations to register the device,
|
803 | // and to check for an updated license (as passed in the callback parameter).
|
804 | // The lsdLcpUpdateInject() function can be used to immediately inject the updated
|
805 | // LCP license (META-INF/license.lcpl) inside the EPUB container on the filesystem.
|
806 | // Note that although the `launchStatusDocumentProcessing()` initializes `publication.LCP.LSD`,
|
807 | // after `lsdLcpUpdateInject()` is invoked a fresh new `publication.LCP` object is created
|
808 | // (which mirrors `META-INF/license.lcpl`), so `launchStatusDocumentProcessing()` must be called again (loop)
|
809 | // to ensure the latest LSD is indeed loaded and verified.
|
810 | // Below is an example of looping the `launchStatusDocumentProcessing()` calls in order to reset `publication.LCP.LSD`
|
811 | // after `lsdLcpUpdateInject()` injects a fresh `publication.LCP` based on the downloaded `META-INF/license.lcpl`.
|
812 | async function tryLSD(deviceIDManager: IDeviceIDManager, publication: Publication, publicationFilePath: string): Promise<boolean> {
|
813 |
|
814 | return new Promise(async (resolve, reject) => {
|
815 | try {
|
816 | await launchStatusDocumentProcessing(publication.LCP as LCP, deviceIDManager,
|
817 | async (licenseUpdateJson: string | undefined) => {
|
818 |
|
819 | if (licenseUpdateJson) {
|
820 | let res: string;
|
821 | try {
|
822 | res = await lsdLcpUpdateInject(
|
823 | licenseUpdateJson,
|
824 | publication as Publication,
|
825 | publicationFilePath);
|
826 |
|
827 | try {
|
828 | await tryLSD(publication, publicationFilePath); // loop to re-init LSD
|
829 | resolve(true);
|
830 | } catch (err) {
|
831 | debug(err);
|
832 | reject(err);
|
833 | }
|
834 | } catch (err) {
|
835 | debug(err);
|
836 | reject(err);
|
837 | }
|
838 | } else {
|
839 | resolve(true);
|
840 | }
|
841 | });
|
842 | } catch (err) {
|
843 | debug(err);
|
844 | reject(err);
|
845 | }
|
846 | });
|
847 | }
|
848 | try {
|
849 | await tryLSD(publication, publicationFilePath);
|
850 | } catch (err) {
|
851 | debug(err);
|
852 | }
|
853 |
|
854 | // A less-ideal alterative is to preserve the previous LSD,
|
855 | // but in principle the new LCP may contain a LSD link that needs to be requested
|
856 | // again in order to get the latest information available for this new license
|
857 | // (e.g. associated LSD events list).
|
858 | try {
|
859 | await launchStatusDocumentProcessing(publication.LCP, deviceIDManager,
|
860 | async (licenseUpdateJson: string | undefined) => {
|
861 |
|
862 | if (licenseUpdateJson) {
|
863 | const LSD_backup = publication.LCP.LSD; // LSD preservation, see comment above.
|
864 |
|
865 | let res: string;
|
866 | try {
|
867 | res = await lsdLcpUpdateInject(
|
868 | licenseUpdateJson,
|
869 | publication as Publication,
|
870 | publicationFilePath);
|
871 |
|
872 | publication.LCP.LSD = LSD_backup; // LSD preservation, see comment above.
|
873 | } catch (err) {
|
874 | debug(err);
|
875 | }
|
876 | }
|
877 | });
|
878 | } catch (err) {
|
879 | debug(err);
|
880 | }
|
881 | ```
|
882 |
|
883 | ```javascript
|
884 | import { downloadEPUBFromLCPL } from "@r2-lcp-js/publication-download";
|
885 |
|
886 | // Downloads the EPUB publication referenced by given LCP license,
|
887 | // injects the license at META-INF/license.lcpl inside the EPUB container,
|
888 | // and returns the result as an array of two string values:
|
889 | // first is the full destination filepath (should be path.join(destinationDirectory, destinationFileName))
|
890 | // second is the URL where the EPUB was downloaded from ("publicatiion" link inside the LCP license)
|
891 | try {
|
892 | let epub = await downloadEPUBFromLCPL(lcplFilePath, destinationDirectory, destinationFileName);
|
893 | } catch (err) {
|
894 | debug(err);
|
895 | }
|
896 | ```
|
897 |
|
898 | ```javascript
|
899 | import { doTryLcpPass } from "@r2-navigator-js/electron/main/lcp";
|
900 |
|
901 | // This asks the LCP library to test an array of passphrases
|
902 | // (or the SHA256 digest of the passphrases).
|
903 | // The promise is rejected (try+catch) when no valid passphrase was found.
|
904 | // The function returns the first valid passphrase.
|
905 | try {
|
906 | const lcpPass = "my LCP passphrase";
|
907 | const validPass = await doTryLcpPass(
|
908 | publicationsServer,
|
909 | publicationFilePath,
|
910 | [lcpPass],
|
911 | isSha256Hex);
|
912 |
|
913 | let passSha256Hex: string | undefined;
|
914 | if (!isSha256Hex) {
|
915 | const checkSum = crypto.createHash("sha256");
|
916 | checkSum.update(lcpPass);
|
917 | passSha256Hex = checkSum.digest("hex");
|
918 | } else {
|
919 | passSha256Hex = lcpPass;
|
920 | }
|
921 | } catch (err) {
|
922 | debug(err);
|
923 | }
|
924 | ```
|
925 |
|
926 | ```javascript
|
927 | import { doLsdRenew } from "@r2-navigator-js/electron/main/lsd";
|
928 | import { doLsdReturn } from "@r2-navigator-js/electron/main/lsd";
|
929 |
|
930 | // LSD "renew" (with a specific end date)
|
931 | try {
|
932 | const lsdJson = await doLsdRenew(
|
933 | publicationsServer,
|
934 | deviceIDManager,
|
935 | publicationFilePath,
|
936 | endDateStr);
|
937 | } catch (err) {
|
938 | debug(err);
|
939 | }
|
940 |
|
941 | // LSD "return"
|
942 | try {
|
943 | const lsdJson = await doLsdReturn(
|
944 | publicationsServer,
|
945 | deviceIDManager,
|
946 | publicationFilePath);
|
947 | } catch (err) {
|
948 | debug(err);
|
949 | }
|
950 |
|
951 | // Both LSD "renew" and "return" interactions can return errors (i.e. Promise.reject => try/catch with async/await)
|
952 | // when the server responds with HTTP statusCode < 200 || >= 300.
|
953 | // The `err` object in the above code snippet can be a number (HTTP status code) when no response body is available.
|
954 | // Otherwise, it can be an object with the `httpStatusCode` property (number) and httpResponseBody (string)
|
955 | // when the response body cannot be parsed to JSON.
|
956 | // Otherwise, it can be an object with the `httpStatusCode` property (number) and other arbitrary JSON properties,
|
957 | // depending on the server response. Typically, compliant LCP/LSD servers are expected to return Problem Details JSON (RFC7807),
|
958 | // which provides `title` `type` and `details` JSON properties.
|
959 | // See https://readium.org/technical/readium-lsd-specification/#31-handling-errors
|
960 | ```
|