From 4caa393a1d410c0eeec0f014a7e948ff6ee38f55 Mon Sep 17 00:00:00 2001 From: Luis Bonilla Date: Wed, 23 Feb 2022 14:02:14 -0600 Subject: [PATCH] Added new property 'selectionStopDelay' to allow configure the selection event when it stop. New property 'selectionStopDelay' added to the Epub settings. Changed the function 'onSelectionChange' to use the settings to configure the Timeout with the new attribute selectionStopDelay. --- src/contents.js | 2526 +++++++++++++++--------------- src/managers/continuous/index.js | 1204 +++++++------- src/managers/default/index.js | 2146 +++++++++++++------------ src/managers/views/iframe.js | 1715 ++++++++++---------- src/rendition.js | 1999 +++++++++++------------ types/managers/view.d.ts | 4 +- types/rendition.d.ts | 1 + 7 files changed, 4910 insertions(+), 4685 deletions(-) diff --git a/src/contents.js b/src/contents.js index 3effe7252..b25a4370f 100644 --- a/src/contents.js +++ b/src/contents.js @@ -1,1262 +1,1296 @@ import EventEmitter from "event-emitter"; -import {isNumber, prefixed, borders, defaults} from "./utils/core"; +import { isNumber, prefixed, borders, defaults } from "./utils/core"; import EpubCFI from "./epubcfi"; import Mapping from "./mapping"; -import {replaceLinks} from "./utils/replacements"; +import { replaceLinks } from "./utils/replacements"; import { EPUBJS_VERSION, EVENTS, DOM_EVENTS } from "./utils/constants"; -const hasNavigator = typeof (navigator) !== "undefined"; +const hasNavigator = typeof navigator !== "undefined"; const isChrome = hasNavigator && /Chrome/.test(navigator.userAgent); -const isWebkit = hasNavigator && !isChrome && /AppleWebKit/.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 - */ + * 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 - * - */ - 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(); - - } + constructor(doc, content, cfiBase, sectionIndex, settings) { + // Blank Cfi for Parsing + this.epubcfi = new EpubCFI(); + this.settings = settings; + 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 + * + */ + 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), + this.settings ? this.settings.selectionStopDelay : 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); diff --git a/src/managers/continuous/index.js b/src/managers/continuous/index.js index d61f8f76c..0c0c4bd75 100644 --- a/src/managers/continuous/index.js +++ b/src/managers/continuous/index.js @@ -1,592 +1,630 @@ -import {extend, defer, requestAnimationFrame} from "../../utils/core"; +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, - allowScriptedContent: false, - allowPopups: false - }); - - 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, - allowScriptedContent: this.settings.allowScriptedContent, - allowPopups: this.settings.allowPopups - }; - - 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(); - } - } - + 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, + allowScriptedContent: false, + allowPopups: false, + selectionStopDelay: 250, + }); + + 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, + allowScriptedContent: this.settings.allowScriptedContent, + allowPopups: this.settings.allowPopups, + selectionStopDelay: this.settings.selectionStopDelay, + }; + + 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/src/managers/default/index.js b/src/managers/default/index.js index 2812d878a..ba8d35823 100644 --- a/src/managers/default/index.js +++ b/src/managers/default/index.js @@ -1,5 +1,5 @@ import EventEmitter from "event-emitter"; -import {extend, defer, windowBounds, isNumber} from "../../utils/core"; +import { extend, defer, windowBounds, isNumber } from "../../utils/core"; import scrollType from "../../utils/scrolltype"; import Mapping from "../../mapping"; import Queue from "../../utils/queue"; @@ -8,156 +8,160 @@ 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, - allowScriptedContent: false, - allowPopups: false - }); - - 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, - allowScriptedContent: this.settings.allowScriptedContent, - allowPopups: this.settings.allowPopups - }; - - 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; - } + 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, + allowScriptedContent: true, + allowPopups: false, + selectionStopDelay: 250, + }); + + 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, + allowScriptedContent: this.settings.allowScriptedContent, + allowPopups: this.settings.allowPopups, + selectionStopDelay: this.settings.selectionStopDelay, + }; + + 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; + } - if (this.settings.fullsize) { - this.settings.overflow = "visible"; - this.overflow = this.settings.overflow; - } + this._onScroll = this.onScroll.bind(this); + scroller.addEventListener("scroll", this._onScroll); + } + + removeEventListeners() { + var scroller; - this.settings.size = size; + if (!this.settings.fullsize) { + scroller = this.container; + } else { + scroller = window; + } - this.settings.rtlScrollType = scrollType(); + scroller.removeEventListener("scroll", this._onScroll); + this._onScroll = undefined; + } - // 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 - }); + destroy() { + clearTimeout(this.orientationTimeout); + clearTimeout(this.resizeTimeout); + clearTimeout(this.afterScrolled); - this.stage.attachTo(element); + this.clear(); - // Get this stage container div - this.container = this.stage.getContainer(); + this.removeEventListeners(); - // Views array methods - this.views = new Views(this.container); + this.stage.destroy(); - // Calculate Stage Size - this._bounds = this.bounds(); - this._stageSize = this.stage.size(); + this.rendered = false; - // 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) { @@ -166,909 +170,971 @@ class DefaultViewManager { 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'){ - /*** + } + + 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(); - } + distX = distX + this.layout.delta; + distX = distX - width; + } + this.scrollTo(distX, distY, true); + } - isRendered() { - return this.rendered; - } + 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 diff --git a/src/managers/views/iframe.js b/src/managers/views/iframe.js index e20e54d13..d235ec3c1 100644 --- a/src/managers/views/iframe.js +++ b/src/managers/views/iframe.js @@ -1,849 +1,886 @@ import EventEmitter from "event-emitter"; -import {extend, borders, uuid, isNumber, bounds, defer, createBlobUrl, revokeBlobUrl} from "../../utils/core"; +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, - allowScriptedContent: false, - allowPopups: 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"; - - // sandbox - this.iframe.sandbox = "allow-same-origin"; - if (this.settings.allowScriptedContent) { - this.iframe.sandbox += " allow-scripts"; - } - if (this.settings.allowPopups) { - this.iframe.sandbox += " allow-popups"; - } - - 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); - - if (this.pane) { - this.pane.element.remove(); - this.pane = undefined; - } - - 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"; - } + 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, + allowScriptedContent: false, + allowPopups: false, + selectionStopDelay: 250, + }, + 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"; + + // sandbox + this.iframe.sandbox = "allow-same-origin"; + if (this.settings.allowScriptedContent) { + this.iframe.sandbox += " allow-scripts"; + } + if (this.settings.allowPopups) { + this.iframe.sandbox += " allow-popups"; + } + + 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.settings + ); + + 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); + + if (this.pane) { + this.pane.element.remove(); + this.pane = undefined; + } + + 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); diff --git a/src/rendition.js b/src/rendition.js index 14a21b450..a4ba31cc2 100644 --- a/src/rendition.js +++ b/src/rendition.js @@ -41,331 +41,342 @@ import ContinuousViewManager from "./managers/continuous/index"; * @param {boolean} [options.allowPopups=false] enable opening popup in content */ 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", - allowScriptedContent: false, - allowPopups: false - }); - - 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; - } - - /* + 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", + allowScriptedContent: false, + allowPopups: false, + selectionStopDelay: 250, + }); + + 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); @@ -410,657 +421,693 @@ class Rendition { } */ - /** - * 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 occurred - * @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); - } - + /** + * 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 occurred + * @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))) * + 0.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 diff --git a/types/managers/view.d.ts b/types/managers/view.d.ts index b8f297fd8..9fbf98e5e 100644 --- a/types/managers/view.d.ts +++ b/types/managers/view.d.ts @@ -11,7 +11,9 @@ export interface ViewSettings { width?: number, height?: number, forceEvenPages?: boolean, - allowScriptedContent?: boolean + allowScriptedContent?: boolean, + selectionStopDelay?:number + } export default class View { diff --git a/types/rendition.d.ts b/types/rendition.d.ts index 489f3faeb..db5ee1b89 100644 --- a/types/rendition.d.ts +++ b/types/rendition.d.ts @@ -26,6 +26,7 @@ export interface RenditionOptions { snap?: boolean | object, defaultDirection?: string, allowScriptedContent?: boolean + selectionStopDelay?:number } export interface DisplayedLocation {