diff options
author | Andrew Dolgov <[email protected]> | 2021-09-17 21:53:37 +0300 |
---|---|---|
committer | Andrew Dolgov <[email protected]> | 2021-09-17 21:53:37 +0300 |
commit | 4fd9b8f2b5a98bfcde57970b48fed2488a80f356 (patch) | |
tree | 51e0ce9cd61c24916b7d5820ee69e74bd3e76aac /lib/epub.js/src | |
parent | d0cd10f08286be33306336fe8c4cac26ea7ce637 (diff) |
add in master snapshot of epubjs
Diffstat (limited to 'lib/epub.js/src')
38 files changed, 14497 insertions, 0 deletions
diff --git a/lib/epub.js/src/annotations.js b/lib/epub.js/src/annotations.js new file mode 100644 index 0000000..ecbcb56 --- /dev/null +++ b/lib/epub.js/src/annotations.js @@ -0,0 +1,301 @@ +import EventEmitter from "event-emitter"; +import EpubCFI from "./epubcfi"; +import { EVENTS } from "./utils/constants"; + +/** + * Handles managing adding & removing Annotations + * @param {Rendition} rendition + * @class + */ +class Annotations { + + constructor (rendition) { + this.rendition = rendition; + this.highlights = []; + this.underlines = []; + this.marks = []; + this._annotations = {}; + this._annotationsBySectionIndex = {}; + + this.rendition.hooks.render.register(this.inject.bind(this)); + this.rendition.hooks.unloaded.register(this.clear.bind(this)); + } + + /** + * Add an annotation to store + * @param {string} type Type of annotation to add: "highlight", "underline", "mark" + * @param {EpubCFI} cfiRange EpubCFI range to attach annotation to + * @param {object} data Data to assign to annotation + * @param {function} [cb] Callback after annotation is added + * @param {string} className CSS class to assign to annotation + * @param {object} styles CSS styles to assign to annotation + * @returns {Annotation} annotation + */ + add (type, cfiRange, data, cb, className, styles) { + let hash = encodeURI(cfiRange + type); + let cfi = new EpubCFI(cfiRange); + let sectionIndex = cfi.spinePos; + let annotation = new Annotation({ + type, + cfiRange, + data, + sectionIndex, + cb, + className, + styles + }); + + this._annotations[hash] = annotation; + + if (sectionIndex in this._annotationsBySectionIndex) { + this._annotationsBySectionIndex[sectionIndex].push(hash); + } else { + this._annotationsBySectionIndex[sectionIndex] = [hash]; + } + + let views = this.rendition.views(); + + views.forEach( (view) => { + if (annotation.sectionIndex === view.index) { + annotation.attach(view); + } + }); + + return annotation; + } + + /** + * Remove an annotation from store + * @param {EpubCFI} cfiRange EpubCFI range the annotation is attached to + * @param {string} type Type of annotation to add: "highlight", "underline", "mark" + */ + remove (cfiRange, type) { + let hash = encodeURI(cfiRange + type); + + if (hash in this._annotations) { + let annotation = this._annotations[hash]; + + if (type && annotation.type !== type) { + return; + } + + let views = this.rendition.views(); + views.forEach( (view) => { + this._removeFromAnnotationBySectionIndex(annotation.sectionIndex, hash); + if (annotation.sectionIndex === view.index) { + annotation.detach(view); + } + }); + + delete this._annotations[hash]; + } + } + + /** + * Remove an annotations by Section Index + * @private + */ + _removeFromAnnotationBySectionIndex (sectionIndex, hash) { + this._annotationsBySectionIndex[sectionIndex] = this._annotationsAt(sectionIndex).filter(h => h !== hash); + } + + /** + * Get annotations by Section Index + * @private + */ + _annotationsAt (index) { + return this._annotationsBySectionIndex[index]; + } + + + /** + * Add a highlight to the store + * @param {EpubCFI} cfiRange EpubCFI range to attach annotation to + * @param {object} data Data to assign to annotation + * @param {function} cb Callback after annotation is clicked + * @param {string} className CSS class to assign to annotation + * @param {object} styles CSS styles to assign to annotation + */ + highlight (cfiRange, data, cb, className, styles) { + return this.add("highlight", cfiRange, data, cb, className, styles); + } + + /** + * Add a underline to the store + * @param {EpubCFI} cfiRange EpubCFI range to attach annotation to + * @param {object} data Data to assign to annotation + * @param {function} cb Callback after annotation is clicked + * @param {string} className CSS class to assign to annotation + * @param {object} styles CSS styles to assign to annotation + */ + underline (cfiRange, data, cb, className, styles) { + return this.add("underline", cfiRange, data, cb, className, styles); + } + + /** + * Add a mark to the store + * @param {EpubCFI} cfiRange EpubCFI range to attach annotation to + * @param {object} data Data to assign to annotation + * @param {function} cb Callback after annotation is clicked + */ + mark (cfiRange, data, cb) { + return this.add("mark", cfiRange, data, cb); + } + + /** + * iterate over annotations in the store + */ + each () { + return this._annotations.forEach.apply(this._annotations, arguments); + } + + /** + * Hook for injecting annotation into a view + * @param {View} view + * @private + */ + inject (view) { + let sectionIndex = view.index; + if (sectionIndex in this._annotationsBySectionIndex) { + let annotations = this._annotationsBySectionIndex[sectionIndex]; + annotations.forEach((hash) => { + let annotation = this._annotations[hash]; + annotation.attach(view); + }); + } + } + + /** + * Hook for removing annotation from a view + * @param {View} view + * @private + */ + clear (view) { + let sectionIndex = view.index; + if (sectionIndex in this._annotationsBySectionIndex) { + let annotations = this._annotationsBySectionIndex[sectionIndex]; + annotations.forEach((hash) => { + let annotation = this._annotations[hash]; + annotation.detach(view); + }); + } + } + + /** + * [Not Implemented] Show annotations + * @TODO: needs implementation in View + */ + show () { + + } + + /** + * [Not Implemented] Hide annotations + * @TODO: needs implementation in View + */ + hide () { + + } + +} + +/** + * Annotation object + * @class + * @param {object} options + * @param {string} options.type Type of annotation to add: "highlight", "underline", "mark" + * @param {EpubCFI} options.cfiRange EpubCFI range to attach annotation to + * @param {object} options.data Data to assign to annotation + * @param {int} options.sectionIndex Index in the Spine of the Section annotation belongs to + * @param {function} [options.cb] Callback after annotation is clicked + * @param {string} className CSS class to assign to annotation + * @param {object} styles CSS styles to assign to annotation + * @returns {Annotation} annotation + */ +class Annotation { + + constructor ({ + type, + cfiRange, + data, + sectionIndex, + cb, + className, + styles + }) { + this.type = type; + this.cfiRange = cfiRange; + this.data = data; + this.sectionIndex = sectionIndex; + this.mark = undefined; + this.cb = cb; + this.className = className; + this.styles = styles; + } + + /** + * Update stored data + * @param {object} data + */ + update (data) { + this.data = data; + } + + /** + * Add to a view + * @param {View} view + */ + attach (view) { + let {cfiRange, data, type, mark, cb, className, styles} = this; + let result; + + if (type === "highlight") { + result = view.highlight(cfiRange, data, cb, className, styles); + } else if (type === "underline") { + result = view.underline(cfiRange, data, cb, className, styles); + } else if (type === "mark") { + result = view.mark(cfiRange, data, cb); + } + + this.mark = result; + this.emit(EVENTS.ANNOTATION.ATTACH, result); + return result; + } + + /** + * Remove from a view + * @param {View} view + */ + detach (view) { + let {cfiRange, type} = this; + let result; + + if (view) { + if (type === "highlight") { + result = view.unhighlight(cfiRange); + } else if (type === "underline") { + result = view.ununderline(cfiRange); + } else if (type === "mark") { + result = view.unmark(cfiRange); + } + } + + this.mark = undefined; + this.emit(EVENTS.ANNOTATION.DETACH, result); + return result; + } + + /** + * [Not Implemented] Get text of an annotation + * @TODO: needs implementation in contents + */ + text () { + + } + +} + +EventEmitter(Annotation.prototype); + + +export default Annotations diff --git a/lib/epub.js/src/archive.js b/lib/epub.js/src/archive.js new file mode 100644 index 0000000..ea22a81 --- /dev/null +++ b/lib/epub.js/src/archive.js @@ -0,0 +1,255 @@ +import {defer, isXml, parse} from "./utils/core"; +import request from "./utils/request"; +import mime from "./utils/mime"; +import Path from "./utils/path"; +import JSZip from "jszip/dist/jszip"; + +/** + * Handles Unzipping a requesting files from an Epub Archive + * @class + */ +class Archive { + + constructor() { + this.zip = undefined; + this.urlCache = {}; + + this.checkRequirements(); + + } + + /** + * Checks to see if JSZip exists in global namspace, + * Requires JSZip if it isn't there + * @private + */ + checkRequirements(){ + try { + this.zip = new JSZip(); + } catch (e) { + throw new Error("JSZip lib not loaded"); + } + } + + /** + * Open an archive + * @param {binary} input + * @param {boolean} [isBase64] tells JSZip if the input data is base64 encoded + * @return {Promise} zipfile + */ + open(input, isBase64){ + return this.zip.loadAsync(input, {"base64": isBase64}); + } + + /** + * Load and Open an archive + * @param {string} zipUrl + * @param {boolean} [isBase64] tells JSZip if the input data is base64 encoded + * @return {Promise} zipfile + */ + openUrl(zipUrl, isBase64){ + return request(zipUrl, "binary") + .then(function(data){ + return this.zip.loadAsync(data, {"base64": isBase64}); + }.bind(this)); + } + + /** + * Request a url from the archive + * @param {string} url a url to request from the archive + * @param {string} [type] specify the type of the returned result + * @return {Promise<Blob | string | JSON | Document | XMLDocument>} + */ + request(url, type){ + var deferred = new defer(); + var response; + var path = new Path(url); + + // If type isn't set, determine it from the file extension + if(!type) { + type = path.extension; + } + + if(type == "blob"){ + response = this.getBlob(url); + } else { + response = this.getText(url); + } + + if (response) { + response.then(function (r) { + let result = this.handleResponse(r, type); + deferred.resolve(result); + }.bind(this)); + } else { + deferred.reject({ + message : "File not found in the epub: " + url, + stack : new Error().stack + }); + } + return deferred.promise; + } + + /** + * Handle the response from request + * @private + * @param {any} response + * @param {string} [type] + * @return {any} the parsed result + */ + handleResponse(response, type){ + var r; + + if(type == "json") { + r = JSON.parse(response); + } + else + if(isXml(type)) { + r = parse(response, "text/xml"); + } + else + if(type == "xhtml") { + r = parse(response, "application/xhtml+xml"); + } + else + if(type == "html" || type == "htm") { + r = parse(response, "text/html"); + } else { + r = response; + } + + return r; + } + + /** + * Get a Blob from Archive by Url + * @param {string} url + * @param {string} [mimeType] + * @return {Blob} + */ + getBlob(url, mimeType){ + var decodededUrl = window.decodeURIComponent(url.substr(1)); // Remove first slash + var entry = this.zip.file(decodededUrl); + + if(entry) { + mimeType = mimeType || mime.lookup(entry.name); + return entry.async("uint8array").then(function(uint8array) { + return new Blob([uint8array], {type : mimeType}); + }); + } + } + + /** + * Get Text from Archive by Url + * @param {string} url + * @param {string} [encoding] + * @return {string} + */ + getText(url, encoding){ + var decodededUrl = window.decodeURIComponent(url.substr(1)); // Remove first slash + var entry = this.zip.file(decodededUrl); + + if(entry) { + return entry.async("string").then(function(text) { + return text; + }); + } + } + + /** + * Get a base64 encoded result from Archive by Url + * @param {string} url + * @param {string} [mimeType] + * @return {string} base64 encoded + */ + getBase64(url, mimeType){ + var decodededUrl = window.decodeURIComponent(url.substr(1)); // Remove first slash + var entry = this.zip.file(decodededUrl); + + if(entry) { + mimeType = mimeType || mime.lookup(entry.name); + return entry.async("base64").then(function(data) { + return "data:" + mimeType + ";base64," + data; + }); + } + } + + /** + * Create a Url from an unarchived item + * @param {string} url + * @param {object} [options.base64] use base64 encoding or blob url + * @return {Promise} url promise with Url string + */ + createUrl(url, options){ + var deferred = new defer(); + var _URL = window.URL || window.webkitURL || window.mozURL; + var tempUrl; + var response; + var useBase64 = options && options.base64; + + if(url in this.urlCache) { + deferred.resolve(this.urlCache[url]); + return deferred.promise; + } + + if (useBase64) { + response = this.getBase64(url); + + if (response) { + response.then(function(tempUrl) { + + this.urlCache[url] = tempUrl; + deferred.resolve(tempUrl); + + }.bind(this)); + + } + + } else { + + response = this.getBlob(url); + + if (response) { + response.then(function(blob) { + + tempUrl = _URL.createObjectURL(blob); + this.urlCache[url] = tempUrl; + deferred.resolve(tempUrl); + + }.bind(this)); + + } + } + + + if (!response) { + deferred.reject({ + message : "File not found in the epub: " + url, + stack : new Error().stack + }); + } + + return deferred.promise; + } + + /** + * Revoke Temp Url for a achive item + * @param {string} url url of the item in the archive + */ + revokeUrl(url){ + var _URL = window.URL || window.webkitURL || window.mozURL; + var fromCache = this.urlCache[url]; + if(fromCache) _URL.revokeObjectURL(fromCache); + } + + destroy() { + var _URL = window.URL || window.webkitURL || window.mozURL; + for (let fromCache in this.urlCache) { + _URL.revokeObjectURL(fromCache); + } + this.zip = undefined; + this.urlCache = {}; + } +} + +export default Archive; diff --git a/lib/epub.js/src/book.js b/lib/epub.js/src/book.js new file mode 100644 index 0000000..4243bd1 --- /dev/null +++ b/lib/epub.js/src/book.js @@ -0,0 +1,768 @@ +import EventEmitter from "event-emitter"; +import {extend, defer} from "./utils/core"; +import Url from "./utils/url"; +import Path from "./utils/path"; +import Spine from "./spine"; +import Locations from "./locations"; +import Container from "./container"; +import Packaging from "./packaging"; +import Navigation from "./navigation"; +import Resources from "./resources"; +import PageList from "./pagelist"; +import Rendition from "./rendition"; +import Archive from "./archive"; +import request from "./utils/request"; +import EpubCFI from "./epubcfi"; +import Store from "./store"; +import DisplayOptions from "./displayoptions"; +import { EPUBJS_VERSION, EVENTS } from "./utils/constants"; + +const CONTAINER_PATH = "META-INF/container.xml"; +const IBOOKS_DISPLAY_OPTIONS_PATH = "META-INF/com.apple.ibooks.display-options.xml"; + +const INPUT_TYPE = { + BINARY: "binary", + BASE64: "base64", + EPUB: "epub", + OPF: "opf", + MANIFEST: "json", + DIRECTORY: "directory" +}; + +/** + * An Epub representation with methods for the loading, parsing and manipulation + * of its contents. + * @class + * @param {string} [url] + * @param {object} [options] + * @param {method} [options.requestMethod] a request function to use instead of the default + * @param {boolean} [options.requestCredentials=undefined] send the xhr request withCredentials + * @param {object} [options.requestHeaders=undefined] send the xhr request headers + * @param {string} [options.encoding=binary] optional to pass 'binary' or base64' for archived Epubs + * @param {string} [options.replacements=none] use base64, blobUrl, or none for replacing assets in archived Epubs + * @param {method} [options.canonical] optional function to determine canonical urls for a path + * @param {string} [options.openAs] optional string to determine the input type + * @param {string} [options.store=false] cache the contents in local storage, value should be the name of the reader + * @returns {Book} + * @example new Book("/path/to/book.epub", {}) + * @example new Book({ replacements: "blobUrl" }) + */ +class Book { + constructor(url, options) { + // Allow passing just options to the Book + if (typeof(options) === "undefined" && + typeof(url) !== "string" && + url instanceof Blob === false && + url instanceof ArrayBuffer === false) { + options = url; + url = undefined; + } + + this.settings = extend(this.settings || {}, { + requestMethod: undefined, + requestCredentials: undefined, + requestHeaders: undefined, + encoding: undefined, + replacements: undefined, + canonical: undefined, + openAs: undefined, + store: undefined + }); + + extend(this.settings, options); + + + // Promises + this.opening = new defer(); + /** + * @member {promise} opened returns after the book is loaded + * @memberof Book + */ + this.opened = this.opening.promise; + this.isOpen = false; + + this.loading = { + manifest: new defer(), + spine: new defer(), + metadata: new defer(), + cover: new defer(), + navigation: new defer(), + pageList: new defer(), + resources: new defer(), + displayOptions: new defer() + }; + + this.loaded = { + manifest: this.loading.manifest.promise, + spine: this.loading.spine.promise, + metadata: this.loading.metadata.promise, + cover: this.loading.cover.promise, + navigation: this.loading.navigation.promise, + pageList: this.loading.pageList.promise, + resources: this.loading.resources.promise, + displayOptions: this.loading.displayOptions.promise + }; + + /** + * @member {promise} ready returns after the book is loaded and parsed + * @memberof Book + * @private + */ + this.ready = Promise.all([ + this.loaded.manifest, + this.loaded.spine, + this.loaded.metadata, + this.loaded.cover, + this.loaded.navigation, + this.loaded.resources, + this.loaded.displayOptions + ]); + + + // Queue for methods used before opening + this.isRendered = false; + // this._q = queue(this); + + /** + * @member {method} request + * @memberof Book + * @private + */ + this.request = this.settings.requestMethod || request; + + /** + * @member {Spine} spine + * @memberof Book + */ + this.spine = new Spine(); + + /** + * @member {Locations} locations + * @memberof Book + */ + this.locations = new Locations(this.spine, this.load.bind(this)); + + /** + * @member {Navigation} navigation + * @memberof Book + */ + this.navigation = undefined; + + /** + * @member {PageList} pagelist + * @memberof Book + */ + this.pageList = undefined; + + /** + * @member {Url} url + * @memberof Book + * @private + */ + this.url = undefined; + + /** + * @member {Path} path + * @memberof Book + * @private + */ + this.path = undefined; + + /** + * @member {boolean} archived + * @memberof Book + * @private + */ + this.archived = false; + + /** + * @member {Archive} archive + * @memberof Book + * @private + */ + this.archive = undefined; + + /** + * @member {Store} storage + * @memberof Book + * @private + */ + this.storage = undefined; + + /** + * @member {Resources} resources + * @memberof Book + * @private + */ + this.resources = undefined; + + /** + * @member {Rendition} rendition + * @memberof Book + * @private + */ + this.rendition = undefined; + + /** + * @member {Container} container + * @memberof Book + * @private + */ + this.container = undefined; + + /** + * @member {Packaging} packaging + * @memberof Book + * @private + */ + this.packaging = undefined; + + /** + * @member {DisplayOptions} displayOptions + * @memberof DisplayOptions + * @private + */ + this.displayOptions = undefined; + + // this.toc = undefined; + if (this.settings.store) { + this.store(this.settings.store); + } + + if(url) { + this.open(url, this.settings.openAs).catch((error) => { + var err = new Error("Cannot load book at "+ url ); + this.emit(EVENTS.BOOK.OPEN_FAILED, err); + }); + } + } + + /** + * Open a epub or url + * @param {string | ArrayBuffer} input Url, Path or ArrayBuffer + * @param {string} [what="binary", "base64", "epub", "opf", "json", "directory"] force opening as a certain type + * @returns {Promise} of when the book has been loaded + * @example book.open("/path/to/book.epub") + */ + open(input, what) { + var opening; + var type = what || this.determineType(input); + + if (type === INPUT_TYPE.BINARY) { + this.archived = true; + this.url = new Url("/", ""); + opening = this.openEpub(input); + } else if (type === INPUT_TYPE.BASE64) { + this.archived = true; + this.url = new Url("/", ""); + opening = this.openEpub(input, type); + } else if (type === INPUT_TYPE.EPUB) { + this.archived = true; + this.url = new Url("/", ""); + opening = this.request(input, "binary", this.settings.requestCredentials, this.settings.requestHeaders) + .then(this.openEpub.bind(this)); + } else if(type == INPUT_TYPE.OPF) { + this.url = new Url(input); + opening = this.openPackaging(this.url.Path.toString()); + } else if(type == INPUT_TYPE.MANIFEST) { + this.url = new Url(input); + opening = this.openManifest(this.url.Path.toString()); + } else { + this.url = new Url(input); + opening = this.openContainer(CONTAINER_PATH) + .then(this.openPackaging.bind(this)); + } + + return opening; + } + + /** + * Open an archived epub + * @private + * @param {binary} data + * @param {string} [encoding] + * @return {Promise} + */ + openEpub(data, encoding) { + return this.unarchive(data, encoding || this.settings.encoding) + .then(() => { + return this.openContainer(CONTAINER_PATH); + }) + .then((packagePath) => { + return this.openPackaging(packagePath); + }); + } + + /** + * Open the epub container + * @private + * @param {string} url + * @return {string} packagePath + */ + openContainer(url) { + return this.load(url) + .then((xml) => { + this.container = new Container(xml); + return this.resolve(this.container.packagePath); + }); + } + + /** + * Open the Open Packaging Format Xml + * @private + * @param {string} url + * @return {Promise} + */ + openPackaging(url) { + this.path = new Path(url); + return this.load(url) + .then((xml) => { + this.packaging = new Packaging(xml); + return this.unpack(this.packaging); + }); + } + + /** + * Open the manifest JSON + * @private + * @param {string} url + * @return {Promise} + */ + openManifest(url) { + this.path = new Path(url); + return this.load(url) + .then((json) => { + this.packaging = new Packaging(); + this.packaging.load(json); + return this.unpack(this.packaging); + }); + } + + /** + * Load a resource from the Book + * @param {string} path path to the resource to load + * @return {Promise} returns a promise with the requested resource + */ + load(path) { + var resolved = this.resolve(path); + if(this.archived) { + return this.archive.request(resolved); + } else { + return this.request(resolved, null, this.settings.requestCredentials, this.settings.requestHeaders); + } + } + + /** + * Resolve a path to it's absolute position in the Book + * @param {string} path + * @param {boolean} [absolute] force resolving the full URL + * @return {string} the resolved path string + */ + resolve(path, absolute) { + if (!path) { + return; + } + var resolved = path; + var isAbsolute = (path.indexOf("://") > -1); + + if (isAbsolute) { + return path; + } + + if (this.path) { + resolved = this.path.resolve(path); + } + + if(absolute != false && this.url) { + resolved = this.url.resolve(resolved); + } + + return resolved; + } + + /** + * Get a canonical link to a path + * @param {string} path + * @return {string} the canonical path string + */ + canonical(path) { + var url = path; + + if (!path) { + return ""; + } + + if (this.settings.canonical) { + url = this.settings.canonical(path); + } else { + url = this.resolve(path, true); + } + + return url; + } + + /** + * Determine the type of they input passed to open + * @private + * @param {string} input + * @return {string} binary | directory | epub | opf + */ + determineType(input) { + var url; + var path; + var extension; + + if (this.settings.encoding === "base64") { + return INPUT_TYPE.BASE64; + } + + if(typeof(input) != "string") { + return INPUT_TYPE.BINARY; + } + + url = new Url(input); + path = url.path(); + extension = path.extension; + + // If there's a search string, remove it before determining type + if (extension) { + extension = extension.replace(/\?.*$/, ""); + } + + if (!extension) { + return INPUT_TYPE.DIRECTORY; + } + + if(extension === "epub"){ + return INPUT_TYPE.EPUB; + } + + if(extension === "opf"){ + return INPUT_TYPE.OPF; + } + + if(extension === "json"){ + return INPUT_TYPE.MANIFEST; + } + } + + + /** + * unpack the contents of the Books packaging + * @private + * @param {Packaging} packaging object + */ + unpack(packaging) { + this.package = packaging; //TODO: deprecated this + + if (this.packaging.metadata.layout === "") { + // rendition:layout not set - check display options if book is pre-paginated + this.load(this.url.resolve(IBOOKS_DISPLAY_OPTIONS_PATH)).then((xml) => { + this.displayOptions = new DisplayOptions(xml); + this.loading.displayOptions.resolve(this.displayOptions); + }).catch((err) => { + this.displayOptions = new DisplayOptions(); + this.loading.displayOptions.resolve(this.displayOptions); + }); + } else { + this.displayOptions = new DisplayOptions(); + this.loading.displayOptions.resolve(this.displayOptions); + } + + this.spine.unpack(this.packaging, this.resolve.bind(this), this.canonical.bind(this)); + + this.resources = new Resources(this.packaging.manifest, { + archive: this.archive, + resolver: this.resolve.bind(this), + request: this.request.bind(this), + replacements: this.settings.replacements || (this.archived ? "blobUrl" : "base64") + }); + + this.loadNavigation(this.packaging).then(() => { + // this.toc = this.navigation.toc; + this.loading.navigation.resolve(this.navigation); + }); + + if (this.packaging.coverPath) { + this.cover = this.resolve(this.packaging.coverPath); + } + // Resolve promises + this.loading.manifest.resolve(this.packaging.manifest); + this.loading.metadata.resolve(this.packaging.metadata); + this.loading.spine.resolve(this.spine); + this.loading.cover.resolve(this.cover); + this.loading.resources.resolve(this.resources); + this.loading.pageList.resolve(this.pageList); + + this.isOpen = true; + + if(this.archived || this.settings.replacements && this.settings.replacements != "none") { + this.replacements().then(() => { + this.loaded.displayOptions.then(() => { + this.opening.resolve(this); + }); + }) + .catch((err) => { + console.error(err); + }); + } else { + // Resolve book opened promise + this.loaded.displayOptions.then(() => { + this.opening.resolve(this); + }); + } + + } + + /** + * Load Navigation and PageList from package + * @private + * @param {Packaging} packaging + */ + loadNavigation(packaging) { + let navPath = packaging.navPath || packaging.ncxPath; + let toc = packaging.toc; + + // From json manifest + if (toc) { + return new Promise((resolve, reject) => { + this.navigation = new Navigation(toc); + + if (packaging.pageList) { + this.pageList = new PageList(packaging.pageList); // TODO: handle page lists from Manifest + } + + resolve(this.navigation); + }); + } + + if (!navPath) { + return new Promise((resolve, reject) => { + this.navigation = new Navigation(); + this.pageList = new PageList(); + + resolve(this.navigation); + }); + } + + return this.load(navPath, "xml") + .then((xml) => { + this.navigation = new Navigation(xml); + this.pageList = new PageList(xml); + return this.navigation; + }); + } + + /** + * Gets a Section of the Book from the Spine + * Alias for `book.spine.get` + * @param {string} target + * @return {Section} + */ + section(target) { + return this.spine.get(target); + } + + /** + * Sugar to render a book to an element + * @param {element | string} element element or string to add a rendition to + * @param {object} [options] + * @return {Rendition} + */ + renderTo(element, options) { + this.rendition = new Rendition(this, options); + this.rendition.attachTo(element); + + return this.rendition; + } + + /** + * Set if request should use withCredentials + * @param {boolean} credentials + */ + setRequestCredentials(credentials) { + this.settings.requestCredentials = credentials; + } + + /** + * Set headers request should use + * @param {object} headers + */ + setRequestHeaders(headers) { + this.settings.requestHeaders = headers; + } + + /** + * Unarchive a zipped epub + * @private + * @param {binary} input epub data + * @param {string} [encoding] + * @return {Archive} + */ + unarchive(input, encoding) { + this.archive = new Archive(); + return this.archive.open(input, encoding); + } + + /** + * Store the epubs contents + * @private + * @param {binary} input epub data + * @param {string} [encoding] + * @return {Store} + */ + store(name) { + // Use "blobUrl" or "base64" for replacements + let replacementsSetting = this.settings.replacements && this.settings.replacements !== "none"; + // Save original url + let originalUrl = this.url; + // Save original request method + let requester = this.settings.requestMethod || request.bind(this); + // Create new Store + this.storage = new Store(name, requester, this.resolve.bind(this)); + // Replace request method to go through store + this.request = this.storage.request.bind(this.storage); + + this.opened.then(() => { + if (this.archived) { + this.storage.requester = this.archive.request.bind(this.archive); + } + // Substitute hook + let substituteResources = (output, section) => { + section.output = this.resources.substitute(output, section.url); + }; + + // Set to use replacements + this.resources.settings.replacements = replacementsSetting || "blobUrl"; + // Create replacement urls + this.resources.replacements(). + then(() => { + return this.resources.replaceCss(); + }); + + this.storage.on("offline", () => { + // Remove url to use relative resolving for hrefs + this.url = new Url("/", ""); + // Add hook to replace resources in contents + this.spine.hooks.serialize.register(substituteResources); + }); + + this.storage.on("online", () => { + // Restore original url + this.url = originalUrl; + // Remove hook + this.spine.hooks.serialize.deregister(substituteResources); + }); + + }); + + return this.storage; + } + + /** + * Get the cover url + * @return {Promise<?string>} coverUrl + */ + coverUrl() { + return this.loaded.cover.then(() => { + if (!this.cover) { + return null; + } + + if (this.archived) { + return this.archive.createUrl(this.cover); + } else { + return this.cover; + } + }); + } + + /** + * Load replacement urls + * @private + * @return {Promise} completed loading urls + */ + replacements() { + this.spine.hooks.serialize.register((output, section) => { + section.output = this.resources.substitute(output, section.url); + }); + + return this.resources.replacements(). + then(() => { + return this.resources.replaceCss(); + }); + } + + /** + * Find a DOM Range for a given CFI Range + * @param {EpubCFI} cfiRange a epub cfi range + * @return {Promise} + */ + getRange(cfiRange) { + var cfi = new EpubCFI(cfiRange); + var item = this.spine.get(cfi.spinePos); + var _request = this.load.bind(this); + if (!item) { + return new Promise((resolve, reject) => { + reject("CFI could not be found"); + }); + } + return item.load(_request).then(function (contents) { + var range = cfi.toRange(item.document); + return range; + }); + } + + /** + * Generates the Book Key using the identifer in the manifest or other string provided + * @param {string} [identifier] to use instead of metadata identifier + * @return {string} key + */ + key(identifier) { + var ident = identifier || this.packaging.metadata.identifier || this.url.filename; + return `epubjs:${EPUBJS_VERSION}:${ident}`; + } + + /** + * Destroy the Book and all associated objects + */ + destroy() { + this.opened = undefined; + this.loading = undefined; + this.loaded = undefined; + this.ready = undefined; + + this.isOpen = false; + this.isRendered = false; + + this.spine && this.spine.destroy(); + this.locations && this.locations.destroy(); + this.pageList && this.pageList.destroy(); + this.archive && this.archive.destroy(); + this.resources && this.resources.destroy(); + this.container && this.container.destroy(); + this.packaging && this.packaging.destroy(); + this.rendition && this.rendition.destroy(); + this.displayOptions && this.displayOptions.destroy(); + + this.spine = undefined; + this.locations = undefined; + this.pageList = undefined; + this.archive = undefined; + this.resources = undefined; + this.container = undefined; + this.packaging = undefined; + this.rendition = undefined; + + this.navigation = undefined; + this.url = undefined; + this.path = undefined; + this.archived = false; + } + +} + +//-- Enable binding events to book +EventEmitter(Book.prototype); + +export default Book; diff --git a/lib/epub.js/src/container.js b/lib/epub.js/src/container.js new file mode 100644 index 0000000..f3a214f --- /dev/null +++ b/lib/epub.js/src/container.js @@ -0,0 +1,50 @@ +import path from "path-webpack"; +import {qs} from "./utils/core"; + +/** + * Handles Parsing and Accessing an Epub Container + * @class + * @param {document} [containerDocument] xml document + */ +class Container { + constructor(containerDocument) { + this.packagePath = ''; + this.directory = ''; + this.encoding = ''; + + if (containerDocument) { + this.parse(containerDocument); + } + } + + /** + * Parse the Container XML + * @param {document} containerDocument + */ + parse(containerDocument){ + //-- <rootfile full-path="OPS/package.opf" media-type="application/oebps-package+xml"/> + var rootfile; + + if(!containerDocument) { + throw new Error("Container File Not Found"); + } + + rootfile = qs(containerDocument, "rootfile"); + + if(!rootfile) { + throw new Error("No RootFile Found"); + } + + this.packagePath = rootfile.getAttribute("full-path"); + this.directory = path.dirname(this.packagePath); + this.encoding = containerDocument.xmlEncoding; + } + + destroy() { + this.packagePath = undefined; + this.directory = undefined; + this.encoding = undefined; + } +} + +export default Container; diff --git a/lib/epub.js/src/contents.js b/lib/epub.js/src/contents.js new file mode 100644 index 0000000..3effe72 --- /dev/null +++ b/lib/epub.js/src/contents.js @@ -0,0 +1,1264 @@ +import EventEmitter from "event-emitter"; +import {isNumber, prefixed, borders, defaults} from "./utils/core"; +import EpubCFI from "./epubcfi"; +import Mapping from "./mapping"; +import {replaceLinks} from "./utils/replacements"; +import { EPUBJS_VERSION, EVENTS, DOM_EVENTS } from "./utils/constants"; + +const hasNavigator = typeof (navigator) !== "undefined"; + +const isChrome = hasNavigator && /Chrome/.test(navigator.userAgent); +const isWebkit = hasNavigator && !isChrome && /AppleWebKit/.test(navigator.userAgent); + +const ELEMENT_NODE = 1; +const TEXT_NODE = 3; + +/** + * Handles DOM manipulation, queries and events for View contents + * @class + * @param {document} doc Document + * @param {element} content Parent Element (typically Body) + * @param {string} cfiBase Section component of CFIs + * @param {number} sectionIndex Index in Spine of Conntent's Section + */ +class Contents { + constructor(doc, content, cfiBase, sectionIndex) { + // Blank Cfi for Parsing + this.epubcfi = new EpubCFI(); + + this.document = doc; + this.documentElement = this.document.documentElement; + this.content = content || this.document.body; + this.window = this.document.defaultView; + + this._size = { + width: 0, + height: 0 + }; + + this.sectionIndex = sectionIndex || 0; + this.cfiBase = cfiBase || ""; + + this.epubReadingSystem("epub.js", EPUBJS_VERSION); + this.called = 0; + this.active = true; + this.listeners(); + } + + /** + * Get DOM events that are listened for and passed along + */ + static get listenedEvents() { + return DOM_EVENTS; + } + + /** + * Get or Set width + * @param {number} [w] + * @returns {number} width + */ + width(w) { + // var frame = this.documentElement; + var frame = this.content; + + if (w && isNumber(w)) { + w = w + "px"; + } + + if (w) { + frame.style.width = w; + // this.content.style.width = w; + } + + return parseInt(this.window.getComputedStyle(frame)["width"]); + + + } + + /** + * Get or Set height + * @param {number} [h] + * @returns {number} height + */ + height(h) { + // var frame = this.documentElement; + var frame = this.content; + + if (h && isNumber(h)) { + h = h + "px"; + } + + if (h) { + frame.style.height = h; + // this.content.style.height = h; + } + + return parseInt(this.window.getComputedStyle(frame)["height"]); + + } + + /** + * Get or Set width of the contents + * @param {number} [w] + * @returns {number} width + */ + contentWidth(w) { + + var content = this.content || this.document.body; + + if (w && isNumber(w)) { + w = w + "px"; + } + + if (w) { + content.style.width = w; + } + + return parseInt(this.window.getComputedStyle(content)["width"]); + + + } + + /** + * Get or Set height of the contents + * @param {number} [h] + * @returns {number} height + */ + contentHeight(h) { + + var content = this.content || this.document.body; + + if (h && isNumber(h)) { + h = h + "px"; + } + + if (h) { + content.style.height = h; + } + + return parseInt(this.window.getComputedStyle(content)["height"]); + + } + + /** + * Get the width of the text using Range + * @returns {number} width + */ + textWidth() { + let rect; + let width; + let range = this.document.createRange(); + let content = this.content || this.document.body; + let border = borders(content); + + // Select the contents of frame + range.selectNodeContents(content); + + // get the width of the text content + rect = range.getBoundingClientRect(); + width = rect.width; + + if (border && border.width) { + width += border.width; + } + + return Math.round(width); + } + + /** + * Get the height of the text using Range + * @returns {number} height + */ + textHeight() { + let rect; + let height; + let range = this.document.createRange(); + let content = this.content || this.document.body; + + range.selectNodeContents(content); + + rect = range.getBoundingClientRect(); + height = rect.bottom; + + return Math.round(height); + } + + /** + * Get documentElement scrollWidth + * @returns {number} width + */ + scrollWidth() { + var width = this.documentElement.scrollWidth; + + return width; + } + + /** + * Get documentElement scrollHeight + * @returns {number} height + */ + scrollHeight() { + var height = this.documentElement.scrollHeight; + + return height; + } + + /** + * Set overflow css style of the contents + * @param {string} [overflow] + */ + overflow(overflow) { + + if (overflow) { + this.documentElement.style.overflow = overflow; + } + + return this.window.getComputedStyle(this.documentElement)["overflow"]; + } + + /** + * Set overflowX css style of the documentElement + * @param {string} [overflow] + */ + overflowX(overflow) { + + if (overflow) { + this.documentElement.style.overflowX = overflow; + } + + return this.window.getComputedStyle(this.documentElement)["overflowX"]; + } + + /** + * Set overflowY css style of the documentElement + * @param {string} [overflow] + */ + overflowY(overflow) { + + if (overflow) { + this.documentElement.style.overflowY = overflow; + } + + return this.window.getComputedStyle(this.documentElement)["overflowY"]; + } + + /** + * Set Css styles on the contents element (typically Body) + * @param {string} property + * @param {string} value + * @param {boolean} [priority] set as "important" + */ + css(property, value, priority) { + var content = this.content || this.document.body; + + if (value) { + content.style.setProperty(property, value, priority ? "important" : ""); + } else { + content.style.removeProperty(property); + } + + return this.window.getComputedStyle(content)[property]; + } + + /** + * Get or Set the viewport element + * @param {object} [options] + * @param {string} [options.width] + * @param {string} [options.height] + * @param {string} [options.scale] + * @param {string} [options.minimum] + * @param {string} [options.maximum] + * @param {string} [options.scalable] + */ + viewport(options) { + var _width, _height, _scale, _minimum, _maximum, _scalable; + // var width, height, scale, minimum, maximum, scalable; + var $viewport = this.document.querySelector("meta[name='viewport']"); + var parsed = { + "width": undefined, + "height": undefined, + "scale": undefined, + "minimum": undefined, + "maximum": undefined, + "scalable": undefined + }; + var newContent = []; + var settings = {}; + + /* + * check for the viewport size + * <meta name="viewport" content="width=1024,height=697" /> + */ + if($viewport && $viewport.hasAttribute("content")) { + let content = $viewport.getAttribute("content"); + let _width = content.match(/width\s*=\s*([^,]*)/); + let _height = content.match(/height\s*=\s*([^,]*)/); + let _scale = content.match(/initial-scale\s*=\s*([^,]*)/); + let _minimum = content.match(/minimum-scale\s*=\s*([^,]*)/); + let _maximum = content.match(/maximum-scale\s*=\s*([^,]*)/); + let _scalable = content.match(/user-scalable\s*=\s*([^,]*)/); + + if(_width && _width.length && typeof _width[1] !== "undefined"){ + parsed.width = _width[1]; + } + if(_height && _height.length && typeof _height[1] !== "undefined"){ + parsed.height = _height[1]; + } + if(_scale && _scale.length && typeof _scale[1] !== "undefined"){ + parsed.scale = _scale[1]; + } + if(_minimum && _minimum.length && typeof _minimum[1] !== "undefined"){ + parsed.minimum = _minimum[1]; + } + if(_maximum && _maximum.length && typeof _maximum[1] !== "undefined"){ + parsed.maximum = _maximum[1]; + } + if(_scalable && _scalable.length && typeof _scalable[1] !== "undefined"){ + parsed.scalable = _scalable[1]; + } + } + + settings = defaults(options || {}, parsed); + + if (options) { + if (settings.width) { + newContent.push("width=" + settings.width); + } + + if (settings.height) { + newContent.push("height=" + settings.height); + } + + if (settings.scale) { + newContent.push("initial-scale=" + settings.scale); + } + + if (settings.scalable === "no") { + newContent.push("minimum-scale=" + settings.scale); + newContent.push("maximum-scale=" + settings.scale); + newContent.push("user-scalable=" + settings.scalable); + } else { + + if (settings.scalable) { + newContent.push("user-scalable=" + settings.scalable); + } + + if (settings.minimum) { + newContent.push("minimum-scale=" + settings.minimum); + } + + if (settings.maximum) { + newContent.push("minimum-scale=" + settings.maximum); + } + } + + if (!$viewport) { + $viewport = this.document.createElement("meta"); + $viewport.setAttribute("name", "viewport"); + this.document.querySelector("head").appendChild($viewport); + } + + $viewport.setAttribute("content", newContent.join(", ")); + + this.window.scrollTo(0, 0); + } + + + return settings; + } + + /** + * Event emitter for when the contents has expanded + * @private + */ + expand() { + this.emit(EVENTS.CONTENTS.EXPAND); + } + + /** + * Add DOM listeners + * @private + */ + listeners() { + this.imageLoadListeners(); + + this.mediaQueryListeners(); + + // this.fontLoadListeners(); + + this.addEventListeners(); + + this.addSelectionListeners(); + + // this.transitionListeners(); + + if (typeof ResizeObserver === "undefined") { + this.resizeListeners(); + this.visibilityListeners(); + } else { + this.resizeObservers(); + } + + // this.mutationObservers(); + + this.linksHandler(); + } + + /** + * Remove DOM listeners + * @private + */ + removeListeners() { + + this.removeEventListeners(); + + this.removeSelectionListeners(); + + if (this.observer) { + this.observer.disconnect(); + } + + clearTimeout(this.expanding); + } + + /** + * Check if size of contents has changed and + * emit 'resize' event if it has. + * @private + */ + resizeCheck() { + let width = this.textWidth(); + let height = this.textHeight(); + + if (width != this._size.width || height != this._size.height) { + + this._size = { + width: width, + height: height + }; + + this.onResize && this.onResize(this._size); + this.emit(EVENTS.CONTENTS.RESIZE, this._size); + } + } + + /** + * Poll for resize detection + * @private + */ + resizeListeners() { + var width, height; + // Test size again + clearTimeout(this.expanding); + requestAnimationFrame(this.resizeCheck.bind(this)); + this.expanding = setTimeout(this.resizeListeners.bind(this), 350); + } + + /** + * Listen for visibility of tab to change + * @private + */ + visibilityListeners() { + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible" && this.active === false) { + this.active = true; + this.resizeListeners(); + } else { + this.active = false; + clearTimeout(this.expanding); + } + }); + } + + /** + * Use css transitions to detect resize + * @private + */ + transitionListeners() { + let body = this.content; + + body.style['transitionProperty'] = "font, font-size, font-size-adjust, font-stretch, font-variation-settings, font-weight, width, height"; + body.style['transitionDuration'] = "0.001ms"; + body.style['transitionTimingFunction'] = "linear"; + body.style['transitionDelay'] = "0"; + + this._resizeCheck = this.resizeCheck.bind(this); + this.document.addEventListener('transitionend', this._resizeCheck); + } + + /** + * Listen for media query changes and emit 'expand' event + * Adapted from: https://github.com/tylergaw/media-query-events/blob/master/js/mq-events.js + * @private + */ + mediaQueryListeners() { + var sheets = this.document.styleSheets; + var mediaChangeHandler = function(m){ + if(m.matches && !this._expanding) { + setTimeout(this.expand.bind(this), 1); + } + }.bind(this); + + for (var i = 0; i < sheets.length; i += 1) { + var rules; + // Firefox errors if we access cssRules cross-domain + try { + rules = sheets[i].cssRules; + } catch (e) { + return; + } + if(!rules) return; // Stylesheets changed + for (var j = 0; j < rules.length; j += 1) { + //if (rules[j].constructor === CSSMediaRule) { + if(rules[j].media){ + var mql = this.window.matchMedia(rules[j].media.mediaText); + mql.addListener(mediaChangeHandler); + //mql.onchange = mediaChangeHandler; + } + } + } + } + + /** + * Use ResizeObserver to listen for changes in the DOM and check for resize + * @private + */ + resizeObservers() { + // create an observer instance + this.observer = new ResizeObserver((e) => { + requestAnimationFrame(this.resizeCheck.bind(this)); + }); + + // pass in the target node + this.observer.observe(this.document.documentElement); + } + + /** + * Use MutationObserver to listen for changes in the DOM and check for resize + * @private + */ + mutationObservers() { + // create an observer instance + this.observer = new MutationObserver((mutations) => { + this.resizeCheck(); + }); + + // configuration of the observer: + let config = { attributes: true, childList: true, characterData: true, subtree: true }; + + // pass in the target node, as well as the observer options + this.observer.observe(this.document, config); + } + + /** + * Test if images are loaded or add listener for when they load + * @private + */ + imageLoadListeners() { + var images = this.document.querySelectorAll("img"); + var img; + for (var i = 0; i < images.length; i++) { + img = images[i]; + + if (typeof img.naturalWidth !== "undefined" && + img.naturalWidth === 0) { + img.onload = this.expand.bind(this); + } + } + } + + /** + * Listen for font load and check for resize when loaded + * @private + */ + fontLoadListeners() { + if (!this.document || !this.document.fonts) { + return; + } + + this.document.fonts.ready.then(function () { + this.resizeCheck(); + }.bind(this)); + + } + + /** + * Get the documentElement + * @returns {element} documentElement + */ + root() { + if(!this.document) return null; + return this.document.documentElement; + } + + /** + * Get the location offset of a EpubCFI or an #id + * @param {string | EpubCFI} target + * @param {string} [ignoreClass] for the cfi + * @returns { {left: Number, top: Number } + */ + locationOf(target, ignoreClass) { + var position; + var targetPos = {"left": 0, "top": 0}; + + if(!this.document) return targetPos; + + if(this.epubcfi.isCfiString(target)) { + let range = new EpubCFI(target).toRange(this.document, ignoreClass); + + if(range) { + try { + if (!range.endContainer || + (range.startContainer == range.endContainer + && range.startOffset == range.endOffset)) { + // If the end for the range is not set, it results in collapsed becoming + // true. This in turn leads to inconsistent behaviour when calling + // getBoundingRect. Wrong bounds lead to the wrong page being displayed. + // https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/15684911/ + let pos = range.startContainer.textContent.indexOf(" ", range.startOffset); + if (pos == -1) { + pos = range.startContainer.textContent.length; + } + range.setEnd(range.startContainer, pos); + } + } catch (e) { + console.error("setting end offset to start container length failed", e); + } + + if (range.startContainer.nodeType === Node.ELEMENT_NODE) { + position = range.startContainer.getBoundingClientRect(); + targetPos.left = position.left; + targetPos.top = position.top; + } else { + // Webkit does not handle collapsed range bounds correctly + // https://bugs.webkit.org/show_bug.cgi?id=138949 + + // Construct a new non-collapsed range + if (isWebkit) { + let container = range.startContainer; + let newRange = new Range(); + try { + if (container.nodeType === ELEMENT_NODE) { + position = container.getBoundingClientRect(); + } else if (range.startOffset + 2 < container.length) { + newRange.setStart(container, range.startOffset); + newRange.setEnd(container, range.startOffset + 2); + position = newRange.getBoundingClientRect(); + } else if (range.startOffset - 2 > 0) { + newRange.setStart(container, range.startOffset - 2); + newRange.setEnd(container, range.startOffset); + position = newRange.getBoundingClientRect(); + } else { // empty, return the parent element + position = container.parentNode.getBoundingClientRect(); + } + } catch (e) { + console.error(e, e.stack); + } + } else { + position = range.getBoundingClientRect(); + } + } + } + + } else if(typeof target === "string" && + target.indexOf("#") > -1) { + + let id = target.substring(target.indexOf("#")+1); + let el = this.document.getElementById(id); + if(el) { + if (isWebkit) { + // Webkit reports incorrect bounding rects in Columns + let newRange = new Range(); + newRange.selectNode(el); + position = newRange.getBoundingClientRect(); + } else { + position = el.getBoundingClientRect(); + } + } + } + + if (position) { + targetPos.left = position.left; + targetPos.top = position.top; + } + + return targetPos; + } + + /** + * Append a stylesheet link to the document head + * @param {string} src url + */ + addStylesheet(src) { + return new Promise(function(resolve, reject){ + var $stylesheet; + var ready = false; + + if(!this.document) { + resolve(false); + return; + } + + // Check if link already exists + $stylesheet = this.document.querySelector("link[href='"+src+"']"); + if ($stylesheet) { + resolve(true); + return; // already present + } + + $stylesheet = this.document.createElement("link"); + $stylesheet.type = "text/css"; + $stylesheet.rel = "stylesheet"; + $stylesheet.href = src; + $stylesheet.onload = $stylesheet.onreadystatechange = function() { + if ( !ready && (!this.readyState || this.readyState == "complete") ) { + ready = true; + // Let apply + setTimeout(() => { + resolve(true); + }, 1); + } + }; + + this.document.head.appendChild($stylesheet); + + }.bind(this)); + } + + _getStylesheetNode(key) { + var styleEl; + key = "epubjs-inserted-css-" + (key || ''); + + if(!this.document) return false; + + // Check if link already exists + styleEl = this.document.getElementById(key); + if (!styleEl) { + styleEl = this.document.createElement("style"); + styleEl.id = key; + // Append style element to head + this.document.head.appendChild(styleEl); + } + return styleEl; + } + + /** + * Append stylesheet css + * @param {string} serializedCss + * @param {string} key If the key is the same, the CSS will be replaced instead of inserted + */ + addStylesheetCss(serializedCss, key) { + if(!this.document || !serializedCss) return false; + + var styleEl; + styleEl = this._getStylesheetNode(key); + styleEl.innerHTML = serializedCss; + + return true; + } + + /** + * Append stylesheet rules to a generate stylesheet + * Array: https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet/insertRule + * Object: https://github.com/desirable-objects/json-to-css + * @param {array | object} rules + * @param {string} key If the key is the same, the CSS will be replaced instead of inserted + */ + addStylesheetRules(rules, key) { + var styleSheet; + + if(!this.document || !rules || rules.length === 0) return; + + // Grab style sheet + styleSheet = this._getStylesheetNode(key).sheet; + + if (Object.prototype.toString.call(rules) === "[object Array]") { + for (var i = 0, rl = rules.length; i < rl; i++) { + var j = 1, rule = rules[i], selector = rules[i][0], propStr = ""; + // If the second argument of a rule is an array of arrays, correct our variables. + if (Object.prototype.toString.call(rule[1][0]) === "[object Array]") { + rule = rule[1]; + j = 0; + } + + for (var pl = rule.length; j < pl; j++) { + var prop = rule[j]; + propStr += prop[0] + ":" + prop[1] + (prop[2] ? " !important" : "") + ";\n"; + } + + // Insert CSS Rule + styleSheet.insertRule(selector + "{" + propStr + "}", styleSheet.cssRules.length); + } + } else { + const selectors = Object.keys(rules); + selectors.forEach((selector) => { + const definition = rules[selector]; + if (Array.isArray(definition)) { + definition.forEach((item) => { + const _rules = Object.keys(item); + const result = _rules.map((rule) => { + return `${rule}:${item[rule]}`; + }).join(';'); + styleSheet.insertRule(`${selector}{${result}}`, styleSheet.cssRules.length); + }); + } else { + const _rules = Object.keys(definition); + const result = _rules.map((rule) => { + return `${rule}:${definition[rule]}`; + }).join(';'); + styleSheet.insertRule(`${selector}{${result}}`, styleSheet.cssRules.length); + } + }); + } + } + + /** + * Append a script tag to the document head + * @param {string} src url + * @returns {Promise} loaded + */ + addScript(src) { + + return new Promise(function(resolve, reject){ + var $script; + var ready = false; + + if(!this.document) { + resolve(false); + return; + } + + $script = this.document.createElement("script"); + $script.type = "text/javascript"; + $script.async = true; + $script.src = src; + $script.onload = $script.onreadystatechange = function() { + if ( !ready && (!this.readyState || this.readyState == "complete") ) { + ready = true; + setTimeout(function(){ + resolve(true); + }, 1); + } + }; + + this.document.head.appendChild($script); + + }.bind(this)); + } + + /** + * Add a class to the contents container + * @param {string} className + */ + addClass(className) { + var content; + + if(!this.document) return; + + content = this.content || this.document.body; + + if (content) { + content.classList.add(className); + } + + } + + /** + * Remove a class from the contents container + * @param {string} removeClass + */ + removeClass(className) { + var content; + + if(!this.document) return; + + content = this.content || this.document.body; + + if (content) { + content.classList.remove(className); + } + + } + + /** + * Add DOM event listeners + * @private + */ + addEventListeners(){ + if(!this.document) { + return; + } + + this._triggerEvent = this.triggerEvent.bind(this); + + DOM_EVENTS.forEach(function(eventName){ + this.document.addEventListener(eventName, this._triggerEvent, { passive: true }); + }, this); + + } + + /** + * Remove DOM event listeners + * @private + */ + removeEventListeners(){ + if(!this.document) { + return; + } + DOM_EVENTS.forEach(function(eventName){ + this.document.removeEventListener(eventName, this._triggerEvent, { passive: true }); + }, this); + this._triggerEvent = undefined; + } + + /** + * Emit passed browser events + * @private + */ + triggerEvent(e){ + this.emit(e.type, e); + } + + /** + * Add listener for text selection + * @private + */ + addSelectionListeners(){ + if(!this.document) { + return; + } + this._onSelectionChange = this.onSelectionChange.bind(this); + this.document.addEventListener("selectionchange", this._onSelectionChange, { passive: true }); + } + + /** + * Remove listener for text selection + * @private + */ + removeSelectionListeners(){ + if(!this.document) { + return; + } + this.document.removeEventListener("selectionchange", this._onSelectionChange, { passive: true }); + this._onSelectionChange = undefined; + } + + /** + * Handle getting text on selection + * @private + */ + onSelectionChange(e){ + if (this.selectionEndTimeout) { + clearTimeout(this.selectionEndTimeout); + } + this.selectionEndTimeout = setTimeout(function() { + var selection = this.window.getSelection(); + this.triggerSelectedEvent(selection); + }.bind(this), 250); + } + + /** + * Emit event on text selection + * @private + */ + triggerSelectedEvent(selection){ + var range, cfirange; + + if (selection && selection.rangeCount > 0) { + range = selection.getRangeAt(0); + if(!range.collapsed) { + // cfirange = this.section.cfiFromRange(range); + cfirange = new EpubCFI(range, this.cfiBase).toString(); + this.emit(EVENTS.CONTENTS.SELECTED, cfirange); + this.emit(EVENTS.CONTENTS.SELECTED_RANGE, range); + } + } + } + + /** + * Get a Dom Range from EpubCFI + * @param {EpubCFI} _cfi + * @param {string} [ignoreClass] + * @returns {Range} range + */ + range(_cfi, ignoreClass){ + var cfi = new EpubCFI(_cfi); + return cfi.toRange(this.document, ignoreClass); + } + + /** + * Get an EpubCFI from a Dom Range + * @param {Range} range + * @param {string} [ignoreClass] + * @returns {EpubCFI} cfi + */ + cfiFromRange(range, ignoreClass){ + return new EpubCFI(range, this.cfiBase, ignoreClass).toString(); + } + + /** + * Get an EpubCFI from a Dom node + * @param {node} node + * @param {string} [ignoreClass] + * @returns {EpubCFI} cfi + */ + cfiFromNode(node, ignoreClass){ + return new EpubCFI(node, this.cfiBase, ignoreClass).toString(); + } + + // TODO: find where this is used - remove? + map(layout){ + var map = new Mapping(layout); + return map.section(); + } + + /** + * Size the contents to a given width and height + * @param {number} [width] + * @param {number} [height] + */ + size(width, height){ + var viewport = { scale: 1.0, scalable: "no" }; + + this.layoutStyle("scrolling"); + + if (width >= 0) { + this.width(width); + viewport.width = width; + this.css("padding", "0 "+(width/12)+"px"); + } + + if (height >= 0) { + this.height(height); + viewport.height = height; + } + + this.css("margin", "0"); + this.css("box-sizing", "border-box"); + + + this.viewport(viewport); + } + + /** + * Apply columns to the contents for pagination + * @param {number} width + * @param {number} height + * @param {number} columnWidth + * @param {number} gap + */ + columns(width, height, columnWidth, gap, dir){ + let COLUMN_AXIS = prefixed("column-axis"); + let COLUMN_GAP = prefixed("column-gap"); + let COLUMN_WIDTH = prefixed("column-width"); + let COLUMN_FILL = prefixed("column-fill"); + + let writingMode = this.writingMode(); + let axis = (writingMode.indexOf("vertical") === 0) ? "vertical" : "horizontal"; + + this.layoutStyle("paginated"); + + if (dir === "rtl" && axis === "horizontal") { + this.direction(dir); + } + + this.width(width); + this.height(height); + + // Deal with Mobile trying to scale to viewport + this.viewport({ width: width, height: height, scale: 1.0, scalable: "no" }); + + // TODO: inline-block needs more testing + // Fixes Safari column cut offs, but causes RTL issues + // this.css("display", "inline-block"); + + this.css("overflow-y", "hidden"); + this.css("margin", "0", true); + + if (axis === "vertical") { + this.css("padding-top", (gap / 2) + "px", true); + this.css("padding-bottom", (gap / 2) + "px", true); + this.css("padding-left", "20px"); + this.css("padding-right", "20px"); + this.css(COLUMN_AXIS, "vertical"); + } else { + this.css("padding-top", "20px"); + this.css("padding-bottom", "20px"); + this.css("padding-left", (gap / 2) + "px", true); + this.css("padding-right", (gap / 2) + "px", true); + this.css(COLUMN_AXIS, "horizontal"); + } + + this.css("box-sizing", "border-box"); + this.css("max-width", "inherit"); + + this.css(COLUMN_FILL, "auto"); + + this.css(COLUMN_GAP, gap+"px"); + this.css(COLUMN_WIDTH, columnWidth+"px"); + + // Fix glyph clipping in WebKit + // https://github.com/futurepress/epub.js/issues/983 + this.css("-webkit-line-box-contain", "block glyphs replaced"); + } + + /** + * Scale contents from center + * @param {number} scale + * @param {number} offsetX + * @param {number} offsetY + */ + scaler(scale, offsetX, offsetY){ + var scaleStr = "scale(" + scale + ")"; + var translateStr = ""; + // this.css("position", "absolute")); + this.css("transform-origin", "top left"); + + if (offsetX >= 0 || offsetY >= 0) { + translateStr = " translate(" + (offsetX || 0 )+ "px, " + (offsetY || 0 )+ "px )"; + } + + this.css("transform", scaleStr + translateStr); + } + + /** + * Fit contents into a fixed width and height + * @param {number} width + * @param {number} height + */ + fit(width, height, section){ + var viewport = this.viewport(); + var viewportWidth = parseInt(viewport.width); + var viewportHeight = parseInt(viewport.height); + var widthScale = width / viewportWidth; + var heightScale = height / viewportHeight; + var scale = widthScale < heightScale ? widthScale : heightScale; + + // the translate does not work as intended, elements can end up unaligned + // var offsetY = (height - (viewportHeight * scale)) / 2; + // var offsetX = 0; + // if (this.sectionIndex % 2 === 1) { + // offsetX = width - (viewportWidth * scale); + // } + + this.layoutStyle("paginated"); + + // scale needs width and height to be set + this.width(viewportWidth); + this.height(viewportHeight); + this.overflow("hidden"); + + // Scale to the correct size + this.scaler(scale, 0, 0); + // this.scaler(scale, offsetX > 0 ? offsetX : 0, offsetY); + + // background images are not scaled by transform + this.css("background-size", viewportWidth * scale + "px " + viewportHeight * scale + "px"); + + this.css("background-color", "transparent"); + if (section && section.properties.includes("page-spread-left")) { + // set margin since scale is weird + var marginLeft = width - (viewportWidth * scale); + this.css("margin-left", marginLeft + "px"); + } + } + + /** + * Set the direction of the text + * @param {string} [dir="ltr"] "rtl" | "ltr" + */ + direction(dir) { + if (this.documentElement) { + this.documentElement.style["direction"] = dir; + } + } + + mapPage(cfiBase, layout, start, end, dev) { + var mapping = new Mapping(layout, dev); + + return mapping.page(this, cfiBase, start, end); + } + + /** + * Emit event when link in content is clicked + * @private + */ + linksHandler() { + replaceLinks(this.content, (href) => { + this.emit(EVENTS.CONTENTS.LINK_CLICKED, href); + }); + } + + /** + * Set the writingMode of the text + * @param {string} [mode="horizontal-tb"] "horizontal-tb" | "vertical-rl" | "vertical-lr" + */ + writingMode(mode) { + let WRITING_MODE = prefixed("writing-mode"); + + if (mode && this.documentElement) { + this.documentElement.style[WRITING_MODE] = mode; + } + + return this.window.getComputedStyle(this.documentElement)[WRITING_MODE] || ''; + } + + /** + * Set the layoutStyle of the content + * @param {string} [style="paginated"] "scrolling" | "paginated" + * @private + */ + layoutStyle(style) { + + if (style) { + this._layoutStyle = style; + navigator.epubReadingSystem.layoutStyle = this._layoutStyle; + } + + return this._layoutStyle || "paginated"; + } + + /** + * Add the epubReadingSystem object to the navigator + * @param {string} name + * @param {string} version + * @private + */ + epubReadingSystem(name, version) { + navigator.epubReadingSystem = { + name: name, + version: version, + layoutStyle: this.layoutStyle(), + hasFeature: function (feature) { + switch (feature) { + case "dom-manipulation": + return true; + case "layout-changes": + return true; + case "touch-events": + return true; + case "mouse-events": + return true; + case "keyboard-events": + return true; + case "spine-scripting": + return false; + default: + return false; + } + } + }; + return navigator.epubReadingSystem; + } + + destroy() { + // this.document.removeEventListener('transitionend', this._resizeCheck); + + this.removeListeners(); + + } +} + +EventEmitter(Contents.prototype); + +export default Contents; diff --git a/lib/epub.js/src/displayoptions.js b/lib/epub.js/src/displayoptions.js new file mode 100644 index 0000000..a2793e2 --- /dev/null +++ b/lib/epub.js/src/displayoptions.js @@ -0,0 +1,70 @@ +import {qs, qsa } from "./utils/core"; + +/** + * Open DisplayOptions Format Parser + * @class + * @param {document} displayOptionsDocument XML + */ +class DisplayOptions { + constructor(displayOptionsDocument) { + this.interactive = ""; + this.fixedLayout = ""; + this.openToSpread = ""; + this.orientationLock = ""; + + if (displayOptionsDocument) { + this.parse(displayOptionsDocument); + } + } + + /** + * Parse XML + * @param {document} displayOptionsDocument XML + * @return {DisplayOptions} self + */ + parse(displayOptionsDocument) { + if(!displayOptionsDocument) { + return this; + } + + const displayOptionsNode = qs(displayOptionsDocument, "display_options"); + if(!displayOptionsNode) { + return this; + } + + const options = qsa(displayOptionsNode, "option"); + options.forEach((el) => { + let value = ""; + + if (el.childNodes.length) { + value = el.childNodes[0].nodeValue; + } + + switch (el.attributes.name.value) { + case "interactive": + this.interactive = value; + break; + case "fixed-layout": + this.fixedLayout = value; + break; + case "open-to-spread": + this.openToSpread = value; + break; + case "orientation-lock": + this.orientationLock = value; + break; + } + }); + + return this; + } + + destroy() { + this.interactive = undefined; + this.fixedLayout = undefined; + this.openToSpread = undefined; + this.orientationLock = undefined; + } +} + +export default DisplayOptions; diff --git a/lib/epub.js/src/epub.js b/lib/epub.js/src/epub.js new file mode 100644 index 0000000..e6fcdfb --- /dev/null +++ b/lib/epub.js/src/epub.js @@ -0,0 +1,35 @@ +import Book from "./book"; +import Rendition from "./rendition"; +import CFI from "./epubcfi"; +import Contents from "./contents"; +import * as utils from "./utils/core"; +import { EPUBJS_VERSION } from "./utils/constants"; + +import IframeView from "./managers/views/iframe"; +import DefaultViewManager from "./managers/default"; +import ContinuousViewManager from "./managers/continuous"; + +/** + * Creates a new Book + * @param {string|ArrayBuffer} url URL, Path or ArrayBuffer + * @param {object} options to pass to the book + * @returns {Book} a new Book object + * @example ePub("/path/to/book.epub", {}) + */ +function ePub(url, options) { + return new Book(url, options); +} + +ePub.VERSION = EPUBJS_VERSION; + +if (typeof(global) !== "undefined") { + global.EPUBJS_VERSION = EPUBJS_VERSION; +} + +ePub.Book = Book; +ePub.Rendition = Rendition; +ePub.Contents = Contents; +ePub.CFI = CFI; +ePub.utils = utils; + +export default ePub; diff --git a/lib/epub.js/src/epubcfi.js b/lib/epub.js/src/epubcfi.js new file mode 100644 index 0000000..77142a3 --- /dev/null +++ b/lib/epub.js/src/epubcfi.js @@ -0,0 +1,1048 @@ +import {extend, type, findChildren, RangeObject, isNumber} from "./utils/core"; + +const ELEMENT_NODE = 1; +const TEXT_NODE = 3; +const COMMENT_NODE = 8; +const DOCUMENT_NODE = 9; + +/** + * Parsing and creation of EpubCFIs: http://www.idpf.org/epub/linking/cfi/epub-cfi.html + + * Implements: + * - Character Offset: epubcfi(/6/4[chap01ref]!/4[body01]/10[para05]/2/1:3) + * - Simple Ranges : epubcfi(/6/4[chap01ref]!/4[body01]/10[para05],/2/1:1,/3:4) + + * Does Not Implement: + * - Temporal Offset (~) + * - Spatial Offset (@) + * - Temporal-Spatial Offset (~ + @) + * - Text Location Assertion ([) + * @class + @param {string | Range | Node } [cfiFrom] + @param {string | object} [base] + @param {string} [ignoreClass] class to ignore when parsing DOM +*/ +class EpubCFI { + constructor(cfiFrom, base, ignoreClass){ + var type; + + this.str = ""; + + this.base = {}; + this.spinePos = 0; // For compatibility + + this.range = false; // true || false; + + this.path = {}; + this.start = null; + this.end = null; + + // Allow instantiation without the "new" keyword + if (!(this instanceof EpubCFI)) { + return new EpubCFI(cfiFrom, base, ignoreClass); + } + + if(typeof base === "string") { + this.base = this.parseComponent(base); + } else if(typeof base === "object" && base.steps) { + this.base = base; + } + + type = this.checkType(cfiFrom); + + + if(type === "string") { + this.str = cfiFrom; + return extend(this, this.parse(cfiFrom)); + } else if (type === "range") { + return extend(this, this.fromRange(cfiFrom, this.base, ignoreClass)); + } else if (type === "node") { + return extend(this, this.fromNode(cfiFrom, this.base, ignoreClass)); + } else if (type === "EpubCFI" && cfiFrom.path) { + return cfiFrom; + } else if (!cfiFrom) { + return this; + } else { + throw new TypeError("not a valid argument for EpubCFI"); + } + + } + + /** + * Check the type of constructor input + * @private + */ + checkType(cfi) { + + if (this.isCfiString(cfi)) { + return "string"; + // Is a range object + } else if (cfi && typeof cfi === "object" && (type(cfi) === "Range" || typeof(cfi.startContainer) != "undefined")){ + return "range"; + } else if (cfi && typeof cfi === "object" && typeof(cfi.nodeType) != "undefined" ){ // || typeof cfi === "function" + return "node"; + } else if (cfi && typeof cfi === "object" && cfi instanceof EpubCFI){ + return "EpubCFI"; + } else { + return false; + } + } + + /** + * Parse a cfi string to a CFI object representation + * @param {string} cfiStr + * @returns {object} cfi + */ + parse(cfiStr) { + var cfi = { + spinePos: -1, + range: false, + base: {}, + path: {}, + start: null, + end: null + }; + var baseComponent, pathComponent, range; + + if(typeof cfiStr !== "string") { + return {spinePos: -1}; + } + + if(cfiStr.indexOf("epubcfi(") === 0 && cfiStr[cfiStr.length-1] === ")") { + // Remove intial epubcfi( and ending ) + cfiStr = cfiStr.slice(8, cfiStr.length-1); + } + + baseComponent = this.getChapterComponent(cfiStr); + + // Make sure this is a valid cfi or return + if(!baseComponent) { + return {spinePos: -1}; + } + + cfi.base = this.parseComponent(baseComponent); + + pathComponent = this.getPathComponent(cfiStr); + cfi.path = this.parseComponent(pathComponent); + + range = this.getRange(cfiStr); + + if(range) { + cfi.range = true; + cfi.start = this.parseComponent(range[0]); + cfi.end = this.parseComponent(range[1]); + } + + // Get spine node position + // cfi.spineSegment = cfi.base.steps[1]; + + // Chapter segment is always the second step + cfi.spinePos = cfi.base.steps[1].index; + + return cfi; + } + + parseComponent(componentStr){ + var component = { + steps: [], + terminal: { + offset: null, + assertion: null + } + }; + var parts = componentStr.split(":"); + var steps = parts[0].split("/"); + var terminal; + + if(parts.length > 1) { + terminal = parts[1]; + component.terminal = this.parseTerminal(terminal); + } + + if (steps[0] === "") { + steps.shift(); // Ignore the first slash + } + + component.steps = steps.map(function(step){ + return this.parseStep(step); + }.bind(this)); + + return component; + } + + parseStep(stepStr){ + var type, num, index, has_brackets, id; + + has_brackets = stepStr.match(/\[(.*)\]/); + if(has_brackets && has_brackets[1]){ + id = has_brackets[1]; + } + + //-- Check if step is a text node or element + num = parseInt(stepStr); + + if(isNaN(num)) { + return; + } + + if(num % 2 === 0) { // Even = is an element + type = "element"; + index = num / 2 - 1; + } else { + type = "text"; + index = (num - 1 ) / 2; + } + + return { + "type" : type, + "index" : index, + "id" : id || null + }; + } + + parseTerminal(termialStr){ + var characterOffset, textLocationAssertion; + var assertion = termialStr.match(/\[(.*)\]/); + + if(assertion && assertion[1]){ + characterOffset = parseInt(termialStr.split("[")[0]); + textLocationAssertion = assertion[1]; + } else { + characterOffset = parseInt(termialStr); + } + + if (!isNumber(characterOffset)) { + characterOffset = null; + } + + return { + "offset": characterOffset, + "assertion": textLocationAssertion + }; + + } + + getChapterComponent(cfiStr) { + + var indirection = cfiStr.split("!"); + + return indirection[0]; + } + + getPathComponent(cfiStr) { + + var indirection = cfiStr.split("!"); + + if(indirection[1]) { + let ranges = indirection[1].split(","); + return ranges[0]; + } + + } + + getRange(cfiStr) { + + var ranges = cfiStr.split(","); + + if(ranges.length === 3){ + return [ + ranges[1], + ranges[2] + ]; + } + + return false; + } + + getCharecterOffsetComponent(cfiStr) { + var splitStr = cfiStr.split(":"); + return splitStr[1] || ""; + } + + joinSteps(steps) { + if(!steps) { + return ""; + } + + return steps.map(function(part){ + var segment = ""; + + if(part.type === "element") { + segment += (part.index + 1) * 2; + } + + if(part.type === "text") { + segment += 1 + (2 * part.index); // TODO: double check that this is odd + } + + if(part.id) { + segment += "[" + part.id + "]"; + } + + return segment; + + }).join("/"); + + } + + segmentString(segment) { + var segmentString = "/"; + + segmentString += this.joinSteps(segment.steps); + + if(segment.terminal && segment.terminal.offset != null){ + segmentString += ":" + segment.terminal.offset; + } + + if(segment.terminal && segment.terminal.assertion != null){ + segmentString += "[" + segment.terminal.assertion + "]"; + } + + return segmentString; + } + + /** + * Convert CFI to a epubcfi(...) string + * @returns {string} epubcfi + */ + toString() { + var cfiString = "epubcfi("; + + cfiString += this.segmentString(this.base); + + cfiString += "!"; + cfiString += this.segmentString(this.path); + + // Add Range, if present + if(this.range && this.start) { + cfiString += ","; + cfiString += this.segmentString(this.start); + } + + if(this.range && this.end) { + cfiString += ","; + cfiString += this.segmentString(this.end); + } + + cfiString += ")"; + + return cfiString; + } + + + /** + * Compare which of two CFIs is earlier in the text + * @returns {number} First is earlier = -1, Second is earlier = 1, They are equal = 0 + */ + compare(cfiOne, cfiTwo) { + var stepsA, stepsB; + var terminalA, terminalB; + + var rangeAStartSteps, rangeAEndSteps; + var rangeBEndSteps, rangeBEndSteps; + var rangeAStartTerminal, rangeAEndTerminal; + var rangeBStartTerminal, rangeBEndTerminal; + + if(typeof cfiOne === "string") { + cfiOne = new EpubCFI(cfiOne); + } + if(typeof cfiTwo === "string") { + cfiTwo = new EpubCFI(cfiTwo); + } + // Compare Spine Positions + if(cfiOne.spinePos > cfiTwo.spinePos) { + return 1; + } + if(cfiOne.spinePos < cfiTwo.spinePos) { + return -1; + } + + if (cfiOne.range) { + stepsA = cfiOne.path.steps.concat(cfiOne.start.steps); + terminalA = cfiOne.start.terminal; + } else { + stepsA = cfiOne.path.steps; + terminalA = cfiOne.path.terminal; + } + + if (cfiTwo.range) { + stepsB = cfiTwo.path.steps.concat(cfiTwo.start.steps); + terminalB = cfiTwo.start.terminal; + } else { + stepsB = cfiTwo.path.steps; + terminalB = cfiTwo.path.terminal; + } + + // Compare Each Step in the First item + for (var i = 0; i < stepsA.length; i++) { + if(!stepsA[i]) { + return -1; + } + if(!stepsB[i]) { + return 1; + } + if(stepsA[i].index > stepsB[i].index) { + return 1; + } + if(stepsA[i].index < stepsB[i].index) { + return -1; + } + // Otherwise continue checking + } + + // All steps in First equal to Second and First is Less Specific + if(stepsA.length < stepsB.length) { + return -1; + } + + // Compare the charecter offset of the text node + if(terminalA.offset > terminalB.offset) { + return 1; + } + if(terminalA.offset < terminalB.offset) { + return -1; + } + + // CFI's are equal + return 0; + } + + step(node) { + var nodeType = (node.nodeType === TEXT_NODE) ? "text" : "element"; + + return { + "id" : node.id, + "tagName" : node.tagName, + "type" : nodeType, + "index" : this.position(node) + }; + } + + filteredStep(node, ignoreClass) { + var filteredNode = this.filter(node, ignoreClass); + var nodeType; + + // Node filtered, so ignore + if (!filteredNode) { + return; + } + + // Otherwise add the filter node in + nodeType = (filteredNode.nodeType === TEXT_NODE) ? "text" : "element"; + + return { + "id" : filteredNode.id, + "tagName" : filteredNode.tagName, + "type" : nodeType, + "index" : this.filteredPosition(filteredNode, ignoreClass) + }; + } + + pathTo(node, offset, ignoreClass) { + var segment = { + steps: [], + terminal: { + offset: null, + assertion: null + } + }; + var currentNode = node; + var step; + + while(currentNode && currentNode.parentNode && + currentNode.parentNode.nodeType != DOCUMENT_NODE) { + + if (ignoreClass) { + step = this.filteredStep(currentNode, ignoreClass); + } else { + step = this.step(currentNode); + } + + if (step) { + segment.steps.unshift(step); + } + + currentNode = currentNode.parentNode; + + } + + if (offset != null && offset >= 0) { + + segment.terminal.offset = offset; + + // Make sure we are getting to a textNode if there is an offset + if(segment.steps[segment.steps.length-1].type != "text") { + segment.steps.push({ + "type" : "text", + "index" : 0 + }); + } + + } + + + return segment; + } + + equalStep(stepA, stepB) { + if (!stepA || !stepB) { + return false; + } + + if(stepA.index === stepB.index && + stepA.id === stepB.id && + stepA.type === stepB.type) { + return true; + } + + return false; + } + + /** + * Create a CFI object from a Range + * @param {Range} range + * @param {string | object} base + * @param {string} [ignoreClass] + * @returns {object} cfi + */ + fromRange(range, base, ignoreClass) { + var cfi = { + range: false, + base: {}, + path: {}, + start: null, + end: null + }; + + var start = range.startContainer; + var end = range.endContainer; + + var startOffset = range.startOffset; + var endOffset = range.endOffset; + + var needsIgnoring = false; + + if (ignoreClass) { + // Tell pathTo if / what to ignore + needsIgnoring = (start.ownerDocument.querySelector("." + ignoreClass) != null); + } + + + if (typeof base === "string") { + cfi.base = this.parseComponent(base); + cfi.spinePos = cfi.base.steps[1].index; + } else if (typeof base === "object") { + cfi.base = base; + } + + if (range.collapsed) { + if (needsIgnoring) { + startOffset = this.patchOffset(start, startOffset, ignoreClass); + } + cfi.path = this.pathTo(start, startOffset, ignoreClass); + } else { + cfi.range = true; + + if (needsIgnoring) { + startOffset = this.patchOffset(start, startOffset, ignoreClass); + } + + cfi.start = this.pathTo(start, startOffset, ignoreClass); + if (needsIgnoring) { + endOffset = this.patchOffset(end, endOffset, ignoreClass); + } + + cfi.end = this.pathTo(end, endOffset, ignoreClass); + + // Create a new empty path + cfi.path = { + steps: [], + terminal: null + }; + + // Push steps that are shared between start and end to the common path + var len = cfi.start.steps.length; + var i; + + for (i = 0; i < len; i++) { + if (this.equalStep(cfi.start.steps[i], cfi.end.steps[i])) { + if(i === len-1) { + // Last step is equal, check terminals + if(cfi.start.terminal === cfi.end.terminal) { + // CFI's are equal + cfi.path.steps.push(cfi.start.steps[i]); + // Not a range + cfi.range = false; + } + } else { + cfi.path.steps.push(cfi.start.steps[i]); + } + + } else { + break; + } + } + + cfi.start.steps = cfi.start.steps.slice(cfi.path.steps.length); + cfi.end.steps = cfi.end.steps.slice(cfi.path.steps.length); + + // TODO: Add Sanity check to make sure that the end if greater than the start + } + + return cfi; + } + + /** + * Create a CFI object from a Node + * @param {Node} anchor + * @param {string | object} base + * @param {string} [ignoreClass] + * @returns {object} cfi + */ + fromNode(anchor, base, ignoreClass) { + var cfi = { + range: false, + base: {}, + path: {}, + start: null, + end: null + }; + + if (typeof base === "string") { + cfi.base = this.parseComponent(base); + cfi.spinePos = cfi.base.steps[1].index; + } else if (typeof base === "object") { + cfi.base = base; + } + + cfi.path = this.pathTo(anchor, null, ignoreClass); + + return cfi; + } + + filter(anchor, ignoreClass) { + var needsIgnoring; + var sibling; // to join with + var parent, previousSibling, nextSibling; + var isText = false; + + if (anchor.nodeType === TEXT_NODE) { + isText = true; + parent = anchor.parentNode; + needsIgnoring = anchor.parentNode.classList.contains(ignoreClass); + } else { + isText = false; + needsIgnoring = anchor.classList.contains(ignoreClass); + } + + if (needsIgnoring && isText) { + previousSibling = parent.previousSibling; + nextSibling = parent.nextSibling; + + // If the sibling is a text node, join the nodes + if (previousSibling && previousSibling.nodeType === TEXT_NODE) { + sibling = previousSibling; + } else if (nextSibling && nextSibling.nodeType === TEXT_NODE) { + sibling = nextSibling; + } + + if (sibling) { + return sibling; + } else { + // Parent will be ignored on next step + return anchor; + } + + } else if (needsIgnoring && !isText) { + // Otherwise just skip the element node + return false; + } else { + // No need to filter + return anchor; + } + + } + + patchOffset(anchor, offset, ignoreClass) { + if (anchor.nodeType != TEXT_NODE) { + throw new Error("Anchor must be a text node"); + } + + var curr = anchor; + var totalOffset = offset; + + // If the parent is a ignored node, get offset from it's start + if (anchor.parentNode.classList.contains(ignoreClass)) { + curr = anchor.parentNode; + } + + while (curr.previousSibling) { + if(curr.previousSibling.nodeType === ELEMENT_NODE) { + // Originally a text node, so join + if(curr.previousSibling.classList.contains(ignoreClass)){ + totalOffset += curr.previousSibling.textContent.length; + } else { + break; // Normal node, dont join + } + } else { + // If the previous sibling is a text node, join the nodes + totalOffset += curr.previousSibling.textContent.length; + } + + curr = curr.previousSibling; + } + + return totalOffset; + + } + + normalizedMap(children, nodeType, ignoreClass) { + var output = {}; + var prevIndex = -1; + var i, len = children.length; + var currNodeType; + var prevNodeType; + + for (i = 0; i < len; i++) { + + currNodeType = children[i].nodeType; + + // Check if needs ignoring + if (currNodeType === ELEMENT_NODE && + children[i].classList.contains(ignoreClass)) { + currNodeType = TEXT_NODE; + } + + if (i > 0 && + currNodeType === TEXT_NODE && + prevNodeType === TEXT_NODE) { + // join text nodes + output[i] = prevIndex; + } else if (nodeType === currNodeType){ + prevIndex = prevIndex + 1; + output[i] = prevIndex; + } + + prevNodeType = currNodeType; + + } + + return output; + } + + position(anchor) { + var children, index; + if (anchor.nodeType === ELEMENT_NODE) { + children = anchor.parentNode.children; + if (!children) { + children = findChildren(anchor.parentNode); + } + index = Array.prototype.indexOf.call(children, anchor); + } else { + children = this.textNodes(anchor.parentNode); + index = children.indexOf(anchor); + } + + return index; + } + + filteredPosition(anchor, ignoreClass) { + var children, index, map; + + if (anchor.nodeType === ELEMENT_NODE) { + children = anchor.parentNode.children; + map = this.normalizedMap(children, ELEMENT_NODE, ignoreClass); + } else { + children = anchor.parentNode.childNodes; + // Inside an ignored node + if(anchor.parentNode.classList.contains(ignoreClass)) { + anchor = anchor.parentNode; + children = anchor.parentNode.childNodes; + } + map = this.normalizedMap(children, TEXT_NODE, ignoreClass); + } + + + index = Array.prototype.indexOf.call(children, anchor); + + return map[index]; + } + + stepsToXpath(steps) { + var xpath = [".", "*"]; + + steps.forEach(function(step){ + var position = step.index + 1; + + if(step.id){ + xpath.push("*[position()=" + position + " and @id='" + step.id + "']"); + } else if(step.type === "text") { + xpath.push("text()[" + position + "]"); + } else { + xpath.push("*[" + position + "]"); + } + }); + + return xpath.join("/"); + } + + + /* + + To get the last step if needed: + + // Get the terminal step + lastStep = steps[steps.length-1]; + // Get the query string + query = this.stepsToQuery(steps); + // Find the containing element + startContainerParent = doc.querySelector(query); + // Find the text node within that element + if(startContainerParent && lastStep.type == "text") { + container = startContainerParent.childNodes[lastStep.index]; + } + */ + stepsToQuerySelector(steps) { + var query = ["html"]; + + steps.forEach(function(step){ + var position = step.index + 1; + + if(step.id){ + query.push("#" + step.id); + } else if(step.type === "text") { + // unsupported in querySelector + // query.push("text()[" + position + "]"); + } else { + query.push("*:nth-child(" + position + ")"); + } + }); + + return query.join(">"); + + } + + textNodes(container, ignoreClass) { + return Array.prototype.slice.call(container.childNodes). + filter(function (node) { + if (node.nodeType === TEXT_NODE) { + return true; + } else if (ignoreClass && node.classList.contains(ignoreClass)) { + return true; + } + return false; + }); + } + + walkToNode(steps, _doc, ignoreClass) { + var doc = _doc || document; + var container = doc.documentElement; + var children; + var step; + var len = steps.length; + var i; + + for (i = 0; i < len; i++) { + step = steps[i]; + + if(step.type === "element") { + //better to get a container using id as some times step.index may not be correct + //For ex.https://github.com/futurepress/epub.js/issues/561 + if(step.id) { + container = doc.getElementById(step.id); + } + else { + children = container.children || findChildren(container); + container = children[step.index]; + } + } else if(step.type === "text") { + container = this.textNodes(container, ignoreClass)[step.index]; + } + if(!container) { + //Break the for loop as due to incorrect index we can get error if + //container is undefined so that other functionailties works fine + //like navigation + break; + } + + } + + return container; + } + + findNode(steps, _doc, ignoreClass) { + var doc = _doc || document; + var container; + var xpath; + + if(!ignoreClass && typeof doc.evaluate != "undefined") { + xpath = this.stepsToXpath(steps); + container = doc.evaluate(xpath, doc, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; + } else if(ignoreClass) { + container = this.walkToNode(steps, doc, ignoreClass); + } else { + container = this.walkToNode(steps, doc); + } + + return container; + } + + fixMiss(steps, offset, _doc, ignoreClass) { + var container = this.findNode(steps.slice(0,-1), _doc, ignoreClass); + var children = container.childNodes; + var map = this.normalizedMap(children, TEXT_NODE, ignoreClass); + var child; + var len; + var lastStepIndex = steps[steps.length-1].index; + + for (let childIndex in map) { + if (!map.hasOwnProperty(childIndex)) return; + + if(map[childIndex] === lastStepIndex) { + child = children[childIndex]; + len = child.textContent.length; + if(offset > len) { + offset = offset - len; + } else { + if (child.nodeType === ELEMENT_NODE) { + container = child.childNodes[0]; + } else { + container = child; + } + break; + } + } + } + + return { + container: container, + offset: offset + }; + + } + + /** + * Creates a DOM range representing a CFI + * @param {document} _doc document referenced in the base + * @param {string} [ignoreClass] + * @return {Range} + */ + toRange(_doc, ignoreClass) { + var doc = _doc || document; + var range; + var start, end, startContainer, endContainer; + var cfi = this; + var startSteps, endSteps; + var needsIgnoring = ignoreClass ? (doc.querySelector("." + ignoreClass) != null) : false; + var missed; + + if (typeof(doc.createRange) !== "undefined") { + range = doc.createRange(); + } else { + range = new RangeObject(); + } + + if (cfi.range) { + start = cfi.start; + startSteps = cfi.path.steps.concat(start.steps); + startContainer = this.findNode(startSteps, doc, needsIgnoring ? ignoreClass : null); + end = cfi.end; + endSteps = cfi.path.steps.concat(end.steps); + endContainer = this.findNode(endSteps, doc, needsIgnoring ? ignoreClass : null); + } else { + start = cfi.path; + startSteps = cfi.path.steps; + startContainer = this.findNode(cfi.path.steps, doc, needsIgnoring ? ignoreClass : null); + } + + if(startContainer) { + try { + + if(start.terminal.offset != null) { + range.setStart(startContainer, start.terminal.offset); + } else { + range.setStart(startContainer, 0); + } + + } catch (e) { + missed = this.fixMiss(startSteps, start.terminal.offset, doc, needsIgnoring ? ignoreClass : null); + range.setStart(missed.container, missed.offset); + } + } else { + console.log("No startContainer found for", this.toString()); + // No start found + return null; + } + + if (endContainer) { + try { + + if(end.terminal.offset != null) { + range.setEnd(endContainer, end.terminal.offset); + } else { + range.setEnd(endContainer, 0); + } + + } catch (e) { + missed = this.fixMiss(endSteps, cfi.end.terminal.offset, doc, needsIgnoring ? ignoreClass : null); + range.setEnd(missed.container, missed.offset); + } + } + + + // doc.defaultView.getSelection().addRange(range); + return range; + } + + /** + * Check if a string is wrapped with "epubcfi()" + * @param {string} str + * @returns {boolean} + */ + isCfiString(str) { + if(typeof str === "string" && + str.indexOf("epubcfi(") === 0 && + str[str.length-1] === ")") { + return true; + } + + return false; + } + + generateChapterComponent(_spineNodeIndex, _pos, id) { + var pos = parseInt(_pos), + spineNodeIndex = (_spineNodeIndex + 1) * 2, + cfi = "/"+spineNodeIndex+"/"; + + cfi += (pos + 1) * 2; + + if(id) { + cfi += "[" + id + "]"; + } + + return cfi; + } + + /** + * Collapse a CFI Range to a single CFI Position + * @param {boolean} [toStart=false] + */ + collapse(toStart) { + if (!this.range) { + return; + } + + this.range = false; + + if (toStart) { + this.path.steps = this.path.steps.concat(this.start.steps); + this.path.terminal = this.start.terminal; + } else { + this.path.steps = this.path.steps.concat(this.end.steps); + this.path.terminal = this.end.terminal; + } + + } +} + +export default EpubCFI; diff --git a/lib/epub.js/src/index.js b/lib/epub.js/src/index.js new file mode 100644 index 0000000..16ef8b6 --- /dev/null +++ b/lib/epub.js/src/index.js @@ -0,0 +1,15 @@ +import Book from "./book"; +import EpubCFI from "./epubcfi"; +import Rendition from "./rendition"; +import Contents from "./contents"; +import Layout from "./layout"; +import ePub from "./epub"; + +export default ePub; +export { + Book, + EpubCFI, + Rendition, + Contents, + Layout +}; diff --git a/lib/epub.js/src/layout.js b/lib/epub.js/src/layout.js new file mode 100644 index 0000000..4f16a0f --- /dev/null +++ b/lib/epub.js/src/layout.js @@ -0,0 +1,260 @@ +import { extend } from "./utils/core"; +import { EVENTS } from "./utils/constants"; +import EventEmitter from "event-emitter"; + +/** + * Figures out the CSS values to apply for a layout + * @class + * @param {object} settings + * @param {string} [settings.layout='reflowable'] + * @param {string} [settings.spread] + * @param {number} [settings.minSpreadWidth=800] + * @param {boolean} [settings.evenSpreads=false] + */ +class Layout { + constructor(settings) { + this.settings = settings; + this.name = settings.layout || "reflowable"; + this._spread = (settings.spread === "none") ? false : true; + this._minSpreadWidth = settings.minSpreadWidth || 800; + this._evenSpreads = settings.evenSpreads || false; + + if (settings.flow === "scrolled" || + settings.flow === "scrolled-continuous" || + settings.flow === "scrolled-doc") { + this._flow = "scrolled"; + } else { + this._flow = "paginated"; + } + + + this.width = 0; + this.height = 0; + this.spreadWidth = 0; + this.delta = 0; + + this.columnWidth = 0; + this.gap = 0; + this.divisor = 1; + + this.props = { + name: this.name, + spread: this._spread, + flow: this._flow, + width: 0, + height: 0, + spreadWidth: 0, + delta: 0, + columnWidth: 0, + gap: 0, + divisor: 1 + }; + + } + + /** + * Switch the flow between paginated and scrolled + * @param {string} flow paginated | scrolled + * @return {string} simplified flow + */ + flow(flow) { + if (typeof(flow) != "undefined") { + if (flow === "scrolled" || + flow === "scrolled-continuous" || + flow === "scrolled-doc") { + this._flow = "scrolled"; + } else { + this._flow = "paginated"; + } + // this.props.flow = this._flow; + this.update({flow: this._flow}); + } + return this._flow; + } + + /** + * Switch between using spreads or not, and set the + * width at which they switch to single. + * @param {string} spread "none" | "always" | "auto" + * @param {number} min integer in pixels + * @return {boolean} spread true | false + */ + spread(spread, min) { + + if (spread) { + this._spread = (spread === "none") ? false : true; + // this.props.spread = this._spread; + this.update({spread: this._spread}); + } + + if (min >= 0) { + this._minSpreadWidth = min; + } + + return this._spread; + } + + /** + * Calculate the dimensions of the pagination + * @param {number} _width width of the rendering + * @param {number} _height height of the rendering + * @param {number} _gap width of the gap between columns + */ + calculate(_width, _height, _gap){ + + var divisor = 1; + var gap = _gap || 0; + + //-- Check the width and create even width columns + // var fullWidth = Math.floor(_width); + var width = _width; + var height = _height; + + var section = Math.floor(width / 12); + + var columnWidth; + var spreadWidth; + var pageWidth; + var delta; + + if (this._spread && width >= this._minSpreadWidth) { + divisor = 2; + } else { + divisor = 1; + } + + if (this.name === "reflowable" && this._flow === "paginated" && !(_gap >= 0)) { + gap = ((section % 2 === 0) ? section : section - 1); + } + + if (this.name === "pre-paginated" ) { + gap = 0; + } + + //-- Double Page + if(divisor > 1) { + // width = width - gap; + // columnWidth = (width - gap) / divisor; + // gap = gap / divisor; + columnWidth = (width / divisor) - gap; + pageWidth = columnWidth + gap; + } else { + columnWidth = width; + pageWidth = width; + } + + if (this.name === "pre-paginated" && divisor > 1) { + width = columnWidth; + } + + spreadWidth = (columnWidth * divisor) + gap; + + delta = width; + + this.width = width; + this.height = height; + this.spreadWidth = spreadWidth; + this.pageWidth = pageWidth; + this.delta = delta; + + this.columnWidth = columnWidth; + this.gap = gap; + this.divisor = divisor; + + // this.props.width = width; + // this.props.height = _height; + // this.props.spreadWidth = spreadWidth; + // this.props.pageWidth = pageWidth; + // this.props.delta = delta; + // + // this.props.columnWidth = colWidth; + // this.props.gap = gap; + // this.props.divisor = divisor; + + this.update({ + width, + height, + spreadWidth, + pageWidth, + delta, + columnWidth, + gap, + divisor + }); + + } + + /** + * Apply Css to a Document + * @param {Contents} contents + * @return {Promise} + */ + format(contents, section, axis){ + var formating; + + if (this.name === "pre-paginated") { + formating = contents.fit(this.columnWidth, this.height, section); + } else if (this._flow === "paginated") { + formating = contents.columns(this.width, this.height, this.columnWidth, this.gap, this.settings.direction); + } else if (axis && axis === "horizontal") { + formating = contents.size(null, this.height); + } else { + formating = contents.size(this.width, null); + } + + return formating; // might be a promise in some View Managers + } + + /** + * Count number of pages + * @param {number} totalLength + * @param {number} pageLength + * @return {{spreads: Number, pages: Number}} + */ + count(totalLength, pageLength) { + + let spreads, pages; + + if (this.name === "pre-paginated") { + spreads = 1; + pages = 1; + } else if (this._flow === "paginated") { + pageLength = pageLength || this.delta; + spreads = Math.ceil( totalLength / pageLength); + pages = spreads * this.divisor; + } else { // scrolled + pageLength = pageLength || this.height; + spreads = Math.ceil( totalLength / pageLength); + pages = spreads; + } + + return { + spreads, + pages + }; + + } + + /** + * Update props that have changed + * @private + * @param {object} props + */ + update(props) { + // Remove props that haven't changed + Object.keys(props).forEach((propName) => { + if (this.props[propName] === props[propName]) { + delete props[propName]; + } + }); + + if(Object.keys(props).length > 0) { + let newProps = extend(this.props, props); + this.emit(EVENTS.LAYOUT.UPDATED, newProps, props); + } + } +} + +EventEmitter(Layout.prototype); + +export default Layout; diff --git a/lib/epub.js/src/locations.js b/lib/epub.js/src/locations.js new file mode 100644 index 0000000..913c40d --- /dev/null +++ b/lib/epub.js/src/locations.js @@ -0,0 +1,501 @@ +import {qs, sprint, locationOf, defer} from "./utils/core"; +import Queue from "./utils/queue"; +import EpubCFI from "./epubcfi"; +import { EVENTS } from "./utils/constants"; +import EventEmitter from "event-emitter"; + +/** + * Find Locations for a Book + * @param {Spine} spine + * @param {request} request + * @param {number} [pause=100] + */ +class Locations { + constructor(spine, request, pause) { + this.spine = spine; + this.request = request; + this.pause = pause || 100; + + this.q = new Queue(this); + this.epubcfi = new EpubCFI(); + + this._locations = []; + this._locationsWords = []; + this.total = 0; + + this.break = 150; + + this._current = 0; + + this._wordCounter = 0; + + this.currentLocation = ''; + this._currentCfi =''; + this.processingTimeout = undefined; + } + + /** + * Load all of sections in the book to generate locations + * @param {int} chars how many chars to split on + * @return {object} locations + */ + generate(chars) { + + if (chars) { + this.break = chars; + } + + this.q.pause(); + + this.spine.each(function(section) { + if (section.linear) { + this.q.enqueue(this.process.bind(this), section); + } + }.bind(this)); + + return this.q.run().then(function() { + this.total = this._locations.length - 1; + + if (this._currentCfi) { + this.currentLocation = this._currentCfi; + } + + return this._locations; + // console.log(this.percentage(this.book.rendition.location.start), this.percentage(this.book.rendition.location.end)); + }.bind(this)); + + } + + createRange () { + return { + startContainer: undefined, + startOffset: undefined, + endContainer: undefined, + endOffset: undefined + }; + } + + process(section) { + + return section.load(this.request) + .then(function(contents) { + var completed = new defer(); + var locations = this.parse(contents, section.cfiBase); + this._locations = this._locations.concat(locations); + + section.unload(); + + this.processingTimeout = setTimeout(() => completed.resolve(locations), this.pause); + return completed.promise; + }.bind(this)); + + } + + parse(contents, cfiBase, chars) { + var locations = []; + var range; + var doc = contents.ownerDocument; + var body = qs(doc, "body"); + var counter = 0; + var prev; + var _break = chars || this.break; + var parser = function(node) { + var len = node.length; + var dist; + var pos = 0; + + if (node.textContent.trim().length === 0) { + return false; // continue + } + + // Start range + if (counter == 0) { + range = this.createRange(); + range.startContainer = node; + range.startOffset = 0; + } + + dist = _break - counter; + + // Node is smaller than a break, + // skip over it + if(dist > len){ + counter += len; + pos = len; + } + + + while (pos < len) { + dist = _break - counter; + + if (counter === 0) { + // Start new range + pos += 1; + range = this.createRange(); + range.startContainer = node; + range.startOffset = pos; + } + + // pos += dist; + + // Gone over + if(pos + dist >= len){ + // Continue counter for next node + counter += len - pos; + // break + pos = len; + // At End + } else { + // Advance pos + pos += dist; + + // End the previous range + range.endContainer = node; + range.endOffset = pos; + // cfi = section.cfiFromRange(range); + let cfi = new EpubCFI(range, cfiBase).toString(); + locations.push(cfi); + counter = 0; + } + } + prev = node; + }; + + sprint(body, parser.bind(this)); + + // Close remaining + if (range && range.startContainer && prev) { + range.endContainer = prev; + range.endOffset = prev.length; + let cfi = new EpubCFI(range, cfiBase).toString(); + locations.push(cfi); + counter = 0; + } + + return locations; + } + + + /** + * Load all of sections in the book to generate locations + * @param {string} startCfi start position + * @param {int} wordCount how many words to split on + * @param {int} count result count + * @return {object} locations + */ + generateFromWords(startCfi, wordCount, count) { + var start = startCfi ? new EpubCFI(startCfi) : undefined; + this.q.pause(); + this._locationsWords = []; + this._wordCounter = 0; + + this.spine.each(function(section) { + if (section.linear) { + if (start) { + if (section.index >= start.spinePos) { + this.q.enqueue(this.processWords.bind(this), section, wordCount, start, count); + } + } else { + this.q.enqueue(this.processWords.bind(this), section, wordCount, start, count); + } + } + }.bind(this)); + + return this.q.run().then(function() { + if (this._currentCfi) { + this.currentLocation = this._currentCfi; + } + + return this._locationsWords; + }.bind(this)); + + } + + processWords(section, wordCount, startCfi, count) { + if (count && this._locationsWords.length >= count) { + return Promise.resolve(); + } + + return section.load(this.request) + .then(function(contents) { + var completed = new defer(); + var locations = this.parseWords(contents, section, wordCount, startCfi); + var remainingCount = count - this._locationsWords.length; + this._locationsWords = this._locationsWords.concat(locations.length >= count ? locations.slice(0, remainingCount) : locations); + + section.unload(); + + this.processingTimeout = setTimeout(() => completed.resolve(locations), this.pause); + return completed.promise; + }.bind(this)); + } + + //http://stackoverflow.com/questions/18679576/counting-words-in-string + countWords(s) { + s = s.replace(/(^\s*)|(\s*$)/gi, "");//exclude start and end white-space + s = s.replace(/[ ]{2,}/gi, " ");//2 or more space to 1 + s = s.replace(/\n /, "\n"); // exclude newline with a start spacing + return s.split(" ").length; + } + + parseWords(contents, section, wordCount, startCfi) { + var cfiBase = section.cfiBase; + var locations = []; + var doc = contents.ownerDocument; + var body = qs(doc, "body"); + var prev; + var _break = wordCount; + var foundStartNode = startCfi ? startCfi.spinePos !== section.index : true; + var startNode; + if (startCfi && section.index === startCfi.spinePos) { + startNode = startCfi.findNode(startCfi.range ? startCfi.path.steps.concat(startCfi.start.steps) : startCfi.path.steps, contents.ownerDocument); + } + var parser = function(node) { + if (!foundStartNode) { + if (node === startNode) { + foundStartNode = true; + } else { + return false; + } + } + if (node.textContent.length < 10) { + if (node.textContent.trim().length === 0) { + return false; + } + } + var len = this.countWords(node.textContent); + var dist; + var pos = 0; + + if (len === 0) { + return false; // continue + } + + dist = _break - this._wordCounter; + + // Node is smaller than a break, + // skip over it + if (dist > len) { + this._wordCounter += len; + pos = len; + } + + + while (pos < len) { + dist = _break - this._wordCounter; + + // Gone over + if (pos + dist >= len) { + // Continue counter for next node + this._wordCounter += len - pos; + // break + pos = len; + // At End + } else { + // Advance pos + pos += dist; + + let cfi = new EpubCFI(node, cfiBase); + locations.push({ cfi: cfi.toString(), wordCount: this._wordCounter }); + this._wordCounter = 0; + } + } + prev = node; + }; + + sprint(body, parser.bind(this)); + + return locations; + } + + /** + * Get a location from an EpubCFI + * @param {EpubCFI} cfi + * @return {number} + */ + locationFromCfi(cfi){ + let loc; + if (EpubCFI.prototype.isCfiString(cfi)) { + cfi = new EpubCFI(cfi); + } + // Check if the location has not been set yet + if(this._locations.length === 0) { + return -1; + } + + loc = locationOf(cfi, this._locations, this.epubcfi.compare); + + if (loc > this.total) { + return this.total; + } + + return loc; + } + + /** + * Get a percentage position in locations from an EpubCFI + * @param {EpubCFI} cfi + * @return {number} + */ + percentageFromCfi(cfi) { + if(this._locations.length === 0) { + return null; + } + // Find closest cfi + var loc = this.locationFromCfi(cfi); + // Get percentage in total + return this.percentageFromLocation(loc); + } + + /** + * Get a percentage position from a location index + * @param {number} location + * @return {number} + */ + percentageFromLocation(loc) { + if (!loc || !this.total) { + return 0; + } + + return (loc / this.total); + } + + /** + * Get an EpubCFI from location index + * @param {number} loc + * @return {EpubCFI} cfi + */ + cfiFromLocation(loc){ + var cfi = -1; + // check that pg is an int + if(typeof loc != "number"){ + loc = parseInt(loc); + } + + if(loc >= 0 && loc < this._locations.length) { + cfi = this._locations[loc]; + } + + return cfi; + } + + /** + * Get an EpubCFI from location percentage + * @param {number} percentage + * @return {EpubCFI} cfi + */ + cfiFromPercentage(percentage){ + let loc; + if (percentage > 1) { + console.warn("Normalize cfiFromPercentage value to between 0 - 1"); + } + + // Make sure 1 goes to very end + if (percentage >= 1) { + let cfi = new EpubCFI(this._locations[this.total]); + cfi.collapse(); + return cfi.toString(); + } + + loc = Math.ceil(this.total * percentage); + return this.cfiFromLocation(loc); + } + + /** + * Load locations from JSON + * @param {json} locations + */ + load(locations){ + if (typeof locations === "string") { + this._locations = JSON.parse(locations); + } else { + this._locations = locations; + } + this.total = this._locations.length - 1; + return this._locations; + } + + /** + * Save locations to JSON + * @return {json} + */ + save(){ + return JSON.stringify(this._locations); + } + + getCurrent(){ + return this._current; + } + + setCurrent(curr){ + var loc; + + if(typeof curr == "string"){ + this._currentCfi = curr; + } else if (typeof curr == "number") { + this._current = curr; + } else { + return; + } + + if(this._locations.length === 0) { + return; + } + + if(typeof curr == "string"){ + loc = this.locationFromCfi(curr); + this._current = loc; + } else { + loc = curr; + } + + this.emit(EVENTS.LOCATIONS.CHANGED, { + percentage: this.percentageFromLocation(loc) + }); + } + + /** + * Get the current location + */ + get currentLocation() { + return this._current; + } + + /** + * Set the current location + */ + set currentLocation(curr) { + this.setCurrent(curr); + } + + /** + * Locations length + */ + length () { + return this._locations.length; + } + + destroy () { + this.spine = undefined; + this.request = undefined; + this.pause = undefined; + + this.q.stop(); + this.q = undefined; + this.epubcfi = undefined; + + this._locations = undefined + this.total = undefined; + + this.break = undefined; + this._current = undefined; + + this.currentLocation = undefined; + this._currentCfi = undefined; + clearTimeout(this.processingTimeout); + } +} + +EventEmitter(Locations.prototype); + +export default Locations; diff --git a/lib/epub.js/src/managers/continuous/index.js b/lib/epub.js/src/managers/continuous/index.js new file mode 100644 index 0000000..e6a9e61 --- /dev/null +++ b/lib/epub.js/src/managers/continuous/index.js @@ -0,0 +1,588 @@ +import {extend, defer, requestAnimationFrame} from "../../utils/core"; +import DefaultViewManager from "../default"; +import Snap from "../helpers/snap"; +import { EVENTS } from "../../utils/constants"; +import debounce from "lodash/debounce"; + +class ContinuousViewManager extends DefaultViewManager { + constructor(options) { + super(options); + + this.name = "continuous"; + + this.settings = extend(this.settings || {}, { + infinite: true, + overflow: undefined, + axis: undefined, + writingMode: undefined, + flow: "scrolled", + offset: 500, + offsetDelta: 250, + width: undefined, + height: undefined, + snap: false, + afterScrolledTimeout: 10 + }); + + extend(this.settings, options.settings || {}); + + // Gap can be 0, but defaults doesn't handle that + if (options.settings.gap != "undefined" && options.settings.gap === 0) { + this.settings.gap = options.settings.gap; + } + + this.viewSettings = { + ignoreClass: this.settings.ignoreClass, + axis: this.settings.axis, + flow: this.settings.flow, + layout: this.layout, + width: 0, + height: 0, + forceEvenPages: false + }; + + this.scrollTop = 0; + this.scrollLeft = 0; + } + + display(section, target){ + return DefaultViewManager.prototype.display.call(this, section, target) + .then(function () { + return this.fill(); + }.bind(this)); + } + + fill(_full){ + var full = _full || new defer(); + + this.q.enqueue(() => { + return this.check(); + }).then((result) => { + if (result) { + this.fill(full); + } else { + full.resolve(); + } + }); + + return full.promise; + } + + moveTo(offset){ + // var bounds = this.stage.bounds(); + // var dist = Math.floor(offset.top / bounds.height) * bounds.height; + var distX = 0, + distY = 0; + + var offsetX = 0, + offsetY = 0; + + if(!this.isPaginated) { + distY = offset.top; + offsetY = offset.top+this.settings.offsetDelta; + } else { + distX = Math.floor(offset.left / this.layout.delta) * this.layout.delta; + offsetX = distX+this.settings.offsetDelta; + } + + if (distX > 0 || distY > 0) { + this.scrollBy(distX, distY, true); + } + } + + afterResized(view){ + this.emit(EVENTS.MANAGERS.RESIZE, view.section); + } + + // Remove Previous Listeners if present + removeShownListeners(view){ + + // view.off("shown", this.afterDisplayed); + // view.off("shown", this.afterDisplayedAbove); + view.onDisplayed = function(){}; + + } + + add(section){ + var view = this.createView(section); + + this.views.append(view); + + view.on(EVENTS.VIEWS.RESIZED, (bounds) => { + view.expanded = true; + }); + + view.on(EVENTS.VIEWS.AXIS, (axis) => { + this.updateAxis(axis); + }); + + view.on(EVENTS.VIEWS.WRITING_MODE, (mode) => { + this.updateWritingMode(mode); + }); + + // view.on(EVENTS.VIEWS.SHOWN, this.afterDisplayed.bind(this)); + view.onDisplayed = this.afterDisplayed.bind(this); + view.onResize = this.afterResized.bind(this); + + return view.display(this.request); + } + + append(section){ + var view = this.createView(section); + + view.on(EVENTS.VIEWS.RESIZED, (bounds) => { + view.expanded = true; + }); + + view.on(EVENTS.VIEWS.AXIS, (axis) => { + this.updateAxis(axis); + }); + + view.on(EVENTS.VIEWS.WRITING_MODE, (mode) => { + this.updateWritingMode(mode); + }); + + this.views.append(view); + + view.onDisplayed = this.afterDisplayed.bind(this); + + return view; + } + + prepend(section){ + var view = this.createView(section); + + view.on(EVENTS.VIEWS.RESIZED, (bounds) => { + this.counter(bounds); + view.expanded = true; + }); + + view.on(EVENTS.VIEWS.AXIS, (axis) => { + this.updateAxis(axis); + }); + + view.on(EVENTS.VIEWS.WRITING_MODE, (mode) => { + this.updateWritingMode(mode); + }); + + this.views.prepend(view); + + view.onDisplayed = this.afterDisplayed.bind(this); + + return view; + } + + counter(bounds){ + if(this.settings.axis === "vertical") { + this.scrollBy(0, bounds.heightDelta, true); + } else { + this.scrollBy(bounds.widthDelta, 0, true); + } + } + + update(_offset){ + var container = this.bounds(); + var views = this.views.all(); + var viewsLength = views.length; + var visible = []; + var offset = typeof _offset != "undefined" ? _offset : (this.settings.offset || 0); + var isVisible; + var view; + + var updating = new defer(); + var promises = []; + for (var i = 0; i < viewsLength; i++) { + view = views[i]; + + isVisible = this.isVisible(view, offset, offset, container); + + if(isVisible === true) { + // console.log("visible " + view.index, view.displayed); + + if (!view.displayed) { + let displayed = view.display(this.request) + .then(function (view) { + view.show(); + }, (err) => { + view.hide(); + }); + promises.push(displayed); + } else { + view.show(); + } + visible.push(view); + } else { + this.q.enqueue(view.destroy.bind(view)); + // console.log("hidden " + view.index, view.displayed); + + clearTimeout(this.trimTimeout); + this.trimTimeout = setTimeout(function(){ + this.q.enqueue(this.trim.bind(this)); + }.bind(this), 250); + } + + } + + if(promises.length){ + return Promise.all(promises) + .catch((err) => { + updating.reject(err); + }); + } else { + updating.resolve(); + return updating.promise; + } + + } + + check(_offsetLeft, _offsetTop){ + var checking = new defer(); + var newViews = []; + + var horizontal = (this.settings.axis === "horizontal"); + var delta = this.settings.offset || 0; + + if (_offsetLeft && horizontal) { + delta = _offsetLeft; + } + + if (_offsetTop && !horizontal) { + delta = _offsetTop; + } + + var bounds = this._bounds; // bounds saved this until resize + + let offset = horizontal ? this.scrollLeft : this.scrollTop; + let visibleLength = horizontal ? Math.floor(bounds.width) : bounds.height; + let contentLength = horizontal ? this.container.scrollWidth : this.container.scrollHeight; + let writingMode = (this.writingMode && this.writingMode.indexOf("vertical") === 0) ? "vertical" : "horizontal"; + let rtlScrollType = this.settings.rtlScrollType; + let rtl = this.settings.direction === "rtl"; + + if (!this.settings.fullsize) { + // Scroll offset starts at width of element + if (rtl && rtlScrollType === "default" && writingMode === "horizontal") { + offset = contentLength - visibleLength - offset; + } + // Scroll offset starts at 0 and goes negative + if (rtl && rtlScrollType === "negative" && writingMode === "horizontal") { + offset = offset * -1; + } + } else { + // Scroll offset starts at 0 and goes negative + if ((horizontal && rtl && rtlScrollType === "negative") || + (!horizontal && rtl && rtlScrollType === "default")) { + offset = offset * -1; + } + } + + let prepend = () => { + let first = this.views.first(); + let prev = first && first.section.prev(); + + if(prev) { + newViews.push(this.prepend(prev)); + } + }; + + let append = () => { + let last = this.views.last(); + let next = last && last.section.next(); + + if(next) { + newViews.push(this.append(next)); + } + + }; + + let end = offset + visibleLength + delta; + let start = offset - delta; + + if (end >= contentLength) { + append(); + } + + if (start < 0) { + prepend(); + } + + + let promises = newViews.map((view) => { + return view.display(this.request); + }); + + if(newViews.length){ + return Promise.all(promises) + .then(() => { + return this.check(); + }) + .then(() => { + // Check to see if anything new is on screen after rendering + return this.update(delta); + }, (err) => { + return err; + }); + } else { + this.q.enqueue(function(){ + this.update(); + }.bind(this)); + checking.resolve(false); + return checking.promise; + } + + + } + + trim(){ + var task = new defer(); + var displayed = this.views.displayed(); + var first = displayed[0]; + var last = displayed[displayed.length-1]; + var firstIndex = this.views.indexOf(first); + var lastIndex = this.views.indexOf(last); + var above = this.views.slice(0, firstIndex); + var below = this.views.slice(lastIndex+1); + + // Erase all but last above + for (var i = 0; i < above.length-1; i++) { + this.erase(above[i], above); + } + + // Erase all except first below + for (var j = 1; j < below.length; j++) { + this.erase(below[j]); + } + + task.resolve(); + return task.promise; + } + + erase(view, above){ //Trim + + var prevTop; + var prevLeft; + + if(!this.settings.fullsize) { + prevTop = this.container.scrollTop; + prevLeft = this.container.scrollLeft; + } else { + prevTop = window.scrollY; + prevLeft = window.scrollX; + } + + var bounds = view.bounds(); + + this.views.remove(view); + + if(above) { + if (this.settings.axis === "vertical") { + this.scrollTo(0, prevTop - bounds.height, true); + } else { + if(this.settings.direction === 'rtl') { + if (!this.settings.fullsize) { + this.scrollTo(prevLeft, 0, true); + } else { + this.scrollTo(prevLeft + Math.floor(bounds.width), 0, true); + } + } else { + this.scrollTo(prevLeft - Math.floor(bounds.width), 0, true); + } + } + } + + } + + addEventListeners(stage){ + + window.addEventListener("unload", function(e){ + this.ignore = true; + // this.scrollTo(0,0); + this.destroy(); + }.bind(this)); + + this.addScrollListeners(); + + if (this.isPaginated && this.settings.snap) { + this.snapper = new Snap(this, this.settings.snap && (typeof this.settings.snap === "object") && this.settings.snap); + } + } + + addScrollListeners() { + var scroller; + + this.tick = requestAnimationFrame; + + let dir = this.settings.direction === "rtl" && this.settings.rtlScrollType === "default" ? -1 : 1; + + this.scrollDeltaVert = 0; + this.scrollDeltaHorz = 0; + + if(!this.settings.fullsize) { + scroller = this.container; + this.scrollTop = this.container.scrollTop; + this.scrollLeft = this.container.scrollLeft; + } else { + scroller = window; + this.scrollTop = window.scrollY * dir; + this.scrollLeft = window.scrollX * dir; + } + + this._onScroll = this.onScroll.bind(this); + scroller.addEventListener("scroll", this._onScroll); + this._scrolled = debounce(this.scrolled.bind(this), 30); + // this.tick.call(window, this.onScroll.bind(this)); + + this.didScroll = false; + + } + + removeEventListeners(){ + var scroller; + + if(!this.settings.fullsize) { + scroller = this.container; + } else { + scroller = window; + } + + scroller.removeEventListener("scroll", this._onScroll); + this._onScroll = undefined; + } + + onScroll(){ + let scrollTop; + let scrollLeft; + let dir = this.settings.direction === "rtl" && this.settings.rtlScrollType === "default" ? -1 : 1; + + if(!this.settings.fullsize) { + scrollTop = this.container.scrollTop; + scrollLeft = this.container.scrollLeft; + } else { + scrollTop = window.scrollY * dir; + scrollLeft = window.scrollX * dir; + } + + this.scrollTop = scrollTop; + this.scrollLeft = scrollLeft; + + if(!this.ignore) { + + this._scrolled(); + + } else { + this.ignore = false; + } + + this.scrollDeltaVert += Math.abs(scrollTop-this.prevScrollTop); + this.scrollDeltaHorz += Math.abs(scrollLeft-this.prevScrollLeft); + + this.prevScrollTop = scrollTop; + this.prevScrollLeft = scrollLeft; + + clearTimeout(this.scrollTimeout); + this.scrollTimeout = setTimeout(function(){ + this.scrollDeltaVert = 0; + this.scrollDeltaHorz = 0; + }.bind(this), 150); + + clearTimeout(this.afterScrolled); + + this.didScroll = false; + + } + + scrolled() { + + this.q.enqueue(function() { + return this.check(); + }.bind(this)); + + this.emit(EVENTS.MANAGERS.SCROLL, { + top: this.scrollTop, + left: this.scrollLeft + }); + + clearTimeout(this.afterScrolled); + this.afterScrolled = setTimeout(function () { + + // Don't report scroll if we are about the snap + if (this.snapper && this.snapper.supportsTouch && this.snapper.needsSnap()) { + return; + } + + this.emit(EVENTS.MANAGERS.SCROLLED, { + top: this.scrollTop, + left: this.scrollLeft + }); + + }.bind(this), this.settings.afterScrolledTimeout); + } + + next(){ + + let delta = this.layout.props.name === "pre-paginated" && + this.layout.props.spread ? this.layout.props.delta * 2 : this.layout.props.delta; + + if(!this.views.length) return; + + if(this.isPaginated && this.settings.axis === "horizontal") { + + this.scrollBy(delta, 0, true); + + } else { + + this.scrollBy(0, this.layout.height, true); + + } + + this.q.enqueue(function() { + return this.check(); + }.bind(this)); + } + + prev(){ + + let delta = this.layout.props.name === "pre-paginated" && + this.layout.props.spread ? this.layout.props.delta * 2 : this.layout.props.delta; + + if(!this.views.length) return; + + if(this.isPaginated && this.settings.axis === "horizontal") { + + this.scrollBy(-delta, 0, true); + + } else { + + this.scrollBy(0, -this.layout.height, true); + + } + + this.q.enqueue(function() { + return this.check(); + }.bind(this)); + } + + updateFlow(flow){ + if (this.rendered && this.snapper) { + this.snapper.destroy(); + this.snapper = undefined; + } + + super.updateFlow(flow, "scroll"); + + if (this.rendered && this.isPaginated && this.settings.snap) { + this.snapper = new Snap(this, this.settings.snap && (typeof this.settings.snap === "object") && this.settings.snap); + } + } + + destroy(){ + super.destroy(); + + if (this.snapper) { + this.snapper.destroy(); + } + } + +} + +export default ContinuousViewManager; diff --git a/lib/epub.js/src/managers/default/index.js b/lib/epub.js/src/managers/default/index.js new file mode 100644 index 0000000..b24a4f6 --- /dev/null +++ b/lib/epub.js/src/managers/default/index.js @@ -0,0 +1,1073 @@ +import EventEmitter from "event-emitter"; +import {extend, defer, windowBounds, isNumber} from "../../utils/core"; +import scrollType from "../../utils/scrolltype"; +import Mapping from "../../mapping"; +import Queue from "../../utils/queue"; +import Stage from "../helpers/stage"; +import Views from "../helpers/views"; +import { EVENTS } from "../../utils/constants"; + +class DefaultViewManager { + constructor(options) { + + this.name = "default"; + this.optsSettings = options.settings; + this.View = options.view; + this.request = options.request; + this.renditionQueue = options.queue; + this.q = new Queue(this); + + this.settings = extend(this.settings || {}, { + infinite: true, + hidden: false, + width: undefined, + height: undefined, + axis: undefined, + writingMode: undefined, + flow: "scrolled", + ignoreClass: "", + fullsize: undefined + }); + + extend(this.settings, options.settings || {}); + + this.viewSettings = { + ignoreClass: this.settings.ignoreClass, + axis: this.settings.axis, + flow: this.settings.flow, + layout: this.layout, + method: this.settings.method, // srcdoc, blobUrl, write + width: 0, + height: 0, + forceEvenPages: true + }; + + this.rendered = false; + + } + + render(element, size){ + let tag = element.tagName; + + if (typeof this.settings.fullsize === "undefined" && + tag && (tag.toLowerCase() == "body" || + tag.toLowerCase() == "html")) { + this.settings.fullsize = true; + } + + if (this.settings.fullsize) { + this.settings.overflow = "visible"; + this.overflow = this.settings.overflow; + } + + this.settings.size = size; + + this.settings.rtlScrollType = scrollType(); + + // Save the stage + this.stage = new Stage({ + width: size.width, + height: size.height, + overflow: this.overflow, + hidden: this.settings.hidden, + axis: this.settings.axis, + fullsize: this.settings.fullsize, + direction: this.settings.direction + }); + + this.stage.attachTo(element); + + // Get this stage container div + this.container = this.stage.getContainer(); + + // Views array methods + this.views = new Views(this.container); + + // Calculate Stage Size + this._bounds = this.bounds(); + this._stageSize = this.stage.size(); + + // Set the dimensions for views + this.viewSettings.width = this._stageSize.width; + this.viewSettings.height = this._stageSize.height; + + // Function to handle a resize event. + // Will only attach if width and height are both fixed. + this.stage.onResize(this.onResized.bind(this)); + + this.stage.onOrientationChange(this.onOrientationChange.bind(this)); + + // Add Event Listeners + this.addEventListeners(); + + // Add Layout method + // this.applyLayoutMethod(); + if (this.layout) { + this.updateLayout(); + } + + this.rendered = true; + + } + + addEventListeners(){ + var scroller; + + window.addEventListener("unload", function(e){ + this.destroy(); + }.bind(this)); + + if(!this.settings.fullsize) { + scroller = this.container; + } else { + scroller = window; + } + + this._onScroll = this.onScroll.bind(this); + scroller.addEventListener("scroll", this._onScroll); + } + + removeEventListeners(){ + var scroller; + + if(!this.settings.fullsize) { + scroller = this.container; + } else { + scroller = window; + } + + scroller.removeEventListener("scroll", this._onScroll); + this._onScroll = undefined; + } + + destroy(){ + clearTimeout(this.orientationTimeout); + clearTimeout(this.resizeTimeout); + clearTimeout(this.afterScrolled); + + this.clear(); + + this.removeEventListeners(); + + this.stage.destroy(); + + this.rendered = false; + + /* + + clearTimeout(this.trimTimeout); + if(this.settings.hidden) { + this.element.removeChild(this.wrapper); + } else { + this.element.removeChild(this.container); + } + */ + } + + onOrientationChange(e) { + let {orientation} = window; + + if(this.optsSettings.resizeOnOrientationChange) { + this.resize(); + } + + // Per ampproject: + // In IOS 10.3, the measured size of an element is incorrect if the + // element size depends on window size directly and the measurement + // happens in window.resize event. Adding a timeout for correct + // measurement. See https://github.com/ampproject/amphtml/issues/8479 + clearTimeout(this.orientationTimeout); + this.orientationTimeout = setTimeout(function(){ + this.orientationTimeout = undefined; + + if(this.optsSettings.resizeOnOrientationChange) { + this.resize(); + } + + this.emit(EVENTS.MANAGERS.ORIENTATION_CHANGE, orientation); + }.bind(this), 500); + + } + + onResized(e) { + this.resize(); + } + + resize(width, height, epubcfi){ + let stageSize = this.stage.size(width, height); + + // For Safari, wait for orientation to catch up + // if the window is a square + this.winBounds = windowBounds(); + if (this.orientationTimeout && + this.winBounds.width === this.winBounds.height) { + // reset the stage size for next resize + this._stageSize = undefined; + return; + } + + if (this._stageSize && + this._stageSize.width === stageSize.width && + this._stageSize.height === stageSize.height ) { + // Size is the same, no need to resize + return; + } + + this._stageSize = stageSize; + + this._bounds = this.bounds(); + + // Clear current views + this.clear(); + + // Update for new views + this.viewSettings.width = this._stageSize.width; + this.viewSettings.height = this._stageSize.height; + + this.updateLayout(); + + this.emit(EVENTS.MANAGERS.RESIZED, { + width: this._stageSize.width, + height: this._stageSize.height + }, epubcfi); + } + + createView(section, forceRight) { + return new this.View(section, extend(this.viewSettings, { forceRight }) ); + } + + handleNextPrePaginated(forceRight, section, action) { + let next; + + if (this.layout.name === "pre-paginated" && this.layout.divisor > 1) { + if (forceRight || section.index === 0) { + // First page (cover) should stand alone for pre-paginated books + return; + } + next = section.next(); + if (next && !next.properties.includes("page-spread-left")) { + return action.call(this, next); + } + } + } + + display(section, target){ + + var displaying = new defer(); + var displayed = displaying.promise; + + // Check if moving to target is needed + if (target === section.href || isNumber(target)) { + target = undefined; + } + + // Check to make sure the section we want isn't already shown + var visible = this.views.find(section); + + // View is already shown, just move to correct location in view + if(visible && section && this.layout.name !== "pre-paginated") { + let offset = visible.offset(); + + if (this.settings.direction === "ltr") { + this.scrollTo(offset.left, offset.top, true); + } else { + let width = visible.width(); + this.scrollTo(offset.left + width, offset.top, true); + } + + if(target) { + let offset = visible.locationOf(target); + let width = visible.width(); + this.moveTo(offset, width); + } + + displaying.resolve(); + return displayed; + } + + // Hide all current views + this.clear(); + + let forceRight = false; + if (this.layout.name === "pre-paginated" && this.layout.divisor === 2 && section.properties.includes("page-spread-right")) { + forceRight = true; + } + + this.add(section, forceRight) + .then(function(view){ + + // Move to correct place within the section, if needed + if(target) { + let offset = view.locationOf(target); + let width = view.width(); + this.moveTo(offset, width); + } + + }.bind(this), (err) => { + displaying.reject(err); + }) + .then(function(){ + return this.handleNextPrePaginated(forceRight, section, this.add); + }.bind(this)) + .then(function(){ + + this.views.show(); + + displaying.resolve(); + + }.bind(this)); + // .then(function(){ + // return this.hooks.display.trigger(view); + // }.bind(this)) + // .then(function(){ + // this.views.show(); + // }.bind(this)); + return displayed; + } + + afterDisplayed(view){ + this.emit(EVENTS.MANAGERS.ADDED, view); + } + + afterResized(view){ + this.emit(EVENTS.MANAGERS.RESIZE, view.section); + } + + moveTo(offset, width){ + var distX = 0, + distY = 0; + + if(!this.isPaginated) { + distY = offset.top; + } else { + distX = Math.floor(offset.left / this.layout.delta) * this.layout.delta; + + if (distX + this.layout.delta > this.container.scrollWidth) { + distX = this.container.scrollWidth - this.layout.delta; + } + + distY = Math.floor(offset.top / this.layout.delta) * this.layout.delta; + + if (distY + this.layout.delta > this.container.scrollHeight) { + distY = this.container.scrollHeight - this.layout.delta; + } + } + if(this.settings.direction === 'rtl'){ + /*** + the `floor` function above (L343) is on positive values, so we should add one `layout.delta` + to distX or use `Math.ceil` function, or multiply offset.left by -1 + before `Math.floor` + */ + distX = distX + this.layout.delta + distX = distX - width + } + this.scrollTo(distX, distY, true); + } + + add(section, forceRight){ + var view = this.createView(section, forceRight); + + this.views.append(view); + + // view.on(EVENTS.VIEWS.SHOWN, this.afterDisplayed.bind(this)); + view.onDisplayed = this.afterDisplayed.bind(this); + view.onResize = this.afterResized.bind(this); + + view.on(EVENTS.VIEWS.AXIS, (axis) => { + this.updateAxis(axis); + }); + + view.on(EVENTS.VIEWS.WRITING_MODE, (mode) => { + this.updateWritingMode(mode); + }); + + return view.display(this.request); + } + + append(section, forceRight){ + var view = this.createView(section, forceRight); + this.views.append(view); + + view.onDisplayed = this.afterDisplayed.bind(this); + view.onResize = this.afterResized.bind(this); + + view.on(EVENTS.VIEWS.AXIS, (axis) => { + this.updateAxis(axis); + }); + + view.on(EVENTS.VIEWS.WRITING_MODE, (mode) => { + this.updateWritingMode(mode); + }); + + return view.display(this.request); + } + + prepend(section, forceRight){ + var view = this.createView(section, forceRight); + + view.on(EVENTS.VIEWS.RESIZED, (bounds) => { + this.counter(bounds); + }); + + this.views.prepend(view); + + view.onDisplayed = this.afterDisplayed.bind(this); + view.onResize = this.afterResized.bind(this); + + view.on(EVENTS.VIEWS.AXIS, (axis) => { + this.updateAxis(axis); + }); + + view.on(EVENTS.VIEWS.WRITING_MODE, (mode) => { + this.updateWritingMode(mode); + }); + + return view.display(this.request); + } + + counter(bounds){ + if(this.settings.axis === "vertical") { + this.scrollBy(0, bounds.heightDelta, true); + } else { + this.scrollBy(bounds.widthDelta, 0, true); + } + + } + + // resizeView(view) { + // + // if(this.settings.globalLayoutProperties.layout === "pre-paginated") { + // view.lock("both", this.bounds.width, this.bounds.height); + // } else { + // view.lock("width", this.bounds.width, this.bounds.height); + // } + // + // }; + + next(){ + var next; + var left; + + let dir = this.settings.direction; + + if(!this.views.length) return; + + if(this.isPaginated && this.settings.axis === "horizontal" && (!dir || dir === "ltr")) { + + this.scrollLeft = this.container.scrollLeft; + + left = this.container.scrollLeft + this.container.offsetWidth + this.layout.delta; + + if(left <= this.container.scrollWidth) { + this.scrollBy(this.layout.delta, 0, true); + } else { + next = this.views.last().section.next(); + } + } else if (this.isPaginated && this.settings.axis === "horizontal" && dir === "rtl") { + + this.scrollLeft = this.container.scrollLeft; + + if (this.settings.rtlScrollType === "default"){ + left = this.container.scrollLeft; + + if (left > 0) { + this.scrollBy(this.layout.delta, 0, true); + } else { + next = this.views.last().section.next(); + } + } else { + left = this.container.scrollLeft + ( this.layout.delta * -1 ); + + if (left > this.container.scrollWidth * -1){ + this.scrollBy(this.layout.delta, 0, true); + } else { + next = this.views.last().section.next(); + } + } + + } else if (this.isPaginated && this.settings.axis === "vertical") { + + this.scrollTop = this.container.scrollTop; + + let top = this.container.scrollTop + this.container.offsetHeight; + + if(top < this.container.scrollHeight) { + this.scrollBy(0, this.layout.height, true); + } else { + next = this.views.last().section.next(); + } + + } else { + next = this.views.last().section.next(); + } + + if(next) { + this.clear(); + // The new section may have a different writing-mode from the old section. Thus, we need to update layout. + this.updateLayout(); + + let forceRight = false; + if (this.layout.name === "pre-paginated" && this.layout.divisor === 2 && next.properties.includes("page-spread-right")) { + forceRight = true; + } + + return this.append(next, forceRight) + .then(function(){ + return this.handleNextPrePaginated(forceRight, next, this.append); + }.bind(this), (err) => { + return err; + }) + .then(function(){ + + // Reset position to start for scrolled-doc vertical-rl in default mode + if (!this.isPaginated && + this.settings.axis === "horizontal" && + this.settings.direction === "rtl" && + this.settings.rtlScrollType === "default") { + + this.scrollTo(this.container.scrollWidth, 0, true); + } + this.views.show(); + }.bind(this)); + } + + + } + + prev(){ + var prev; + var left; + let dir = this.settings.direction; + + if(!this.views.length) return; + + if(this.isPaginated && this.settings.axis === "horizontal" && (!dir || dir === "ltr")) { + + this.scrollLeft = this.container.scrollLeft; + + left = this.container.scrollLeft; + + if(left > 0) { + this.scrollBy(-this.layout.delta, 0, true); + } else { + prev = this.views.first().section.prev(); + } + + } else if (this.isPaginated && this.settings.axis === "horizontal" && dir === "rtl") { + + this.scrollLeft = this.container.scrollLeft; + + if (this.settings.rtlScrollType === "default"){ + left = this.container.scrollLeft + this.container.offsetWidth; + + if (left < this.container.scrollWidth) { + this.scrollBy(-this.layout.delta, 0, true); + } else { + prev = this.views.first().section.prev(); + } + } + else{ + left = this.container.scrollLeft; + + if (left < 0) { + this.scrollBy(-this.layout.delta, 0, true); + } else { + prev = this.views.first().section.prev(); + } + } + + } else if (this.isPaginated && this.settings.axis === "vertical") { + + this.scrollTop = this.container.scrollTop; + + let top = this.container.scrollTop; + + if(top > 0) { + this.scrollBy(0, -(this.layout.height), true); + } else { + prev = this.views.first().section.prev(); + } + + } else { + + prev = this.views.first().section.prev(); + + } + + if(prev) { + this.clear(); + // The new section may have a different writing-mode from the old section. Thus, we need to update layout. + this.updateLayout(); + + let forceRight = false; + if (this.layout.name === "pre-paginated" && this.layout.divisor === 2 && typeof prev.prev() !== "object") { + forceRight = true; + } + + return this.prepend(prev, forceRight) + .then(function(){ + var left; + if (this.layout.name === "pre-paginated" && this.layout.divisor > 1) { + left = prev.prev(); + if (left) { + return this.prepend(left); + } + } + }.bind(this), (err) => { + return err; + }) + .then(function(){ + if(this.isPaginated && this.settings.axis === "horizontal") { + if (this.settings.direction === "rtl") { + if (this.settings.rtlScrollType === "default"){ + this.scrollTo(0, 0, true); + } + else{ + this.scrollTo((this.container.scrollWidth * -1) + this.layout.delta, 0, true); + } + } else { + this.scrollTo(this.container.scrollWidth - this.layout.delta, 0, true); + } + } + this.views.show(); + }.bind(this)); + } + } + + current(){ + var visible = this.visible(); + if(visible.length){ + // Current is the last visible view + return visible[visible.length-1]; + } + return null; + } + + clear () { + + // this.q.clear(); + + if (this.views) { + this.views.hide(); + this.scrollTo(0,0, true); + this.views.clear(); + } + } + + currentLocation(){ + this.updateLayout(); + if (this.isPaginated && this.settings.axis === "horizontal") { + this.location = this.paginatedLocation(); + } else { + this.location = this.scrolledLocation(); + } + return this.location; + } + + scrolledLocation() { + let visible = this.visible(); + let container = this.container.getBoundingClientRect(); + let pageHeight = (container.height < window.innerHeight) ? container.height : window.innerHeight; + let pageWidth = (container.width < window.innerWidth) ? container.width : window.innerWidth; + let vertical = (this.settings.axis === "vertical"); + let rtl = (this.settings.direction === "rtl"); + + let offset = 0; + let used = 0; + + if(this.settings.fullsize) { + offset = vertical ? window.scrollY : window.scrollX; + } + + let sections = visible.map((view) => { + let {index, href} = view.section; + let position = view.position(); + let width = view.width(); + let height = view.height(); + + let startPos; + let endPos; + let stopPos; + let totalPages; + + if (vertical) { + startPos = offset + container.top - position.top + used; + endPos = startPos + pageHeight - used; + totalPages = this.layout.count(height, pageHeight).pages; + stopPos = pageHeight; + } else { + startPos = offset + container.left - position.left + used; + endPos = startPos + pageWidth - used; + totalPages = this.layout.count(width, pageWidth).pages; + stopPos = pageWidth; + } + + let currPage = Math.ceil(startPos / stopPos); + let pages = []; + let endPage = Math.ceil(endPos / stopPos); + + // Reverse page counts for horizontal rtl + if (this.settings.direction === "rtl" && !vertical) { + let tempStartPage = currPage; + currPage = totalPages - endPage; + endPage = totalPages - tempStartPage; + } + + pages = []; + for (var i = currPage; i <= endPage; i++) { + let pg = i + 1; + pages.push(pg); + } + + let mapping = this.mapping.page(view.contents, view.section.cfiBase, startPos, endPos); + + return { + index, + href, + pages, + totalPages, + mapping + }; + }); + + return sections; + } + + paginatedLocation(){ + let visible = this.visible(); + let container = this.container.getBoundingClientRect(); + + let left = 0; + let used = 0; + + if(this.settings.fullsize) { + left = window.scrollX; + } + + let sections = visible.map((view) => { + let {index, href} = view.section; + let offset; + let position = view.position(); + let width = view.width(); + + // Find mapping + let start; + let end; + let pageWidth; + + if (this.settings.direction === "rtl") { + offset = container.right - left; + pageWidth = Math.min(Math.abs(offset - position.left), this.layout.width) - used; + end = position.width - (position.right - offset) - used; + start = end - pageWidth; + } else { + offset = container.left + left; + pageWidth = Math.min(position.right - offset, this.layout.width) - used; + start = offset - position.left + used; + end = start + pageWidth; + } + + used += pageWidth; + + let mapping = this.mapping.page(view.contents, view.section.cfiBase, start, end); + + let totalPages = this.layout.count(width).pages; + let startPage = Math.floor(start / this.layout.pageWidth); + let pages = []; + let endPage = Math.floor(end / this.layout.pageWidth); + + // start page should not be negative + if (startPage < 0) { + startPage = 0; + endPage = endPage + 1; + } + + // Reverse page counts for rtl + if (this.settings.direction === "rtl") { + let tempStartPage = startPage; + startPage = totalPages - endPage; + endPage = totalPages - tempStartPage; + } + + + for (var i = startPage + 1; i <= endPage; i++) { + let pg = i; + pages.push(pg); + } + + return { + index, + href, + pages, + totalPages, + mapping + }; + }); + + return sections; + } + + isVisible(view, offsetPrev, offsetNext, _container){ + var position = view.position(); + var container = _container || this.bounds(); + + if(this.settings.axis === "horizontal" && + position.right > container.left - offsetPrev && + position.left < container.right + offsetNext) { + + return true; + + } else if(this.settings.axis === "vertical" && + position.bottom > container.top - offsetPrev && + position.top < container.bottom + offsetNext) { + + return true; + } + + return false; + + } + + visible(){ + var container = this.bounds(); + var views = this.views.displayed(); + var viewsLength = views.length; + var visible = []; + var isVisible; + var view; + + for (var i = 0; i < viewsLength; i++) { + view = views[i]; + isVisible = this.isVisible(view, 0, 0, container); + + if(isVisible === true) { + visible.push(view); + } + + } + return visible; + } + + scrollBy(x, y, silent){ + let dir = this.settings.direction === "rtl" ? -1 : 1; + + if(silent) { + this.ignore = true; + } + + if(!this.settings.fullsize) { + if(x) this.container.scrollLeft += x * dir; + if(y) this.container.scrollTop += y; + } else { + window.scrollBy(x * dir, y * dir); + } + this.scrolled = true; + } + + scrollTo(x, y, silent){ + if(silent) { + this.ignore = true; + } + + if(!this.settings.fullsize) { + this.container.scrollLeft = x; + this.container.scrollTop = y; + } else { + window.scrollTo(x,y); + } + this.scrolled = true; + } + + onScroll(){ + let scrollTop; + let scrollLeft; + + if(!this.settings.fullsize) { + scrollTop = this.container.scrollTop; + scrollLeft = this.container.scrollLeft; + } else { + scrollTop = window.scrollY; + scrollLeft = window.scrollX; + } + + this.scrollTop = scrollTop; + this.scrollLeft = scrollLeft; + + if(!this.ignore) { + this.emit(EVENTS.MANAGERS.SCROLL, { + top: scrollTop, + left: scrollLeft + }); + + clearTimeout(this.afterScrolled); + this.afterScrolled = setTimeout(function () { + this.emit(EVENTS.MANAGERS.SCROLLED, { + top: this.scrollTop, + left: this.scrollLeft + }); + }.bind(this), 20); + + + + } else { + this.ignore = false; + } + + } + + bounds() { + var bounds; + + bounds = this.stage.bounds(); + + return bounds; + } + + applyLayout(layout) { + + this.layout = layout; + this.updateLayout(); + if (this.views && this.views.length > 0 && this.layout.name === "pre-paginated") { + this.display(this.views.first().section); + } + // this.manager.layout(this.layout.format); + } + + updateLayout() { + + if (!this.stage) { + return; + } + + this._stageSize = this.stage.size(); + + if(!this.isPaginated) { + this.layout.calculate(this._stageSize.width, this._stageSize.height); + } else { + this.layout.calculate( + this._stageSize.width, + this._stageSize.height, + this.settings.gap + ); + + // Set the look ahead offset for what is visible + this.settings.offset = this.layout.delta / this.layout.divisor; + + // this.stage.addStyleRules("iframe", [{"margin-right" : this.layout.gap + "px"}]); + + } + + // Set the dimensions for views + this.viewSettings.width = this.layout.width; + this.viewSettings.height = this.layout.height; + + this.setLayout(this.layout); + } + + setLayout(layout){ + + this.viewSettings.layout = layout; + + this.mapping = new Mapping(layout.props, this.settings.direction, this.settings.axis); + + if(this.views) { + + this.views.forEach(function(view){ + if (view) { + view.setLayout(layout); + } + }); + + } + + } + + updateWritingMode(mode) { + this.writingMode = mode; + } + + updateAxis(axis, forceUpdate){ + + if (!forceUpdate && axis === this.settings.axis) { + return; + } + + this.settings.axis = axis; + + this.stage && this.stage.axis(axis); + + this.viewSettings.axis = axis; + + if (this.mapping) { + this.mapping = new Mapping(this.layout.props, this.settings.direction, this.settings.axis); + } + + if (this.layout) { + if (axis === "vertical") { + this.layout.spread("none"); + } else { + this.layout.spread(this.layout.settings.spread); + } + } + } + + updateFlow(flow, defaultScrolledOverflow="auto"){ + let isPaginated = (flow === "paginated" || flow === "auto"); + + this.isPaginated = isPaginated; + + if (flow === "scrolled-doc" || + flow === "scrolled-continuous" || + flow === "scrolled") { + this.updateAxis("vertical"); + } else { + this.updateAxis("horizontal"); + } + + this.viewSettings.flow = flow; + + if (!this.settings.overflow) { + this.overflow = isPaginated ? "hidden" : defaultScrolledOverflow; + } else { + this.overflow = this.settings.overflow; + } + + this.stage && this.stage.overflow(this.overflow); + + this.updateLayout(); + + } + + getContents(){ + var contents = []; + if (!this.views) { + return contents; + } + this.views.forEach(function(view){ + const viewContents = view && view.contents; + if (viewContents) { + contents.push(viewContents); + } + }); + return contents; + } + + direction(dir="ltr") { + this.settings.direction = dir; + + this.stage && this.stage.direction(dir); + + this.viewSettings.direction = dir; + + this.updateLayout(); + } + + isRendered() { + return this.rendered; + } +} + +//-- Enable binding events to Manager +EventEmitter(DefaultViewManager.prototype); + +export default DefaultViewManager; diff --git a/lib/epub.js/src/managers/helpers/snap.js b/lib/epub.js/src/managers/helpers/snap.js new file mode 100644 index 0000000..db0aaff --- /dev/null +++ b/lib/epub.js/src/managers/helpers/snap.js @@ -0,0 +1,338 @@ +import {extend, defer, requestAnimationFrame, prefixed} from "../../utils/core"; +import { EVENTS, DOM_EVENTS } from "../../utils/constants"; +import EventEmitter from "event-emitter"; + +// easing equations from https://github.com/danro/easing-js/blob/master/easing.js +const PI_D2 = (Math.PI / 2); +const EASING_EQUATIONS = { + easeOutSine: function (pos) { + return Math.sin(pos * PI_D2); + }, + easeInOutSine: function (pos) { + return (-0.5 * (Math.cos(Math.PI * pos) - 1)); + }, + easeInOutQuint: function (pos) { + if ((pos /= 0.5) < 1) { + return 0.5 * Math.pow(pos, 5); + } + return 0.5 * (Math.pow((pos - 2), 5) + 2); + }, + easeInCubic: function(pos) { + return Math.pow(pos, 3); + } +}; + +class Snap { + constructor(manager, options) { + + this.settings = extend({ + duration: 80, + minVelocity: 0.2, + minDistance: 10, + easing: EASING_EQUATIONS['easeInCubic'] + }, options || {}); + + this.supportsTouch = this.supportsTouch(); + + if (this.supportsTouch) { + this.setup(manager); + } + } + + setup(manager) { + this.manager = manager; + + this.layout = this.manager.layout; + + this.fullsize = this.manager.settings.fullsize; + if (this.fullsize) { + this.element = this.manager.stage.element; + this.scroller = window; + this.disableScroll(); + } else { + this.element = this.manager.stage.container; + this.scroller = this.element; + this.element.style["WebkitOverflowScrolling"] = "touch"; + } + + // this.overflow = this.manager.overflow; + + // set lookahead offset to page width + this.manager.settings.offset = this.layout.width; + this.manager.settings.afterScrolledTimeout = this.settings.duration * 2; + + this.isVertical = this.manager.settings.axis === "vertical"; + + // disable snapping if not paginated or axis in not horizontal + if (!this.manager.isPaginated || this.isVertical) { + return; + } + + this.touchCanceler = false; + this.resizeCanceler = false; + this.snapping = false; + + + this.scrollLeft; + this.scrollTop; + + this.startTouchX = undefined; + this.startTouchY = undefined; + this.startTime = undefined; + this.endTouchX = undefined; + this.endTouchY = undefined; + this.endTime = undefined; + + this.addListeners(); + } + + supportsTouch() { + if (('ontouchstart' in window) || window.DocumentTouch && document instanceof DocumentTouch) { + return true; + } + + return false; + } + + disableScroll() { + this.element.style.overflow = "hidden"; + } + + enableScroll() { + this.element.style.overflow = ""; + } + + addListeners() { + this._onResize = this.onResize.bind(this); + window.addEventListener('resize', this._onResize); + + this._onScroll = this.onScroll.bind(this); + this.scroller.addEventListener('scroll', this._onScroll); + + this._onTouchStart = this.onTouchStart.bind(this); + this.scroller.addEventListener('touchstart', this._onTouchStart, { passive: true }); + this.on('touchstart', this._onTouchStart); + + this._onTouchMove = this.onTouchMove.bind(this); + this.scroller.addEventListener('touchmove', this._onTouchMove, { passive: true }); + this.on('touchmove', this._onTouchMove); + + this._onTouchEnd = this.onTouchEnd.bind(this); + this.scroller.addEventListener('touchend', this._onTouchEnd, { passive: true }); + this.on('touchend', this._onTouchEnd); + + this._afterDisplayed = this.afterDisplayed.bind(this); + this.manager.on(EVENTS.MANAGERS.ADDED, this._afterDisplayed); + } + + removeListeners() { + window.removeEventListener('resize', this._onResize); + this._onResize = undefined; + + this.scroller.removeEventListener('scroll', this._onScroll); + this._onScroll = undefined; + + this.scroller.removeEventListener('touchstart', this._onTouchStart, { passive: true }); + this.off('touchstart', this._onTouchStart); + this._onTouchStart = undefined; + + this.scroller.removeEventListener('touchmove', this._onTouchMove, { passive: true }); + this.off('touchmove', this._onTouchMove); + this._onTouchMove = undefined; + + this.scroller.removeEventListener('touchend', this._onTouchEnd, { passive: true }); + this.off('touchend', this._onTouchEnd); + this._onTouchEnd = undefined; + + this.manager.off(EVENTS.MANAGERS.ADDED, this._afterDisplayed); + this._afterDisplayed = undefined; + } + + afterDisplayed(view) { + let contents = view.contents; + ["touchstart", "touchmove", "touchend"].forEach((e) => { + contents.on(e, (ev) => this.triggerViewEvent(ev, contents)); + }); + } + + triggerViewEvent(e, contents){ + this.emit(e.type, e, contents); + } + + onScroll(e) { + this.scrollLeft = this.fullsize ? window.scrollX : this.scroller.scrollLeft; + this.scrollTop = this.fullsize ? window.scrollY : this.scroller.scrollTop; + } + + onResize(e) { + this.resizeCanceler = true; + } + + onTouchStart(e) { + let { screenX, screenY } = e.touches[0]; + + if (this.fullsize) { + this.enableScroll(); + } + + this.touchCanceler = true; + + if (!this.startTouchX) { + this.startTouchX = screenX; + this.startTouchY = screenY; + this.startTime = this.now(); + } + + this.endTouchX = screenX; + this.endTouchY = screenY; + this.endTime = this.now(); + } + + onTouchMove(e) { + let { screenX, screenY } = e.touches[0]; + let deltaY = Math.abs(screenY - this.endTouchY); + + this.touchCanceler = true; + + + if (!this.fullsize && deltaY < 10) { + this.element.scrollLeft -= screenX - this.endTouchX; + } + + this.endTouchX = screenX; + this.endTouchY = screenY; + this.endTime = this.now(); + } + + onTouchEnd(e) { + if (this.fullsize) { + this.disableScroll(); + } + + this.touchCanceler = false; + + let swipped = this.wasSwiped(); + + if (swipped !== 0) { + this.snap(swipped); + } else { + this.snap(); + } + + this.startTouchX = undefined; + this.startTouchY = undefined; + this.startTime = undefined; + this.endTouchX = undefined; + this.endTouchY = undefined; + this.endTime = undefined; + } + + wasSwiped() { + let snapWidth = this.layout.pageWidth * this.layout.divisor; + let distance = (this.endTouchX - this.startTouchX); + let absolute = Math.abs(distance); + let time = this.endTime - this.startTime; + let velocity = (distance / time); + let minVelocity = this.settings.minVelocity; + + if (absolute <= this.settings.minDistance || absolute >= snapWidth) { + return 0; + } + + if (velocity > minVelocity) { + // previous + return -1; + } else if (velocity < -minVelocity) { + // next + return 1; + } + } + + needsSnap() { + let left = this.scrollLeft; + let snapWidth = this.layout.pageWidth * this.layout.divisor; + return (left % snapWidth) !== 0; + } + + snap(howMany=0) { + let left = this.scrollLeft; + let snapWidth = this.layout.pageWidth * this.layout.divisor; + let snapTo = Math.round(left / snapWidth) * snapWidth; + + if (howMany) { + snapTo += (howMany * snapWidth); + } + + return this.smoothScrollTo(snapTo); + } + + smoothScrollTo(destination) { + const deferred = new defer(); + const start = this.scrollLeft; + const startTime = this.now(); + + const duration = this.settings.duration; + const easing = this.settings.easing; + + this.snapping = true; + + // add animation loop + function tick() { + const now = this.now(); + const time = Math.min(1, ((now - startTime) / duration)); + const timeFunction = easing(time); + + + if (this.touchCanceler || this.resizeCanceler) { + this.resizeCanceler = false; + this.snapping = false; + deferred.resolve(); + return; + } + + if (time < 1) { + window.requestAnimationFrame(tick.bind(this)); + this.scrollTo(start + ((destination - start) * time), 0); + } else { + this.scrollTo(destination, 0); + this.snapping = false; + deferred.resolve(); + } + } + + tick.call(this); + + return deferred.promise; + } + + scrollTo(left=0, top=0) { + if (this.fullsize) { + window.scroll(left, top); + } else { + this.scroller.scrollLeft = left; + this.scroller.scrollTop = top; + } + } + + now() { + return ('now' in window.performance) ? performance.now() : new Date().getTime(); + } + + destroy() { + if (!this.scroller) { + return; + } + + if (this.fullsize) { + this.enableScroll(); + } + + this.removeListeners(); + + this.scroller = undefined; + } +} + +EventEmitter(Snap.prototype); + +export default Snap; diff --git a/lib/epub.js/src/managers/helpers/stage.js b/lib/epub.js/src/managers/helpers/stage.js new file mode 100644 index 0000000..d0f67e6 --- /dev/null +++ b/lib/epub.js/src/managers/helpers/stage.js @@ -0,0 +1,363 @@ +import {uuid, isNumber, isElement, windowBounds, extend} from "../../utils/core"; +import throttle from 'lodash/throttle' + +class Stage { + constructor(_options) { + this.settings = _options || {}; + this.id = "epubjs-container-" + uuid(); + + this.container = this.create(this.settings); + + if(this.settings.hidden) { + this.wrapper = this.wrap(this.container); + } + + } + + /* + * Creates an element to render to. + * Resizes to passed width and height or to the elements size + */ + create(options){ + let height = options.height;// !== false ? options.height : "100%"; + let width = options.width;// !== false ? options.width : "100%"; + let overflow = options.overflow || false; + let axis = options.axis || "vertical"; + let direction = options.direction; + + extend(this.settings, options); + + if(options.height && isNumber(options.height)) { + height = options.height + "px"; + } + + if(options.width && isNumber(options.width)) { + width = options.width + "px"; + } + + // Create new container element + let container = document.createElement("div"); + + container.id = this.id; + container.classList.add("epub-container"); + + // Style Element + // container.style.fontSize = "0"; + container.style.wordSpacing = "0"; + container.style.lineHeight = "0"; + container.style.verticalAlign = "top"; + container.style.position = "relative"; + + if(axis === "horizontal") { + // container.style.whiteSpace = "nowrap"; + container.style.display = "flex"; + container.style.flexDirection = "row"; + container.style.flexWrap = "nowrap"; + } + + if(width){ + container.style.width = width; + } + + if(height){ + container.style.height = height; + } + + if (overflow) { + if (overflow === "scroll" && axis === "vertical") { + container.style["overflow-y"] = overflow; + container.style["overflow-x"] = "hidden"; + } else if (overflow === "scroll" && axis === "horizontal") { + container.style["overflow-y"] = "hidden"; + container.style["overflow-x"] = overflow; + } else { + container.style["overflow"] = overflow; + } + } + + if (direction) { + container.dir = direction; + container.style["direction"] = direction; + } + + if (direction && this.settings.fullsize) { + document.body.style["direction"] = direction; + } + + return container; + } + + wrap(container) { + var wrapper = document.createElement("div"); + + wrapper.style.visibility = "hidden"; + wrapper.style.overflow = "hidden"; + wrapper.style.width = "0"; + wrapper.style.height = "0"; + + wrapper.appendChild(container); + return wrapper; + } + + + getElement(_element){ + var element; + + if(isElement(_element)) { + element = _element; + } else if (typeof _element === "string") { + element = document.getElementById(_element); + } + + if(!element){ + throw new Error("Not an Element"); + } + + return element; + } + + attachTo(what){ + + var element = this.getElement(what); + var base; + + if(!element){ + return; + } + + if(this.settings.hidden) { + base = this.wrapper; + } else { + base = this.container; + } + + element.appendChild(base); + + this.element = element; + + return element; + + } + + getContainer() { + return this.container; + } + + onResize(func){ + // Only listen to window for resize event if width and height are not fixed. + // This applies if it is set to a percent or auto. + if(!isNumber(this.settings.width) || + !isNumber(this.settings.height) ) { + this.resizeFunc = throttle(func, 50); + window.addEventListener("resize", this.resizeFunc, false); + } + + } + + onOrientationChange(func){ + this.orientationChangeFunc = func; + window.addEventListener("orientationchange", this.orientationChangeFunc, false); + } + + size(width, height){ + var bounds; + let _width = width || this.settings.width; + let _height = height || this.settings.height; + + // If width or height are set to false, inherit them from containing element + if(width === null) { + bounds = this.element.getBoundingClientRect(); + + if(bounds.width) { + width = Math.floor(bounds.width); + this.container.style.width = width + "px"; + } + } else { + if (isNumber(width)) { + this.container.style.width = width + "px"; + } else { + this.container.style.width = width; + } + } + + if(height === null) { + bounds = bounds || this.element.getBoundingClientRect(); + + if(bounds.height) { + height = bounds.height; + this.container.style.height = height + "px"; + } + + } else { + if (isNumber(height)) { + this.container.style.height = height + "px"; + } else { + this.container.style.height = height; + } + } + + if(!isNumber(width)) { + width = this.container.clientWidth; + } + + if(!isNumber(height)) { + height = this.container.clientHeight; + } + + this.containerStyles = window.getComputedStyle(this.container); + + this.containerPadding = { + left: parseFloat(this.containerStyles["padding-left"]) || 0, + right: parseFloat(this.containerStyles["padding-right"]) || 0, + top: parseFloat(this.containerStyles["padding-top"]) || 0, + bottom: parseFloat(this.containerStyles["padding-bottom"]) || 0 + }; + + // Bounds not set, get them from window + let _windowBounds = windowBounds(); + let bodyStyles = window.getComputedStyle(document.body); + let bodyPadding = { + left: parseFloat(bodyStyles["padding-left"]) || 0, + right: parseFloat(bodyStyles["padding-right"]) || 0, + top: parseFloat(bodyStyles["padding-top"]) || 0, + bottom: parseFloat(bodyStyles["padding-bottom"]) || 0 + }; + + if (!_width) { + width = _windowBounds.width - + bodyPadding.left - + bodyPadding.right; + } + + if ((this.settings.fullsize && !_height) || !_height) { + height = _windowBounds.height - + bodyPadding.top - + bodyPadding.bottom; + } + + return { + width: width - + this.containerPadding.left - + this.containerPadding.right, + height: height - + this.containerPadding.top - + this.containerPadding.bottom + }; + + } + + bounds(){ + let box; + if (this.container.style.overflow !== "visible") { + box = this.container && this.container.getBoundingClientRect(); + } + + if(!box || !box.width || !box.height) { + return windowBounds(); + } else { + return box; + } + + } + + getSheet(){ + var style = document.createElement("style"); + + // WebKit hack --> https://davidwalsh.name/add-rules-stylesheets + style.appendChild(document.createTextNode("")); + + document.head.appendChild(style); + + return style.sheet; + } + + addStyleRules(selector, rulesArray){ + var scope = "#" + this.id + " "; + var rules = ""; + + if(!this.sheet){ + this.sheet = this.getSheet(); + } + + rulesArray.forEach(function(set) { + for (var prop in set) { + if(set.hasOwnProperty(prop)) { + rules += prop + ":" + set[prop] + ";"; + } + } + }); + + this.sheet.insertRule(scope + selector + " {" + rules + "}", 0); + } + + axis(axis) { + if(axis === "horizontal") { + this.container.style.display = "flex"; + this.container.style.flexDirection = "row"; + this.container.style.flexWrap = "nowrap"; + } else { + this.container.style.display = "block"; + } + this.settings.axis = axis; + } + + // orientation(orientation) { + // if (orientation === "landscape") { + // + // } else { + // + // } + // + // this.orientation = orientation; + // } + + direction(dir) { + if (this.container) { + this.container.dir = dir; + this.container.style["direction"] = dir; + } + + if (this.settings.fullsize) { + document.body.style["direction"] = dir; + } + this.settings.dir = dir; + } + + overflow(overflow) { + if (this.container) { + if (overflow === "scroll" && this.settings.axis === "vertical") { + this.container.style["overflow-y"] = overflow; + this.container.style["overflow-x"] = "hidden"; + } else if (overflow === "scroll" && this.settings.axis === "horizontal") { + this.container.style["overflow-y"] = "hidden"; + this.container.style["overflow-x"] = overflow; + } else { + this.container.style["overflow"] = overflow; + } + } + this.settings.overflow = overflow; + } + + destroy() { + var base; + + if (this.element) { + + if(this.settings.hidden) { + base = this.wrapper; + } else { + base = this.container; + } + + if(this.element.contains(this.container)) { + this.element.removeChild(this.container); + } + + window.removeEventListener("resize", this.resizeFunc); + window.removeEventListener("orientationChange", this.orientationChangeFunc); + + } + } +} + +export default Stage; diff --git a/lib/epub.js/src/managers/helpers/views.js b/lib/epub.js/src/managers/helpers/views.js new file mode 100644 index 0000000..4368da2 --- /dev/null +++ b/lib/epub.js/src/managers/helpers/views.js @@ -0,0 +1,167 @@ +class Views { + constructor(container) { + this.container = container; + this._views = []; + this.length = 0; + this.hidden = false; + } + + all() { + return this._views; + } + + first() { + return this._views[0]; + } + + last() { + return this._views[this._views.length-1]; + } + + indexOf(view) { + return this._views.indexOf(view); + } + + slice() { + return this._views.slice.apply(this._views, arguments); + } + + get(i) { + return this._views[i]; + } + + append(view){ + this._views.push(view); + if(this.container){ + this.container.appendChild(view.element); + } + this.length++; + return view; + } + + prepend(view){ + this._views.unshift(view); + if(this.container){ + this.container.insertBefore(view.element, this.container.firstChild); + } + this.length++; + return view; + } + + insert(view, index) { + this._views.splice(index, 0, view); + + if(this.container){ + if(index < this.container.children.length){ + this.container.insertBefore(view.element, this.container.children[index]); + } else { + this.container.appendChild(view.element); + } + } + + this.length++; + return view; + } + + remove(view) { + var index = this._views.indexOf(view); + + if(index > -1) { + this._views.splice(index, 1); + } + + + this.destroy(view); + + this.length--; + } + + destroy(view) { + if(view.displayed){ + view.destroy(); + } + + if(this.container){ + this.container.removeChild(view.element); + } + view = null; + } + + // Iterators + + forEach() { + return this._views.forEach.apply(this._views, arguments); + } + + clear(){ + // Remove all views + var view; + var len = this.length; + + if(!this.length) return; + + for (var i = 0; i < len; i++) { + view = this._views[i]; + this.destroy(view); + } + + this._views = []; + this.length = 0; + } + + find(section){ + + var view; + var len = this.length; + + for (var i = 0; i < len; i++) { + view = this._views[i]; + if(view.displayed && view.section.index == section.index) { + return view; + } + } + + } + + displayed(){ + var displayed = []; + var view; + var len = this.length; + + for (var i = 0; i < len; i++) { + view = this._views[i]; + if(view.displayed){ + displayed.push(view); + } + } + return displayed; + } + + show(){ + var view; + var len = this.length; + + for (var i = 0; i < len; i++) { + view = this._views[i]; + if(view.displayed){ + view.show(); + } + } + this.hidden = false; + } + + hide(){ + var view; + var len = this.length; + + for (var i = 0; i < len; i++) { + view = this._views[i]; + if(view.displayed){ + view.hide(); + } + } + this.hidden = true; + } +} + +export default Views; diff --git a/lib/epub.js/src/managers/views/iframe.js b/lib/epub.js/src/managers/views/iframe.js new file mode 100644 index 0000000..76b2c1d --- /dev/null +++ b/lib/epub.js/src/managers/views/iframe.js @@ -0,0 +1,835 @@ +import EventEmitter from "event-emitter"; +import {extend, borders, uuid, isNumber, bounds, defer, createBlobUrl, revokeBlobUrl} from "../../utils/core"; +import EpubCFI from "../../epubcfi"; +import Contents from "../../contents"; +import { EVENTS } from "../../utils/constants"; +import { Pane, Highlight, Underline } from "marks-pane"; + +class IframeView { + constructor(section, options) { + this.settings = extend({ + ignoreClass : "", + axis: undefined, //options.layout && options.layout.props.flow === "scrolled" ? "vertical" : "horizontal", + direction: undefined, + width: 0, + height: 0, + layout: undefined, + globalLayoutProperties: {}, + method: undefined, + forceRight: false + }, options || {}); + + this.id = "epubjs-view-" + uuid(); + this.section = section; + this.index = section.index; + + this.element = this.container(this.settings.axis); + + this.added = false; + this.displayed = false; + this.rendered = false; + + // this.width = this.settings.width; + // this.height = this.settings.height; + + this.fixedWidth = 0; + this.fixedHeight = 0; + + // Blank Cfi for Parsing + this.epubcfi = new EpubCFI(); + + this.layout = this.settings.layout; + // Dom events to listen for + // this.listenedEvents = ["keydown", "keyup", "keypressed", "mouseup", "mousedown", "click", "touchend", "touchstart"]; + + this.pane = undefined; + this.highlights = {}; + this.underlines = {}; + this.marks = {}; + + } + + container(axis) { + var element = document.createElement("div"); + + element.classList.add("epub-view"); + + // this.element.style.minHeight = "100px"; + element.style.height = "0px"; + element.style.width = "0px"; + element.style.overflow = "hidden"; + element.style.position = "relative"; + element.style.display = "block"; + + if(axis && axis == "horizontal"){ + element.style.flex = "none"; + } else { + element.style.flex = "initial"; + } + + return element; + } + + create() { + + if(this.iframe) { + return this.iframe; + } + + if(!this.element) { + this.element = this.createContainer(); + } + + this.iframe = document.createElement("iframe"); + this.iframe.id = this.id; + this.iframe.scrolling = "no"; // Might need to be removed: breaks ios width calculations + this.iframe.style.overflow = "hidden"; + this.iframe.seamless = "seamless"; + // Back up if seamless isn't supported + this.iframe.style.border = "none"; + + this.iframe.setAttribute("enable-annotation", "true"); + + this.resizing = true; + + // this.iframe.style.display = "none"; + this.element.style.visibility = "hidden"; + this.iframe.style.visibility = "hidden"; + + this.iframe.style.width = "0"; + this.iframe.style.height = "0"; + this._width = 0; + this._height = 0; + + this.element.setAttribute("ref", this.index); + + this.added = true; + + this.elementBounds = bounds(this.element); + + // if(width || height){ + // this.resize(width, height); + // } else if(this.width && this.height){ + // this.resize(this.width, this.height); + // } else { + // this.iframeBounds = bounds(this.iframe); + // } + + + if(("srcdoc" in this.iframe)) { + this.supportsSrcdoc = true; + } else { + this.supportsSrcdoc = false; + } + + if (!this.settings.method) { + this.settings.method = this.supportsSrcdoc ? "srcdoc" : "write"; + } + + return this.iframe; + } + + render(request, show) { + + // view.onLayout = this.layout.format.bind(this.layout); + this.create(); + + // Fit to size of the container, apply padding + this.size(); + + if(!this.sectionRender) { + this.sectionRender = this.section.render(request); + } + + // Render Chain + return this.sectionRender + .then(function(contents){ + return this.load(contents); + }.bind(this)) + .then(function(){ + + // find and report the writingMode axis + let writingMode = this.contents.writingMode(); + + // Set the axis based on the flow and writing mode + let axis; + if (this.settings.flow === "scrolled") { + axis = (writingMode.indexOf("vertical") === 0) ? "horizontal" : "vertical"; + } else { + axis = (writingMode.indexOf("vertical") === 0) ? "vertical" : "horizontal"; + } + + if (writingMode.indexOf("vertical") === 0 && this.settings.flow === "paginated") { + this.layout.delta = this.layout.height; + } + + this.setAxis(axis); + this.emit(EVENTS.VIEWS.AXIS, axis); + + this.setWritingMode(writingMode); + this.emit(EVENTS.VIEWS.WRITING_MODE, writingMode); + + + // apply the layout function to the contents + this.layout.format(this.contents, this.section, this.axis); + + // Listen for events that require an expansion of the iframe + this.addListeners(); + + return new Promise((resolve, reject) => { + // Expand the iframe to the full size of the content + this.expand(); + + if (this.settings.forceRight) { + this.element.style.marginLeft = this.width() + "px"; + } + resolve(); + }); + + }.bind(this), function(e){ + this.emit(EVENTS.VIEWS.LOAD_ERROR, e); + return new Promise((resolve, reject) => { + reject(e); + }); + }.bind(this)) + .then(function() { + this.emit(EVENTS.VIEWS.RENDERED, this.section); + }.bind(this)); + + } + + reset () { + if (this.iframe) { + this.iframe.style.width = "0"; + this.iframe.style.height = "0"; + this._width = 0; + this._height = 0; + this._textWidth = undefined; + this._contentWidth = undefined; + this._textHeight = undefined; + this._contentHeight = undefined; + } + this._needsReframe = true; + } + + // Determine locks base on settings + size(_width, _height) { + var width = _width || this.settings.width; + var height = _height || this.settings.height; + + if(this.layout.name === "pre-paginated") { + this.lock("both", width, height); + } else if(this.settings.axis === "horizontal") { + this.lock("height", width, height); + } else { + this.lock("width", width, height); + } + + this.settings.width = width; + this.settings.height = height; + } + + // Lock an axis to element dimensions, taking borders into account + lock(what, width, height) { + var elBorders = borders(this.element); + var iframeBorders; + + if(this.iframe) { + iframeBorders = borders(this.iframe); + } else { + iframeBorders = {width: 0, height: 0}; + } + + if(what == "width" && isNumber(width)){ + this.lockedWidth = width - elBorders.width - iframeBorders.width; + // this.resize(this.lockedWidth, width); // width keeps ratio correct + } + + if(what == "height" && isNumber(height)){ + this.lockedHeight = height - elBorders.height - iframeBorders.height; + // this.resize(width, this.lockedHeight); + } + + if(what === "both" && + isNumber(width) && + isNumber(height)){ + + this.lockedWidth = width - elBorders.width - iframeBorders.width; + this.lockedHeight = height - elBorders.height - iframeBorders.height; + // this.resize(this.lockedWidth, this.lockedHeight); + } + + if(this.displayed && this.iframe) { + + // this.contents.layout(); + this.expand(); + } + + + + } + + // Resize a single axis based on content dimensions + expand(force) { + var width = this.lockedWidth; + var height = this.lockedHeight; + var columns; + + var textWidth, textHeight; + + if(!this.iframe || this._expanding) return; + + this._expanding = true; + + if(this.layout.name === "pre-paginated") { + width = this.layout.columnWidth; + height = this.layout.height; + } + // Expand Horizontally + else if(this.settings.axis === "horizontal") { + // Get the width of the text + width = this.contents.textWidth(); + + if (width % this.layout.pageWidth > 0) { + width = Math.ceil(width / this.layout.pageWidth) * this.layout.pageWidth; + } + + if (this.settings.forceEvenPages) { + columns = (width / this.layout.pageWidth); + if ( this.layout.divisor > 1 && + this.layout.name === "reflowable" && + (columns % 2 > 0)) { + // add a blank page + width += this.layout.pageWidth; + } + } + + } // Expand Vertically + else if(this.settings.axis === "vertical") { + height = this.contents.textHeight(); + if (this.settings.flow === "paginated" && + height % this.layout.height > 0) { + height = Math.ceil(height / this.layout.height) * this.layout.height; + } + } + + // Only Resize if dimensions have changed or + // if Frame is still hidden, so needs reframing + if(this._needsReframe || width != this._width || height != this._height){ + this.reframe(width, height); + } + + this._expanding = false; + } + + reframe(width, height) { + var size; + + if(isNumber(width)){ + this.element.style.width = width + "px"; + this.iframe.style.width = width + "px"; + this._width = width; + } + + if(isNumber(height)){ + this.element.style.height = height + "px"; + this.iframe.style.height = height + "px"; + this._height = height; + } + + let widthDelta = this.prevBounds ? width - this.prevBounds.width : width; + let heightDelta = this.prevBounds ? height - this.prevBounds.height : height; + + size = { + width: width, + height: height, + widthDelta: widthDelta, + heightDelta: heightDelta, + }; + + this.pane && this.pane.render(); + + requestAnimationFrame(() => { + let mark; + for (let m in this.marks) { + if (this.marks.hasOwnProperty(m)) { + mark = this.marks[m]; + this.placeMark(mark.element, mark.range); + } + } + }); + + this.onResize(this, size); + + this.emit(EVENTS.VIEWS.RESIZED, size); + + this.prevBounds = size; + + this.elementBounds = bounds(this.element); + + } + + + load(contents) { + var loading = new defer(); + var loaded = loading.promise; + + if(!this.iframe) { + loading.reject(new Error("No Iframe Available")); + return loaded; + } + + this.iframe.onload = function(event) { + + this.onLoad(event, loading); + + }.bind(this); + + if (this.settings.method === "blobUrl") { + this.blobUrl = createBlobUrl(contents, "application/xhtml+xml"); + this.iframe.src = this.blobUrl; + this.element.appendChild(this.iframe); + } else if(this.settings.method === "srcdoc"){ + this.iframe.srcdoc = contents; + this.element.appendChild(this.iframe); + } else { + + this.element.appendChild(this.iframe); + + this.document = this.iframe.contentDocument; + + if(!this.document) { + loading.reject(new Error("No Document Available")); + return loaded; + } + + this.iframe.contentDocument.open(); + // For Cordova windows platform + if(window.MSApp && MSApp.execUnsafeLocalFunction) { + var outerThis = this; + MSApp.execUnsafeLocalFunction(function () { + outerThis.iframe.contentDocument.write(contents); + }); + } else { + this.iframe.contentDocument.write(contents); + } + this.iframe.contentDocument.close(); + + } + + return loaded; + } + + onLoad(event, promise) { + + this.window = this.iframe.contentWindow; + this.document = this.iframe.contentDocument; + + this.contents = new Contents(this.document, this.document.body, this.section.cfiBase, this.section.index); + + this.rendering = false; + + var link = this.document.querySelector("link[rel='canonical']"); + if (link) { + link.setAttribute("href", this.section.canonical); + } else { + link = this.document.createElement("link"); + link.setAttribute("rel", "canonical"); + link.setAttribute("href", this.section.canonical); + this.document.querySelector("head").appendChild(link); + } + + this.contents.on(EVENTS.CONTENTS.EXPAND, () => { + if(this.displayed && this.iframe) { + this.expand(); + if (this.contents) { + this.layout.format(this.contents); + } + } + }); + + this.contents.on(EVENTS.CONTENTS.RESIZE, (e) => { + if(this.displayed && this.iframe) { + this.expand(); + if (this.contents) { + this.layout.format(this.contents); + } + } + }); + + promise.resolve(this.contents); + } + + setLayout(layout) { + this.layout = layout; + + if (this.contents) { + this.layout.format(this.contents); + this.expand(); + } + } + + setAxis(axis) { + + this.settings.axis = axis; + + if(axis == "horizontal"){ + this.element.style.flex = "none"; + } else { + this.element.style.flex = "initial"; + } + + this.size(); + + } + + setWritingMode(mode) { + // this.element.style.writingMode = writingMode; + this.writingMode = mode; + } + + addListeners() { + //TODO: Add content listeners for expanding + } + + removeListeners(layoutFunc) { + //TODO: remove content listeners for expanding + } + + display(request) { + var displayed = new defer(); + + if (!this.displayed) { + + this.render(request) + .then(function () { + + this.emit(EVENTS.VIEWS.DISPLAYED, this); + this.onDisplayed(this); + + this.displayed = true; + displayed.resolve(this); + + }.bind(this), function (err) { + displayed.reject(err, this); + }); + + } else { + displayed.resolve(this); + } + + + return displayed.promise; + } + + show() { + + this.element.style.visibility = "visible"; + + if(this.iframe){ + this.iframe.style.visibility = "visible"; + + // Remind Safari to redraw the iframe + this.iframe.style.transform = "translateZ(0)"; + this.iframe.offsetWidth; + this.iframe.style.transform = null; + } + + this.emit(EVENTS.VIEWS.SHOWN, this); + } + + hide() { + // this.iframe.style.display = "none"; + this.element.style.visibility = "hidden"; + this.iframe.style.visibility = "hidden"; + + this.stopExpanding = true; + this.emit(EVENTS.VIEWS.HIDDEN, this); + } + + offset() { + return { + top: this.element.offsetTop, + left: this.element.offsetLeft + } + } + + width() { + return this._width; + } + + height() { + return this._height; + } + + position() { + return this.element.getBoundingClientRect(); + } + + locationOf(target) { + var parentPos = this.iframe.getBoundingClientRect(); + var targetPos = this.contents.locationOf(target, this.settings.ignoreClass); + + return { + "left": targetPos.left, + "top": targetPos.top + }; + } + + onDisplayed(view) { + // Stub, override with a custom functions + } + + onResize(view, e) { + // Stub, override with a custom functions + } + + bounds(force) { + if(force || !this.elementBounds) { + this.elementBounds = bounds(this.element); + } + + return this.elementBounds; + } + + highlight(cfiRange, data={}, cb, className = "epubjs-hl", styles = {}) { + if (!this.contents) { + return; + } + const attributes = Object.assign({"fill": "yellow", "fill-opacity": "0.3", "mix-blend-mode": "multiply"}, styles); + let range = this.contents.range(cfiRange); + + let emitter = () => { + this.emit(EVENTS.VIEWS.MARK_CLICKED, cfiRange, data); + }; + + data["epubcfi"] = cfiRange; + + if (!this.pane) { + this.pane = new Pane(this.iframe, this.element); + } + + let m = new Highlight(range, className, data, attributes); + let h = this.pane.addMark(m); + + this.highlights[cfiRange] = { "mark": h, "element": h.element, "listeners": [emitter, cb] }; + + h.element.setAttribute("ref", className); + h.element.addEventListener("click", emitter); + h.element.addEventListener("touchstart", emitter); + + if (cb) { + h.element.addEventListener("click", cb); + h.element.addEventListener("touchstart", cb); + } + return h; + } + + underline(cfiRange, data={}, cb, className = "epubjs-ul", styles = {}) { + if (!this.contents) { + return; + } + const attributes = Object.assign({"stroke": "black", "stroke-opacity": "0.3", "mix-blend-mode": "multiply"}, styles); + let range = this.contents.range(cfiRange); + let emitter = () => { + this.emit(EVENTS.VIEWS.MARK_CLICKED, cfiRange, data); + }; + + data["epubcfi"] = cfiRange; + + if (!this.pane) { + this.pane = new Pane(this.iframe, this.element); + } + + let m = new Underline(range, className, data, attributes); + let h = this.pane.addMark(m); + + this.underlines[cfiRange] = { "mark": h, "element": h.element, "listeners": [emitter, cb] }; + + h.element.setAttribute("ref", className); + h.element.addEventListener("click", emitter); + h.element.addEventListener("touchstart", emitter); + + if (cb) { + h.element.addEventListener("click", cb); + h.element.addEventListener("touchstart", cb); + } + return h; + } + + mark(cfiRange, data={}, cb) { + if (!this.contents) { + return; + } + + if (cfiRange in this.marks) { + let item = this.marks[cfiRange]; + return item; + } + + let range = this.contents.range(cfiRange); + if (!range) { + return; + } + let container = range.commonAncestorContainer; + let parent = (container.nodeType === 1) ? container : container.parentNode; + + let emitter = (e) => { + this.emit(EVENTS.VIEWS.MARK_CLICKED, cfiRange, data); + }; + + if (range.collapsed && container.nodeType === 1) { + range = new Range(); + range.selectNodeContents(container); + } else if (range.collapsed) { // Webkit doesn't like collapsed ranges + range = new Range(); + range.selectNodeContents(parent); + } + + let mark = this.document.createElement("a"); + mark.setAttribute("ref", "epubjs-mk"); + mark.style.position = "absolute"; + + mark.dataset["epubcfi"] = cfiRange; + + if (data) { + Object.keys(data).forEach((key) => { + mark.dataset[key] = data[key]; + }); + } + + if (cb) { + mark.addEventListener("click", cb); + mark.addEventListener("touchstart", cb); + } + + mark.addEventListener("click", emitter); + mark.addEventListener("touchstart", emitter); + + this.placeMark(mark, range); + + this.element.appendChild(mark); + + this.marks[cfiRange] = { "element": mark, "range": range, "listeners": [emitter, cb] }; + + return parent; + } + + placeMark(element, range) { + let top, right, left; + + if(this.layout.name === "pre-paginated" || + this.settings.axis !== "horizontal") { + let pos = range.getBoundingClientRect(); + top = pos.top; + right = pos.right; + } else { + // Element might break columns, so find the left most element + let rects = range.getClientRects(); + + let rect; + for (var i = 0; i != rects.length; i++) { + rect = rects[i]; + if (!left || rect.left < left) { + left = rect.left; + // right = rect.right; + right = Math.ceil(left / this.layout.props.pageWidth) * this.layout.props.pageWidth - (this.layout.gap / 2); + top = rect.top; + } + } + } + + element.style.top = `${top}px`; + element.style.left = `${right}px`; + } + + unhighlight(cfiRange) { + let item; + if (cfiRange in this.highlights) { + item = this.highlights[cfiRange]; + + this.pane.removeMark(item.mark); + item.listeners.forEach((l) => { + if (l) { + item.element.removeEventListener("click", l); + item.element.removeEventListener("touchstart", l); + }; + }); + delete this.highlights[cfiRange]; + } + } + + ununderline(cfiRange) { + let item; + if (cfiRange in this.underlines) { + item = this.underlines[cfiRange]; + this.pane.removeMark(item.mark); + item.listeners.forEach((l) => { + if (l) { + item.element.removeEventListener("click", l); + item.element.removeEventListener("touchstart", l); + }; + }); + delete this.underlines[cfiRange]; + } + } + + unmark(cfiRange) { + let item; + if (cfiRange in this.marks) { + item = this.marks[cfiRange]; + this.element.removeChild(item.element); + item.listeners.forEach((l) => { + if (l) { + item.element.removeEventListener("click", l); + item.element.removeEventListener("touchstart", l); + }; + }); + delete this.marks[cfiRange]; + } + } + + destroy() { + + for (let cfiRange in this.highlights) { + this.unhighlight(cfiRange); + } + + for (let cfiRange in this.underlines) { + this.ununderline(cfiRange); + } + + for (let cfiRange in this.marks) { + this.unmark(cfiRange); + } + + if (this.blobUrl) { + revokeBlobUrl(this.blobUrl); + } + + if(this.displayed){ + this.displayed = false; + + this.removeListeners(); + this.contents.destroy(); + + this.stopExpanding = true; + this.element.removeChild(this.iframe); + + this.iframe = undefined; + this.contents = undefined; + + this._textWidth = null; + this._textHeight = null; + this._width = null; + this._height = null; + } + + // this.element.style.height = "0px"; + // this.element.style.width = "0px"; + } +} + +EventEmitter(IframeView.prototype); + +export default IframeView; diff --git a/lib/epub.js/src/managers/views/inline.js b/lib/epub.js/src/managers/views/inline.js new file mode 100644 index 0000000..072b586 --- /dev/null +++ b/lib/epub.js/src/managers/views/inline.js @@ -0,0 +1,432 @@ +import EventEmitter from "event-emitter"; +import {extend, borders, uuid, isNumber, bounds, defer, qs, parse} from "../../utils/core"; +import EpubCFI from "../../epubcfi"; +import Contents from "../../contents"; +import { EVENTS } from "../../utils/constants"; + +class InlineView { + constructor(section, options) { + this.settings = extend({ + ignoreClass : "", + axis: "vertical", + width: 0, + height: 0, + layout: undefined, + globalLayoutProperties: {}, + }, options || {}); + + this.id = "epubjs-view:" + uuid(); + this.section = section; + this.index = section.index; + + this.element = this.container(this.settings.axis); + + this.added = false; + this.displayed = false; + this.rendered = false; + + this.width = this.settings.width; + this.height = this.settings.height; + + this.fixedWidth = 0; + this.fixedHeight = 0; + + // Blank Cfi for Parsing + this.epubcfi = new EpubCFI(); + + this.layout = this.settings.layout; + // Dom events to listen for + // this.listenedEvents = ["keydown", "keyup", "keypressed", "mouseup", "mousedown", "click", "touchend", "touchstart"]; + + } + + container(axis) { + var element = document.createElement("div"); + + element.classList.add("epub-view"); + + // if(this.settings.axis === "horizontal") { + // element.style.width = "auto"; + // element.style.height = "0"; + // } else { + // element.style.width = "0"; + // element.style.height = "auto"; + // } + + element.style.overflow = "hidden"; + + if(axis && axis == "horizontal"){ + element.style.display = "inline-block"; + } else { + element.style.display = "block"; + } + + return element; + } + + create() { + + if(this.frame) { + return this.frame; + } + + if(!this.element) { + this.element = this.createContainer(); + } + + this.frame = document.createElement("div"); + this.frame.id = this.id; + this.frame.style.overflow = "hidden"; + this.frame.style.wordSpacing = "initial"; + this.frame.style.lineHeight = "initial"; + + this.resizing = true; + + // this.frame.style.display = "none"; + this.element.style.visibility = "hidden"; + this.frame.style.visibility = "hidden"; + + if(this.settings.axis === "horizontal") { + this.frame.style.width = "auto"; + this.frame.style.height = "0"; + } else { + this.frame.style.width = "0"; + this.frame.style.height = "auto"; + } + + this._width = 0; + this._height = 0; + + this.element.appendChild(this.frame); + this.added = true; + + this.elementBounds = bounds(this.element); + + return this.frame; + } + + render(request, show) { + + // view.onLayout = this.layout.format.bind(this.layout); + this.create(); + + // Fit to size of the container, apply padding + this.size(); + + // Render Chain + return this.section.render(request) + .then(function(contents){ + return this.load(contents); + }.bind(this)) + // .then(function(doc){ + // return this.hooks.content.trigger(view, this); + // }.bind(this)) + .then(function(){ + // this.settings.layout.format(view.contents); + // return this.hooks.layout.trigger(view, this); + }.bind(this)) + // .then(function(){ + // return this.display(); + // }.bind(this)) + // .then(function(){ + // return this.hooks.render.trigger(view, this); + // }.bind(this)) + .then(function(){ + + // apply the layout function to the contents + this.settings.layout.format(this.contents); + + // Expand the iframe to the full size of the content + // this.expand(); + + // Listen for events that require an expansion of the iframe + this.addListeners(); + + if(show !== false) { + //this.q.enqueue(function(view){ + this.show(); + //}, view); + } + // this.map = new Map(view, this.layout); + //this.hooks.show.trigger(view, this); + this.emit(EVENTS.VIEWS.RENDERED, this.section); + + }.bind(this)) + .catch(function(e){ + this.emit(EVENTS.VIEWS.LOAD_ERROR, e); + }.bind(this)); + + } + + // Determine locks base on settings + size(_width, _height) { + var width = _width || this.settings.width; + var height = _height || this.settings.height; + + if(this.layout.name === "pre-paginated") { + // TODO: check if these are different than the size set in chapter + this.lock("both", width, height); + } else if(this.settings.axis === "horizontal") { + this.lock("height", width, height); + } else { + this.lock("width", width, height); + } + + } + + // Lock an axis to element dimensions, taking borders into account + lock(what, width, height) { + var elBorders = borders(this.element); + var iframeBorders; + + if(this.frame) { + iframeBorders = borders(this.frame); + } else { + iframeBorders = {width: 0, height: 0}; + } + + if(what == "width" && isNumber(width)){ + this.lockedWidth = width - elBorders.width - iframeBorders.width; + this.resize(this.lockedWidth, false); // width keeps ratio correct + } + + if(what == "height" && isNumber(height)){ + this.lockedHeight = height - elBorders.height - iframeBorders.height; + this.resize(false, this.lockedHeight); + } + + if(what === "both" && + isNumber(width) && + isNumber(height)){ + + this.lockedWidth = width - elBorders.width - iframeBorders.width; + this.lockedHeight = height - elBorders.height - iframeBorders.height; + + this.resize(this.lockedWidth, this.lockedHeight); + } + + } + + // Resize a single axis based on content dimensions + expand(force) { + var width = this.lockedWidth; + var height = this.lockedHeight; + + var textWidth, textHeight; + + if(!this.frame || this._expanding) return; + + this._expanding = true; + + // Expand Horizontally + if(this.settings.axis === "horizontal") { + width = this.contentWidth(textWidth); + } // Expand Vertically + else if(this.settings.axis === "vertical") { + height = this.contentHeight(textHeight); + } + + // Only Resize if dimensions have changed or + // if Frame is still hidden, so needs reframing + if(this._needsReframe || width != this._width || height != this._height){ + this.resize(width, height); + } + + this._expanding = false; + } + + contentWidth(min) { + return this.frame.scrollWidth; + } + + contentHeight(min) { + return this.frame.scrollHeight; + } + + + resize(width, height) { + + if(!this.frame) return; + + if(isNumber(width)){ + this.frame.style.width = width + "px"; + this._width = width; + } + + if(isNumber(height)){ + this.frame.style.height = height + "px"; + this._height = height; + } + + this.prevBounds = this.elementBounds; + + this.elementBounds = bounds(this.element); + + let size = { + width: this.elementBounds.width, + height: this.elementBounds.height, + widthDelta: this.elementBounds.width - this.prevBounds.width, + heightDelta: this.elementBounds.height - this.prevBounds.height, + }; + + this.onResize(this, size); + + this.emit(EVENTS.VIEWS.RESIZED, size); + + } + + + load(contents) { + var loading = new defer(); + var loaded = loading.promise; + var doc = parse(contents, "text/html"); + var body = qs(doc, "body"); + + /* + var srcs = doc.querySelectorAll("[src]"); + + Array.prototype.slice.call(srcs) + .forEach(function(item) { + var src = item.getAttribute("src"); + var assetUri = URI(src); + var origin = assetUri.origin(); + var absoluteUri; + + if (!origin) { + absoluteUri = assetUri.absoluteTo(this.section.url); + item.src = absoluteUri; + } + }.bind(this)); + */ + this.frame.innerHTML = body.innerHTML; + + this.document = this.frame.ownerDocument; + this.window = this.document.defaultView; + + this.contents = new Contents(this.document, this.frame); + + this.rendering = false; + + loading.resolve(this.contents); + + + return loaded; + } + + setLayout(layout) { + this.layout = layout; + } + + + resizeListenters() { + // Test size again + // clearTimeout(this.expanding); + // this.expanding = setTimeout(this.expand.bind(this), 350); + } + + addListeners() { + //TODO: Add content listeners for expanding + } + + removeListeners(layoutFunc) { + //TODO: remove content listeners for expanding + } + + display(request) { + var displayed = new defer(); + + if (!this.displayed) { + + this.render(request).then(function () { + + this.emit(EVENTS.VIEWS.DISPLAYED, this); + this.onDisplayed(this); + + this.displayed = true; + + displayed.resolve(this); + + }.bind(this)); + + } else { + displayed.resolve(this); + } + + + return displayed.promise; + } + + show() { + + this.element.style.visibility = "visible"; + + if(this.frame){ + this.frame.style.visibility = "visible"; + } + + this.emit(EVENTS.VIEWS.SHOWN, this); + } + + hide() { + // this.frame.style.display = "none"; + this.element.style.visibility = "hidden"; + this.frame.style.visibility = "hidden"; + + this.stopExpanding = true; + this.emit(EVENTS.VIEWS.HIDDEN, this); + } + + position() { + return this.element.getBoundingClientRect(); + } + + locationOf(target) { + var parentPos = this.frame.getBoundingClientRect(); + var targetPos = this.contents.locationOf(target, this.settings.ignoreClass); + + return { + "left": window.scrollX + parentPos.left + targetPos.left, + "top": window.scrollY + parentPos.top + targetPos.top + }; + } + + onDisplayed(view) { + // Stub, override with a custom functions + } + + onResize(view, e) { + // Stub, override with a custom functions + } + + bounds() { + if(!this.elementBounds) { + this.elementBounds = bounds(this.element); + } + return this.elementBounds; + } + + destroy() { + + if(this.displayed){ + this.displayed = false; + + this.removeListeners(); + + this.stopExpanding = true; + this.element.removeChild(this.frame); + this.displayed = false; + this.frame = null; + + this._textWidth = null; + this._textHeight = null; + this._width = null; + this._height = null; + } + // this.element.style.height = "0px"; + // this.element.style.width = "0px"; + } +} + +EventEmitter(InlineView.prototype); + +export default InlineView; diff --git a/lib/epub.js/src/mapping.js b/lib/epub.js/src/mapping.js new file mode 100644 index 0000000..4216204 --- /dev/null +++ b/lib/epub.js/src/mapping.js @@ -0,0 +1,511 @@ +import EpubCFI from "./epubcfi"; +import { nodeBounds } from "./utils/core"; + +/** + * Map text locations to CFI ranges + * @class + * @param {Layout} layout Layout to apply + * @param {string} [direction="ltr"] Text direction + * @param {string} [axis="horizontal"] vertical or horizontal axis + * @param {boolean} [dev] toggle developer highlighting + */ +class Mapping { + constructor(layout, direction, axis, dev=false) { + this.layout = layout; + this.horizontal = (axis === "horizontal") ? true : false; + this.direction = direction || "ltr"; + this._dev = dev; + } + + /** + * Find CFI pairs for entire section at once + */ + section(view) { + var ranges = this.findRanges(view); + var map = this.rangeListToCfiList(view.section.cfiBase, ranges); + + return map; + } + + /** + * Find CFI pairs for a page + * @param {Contents} contents Contents from view + * @param {string} cfiBase string of the base for a cfi + * @param {number} start position to start at + * @param {number} end position to end at + */ + page(contents, cfiBase, start, end) { + var root = contents && contents.document ? contents.document.body : false; + var result; + + if (!root) { + return; + } + + result = this.rangePairToCfiPair(cfiBase, { + start: this.findStart(root, start, end), + end: this.findEnd(root, start, end) + }); + + if (this._dev === true) { + let doc = contents.document; + let startRange = new EpubCFI(result.start).toRange(doc); + let endRange = new EpubCFI(result.end).toRange(doc); + + let selection = doc.defaultView.getSelection(); + let r = doc.createRange(); + selection.removeAllRanges(); + r.setStart(startRange.startContainer, startRange.startOffset); + r.setEnd(endRange.endContainer, endRange.endOffset); + selection.addRange(r); + } + + return result; + } + + /** + * Walk a node, preforming a function on each node it finds + * @private + * @param {Node} root Node to walkToNode + * @param {function} func walk function + * @return {*} returns the result of the walk function + */ + walk(root, func) { + // IE11 has strange issue, if root is text node IE throws exception on + // calling treeWalker.nextNode(), saying + // Unexpected call to method or property access instead of returing null value + if(root && root.nodeType === Node.TEXT_NODE) { + return; + } + // safeFilter is required so that it can work in IE as filter is a function for IE + // and for other browser filter is an object. + var filter = { + acceptNode: function(node) { + if (node.data.trim().length > 0) { + return NodeFilter.FILTER_ACCEPT; + } else { + return NodeFilter.FILTER_REJECT; + } + } + }; + var safeFilter = filter.acceptNode; + safeFilter.acceptNode = filter.acceptNode; + + var treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, safeFilter, false); + var node; + var result; + while ((node = treeWalker.nextNode())) { + result = func(node); + if(result) break; + } + + return result; + } + + findRanges(view){ + var columns = []; + var scrollWidth = view.contents.scrollWidth(); + var spreads = Math.ceil( scrollWidth / this.layout.spreadWidth); + var count = spreads * this.layout.divisor; + var columnWidth = this.layout.columnWidth; + var gap = this.layout.gap; + var start, end; + + for (var i = 0; i < count.pages; i++) { + start = (columnWidth + gap) * i; + end = (columnWidth * (i+1)) + (gap * i); + columns.push({ + start: this.findStart(view.document.body, start, end), + end: this.findEnd(view.document.body, start, end) + }); + } + + return columns; + } + + /** + * Find Start Range + * @private + * @param {Node} root root node + * @param {number} start position to start at + * @param {number} end position to end at + * @return {Range} + */ + findStart(root, start, end){ + var stack = [root]; + var $el; + var found; + var $prev = root; + + while (stack.length) { + + $el = stack.shift(); + + found = this.walk($el, (node) => { + var left, right, top, bottom; + var elPos; + var elRange; + + + elPos = nodeBounds(node); + + if (this.horizontal && this.direction === "ltr") { + + left = this.horizontal ? elPos.left : elPos.top; + right = this.horizontal ? elPos.right : elPos.bottom; + + if( left >= start && left <= end ) { + return node; + } else if (right > start) { + return node; + } else { + $prev = node; + stack.push(node); + } + + } else if (this.horizontal && this.direction === "rtl") { + + left = elPos.left; + right = elPos.right; + + if( right <= end && right >= start ) { + return node; + } else if (left < end) { + return node; + } else { + $prev = node; + stack.push(node); + } + + } else { + + top = elPos.top; + bottom = elPos.bottom; + + if( top >= start && top <= end ) { + return node; + } else if (bottom > start) { + return node; + } else { + $prev = node; + stack.push(node); + } + + } + + + }); + + if(found) { + return this.findTextStartRange(found, start, end); + } + + } + + // Return last element + return this.findTextStartRange($prev, start, end); + } + + /** + * Find End Range + * @private + * @param {Node} root root node + * @param {number} start position to start at + * @param {number} end position to end at + * @return {Range} + */ + findEnd(root, start, end){ + var stack = [root]; + var $el; + var $prev = root; + var found; + + while (stack.length) { + + $el = stack.shift(); + + found = this.walk($el, (node) => { + + var left, right, top, bottom; + var elPos; + var elRange; + + elPos = nodeBounds(node); + + if (this.horizontal && this.direction === "ltr") { + + left = Math.round(elPos.left); + right = Math.round(elPos.right); + + if(left > end && $prev) { + return $prev; + } else if(right > end) { + return node; + } else { + $prev = node; + stack.push(node); + } + + } else if (this.horizontal && this.direction === "rtl") { + + left = Math.round(this.horizontal ? elPos.left : elPos.top); + right = Math.round(this.horizontal ? elPos.right : elPos.bottom); + + if(right < start && $prev) { + return $prev; + } else if(left < start) { + return node; + } else { + $prev = node; + stack.push(node); + } + + } else { + + top = Math.round(elPos.top); + bottom = Math.round(elPos.bottom); + + if(top > end && $prev) { + return $prev; + } else if(bottom > end) { + return node; + } else { + $prev = node; + stack.push(node); + } + + } + + }); + + + if(found){ + return this.findTextEndRange(found, start, end); + } + + } + + // end of chapter + return this.findTextEndRange($prev, start, end); + } + + /** + * Find Text Start Range + * @private + * @param {Node} root root node + * @param {number} start position to start at + * @param {number} end position to end at + * @return {Range} + */ + findTextStartRange(node, start, end){ + var ranges = this.splitTextNodeIntoRanges(node); + var range; + var pos; + var left, top, right; + + for (var i = 0; i < ranges.length; i++) { + range = ranges[i]; + + pos = range.getBoundingClientRect(); + + if (this.horizontal && this.direction === "ltr") { + + left = pos.left; + if( left >= start ) { + return range; + } + + } else if (this.horizontal && this.direction === "rtl") { + + right = pos.right; + if( right <= end ) { + return range; + } + + } else { + + top = pos.top; + if( top >= start ) { + return range; + } + + } + + // prev = range; + + } + + return ranges[0]; + } + + /** + * Find Text End Range + * @private + * @param {Node} root root node + * @param {number} start position to start at + * @param {number} end position to end at + * @return {Range} + */ + findTextEndRange(node, start, end){ + var ranges = this.splitTextNodeIntoRanges(node); + var prev; + var range; + var pos; + var left, right, top, bottom; + + for (var i = 0; i < ranges.length; i++) { + range = ranges[i]; + + pos = range.getBoundingClientRect(); + + if (this.horizontal && this.direction === "ltr") { + + left = pos.left; + right = pos.right; + + if(left > end && prev) { + return prev; + } else if(right > end) { + return range; + } + + } else if (this.horizontal && this.direction === "rtl") { + + left = pos.left + right = pos.right; + + if(right < start && prev) { + return prev; + } else if(left < start) { + return range; + } + + } else { + + top = pos.top; + bottom = pos.bottom; + + if(top > end && prev) { + return prev; + } else if(bottom > end) { + return range; + } + + } + + + prev = range; + + } + + // Ends before limit + return ranges[ranges.length-1]; + + } + + /** + * Split up a text node into ranges for each word + * @private + * @param {Node} root root node + * @param {string} [_splitter] what to split on + * @return {Range[]} + */ + splitTextNodeIntoRanges(node, _splitter){ + var ranges = []; + var textContent = node.textContent || ""; + var text = textContent.trim(); + var range; + var doc = node.ownerDocument; + var splitter = _splitter || " "; + + var pos = text.indexOf(splitter); + + if(pos === -1 || node.nodeType != Node.TEXT_NODE) { + range = doc.createRange(); + range.selectNodeContents(node); + return [range]; + } + + range = doc.createRange(); + range.setStart(node, 0); + range.setEnd(node, pos); + ranges.push(range); + range = false; + + while ( pos != -1 ) { + + pos = text.indexOf(splitter, pos + 1); + if(pos > 0) { + + if(range) { + range.setEnd(node, pos); + ranges.push(range); + } + + range = doc.createRange(); + range.setStart(node, pos+1); + } + } + + if(range) { + range.setEnd(node, text.length); + ranges.push(range); + } + + return ranges; + } + + + /** + * Turn a pair of ranges into a pair of CFIs + * @private + * @param {string} cfiBase base string for an EpubCFI + * @param {object} rangePair { start: Range, end: Range } + * @return {object} { start: "epubcfi(...)", end: "epubcfi(...)" } + */ + rangePairToCfiPair(cfiBase, rangePair){ + + var startRange = rangePair.start; + var endRange = rangePair.end; + + startRange.collapse(true); + endRange.collapse(false); + + let startCfi = new EpubCFI(startRange, cfiBase).toString(); + let endCfi = new EpubCFI(endRange, cfiBase).toString(); + + return { + start: startCfi, + end: endCfi + }; + + } + + rangeListToCfiList(cfiBase, columns){ + var map = []; + var cifPair; + + for (var i = 0; i < columns.length; i++) { + cifPair = this.rangePairToCfiPair(cfiBase, columns[i]); + + map.push(cifPair); + + } + + return map; + } + + /** + * Set the axis for mapping + * @param {string} axis horizontal | vertical + * @return {boolean} is it horizontal? + */ + axis(axis) { + if (axis) { + this.horizontal = (axis === "horizontal") ? true : false; + } + return this.horizontal; + } +} + +export default Mapping; diff --git a/lib/epub.js/src/navigation.js b/lib/epub.js/src/navigation.js new file mode 100644 index 0000000..8d593a8 --- /dev/null +++ b/lib/epub.js/src/navigation.js @@ -0,0 +1,356 @@ +import {qs, qsa, querySelectorByType, filterChildren, getParentByTagName} from "./utils/core"; + +/** + * Navigation Parser + * @param {document} xml navigation html / xhtml / ncx + */ +class Navigation { + constructor(xml) { + this.toc = []; + this.tocByHref = {}; + this.tocById = {}; + + this.landmarks = []; + this.landmarksByType = {}; + + this.length = 0; + if (xml) { + this.parse(xml); + } + } + + /** + * Parse out the navigation items + * @param {document} xml navigation html / xhtml / ncx + */ + parse(xml) { + let isXml = xml.nodeType; + let html; + let ncx; + + if (isXml) { + html = qs(xml, "html"); + ncx = qs(xml, "ncx"); + } + + if (!isXml) { + this.toc = this.load(xml); + } else if(html) { + this.toc = this.parseNav(xml); + this.landmarks = this.parseLandmarks(xml); + } else if(ncx){ + this.toc = this.parseNcx(xml); + } + + this.length = 0; + + this.unpack(this.toc); + } + + /** + * Unpack navigation items + * @private + * @param {array} toc + */ + unpack(toc) { + var item; + + for (var i = 0; i < toc.length; i++) { + item = toc[i]; + + if (item.href) { + this.tocByHref[item.href] = i; + } + + if (item.id) { + this.tocById[item.id] = i; + } + + this.length++; + + if (item.subitems.length) { + this.unpack(item.subitems); + } + } + + } + + /** + * Get an item from the navigation + * @param {string} target + * @return {object} navItem + */ + get(target) { + var index; + + if(!target) { + return this.toc; + } + + if(target.indexOf("#") === 0) { + index = this.tocById[target.substring(1)]; + } else if(target in this.tocByHref){ + index = this.tocByHref[target]; + } + + return this.getByIndex(target, index, this.toc); + } + + /** + * Get an item from navigation subitems recursively by index + * @param {string} target + * @param {number} index + * @param {array} navItems + * @return {object} navItem + */ + getByIndex(target, index, navItems) { + if (navItems.length === 0) { + return; + } + + const item = navItems[index]; + if (item && (target === item.id || target === item.href)) { + return item; + } else { + let result; + for (let i = 0; i < navItems.length; ++i) { + result = this.getByIndex(target, index, navItems[i].subitems); + if (result) { + break; + } + } + return result; + } + } + + /** + * Get a landmark by type + * List of types: https://idpf.github.io/epub-vocabs/structure/ + * @param {string} type + * @return {object} landmarkItem + */ + landmark(type) { + var index; + + if(!type) { + return this.landmarks; + } + + index = this.landmarksByType[type]; + + return this.landmarks[index]; + } + + /** + * Parse toc from a Epub > 3.0 Nav + * @private + * @param {document} navHtml + * @return {array} navigation list + */ + parseNav(navHtml){ + var navElement = querySelectorByType(navHtml, "nav", "toc"); + var list = []; + + if (!navElement) return list; + + let navList = filterChildren(navElement, "ol", true); + if (!navList) return list; + + list = this.parseNavList(navList); + return list; + } + + /** + * Parses lists in the toc + * @param {document} navListHtml + * @param {string} parent id + * @return {array} navigation list + */ + parseNavList(navListHtml, parent) { + const result = []; + + if (!navListHtml) return result; + if (!navListHtml.children) return result; + + for (let i = 0; i < navListHtml.children.length; i++) { + const item = this.navItem(navListHtml.children[i], parent); + + if (item) { + result.push(item); + } + } + + return result; + } + + /** + * Create a navItem + * @private + * @param {element} item + * @return {object} navItem + */ + navItem(item, parent) { + let id = item.getAttribute("id") || undefined; + let content = filterChildren(item, "a", true); + + if (!content) { + return; + } + + let src = content.getAttribute("href") || ""; + + if (!id) { + id = src; + } + let text = content.textContent || ""; + + let subitems = []; + let nested = filterChildren(item, "ol", true); + if (nested) { + subitems = this.parseNavList(nested, id); + } + + return { + "id": id, + "href": src, + "label": text, + "subitems" : subitems, + "parent" : parent + }; + } + + /** + * Parse landmarks from a Epub > 3.0 Nav + * @private + * @param {document} navHtml + * @return {array} landmarks list + */ + parseLandmarks(navHtml){ + var navElement = querySelectorByType(navHtml, "nav", "landmarks"); + var navItems = navElement ? qsa(navElement, "li") : []; + var length = navItems.length; + var i; + var list = []; + var item; + + if(!navItems || length === 0) return list; + + for (i = 0; i < length; ++i) { + item = this.landmarkItem(navItems[i]); + if (item) { + list.push(item); + this.landmarksByType[item.type] = i; + } + } + + return list; + } + + /** + * Create a landmarkItem + * @private + * @param {element} item + * @return {object} landmarkItem + */ + landmarkItem(item){ + let content = filterChildren(item, "a", true); + + if (!content) { + return; + } + + let type = content.getAttributeNS("http://www.idpf.org/2007/ops", "type") || undefined; + let href = content.getAttribute("href") || ""; + let text = content.textContent || ""; + + return { + "href": href, + "label": text, + "type" : type + }; + } + + /** + * Parse from a Epub > 3.0 NC + * @private + * @param {document} navHtml + * @return {array} navigation list + */ + parseNcx(tocXml){ + var navPoints = qsa(tocXml, "navPoint"); + var length = navPoints.length; + var i; + var toc = {}; + var list = []; + var item, parent; + + if(!navPoints || length === 0) return list; + + for (i = 0; i < length; ++i) { + item = this.ncxItem(navPoints[i]); + toc[item.id] = item; + if(!item.parent) { + list.push(item); + } else { + parent = toc[item.parent]; + parent.subitems.push(item); + } + } + + return list; + } + + /** + * Create a ncxItem + * @private + * @param {element} item + * @return {object} ncxItem + */ + ncxItem(item){ + var id = item.getAttribute("id") || false, + content = qs(item, "content"), + src = content.getAttribute("src"), + navLabel = qs(item, "navLabel"), + text = navLabel.textContent ? navLabel.textContent : "", + subitems = [], + parentNode = item.parentNode, + parent; + + if(parentNode && (parentNode.nodeName === "navPoint" || parentNode.nodeName.split(':').slice(-1)[0] === "navPoint")) { + parent = parentNode.getAttribute("id"); + } + + + return { + "id": id, + "href": src, + "label": text, + "subitems" : subitems, + "parent" : parent + }; + } + + /** + * Load Spine Items + * @param {object} json the items to be loaded + * @return {Array} navItems + */ + load(json) { + return json.map(item => { + item.label = item.title; + item.subitems = item.children ? this.load(item.children) : []; + return item; + }); + } + + /** + * forEach pass through + * @param {Function} fn function to run on each item + * @return {method} forEach loop + */ + forEach(fn) { + return this.toc.forEach(fn); + } +} + +export default Navigation; diff --git a/lib/epub.js/src/packaging.js b/lib/epub.js/src/packaging.js new file mode 100644 index 0000000..396b5d4 --- /dev/null +++ b/lib/epub.js/src/packaging.js @@ -0,0 +1,372 @@ +import {qs, qsa, qsp, indexOfElementNode} from "./utils/core"; + +/** + * Open Packaging Format Parser + * @class + * @param {document} packageDocument OPF XML + */ +class Packaging { + constructor(packageDocument) { + this.manifest = {}; + this.navPath = ''; + this.ncxPath = ''; + this.coverPath = ''; + this.spineNodeIndex = 0; + this.spine = []; + this.metadata = {}; + + if (packageDocument) { + this.parse(packageDocument); + } + } + + /** + * Parse OPF XML + * @param {document} packageDocument OPF XML + * @return {object} parsed package parts + */ + parse(packageDocument){ + var metadataNode, manifestNode, spineNode; + + if(!packageDocument) { + throw new Error("Package File Not Found"); + } + + metadataNode = qs(packageDocument, "metadata"); + if(!metadataNode) { + throw new Error("No Metadata Found"); + } + + manifestNode = qs(packageDocument, "manifest"); + if(!manifestNode) { + throw new Error("No Manifest Found"); + } + + spineNode = qs(packageDocument, "spine"); + if(!spineNode) { + throw new Error("No Spine Found"); + } + + this.manifest = this.parseManifest(manifestNode); + this.navPath = this.findNavPath(manifestNode); + this.ncxPath = this.findNcxPath(manifestNode, spineNode); + this.coverPath = this.findCoverPath(packageDocument); + + this.spineNodeIndex = indexOfElementNode(spineNode); + + this.spine = this.parseSpine(spineNode, this.manifest); + + this.uniqueIdentifier = this.findUniqueIdentifier(packageDocument); + this.metadata = this.parseMetadata(metadataNode); + + this.metadata.direction = spineNode.getAttribute("page-progression-direction"); + + return { + "metadata" : this.metadata, + "spine" : this.spine, + "manifest" : this.manifest, + "navPath" : this.navPath, + "ncxPath" : this.ncxPath, + "coverPath": this.coverPath, + "spineNodeIndex" : this.spineNodeIndex + }; + } + + /** + * Parse Metadata + * @private + * @param {node} xml + * @return {object} metadata + */ + parseMetadata(xml){ + var metadata = {}; + + metadata.title = this.getElementText(xml, "title"); + metadata.creator = this.getElementText(xml, "creator"); + metadata.description = this.getElementText(xml, "description"); + + metadata.pubdate = this.getElementText(xml, "date"); + + metadata.publisher = this.getElementText(xml, "publisher"); + + metadata.identifier = this.getElementText(xml, "identifier"); + metadata.language = this.getElementText(xml, "language"); + metadata.rights = this.getElementText(xml, "rights"); + + metadata.modified_date = this.getPropertyText(xml, "dcterms:modified"); + + metadata.layout = this.getPropertyText(xml, "rendition:layout"); + metadata.orientation = this.getPropertyText(xml, "rendition:orientation"); + metadata.flow = this.getPropertyText(xml, "rendition:flow"); + metadata.viewport = this.getPropertyText(xml, "rendition:viewport"); + metadata.media_active_class = this.getPropertyText(xml, "media:active-class"); + metadata.spread = this.getPropertyText(xml, "rendition:spread"); + // metadata.page_prog_dir = packageXml.querySelector("spine").getAttribute("page-progression-direction"); + + return metadata; + } + + /** + * Parse Manifest + * @private + * @param {node} manifestXml + * @return {object} manifest + */ + parseManifest(manifestXml){ + var manifest = {}; + + //-- Turn items into an array + // var selected = manifestXml.querySelectorAll("item"); + var selected = qsa(manifestXml, "item"); + var items = Array.prototype.slice.call(selected); + + //-- Create an object with the id as key + items.forEach(function(item){ + var id = item.getAttribute("id"), + href = item.getAttribute("href") || "", + type = item.getAttribute("media-type") || "", + overlay = item.getAttribute("media-overlay") || "", + properties = item.getAttribute("properties") || ""; + + manifest[id] = { + "href" : href, + // "url" : href, + "type" : type, + "overlay" : overlay, + "properties" : properties.length ? properties.split(" ") : [] + }; + + }); + + return manifest; + + } + + /** + * Parse Spine + * @private + * @param {node} spineXml + * @param {Packaging.manifest} manifest + * @return {object} spine + */ + parseSpine(spineXml, manifest){ + var spine = []; + + var selected = qsa(spineXml, "itemref"); + var items = Array.prototype.slice.call(selected); + + // var epubcfi = new EpubCFI(); + + //-- Add to array to mantain ordering and cross reference with manifest + items.forEach(function(item, index){ + var idref = item.getAttribute("idref"); + // var cfiBase = epubcfi.generateChapterComponent(spineNodeIndex, index, Id); + var props = item.getAttribute("properties") || ""; + var propArray = props.length ? props.split(" ") : []; + // var manifestProps = manifest[Id].properties; + // var manifestPropArray = manifestProps.length ? manifestProps.split(" ") : []; + + var itemref = { + "idref" : idref, + "linear" : item.getAttribute("linear") || "yes", + "properties" : propArray, + // "href" : manifest[Id].href, + // "url" : manifest[Id].url, + "index" : index + // "cfiBase" : cfiBase + }; + spine.push(itemref); + }); + + return spine; + } + + /** + * Find Unique Identifier + * @private + * @param {node} packageXml + * @return {string} Unique Identifier text + */ + findUniqueIdentifier(packageXml){ + var uniqueIdentifierId = packageXml.documentElement.getAttribute("unique-identifier"); + if (! uniqueIdentifierId) { + return ""; + } + var identifier = packageXml.getElementById(uniqueIdentifierId); + if (! identifier) { + return ""; + } + + if (identifier.localName === "identifier" && identifier.namespaceURI === "http://purl.org/dc/elements/1.1/") { + return identifier.childNodes.length > 0 ? identifier.childNodes[0].nodeValue.trim() : ""; + } + + return ""; + } + + /** + * Find TOC NAV + * @private + * @param {element} manifestNode + * @return {string} + */ + findNavPath(manifestNode){ + // Find item with property "nav" + // Should catch nav irregardless of order + // var node = manifestNode.querySelector("item[properties$='nav'], item[properties^='nav '], item[properties*=' nav ']"); + var node = qsp(manifestNode, "item", {"properties":"nav"}); + return node ? node.getAttribute("href") : false; + } + + /** + * Find TOC NCX + * media-type="application/x-dtbncx+xml" href="toc.ncx" + * @private + * @param {element} manifestNode + * @param {element} spineNode + * @return {string} + */ + findNcxPath(manifestNode, spineNode){ + // var node = manifestNode.querySelector("item[media-type='application/x-dtbncx+xml']"); + var node = qsp(manifestNode, "item", {"media-type":"application/x-dtbncx+xml"}); + var tocId; + + // If we can't find the toc by media-type then try to look for id of the item in the spine attributes as + // according to http://www.idpf.org/epub/20/spec/OPF_2.0.1_draft.htm#Section2.4.1.2, + // "The item that describes the NCX must be referenced by the spine toc attribute." + if (!node) { + tocId = spineNode.getAttribute("toc"); + if(tocId) { + // node = manifestNode.querySelector("item[id='" + tocId + "']"); + node = manifestNode.querySelector(`#${tocId}`); + } + } + + return node ? node.getAttribute("href") : false; + } + + /** + * Find the Cover Path + * <item properties="cover-image" id="ci" href="cover.svg" media-type="image/svg+xml" /> + * Fallback for Epub 2.0 + * @private + * @param {node} packageXml + * @return {string} href + */ + findCoverPath(packageXml){ + var pkg = qs(packageXml, "package"); + var epubVersion = pkg.getAttribute("version"); + + // Try parsing cover with epub 3. + // var node = packageXml.querySelector("item[properties='cover-image']"); + var node = qsp(packageXml, "item", {"properties":"cover-image"}); + if (node) return node.getAttribute("href"); + + // Fallback to epub 2. + var metaCover = qsp(packageXml, "meta", {"name":"cover"}); + + if (metaCover) { + var coverId = metaCover.getAttribute("content"); + // var cover = packageXml.querySelector("item[id='" + coverId + "']"); + var cover = packageXml.getElementById(coverId); + return cover ? cover.getAttribute("href") : ""; + } + else { + return false; + } + } + + /** + * Get text of a namespaced element + * @private + * @param {node} xml + * @param {string} tag + * @return {string} text + */ + getElementText(xml, tag){ + var found = xml.getElementsByTagNameNS("http://purl.org/dc/elements/1.1/", tag); + var el; + + if(!found || found.length === 0) return ""; + + el = found[0]; + + if(el.childNodes.length){ + return el.childNodes[0].nodeValue; + } + + return ""; + + } + + /** + * Get text by property + * @private + * @param {node} xml + * @param {string} property + * @return {string} text + */ + getPropertyText(xml, property){ + var el = qsp(xml, "meta", {"property":property}); + + if(el && el.childNodes.length){ + return el.childNodes[0].nodeValue; + } + + return ""; + } + + /** + * Load JSON Manifest + * @param {document} packageDocument OPF XML + * @return {object} parsed package parts + */ + load(json) { + this.metadata = json.metadata; + + let spine = json.readingOrder || json.spine; + this.spine = spine.map((item, index) =>{ + item.index = index; + item.linear = item.linear || "yes"; + return item; + }); + + json.resources.forEach((item, index) => { + this.manifest[index] = item; + + if (item.rel && item.rel[0] === "cover") { + this.coverPath = item.href; + } + }); + + this.spineNodeIndex = 0; + + this.toc = json.toc.map((item, index) =>{ + item.label = item.title; + return item; + }); + + return { + "metadata" : this.metadata, + "spine" : this.spine, + "manifest" : this.manifest, + "navPath" : this.navPath, + "ncxPath" : this.ncxPath, + "coverPath": this.coverPath, + "spineNodeIndex" : this.spineNodeIndex, + "toc" : this.toc + }; + } + + destroy() { + this.manifest = undefined; + this.navPath = undefined; + this.ncxPath = undefined; + this.coverPath = undefined; + this.spineNodeIndex = undefined; + this.spine = undefined; + this.metadata = undefined; + } +} + +export default Packaging; diff --git a/lib/epub.js/src/pagelist.js b/lib/epub.js/src/pagelist.js new file mode 100644 index 0000000..6de82f6 --- /dev/null +++ b/lib/epub.js/src/pagelist.js @@ -0,0 +1,274 @@ +import EpubCFI from "./epubcfi"; +import { + qs, + qsa, + querySelectorByType, + indexOfSorted, + locationOf +} from "./utils/core"; + +/** + * Page List Parser + * @param {document} [xml] + */ +class PageList { + constructor(xml) { + this.pages = []; + this.locations = []; + this.epubcfi = new EpubCFI(); + + this.firstPage = 0; + this.lastPage = 0; + this.totalPages = 0; + + this.toc = undefined; + this.ncx = undefined; + + if (xml) { + this.pageList = this.parse(xml); + } + + if(this.pageList && this.pageList.length) { + this.process(this.pageList); + } + } + + /** + * Parse PageList Xml + * @param {document} xml + */ + parse(xml) { + var html = qs(xml, "html"); + var ncx = qs(xml, "ncx"); + + if(html) { + return this.parseNav(xml); + } else if(ncx){ + return this.parseNcx(xml); + } + + } + + /** + * Parse a Nav PageList + * @private + * @param {node} navHtml + * @return {PageList.item[]} list + */ + parseNav(navHtml){ + var navElement = querySelectorByType(navHtml, "nav", "page-list"); + var navItems = navElement ? qsa(navElement, "li") : []; + var length = navItems.length; + var i; + var list = []; + var item; + + if(!navItems || length === 0) return list; + + for (i = 0; i < length; ++i) { + item = this.item(navItems[i]); + list.push(item); + } + + return list; + } + + parseNcx(navXml) { + var list = []; + var i = 0; + var item; + var pageList; + var pageTargets; + var length = 0; + + pageList = qs(navXml, "pageList"); + if (!pageList) return list; + + pageTargets = qsa(pageList, "pageTarget"); + length = pageTargets.length; + + if (!pageTargets || pageTargets.length === 0) { + return list; + } + + for (i = 0; i < length; ++i) { + item = this.ncxItem(pageTargets[i]); + list.push(item); + } + + return list; + } + + ncxItem(item) { + var navLabel = qs(item, "navLabel"); + var navLabelText = qs(navLabel, "text"); + var pageText = navLabelText.textContent; + var content = qs(item, "content"); + + var href = content.getAttribute("src"); + var page = parseInt(pageText, 10); + + return { + "href": href, + "page": page, + }; + } + + /** + * Page List Item + * @private + * @param {node} item + * @return {object} pageListItem + */ + item(item){ + var content = qs(item, "a"), + href = content.getAttribute("href") || "", + text = content.textContent || "", + page = parseInt(text), + isCfi = href.indexOf("epubcfi"), + split, + packageUrl, + cfi; + + if(isCfi != -1) { + split = href.split("#"); + packageUrl = split[0]; + cfi = split.length > 1 ? split[1] : false; + return { + "cfi" : cfi, + "href" : href, + "packageUrl" : packageUrl, + "page" : page + }; + } else { + return { + "href" : href, + "page" : page + }; + } + } + + /** + * Process pageList items + * @private + * @param {array} pageList + */ + process(pageList){ + pageList.forEach(function(item){ + this.pages.push(item.page); + if (item.cfi) { + this.locations.push(item.cfi); + } + }, this); + this.firstPage = parseInt(this.pages[0]); + this.lastPage = parseInt(this.pages[this.pages.length-1]); + this.totalPages = this.lastPage - this.firstPage; + } + + /** + * Get a PageList result from a EpubCFI + * @param {string} cfi EpubCFI String + * @return {number} page + */ + pageFromCfi(cfi){ + var pg = -1; + + // Check if the pageList has not been set yet + if(this.locations.length === 0) { + return -1; + } + + // TODO: check if CFI is valid? + + // check if the cfi is in the location list + // var index = this.locations.indexOf(cfi); + var index = indexOfSorted(cfi, this.locations, this.epubcfi.compare); + if(index != -1) { + pg = this.pages[index]; + } else { + // Otherwise add it to the list of locations + // Insert it in the correct position in the locations page + //index = EPUBJS.core.insert(cfi, this.locations, this.epubcfi.compare); + index = locationOf(cfi, this.locations, this.epubcfi.compare); + // Get the page at the location just before the new one, or return the first + pg = index-1 >= 0 ? this.pages[index-1] : this.pages[0]; + if(pg !== undefined) { + // Add the new page in so that the locations and page array match up + //this.pages.splice(index, 0, pg); + } else { + pg = -1; + } + + } + return pg; + } + + /** + * Get an EpubCFI from a Page List Item + * @param {string | number} pg + * @return {string} cfi + */ + cfiFromPage(pg){ + var cfi = -1; + // check that pg is an int + if(typeof pg != "number"){ + pg = parseInt(pg); + } + + // check if the cfi is in the page list + // Pages could be unsorted. + var index = this.pages.indexOf(pg); + if(index != -1) { + cfi = this.locations[index]; + } + // TODO: handle pages not in the list + return cfi; + } + + /** + * Get a Page from Book percentage + * @param {number} percent + * @return {number} page + */ + pageFromPercentage(percent){ + var pg = Math.round(this.totalPages * percent); + return pg; + } + + /** + * Returns a value between 0 - 1 corresponding to the location of a page + * @param {number} pg the page + * @return {number} percentage + */ + percentageFromPage(pg){ + var percentage = (pg - this.firstPage) / this.totalPages; + return Math.round(percentage * 1000) / 1000; + } + + /** + * Returns a value between 0 - 1 corresponding to the location of a cfi + * @param {string} cfi EpubCFI String + * @return {number} percentage + */ + percentageFromCfi(cfi){ + var pg = this.pageFromCfi(cfi); + var percentage = this.percentageFromPage(pg); + return percentage; + } + + /** + * Destroy + */ + destroy() { + this.pages = undefined; + this.locations = undefined; + this.epubcfi = undefined; + + this.pageList = undefined; + + this.toc = undefined; + this.ncx = undefined; + } +} + +export default PageList; diff --git a/lib/epub.js/src/rendition.js b/lib/epub.js/src/rendition.js new file mode 100644 index 0000000..f9ba53d --- /dev/null +++ b/lib/epub.js/src/rendition.js @@ -0,0 +1,1064 @@ +import EventEmitter from "event-emitter"; +import { extend, defer, isFloat } from "./utils/core"; +import Hook from "./utils/hook"; +import EpubCFI from "./epubcfi"; +import Queue from "./utils/queue"; +import Layout from "./layout"; +// import Mapping from "./mapping"; +import Themes from "./themes"; +import Contents from "./contents"; +import Annotations from "./annotations"; +import { EVENTS, DOM_EVENTS } from "./utils/constants"; + +// Default Views +import IframeView from "./managers/views/iframe"; + +// Default View Managers +import DefaultViewManager from "./managers/default/index"; +import ContinuousViewManager from "./managers/continuous/index"; + +/** + * Displays an Epub as a series of Views for each Section. + * Requires Manager and View class to handle specifics of rendering + * the section content. + * @class + * @param {Book} book + * @param {object} [options] + * @param {number} [options.width] + * @param {number} [options.height] + * @param {string} [options.ignoreClass] class for the cfi parser to ignore + * @param {string | function | object} [options.manager='default'] + * @param {string | function} [options.view='iframe'] + * @param {string} [options.layout] layout to force + * @param {string} [options.spread] force spread value + * @param {number} [options.minSpreadWidth] overridden by spread: none (never) / both (always) + * @param {string} [options.stylesheet] url of stylesheet to be injected + * @param {boolean} [options.resizeOnOrientationChange] false to disable orientation events + * @param {string} [options.script] url of script to be injected + * @param {boolean | object} [options.snap=false] use snap scrolling + */ +class Rendition { + constructor(book, options) { + + this.settings = extend(this.settings || {}, { + width: null, + height: null, + ignoreClass: "", + manager: "default", + view: "iframe", + flow: null, + layout: null, + spread: null, + minSpreadWidth: 800, + stylesheet: null, + resizeOnOrientationChange: true, + script: null, + snap: false, + defaultDirection: "ltr" + }); + + extend(this.settings, options); + + if (typeof(this.settings.manager) === "object") { + this.manager = this.settings.manager; + } + + this.book = book; + + /** + * Adds Hook methods to the Rendition prototype + * @member {object} hooks + * @property {Hook} hooks.content + * @memberof Rendition + */ + this.hooks = {}; + this.hooks.display = new Hook(this); + this.hooks.serialize = new Hook(this); + this.hooks.content = new Hook(this); + this.hooks.unloaded = new Hook(this); + this.hooks.layout = new Hook(this); + this.hooks.render = new Hook(this); + this.hooks.show = new Hook(this); + + this.hooks.content.register(this.handleLinks.bind(this)); + this.hooks.content.register(this.passEvents.bind(this)); + this.hooks.content.register(this.adjustImages.bind(this)); + + this.book.spine.hooks.content.register(this.injectIdentifier.bind(this)); + + if (this.settings.stylesheet) { + this.book.spine.hooks.content.register(this.injectStylesheet.bind(this)); + } + + if (this.settings.script) { + this.book.spine.hooks.content.register(this.injectScript.bind(this)); + } + + /** + * @member {Themes} themes + * @memberof Rendition + */ + this.themes = new Themes(this); + + /** + * @member {Annotations} annotations + * @memberof Rendition + */ + this.annotations = new Annotations(this); + + this.epubcfi = new EpubCFI(); + + this.q = new Queue(this); + + /** + * A Rendered Location Range + * @typedef location + * @type {Object} + * @property {object} start + * @property {string} start.index + * @property {string} start.href + * @property {object} start.displayed + * @property {EpubCFI} start.cfi + * @property {number} start.location + * @property {number} start.percentage + * @property {number} start.displayed.page + * @property {number} start.displayed.total + * @property {object} end + * @property {string} end.index + * @property {string} end.href + * @property {object} end.displayed + * @property {EpubCFI} end.cfi + * @property {number} end.location + * @property {number} end.percentage + * @property {number} end.displayed.page + * @property {number} end.displayed.total + * @property {boolean} atStart + * @property {boolean} atEnd + * @memberof Rendition + */ + this.location = undefined; + + // Hold queue until book is opened + this.q.enqueue(this.book.opened); + + this.starting = new defer(); + /** + * @member {promise} started returns after the rendition has started + * @memberof Rendition + */ + this.started = this.starting.promise; + + // Block the queue until rendering is started + this.q.enqueue(this.start); + } + + /** + * Set the manager function + * @param {function} manager + */ + setManager(manager) { + this.manager = manager; + } + + /** + * Require the manager from passed string, or as a class function + * @param {string|object} manager [description] + * @return {method} + */ + requireManager(manager) { + var viewManager; + + // If manager is a string, try to load from imported managers + if (typeof manager === "string" && manager === "default") { + viewManager = DefaultViewManager; + } else if (typeof manager === "string" && manager === "continuous") { + viewManager = ContinuousViewManager; + } else { + // otherwise, assume we were passed a class function + viewManager = manager; + } + + return viewManager; + } + + /** + * Require the view from passed string, or as a class function + * @param {string|object} view + * @return {view} + */ + requireView(view) { + var View; + + // If view is a string, try to load from imported views, + if (typeof view == "string" && view === "iframe") { + View = IframeView; + } else { + // otherwise, assume we were passed a class function + View = view; + } + + return View; + } + + /** + * Start the rendering + * @return {Promise} rendering has started + */ + start(){ + if (!this.settings.layout && (this.book.package.metadata.layout === "pre-paginated" || this.book.displayOptions.fixedLayout === "true")) { + this.settings.layout = "pre-paginated"; + } + switch(this.book.package.metadata.spread) { + case 'none': + this.settings.spread = 'none'; + break; + case 'both': + this.settings.spread = true; + break; + } + + if(!this.manager) { + this.ViewManager = this.requireManager(this.settings.manager); + this.View = this.requireView(this.settings.view); + + this.manager = new this.ViewManager({ + view: this.View, + queue: this.q, + request: this.book.load.bind(this.book), + settings: this.settings + }); + } + + this.direction(this.book.package.metadata.direction || this.settings.defaultDirection); + + // Parse metadata to get layout props + this.settings.globalLayoutProperties = this.determineLayoutProperties(this.book.package.metadata); + + this.flow(this.settings.globalLayoutProperties.flow); + + this.layout(this.settings.globalLayoutProperties); + + // Listen for displayed views + this.manager.on(EVENTS.MANAGERS.ADDED, this.afterDisplayed.bind(this)); + this.manager.on(EVENTS.MANAGERS.REMOVED, this.afterRemoved.bind(this)); + + // Listen for resizing + this.manager.on(EVENTS.MANAGERS.RESIZED, this.onResized.bind(this)); + + // Listen for rotation + this.manager.on(EVENTS.MANAGERS.ORIENTATION_CHANGE, this.onOrientationChange.bind(this)); + + // Listen for scroll changes + this.manager.on(EVENTS.MANAGERS.SCROLLED, this.reportLocation.bind(this)); + + /** + * Emit that rendering has started + * @event started + * @memberof Rendition + */ + this.emit(EVENTS.RENDITION.STARTED); + + // Start processing queue + this.starting.resolve(); + } + + /** + * Call to attach the container to an element in the dom + * Container must be attached before rendering can begin + * @param {element} element to attach to + * @return {Promise} + */ + attachTo(element){ + + return this.q.enqueue(function () { + + // Start rendering + this.manager.render(element, { + "width" : this.settings.width, + "height" : this.settings.height + }); + + /** + * Emit that rendering has attached to an element + * @event attached + * @memberof Rendition + */ + this.emit(EVENTS.RENDITION.ATTACHED); + + }.bind(this)); + + } + + /** + * Display a point in the book + * The request will be added to the rendering Queue, + * so it will wait until book is opened, rendering started + * and all other rendering tasks have finished to be called. + * @param {string} target Url or EpubCFI + * @return {Promise} + */ + display(target){ + if (this.displaying) { + this.displaying.resolve(); + } + return this.q.enqueue(this._display, target); + } + + /** + * Tells the manager what to display immediately + * @private + * @param {string} target Url or EpubCFI + * @return {Promise} + */ + _display(target){ + if (!this.book) { + return; + } + var isCfiString = this.epubcfi.isCfiString(target); + var displaying = new defer(); + var displayed = displaying.promise; + var section; + var moveTo; + + this.displaying = displaying; + + // Check if this is a book percentage + if (this.book.locations.length() && isFloat(target)) { + target = this.book.locations.cfiFromPercentage(parseFloat(target)); + } + + section = this.book.spine.get(target); + + if(!section){ + displaying.reject(new Error("No Section Found")); + return displayed; + } + + this.manager.display(section, target) + .then(() => { + displaying.resolve(section); + this.displaying = undefined; + + /** + * Emit that a section has been displayed + * @event displayed + * @param {Section} section + * @memberof Rendition + */ + this.emit(EVENTS.RENDITION.DISPLAYED, section); + this.reportLocation(); + }, (err) => { + /** + * Emit that has been an error displaying + * @event displayError + * @param {Section} section + * @memberof Rendition + */ + this.emit(EVENTS.RENDITION.DISPLAY_ERROR, err); + }); + + return displayed; + } + + /* + render(view, show) { + + // view.onLayout = this.layout.format.bind(this.layout); + view.create(); + + // Fit to size of the container, apply padding + this.manager.resizeView(view); + + // Render Chain + return view.section.render(this.book.request) + .then(function(contents){ + return view.load(contents); + }.bind(this)) + .then(function(doc){ + return this.hooks.content.trigger(view, this); + }.bind(this)) + .then(function(){ + this.layout.format(view.contents); + return this.hooks.layout.trigger(view, this); + }.bind(this)) + .then(function(){ + return view.display(); + }.bind(this)) + .then(function(){ + return this.hooks.render.trigger(view, this); + }.bind(this)) + .then(function(){ + if(show !== false) { + this.q.enqueue(function(view){ + view.show(); + }, view); + } + // this.map = new Map(view, this.layout); + this.hooks.show.trigger(view, this); + this.trigger("rendered", view.section); + + }.bind(this)) + .catch(function(e){ + this.trigger("loaderror", e); + }.bind(this)); + + } + */ + + /** + * Report what section has been displayed + * @private + * @param {*} view + */ + afterDisplayed(view){ + + view.on(EVENTS.VIEWS.MARK_CLICKED, (cfiRange, data) => this.triggerMarkEvent(cfiRange, data, view.contents)); + + this.hooks.render.trigger(view, this) + .then(() => { + if (view.contents) { + this.hooks.content.trigger(view.contents, this).then(() => { + /** + * Emit that a section has been rendered + * @event rendered + * @param {Section} section + * @param {View} view + * @memberof Rendition + */ + this.emit(EVENTS.RENDITION.RENDERED, view.section, view); + }); + } else { + this.emit(EVENTS.RENDITION.RENDERED, view.section, view); + } + }); + + } + + /** + * Report what has been removed + * @private + * @param {*} view + */ + afterRemoved(view){ + this.hooks.unloaded.trigger(view, this).then(() => { + /** + * Emit that a section has been removed + * @event removed + * @param {Section} section + * @param {View} view + * @memberof Rendition + */ + this.emit(EVENTS.RENDITION.REMOVED, view.section, view); + }); + } + + /** + * Report resize events and display the last seen location + * @private + */ + onResized(size, epubcfi){ + + /** + * Emit that the rendition has been resized + * @event resized + * @param {number} width + * @param {height} height + * @param {string} epubcfi (optional) + * @memberof Rendition + */ + this.emit(EVENTS.RENDITION.RESIZED, { + width: size.width, + height: size.height + }, epubcfi); + + if (this.location && this.location.start) { + this.display(epubcfi || this.location.start.cfi); + } + + } + + /** + * Report orientation events and display the last seen location + * @private + */ + onOrientationChange(orientation){ + /** + * Emit that the rendition has been rotated + * @event orientationchange + * @param {string} orientation + * @memberof Rendition + */ + this.emit(EVENTS.RENDITION.ORIENTATION_CHANGE, orientation); + } + + /** + * Move the Rendition to a specific offset + * Usually you would be better off calling display() + * @param {object} offset + */ + moveTo(offset){ + this.manager.moveTo(offset); + } + + /** + * Trigger a resize of the views + * @param {number} [width] + * @param {number} [height] + * @param {string} [epubcfi] (optional) + */ + resize(width, height, epubcfi){ + if (width) { + this.settings.width = width; + } + if (height) { + this.settings.height = height; + } + this.manager.resize(width, height, epubcfi); + } + + /** + * Clear all rendered views + */ + clear(){ + this.manager.clear(); + } + + /** + * Go to the next "page" in the rendition + * @return {Promise} + */ + next(){ + return this.q.enqueue(this.manager.next.bind(this.manager)) + .then(this.reportLocation.bind(this)); + } + + /** + * Go to the previous "page" in the rendition + * @return {Promise} + */ + prev(){ + return this.q.enqueue(this.manager.prev.bind(this.manager)) + .then(this.reportLocation.bind(this)); + } + + //-- http://www.idpf.org/epub/301/spec/epub-publications.html#meta-properties-rendering + /** + * Determine the Layout properties from metadata and settings + * @private + * @param {object} metadata + * @return {object} properties + */ + determineLayoutProperties(metadata){ + var properties; + var layout = this.settings.layout || metadata.layout || "reflowable"; + var spread = this.settings.spread || metadata.spread || "auto"; + var orientation = this.settings.orientation || metadata.orientation || "auto"; + var flow = this.settings.flow || metadata.flow || "auto"; + var viewport = metadata.viewport || ""; + var minSpreadWidth = this.settings.minSpreadWidth || metadata.minSpreadWidth || 800; + var direction = this.settings.direction || metadata.direction || "ltr"; + + if ((this.settings.width === 0 || this.settings.width > 0) && + (this.settings.height === 0 || this.settings.height > 0)) { + // viewport = "width="+this.settings.width+", height="+this.settings.height+""; + } + + properties = { + layout : layout, + spread : spread, + orientation : orientation, + flow : flow, + viewport : viewport, + minSpreadWidth : minSpreadWidth, + direction: direction + }; + + return properties; + } + + /** + * Adjust the flow of the rendition to paginated or scrolled + * (scrolled-continuous vs scrolled-doc are handled by different view managers) + * @param {string} flow + */ + flow(flow){ + var _flow = flow; + if (flow === "scrolled" || + flow === "scrolled-doc" || + flow === "scrolled-continuous") { + _flow = "scrolled"; + } + + if (flow === "auto" || flow === "paginated") { + _flow = "paginated"; + } + + this.settings.flow = flow; + + if (this._layout) { + this._layout.flow(_flow); + } + + if (this.manager && this._layout) { + this.manager.applyLayout(this._layout); + } + + if (this.manager) { + this.manager.updateFlow(_flow); + } + + if (this.manager && this.manager.isRendered() && this.location) { + this.manager.clear(); + this.display(this.location.start.cfi); + } + } + + /** + * Adjust the layout of the rendition to reflowable or pre-paginated + * @param {object} settings + */ + layout(settings){ + if (settings) { + this._layout = new Layout(settings); + this._layout.spread(settings.spread, this.settings.minSpreadWidth); + + // this.mapping = new Mapping(this._layout.props); + + this._layout.on(EVENTS.LAYOUT.UPDATED, (props, changed) => { + this.emit(EVENTS.RENDITION.LAYOUT, props, changed); + }) + } + + if (this.manager && this._layout) { + this.manager.applyLayout(this._layout); + } + + return this._layout; + } + + /** + * Adjust if the rendition uses spreads + * @param {string} spread none | auto (TODO: implement landscape, portrait, both) + * @param {int} [min] min width to use spreads at + */ + spread(spread, min){ + + this.settings.spread = spread; + + if (min) { + this.settings.minSpreadWidth = min; + } + + if (this._layout) { + this._layout.spread(spread, min); + } + + if (this.manager && this.manager.isRendered()) { + this.manager.updateLayout(); + } + } + + /** + * Adjust the direction of the rendition + * @param {string} dir + */ + direction(dir){ + + this.settings.direction = dir || "ltr"; + + if (this.manager) { + this.manager.direction(this.settings.direction); + } + + if (this.manager && this.manager.isRendered() && this.location) { + this.manager.clear(); + this.display(this.location.start.cfi); + } + } + + /** + * Report the current location + * @fires relocated + * @fires locationChanged + */ + reportLocation(){ + return this.q.enqueue(function reportedLocation(){ + requestAnimationFrame(function reportedLocationAfterRAF() { + var location = this.manager.currentLocation(); + if (location && location.then && typeof location.then === "function") { + location.then(function(result) { + let located = this.located(result); + + if (!located || !located.start || !located.end) { + return; + } + + this.location = located; + + this.emit(EVENTS.RENDITION.LOCATION_CHANGED, { + index: this.location.start.index, + href: this.location.start.href, + start: this.location.start.cfi, + end: this.location.end.cfi, + percentage: this.location.start.percentage + }); + + this.emit(EVENTS.RENDITION.RELOCATED, this.location); + }.bind(this)); + } else if (location) { + let located = this.located(location); + + if (!located || !located.start || !located.end) { + return; + } + + this.location = located; + + /** + * @event locationChanged + * @deprecated + * @type {object} + * @property {number} index + * @property {string} href + * @property {EpubCFI} start + * @property {EpubCFI} end + * @property {number} percentage + * @memberof Rendition + */ + this.emit(EVENTS.RENDITION.LOCATION_CHANGED, { + index: this.location.start.index, + href: this.location.start.href, + start: this.location.start.cfi, + end: this.location.end.cfi, + percentage: this.location.start.percentage + }); + + /** + * @event relocated + * @type {displayedLocation} + * @memberof Rendition + */ + this.emit(EVENTS.RENDITION.RELOCATED, this.location); + } + }.bind(this)); + }.bind(this)); + } + + /** + * Get the Current Location object + * @return {displayedLocation | promise} location (may be a promise) + */ + currentLocation(){ + var location = this.manager.currentLocation(); + if (location && location.then && typeof location.then === "function") { + location.then(function(result) { + let located = this.located(result); + return located; + }.bind(this)); + } else if (location) { + let located = this.located(location); + return located; + } + } + + /** + * Creates a Rendition#locationRange from location + * passed by the Manager + * @returns {displayedLocation} + * @private + */ + located(location){ + if (!location.length) { + return {}; + } + let start = location[0]; + let end = location[location.length-1]; + + let located = { + start: { + index: start.index, + href: start.href, + cfi: start.mapping.start, + displayed: { + page: start.pages[0] || 1, + total: start.totalPages + } + }, + end: { + index: end.index, + href: end.href, + cfi: end.mapping.end, + displayed: { + page: end.pages[end.pages.length-1] || 1, + total: end.totalPages + } + } + }; + + let locationStart = this.book.locations.locationFromCfi(start.mapping.start); + let locationEnd = this.book.locations.locationFromCfi(end.mapping.end); + + if (locationStart != null) { + located.start.location = locationStart; + located.start.percentage = this.book.locations.percentageFromLocation(locationStart); + } + if (locationEnd != null) { + located.end.location = locationEnd; + located.end.percentage = this.book.locations.percentageFromLocation(locationEnd); + } + + let pageStart = this.book.pageList.pageFromCfi(start.mapping.start); + let pageEnd = this.book.pageList.pageFromCfi(end.mapping.end); + + if (pageStart != -1) { + located.start.page = pageStart; + } + if (pageEnd != -1) { + located.end.page = pageEnd; + } + + if (end.index === this.book.spine.last().index && + located.end.displayed.page >= located.end.displayed.total) { + located.atEnd = true; + } + + if (start.index === this.book.spine.first().index && + located.start.displayed.page === 1) { + located.atStart = true; + } + + return located; + } + + /** + * Remove and Clean Up the Rendition + */ + destroy(){ + // Clear the queue + // this.q.clear(); + // this.q = undefined; + + this.manager && this.manager.destroy(); + + this.book = undefined; + + // this.views = null; + + // this.hooks.display.clear(); + // this.hooks.serialize.clear(); + // this.hooks.content.clear(); + // this.hooks.layout.clear(); + // this.hooks.render.clear(); + // this.hooks.show.clear(); + // this.hooks = {}; + + // this.themes.destroy(); + // this.themes = undefined; + + // this.epubcfi = undefined; + + // this.starting = undefined; + // this.started = undefined; + + + } + + /** + * Pass the events from a view's Contents + * @private + * @param {Contents} view contents + */ + passEvents(contents){ + DOM_EVENTS.forEach((e) => { + contents.on(e, (ev) => this.triggerViewEvent(ev, contents)); + }); + + contents.on(EVENTS.CONTENTS.SELECTED, (e) => this.triggerSelectedEvent(e, contents)); + } + + /** + * Emit events passed by a view + * @private + * @param {event} e + */ + triggerViewEvent(e, contents){ + this.emit(e.type, e, contents); + } + + /** + * Emit a selection event's CFI Range passed from a a view + * @private + * @param {string} cfirange + */ + triggerSelectedEvent(cfirange, contents){ + /** + * Emit that a text selection has occured + * @event selected + * @param {string} cfirange + * @param {Contents} contents + * @memberof Rendition + */ + this.emit(EVENTS.RENDITION.SELECTED, cfirange, contents); + } + + /** + * Emit a markClicked event with the cfiRange and data from a mark + * @private + * @param {EpubCFI} cfirange + */ + triggerMarkEvent(cfiRange, data, contents){ + /** + * Emit that a mark was clicked + * @event markClicked + * @param {EpubCFI} cfirange + * @param {object} data + * @param {Contents} contents + * @memberof Rendition + */ + this.emit(EVENTS.RENDITION.MARK_CLICKED, cfiRange, data, contents); + } + + /** + * Get a Range from a Visible CFI + * @param {string} cfi EpubCfi String + * @param {string} ignoreClass + * @return {range} + */ + getRange(cfi, ignoreClass){ + var _cfi = new EpubCFI(cfi); + var found = this.manager.visible().filter(function (view) { + if(_cfi.spinePos === view.index) return true; + }); + + // Should only every return 1 item + if (found.length) { + return found[0].contents.range(_cfi, ignoreClass); + } + } + + /** + * Hook to adjust images to fit in columns + * @param {Contents} contents + * @private + */ + adjustImages(contents) { + + if (this._layout.name === "pre-paginated") { + return new Promise(function(resolve){ + resolve(); + }); + } + + let computed = contents.window.getComputedStyle(contents.content, null); + let height = (contents.content.offsetHeight - (parseFloat(computed.paddingTop) + parseFloat(computed.paddingBottom))) * .95; + let horizontalPadding = parseFloat(computed.paddingLeft) + parseFloat(computed.paddingRight); + + contents.addStylesheetRules({ + "img" : { + "max-width": (this._layout.columnWidth ? (this._layout.columnWidth - horizontalPadding) + "px" : "100%") + "!important", + "max-height": height + "px" + "!important", + "object-fit": "contain", + "page-break-inside": "avoid", + "break-inside": "avoid", + "box-sizing": "border-box" + }, + "svg" : { + "max-width": (this._layout.columnWidth ? (this._layout.columnWidth - horizontalPadding) + "px" : "100%") + "!important", + "max-height": height + "px" + "!important", + "page-break-inside": "avoid", + "break-inside": "avoid" + } + }); + + return new Promise(function(resolve, reject){ + // Wait to apply + setTimeout(function() { + resolve(); + }, 1); + }); + } + + /** + * Get the Contents object of each rendered view + * @returns {Contents[]} + */ + getContents () { + return this.manager ? this.manager.getContents() : []; + } + + /** + * Get the views member from the manager + * @returns {Views} + */ + views () { + let views = this.manager ? this.manager.views : undefined; + return views || []; + } + + /** + * Hook to handle link clicks in rendered content + * @param {Contents} contents + * @private + */ + handleLinks(contents) { + if (contents) { + contents.on(EVENTS.CONTENTS.LINK_CLICKED, (href) => { + let relative = this.book.path.relative(href); + this.display(relative); + }); + } + } + + /** + * Hook to handle injecting stylesheet before + * a Section is serialized + * @param {document} doc + * @param {Section} section + * @private + */ + injectStylesheet(doc, section) { + let style = doc.createElement("link"); + style.setAttribute("type", "text/css"); + style.setAttribute("rel", "stylesheet"); + style.setAttribute("href", this.settings.stylesheet); + doc.getElementsByTagName("head")[0].appendChild(style); + } + + /** + * Hook to handle injecting scripts before + * a Section is serialized + * @param {document} doc + * @param {Section} section + * @private + */ + injectScript(doc, section) { + let script = doc.createElement("script"); + script.setAttribute("type", "text/javascript"); + script.setAttribute("src", this.settings.script); + script.textContent = " "; // Needed to prevent self closing tag + doc.getElementsByTagName("head")[0].appendChild(script); + } + + /** + * Hook to handle the document identifier before + * a Section is serialized + * @param {document} doc + * @param {Section} section + * @private + */ + injectIdentifier(doc, section) { + let ident = this.book.packaging.metadata.identifier; + let meta = doc.createElement("meta"); + meta.setAttribute("name", "dc.relation.ispartof"); + if (ident) { + meta.setAttribute("content", ident); + } + doc.getElementsByTagName("head")[0].appendChild(meta); + } + +} + +//-- Enable binding events to Renderer +EventEmitter(Rendition.prototype); + +export default Rendition; diff --git a/lib/epub.js/src/resources.js b/lib/epub.js/src/resources.js new file mode 100644 index 0000000..b5d77f8 --- /dev/null +++ b/lib/epub.js/src/resources.js @@ -0,0 +1,320 @@ +import {substitute} from "./utils/replacements"; +import {createBase64Url, createBlobUrl, blob2base64} from "./utils/core"; +import Url from "./utils/url"; +import mime from "./utils/mime"; +import Path from "./utils/path"; +import path from "path-webpack"; + +/** + * Handle Package Resources + * @class + * @param {Manifest} manifest + * @param {object} [options] + * @param {string} [options.replacements="base64"] + * @param {Archive} [options.archive] + * @param {method} [options.resolver] + */ +class Resources { + constructor(manifest, options) { + this.settings = { + replacements: (options && options.replacements) || "base64", + archive: (options && options.archive), + resolver: (options && options.resolver), + request: (options && options.request) + }; + + this.process(manifest); + } + + /** + * Process resources + * @param {Manifest} manifest + */ + process(manifest){ + this.manifest = manifest; + this.resources = Object.keys(manifest). + map(function (key){ + return manifest[key]; + }); + + this.replacementUrls = []; + + this.html = []; + this.assets = []; + this.css = []; + + this.urls = []; + this.cssUrls = []; + + this.split(); + this.splitUrls(); + } + + /** + * Split resources by type + * @private + */ + split(){ + + // HTML + this.html = this.resources. + filter(function (item){ + if (item.type === "application/xhtml+xml" || + item.type === "text/html") { + return true; + } + }); + + // Exclude HTML + this.assets = this.resources. + filter(function (item){ + if (item.type !== "application/xhtml+xml" && + item.type !== "text/html") { + return true; + } + }); + + // Only CSS + this.css = this.resources. + filter(function (item){ + if (item.type === "text/css") { + return true; + } + }); + } + + /** + * Convert split resources into Urls + * @private + */ + splitUrls(){ + + // All Assets Urls + this.urls = this.assets. + map(function(item) { + return item.href; + }.bind(this)); + + // Css Urls + this.cssUrls = this.css.map(function(item) { + return item.href; + }); + + } + + /** + * Create a url to a resource + * @param {string} url + * @return {Promise<string>} Promise resolves with url string + */ + createUrl (url) { + var parsedUrl = new Url(url); + var mimeType = mime.lookup(parsedUrl.filename); + + if (this.settings.archive) { + return this.settings.archive.createUrl(url, {"base64": (this.settings.replacements === "base64")}); + } else { + if (this.settings.replacements === "base64") { + return this.settings.request(url, 'blob') + .then((blob) => { + return blob2base64(blob); + }) + .then((blob) => { + return createBase64Url(blob, mimeType); + }); + } else { + return this.settings.request(url, 'blob').then((blob) => { + return createBlobUrl(blob, mimeType); + }) + } + } + } + + /** + * Create blob urls for all the assets + * @return {Promise} returns replacement urls + */ + replacements(){ + if (this.settings.replacements === "none") { + return new Promise(function(resolve) { + resolve(this.urls); + }.bind(this)); + } + + var replacements = this.urls.map( (url) => { + var absolute = this.settings.resolver(url); + + return this.createUrl(absolute). + catch((err) => { + console.error(err); + return null; + }); + }); + + return Promise.all(replacements) + .then( (replacementUrls) => { + this.replacementUrls = replacementUrls.filter((url) => { + return (typeof(url) === "string"); + }); + return replacementUrls; + }); + } + + /** + * Replace URLs in CSS resources + * @private + * @param {Archive} [archive] + * @param {method} [resolver] + * @return {Promise} + */ + replaceCss(archive, resolver){ + var replaced = []; + archive = archive || this.settings.archive; + resolver = resolver || this.settings.resolver; + this.cssUrls.forEach(function(href) { + var replacement = this.createCssFile(href, archive, resolver) + .then(function (replacementUrl) { + // switch the url in the replacementUrls + var indexInUrls = this.urls.indexOf(href); + if (indexInUrls > -1) { + this.replacementUrls[indexInUrls] = replacementUrl; + } + }.bind(this)) + + + replaced.push(replacement); + }.bind(this)); + return Promise.all(replaced); + } + + /** + * Create a new CSS file with the replaced URLs + * @private + * @param {string} href the original css file + * @return {Promise} returns a BlobUrl to the new CSS file or a data url + */ + createCssFile(href){ + var newUrl; + + if (path.isAbsolute(href)) { + return new Promise(function(resolve){ + resolve(); + }); + } + + var absolute = this.settings.resolver(href); + + // Get the text of the css file from the archive + var textResponse; + + if (this.settings.archive) { + textResponse = this.settings.archive.getText(absolute); + } else { + textResponse = this.settings.request(absolute, "text"); + } + + // Get asset links relative to css file + var relUrls = this.urls.map( (assetHref) => { + var resolved = this.settings.resolver(assetHref); + var relative = new Path(absolute).relative(resolved); + + return relative; + }); + + if (!textResponse) { + // file not found, don't replace + return new Promise(function(resolve){ + resolve(); + }); + } + + return textResponse.then( (text) => { + // Replacements in the css text + text = substitute(text, relUrls, this.replacementUrls); + + // Get the new url + if (this.settings.replacements === "base64") { + newUrl = createBase64Url(text, "text/css"); + } else { + newUrl = createBlobUrl(text, "text/css"); + } + + return newUrl; + }, (err) => { + // handle response errors + return new Promise(function(resolve){ + resolve(); + }); + }); + + } + + /** + * Resolve all resources URLs relative to an absolute URL + * @param {string} absolute to be resolved to + * @param {resolver} [resolver] + * @return {string[]} array with relative Urls + */ + relativeTo(absolute, resolver){ + resolver = resolver || this.settings.resolver; + + // Get Urls relative to current sections + return this.urls. + map(function(href) { + var resolved = resolver(href); + var relative = new Path(absolute).relative(resolved); + return relative; + }.bind(this)); + } + + /** + * Get a URL for a resource + * @param {string} path + * @return {string} url + */ + get(path) { + var indexInUrls = this.urls.indexOf(path); + if (indexInUrls === -1) { + return; + } + if (this.replacementUrls.length) { + return new Promise(function(resolve, reject) { + resolve(this.replacementUrls[indexInUrls]); + }.bind(this)); + } else { + return this.createUrl(path); + } + } + + /** + * Substitute urls in content, with replacements, + * relative to a url if provided + * @param {string} content + * @param {string} [url] url to resolve to + * @return {string} content with urls substituted + */ + substitute(content, url) { + var relUrls; + if (url) { + relUrls = this.relativeTo(url); + } else { + relUrls = this.urls; + } + return substitute(content, relUrls, this.replacementUrls); + } + + destroy() { + this.settings = undefined; + this.manifest = undefined; + this.resources = undefined; + this.replacementUrls = undefined; + this.html = undefined; + this.assets = undefined; + this.css = undefined; + + this.urls = undefined; + this.cssUrls = undefined; + } +} + +export default Resources; diff --git a/lib/epub.js/src/section.js b/lib/epub.js/src/section.js new file mode 100644 index 0000000..89912d7 --- /dev/null +++ b/lib/epub.js/src/section.js @@ -0,0 +1,323 @@ +import { defer } from "./utils/core"; +import EpubCFI from "./epubcfi"; +import Hook from "./utils/hook"; +import { sprint } from "./utils/core"; +import { replaceBase } from "./utils/replacements"; +import Request from "./utils/request"; +import { XMLDOMParser as XMLDOMSerializer } from "xmldom"; + +/** + * Represents a Section of the Book + * + * In most books this is equivelent to a Chapter + * @param {object} item The spine item representing the section + * @param {object} hooks hooks for serialize and content + */ +class Section { + constructor(item, hooks){ + this.idref = item.idref; + this.linear = item.linear === "yes"; + this.properties = item.properties; + this.index = item.index; + this.href = item.href; + this.url = item.url; + this.canonical = item.canonical; + this.next = item.next; + this.prev = item.prev; + + this.cfiBase = item.cfiBase; + + if (hooks) { + this.hooks = hooks; + } else { + this.hooks = {}; + this.hooks.serialize = new Hook(this); + this.hooks.content = new Hook(this); + } + + this.document = undefined; + this.contents = undefined; + this.output = undefined; + } + + /** + * Load the section from its url + * @param {method} [_request] a request method to use for loading + * @return {document} a promise with the xml document + */ + load(_request){ + var request = _request || this.request || Request; + var loading = new defer(); + var loaded = loading.promise; + + if(this.contents) { + loading.resolve(this.contents); + } else { + request(this.url) + .then(function(xml){ + // var directory = new Url(this.url).directory; + + this.document = xml; + this.contents = xml.documentElement; + + return this.hooks.content.trigger(this.document, this); + }.bind(this)) + .then(function(){ + loading.resolve(this.contents); + }.bind(this)) + .catch(function(error){ + loading.reject(error); + }); + } + + return loaded; + } + + /** + * Adds a base tag for resolving urls in the section + * @private + */ + base(){ + return replaceBase(this.document, this); + } + + /** + * Render the contents of a section + * @param {method} [_request] a request method to use for loading + * @return {string} output a serialized XML Document + */ + render(_request){ + var rendering = new defer(); + var rendered = rendering.promise; + this.output; // TODO: better way to return this from hooks? + + this.load(_request). + then(function(contents){ + var userAgent = (typeof navigator !== 'undefined' && navigator.userAgent) || ''; + var isIE = userAgent.indexOf('Trident') >= 0; + var Serializer; + if (typeof XMLSerializer === "undefined" || isIE) { + Serializer = XMLDOMSerializer; + } else { + Serializer = XMLSerializer; + } + var serializer = new Serializer(); + this.output = serializer.serializeToString(contents); + return this.output; + }.bind(this)). + then(function(){ + return this.hooks.serialize.trigger(this.output, this); + }.bind(this)). + then(function(){ + rendering.resolve(this.output); + }.bind(this)) + .catch(function(error){ + rendering.reject(error); + }); + + return rendered; + } + + /** + * Find a string in a section + * @param {string} _query The query string to find + * @return {object[]} A list of matches, with form {cfi, excerpt} + */ + find(_query){ + var section = this; + var matches = []; + var query = _query.toLowerCase(); + var find = function(node){ + var text = node.textContent.toLowerCase(); + var range = section.document.createRange(); + var cfi; + var pos; + var last = -1; + var excerpt; + var limit = 150; + + while (pos != -1) { + // Search for the query + pos = text.indexOf(query, last + 1); + + if (pos != -1) { + // We found it! Generate a CFI + range = section.document.createRange(); + range.setStart(node, pos); + range.setEnd(node, pos + query.length); + + cfi = section.cfiFromRange(range); + + // Generate the excerpt + if (node.textContent.length < limit) { + excerpt = node.textContent; + } + else { + excerpt = node.textContent.substring(pos - limit/2, pos + limit/2); + excerpt = "..." + excerpt + "..."; + } + + // Add the CFI to the matches list + matches.push({ + cfi: cfi, + excerpt: excerpt + }); + } + + last = pos; + } + }; + + sprint(section.document, function(node) { + find(node); + }); + + return matches; + }; + + + /** + * Search a string in multiple sequential Element of the section. If the document.createTreeWalker api is missed(eg: IE8), use `find` as a fallback. + * @param {string} _query The query string to search + * @param {int} maxSeqEle The maximum number of Element that are combined for search, defualt value is 5. + * @return {object[]} A list of matches, with form {cfi, excerpt} + */ + search(_query , maxSeqEle = 5){ + if (typeof(document.createTreeWalker) == "undefined") { + return this.find(_query); + } + let matches = []; + const excerptLimit = 150; + const section = this; + const query = _query.toLowerCase(); + const search = function(nodeList){ + const textWithCase = nodeList.reduce((acc ,current)=>{ + return acc + current.textContent; + },""); + const text = textWithCase.toLowerCase(); + const pos = text.indexOf(query); + if (pos != -1){ + const startNodeIndex = 0 , endPos = pos + query.length; + let endNodeIndex = 0 , l = 0; + if (pos < nodeList[startNodeIndex].length){ + let cfi; + while( endNodeIndex < nodeList.length - 1 ){ + l += nodeList[endNodeIndex].length; + if ( endPos <= l){ + break; + } + endNodeIndex += 1; + } + + let startNode = nodeList[startNodeIndex] , endNode = nodeList[endNodeIndex]; + let range = section.document.createRange(); + range.setStart(startNode,pos); + let beforeEndLengthCount = nodeList.slice(0, endNodeIndex).reduce((acc,current)=>{return acc+current.textContent.length;},0) ; + range.setEnd(endNode, beforeEndLengthCount > endPos ? endPos : endPos - beforeEndLengthCount ); + cfi = section.cfiFromRange(range); + + let excerpt = nodeList.slice(0, endNodeIndex+1).reduce((acc,current)=>{return acc+current.textContent ;},""); + if (excerpt.length > excerptLimit){ + excerpt = excerpt.substring(pos - excerptLimit/2, pos + excerptLimit/2); + excerpt = "..." + excerpt + "..."; + } + matches.push({ + cfi: cfi, + excerpt: excerpt + }); + } + } + } + + const treeWalker = document.createTreeWalker(section.document, NodeFilter.SHOW_TEXT, null, false); + let node , nodeList = []; + while (node = treeWalker.nextNode()) { + nodeList.push(node); + if (nodeList.length == maxSeqEle){ + search(nodeList.slice(0 , maxSeqEle)); + nodeList = nodeList.slice(1, maxSeqEle); + } + } + if (nodeList.length > 0){ + search(nodeList); + } + return matches; + } + + /** + * Reconciles the current chapters layout properies with + * the global layout properities. + * @param {object} globalLayout The global layout settings object, chapter properties string + * @return {object} layoutProperties Object with layout properties + */ + reconcileLayoutSettings(globalLayout){ + //-- Get the global defaults + var settings = { + layout : globalLayout.layout, + spread : globalLayout.spread, + orientation : globalLayout.orientation + }; + + //-- Get the chapter's display type + this.properties.forEach(function(prop){ + var rendition = prop.replace("rendition:", ""); + var split = rendition.indexOf("-"); + var property, value; + + if(split != -1){ + property = rendition.slice(0, split); + value = rendition.slice(split+1); + + settings[property] = value; + } + }); + return settings; + } + + /** + * Get a CFI from a Range in the Section + * @param {range} _range + * @return {string} cfi an EpubCFI string + */ + cfiFromRange(_range) { + return new EpubCFI(_range, this.cfiBase).toString(); + } + + /** + * Get a CFI from an Element in the Section + * @param {element} el + * @return {string} cfi an EpubCFI string + */ + cfiFromElement(el) { + return new EpubCFI(el, this.cfiBase).toString(); + } + + /** + * Unload the section document + */ + unload() { + this.document = undefined; + this.contents = undefined; + this.output = undefined; + } + + destroy() { + this.unload(); + this.hooks.serialize.clear(); + this.hooks.content.clear(); + + this.hooks = undefined; + this.idref = undefined; + this.linear = undefined; + this.properties = undefined; + this.index = undefined; + this.href = undefined; + this.url = undefined; + this.next = undefined; + this.prev = undefined; + + this.cfiBase = undefined; + } +} + +export default Section; diff --git a/lib/epub.js/src/spine.js b/lib/epub.js/src/spine.js new file mode 100644 index 0000000..519b67c --- /dev/null +++ b/lib/epub.js/src/spine.js @@ -0,0 +1,274 @@ +import EpubCFI from "./epubcfi"; +import Hook from "./utils/hook"; +import Section from "./section"; +import {replaceBase, replaceCanonical, replaceMeta} from "./utils/replacements"; + +/** + * A collection of Spine Items + */ +class Spine { + constructor() { + this.spineItems = []; + this.spineByHref = {}; + this.spineById = {}; + + this.hooks = {}; + this.hooks.serialize = new Hook(); + this.hooks.content = new Hook(); + + // Register replacements + this.hooks.content.register(replaceBase); + this.hooks.content.register(replaceCanonical); + this.hooks.content.register(replaceMeta); + + this.epubcfi = new EpubCFI(); + + this.loaded = false; + + this.items = undefined; + this.manifest = undefined; + this.spineNodeIndex = undefined; + this.baseUrl = undefined; + this.length = undefined; + } + + /** + * Unpack items from a opf into spine items + * @param {Packaging} _package + * @param {method} resolver URL resolver + * @param {method} canonical Resolve canonical url + */ + unpack(_package, resolver, canonical) { + + this.items = _package.spine; + this.manifest = _package.manifest; + this.spineNodeIndex = _package.spineNodeIndex; + this.baseUrl = _package.baseUrl || _package.basePath || ""; + this.length = this.items.length; + + this.items.forEach( (item, index) => { + var manifestItem = this.manifest[item.idref]; + var spineItem; + + item.index = index; + item.cfiBase = this.epubcfi.generateChapterComponent(this.spineNodeIndex, item.index, item.idref); + + if (item.href) { + item.url = resolver(item.href, true); + item.canonical = canonical(item.href); + } + + if(manifestItem) { + item.href = manifestItem.href; + item.url = resolver(item.href, true); + item.canonical = canonical(item.href); + + if(manifestItem.properties.length){ + item.properties.push.apply(item.properties, manifestItem.properties); + } + } + + if (item.linear === "yes") { + item.prev = function() { + let prevIndex = item.index; + while (prevIndex > 0) { + let prev = this.get(prevIndex-1); + if (prev && prev.linear) { + return prev; + } + prevIndex -= 1; + } + return; + }.bind(this); + item.next = function() { + let nextIndex = item.index; + while (nextIndex < this.spineItems.length-1) { + let next = this.get(nextIndex+1); + if (next && next.linear) { + return next; + } + nextIndex += 1; + } + return; + }.bind(this); + } else { + item.prev = function() { + return; + } + item.next = function() { + return; + } + } + + + spineItem = new Section(item, this.hooks); + + this.append(spineItem); + + + }); + + this.loaded = true; + } + + /** + * Get an item from the spine + * @param {string|number} [target] + * @return {Section} section + * @example spine.get(); + * @example spine.get(1); + * @example spine.get("chap1.html"); + * @example spine.get("#id1234"); + */ + get(target) { + var index = 0; + + if (typeof target === "undefined") { + while (index < this.spineItems.length) { + let next = this.spineItems[index]; + if (next && next.linear) { + break; + } + index += 1; + } + } else if(this.epubcfi.isCfiString(target)) { + let cfi = new EpubCFI(target); + index = cfi.spinePos; + } else if(typeof target === "number" || isNaN(target) === false){ + index = target; + } else if(typeof target === "string" && target.indexOf("#") === 0) { + index = this.spineById[target.substring(1)]; + } else if(typeof target === "string") { + // Remove fragments + target = target.split("#")[0]; + index = this.spineByHref[target] || this.spineByHref[encodeURI(target)]; + } + + return this.spineItems[index] || null; + } + + /** + * Append a Section to the Spine + * @private + * @param {Section} section + */ + append(section) { + var index = this.spineItems.length; + section.index = index; + + this.spineItems.push(section); + + // Encode and Decode href lookups + // see pr for details: https://github.com/futurepress/epub.js/pull/358 + this.spineByHref[decodeURI(section.href)] = index; + this.spineByHref[encodeURI(section.href)] = index; + this.spineByHref[section.href] = index; + + this.spineById[section.idref] = index; + + return index; + } + + /** + * Prepend a Section to the Spine + * @private + * @param {Section} section + */ + prepend(section) { + // var index = this.spineItems.unshift(section); + this.spineByHref[section.href] = 0; + this.spineById[section.idref] = 0; + + // Re-index + this.spineItems.forEach(function(item, index){ + item.index = index; + }); + + return 0; + } + + // insert(section, index) { + // + // }; + + /** + * Remove a Section from the Spine + * @private + * @param {Section} section + */ + remove(section) { + var index = this.spineItems.indexOf(section); + + if(index > -1) { + delete this.spineByHref[section.href]; + delete this.spineById[section.idref]; + + return this.spineItems.splice(index, 1); + } + } + + /** + * Loop over the Sections in the Spine + * @return {method} forEach + */ + each() { + return this.spineItems.forEach.apply(this.spineItems, arguments); + } + + /** + * Find the first Section in the Spine + * @return {Section} first section + */ + first() { + let index = 0; + + do { + let next = this.get(index); + + if (next && next.linear) { + return next; + } + index += 1; + } while (index < this.spineItems.length) ; + } + + /** + * Find the last Section in the Spine + * @return {Section} last section + */ + last() { + let index = this.spineItems.length-1; + + do { + let prev = this.get(index); + if (prev && prev.linear) { + return prev; + } + index -= 1; + } while (index >= 0); + } + + destroy() { + this.each((section) => section.destroy()); + + this.spineItems = undefined + this.spineByHref = undefined + this.spineById = undefined + + this.hooks.serialize.clear(); + this.hooks.content.clear(); + this.hooks = undefined; + + this.epubcfi = undefined; + + this.loaded = false; + + this.items = undefined; + this.manifest = undefined; + this.spineNodeIndex = undefined; + this.baseUrl = undefined; + this.length = undefined; + } +} + +export default Spine; diff --git a/lib/epub.js/src/store.js b/lib/epub.js/src/store.js new file mode 100644 index 0000000..0d12103 --- /dev/null +++ b/lib/epub.js/src/store.js @@ -0,0 +1,384 @@ +import {defer, isXml, parse} from "./utils/core"; +import httpRequest from "./utils/request"; +import mime from "./utils/mime"; +import Path from "./utils/path"; +import EventEmitter from "event-emitter"; +import localforage from "localforage"; + +/** + * Handles saving and requesting files from local storage + * @class + * @param {string} name This should be the name of the application for modals + * @param {function} [requester] + * @param {function} [resolver] + */ +class Store { + + constructor(name, requester, resolver) { + this.urlCache = {}; + + this.storage = undefined; + + this.name = name; + this.requester = requester || httpRequest; + this.resolver = resolver; + + this.online = true; + + this.checkRequirements(); + + this.addListeners(); + } + + /** + * Checks to see if localForage exists in global namspace, + * Requires localForage if it isn't there + * @private + */ + checkRequirements(){ + try { + let store; + if (typeof localforage === "undefined") { + store = localforage; + } + this.storage = store.createInstance({ + name: this.name + }); + } catch (e) { + throw new Error("localForage lib not loaded"); + } + } + + /** + * Add online and offline event listeners + * @private + */ + addListeners() { + this._status = this.status.bind(this); + window.addEventListener('online', this._status); + window.addEventListener('offline', this._status); + } + + /** + * Remove online and offline event listeners + * @private + */ + removeListeners() { + window.removeEventListener('online', this._status); + window.removeEventListener('offline', this._status); + this._status = undefined; + } + + /** + * Update the online / offline status + * @private + */ + status(event) { + let online = navigator.onLine; + this.online = online; + if (online) { + this.emit("online", this); + } else { + this.emit("offline", this); + } + } + + /** + * Add all of a book resources to the store + * @param {Resources} resources book resources + * @param {boolean} [force] force resaving resources + * @return {Promise<object>} store objects + */ + add(resources, force) { + let mapped = resources.resources.map((item) => { + let { href } = item; + let url = this.resolver(href); + let encodedUrl = window.encodeURIComponent(url); + + return this.storage.getItem(encodedUrl).then((item) => { + if (!item || force) { + return this.requester(url, "binary") + .then((data) => { + return this.storage.setItem(encodedUrl, data); + }); + } else { + return item; + } + }); + + }); + return Promise.all(mapped); + } + + /** + * Put binary data from a url to storage + * @param {string} url a url to request from storage + * @param {boolean} [withCredentials] + * @param {object} [headers] + * @return {Promise<Blob>} + */ + put(url, withCredentials, headers) { + let encodedUrl = window.encodeURIComponent(url); + + return this.storage.getItem(encodedUrl).then((result) => { + if (!result) { + return this.requester(url, "binary", withCredentials, headers).then((data) => { + return this.storage.setItem(encodedUrl, data); + }); + } + return result; + }); + } + + /** + * Request a url + * @param {string} url a url to request from storage + * @param {string} [type] specify the type of the returned result + * @param {boolean} [withCredentials] + * @param {object} [headers] + * @return {Promise<Blob | string | JSON | Document | XMLDocument>} + */ + request(url, type, withCredentials, headers){ + if (this.online) { + // From network + return this.requester(url, type, withCredentials, headers).then((data) => { + // save to store if not present + this.put(url); + return data; + }) + } else { + // From store + return this.retrieve(url, type); + } + + } + + /** + * Request a url from storage + * @param {string} url a url to request from storage + * @param {string} [type] specify the type of the returned result + * @return {Promise<Blob | string | JSON | Document | XMLDocument>} + */ + retrieve(url, type) { + var deferred = new defer(); + var response; + var path = new Path(url); + + // If type isn't set, determine it from the file extension + if(!type) { + type = path.extension; + } + + if(type == "blob"){ + response = this.getBlob(url); + } else { + response = this.getText(url); + } + + + return response.then((r) => { + var deferred = new defer(); + var result; + if (r) { + result = this.handleResponse(r, type); + deferred.resolve(result); + } else { + deferred.reject({ + message : "File not found in storage: " + url, + stack : new Error().stack + }); + } + return deferred.promise; + }); + } + + /** + * Handle the response from request + * @private + * @param {any} response + * @param {string} [type] + * @return {any} the parsed result + */ + handleResponse(response, type){ + var r; + + if(type == "json") { + r = JSON.parse(response); + } + else + if(isXml(type)) { + r = parse(response, "text/xml"); + } + else + if(type == "xhtml") { + r = parse(response, "application/xhtml+xml"); + } + else + if(type == "html" || type == "htm") { + r = parse(response, "text/html"); + } else { + r = response; + } + + return r; + } + + /** + * Get a Blob from Storage by Url + * @param {string} url + * @param {string} [mimeType] + * @return {Blob} + */ + getBlob(url, mimeType){ + let encodedUrl = window.encodeURIComponent(url); + + return this.storage.getItem(encodedUrl).then(function(uint8array) { + if(!uint8array) return; + + mimeType = mimeType || mime.lookup(url); + + return new Blob([uint8array], {type : mimeType}); + }); + + } + + /** + * Get Text from Storage by Url + * @param {string} url + * @param {string} [mimeType] + * @return {string} + */ + getText(url, mimeType){ + let encodedUrl = window.encodeURIComponent(url); + + mimeType = mimeType || mime.lookup(url); + + return this.storage.getItem(encodedUrl).then(function(uint8array) { + var deferred = new defer(); + var reader = new FileReader(); + var blob; + + if(!uint8array) return; + + blob = new Blob([uint8array], {type : mimeType}); + + reader.addEventListener("loadend", () => { + deferred.resolve(reader.result); + }); + + reader.readAsText(blob, mimeType); + + return deferred.promise; + }); + } + + /** + * Get a base64 encoded result from Storage by Url + * @param {string} url + * @param {string} [mimeType] + * @return {string} base64 encoded + */ + getBase64(url, mimeType){ + let encodedUrl = window.encodeURIComponent(url); + + mimeType = mimeType || mime.lookup(url); + + return this.storage.getItem(encodedUrl).then((uint8array) => { + var deferred = new defer(); + var reader = new FileReader(); + var blob; + + if(!uint8array) return; + + blob = new Blob([uint8array], {type : mimeType}); + + reader.addEventListener("loadend", () => { + deferred.resolve(reader.result); + }); + reader.readAsDataURL(blob, mimeType); + + return deferred.promise; + }); + } + + /** + * Create a Url from a stored item + * @param {string} url + * @param {object} [options.base64] use base64 encoding or blob url + * @return {Promise} url promise with Url string + */ + createUrl(url, options){ + var deferred = new defer(); + var _URL = window.URL || window.webkitURL || window.mozURL; + var tempUrl; + var response; + var useBase64 = options && options.base64; + + if(url in this.urlCache) { + deferred.resolve(this.urlCache[url]); + return deferred.promise; + } + + if (useBase64) { + response = this.getBase64(url); + + if (response) { + response.then(function(tempUrl) { + + this.urlCache[url] = tempUrl; + deferred.resolve(tempUrl); + + }.bind(this)); + + } + + } else { + + response = this.getBlob(url); + + if (response) { + response.then(function(blob) { + + tempUrl = _URL.createObjectURL(blob); + this.urlCache[url] = tempUrl; + deferred.resolve(tempUrl); + + }.bind(this)); + + } + } + + + if (!response) { + deferred.reject({ + message : "File not found in storage: " + url, + stack : new Error().stack + }); + } + + return deferred.promise; + } + + /** + * Revoke Temp Url for a achive item + * @param {string} url url of the item in the store + */ + revokeUrl(url){ + var _URL = window.URL || window.webkitURL || window.mozURL; + var fromCache = this.urlCache[url]; + if(fromCache) _URL.revokeObjectURL(fromCache); + } + + destroy() { + var _URL = window.URL || window.webkitURL || window.mozURL; + for (let fromCache in this.urlCache) { + _URL.revokeObjectURL(fromCache); + } + this.urlCache = {}; + this.removeListeners(); + } +} + +EventEmitter(Store.prototype); + +export default Store; diff --git a/lib/epub.js/src/themes.js b/lib/epub.js/src/themes.js new file mode 100644 index 0000000..6e37f1d --- /dev/null +++ b/lib/epub.js/src/themes.js @@ -0,0 +1,268 @@ +import Url from "./utils/url"; + +/** + * Themes to apply to displayed content + * @class + * @param {Rendition} rendition + */ +class Themes { + constructor(rendition) { + this.rendition = rendition; + this._themes = { + "default" : { + "rules" : {}, + "url" : "", + "serialized" : "" + } + }; + this._overrides = {}; + this._current = "default"; + this._injected = []; + this.rendition.hooks.content.register(this.inject.bind(this)); + this.rendition.hooks.content.register(this.overrides.bind(this)); + + } + + /** + * Add themes to be used by a rendition + * @param {object | Array<object> | string} + * @example themes.register("light", "http://example.com/light.css") + * @example themes.register("light", { "body": { "color": "purple"}}) + * @example themes.register({ "light" : {...}, "dark" : {...}}) + */ + register () { + if (arguments.length === 0) { + return; + } + if (arguments.length === 1 && typeof(arguments[0]) === "object") { + return this.registerThemes(arguments[0]); + } + if (arguments.length === 1 && typeof(arguments[0]) === "string") { + return this.default(arguments[0]); + } + if (arguments.length === 2 && typeof(arguments[1]) === "string") { + return this.registerUrl(arguments[0], arguments[1]); + } + if (arguments.length === 2 && typeof(arguments[1]) === "object") { + return this.registerRules(arguments[0], arguments[1]); + } + } + + /** + * Add a default theme to be used by a rendition + * @param {object | string} theme + * @example themes.register("http://example.com/default.css") + * @example themes.register({ "body": { "color": "purple"}}) + */ + default (theme) { + if (!theme) { + return; + } + if (typeof(theme) === "string") { + return this.registerUrl("default", theme); + } + if (typeof(theme) === "object") { + return this.registerRules("default", theme); + } + } + + /** + * Register themes object + * @param {object} themes + */ + registerThemes (themes) { + for (var theme in themes) { + if (themes.hasOwnProperty(theme)) { + if (typeof(themes[theme]) === "string") { + this.registerUrl(theme, themes[theme]); + } else { + this.registerRules(theme, themes[theme]); + } + } + } + } + + /** + * Register a theme by passing its css as string + * @param {string} name + * @param {string} css + */ + registerCss (name, css) { + this._themes[name] = { "serialized" : css }; + if (this._injected[name] || name == 'default') { + this.update(name); + } + } + + /** + * Register a url + * @param {string} name + * @param {string} input + */ + registerUrl (name, input) { + var url = new Url(input); + this._themes[name] = { "url": url.toString() }; + if (this._injected[name] || name == 'default') { + this.update(name); + } + } + + /** + * Register rule + * @param {string} name + * @param {object} rules + */ + registerRules (name, rules) { + this._themes[name] = { "rules": rules }; + // TODO: serialize css rules + if (this._injected[name] || name == 'default') { + this.update(name); + } + } + + /** + * Select a theme + * @param {string} name + */ + select (name) { + var prev = this._current; + var contents; + + this._current = name; + this.update(name); + + contents = this.rendition.getContents(); + contents.forEach( (content) => { + content.removeClass(prev); + content.addClass(name); + }); + } + + /** + * Update a theme + * @param {string} name + */ + update (name) { + var contents = this.rendition.getContents(); + contents.forEach( (content) => { + this.add(name, content); + }); + } + + /** + * Inject all themes into contents + * @param {Contents} contents + */ + inject (contents) { + var links = []; + var themes = this._themes; + var theme; + + for (var name in themes) { + if (themes.hasOwnProperty(name) && (name === this._current || name === "default")) { + theme = themes[name]; + if((theme.rules && Object.keys(theme.rules).length > 0) || (theme.url && links.indexOf(theme.url) === -1)) { + this.add(name, contents); + } + this._injected.push(name); + } + } + + if(this._current != "default") { + contents.addClass(this._current); + } + } + + /** + * Add Theme to contents + * @param {string} name + * @param {Contents} contents + */ + add (name, contents) { + var theme = this._themes[name]; + + if (!theme || !contents) { + return; + } + + if (theme.url) { + contents.addStylesheet(theme.url); + } else if (theme.serialized) { + contents.addStylesheetCss(theme.serialized, name); + theme.injected = true; + } else if (theme.rules) { + contents.addStylesheetRules(theme.rules, name); + theme.injected = true; + } + } + + /** + * Add override + * @param {string} name + * @param {string} value + * @param {boolean} priority + */ + override (name, value, priority) { + var contents = this.rendition.getContents(); + + this._overrides[name] = { + value: value, + priority: priority === true + }; + + contents.forEach( (content) => { + content.css(name, this._overrides[name].value, this._overrides[name].priority); + }); + } + + removeOverride (name) { + var contents = this.rendition.getContents(); + + delete this._overrides[name]; + + contents.forEach( (content) => { + content.css(name); + }); + } + + /** + * Add all overrides + * @param {Content} content + */ + overrides (contents) { + var overrides = this._overrides; + + for (var rule in overrides) { + if (overrides.hasOwnProperty(rule)) { + contents.css(rule, overrides[rule].value, overrides[rule].priority); + } + } + } + + /** + * Adjust the font size of a rendition + * @param {number} size + */ + fontSize (size) { + this.override("font-size", size); + } + + /** + * Adjust the font-family of a rendition + * @param {string} f + */ + font (f) { + this.override("font-family", f, true); + } + + destroy() { + this.rendition = undefined; + this._themes = undefined; + this._overrides = undefined; + this._current = undefined; + this._injected = undefined; + } + +} + +export default Themes; diff --git a/lib/epub.js/src/utils/constants.js b/lib/epub.js/src/utils/constants.js new file mode 100644 index 0000000..ac0a268 --- /dev/null +++ b/lib/epub.js/src/utils/constants.js @@ -0,0 +1,62 @@ +export const EPUBJS_VERSION = "0.3"; + +// Dom events to listen for +export const DOM_EVENTS = ["keydown", "keyup", "keypressed", "mouseup", "mousedown", "mousemove", "click", "touchend", "touchstart", "touchmove"]; + +export const EVENTS = { + BOOK : { + OPEN_FAILED : "openFailed" + }, + CONTENTS : { + EXPAND : "expand", + RESIZE : "resize", + SELECTED : "selected", + SELECTED_RANGE : "selectedRange", + LINK_CLICKED : "linkClicked" + }, + LOCATIONS : { + CHANGED : "changed" + }, + MANAGERS : { + RESIZE : "resize", + RESIZED : "resized", + ORIENTATION_CHANGE : "orientationchange", + ADDED : "added", + SCROLL : "scroll", + SCROLLED : "scrolled", + REMOVED : "removed", + }, + VIEWS : { + AXIS: "axis", + WRITING_MODE: "writingMode", + LOAD_ERROR : "loaderror", + RENDERED : "rendered", + RESIZED : "resized", + DISPLAYED : "displayed", + SHOWN : "shown", + HIDDEN : "hidden", + MARK_CLICKED : "markClicked" + }, + RENDITION : { + STARTED : "started", + ATTACHED : "attached", + DISPLAYED : "displayed", + DISPLAY_ERROR : "displayerror", + RENDERED : "rendered", + REMOVED : "removed", + RESIZED : "resized", + ORIENTATION_CHANGE : "orientationchange", + LOCATION_CHANGED : "locationChanged", + RELOCATED : "relocated", + MARK_CLICKED : "markClicked", + SELECTED : "selected", + LAYOUT: "layout" + }, + LAYOUT : { + UPDATED : "updated" + }, + ANNOTATION : { + ATTACH : "attach", + DETACH : "detach" + } +} diff --git a/lib/epub.js/src/utils/core.js b/lib/epub.js/src/utils/core.js new file mode 100644 index 0000000..5c83944 --- /dev/null +++ b/lib/epub.js/src/utils/core.js @@ -0,0 +1,876 @@ +/** + * Core Utilities and Helpers + * @module Core +*/ +import { DOMParser as XMLDOMParser } from "xmldom"; + +/** + * Vendor prefixed requestAnimationFrame + * @returns {function} requestAnimationFrame + * @memberof Core + */ +export const requestAnimationFrame = (typeof window != "undefined") ? (window.requestAnimationFrame || window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame || window.msRequestAnimationFrame) : false; +const ELEMENT_NODE = 1; +const TEXT_NODE = 3; +const COMMENT_NODE = 8; +const DOCUMENT_NODE = 9; +const _URL = typeof URL != "undefined" ? URL : (typeof window != "undefined" ? (window.URL || window.webkitURL || window.mozURL) : undefined); + +/** + * Generates a UUID + * based on: http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript + * @returns {string} uuid + * @memberof Core + */ +export function uuid() { + var d = new Date().getTime(); + var uuid = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function(c) { + var r = (d + Math.random()*16)%16 | 0; + d = Math.floor(d/16); + return (c=="x" ? r : (r&0x7|0x8)).toString(16); + }); + return uuid; +} + +/** + * Gets the height of a document + * @returns {number} height + * @memberof Core + */ +export function documentHeight() { + return Math.max( + document.documentElement.clientHeight, + document.body.scrollHeight, + document.documentElement.scrollHeight, + document.body.offsetHeight, + document.documentElement.offsetHeight + ); +} + +/** + * Checks if a node is an element + * @param {object} obj + * @returns {boolean} + * @memberof Core + */ +export function isElement(obj) { + return !!(obj && obj.nodeType == 1); +} + +/** + * @param {any} n + * @returns {boolean} + * @memberof Core + */ +export function isNumber(n) { + return !isNaN(parseFloat(n)) && isFinite(n); +} + +/** + * @param {any} n + * @returns {boolean} + * @memberof Core + */ +export function isFloat(n) { + let f = parseFloat(n); + + if (isNumber(n) === false) { + return false; + } + + if (typeof n === "string" && n.indexOf(".") > -1) { + return true; + } + + return Math.floor(f) !== f; +} + +/** + * Get a prefixed css property + * @param {string} unprefixed + * @returns {string} + * @memberof Core + */ +export function prefixed(unprefixed) { + var vendors = ["Webkit", "webkit", "Moz", "O", "ms" ]; + var prefixes = ["-webkit-", "-webkit-", "-moz-", "-o-", "-ms-"]; + var lower = unprefixed.toLowerCase(); + var length = vendors.length; + + if (typeof(document) === "undefined" || typeof(document.body.style[lower]) != "undefined") { + return unprefixed; + } + + for (var i = 0; i < length; i++) { + if (typeof(document.body.style[prefixes[i] + lower]) != "undefined") { + return prefixes[i] + lower; + } + } + + return unprefixed; +} + +/** + * Apply defaults to an object + * @param {object} obj + * @returns {object} + * @memberof Core + */ +export function defaults(obj) { + for (var i = 1, length = arguments.length; i < length; i++) { + var source = arguments[i]; + for (var prop in source) { + if (obj[prop] === void 0) obj[prop] = source[prop]; + } + } + return obj; +} + +/** + * Extend properties of an object + * @param {object} target + * @returns {object} + * @memberof Core + */ +export function extend(target) { + var sources = [].slice.call(arguments, 1); + sources.forEach(function (source) { + if(!source) return; + Object.getOwnPropertyNames(source).forEach(function(propName) { + Object.defineProperty(target, propName, Object.getOwnPropertyDescriptor(source, propName)); + }); + }); + return target; +} + +/** + * Fast quicksort insert for sorted array -- based on: + * http://stackoverflow.com/questions/1344500/efficient-way-to-insert-a-number-into-a-sorted-array-of-numbers + * @param {any} item + * @param {array} array + * @param {function} [compareFunction] + * @returns {number} location (in array) + * @memberof Core + */ +export function insert(item, array, compareFunction) { + var location = locationOf(item, array, compareFunction); + array.splice(location, 0, item); + + return location; +} + +/** + * Finds where something would fit into a sorted array + * @param {any} item + * @param {array} array + * @param {function} [compareFunction] + * @param {function} [_start] + * @param {function} [_end] + * @returns {number} location (in array) + * @memberof Core + */ +export function locationOf(item, array, compareFunction, _start, _end) { + var start = _start || 0; + var end = _end || array.length; + var pivot = parseInt(start + (end - start) / 2); + var compared; + if(!compareFunction){ + compareFunction = function(a, b) { + if(a > b) return 1; + if(a < b) return -1; + if(a == b) return 0; + }; + } + if(end-start <= 0) { + return pivot; + } + + compared = compareFunction(array[pivot], item); + if(end-start === 1) { + return compared >= 0 ? pivot : pivot + 1; + } + if(compared === 0) { + return pivot; + } + if(compared === -1) { + return locationOf(item, array, compareFunction, pivot, end); + } else{ + return locationOf(item, array, compareFunction, start, pivot); + } +} + +/** + * Finds index of something in a sorted array + * Returns -1 if not found + * @param {any} item + * @param {array} array + * @param {function} [compareFunction] + * @param {function} [_start] + * @param {function} [_end] + * @returns {number} index (in array) or -1 + * @memberof Core + */ +export function indexOfSorted(item, array, compareFunction, _start, _end) { + var start = _start || 0; + var end = _end || array.length; + var pivot = parseInt(start + (end - start) / 2); + var compared; + if(!compareFunction){ + compareFunction = function(a, b) { + if(a > b) return 1; + if(a < b) return -1; + if(a == b) return 0; + }; + } + if(end-start <= 0) { + return -1; // Not found + } + + compared = compareFunction(array[pivot], item); + if(end-start === 1) { + return compared === 0 ? pivot : -1; + } + if(compared === 0) { + return pivot; // Found + } + if(compared === -1) { + return indexOfSorted(item, array, compareFunction, pivot, end); + } else{ + return indexOfSorted(item, array, compareFunction, start, pivot); + } +} +/** + * Find the bounds of an element + * taking padding and margin into account + * @param {element} el + * @returns {{ width: Number, height: Number}} + * @memberof Core + */ +export function bounds(el) { + + var style = window.getComputedStyle(el); + var widthProps = ["width", "paddingRight", "paddingLeft", "marginRight", "marginLeft", "borderRightWidth", "borderLeftWidth"]; + var heightProps = ["height", "paddingTop", "paddingBottom", "marginTop", "marginBottom", "borderTopWidth", "borderBottomWidth"]; + + var width = 0; + var height = 0; + + widthProps.forEach(function(prop){ + width += parseFloat(style[prop]) || 0; + }); + + heightProps.forEach(function(prop){ + height += parseFloat(style[prop]) || 0; + }); + + return { + height: height, + width: width + }; + +} + +/** + * Find the bounds of an element + * taking padding, margin and borders into account + * @param {element} el + * @returns {{ width: Number, height: Number}} + * @memberof Core + */ +export function borders(el) { + + var style = window.getComputedStyle(el); + var widthProps = ["paddingRight", "paddingLeft", "marginRight", "marginLeft", "borderRightWidth", "borderLeftWidth"]; + var heightProps = ["paddingTop", "paddingBottom", "marginTop", "marginBottom", "borderTopWidth", "borderBottomWidth"]; + + var width = 0; + var height = 0; + + widthProps.forEach(function(prop){ + width += parseFloat(style[prop]) || 0; + }); + + heightProps.forEach(function(prop){ + height += parseFloat(style[prop]) || 0; + }); + + return { + height: height, + width: width + }; + +} + +/** + * Find the bounds of any node + * allows for getting bounds of text nodes by wrapping them in a range + * @param {node} node + * @returns {BoundingClientRect} + * @memberof Core + */ +export function nodeBounds(node) { + let elPos; + let doc = node.ownerDocument; + if(node.nodeType == Node.TEXT_NODE){ + let elRange = doc.createRange(); + elRange.selectNodeContents(node); + elPos = elRange.getBoundingClientRect(); + } else { + elPos = node.getBoundingClientRect(); + } + return elPos; +} + +/** + * Find the equivelent of getBoundingClientRect of a browser window + * @returns {{ width: Number, height: Number, top: Number, left: Number, right: Number, bottom: Number }} + * @memberof Core + */ +export function windowBounds() { + + var width = window.innerWidth; + var height = window.innerHeight; + + return { + top: 0, + left: 0, + right: width, + bottom: height, + width: width, + height: height + }; + +} + +/** + * Gets the index of a node in its parent + * @param {Node} node + * @param {string} typeId + * @return {number} index + * @memberof Core + */ +export function indexOfNode(node, typeId) { + var parent = node.parentNode; + var children = parent.childNodes; + var sib; + var index = -1; + for (var i = 0; i < children.length; i++) { + sib = children[i]; + if (sib.nodeType === typeId) { + index++; + } + if (sib == node) break; + } + + return index; +} + +/** + * Gets the index of a text node in its parent + * @param {node} textNode + * @returns {number} index + * @memberof Core + */ +export function indexOfTextNode(textNode) { + return indexOfNode(textNode, TEXT_NODE); +} + +/** + * Gets the index of an element node in its parent + * @param {element} elementNode + * @returns {number} index + * @memberof Core + */ +export function indexOfElementNode(elementNode) { + return indexOfNode(elementNode, ELEMENT_NODE); +} + +/** + * Check if extension is xml + * @param {string} ext + * @returns {boolean} + * @memberof Core + */ +export function isXml(ext) { + return ["xml", "opf", "ncx"].indexOf(ext) > -1; +} + +/** + * Create a new blob + * @param {any} content + * @param {string} mime + * @returns {Blob} + * @memberof Core + */ +export function createBlob(content, mime){ + return new Blob([content], {type : mime }); +} + +/** + * Create a new blob url + * @param {any} content + * @param {string} mime + * @returns {string} url + * @memberof Core + */ +export function createBlobUrl(content, mime){ + var tempUrl; + var blob = createBlob(content, mime); + + tempUrl = _URL.createObjectURL(blob); + + return tempUrl; +} + +/** + * Remove a blob url + * @param {string} url + * @memberof Core + */ +export function revokeBlobUrl(url){ + return _URL.revokeObjectURL(url); +} + +/** + * Create a new base64 encoded url + * @param {any} content + * @param {string} mime + * @returns {string} url + * @memberof Core + */ +export function createBase64Url(content, mime){ + var data; + var datauri; + + if (typeof(content) !== "string") { + // Only handles strings + return; + } + + data = btoa(encodeURIComponent(content)); + + datauri = "data:" + mime + ";base64," + data; + + return datauri; +} + +/** + * Get type of an object + * @param {object} obj + * @returns {string} type + * @memberof Core + */ +export function type(obj){ + return Object.prototype.toString.call(obj).slice(8, -1); +} + +/** + * Parse xml (or html) markup + * @param {string} markup + * @param {string} mime + * @param {boolean} forceXMLDom force using xmlDom to parse instead of native parser + * @returns {document} document + * @memberof Core + */ +export function parse(markup, mime, forceXMLDom) { + var doc; + var Parser; + + if (typeof DOMParser === "undefined" || forceXMLDom) { + Parser = XMLDOMParser; + } else { + Parser = DOMParser; + } + + // Remove byte order mark before parsing + // https://www.w3.org/International/questions/qa-byte-order-mark + if(markup.charCodeAt(0) === 0xFEFF) { + markup = markup.slice(1); + } + + doc = new Parser().parseFromString(markup, mime); + + return doc; +} + +/** + * querySelector polyfill + * @param {element} el + * @param {string} sel selector string + * @returns {element} element + * @memberof Core + */ +export function qs(el, sel) { + var elements; + if (!el) { + throw new Error("No Element Provided"); + } + + if (typeof el.querySelector != "undefined") { + return el.querySelector(sel); + } else { + elements = el.getElementsByTagName(sel); + if (elements.length) { + return elements[0]; + } + } +} + +/** + * querySelectorAll polyfill + * @param {element} el + * @param {string} sel selector string + * @returns {element[]} elements + * @memberof Core + */ +export function qsa(el, sel) { + + if (typeof el.querySelector != "undefined") { + return el.querySelectorAll(sel); + } else { + return el.getElementsByTagName(sel); + } +} + +/** + * querySelector by property + * @param {element} el + * @param {string} sel selector string + * @param {object[]} props + * @returns {element[]} elements + * @memberof Core + */ +export function qsp(el, sel, props) { + var q, filtered; + if (typeof el.querySelector != "undefined") { + sel += "["; + for (var prop in props) { + sel += prop + "~='" + props[prop] + "'"; + } + sel += "]"; + return el.querySelector(sel); + } else { + q = el.getElementsByTagName(sel); + filtered = Array.prototype.slice.call(q, 0).filter(function(el) { + for (var prop in props) { + if(el.getAttribute(prop) === props[prop]){ + return true; + } + } + return false; + }); + + if (filtered) { + return filtered[0]; + } + } +} + +/** + * Sprint through all text nodes in a document + * @memberof Core + * @param {element} root element to start with + * @param {function} func function to run on each element + */ +export function sprint(root, func) { + var doc = root.ownerDocument || root; + if (typeof(doc.createTreeWalker) !== "undefined") { + treeWalker(root, func, NodeFilter.SHOW_TEXT); + } else { + walk(root, function(node) { + if (node && node.nodeType === 3) { // Node.TEXT_NODE + func(node); + } + }, true); + } +} + +/** + * Create a treeWalker + * @memberof Core + * @param {element} root element to start with + * @param {function} func function to run on each element + * @param {function | object} filter funtion or object to filter with + */ +export function treeWalker(root, func, filter) { + var treeWalker = document.createTreeWalker(root, filter, null, false); + let node; + while ((node = treeWalker.nextNode())) { + func(node); + } +} + +/** + * @memberof Core + * @param {node} node + * @param {callback} return false for continue,true for break inside callback + */ +export function walk(node,callback){ + if(callback(node)){ + return true; + } + node = node.firstChild; + if(node){ + do{ + let walked = walk(node,callback); + if(walked){ + return true; + } + node = node.nextSibling; + } while(node); + } +} + +/** + * Convert a blob to a base64 encoded string + * @param {Blog} blob + * @returns {string} + * @memberof Core + */ +export function blob2base64(blob) { + return new Promise(function(resolve, reject) { + var reader = new FileReader(); + reader.readAsDataURL(blob); + reader.onloadend = function() { + resolve(reader.result); + }; + }); +} + + +/** + * Creates a new pending promise and provides methods to resolve or reject it. + * From: https://developer.mozilla.org/en-US/docs/Mozilla/JavaScript_code_modules/Promise.jsm/Deferred#backwards_forwards_compatible + * @memberof Core + */ +export function defer() { + /* A method to resolve the associated Promise with the value passed. + * If the promise is already settled it does nothing. + * + * @param {anything} value : This value is used to resolve the promise + * If the value is a Promise then the associated promise assumes the state + * of Promise passed as value. + */ + this.resolve = null; + + /* A method to reject the assocaited Promise with the value passed. + * If the promise is already settled it does nothing. + * + * @param {anything} reason: The reason for the rejection of the Promise. + * Generally its an Error object. If however a Promise is passed, then the Promise + * itself will be the reason for rejection no matter the state of the Promise. + */ + this.reject = null; + + this.id = uuid(); + + /* A newly created Pomise object. + * Initially in pending state. + */ + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + Object.freeze(this); +} + +/** + * querySelector with filter by epub type + * @param {element} html + * @param {string} element element type to find + * @param {string} type epub type to find + * @returns {element[]} elements + * @memberof Core + */ +export function querySelectorByType(html, element, type){ + var query; + if (typeof html.querySelector != "undefined") { + query = html.querySelector(`${element}[*|type="${type}"]`); + } + // Handle IE not supporting namespaced epub:type in querySelector + if(!query || query.length === 0) { + query = qsa(html, element); + for (var i = 0; i < query.length; i++) { + if(query[i].getAttributeNS("http://www.idpf.org/2007/ops", "type") === type || + query[i].getAttribute("epub:type") === type) { + return query[i]; + } + } + } else { + return query; + } +} + +/** + * Find direct decendents of an element + * @param {element} el + * @returns {element[]} children + * @memberof Core + */ +export function findChildren(el) { + var result = []; + var childNodes = el.childNodes; + for (var i = 0; i < childNodes.length; i++) { + let node = childNodes[i]; + if (node.nodeType === 1) { + result.push(node); + } + } + return result; +} + +/** + * Find all parents (ancestors) of an element + * @param {element} node + * @returns {element[]} parents + * @memberof Core + */ +export function parents(node) { + var nodes = [node]; + for (; node; node = node.parentNode) { + nodes.unshift(node); + } + return nodes +} + +/** + * Find all direct decendents of a specific type + * @param {element} el + * @param {string} nodeName + * @param {boolean} [single] + * @returns {element[]} children + * @memberof Core + */ +export function filterChildren(el, nodeName, single) { + var result = []; + var childNodes = el.childNodes; + for (var i = 0; i < childNodes.length; i++) { + let node = childNodes[i]; + if (node.nodeType === 1 && node.nodeName.toLowerCase() === nodeName) { + if (single) { + return node; + } else { + result.push(node); + } + } + } + if (!single) { + return result; + } +} + +/** + * Filter all parents (ancestors) with tag name + * @param {element} node + * @param {string} tagname + * @returns {element[]} parents + * @memberof Core + */ +export function getParentByTagName(node, tagname) { + let parent; + if (node === null || tagname === '') return; + parent = node.parentNode; + while (parent.nodeType === 1) { + if (parent.tagName.toLowerCase() === tagname) { + return parent; + } + parent = parent.parentNode; + } +} + +/** + * Lightweight Polyfill for DOM Range + * @class + * @memberof Core + */ +export class RangeObject { + constructor() { + this.collapsed = false; + this.commonAncestorContainer = undefined; + this.endContainer = undefined; + this.endOffset = undefined; + this.startContainer = undefined; + this.startOffset = undefined; + } + + setStart(startNode, startOffset) { + this.startContainer = startNode; + this.startOffset = startOffset; + + if (!this.endContainer) { + this.collapse(true); + } else { + this.commonAncestorContainer = this._commonAncestorContainer(); + } + + this._checkCollapsed(); + } + + setEnd(endNode, endOffset) { + this.endContainer = endNode; + this.endOffset = endOffset; + + if (!this.startContainer) { + this.collapse(false); + } else { + this.collapsed = false; + this.commonAncestorContainer = this._commonAncestorContainer(); + } + + this._checkCollapsed(); + } + + collapse(toStart) { + this.collapsed = true; + if (toStart) { + this.endContainer = this.startContainer; + this.endOffset = this.startOffset; + this.commonAncestorContainer = this.startContainer.parentNode; + } else { + this.startContainer = this.endContainer; + this.startOffset = this.endOffset; + this.commonAncestorContainer = this.endOffset.parentNode; + } + } + + selectNode(referenceNode) { + let parent = referenceNode.parentNode; + let index = Array.prototype.indexOf.call(parent.childNodes, referenceNode); + this.setStart(parent, index); + this.setEnd(parent, index + 1); + } + + selectNodeContents(referenceNode) { + let end = referenceNode.childNodes[referenceNode.childNodes - 1]; + let endIndex = (referenceNode.nodeType === 3) ? + referenceNode.textContent.length : parent.childNodes.length; + this.setStart(referenceNode, 0); + this.setEnd(referenceNode, endIndex); + } + + _commonAncestorContainer(startContainer, endContainer) { + var startParents = parents(startContainer || this.startContainer); + var endParents = parents(endContainer || this.endContainer); + + if (startParents[0] != endParents[0]) return undefined; + + for (var i = 0; i < startParents.length; i++) { + if (startParents[i] != endParents[i]) { + return startParents[i - 1]; + } + } + } + + _checkCollapsed() { + if (this.startContainer === this.endContainer && + this.startOffset === this.endOffset) { + this.collapsed = true; + } else { + this.collapsed = false; + } + } + + toString() { + // TODO: implement walking between start and end to find text + } +} diff --git a/lib/epub.js/src/utils/hook.js b/lib/epub.js/src/utils/hook.js new file mode 100644 index 0000000..ea1b901 --- /dev/null +++ b/lib/epub.js/src/utils/hook.js @@ -0,0 +1,82 @@ +/** + * Hooks allow for injecting functions that must all complete in order before finishing + * They will execute in parallel but all must finish before continuing + * Functions may return a promise if they are asycn. + * @param {any} context scope of this + * @example this.content = new EPUBJS.Hook(this); + */ +class Hook { + constructor(context){ + this.context = context || this; + this.hooks = []; + } + + /** + * Adds a function to be run before a hook completes + * @example this.content.register(function(){...}); + */ + register(){ + for(var i = 0; i < arguments.length; ++i) { + if (typeof arguments[i] === "function") { + this.hooks.push(arguments[i]); + } else { + // unpack array + for(var j = 0; j < arguments[i].length; ++j) { + this.hooks.push(arguments[i][j]); + } + } + } + } + + /** + * Removes a function + * @example this.content.deregister(function(){...}); + */ + deregister(func){ + let hook; + for (let i = 0; i < this.hooks.length; i++) { + hook = this.hooks[i]; + if (hook === func) { + this.hooks.splice(i, 1); + break; + } + } + } + + /** + * Triggers a hook to run all functions + * @example this.content.trigger(args).then(function(){...}); + */ + trigger(){ + var args = arguments; + var context = this.context; + var promises = []; + + this.hooks.forEach(function(task) { + try { + var executing = task.apply(context, args); + } catch (err) { + console.log(err); + } + + if(executing && typeof executing["then"] === "function") { + // Task is a function that returns a promise + promises.push(executing); + } + // Otherwise Task resolves immediately, continue + }); + + + return Promise.all(promises); + } + + // Adds a function to be run before a hook completes + list(){ + return this.hooks; + } + + clear(){ + return this.hooks = []; + } +} +export default Hook; diff --git a/lib/epub.js/src/utils/mime.js b/lib/epub.js/src/utils/mime.js new file mode 100644 index 0000000..8f4cca3 --- /dev/null +++ b/lib/epub.js/src/utils/mime.js @@ -0,0 +1,169 @@ +/* + From Zip.js, by Gildas Lormeau +edited down + */ + +var table = { + "application" : { + "ecmascript" : [ "es", "ecma" ], + "javascript" : "js", + "ogg" : "ogx", + "pdf" : "pdf", + "postscript" : [ "ps", "ai", "eps", "epsi", "epsf", "eps2", "eps3" ], + "rdf+xml" : "rdf", + "smil" : [ "smi", "smil" ], + "xhtml+xml" : [ "xhtml", "xht" ], + "xml" : [ "xml", "xsl", "xsd", "opf", "ncx" ], + "zip" : "zip", + "x-httpd-eruby" : "rhtml", + "x-latex" : "latex", + "x-maker" : [ "frm", "maker", "frame", "fm", "fb", "book", "fbdoc" ], + "x-object" : "o", + "x-shockwave-flash" : [ "swf", "swfl" ], + "x-silverlight" : "scr", + "epub+zip" : "epub", + "font-tdpfr" : "pfr", + "inkml+xml" : [ "ink", "inkml" ], + "json" : "json", + "jsonml+json" : "jsonml", + "mathml+xml" : "mathml", + "metalink+xml" : "metalink", + "mp4" : "mp4s", + // "oebps-package+xml" : "opf", + "omdoc+xml" : "omdoc", + "oxps" : "oxps", + "vnd.amazon.ebook" : "azw", + "widget" : "wgt", + // "x-dtbncx+xml" : "ncx", + "x-dtbook+xml" : "dtb", + "x-dtbresource+xml" : "res", + "x-font-bdf" : "bdf", + "x-font-ghostscript" : "gsf", + "x-font-linux-psf" : "psf", + "x-font-otf" : "otf", + "x-font-pcf" : "pcf", + "x-font-snf" : "snf", + "x-font-ttf" : [ "ttf", "ttc" ], + "x-font-type1" : [ "pfa", "pfb", "pfm", "afm" ], + "x-font-woff" : "woff", + "x-mobipocket-ebook" : [ "prc", "mobi" ], + "x-mspublisher" : "pub", + "x-nzb" : "nzb", + "x-tgif" : "obj", + "xaml+xml" : "xaml", + "xml-dtd" : "dtd", + "xproc+xml" : "xpl", + "xslt+xml" : "xslt", + "internet-property-stream" : "acx", + "x-compress" : "z", + "x-compressed" : "tgz", + "x-gzip" : "gz", + }, + "audio" : { + "flac" : "flac", + "midi" : [ "mid", "midi", "kar", "rmi" ], + "mpeg" : [ "mpga", "mpega", "mp2", "mp3", "m4a", "mp2a", "m2a", "m3a" ], + "mpegurl" : "m3u", + "ogg" : [ "oga", "ogg", "spx" ], + "x-aiff" : [ "aif", "aiff", "aifc" ], + "x-ms-wma" : "wma", + "x-wav" : "wav", + "adpcm" : "adp", + "mp4" : "mp4a", + "webm" : "weba", + "x-aac" : "aac", + "x-caf" : "caf", + "x-matroska" : "mka", + "x-pn-realaudio-plugin" : "rmp", + "xm" : "xm", + "mid" : [ "mid", "rmi" ] + }, + "image" : { + "gif" : "gif", + "ief" : "ief", + "jpeg" : [ "jpeg", "jpg", "jpe" ], + "pcx" : "pcx", + "png" : "png", + "svg+xml" : [ "svg", "svgz" ], + "tiff" : [ "tiff", "tif" ], + "x-icon" : "ico", + "bmp" : "bmp", + "webp" : "webp", + "x-pict" : [ "pic", "pct" ], + "x-tga" : "tga", + "cis-cod" : "cod" + }, + "text" : { + "cache-manifest" : [ "manifest", "appcache" ], + "css" : "css", + "csv" : "csv", + "html" : [ "html", "htm", "shtml", "stm" ], + "mathml" : "mml", + "plain" : [ "txt", "text", "brf", "conf", "def", "list", "log", "in", "bas" ], + "richtext" : "rtx", + "tab-separated-values" : "tsv", + "x-bibtex" : "bib" + }, + "video" : { + "mpeg" : [ "mpeg", "mpg", "mpe", "m1v", "m2v", "mp2", "mpa", "mpv2" ], + "mp4" : [ "mp4", "mp4v", "mpg4" ], + "quicktime" : [ "qt", "mov" ], + "ogg" : "ogv", + "vnd.mpegurl" : [ "mxu", "m4u" ], + "x-flv" : "flv", + "x-la-asf" : [ "lsf", "lsx" ], + "x-mng" : "mng", + "x-ms-asf" : [ "asf", "asx", "asr" ], + "x-ms-wm" : "wm", + "x-ms-wmv" : "wmv", + "x-ms-wmx" : "wmx", + "x-ms-wvx" : "wvx", + "x-msvideo" : "avi", + "x-sgi-movie" : "movie", + "x-matroska" : [ "mpv", "mkv", "mk3d", "mks" ], + "3gpp2" : "3g2", + "h261" : "h261", + "h263" : "h263", + "h264" : "h264", + "jpeg" : "jpgv", + "jpm" : [ "jpm", "jpgm" ], + "mj2" : [ "mj2", "mjp2" ], + "vnd.ms-playready.media.pyv" : "pyv", + "vnd.uvvu.mp4" : [ "uvu", "uvvu" ], + "vnd.vivo" : "viv", + "webm" : "webm", + "x-f4v" : "f4v", + "x-m4v" : "m4v", + "x-ms-vob" : "vob", + "x-smv" : "smv" + } +}; + +var mimeTypes = (function() { + var type, subtype, val, index, mimeTypes = {}; + for (type in table) { + if (table.hasOwnProperty(type)) { + for (subtype in table[type]) { + if (table[type].hasOwnProperty(subtype)) { + val = table[type][subtype]; + if (typeof val == "string") { + mimeTypes[val] = type + "/" + subtype; + } else { + for (index = 0; index < val.length; index++) { + mimeTypes[val[index]] = type + "/" + subtype; + } + } + } + } + } + } + return mimeTypes; +})(); + +var defaultValue = "text/plain";//"application/octet-stream"; + +function lookup(filename) { + return filename && mimeTypes[filename.split(".").pop().toLowerCase()] || defaultValue; +}; + +export default { lookup }; diff --git a/lib/epub.js/src/utils/path.js b/lib/epub.js/src/utils/path.js new file mode 100644 index 0000000..6a060cb --- /dev/null +++ b/lib/epub.js/src/utils/path.js @@ -0,0 +1,102 @@ +import path from "path-webpack"; + +/** + * Creates a Path object for parsing and manipulation of a path strings + * + * Uses a polyfill for Nodejs path: https://nodejs.org/api/path.html + * @param {string} pathString a url string (relative or absolute) + * @class + */ +class Path { + constructor(pathString) { + var protocol; + var parsed; + + protocol = pathString.indexOf("://"); + if (protocol > -1) { + pathString = new URL(pathString).pathname; + } + + parsed = this.parse(pathString); + + this.path = pathString; + + if (this.isDirectory(pathString)) { + this.directory = pathString; + } else { + this.directory = parsed.dir + "/"; + } + + this.filename = parsed.base; + this.extension = parsed.ext.slice(1); + + } + + /** + * Parse the path: https://nodejs.org/api/path.html#path_path_parse_path + * @param {string} what + * @returns {object} + */ + parse (what) { + return path.parse(what); + } + + /** + * @param {string} what + * @returns {boolean} + */ + isAbsolute (what) { + return path.isAbsolute(what || this.path); + } + + /** + * Check if path ends with a directory + * @param {string} what + * @returns {boolean} + */ + isDirectory (what) { + return (what.charAt(what.length-1) === "/"); + } + + /** + * Resolve a path against the directory of the Path + * + * https://nodejs.org/api/path.html#path_path_resolve_paths + * @param {string} what + * @returns {string} resolved + */ + resolve (what) { + return path.resolve(this.directory, what); + } + + /** + * Resolve a path relative to the directory of the Path + * + * https://nodejs.org/api/path.html#path_path_relative_from_to + * @param {string} what + * @returns {string} relative + */ + relative (what) { + var isAbsolute = what && (what.indexOf("://") > -1); + + if (isAbsolute) { + return what; + } + + return path.relative(this.directory, what); + } + + splitPath(filename) { + return this.splitPathRe.exec(filename).slice(1); + } + + /** + * Return the path string + * @returns {string} path + */ + toString () { + return this.path; + } +} + +export default Path; diff --git a/lib/epub.js/src/utils/queue.js b/lib/epub.js/src/utils/queue.js new file mode 100644 index 0000000..1f8a18a --- /dev/null +++ b/lib/epub.js/src/utils/queue.js @@ -0,0 +1,246 @@ +import {defer, requestAnimationFrame} from "./core"; + +/** + * Queue for handling tasks one at a time + * @class + * @param {scope} context what this will resolve to in the tasks + */ +class Queue { + constructor(context){ + this._q = []; + this.context = context; + this.tick = requestAnimationFrame; + this.running = false; + this.paused = false; + } + + /** + * Add an item to the queue + * @return {Promise} + */ + enqueue() { + var deferred, promise; + var queued; + var task = [].shift.call(arguments); + var args = arguments; + + // Handle single args without context + // if(args && !Array.isArray(args)) { + // args = [args]; + // } + if(!task) { + throw new Error("No Task Provided"); + } + + if(typeof task === "function"){ + + deferred = new defer(); + promise = deferred.promise; + + queued = { + "task" : task, + "args" : args, + //"context" : context, + "deferred" : deferred, + "promise" : promise + }; + + } else { + // Task is a promise + queued = { + "promise" : task + }; + + } + + this._q.push(queued); + + // Wait to start queue flush + if (this.paused == false && !this.running) { + // setTimeout(this.flush.bind(this), 0); + // this.tick.call(window, this.run.bind(this)); + this.run(); + } + + return queued.promise; + } + + /** + * Run one item + * @return {Promise} + */ + dequeue(){ + var inwait, task, result; + + if(this._q.length && !this.paused) { + inwait = this._q.shift(); + task = inwait.task; + if(task){ + // console.log(task) + + result = task.apply(this.context, inwait.args); + + if(result && typeof result["then"] === "function") { + // Task is a function that returns a promise + return result.then(function(){ + inwait.deferred.resolve.apply(this.context, arguments); + }.bind(this), function() { + inwait.deferred.reject.apply(this.context, arguments); + }.bind(this)); + } else { + // Task resolves immediately + inwait.deferred.resolve.apply(this.context, result); + return inwait.promise; + } + + + + } else if(inwait.promise) { + // Task is a promise + return inwait.promise; + } + + } else { + inwait = new defer(); + inwait.deferred.resolve(); + return inwait.promise; + } + + } + + // Run All Immediately + dump(){ + while(this._q.length) { + this.dequeue(); + } + } + + /** + * Run all tasks sequentially, at convince + * @return {Promise} + */ + run(){ + + if(!this.running){ + this.running = true; + this.defered = new defer(); + } + + this.tick.call(window, () => { + + if(this._q.length) { + + this.dequeue() + .then(function(){ + this.run(); + }.bind(this)); + + } else { + this.defered.resolve(); + this.running = undefined; + } + + }); + + // Unpause + if(this.paused == true) { + this.paused = false; + } + + return this.defered.promise; + } + + /** + * Flush all, as quickly as possible + * @return {Promise} + */ + flush(){ + + if(this.running){ + return this.running; + } + + if(this._q.length) { + this.running = this.dequeue() + .then(function(){ + this.running = undefined; + return this.flush(); + }.bind(this)); + + return this.running; + } + + } + + /** + * Clear all items in wait + */ + clear(){ + this._q = []; + } + + /** + * Get the number of tasks in the queue + * @return {number} tasks + */ + length(){ + return this._q.length; + } + + /** + * Pause a running queue + */ + pause(){ + this.paused = true; + } + + /** + * End the queue + */ + stop(){ + this._q = []; + this.running = false; + this.paused = true; + } +} + + +/** + * Create a new task from a callback + * @class + * @private + * @param {function} task + * @param {array} args + * @param {scope} context + * @return {function} task + */ +class Task { + constructor(task, args, context){ + + return function(){ + var toApply = arguments || []; + + return new Promise( (resolve, reject) => { + var callback = function(value, err){ + if (!value && err) { + reject(err); + } else { + resolve(value); + } + }; + // Add the callback to the arguments list + toApply.push(callback); + + // Apply all arguments to the functions + task.apply(context || this, toApply); + + }); + + }; + + } +} + + +export default Queue; +export { Task }; diff --git a/lib/epub.js/src/utils/replacements.js b/lib/epub.js/src/utils/replacements.js new file mode 100644 index 0000000..a271088 --- /dev/null +++ b/lib/epub.js/src/utils/replacements.js @@ -0,0 +1,138 @@ +import { qs, qsa } from "./core"; +import Url from "./url"; +import Path from "./path"; + +export function replaceBase(doc, section){ + var base; + var head; + var url = section.url; + var absolute = (url.indexOf("://") > -1); + + if(!doc){ + return; + } + + head = qs(doc, "head"); + base = qs(head, "base"); + + if(!base) { + base = doc.createElement("base"); + head.insertBefore(base, head.firstChild); + } + + // Fix for Safari crashing if the url doesn't have an origin + if (!absolute && window && window.location) { + url = window.location.origin + url; + } + + base.setAttribute("href", url); +} + +export function replaceCanonical(doc, section){ + var head; + var link; + var url = section.canonical; + + if(!doc){ + return; + } + + head = qs(doc, "head"); + link = qs(head, "link[rel='canonical']"); + + if (link) { + link.setAttribute("href", url); + } else { + link = doc.createElement("link"); + link.setAttribute("rel", "canonical"); + link.setAttribute("href", url); + head.appendChild(link); + } +} + +export function replaceMeta(doc, section){ + var head; + var meta; + var id = section.idref; + if(!doc){ + return; + } + + head = qs(doc, "head"); + meta = qs(head, "link[property='dc.identifier']"); + + if (meta) { + meta.setAttribute("content", id); + } else { + meta = doc.createElement("meta"); + meta.setAttribute("name", "dc.identifier"); + meta.setAttribute("content", id); + head.appendChild(meta); + } +} + +// TODO: move me to Contents +export function replaceLinks(contents, fn) { + + var links = contents.querySelectorAll("a[href]"); + + if (!links.length) { + return; + } + + var base = qs(contents.ownerDocument, "base"); + var location = base ? base.getAttribute("href") : undefined; + var replaceLink = function(link){ + var href = link.getAttribute("href"); + + if(href.indexOf("mailto:") === 0){ + return; + } + + var absolute = (href.indexOf("://") > -1); + + if(absolute){ + + link.setAttribute("target", "_blank"); + + }else{ + var linkUrl; + try { + linkUrl = new Url(href, location); + } catch(error) { + // NOOP + } + + link.onclick = function(){ + + if(linkUrl && linkUrl.hash) { + fn(linkUrl.Path.path + linkUrl.hash); + } else if(linkUrl){ + fn(linkUrl.Path.path); + } else { + fn(href); + } + + return false; + }; + } + }.bind(this); + + for (var i = 0; i < links.length; i++) { + replaceLink(links[i]); + } + + +} + +export function substitute(content, urls, replacements) { + urls.forEach(function(url, i){ + if (url && replacements[i]) { + // Account for special characters in the file name. + // See https://stackoverflow.com/a/6318729. + url = url.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); + content = content.replace(new RegExp(url, "g"), replacements[i]); + } + }); + return content; +} diff --git a/lib/epub.js/src/utils/request.js b/lib/epub.js/src/utils/request.js new file mode 100644 index 0000000..0de3b86 --- /dev/null +++ b/lib/epub.js/src/utils/request.js @@ -0,0 +1,150 @@ +import {defer, isXml, parse} from "./core"; +import Path from "./path"; + +function request(url, type, withCredentials, headers) { + var supportsURL = (typeof window != "undefined") ? window.URL : false; // TODO: fallback for url if window isn't defined + var BLOB_RESPONSE = supportsURL ? "blob" : "arraybuffer"; + + var deferred = new defer(); + + var xhr = new XMLHttpRequest(); + + //-- Check from PDF.js: + // https://github.com/mozilla/pdf.js/blob/master/web/compatibility.js + var xhrPrototype = XMLHttpRequest.prototype; + + var header; + + if (!("overrideMimeType" in xhrPrototype)) { + // IE10 might have response, but not overrideMimeType + Object.defineProperty(xhrPrototype, "overrideMimeType", { + value: function xmlHttpRequestOverrideMimeType() {} + }); + } + + if(withCredentials) { + xhr.withCredentials = true; + } + + xhr.onreadystatechange = handler; + xhr.onerror = err; + + xhr.open("GET", url, true); + + for(header in headers) { + xhr.setRequestHeader(header, headers[header]); + } + + if(type == "json") { + xhr.setRequestHeader("Accept", "application/json"); + } + + // If type isn"t set, determine it from the file extension + if(!type) { + type = new Path(url).extension; + } + + if(type == "blob"){ + xhr.responseType = BLOB_RESPONSE; + } + + + if(isXml(type)) { + // xhr.responseType = "document"; + xhr.overrideMimeType("text/xml"); // for OPF parsing + } + + if(type == "xhtml") { + // xhr.responseType = "document"; + } + + if(type == "html" || type == "htm") { + // xhr.responseType = "document"; + } + + if(type == "binary") { + xhr.responseType = "arraybuffer"; + } + + xhr.send(); + + function err(e) { + deferred.reject(e); + } + + function handler() { + if (this.readyState === XMLHttpRequest.DONE) { + var responseXML = false; + + if(this.responseType === "" || this.responseType === "document") { + responseXML = this.responseXML; + } + + if (this.status === 200 || this.status === 0 || responseXML) { //-- Firefox is reporting 0 for blob urls + var r; + + if (!this.response && !responseXML) { + deferred.reject({ + status: this.status, + message : "Empty Response", + stack : new Error().stack + }); + return deferred.promise; + } + + if (this.status === 403) { + deferred.reject({ + status: this.status, + response: this.response, + message : "Forbidden", + stack : new Error().stack + }); + return deferred.promise; + } + if(responseXML){ + r = this.responseXML; + } else + if(isXml(type)){ + // xhr.overrideMimeType("text/xml"); // for OPF parsing + // If this.responseXML wasn't set, try to parse using a DOMParser from text + r = parse(this.response, "text/xml"); + }else + if(type == "xhtml"){ + r = parse(this.response, "application/xhtml+xml"); + }else + if(type == "html" || type == "htm"){ + r = parse(this.response, "text/html"); + }else + if(type == "json"){ + r = JSON.parse(this.response); + }else + if(type == "blob"){ + + if(supportsURL) { + r = this.response; + } else { + //-- Safari doesn't support responseType blob, so create a blob from arraybuffer + r = new Blob([this.response]); + } + + }else{ + r = this.response; + } + + deferred.resolve(r); + } else { + + deferred.reject({ + status: this.status, + message : this.response, + stack : new Error().stack + }); + + } + } + } + + return deferred.promise; +} + +export default request; diff --git a/lib/epub.js/src/utils/scrolltype.js b/lib/epub.js/src/utils/scrolltype.js new file mode 100644 index 0000000..7d2e47b --- /dev/null +++ b/lib/epub.js/src/utils/scrolltype.js @@ -0,0 +1,55 @@ +// Detect RTL scroll type +// Based on https://github.com/othree/jquery.rtl-scroll-type/blob/master/src/jquery.rtl-scroll.js +export default function scrollType() { + var type = "reverse"; + var definer = createDefiner(); + document.body.appendChild(definer); + + if (definer.scrollLeft > 0) { + type = "default"; + } else { + if (typeof Element !== 'undefined' && Element.prototype.scrollIntoView) { + definer.children[0].children[1].scrollIntoView(); + if (definer.scrollLeft < 0) { + type = "negative"; + } + } else { + definer.scrollLeft = 1; + if (definer.scrollLeft === 0) { + type = "negative"; + } + } + } + + document.body.removeChild(definer); + return type; +} + +export function createDefiner() { + var definer = document.createElement('div'); + definer.dir="rtl"; + + definer.style.position = "fixed"; + definer.style.width = "1px"; + definer.style.height = "1px"; + definer.style.top = "0px"; + definer.style.left = "0px"; + definer.style.overflow = "hidden"; + + var innerDiv = document.createElement('div'); + innerDiv.style.width = "2px"; + + var spanA = document.createElement('span'); + spanA.style.width = "1px"; + spanA.style.display = "inline-block"; + + var spanB = document.createElement('span'); + spanB.style.width = "1px"; + spanB.style.display = "inline-block"; + + innerDiv.appendChild(spanA); + innerDiv.appendChild(spanB); + definer.appendChild(innerDiv); + + return definer; +} diff --git a/lib/epub.js/src/utils/url.js b/lib/epub.js/src/utils/url.js new file mode 100644 index 0000000..3cc8c04 --- /dev/null +++ b/lib/epub.js/src/utils/url.js @@ -0,0 +1,108 @@ +import Path from "./path"; +import path from "path-webpack"; + +/** + * creates a Url object for parsing and manipulation of a url string + * @param {string} urlString a url string (relative or absolute) + * @param {string} [baseString] optional base for the url, + * default to window.location.href + */ +class Url { + constructor(urlString, baseString) { + var absolute = (urlString.indexOf("://") > -1); + var pathname = urlString; + var basePath; + + this.Url = undefined; + this.href = urlString; + this.protocol = ""; + this.origin = ""; + this.hash = ""; + this.hash = ""; + this.search = ""; + this.base = baseString; + + if (!absolute && + baseString !== false && + typeof(baseString) !== "string" && + window && window.location) { + this.base = window.location.href; + } + + // URL Polyfill doesn't throw an error if base is empty + if (absolute || this.base) { + try { + if (this.base) { // Safari doesn't like an undefined base + this.Url = new URL(urlString, this.base); + } else { + this.Url = new URL(urlString); + } + this.href = this.Url.href; + + this.protocol = this.Url.protocol; + this.origin = this.Url.origin; + this.hash = this.Url.hash; + this.search = this.Url.search; + + pathname = this.Url.pathname + (this.Url.search ? this.Url.search : ''); + } catch (e) { + // Skip URL parsing + this.Url = undefined; + // resolve the pathname from the base + if (this.base) { + basePath = new Path(this.base); + pathname = basePath.resolve(pathname); + } + } + } + + this.Path = new Path(pathname); + + this.directory = this.Path.directory; + this.filename = this.Path.filename; + this.extension = this.Path.extension; + + } + + /** + * @returns {Path} + */ + path () { + return this.Path; + } + + /** + * Resolves a relative path to a absolute url + * @param {string} what + * @returns {string} url + */ + resolve (what) { + var isAbsolute = (what.indexOf("://") > -1); + var fullpath; + + if (isAbsolute) { + return what; + } + + fullpath = path.resolve(this.directory, what); + return this.origin + fullpath; + } + + /** + * Resolve a path relative to the url + * @param {string} what + * @returns {string} path + */ + relative (what) { + return path.relative(what, this.directory); + } + + /** + * @returns {string} + */ + toString () { + return this.href; + } +} + +export default Url; |