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 {