@readium/navigator 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +28 -0
  2. package/README.MD +11 -0
  3. package/dist/assets/AccessibleDfA.otf +0 -0
  4. package/dist/assets/iAWriterDuospace-Regular.ttf +0 -0
  5. package/dist/index.js +6263 -0
  6. package/dist/index.umd.cjs +107 -0
  7. package/package.json +65 -0
  8. package/src/Navigator.ts +66 -0
  9. package/src/audio/engine/AudioEngine.ts +136 -0
  10. package/src/audio/engine/WebAudioEngine.ts +286 -0
  11. package/src/audio/engine/index.ts +2 -0
  12. package/src/audio/index.ts +1 -0
  13. package/src/epub/EpubNavigator.ts +507 -0
  14. package/src/epub/frame/FrameBlobBuilder.ts +211 -0
  15. package/src/epub/frame/FrameComms.ts +142 -0
  16. package/src/epub/frame/FrameManager.ts +134 -0
  17. package/src/epub/frame/FramePoolManager.ts +179 -0
  18. package/src/epub/frame/index.ts +3 -0
  19. package/src/epub/fxl/FXLCoordinator.ts +152 -0
  20. package/src/epub/fxl/FXLFrameManager.ts +286 -0
  21. package/src/epub/fxl/FXLFramePoolManager.ts +632 -0
  22. package/src/epub/fxl/FXLPeripherals.ts +587 -0
  23. package/src/epub/fxl/FXLPeripheralsDebug.ts +46 -0
  24. package/src/epub/fxl/FXLSpreader.ts +95 -0
  25. package/src/epub/fxl/index.ts +5 -0
  26. package/src/epub/index.ts +3 -0
  27. package/src/helpers/sML.ts +120 -0
  28. package/src/index.ts +3 -0
  29. package/types/src/Navigator.d.ts +41 -0
  30. package/types/src/audio/engine/AudioEngine.d.ts +114 -0
  31. package/types/src/audio/engine/WebAudioEngine.d.ts +107 -0
  32. package/types/src/audio/engine/index.d.ts +2 -0
  33. package/types/src/audio/index.d.ts +1 -0
  34. package/types/src/epub/EpubNavigator.d.ts +66 -0
  35. package/types/src/epub/frame/FrameBlobBuilder.d.ts +13 -0
  36. package/types/src/epub/frame/FrameComms.d.ts +26 -0
  37. package/types/src/epub/frame/FrameManager.d.ts +21 -0
  38. package/types/src/epub/frame/FramePoolManager.d.ts +17 -0
  39. package/types/src/epub/frame/index.d.ts +3 -0
  40. package/types/src/epub/fxl/FXLCoordinator.d.ts +37 -0
  41. package/types/src/epub/fxl/FXLFrameManager.d.ts +41 -0
  42. package/types/src/epub/fxl/FXLFramePoolManager.d.ts +93 -0
  43. package/types/src/epub/fxl/FXLPeripherals.d.ts +97 -0
  44. package/types/src/epub/fxl/FXLPeripheralsDebug.d.ts +13 -0
  45. package/types/src/epub/fxl/FXLSpreader.d.ts +12 -0
  46. package/types/src/epub/fxl/index.d.ts +5 -0
  47. package/types/src/epub/index.d.ts +3 -0
  48. package/types/src/helpers/sML.d.ts +51 -0
  49. package/types/src/index.d.ts +3 -0
@@ -0,0 +1,211 @@
1
+ import { MediaType } from "@readium/shared";
2
+ import { Link, Publication } from "@readium/shared";
3
+
4
+ // Readium CSS imports
5
+ // The "?inline" query is to prevent some bundlers from injecting these into the page (e.g. vite)
6
+ // @ts-ignore
7
+ import readiumCSSAfter from "readium-css/css/dist/ReadiumCSS-after.css?inline";
8
+ // @ts-ignore
9
+ import readiumCSSBefore from "readium-css/css/dist/ReadiumCSS-before.css?inline";
10
+ // @ts-ignore
11
+ import readiumCSSDefault from "readium-css/css/dist/ReadiumCSS-default.css?inline";
12
+
13
+ // Utilities
14
+ const blobify = (source: string, type: string) => URL.createObjectURL(new Blob([source], { type }));
15
+ const stripJS = (source: string) => source.replace(/\/\/.*/g, "").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\n/g, "").replace(/\s+/g, " ");
16
+ const stripCSS = (source: string) => source.replace(/\/\*(?:(?!\*\/)[\s\S])*\*\/|[\r\n\t]+/g, '').replace(/ {2,}/g, ' ')
17
+ // Fully resolve absolute local URLs created by bundlers since it's going into a blob
18
+ .replace(/url\((?!(https?:)?\/\/)("?)\/([^\)]+)/g, `url($2${window.location.origin}/$3`);
19
+ const scriptify = (doc: Document, source: string) => {
20
+ const s = doc.createElement("script");
21
+ s.dataset.readium = "true";
22
+ s.src = source.startsWith("blob:") ? source : blobify(source, "text/javascript");
23
+ return s;
24
+ }
25
+ const styleify = (doc: Document, source: string) => {
26
+ const s = doc.createElement("link");
27
+ s.dataset.readium = "true";
28
+ s.rel = "stylesheet";
29
+ s.type = "text/css";
30
+ s.href = source.startsWith("blob:") ? source : blobify(source, "text/css");
31
+ return s;
32
+ }
33
+
34
+ type CacheFunction = () => string;
35
+ const resourceBlobCache = new Map<string, string>();
36
+ const cached = (key: string, cacher: CacheFunction) => {
37
+ if(resourceBlobCache.has(key)) return resourceBlobCache.get(key)!;
38
+ const value = cacher();
39
+ resourceBlobCache.set(key, value);
40
+ return value;
41
+ };
42
+
43
+ // https://unpkg.com/css-selector-generator@3.6.4/build/index.js
44
+ // CssSelectorGenerator --> _readium_cssSelectorGenerator
45
+ // This has to be injected because you need to be in the iframe's context for it to work properly
46
+ const cssSelectorGenerator = (doc: Document) => scriptify(doc, cached("css-selector-generator", () => blobify(
47
+ "!function(t,e){\"object\"==typeof exports&&\"object\"==typeof module?module.exports=e():\"function\"==typeof define&&define.amd?define([],e):\"object\"==typeof exports?exports._readium_cssSelectorGenerator=e():t._readium_cssSelectorGenerator=e()}(self,(()=>(()=>{\"use strict\";var t,e,n={d:(t,e)=>{for(var o in e)n.o(e,o)&&!n.o(t,o)&&Object.defineProperty(t,o,{enumerable:!0,get:e[o]})},o:(t,e)=>Object.prototype.hasOwnProperty.call(t,e),r:t=>{\"undefined\"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:\"Module\"}),Object.defineProperty(t,\"__esModule\",{value:!0})}},o={};function r(t){return t&&t instanceof Element}function i(t=\"unknown problem\",...e){console.warn(`CssSelectorGenerator: ${t}`,...e)}n.r(o),n.d(o,{default:()=>z,getCssSelector:()=>U}),function(t){t.NONE=\"none\",t.DESCENDANT=\"descendant\",t.CHILD=\"child\"}(t||(t={})),function(t){t.id=\"id\",t.class=\"class\",t.tag=\"tag\",t.attribute=\"attribute\",t.nthchild=\"nthchild\",t.nthoftype=\"nthoftype\"}(e||(e={}));const c={selectors:[e.id,e.class,e.tag,e.attribute],includeTag:!1,whitelist:[],blacklist:[],combineWithinSelector:!0,combineBetweenSelectors:!0,root:null,maxCombinations:Number.POSITIVE_INFINITY,maxCandidates:Number.POSITIVE_INFINITY};function u(t){return t instanceof RegExp}function s(t){return[\"string\",\"function\"].includes(typeof t)||u(t)}function l(t){return Array.isArray(t)?t.filter(s):[]}function a(t){const e=[Node.DOCUMENT_NODE,Node.DOCUMENT_FRAGMENT_NODE,Node.ELEMENT_NODE];return function(t){return t instanceof Node}(t)&&e.includes(t.nodeType)}function f(t,e){if(a(t))return t.contains(e)||i(\"element root mismatch\",\"Provided root does not contain the element. This will most likely result in producing a fallback selector using element\'s real root node. If you plan to use the selector using provided root (e.g. `root.querySelector`), it will nto work as intended.\"),t;const n=e.getRootNode({composed:!1});return a(n)?(n!==document&&i(\"shadow root inferred\",\"You did not provide a root and the element is a child of Shadow DOM. This will produce a selector using ShadowRoot as a root. If you plan to use the selector using document as a root (e.g. `document.querySelector`), it will not work as intended.\"),n):e.ownerDocument.querySelector(\":root\")}function d(t){return\"number\"==typeof t?t:Number.POSITIVE_INFINITY}function m(t=[]){const[e=[],...n]=t;return 0===n.length?e:n.reduce(((t,e)=>t.filter((t=>e.includes(t)))),e)}function p(t){return[].concat(...t)}function h(t){const e=t.map((t=>{if(u(t))return e=>t.test(e);if(\"function\"==typeof t)return e=>{const n=t(e);return\"boolean\"!=typeof n?(i(\"pattern matcher function invalid\",\"Provided pattern matching function does not return boolean. It\'s result will be ignored.\",t),!1):n};if(\"string\"==typeof t){const e=new RegExp(\"^\"+t.replace(\/[|\\\\{}()[\\]^$+?.]\/g,\"\\\\$&\").replace(\/\\*\/g,\".+\")+\"$\");return t=>e.test(t)}return i(\"pattern matcher invalid\",\"Pattern matching only accepts strings, regular expressions and\/or functions. This item is invalid and will be ignored.\",t),()=>!1}));return t=>e.some((e=>e(t)))}function g(t,e,n){const o=Array.from(f(n,t[0]).querySelectorAll(e));return o.length===t.length&&t.every((t=>o.includes(t)))}function y(t,e){e=null!=e?e:function(t){return t.ownerDocument.querySelector(\":root\")}(t);const n=[];let o=t;for(;r(o)&&o!==e;)n.push(o),o=o.parentElement;return n}function b(t,e){return m(t.map((t=>y(t,e))))}const N={[t.NONE]:{type:t.NONE,value:\"\"},[t.DESCENDANT]:{type:t.DESCENDANT,value:\" > \"},[t.CHILD]:{type:t.CHILD,value:\" \"}},S=new RegExp([\"^$\",\"\\\\s\"].join(\"|\")),E=new RegExp([\"^$\"].join(\"|\")),w=[e.nthoftype,e.tag,e.id,e.class,e.attribute,e.nthchild],v=h([\"class\",\"id\",\"ng-*\"]);function C({nodeName:t}){return`[${t}]`}function O({nodeName:t,nodeValue:e}){return`[${t}=\'${L(e)}\']`}function T(t){const e=Array.from(t.attributes).filter((e=>function({nodeName:t},e){const n=e.tagName.toLowerCase();return!([\"input\",\"option\"].includes(n)&&\"value\"===t||v(t))}(e,t)));return[...e.map(C),...e.map(O)]}function I(t){return(t.getAttribute(\"class\")||\"\").trim().split(\/\\s+\/).filter((t=>!E.test(t))).map((t=>`.${L(t)}`))}function x(t){const e=t.getAttribute(\"id\")||\"\",n=`#${L(e)}`,o=t.getRootNode({composed:!1});return!S.test(e)&&g([t],n,o)?[n]:[]}function j(t){const e=t.parentNode;if(e){const n=Array.from(e.childNodes).filter(r).indexOf(t);if(n>-1)return[`:nth-child(${n+1})`]}return[]}function A(t){return[L(t.tagName.toLowerCase())]}function D(t){const e=[...new Set(p(t.map(A)))];return 0===e.length||e.length>1?[]:[e[0]]}function $(t){const e=D([t])[0],n=t.parentElement;if(n){const o=Array.from(n.children).filter((t=>t.tagName.toLowerCase()===e)),r=o.indexOf(t);if(r>-1)return[`${e}:nth-of-type(${r+1})`]}return[]}function R(t=[],{maxResults:e=Number.POSITIVE_INFINITY}={}){const n=[];let o=0,r=k(1);for(;r.length<=t.length&&o<e;)o+=1,n.push(r.map((e=>t[e]))),r=P(r,t.length-1);return n}function P(t=[],e=0){const n=t.length;if(0===n)return[];const o=[...t];o[n-1]+=1;for(let t=n-1;t>=0;t--)if(o[t]>e){if(0===t)return k(n+1);o[t-1]++,o[t]=o[t-1]+1}return o[n-1]>e?k(n+1):o}function k(t=1){return Array.from(Array(t).keys())}const _=\":\".charCodeAt(0).toString(16).toUpperCase(),M=\/[ !\"#$%&\'()\\[\\]{|}<>*+,.\/;=?@^`~\\\\]\/;function L(t=\"\"){var e,n;return null!==(n=null===(e=null===CSS||void 0===CSS?void 0:CSS.escape)||void 0===e?void 0:e.call(CSS,t))&&void 0!==n?n:function(t=\"\"){return t.split(\"\").map((t=>\":\"===t?`\\\\${_} `:M.test(t)?`\\\\${t}`:escape(t).replace(\/%\/g,\"\\\\\"))).join(\"\")}(t)}const q={tag:D,id:function(t){return 0===t.length||t.length>1?[]:x(t[0])},class:function(t){return m(t.map(I))},attribute:function(t){return m(t.map(T))},nthchild:function(t){return m(t.map(j))},nthoftype:function(t){return m(t.map($))}},F={tag:A,id:x,class:I,attribute:T,nthchild:j,nthoftype:$};function V(t){return t.includes(e.tag)||t.includes(e.nthoftype)?[...t]:[...t,e.tag]}function Y(t={}){const n=[...w];return t[e.tag]&&t[e.nthoftype]&&n.splice(n.indexOf(e.tag),1),n.map((e=>{return(o=t)[n=e]?o[n].join(\"\"):\"\";var n,o})).join(\"\")}function B(t,e,n=\"\",o){const r=function(t,e){return\"\"===e?t:function(t,e){return[...t.map((t=>e+\" \"+t)),...t.map((t=>e+\" > \"+t))]}(t,e)}(function(t,e,n){const o=function(t,e){const{blacklist:n,whitelist:o,combineWithinSelector:r,maxCombinations:i}=e,c=h(n),u=h(o);return function(t){const{selectors:e,includeTag:n}=t,o=[].concat(e);return n&&!o.includes(\"tag\")&&o.push(\"tag\"),o}(e).reduce(((e,n)=>{const o=function(t,e){var n;return(null!==(n=q[e])&&void 0!==n?n:()=>[])(t)}(t,n),s=function(t=[],e,n){return t.filter((t=>n(t)||!e(t)))}(o,c,u),l=function(t=[],e){return t.sort(((t,n)=>{const o=e(t),r=e(n);return o&&!r?-1:!o&&r?1:0}))}(s,u);return e[n]=r?R(l,{maxResults:i}):l.map((t=>[t])),e}),{})}(t,n),r=function(t,e){return function(t){const{selectors:e,combineBetweenSelectors:n,includeTag:o,maxCandidates:r}=t,i=n?R(e,{maxResults:r}):e.map((t=>[t]));return o?i.map(V):i}(e).map((e=>function(t,e){const n={};return t.forEach((t=>{const o=e[t];o.length>0&&(n[t]=o)})),function(t={}){let e=[];return Object.entries(t).forEach((([t,n])=>{e=n.flatMap((n=>0===e.length?[{[t]:n}]:e.map((e=>Object.assign(Object.assign({},e),{[t]:n})))))})),e}(n).map(Y)}(e,t))).filter((t=>t.length>0))}(o,n),i=p(r);return[...new Set(i)]}(t,o.root,o),n);for(const e of r)if(g(t,e,o.root))return e;return null}function G(t){return{value:t,include:!1}}function W({selectors:t,operator:n}){let o=[...w];t[e.tag]&&t[e.nthoftype]&&(o=o.filter((t=>t!==e.tag)));let r=\"\";return o.forEach((e=>{(t[e]||[]).forEach((({value:t,include:e})=>{e&&(r+=t)}))})),n.value+r}function H(n){return[\":root\",...y(n).reverse().map((n=>{const o=function(e,n,o=t.NONE){const r={};return n.forEach((t=>{Reflect.set(r,t,function(t,e){return F[e](t)}(e,t).map(G))})),{element:e,operator:N[o],selectors:r}}(n,[e.nthchild],t.DESCENDANT);return o.selectors.nthchild.forEach((t=>{t.include=!0})),o})).map(W)].join(\"\")}function U(t,n={}){const o=function(t){const e=(Array.isArray(t)?t:[t]).filter(r);return[...new Set(e)]}(t),i=function(t,n={}){const o=Object.assign(Object.assign({},c),n);return{selectors:(r=o.selectors,Array.isArray(r)?r.filter((t=>{return n=e,o=t,Object.values(n).includes(o);var n,o})):[]),whitelist:l(o.whitelist),blacklist:l(o.blacklist),root:f(o.root,t),combineWithinSelector:!!o.combineWithinSelector,combineBetweenSelectors:!!o.combineBetweenSelectors,includeTag:!!o.includeTag,maxCombinations:d(o.maxCombinations),maxCandidates:d(o.maxCandidates)};var r}(o[0],n);let u=\"\",s=i.root;function a(){return function(t,e,n=\"\",o){if(0===t.length)return null;const r=[t.length>1?t:[],...b(t,e).map((t=>[t]))];for(const t of r){const e=B(t,0,n,o);if(e)return{foundElements:t,selector:e}}return null}(o,s,u,i)}let m=a();for(;m;){const{foundElements:t,selector:e}=m;if(g(o,e,i.root))return e;s=t[0],u=e,m=a()}return o.length>1?o.map((t=>U(t,i))).join(\", \"):function(t){return t.map(H).join(\", \")}(o)}const z=U;return o})()));",
48
+ "text/javascript"
49
+ )));
50
+
51
+ // Note: we aren't blocking some of the events right now to try and be as nonintrusive as possible.
52
+ // For a more comprehensive implementation, see https://github.com/hackademix/noscript/blob/3a83c0e4a506f175e38b0342dad50cdca3eae836/src/content/syncFetchPolicy.js#L142
53
+ const rBefore = (doc: Document) => scriptify(doc, cached("JS-Before", () => blobify(stripJS(`
54
+ window._readium_blockedEvents = [];
55
+ window._readium_blockEvents = true;
56
+ window._readium_eventBlocker = (e) => {
57
+ if(!window._readium_blockEvents) return;
58
+ e.preventDefault();
59
+ e.stopImmediatePropagation();
60
+ _readium_blockedEvents.push([
61
+ 1, e, e.currentTarget || e.target
62
+ ]);
63
+ };
64
+ window.addEventListener("DOMContentLoaded", window._readium_eventBlocker, true);
65
+ window.addEventListener("load", window._readium_eventBlocker, true);`
66
+ ), "text/javascript")));
67
+ const rAfter = (doc: Document) => scriptify(doc, cached("JS-After", () => blobify(stripJS(`
68
+ if(window.onload) window.onload = new Proxy(window.onload, {
69
+ apply: function(target, receiver, args) {
70
+ if(!window._readium_blockEvents) {
71
+ Reflect.apply(target, receiver, args);
72
+ return;
73
+ }
74
+ _readium_blockedEvents.push([
75
+ 0, target, receiver, args
76
+ ]);
77
+ }
78
+ });`
79
+ ), "text/javascript")));
80
+
81
+ export default class FrameBlobBuider {
82
+ private readonly item: Link;
83
+ private readonly burl: string;
84
+ private readonly pub: Publication;
85
+
86
+ constructor(pub: Publication, baseURL: string, item: Link) {
87
+ this.pub = pub;
88
+ this.item = item;
89
+ this.burl = item.toURL(baseURL) || "";
90
+ }
91
+
92
+ public async build(fxl = false): Promise<string> {
93
+ if(!this.item.mediaType.isHTML) {
94
+ if(this.item.mediaType.isBitmap) {
95
+ return this.buildImageFrame();
96
+ } else
97
+ throw Error("Unsupported frame mediatype " + this.item.mediaType.string);
98
+ } else {
99
+ return await this.buildHtmlFrame(fxl);
100
+ }
101
+ }
102
+
103
+ private async buildHtmlFrame(fxl = false): Promise<string> {
104
+ // Load the HTML resource
105
+ const txt = await this.pub.get(this.item).readAsString();
106
+ if(!txt) throw new Error(`Failed reading item ${this.item.href}`);
107
+ const doc = new DOMParser().parseFromString(
108
+ txt,
109
+ this.item.mediaType.string as DOMParserSupportedType
110
+ );
111
+ const perror = doc.querySelector("parsererror");
112
+ if(perror) {
113
+ const details = perror.querySelector("div");
114
+ throw new Error(`Failed parsing item ${this.item.href}: ${details?.textContent || perror.textContent}`);
115
+ }
116
+ return this.finalizeDOM(doc, this.burl, this.item.mediaType, fxl);
117
+ }
118
+
119
+ private buildImageFrame(): string {
120
+ // Rudimentary image display
121
+ const doc = document.implementation.createHTMLDocument(this.item.title || this.item.href);
122
+ const simg = document.createElement("img");
123
+ simg.src = this.burl || "";
124
+ simg.alt = this.item.title || "";
125
+ simg.decoding = "async";
126
+ doc.body.appendChild(simg);
127
+ return this.finalizeDOM(doc, this.burl, this.item.mediaType, true);
128
+ }
129
+
130
+ // Has JS that may have side-effects when the document is loaded, without any user interaction
131
+ private hasExecutable(doc: Document): boolean {
132
+ // This is not a 100% comprehensive check of all possibilities for JS execution,
133
+ // but it covers what the prevention scripts cover. Other possibilities include:
134
+ // - <iframe> src
135
+ // - <img> with onload/onerror
136
+ // - <meta http-equiv="refresh" content="xxx">
137
+ return (
138
+ !!doc.querySelector("script") || // Any <script> elements
139
+ !!doc.querySelector("body[onload]:not(body[onload=''])") // <body> that executes JS on load
140
+ );
141
+ }
142
+
143
+ private hasStyle(doc: Document): boolean {
144
+ if(
145
+ doc.querySelector("link[rel='stylesheet']") || // Any CSS link
146
+ doc.querySelector("style") || // Any <style> element
147
+ doc.querySelector("[style]:not([style=''])") // Any element with style attribute set
148
+ ) return true;
149
+
150
+ return false;
151
+ }
152
+
153
+ private finalizeDOM(doc: Document, base: string | undefined, mediaType: MediaType, fxl = false): string {
154
+ if(!doc) return "";
155
+
156
+ // Inject styles
157
+ if(!fxl) {
158
+ // Readium CSS Before
159
+ const rcssBefore = styleify(doc, cached("ReadiumCSS-before", () => blobify(stripCSS(readiumCSSBefore), "text/css")));
160
+ doc.head.firstChild ? doc.head.firstChild.before(rcssBefore) : doc.head.appendChild(rcssBefore);
161
+
162
+ // Patch
163
+ const patch = doc.createElement("style");
164
+ patch.dataset.readium = "true";
165
+ patch.innerHTML = `audio[controls] { width: revert; height: revert; }`; // https://github.com/readium/readium-css/issues/94
166
+ rcssBefore.after(patch);
167
+
168
+ // Readium CSS defaults
169
+ if(!this.hasStyle(doc))
170
+ rcssBefore.after(styleify(doc, cached("ReadiumCSS-default", () => blobify(stripCSS(readiumCSSDefault), "text/css"))))
171
+
172
+ // Readium CSS After
173
+ doc.head.appendChild(styleify(doc, cached("ReadiumCSS-after", () => blobify(stripCSS(readiumCSSAfter), "text/css"))));
174
+ }
175
+
176
+ // Set all <img> elements to high priority
177
+ // From what I understand, browser heuristics
178
+ // de-prioritize <iframe> resources. This causes the <img>
179
+ // elements to be loaded in sequence, which in documents
180
+ // with many images causes significant impact to rendering
181
+ // speed. When you increase the priority, the <img> data is
182
+ // loaded in parallel, greatly increasing overall speed.
183
+ doc.body.querySelectorAll("img").forEach((img) => {
184
+ img.setAttribute("fetchpriority", "high");
185
+ });
186
+
187
+ if(base !== undefined) {
188
+ // Set all URL bases. Very convenient!
189
+ const b = doc.createElement("base");
190
+ b.href = base;
191
+ b.dataset.readium = "true";
192
+ doc.head.firstChild!.before(b);
193
+ }
194
+
195
+ // Inject script to prevent in-publication scripts from executing until we want them to
196
+ const hasExecutable = this.hasExecutable(doc);
197
+ if(hasExecutable) doc.head.firstChild!.before(rBefore(doc));
198
+ doc.head.firstChild!.before(cssSelectorGenerator(doc)); // CSS selector utility
199
+ if(hasExecutable) doc.head.appendChild(rAfter(doc)); // Another execution prevention script
200
+
201
+
202
+ // Make blob from doc
203
+ return URL.createObjectURL(
204
+ new Blob([new XMLSerializer().serializeToString(doc)], {
205
+ type: mediaType.isHTML
206
+ ? mediaType.string
207
+ : "application/xhtml+xml", // Fallback to XHTML
208
+ })
209
+ );
210
+ }
211
+ }
@@ -0,0 +1,142 @@
1
+ import {
2
+ COMMS_VERSION,
3
+ CommsMessage,
4
+ CommsCommandKey,
5
+ CommsAck,
6
+ mid,
7
+ CommsEventKey,
8
+ } from "@readium/navigator-html-injectables";
9
+ import { ManagerEventKey } from "../EpubNavigator";
10
+
11
+ interface RegistryValue {
12
+ time: number;
13
+ key: CommsCommandKey;
14
+ cb: CommsAck;
15
+ }
16
+ const REGISTRY_EXPIRY = 10000; // 10 seconds max
17
+
18
+ export type FrameCommsListener = (key: CommsEventKey | ManagerEventKey, value: unknown) => void;
19
+
20
+ export class FrameComms {
21
+ private readonly wnd: Window;
22
+ private readonly registry = new Map<string, RegistryValue>();
23
+ private readonly gc: ReturnType<typeof setInterval>;
24
+ // @ts-ignore
25
+ private readonly origin: string;
26
+ public readonly channelId: string;
27
+ private _ready = false;
28
+ private _listener: FrameCommsListener | undefined;
29
+ private listenerBuffer: [key: CommsEventKey, value: unknown][] = [];
30
+
31
+ public set listener(listener: FrameCommsListener) {
32
+ if(this.listenerBuffer.length > 0)
33
+ this.listenerBuffer.forEach(msg => listener(msg[0], msg[1]));
34
+ this.listenerBuffer = [];
35
+ this._listener = listener;
36
+ }
37
+ public clearListener() {
38
+ if(typeof this._listener === "function") this._listener = undefined;
39
+ }
40
+
41
+ constructor(wnd: Window, origin: string) {
42
+ this.wnd = wnd;
43
+ this.origin = origin;
44
+ try {
45
+ this.channelId = window.crypto.randomUUID();
46
+ } catch (error) {
47
+ this.channelId = mid();
48
+ }
49
+ this.gc = setInterval(() => {
50
+ this.registry.forEach((v, k) => {
51
+ if (performance.now() - v.time > REGISTRY_EXPIRY) {
52
+ console.warn(k, "event for", v.key, "was never handled!");
53
+ this.registry.delete(k);
54
+ }
55
+ });
56
+ }, 5000);
57
+ window.addEventListener("message", this.handler);
58
+ this.send("_ping", undefined);
59
+ }
60
+
61
+ public halt() {
62
+ this._ready = false;
63
+ window.removeEventListener("message", this.handler);
64
+ clearInterval(this.gc);
65
+ this._listener = undefined;
66
+ this.registry.clear();
67
+ }
68
+
69
+ public resume() {
70
+ window.addEventListener("message", this.handler);
71
+ this._ready = true;
72
+ }
73
+
74
+ private handle(e: MessageEvent) {
75
+ const dt = e.data as CommsMessage;
76
+ if (!dt._readium) {
77
+ console.warn("Ignoring", dt);
78
+ return;
79
+ }
80
+ if(dt._channel !== this.channelId) return; // Not meant for us
81
+ switch (dt.key) {
82
+ case "_ack": {
83
+ if (!dt.id) return;
84
+ const v = this.registry.get(dt.id);
85
+ if (!v) return;
86
+ this.registry.delete(dt.id);
87
+ v.cb(!!dt.data);
88
+ return;
89
+ }
90
+ // @ts-ignore
91
+ case "_pong": {
92
+ this._ready = true;
93
+ }
94
+ default: {
95
+ if(!this.ready) return;
96
+ if(typeof this._listener === "function")
97
+ this._listener(dt.key as CommsEventKey, dt.data);
98
+ else
99
+ this.listenerBuffer.push([dt.key as CommsEventKey, dt.data]);
100
+ }
101
+ }
102
+ }
103
+ private handler = this.handle.bind(this);
104
+
105
+ public get ready() {
106
+ return this._ready;
107
+ }
108
+
109
+ /**
110
+ * Send a message to the window using postMessage-based comms communication
111
+ * @returns Identifier associated with the message
112
+ */
113
+ public send(
114
+ key: CommsCommandKey,
115
+ data: unknown,
116
+ callback?: CommsAck,
117
+ strict = false,
118
+ transfer: Transferable[] = []
119
+ ): string {
120
+ const id = mid(); // Generate reasonably unique identifier
121
+ if (callback)
122
+ this.registry.set(id, {
123
+ // Add callback to the registry
124
+ cb: callback,
125
+ time: performance.now(),
126
+ key,
127
+ });
128
+ this.wnd.postMessage(
129
+ {
130
+ _readium: COMMS_VERSION,
131
+ _channel: this.channelId,
132
+ id,
133
+ data,
134
+ key,
135
+ strict,
136
+ } as CommsMessage,
137
+ "/", // Same origin
138
+ transfer
139
+ );
140
+ return id;
141
+ }
142
+ }
@@ -0,0 +1,134 @@
1
+ import { Loader, ModuleName } from "@readium/navigator-html-injectables";
2
+ import { FrameComms } from "./FrameComms";
3
+ import { ReadiumWindow } from "../../../../navigator-html-injectables/types/src/helpers/dom";
4
+
5
+
6
+ export class FrameManager {
7
+ private frame: HTMLIFrameElement;
8
+ private loader: Loader | undefined;
9
+ public readonly source: string;
10
+ private comms: FrameComms | undefined;
11
+
12
+ private currModules: ModuleName[] = [];
13
+
14
+ constructor(source: string) {
15
+ this.frame = document.createElement("iframe");
16
+ this.frame.classList.add("readium-navigator-iframe");
17
+ this.frame.style.visibility = "hidden";
18
+ this.frame.style.setProperty("aria-hidden", "true");
19
+ this.frame.style.opacity = "0";
20
+ this.frame.style.position = "absolute";
21
+ this.frame.style.pointerEvents = "none";
22
+ this.frame.style.transition = "visibility 0s, opacity 0.1s linear";
23
+ this.source = source;
24
+ }
25
+
26
+ async load(modules: ModuleName[]): Promise<Window> {
27
+ return new Promise((res, rej) => {
28
+ if(this.loader) {
29
+ const wnd = this.frame.contentWindow!;
30
+ // Check if currently loaded modules are equal
31
+ if([...this.currModules].sort().join("|") === [...modules].sort().join("|")) {
32
+ try { res(wnd); } catch (error) {};
33
+ return;
34
+ }
35
+ this.comms?.halt();
36
+ this.loader.destroy();
37
+ this.loader = new Loader(wnd as ReadiumWindow, modules);
38
+ this.currModules = modules;
39
+ this.comms = undefined;
40
+ try { res(wnd); } catch (error) {}
41
+ return;
42
+ }
43
+ this.frame.onload = () => {
44
+ const wnd = this.frame.contentWindow!;
45
+ this.loader = new Loader(wnd as ReadiumWindow, modules);
46
+ this.currModules = modules;
47
+ try { res(wnd); } catch (error) {}
48
+ };
49
+ this.frame.onerror = (err) => {
50
+ try { rej(err); } catch (error) {}
51
+ }
52
+ this.frame.contentWindow!.location.replace(this.source);
53
+ });
54
+ }
55
+
56
+ async destroy() {
57
+ await this.hide();
58
+ this.loader?.destroy();
59
+ this.frame.remove();
60
+ }
61
+
62
+ async hide(): Promise<void> {
63
+ this.frame.style.visibility = "hidden";
64
+ this.frame.style.setProperty("aria-hidden", "true");
65
+ this.frame.style.opacity = "0";
66
+ this.frame.style.pointerEvents = "none";
67
+ if(this.frame.parentElement) {
68
+ if(this.comms === undefined) return;
69
+ return new Promise((res, _) => {
70
+ this.comms?.send("unfocus", undefined, (_: boolean) => {
71
+ this.comms?.halt();
72
+ res();
73
+ });
74
+ });
75
+ } else
76
+ this.comms?.halt();
77
+ }
78
+
79
+ async show(atProgress?: number): Promise<void> {
80
+ if(!this.frame.parentElement) {
81
+ console.warn("Trying to show frame that is not attached to the DOM");
82
+ return;
83
+ }
84
+ if(this.comms) this.comms.resume();
85
+ else this.comms = new FrameComms(this.frame.contentWindow!, this.source);
86
+ return new Promise((res, _) => {
87
+ this.comms?.send("activate", undefined, () => {
88
+ this.comms?.send("focus", undefined, () => {
89
+ const remove = () => {
90
+ this.frame.style.removeProperty("visibility");
91
+ this.frame.style.removeProperty("aria-hidden");
92
+ this.frame.style.removeProperty("opacity");
93
+ this.frame.style.removeProperty("pointer-events");
94
+ res();
95
+ }
96
+ if(atProgress && atProgress > 0) {
97
+ this.comms?.send("go_progression", atProgress, remove);
98
+ } else {
99
+ remove();
100
+ }
101
+ });
102
+ });
103
+ });
104
+ }
105
+
106
+ get iframe() {
107
+ return this.frame;
108
+ }
109
+
110
+ get realSize() {
111
+ return this.frame.getBoundingClientRect();
112
+ }
113
+
114
+ get window() {
115
+ if(!this.frame.contentWindow) throw Error("Trying to use frame window when it doesn't exist");
116
+ return this.frame.contentWindow;
117
+ }
118
+
119
+ get atLeft() {
120
+ return this.window.scrollX < 5;
121
+ }
122
+
123
+ get atRight() {
124
+ return this.window.scrollX > this.window.document.scrollingElement!.scrollWidth - this.window.innerWidth - 5
125
+ }
126
+
127
+ get msg() {
128
+ return this.comms;
129
+ }
130
+
131
+ get ldr() {
132
+ return this.loader;
133
+ }
134
+ }
@@ -0,0 +1,179 @@
1
+ import { ModuleName } from "@readium/navigator-html-injectables";
2
+ import { Locator, Publication } from "@readium/shared";
3
+ import FrameBlobBuider from "./FrameBlobBuilder";
4
+ import { FrameManager } from "./FrameManager";
5
+
6
+ const UPPER_BOUNDARY = 5;
7
+ const LOWER_BOUNDARY = 3;
8
+
9
+ export class FramePoolManager {
10
+ private readonly container: HTMLElement;
11
+ private readonly positions: Locator[];
12
+ private _currentFrame: FrameManager | undefined;
13
+ private readonly pool: Map<string, FrameManager> = new Map();
14
+ private readonly blobs: Map<string, string> = new Map();
15
+ private readonly inprogress: Map<string, Promise<void>> = new Map();
16
+ private currentBaseURL: string | undefined;
17
+
18
+ constructor(container: HTMLElement, positions: Locator[]) {
19
+ this.container = container;
20
+ this.positions = positions;
21
+ }
22
+
23
+ async destroy() {
24
+ // Wait for all in-progress loads to complete
25
+ let iit = this.inprogress.values();
26
+ let inp = iit.next();
27
+ const inprogressPromises: Promise<void>[] = [];
28
+ while(inp.value) {
29
+ inprogressPromises.push(inp.value);
30
+ inp = iit.next();
31
+ }
32
+ if(inprogressPromises.length > 0) {
33
+ await Promise.allSettled(inprogressPromises);
34
+ }
35
+ this.inprogress.clear();
36
+
37
+ // Destroy all frames
38
+ let fit = this.pool.values();
39
+ let frm = fit.next();
40
+ while(frm.value) {
41
+ await (frm.value as FrameManager).destroy();
42
+ frm = fit.next();
43
+ }
44
+ this.pool.clear();
45
+
46
+ // Revoke all blobs
47
+ this.blobs.forEach(v => URL.revokeObjectURL(v));
48
+
49
+ // Empty container of elements
50
+ this.container.childNodes.forEach(v => {
51
+ if(v.nodeType === Node.ELEMENT_NODE || v.nodeType === Node.TEXT_NODE) v.remove();
52
+ })
53
+ }
54
+
55
+ async update(pub: Publication, locator: Locator, modules: ModuleName[], force=false) {
56
+ let i = this.positions.findIndex(l => l.locations.position === locator.locations.position);
57
+ if(i < 0) throw Error(`Locator not found in position list: ${locator.locations.position} > ${this.positions.reduce<number>((acc, l) => l.locations.position || 0 > acc ? l.locations.position || 0 : acc, 0) }`);
58
+ const newHref = this.positions[i].href;
59
+
60
+ if(this.inprogress.has(newHref))
61
+ // If this same href is already being loaded, block until the other function
62
+ // call has finished executing so we don't end up e.g. loading the blob twice.
63
+ await this.inprogress.get(newHref);
64
+
65
+ // Create a new progress that doesn't resolve until complete
66
+ // loading of the resource and its dependencies has finished.
67
+ const progressPromise = new Promise<void>(async (resolve, reject) => {
68
+ const disposal: string[] = [];
69
+ const creation: string[] = [];
70
+ this.positions.forEach((l, j) => {
71
+ if(j > (i + UPPER_BOUNDARY) || j < (i - UPPER_BOUNDARY)) {
72
+ if(!disposal.includes(l.href)) disposal.push(l.href);
73
+ }
74
+ if(j < (i + LOWER_BOUNDARY) && j > (i - LOWER_BOUNDARY)) {
75
+ if(!creation.includes(l.href)) creation.push(l.href);
76
+ }
77
+ });
78
+ disposal.forEach(async href => {
79
+ if(creation.includes(href)) return;
80
+ if(!this.pool.has(href)) return;
81
+ await this.pool.get(href)?.destroy();
82
+ this.pool.delete(href);
83
+ });
84
+
85
+ // Check if base URL of publication has changed
86
+ if(this.currentBaseURL !== undefined && pub.baseURL !== this.currentBaseURL) {
87
+ // Revoke all blobs
88
+ this.blobs.forEach(v => URL.revokeObjectURL(v));
89
+ this.blobs.clear();
90
+ }
91
+ this.currentBaseURL = pub.baseURL;
92
+
93
+ const creator = async (href: string) => {
94
+ if(this.pool.has(href)) {
95
+ const fm = this.pool.get(href)!;
96
+ if(!this.blobs.has(href)) {
97
+ await fm.destroy();
98
+ this.pool.delete(href);
99
+ } else {
100
+ await fm.load(modules);
101
+ return;
102
+ }
103
+ }
104
+ const itm = pub.readingOrder.findWithHref(href);
105
+ if(!itm) return; // TODO throw?
106
+ if(!this.blobs.has(href)) {
107
+ const blobBuilder = new FrameBlobBuider(pub, this.currentBaseURL || "", itm);
108
+ const blobURL = await blobBuilder.build();
109
+ this.blobs.set(href, blobURL);
110
+ }
111
+
112
+ // Create <iframe>
113
+ const fm = new FrameManager(this.blobs.get(href)!);
114
+ if(href !== newHref) await fm.hide(); // Avoid unecessary hide
115
+ this.container.appendChild(fm.iframe);
116
+ await fm.load(modules);
117
+ this.pool.set(href, fm);
118
+ }
119
+ try {
120
+ await Promise.all(creation.map(href => creator(href)));
121
+ } catch (error) {
122
+ reject(error);
123
+ }
124
+
125
+ // Update current frame
126
+ const newFrame = this.pool.get(newHref)!;
127
+ if(newFrame?.source !== this._currentFrame?.source || force) {
128
+ await this._currentFrame?.hide(); // Hide current frame. It's possible it no longer even exists in the DOM at this point
129
+ if(newFrame) // If user is speeding through the publication, this can get destroyed
130
+ await newFrame.load(modules); // In order to ensure modules match the latest configuration
131
+
132
+ // Update progression if necessary and show the new frame
133
+ const hasProgression = (locator?.locations?.progression ?? 0) > 0;
134
+ if(newFrame) // If user is speeding through the publication, this can get destroyed
135
+ await newFrame.show(hasProgression ? locator.locations.progression! : undefined); // Show/activate new frame
136
+
137
+ this._currentFrame = newFrame;
138
+ }
139
+ resolve();
140
+ });
141
+
142
+ this.inprogress.set(newHref, progressPromise); // Add the job to the in progress map
143
+ await progressPromise; // Wait on the job to finish...
144
+ this.inprogress.delete(newHref); // Delete it from the in progress map!
145
+ }
146
+
147
+ get currentFrames(): (FrameManager | undefined)[] {
148
+ return [this._currentFrame];
149
+ }
150
+
151
+ get currentBounds(): DOMRect {
152
+ const ret = {
153
+ x: 0,
154
+ y: 0,
155
+ width: 0,
156
+ height: 0,
157
+ top: 0,
158
+ right: 0,
159
+ bottom: 0,
160
+ left: 0,
161
+ toJSON() {
162
+ return this;
163
+ },
164
+ };
165
+ this.currentFrames.forEach(f => {
166
+ if(!f) return;
167
+ const b = f.realSize;
168
+ ret.x = Math.min(ret.x, b.x);
169
+ ret.y = Math.min(ret.y, b.y);
170
+ ret.width += b.width; // TODO different in vertical
171
+ ret.height = Math.max(ret.height, b.height);
172
+ ret.top = Math.min(ret.top, b.top);
173
+ ret.right = Math.min(ret.right, b.right);
174
+ ret.bottom = Math.min(ret.bottom, b.bottom);
175
+ ret.left = Math.min(ret.left, b.left);
176
+ });
177
+ return ret as DOMRect;
178
+ }
179
+ }