Skip to content

Commit

Permalink
Navigator + navigator-html-injectables implementation (readium#12)
Browse files Browse the repository at this point in the history
* initial navigator-html

* add basic swipe handling in ColumnSnapper

* address PR review issues

* updated column snapper

* add will-change for animation

* fix safari columnsnapper behavior

* use transform3d instead of left for snapper

* make using transform an option

* more work on scroll snapper engine

* switch to yarn pnp for dependencies

* Work on EPUB Navigator

* adjustments to epub navigator for interoperability

* framepool improvements

* make iframe pool link to mounted elements
- moves frame display management from navigator to framepoolmanager

* add logging to comms, add message buffers, bugfix

* add stricter frame-side comms enforcement

* move single js package to individual packages in a workspace

* revert CI path changes

* add basic builds for packages, readmes, other tweaks

* attempt to fix size-limit

* move to using pnpm

* update CI to use pnpm

* temporarily disable size.yml

* fix

* fix lint command

* be explicit about commands in CI

* prepare for demo reader

* add unregisterAll function

* add progress event to ColumnSnapper

* reject non-matching comms version

* fix iframe resuming

* add more progress reports to columnsnapper

* epubnavigator improvements

* demo -> testapp

* navigator-html -> navigator-html-injectables

* conslidate imports

* ColumnSnapper fixes

* comment out showToc logic

* use more accurate progression

* add protect/unprotect commands, implement in snapper

* bugfix

* distinguish between touch and tap

* test attempt to fix tapping issue

* columnsnapper/general iframe improvements

* add pointer events disable to hidden iframes

* columnsnapper/frame tweaks

* block parallel update requests for frame pool

* fix accuracy of log source in navigator

* more columnsnapper fixes

* reflowable reader improvments

* reduce FOUC

* replace wentBack with reduced FOUC

* add Oscar's MVP ScrollSnapper, address bugs

* add initialPosition to EpubNavigator

* replace unknown event with customEvent callback

* scrollsnapper changes (by Oscar)

* export HttpResource

* support live alterations to self of publication

* change destination origin

* Oscar: remove scrollbars in ScrollSnapper

* add "HttpClient" equivalent to HttpFetcher

* Oscar: remove duplicate reportprogress call

* implement goto specific progression in resource

* PR suggestions from @dysbulic

* address PR comments from @mickael-menu
  • Loading branch information
chocolatkey authored Nov 1, 2022
1 parent 44d9473 commit ca5f465
Show file tree
Hide file tree
Showing 59 changed files with 11,041 additions and 25,441 deletions.
24 changes: 14 additions & 10 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@ on:
push:
paths:
- 'shared/**'
- 'streamer/**'
- 'navigator/**'
- 'src/**'
- '.github/workflows/CI-shared.yml'
- '.github/workflows/CI.yml'

jobs:
build:
name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }}

runs-on: ${{ matrix.os }}
defaults:
run:
working-directory: ./shared
strategy:
matrix:
node: ['14.x', '16.x']
Expand All @@ -22,19 +22,23 @@ jobs:
- name: Checkout repo
uses: actions/checkout@v2

- name: Set up pnpm
uses: pnpm/[email protected]

- name: Use Node ${{ matrix.node }}
uses: actions/setup-node@v1
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
cache: 'pnpm'

- name: Install deps and build (with cache)
uses: bahmutov/npm-install@v1
- name: Install deps and build
run: pnpm install

- name: Lint
run: yarn lint
run: pnpm run lint

- name: Test
run: yarn test --ci --coverage --maxWorkers=2
run: pnpm run test --ci --coverage --maxWorkers=2

- name: Build
run: yarn build
run: pnpm run build
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ jobs:
- uses: actions/checkout@v1
- uses: andresz1/size-limit-action@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
github_token: ${{ secrets.GITHUB_TOKEN }}
directory: shared/
5 changes: 1 addition & 4 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
*.log
.DS_Store
node_modules
dist
types
coverage
node_modules
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
}
10 changes: 10 additions & 0 deletions navigator-html-injectables/.editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
root = true

[*]
end_of_line = lf
insert_final_newline = true

[*.{js,json,yml}]
charset = utf-8
indent_style = space
indent_size = 2
14 changes: 14 additions & 0 deletions navigator-html-injectables/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
*.log
.DS_Store
node_modules
dist
types
coverage

# Yarn
.yarn/*
!.yarn/cache
!.yarn/releases
!.yarn/plugins
!.yarn/sdks
!.yarn/versions
5 changes: 5 additions & 0 deletions navigator-html-injectables/README.MD
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# navigator-html-injectables

This package can be used either inside a reflowable (X)HTML or outside (in a javascript environment) to provide access and control over a resource from a navigator on any modern browser or embedded browser frame. This is a replacement for the javascript stubs found in the mobile SDKs, such as [this](https://github.com/readium/kotlin-toolkit/tree/develop/readium/navigator/src/main/assets/_scripts/src).

Special care should be taken to make the final produced build compatible with a set of browsers that are older than what you'd typically support on the web, since this SDK can be used in a mobile app's webview, which is an environment that tends to run on an outdated web engine.
46 changes: 46 additions & 0 deletions navigator-html-injectables/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"name": "@readium/navigator-html-injectables",
"version": "0.0.1",
"description": "An embeddable solution for connecting frames of HTML publications with a Readium Navigator",
"author": "readium",
"repository": {
"type": "git",
"url": "git+https://github.com/readium/web.git"
},
"license": "BSD-3-Clause",
"bugs": {
"url": "https://github.com/readium/ts-toolkit/issues"
},
"homepage": "https://github.com/readium/ts-toolkit",
"keywords": [
"readium",
"web",
"epub",
"html",
"reflowable",
"embedded"
],
"scripts": {
"build": "esbuild src/index.ts --bundle --minify --sourcemap --target=es6,chrome58,firefox57,safari11,edge16 --outfile=dist/index.js"
},
"main": "dist/index.js",
"types": "types/index.d.ts",
"files": [
"dist",
"src",
"types"
],
"engines": {
"node": ">=14"
},
"prettier": {
"printWidth": 80,
"semi": true,
"singleQuote": true,
"trailingComma": "es5"
},
"module": "dist/index.js",
"devDependencies": {
"tslib": "^2.3.1"
}
}
84 changes: 84 additions & 0 deletions navigator-html-injectables/src/Loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Comms } from "./comms/comms";
import { Module, ModuleDerived, ModuleLibrary, ModuleName } from "./modules";

/**
* The Module loader. Handles initialization of the HTML injectables
* in the target Window, which could be an IFrame, or the current window).
*/
export class Loader<T extends string = ModuleName> {
private loadedModules: Module[] = [];
private readonly wnd: Window;
private readonly comms: Comms;

/**
* @param wnd Window instance to operate on
* @param initialModules List of initial modules to load
*/
constructor(wnd: Window = window, initialModules: string[] = []) {
this.wnd = wnd; // Window instance
this.comms = new Comms(wnd);

const uniqueModules = [...new Set(initialModules)]; // Deduplicate initial module list
if(!uniqueModules.length) return; // No initial modules

if(typeof wnd === 'undefined') // Detect accidental Node/SSR usage
throw Error("Loader is not in a web browser");

if(wnd.parent !== wnd) this.comms.log("Loader is probably in a frame");

this.loadedModules = uniqueModules.map(name => {
const nm = this.loadModule(name);
if(!nm) return;
nm.mount(this.wnd, this.comms); // Mount module
return nm;
}).filter(m => m !== undefined) as Module[]; // Filter out all modules not found
}

private loadModule(moduleName: string) {
const m = ModuleLibrary.get(moduleName); // Find a module with this name
if(m === undefined) {
this.comms.log(`Module "${name}" does not exist in the library`)
return m;
}
return new m(); // Construct module
}

/**
* Add a module by name
* @param moduleName Module name
* @returns Success
*/
public addModule(moduleName: T): boolean {
const nm = this.loadModule(moduleName);
if(!nm || !nm.mount(this.wnd, this.comms)) return false; // Mount module
this.loadedModules.push(nm); // Add module to list
return true;
}

/**
* Remove a module by name
* @param moduleName Module name
* @returns Success
*/
public removeModule(moduleName: T): boolean {
const m = ModuleLibrary.get(moduleName) as ModuleDerived; // Get the right class
if(m === undefined) {
this.comms.log(`Module "${moduleName}" does not exist in the library`)
return false;
}
const index = this.loadedModules.findIndex(lm => lm instanceof m); // Find module
if(index < 0) return false; // Module not found
this.loadedModules[index].unmount(this.wnd, this.comms); // Unmount module
this.loadedModules.splice(index, 1); // Remove module
return true;
}

/**
* Unmount and remove all modules
*/
public destroy() {
this.comms.destroy();
this.loadedModules.forEach(m => m.unmount(this.wnd, this.comms));
this.loadedModules = [];
}
}
153 changes: 153 additions & 0 deletions navigator-html-injectables/src/comms/comms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { CommsEventKey, CommsCommandKey } from "./keys";
import { mid } from "./mid";

export const COMMS_VERSION = 1;

export interface CommsMessage {
_readium: number; // Sanity/version-checking field
_channel: string; // Channel ID
id?: string; // Optional (but recommended!) unique identifier
strict?: boolean; // Whether or not the event *must* be handled by the receiver
key: CommsEventKey | CommsCommandKey; // The "key" for identification to the listener
data: unknown; // The data to be sent to the module
}

export interface Registrant {
module: string;
cb: CommsCallback;
}
export type CommsAck = (ok: boolean) => void;
export type CommsCallback = (data: unknown, ack: CommsAck) => void; // TODO: maybe more than void?

/**
* Comms is basically a wrapper around window.postMessage that
* adds structure to the messages and lets modules register callbacks.
*/
export class Comms {
private readonly wnd: Window;
private destination: MessageEventSource | null = null;
private registrar = new Map<CommsCommandKey, Registrant[]>();
private origin: string = "";
private channelId: string = "";

constructor(wnd: Window) {
this.wnd = wnd;
wnd.addEventListener("message", this.receiver);
}

private receive(event: MessageEvent) {
if(event.source === null) throw Error("Event source is null");
if(typeof event.data !== "object") return;
const data = event.data as CommsMessage; // Cast it as a CommsMessage
if(!("_readium" in data) || !data._readium || data._readium <= 0) return; // Not for us
if(data.key === "_ping") {
// The "ping" gives us a destination we bind to for posting events
if(!this.destination) {
this.destination = event.source;
this.origin = event.origin;
this.channelId = data._channel;

// Make sure we're communicating with a host on the same comms version
if(data._readium !== COMMS_VERSION) {
if(data._readium > COMMS_VERSION)
this.send("error", `received comms version ${data._readium} higher than ${COMMS_VERSION}`);
else
this.send("error", `received comms version ${data._readium} lower than ${COMMS_VERSION}`);

this.destination = null;
this.origin = "";
this.channelId = "";
return;
}

this.send("_pong", undefined);
this.preLog.forEach(d => this.send("log", d));
this.preLog = [];
}
return;
} else if(this.channelId) {
// Enforce matching channel ID and origin
if(
data._channel !== this.channelId ||
event.origin !== this.origin
) return;
} else {
// Ignore any messages beside _ping if not initialized
return;
}
this.handle(data);
}
private receiver = this.receive.bind(this);

private handle(data: CommsMessage) {
const listeners = this.registrar.get(data.key as CommsCommandKey);
if(!listeners || listeners.length === 0) {
if(data.strict) this.send("_unhandled", data); // Let the sender know the data was not handled by any listener
return;
}
listeners.forEach(l => l.cb(data.data, (ok: boolean) => {
this.send("_ack", ok, data.id); // Acknowledge handling of the event
}));
}

public register(key: CommsCommandKey, module: string, callback: CommsCallback) {
const listeners = this.registrar.get(key);
if(listeners && listeners.length >= 0) {
const existing = listeners.find(l => l.module === module);
if(existing) throw new Error(`Trying to register another callback for combination of event ${key} and module ${module}`);
listeners.push({
cb: callback,
module
})
this.registrar.set(key, listeners);
} else
this.registrar.set(key, [{
cb: callback,
module
}]);
}

public unregister(key: CommsCommandKey, module: string) {
const listeners = this.registrar.get(key);
if(!listeners || listeners.length === 0) return;
listeners.splice(listeners.findIndex(l => l.module === module), 1);
}

public unregisterAll(module: string) {
this.registrar.forEach((v, k) => this.registrar.set(k, v.filter(r => r.module !== module)));
}

// Convenience function for logging data
private preLog: any[] = [];
public log(...data: any[]) {
if(!this.destination) this.preLog.push(data);
else this.send("log", data);
}

public get ready() {
return !!this.destination;
}

public destroy() {
this.destination = null;
this.channelId = "";
this.preLog = [];
this.registrar.clear();
this.wnd.removeEventListener("message", this.receiver);
}

public send(key: CommsEventKey, data: unknown, id: unknown = undefined, transfer: Transferable[] = []) {
if(!this.destination) throw Error("Attempted to send comms message before destination has been initialized");
this.destination.postMessage({
_readium: COMMS_VERSION,
_channel: this.channelId,
id: id ?? mid(),
// scrict,
key,
data
} as CommsMessage, {
targetOrigin: this.origin,
transfer
});
}
}
Loading

0 comments on commit ca5f465

Please sign in to comment.