diff --git a/js/views/cfi_navigation_logic.js b/js/views/cfi_navigation_logic.js index 107134537..b6e7e21ff 100644 --- a/js/views/cfi_navigation_logic.js +++ b/js/views/cfi_navigation_logic.js @@ -26,46 +26,48 @@ // OF THE POSSIBILITY OF SUCH DAMAGE. /** - * CFI navigation helper class - * - * @param options Additional settings for NavigationLogic object - * - paginationInfo Layout details, used by clientRect-based geometry - * - visibleContentOffsets Function that returns offsets. If supplied it is used instead of the inferred offsets - * - frameDimensions Function that returns an object with width and height properties. Needs to be set. - * - $iframe Iframe reference, and needs to be set. - * @constructor - */ +* CFI navigation helper class +* +* @param options Additional settings for NavigationLogic object +* - paginationInfo Layout details, used by clientRect-based geometry +* - visibleContentOffsets Function that returns offsets. If supplied it is used instead of the inferred offsets +* - frameDimensions Function that returns an object with width and height properties. Needs to be set. +* - $iframe Iframe reference, and needs to be set. +* @constructor +*/ define(["jquery", "underscore", "../helpers", 'readium_cfi_js'], function($, _, Helpers, epubCfi) { -var CfiNavigationLogic = function(options) { - +var CfiNavigationLogic = function (options) { var self = this; options = options || {}; - var debugMode = ReadiumSDK.DEBUG_MODE; + var _DEBUG = ReadiumSDK.DEBUG_MODE; + if (_DEBUG) { + window.top._DEBUG_visibleTextRangeOffsetsRuns = window.top._DEBUG_visibleTextRangeOffsetsRuns || []; + } - this.getRootElement = function() { + this.getRootElement = function () { return options.$iframe[0].contentDocument.documentElement; }; - + this.getBodyElement = function () { - + // In SVG documents the root element can be considered the body. return this.getRootDocument().body || this.getRootElement(); }; this.getClassBlacklist = function () { return options.classBlacklist || []; - } + }; this.getIdBlacklist = function () { return options.idBlacklist || []; - } + }; this.getElementBlacklist = function () { return options.elementBlacklist || []; - } + }; this.getRootDocument = function () { return options.$iframe[0].contentDocument; @@ -75,21 +77,23 @@ var CfiNavigationLogic = function(options) { return self.getRootDocument().createRange(); } - function getNodeClientRect(node) { - var range = createRange(); - range.selectNode(node); - return normalizeRectangle(range.getBoundingClientRect(),0,0); + function createRangeFromNode(textnode) { + var documentRange = createRange(); + documentRange.selectNodeContents(textnode); + return documentRange; } - function getNodeContentsClientRect(node) { - var range = createRange(); - range.selectNodeContents(node); - return normalizeRectangle(range.getBoundingClientRect(),0,0); - } + function getNodeClientRect(node) { + var range = createRange(); + range.selectNode(node); + return normalizeRectangle(range.getBoundingClientRect(), 0, 0); + } - function getElementClientRect($element) { - return normalizeRectangle($element[0].getBoundingClientRect(),0,0); - } + function getNodeContentsClientRect(node) { + var range = createRange(); + range.selectNodeContents(node); + return normalizeRectangle(range.getBoundingClientRect(), 0, 0); + } function getNodeRangeClientRect(startNode, startOffset, endNode, endOffset) { var range = createRange(); @@ -99,24 +103,33 @@ var CfiNavigationLogic = function(options) { } else if (endNode.nodeType === Node.TEXT_NODE) { range.setEnd(endNode, endOffset ? endOffset : 0); } - return normalizeRectangle(range.getBoundingClientRect(),0,0); + return normalizeRectangle(range.getBoundingClientRect(), 0, 0); } function getNodeClientRectList(node, visibleContentOffsets) { visibleContentOffsets = visibleContentOffsets || getVisibleContentOffsets(); - + var range = createRange(); range.selectNode(node); + return getRangeClientRectList(range, visibleContentOffsets); + } + + function getRangeClientRectList(range, visibleContentOffsets) { + visibleContentOffsets = visibleContentOffsets || getVisibleContentOffsets(); + + //noinspection JSUnresolvedFunction + return _.map(range.getClientRects(), function (rect) { + return normalizeRectangle(rect, visibleContentOffsets.left, visibleContentOffsets.top); }); } function getFrameDimensions() { - if (options.frameDimensions) { - return options.frameDimensions(); + if (options.frameDimensionsGetter) { + return options.frameDimensionsGetter(); } - + console.error('CfiNavigationLogic: No frame dimensions specified!'); return null; } @@ -154,17 +167,16 @@ var CfiNavigationLogic = function(options) { /** * @private - * Checks whether or not a (fully adjusted) rectangle is at least partly visible + * Checks whether or not a (fully adjusted) rectangle is visible * * @param {Object} rect + * @param {boolean} [ignorePartiallyVisible] * @param {Object} [frameDimensions] - * @param {boolean} [isVwm] isVerticalWritingMode * @returns {boolean} */ - function isRectVisible(rect, ignorePartiallyVisible, frameDimensions, isVwm) { + function isRectVisible(rect, ignorePartiallyVisible, frameDimensions) { frameDimensions = frameDimensions || getFrameDimensions(); - isVwm = isVwm || isVerticalWritingMode(); //Text nodes without printable text dont have client rectangles if (!rect) { @@ -175,1397 +187,1289 @@ var CfiNavigationLogic = function(options) { return false; } - if (isPaginatedView()) { - return (rect.left >= 0 && rect.left < frameDimensions.width) || - (!ignorePartiallyVisible && rect.left < 0 && rect.right >= 0); + if (isPaginatedView() && !isVerticalWritingMode()) { + return (rect.left >= 0 && rect.left < frameDimensions.width) || + (!ignorePartiallyVisible && rect.left < 0 && rect.right > 0); } else { - return (rect.top >= 0 && rect.top < frameDimensions.height) || - (!ignorePartiallyVisible && rect.top < 0 && rect.bottom >= 0); + return (rect.top >= 0 && rect.top < frameDimensions.height) || + (!ignorePartiallyVisible && rect.top < 0 && rect.bottom > 0); } } - /** - * @private - * Retrieves _current_ full width of a column (including its gap) - * - * @returns {number} Full width of a column in pixels - */ - function getColumnFullWidth() { + /** + * @private + * Retrieves _current_ full width of a column (including its gap) + * + * @returns {number} Full width of a column in pixels + */ + function getColumnFullWidth() { + + if (!options.paginationInfo || isVerticalWritingMode()) { + return options.$iframe.width(); + } - if (!options.paginationInfo || isVerticalWritingMode()) - { - return options.$iframe.width(); + return options.paginationInfo.columnWidth + options.paginationInfo.columnGap; } - return options.paginationInfo.columnWidth + options.paginationInfo.columnGap; - } + /** + * @private + * + * Retrieves _current_ offset of a viewport + * (relational to the beginning of the chapter) + * + * @returns {Object} + */ + function getVisibleContentOffsets() { + if (options.visibleContentOffsetsGetter) { + return options.visibleContentOffsetsGetter(); + } - /** - * @private - * - * Retrieves _current_ offset of a viewport - * (related to the beginning of the chapter) - * - * @returns {Object} - */ - function getVisibleContentOffsets() { - if (options.visibleContentOffsets) { - return options.visibleContentOffsets(); - } + if (isVerticalWritingMode() && options.paginationOffsetsGetter) { + return options.paginationOffsetsGetter(); + } - if (isVerticalWritingMode()) { return { - top: (options.paginationInfo ? options.paginationInfo.pageOffset : 0), + top: 0, left: 0 }; } - - // CAUSES REGRESSION BUGS !! TODO FIXME - // https://github.com/readium/readium-shared-js/issues/384#issuecomment-305145129 - // else { - // return { - // top: 0, - // left: (options.paginationInfo ? options.paginationInfo.pageOffset : 0) - // //* (isPageProgressionRightToLeft() ? -1 : 1) - // }; - // } - - return { - top: 0, - left: 0 - }; - } - - /** - * New (rectangle-based) algorithm, useful in multi-column layouts - * - * Note: the second param (props) is ignored intentionally - * (no need to use those in normalization) - * - * @param {jQuery} $element - * @param {Object} _props - * @param {boolean} shouldCalculateVisibilityPercentage - * @param {Object} [frameDimensions] - * @returns {number|null} - * 0 for non-visible elements, - * 0 < n <= 100 for visible elements - * (will just give 100, if `shouldCalculateVisibilityPercentage` => false) - * null for elements with display:none - */ - function checkVisibilityByRectangles($element, shouldCalculateVisibilityPercentage, visibleContentOffsets, frameDimensions) { - visibleContentOffsets = visibleContentOffsets || getVisibleContentOffsets(); - frameDimensions = frameDimensions || getFrameDimensions(); - - var clientRectangles = getNormalizedRectangles($element, visibleContentOffsets); - if (clientRectangles.length === 0) { // elements with display:none, etc. - return null; - } - var visibilityPercentage = 0; - - if (clientRectangles.length === 1) { - var adjustedRect = clientRectangles[0]; - - if (isPaginatedView()) { - if (adjustedRect.bottom > frameDimensions.height || adjustedRect.top < 0) { - // because of webkit inconsistency, that single rectangle should be adjusted - // until it hits the end OR will be based on the FIRST column that is visible - adjustRectangle(adjustedRect, true, frameDimensions); - } - } - - if (isRectVisible(adjustedRect, false, frameDimensions)) { - //it might still be partially visible in webkit - if (shouldCalculateVisibilityPercentage && adjustedRect.top < 0) { - visibilityPercentage = - Math.floor(100 * (adjustedRect.height + adjustedRect.top) / adjustedRect.height); - } else { - visibilityPercentage = 100; - } - } - } else { - // for an element split between several CSS columns,z - // both Firefox and IE produce as many client rectangles; - // each of those should be checked - for (var i = 0, l = clientRectangles.length; i < l; ++i) { - if (isRectVisible(clientRectangles[i], false, frameDimensions)) { - visibilityPercentage = shouldCalculateVisibilityPercentage - ? measureVisibilityPercentageByRectangles(clientRectangles, i) - : 100; - break; - } + function getPaginationOffsets() { + if (options.paginationOffsetsGetter) { + return options.paginationOffsetsGetter(); } - } - return visibilityPercentage; - } - - /** - * Finds a page index (0-based) for a specific element. - * Calculations are based on rectangles retrieved with getClientRects() method. - * - * @param {jQuery} $element - * @param {number} spatialVerticalOffset - * @returns {number|null} - */ - function findPageByRectangles($element, spatialVerticalOffset) { - - var visibleContentOffsets = getVisibleContentOffsets(); - ////////////////////// - // ABOVE CAUSES REGRESSION BUGS !! TODO FIXME - // https://github.com/readium/readium-shared-js/issues/384#issuecomment-305145129 - if (options.visibleContentOffsets) { - visibleContentOffsets = options.visibleContentOffsets(); - } - if (isVerticalWritingMode()) { - visibleContentOffsets = { - top: (options.paginationInfo ? options.paginationInfo.pageOffset : 0), - left: 0 - }; - } - else { // THIS IS ENABLED ONLY FOR findPageByRectangles(), to fix the pageIndex computation. TODO FIXME! - visibleContentOffsets = { + return { top: 0, - left: (options.paginationInfo ? options.paginationInfo.pageOffset : 0) - //* (isPageProgressionRightToLeft() ? -1 : 1) + left: 0 }; } - ////////////////////// - - var clientRectangles = getNormalizedRectangles($element, visibleContentOffsets); - if (clientRectangles.length === 0) { // elements with display:none, etc. - return null; - } - - return calculatePageIndexByRectangles(clientRectangles, spatialVerticalOffset); - } - - /** - * @private - * Calculate a page index (0-based) for given client rectangles. - * - * @param {object} clientRectangles - * @param {number} [spatialVerticalOffset] - * @param {object} [frameDimensions] - * @param {object} [columnFullWidth] - * @returns {number|null} - */ - function calculatePageIndexByRectangles(clientRectangles, spatialVerticalOffset, frameDimensions, columnFullWidth) { - var isRtl = isPageProgressionRightToLeft(); - var isVwm = isVerticalWritingMode(); - columnFullWidth = columnFullWidth || getColumnFullWidth(); - frameDimensions = frameDimensions || getFrameDimensions(); - - if (spatialVerticalOffset) { - trimRectanglesByVertOffset(clientRectangles, spatialVerticalOffset, - frameDimensions, columnFullWidth, isRtl, isVwm); - } - - var firstRectangle = _.first(clientRectangles); - if (clientRectangles.length === 1) { - adjustRectangle(firstRectangle, false, frameDimensions, columnFullWidth, isRtl, isVwm); - } - var pageIndex; - - if (isVwm) { - var topOffset = firstRectangle.top; - pageIndex = Math.floor(topOffset / frameDimensions.height); - } else { - var leftOffset = firstRectangle.left; - if (isRtl) { - leftOffset = (columnFullWidth * (options.paginationInfo ? options.paginationInfo.visibleColumnCount : 1)) - leftOffset; + /** + * New (rectangle-based) algorithm, useful in multi-column layouts + * + * Note: the second param (props) is ignored intentionally + * (no need to use those in normalization) + * + * @param {jQuery} $element + * @param {boolean} shouldCalculateVisibilityPercentage + * @param {Object} [visibleContentOffsets] + * @param {Object} [frameDimensions] + * @returns {number|null} + * 0 for non-visible elements, + * 0 < n <= 100 for visible elements + * (will just give 100, if `shouldCalculateVisibilityPercentage` => false) + * null for elements with display:none + */ + function checkVisibilityByRectangles($element, shouldCalculateVisibilityPercentage, visibleContentOffsets, frameDimensions) { + visibleContentOffsets = visibleContentOffsets || getVisibleContentOffsets(); + frameDimensions = frameDimensions || getFrameDimensions(); + + var clientRectangles = getNormalizedRectangles($element, visibleContentOffsets); + if (clientRectangles.length === 0) { // elements with display:none, etc. + return null; } - pageIndex = Math.floor(leftOffset / columnFullWidth); - } - - if (pageIndex < 0) { - pageIndex = 0; - } - else if (pageIndex >= (options.paginationInfo ? options.paginationInfo.columnCount : 1)) { - pageIndex = (options.paginationInfo ? (options.paginationInfo.columnCount - 1) : 0); - } - return pageIndex; - } - - /** - * Finds a page index (0-based) for a specific client rectangle. - * Calculations are based on viewport dimensions, offsets, and rectangle coordinates - * - * @param {ClientRect} clientRectangle - * @param {Object} [visibleContentOffsets] - * @param {Object} [frameDimensions] - * @returns {number|null} - */ - function findPageBySingleRectangle(clientRectangle, visibleContentOffsets, frameDimensions) { - visibleContentOffsets = visibleContentOffsets || getVisibleContentOffsets(); - frameDimensions = frameDimensions || getFrameDimensions(); - - var normalizedRectangle = normalizeRectangle( - clientRectangle, visibleContentOffsets.left, visibleContentOffsets.top); + var visibilityPercentage = 0; - return calculatePageIndexByRectangles([normalizedRectangle], frameDimensions); - } + if (clientRectangles.length === 1) { + var adjustedRect = clientRectangles[0]; - /** - * @private - * Calculates the visibility offset percentage based on ClientRect dimensions - * - * @param {Array} clientRectangles (should already be normalized) - * @param {number} firstVisibleRectIndex - * @returns {number} - visibility percentage (0 < n <= 100) - */ - function measureVisibilityPercentageByRectangles(clientRectangles, firstVisibleRectIndex) { - - var heightTotal = 0; - var heightVisible = 0; - - if (clientRectangles.length > 1) { - _.each(clientRectangles, function (rect, index) { - heightTotal += rect.height; - if (index >= firstVisibleRectIndex) { - // in this case, all the rectangles after the first visible - // should be counted as visible - heightVisible += rect.height; + if (isPaginatedView()) { + if (adjustedRect.bottom > frameDimensions.height || adjustedRect.top < 0) { + // because of webkit inconsistency, that single rectangle should be adjusted + // until it hits the end OR will be based on the FIRST column that is visible + adjustRectangle(adjustedRect, true, frameDimensions); + } } - }); - } - else { - // should already be normalized and adjusted - heightTotal = clientRectangles[0].height; - heightVisible = clientRectangles[0].height - Math.max( - 0, -clientRectangles[0].top); - } - return heightVisible === heightTotal - ? 100 // trivial case: element is 100% visible - : Math.floor(100 * heightVisible / heightTotal); - } - - /** - * @private - * Retrieves the position of $element in multi-column layout - * - * @param {jQuery} $el - * @param {Object} [visibleContentOffsets] - * @returns {Object} - */ - function getNormalizedRectangles($el, visibleContentOffsets) { - - visibleContentOffsets = visibleContentOffsets || {}; - var leftOffset = visibleContentOffsets.left || 0; - var topOffset = visibleContentOffsets.top || 0; - - var isTextNode = ($el[0].nodeType === Node.TEXT_NODE); - var clientRectList; - - if (isTextNode) { - var range = createRange(); - range.selectNode($el[0]); - clientRectList = range.getClientRects(); - } else { - clientRectList = $el[0].getClientRects(); - } - // all the separate rectangles (for detecting position of the element - // split between several columns) - var clientRectangles = []; - for (var i = 0, l = clientRectList.length; i < l; ++i) { - if (clientRectList[i].height > 0) { - // Firefox sometimes gets it wrong, - // adding literally empty (height = 0) client rectangle preceding the real one, - // that empty client rectanle shouldn't be retrieved - clientRectangles.push( - normalizeRectangle(clientRectList[i], leftOffset, topOffset)); + if (isRectVisible(adjustedRect, false, frameDimensions)) { + if (shouldCalculateVisibilityPercentage && adjustedRect.top < 0) { + visibilityPercentage = + Math.floor(100 * (adjustedRect.height + adjustedRect.top) / adjustedRect.height); + } else if (shouldCalculateVisibilityPercentage && adjustedRect.bottom > frameDimensions.height) { + visibilityPercentage = + Math.floor(100 * (frameDimensions.height - adjustedRect.top) / adjustedRect.height); + } else if (shouldCalculateVisibilityPercentage && adjustedRect.left < 0 && adjustedRect.right > 0) { + visibilityPercentage = + Math.floor(100 * adjustedRect.right / adjustedRect.width); + } else if (shouldCalculateVisibilityPercentage && adjustedRect.left < 0 && adjustedRect.right > 0) { + visibilityPercentage = + Math.floor(100 * adjustedRect.right / adjustedRect.width); + } else { + visibilityPercentage = 100; + } + } + } else { + // for an element split between several CSS columns,z + // both Firefox and IE produce as many client rectangles; + // each of those should be checked + for (var i = 0, l = clientRectangles.length; i < l; ++i) { + if (isRectVisible(clientRectangles[i], false, frameDimensions)) { + visibilityPercentage = shouldCalculateVisibilityPercentage + ? measureVisibilityPercentageByRectangles(clientRectangles, i) + : 100; + break; + } + } } - } - - return clientRectangles; - } - - function getNormalizedBoundingRect($el, visibleContentOffsets) { - visibleContentOffsets = visibleContentOffsets || {}; - var leftOffset = visibleContentOffsets.left || 0; - var topOffset = visibleContentOffsets.top || 0; - var isTextNode = ($el[0].nodeType === Node.TEXT_NODE); - var boundingClientRect; - - if (isTextNode) { - var range = createRange(); - range.selectNode($el[0]); - boundingClientRect = range.getBoundingClientRect(); - } else { - boundingClientRect = $el[0].getBoundingClientRect(); + return visibilityPercentage; } - // union of all rectangles wrapping the element - return normalizeRectangle(boundingClientRect, leftOffset, topOffset); - } - - /** - * @private - * Converts TextRectangle object into a plain object, - * taking content offsets (=scrolls, position shifts etc.) into account - * - * @param {TextRectangle} textRect - * @param {number} leftOffset - * @param {number} topOffset - * @returns {Object} - */ - function normalizeRectangle(textRect, leftOffset, topOffset) { - - var plainRectObject = { - left: textRect.left, - right: textRect.right, - top: textRect.top, - bottom: textRect.bottom, - width: textRect.right - textRect.left, - height: textRect.bottom - textRect.top - }; - offsetRectangle(plainRectObject, leftOffset, topOffset); - return plainRectObject; - } - - /** - * @private - * Offsets plain object (which represents a TextRectangle). - * - * @param {Object} rect - * @param {number} leftOffset - * @param {number} topOffset - */ - function offsetRectangle(rect, leftOffset, topOffset) { - - rect.left += leftOffset; - rect.right += leftOffset; - rect.top += topOffset; - rect.bottom += topOffset; - } - - /** - * @private - * - * When element is spilled over two or more columns, - * most of the time Webkit-based browsers - * still assign a single clientRectangle to it, setting its `top` property to negative value - * (so it looks like it's rendered based on the second column) - * Alas, sometimes they decide to continue the leftmost column - from _below_ its real height. - * In this case, `bottom` property is actually greater than element's height and had to be adjusted accordingly. - * - * Ugh. - * - * @param {Object} rect - * @param {boolean} [shouldLookForFirstVisibleColumn] - * If set, there'll be two-phase adjustment - * (to align a rectangle with a viewport) - * @param {Object} [frameDimensions] - * @param {number} [columnFullWidth] - * @param {boolean} [isRtl] - * @param {boolean} [isVwm] isVerticalWritingMode - */ - function adjustRectangle(rect, shouldLookForFirstVisibleColumn, frameDimensions, columnFullWidth, isRtl, isVwm) { + /** + * Finds a page index (0-based) delta for a specific element. + * Calculations are based on rectangles retrieved with getClientRects() method. + * + * @param {jQuery} $element + * @returns {number|null} + */ + function findPageIndexDeltaByRectangles($element) { - frameDimensions = frameDimensions || getFrameDimensions(); - columnFullWidth = columnFullWidth || getColumnFullWidth(); - isRtl = isRtl || isPageProgressionRightToLeft(); - isVwm = isVwm || isVerticalWritingMode(); + var visibleContentOffsets = getVisibleContentOffsets(); - // Rectangle adjustment is not needed in VWM since it does not deal with columns - if (isVwm) { - return; - } + var clientRectangles = getNormalizedRectangles($element, visibleContentOffsets); + if (clientRectangles.length === 0) { // elements with display:none, etc. + return null; + } - if (isRtl) { - columnFullWidth *= -1; // horizontal shifts are reverted in RTL mode - } + return calculatePageIndexDeltaByRectangles(clientRectangles); + } + + /** + * @private + * Calculate a page index (0-based) delta for given client rectangles. + * + * @param {object[]} clientRectangles + * @param {object} [frameDimensions] + * @param {number} [columnFullWidth] + * @returns {number|null} + */ + function calculatePageIndexDeltaByRectangles(clientRectangles, frameDimensions, columnFullWidth) { + var isRtl = isPageProgressionRightToLeft(); + var isVwm = isVerticalWritingMode(); + columnFullWidth = columnFullWidth || getColumnFullWidth(); + frameDimensions = frameDimensions || getFrameDimensions(); + + var firstRectangle = _.first(clientRectangles); + if (clientRectangles.length === 1) { + adjustRectangle(firstRectangle, false, frameDimensions, columnFullWidth, isRtl, isVwm); + } - // first we go left/right (rebasing onto the very first column available) - while (rect.top < 0) { - offsetRectangle(rect, -columnFullWidth, frameDimensions.height); - } + var pageIndex; - // ... then, if necessary (for visibility offset checks), - // each column is tried again (now in reverse order) - // the loop will be stopped when the column is aligned with a viewport - // (i.e., is the first visible one). - if (shouldLookForFirstVisibleColumn) { - while (rect.bottom >= frameDimensions.height) { - if (isRectVisible(rect, false, frameDimensions, isVwm)) { - break; + if (isVwm) { + var topOffset = firstRectangle.top; + pageIndex = Math.round(topOffset / frameDimensions.height); + } else { + var leftOffset = firstRectangle.left; + if (isRtl) { + leftOffset = (columnFullWidth * (options.paginationInfo ? options.paginationInfo.visibleColumnCount : 1)) - leftOffset; } - offsetRectangle(rect, columnFullWidth, -frameDimensions.height); + pageIndex = Math.round(leftOffset / columnFullWidth); } - } - } - - /** - * @private - * Trims the rectangle(s) representing the given element. - * - * @param {Array} rects - * @param {number} verticalOffset - * @param {number} frameDimensions - * @param {number} columnFullWidth - * @param {boolean} isRtl - * @param {boolean} isVwm isVerticalWritingMode - */ - function trimRectanglesByVertOffset( - rects, verticalOffset, frameDimensions, columnFullWidth, isRtl, isVwm) { - - frameDimensions = frameDimensions || getFrameDimensions(); - columnFullWidth = columnFullWidth || getColumnFullWidth(); - isRtl = isRtl || isPageProgressionRightToLeft(); - isVwm = isVwm || isVerticalWritingMode(); - //TODO: Support vertical writing mode - if (isVwm) { - return; - } - - var totalHeight = _.reduce(rects, function(prev, cur) { - return prev + cur.height; - }, 0); + return pageIndex; + } + + /** + * Finds a page index (0-based) delta for a specific client rectangle. + * Calculations are based on viewport dimensions, offsets, and rectangle coordinates + * + * @param {ClientRect} clientRectangle + * @param {Object} [visibleContentOffsets] + * @param {Object} [frameDimensions] + * @returns {number|null} + */ + function findPageIndexDeltaBySingleRectangle(clientRectangle, visibleContentOffsets, frameDimensions) { + visibleContentOffsets = visibleContentOffsets || getVisibleContentOffsets(); + frameDimensions = frameDimensions || getFrameDimensions(); + + var normalizedRectangle = normalizeRectangle( + clientRectangle, visibleContentOffsets.left, visibleContentOffsets.top); + + return calculatePageIndexDeltaByRectangles([normalizedRectangle], frameDimensions); + } + + /** + * @private + * Calculates the visibility offset percentage based on ClientRect dimensions + * + * @param {Array} clientRectangles (should already be normalized) + * @param {number} firstVisibleRectIndex + * @returns {number} - visibility percentage (0 < n <= 100) + */ + function measureVisibilityPercentageByRectangles(clientRectangles, firstVisibleRectIndex) { + + var heightTotal = 0; + var heightVisible = 0; + + if (clientRectangles.length > 1) { + _.each(clientRectangles, function (rect, index) { + heightTotal += rect.height; + if (index >= firstVisibleRectIndex) { + // in this case, all the rectangles after the first visible + // should be counted as visible + heightVisible += rect.height; + } + }); + } + else { + // should already be normalized and adjusted + heightTotal = clientRectangles[0].height; + heightVisible = clientRectangles[0].height - Math.max( + 0, -clientRectangles[0].top); + } + return heightVisible === heightTotal + ? 100 // trivial case: element is 100% visible + : Math.floor(100 * heightVisible / heightTotal); + } + + /** + * @private + * Retrieves the position of $element in multi-column layout + * + * @param {jQuery} $el + * @param {Object} [visibleContentOffsets] + * @returns {Object[]} + */ + function getNormalizedRectangles($el, visibleContentOffsets) { + + visibleContentOffsets = visibleContentOffsets || {}; + var leftOffset = visibleContentOffsets.left || 0; + var topOffset = visibleContentOffsets.top || 0; + + var isTextNode = ($el[0].nodeType === Node.TEXT_NODE); + var clientRectList; + + if (isTextNode) { + var range = createRange(); + range.selectNode($el[0]); + //noinspection JSUnresolvedFunction + clientRectList = range.getClientRects(); + } else { + //noinspection JSUnresolvedFunction + clientRectList = $el[0].getClientRects(); + } - var heightToHide = totalHeight * verticalOffset / 100; - if (rects.length > 1) { - var heightAccum = 0; - do { - heightAccum += rects[0].height; - if (heightAccum > heightToHide) { - break; + // all the separate rectangles (for detecting position of the element + // split between several columns) + var clientRectangles = []; + for (var i = 0, l = clientRectList.length; i < l; ++i) { + if (clientRectList[i].height > 0 || clientRectList.length === 1) { + // Firefox sometimes gets it wrong, + // adding literally empty (height = 0) client rectangle preceding the real one, + // that empty client rectanle shouldn't be retrieved + clientRectangles.push( + normalizeRectangle(clientRectList[i], leftOffset, topOffset)); } - rects.shift(); - } while (rects.length > 1); - } - else { - // rebase to the last possible column - // (so that adding to top will be properly processed later) - if (isRtl) { - columnFullWidth *= -1; - } - while (rects[0].bottom >= frameDimensions.height) { - offsetRectangle(rects[0], columnFullWidth, -frameDimensions.height); } - rects[0].top += heightToHide; - rects[0].height -= heightToHide; + return clientRectangles; } - } - this.getCfiForElement = function (element) { - var cfi = EPUBcfi.Generator.generateElementCFIComponent(element, - this.getClassBlacklist(), - this.getElementBlacklist(), - this.getIdBlacklist()); + function getNormalizedBoundingRect($el, visibleContentOffsets) { + visibleContentOffsets = visibleContentOffsets || {}; + var leftOffset = visibleContentOffsets.left || 0; + var topOffset = visibleContentOffsets.top || 0; - if (cfi[0] == "!") { - cfi = cfi.substring(1); - } - return cfi; - }; + var isTextNode = ($el[0].nodeType === Node.TEXT_NODE); + var boundingClientRect; - this.getVisibleCfiFromPoint = function (x, y, precisePoint) { - var document = self.getRootDocument(); - var firstVisibleCaretRange = getCaretRangeFromPoint(x, y, document); - var elementFromPoint = document.elementFromPoint(x, y); - var invalidElementFromPoint = !elementFromPoint || elementFromPoint === document.documentElement; - - if (precisePoint) { - if (!elementFromPoint || invalidElementFromPoint) { - return null; - } - var testRect = getNodeContentsClientRect(elementFromPoint); - if (!isRectVisible(testRect, false)) { - return null; - } - if ((x < testRect.left || x > testRect.right) || (y < testRect.top || y > testRect.bottom)) { - return null; + if (isTextNode) { + var range = createRange(); + range.selectNode($el[0]); + boundingClientRect = range.getBoundingClientRect(); + } else { + boundingClientRect = $el[0].getBoundingClientRect(); } - } - if (!firstVisibleCaretRange) { - if (invalidElementFromPoint) { - console.error("Could not generate CFI no visible element on page"); - return null; + // union of all rectangles wrapping the element + return normalizeRectangle(boundingClientRect, leftOffset, topOffset); + } + + /** + * @private + * Converts TextRectangle object into a plain object, + * taking content offsets (=scrolls, position shifts etc.) into account + * + * @param {Object} textRect + * @param {number} leftOffset + * @param {number} topOffset + * @returns {Object} + */ + function normalizeRectangle(textRect, leftOffset, topOffset) { + + var plainRectObject = { + left: textRect.left, + right: textRect.right, + top: textRect.top, + bottom: textRect.bottom, + width: textRect.right - textRect.left, + height: textRect.bottom - textRect.top + }; + if (leftOffset && topOffset) { + offsetRectangle(plainRectObject, leftOffset, topOffset); } - firstVisibleCaretRange = createRange(); - firstVisibleCaretRange.selectNode(elementFromPoint); - } - - var range = firstVisibleCaretRange; - var cfi; - //if we get a text node we need to get an approximate range for the first visible character offsets. - var node = range.startContainer; - var startOffset, endOffset; - if (node.nodeType === Node.TEXT_NODE) { - if (precisePoint && node.parentNode !== elementFromPoint) { - return null; + return plainRectObject; + } + + /** + * @private + * Offsets plain object (which represents a TextRectangle). + * + * @param {Object} rect + * @param {number} leftOffset + * @param {number} topOffset + */ + function offsetRectangle(rect, leftOffset, topOffset) { + + rect.left += leftOffset; + rect.right += leftOffset; + rect.top += topOffset; + rect.bottom += topOffset; + } + + /** + * @private + * + * When element is spilled over two or more columns, + * most of the time Webkit-based browsers + * still assign a single clientRectangle to it, setting its `top` property to negative value + * (so it looks like it's rendered based on the second column) + * Alas, sometimes they decide to continue the leftmost column - from _below_ its real height. + * In this case, `bottom` property is actually greater than element's height and had to be adjusted accordingly. + * + * Ugh. + * + * @param {Object} rect + * @param {boolean} [shouldLookForFirstVisibleColumn] + * If set, there'll be two-phase adjustment + * (to align a rectangle with a viewport) + * @param {Object} [frameDimensions] + * @param {number} [columnFullWidth] + * @param {boolean} [isRtl] + * @param {boolean} [isVwm] isVerticalWritingMode + */ + function adjustRectangle(rect, shouldLookForFirstVisibleColumn, frameDimensions, columnFullWidth, isRtl, isVwm) { + + frameDimensions = frameDimensions || getFrameDimensions(); + columnFullWidth = columnFullWidth || getColumnFullWidth(); + isRtl = isRtl || isPageProgressionRightToLeft(); + isVwm = isVwm || isVerticalWritingMode(); + + // Rectangle adjustment is not needed in VWM since it does not deal with columns + if (isVwm) { + return; } - if (node.length === 1 && range.startOffset === 1) { - startOffset = 0; - endOffset = 1; - } else if (range.startOffset === node.length) { - startOffset = range.startOffset - 1; - endOffset = range.startOffset; - } else { - startOffset = range.startOffset; - endOffset = range.startOffset + 1; - } - var wrappedRange = { - startContainer: node, - endContainer: node, - startOffset: startOffset, - endOffset: endOffset, - commonAncestorContainer: range.commonAncestorContainer - }; - if (debugMode) { - drawDebugOverlayFromDomRange(wrappedRange); + if (isRtl) { + columnFullWidth *= -1; // horizontal shifts are reverted in RTL mode } - cfi = generateCfiFromDomRange(wrappedRange); - } else if (node.nodeType === Node.ELEMENT_NODE) { - node = - range.startContainer.childNodes[range.startOffset] || - range.startContainer.childNodes[0] || - range.startContainer; - if (precisePoint && node !== elementFromPoint) { - return null; + // first we go left/right (rebasing onto the very first column available) + while (rect.top < 0) { + offsetRectangle(rect, -columnFullWidth, frameDimensions.height); } - if (node.nodeType !== Node.ELEMENT_NODE) { - cfi = generateCfiFromDomRange(range); - } else { - cfi = self.getCfiForElement(node); - } - } else { - if (precisePoint && node !== elementFromPoint) { - return null; + // ... then, if necessary (for visibility offset checks), + // each column is tried again (now in reverse order) + // the loop will be stopped when the column is aligned with a viewport + // (i.e., is the first visible one). + if (shouldLookForFirstVisibleColumn) { + while (rect.bottom >= frameDimensions.height) { + if (isRectVisible(rect, false, frameDimensions)) { + break; + } + offsetRectangle(rect, columnFullWidth, -frameDimensions.height); + } } - - cfi = self.getCfiForElement(elementFromPoint); } - //This should not happen but if it does print some output, just in case - if (cfi && cfi.indexOf('NaN') !== -1) { - console.log('Did not generate a valid CFI:' + cfi); - return undefined; - } + this.getCfiForElement = function (element) { - return cfi; - }; + var cfi = EPUBcfi.Generator.generateElementCFIComponent(element, + this.getClassBlacklist(), + this.getElementBlacklist(), + this.getIdBlacklist()); - this.getRangeCfiFromPoints = function(startX, startY, endX, endY) { - var document = self.getRootDocument(); - var start = getCaretRangeFromPoint(startX, startY, document), - end = getCaretRangeFromPoint(endX, endY, document), - range = createRange(); - range.setStart(start.startContainer, start.startOffset); - range.setEnd(end.startContainer, end.startOffset); - // if we're looking at a text node create a nice range (n, n+1) - if (start.startContainer === start.endContainer && start.startContainer.nodeType === Node.TEXT_NODE && end.startContainer.length > end.startOffset+1) { - range.setEnd(end.startContainer, end.startOffset+1); - } - return generateCfiFromDomRange(range); - }; + if (cfi[0] == "!") { + cfi = cfi.substring(1); + } + return cfi; + }; - function getTextNodeRectCornerPairs(rect) { - // - // top left top right - // ╲ ╱ - // ── ▒T▒E▒X▒T▒ ▒R▒E▒C▒T▒ ── - // - // top left corner & top right corner - // but for y coord use the mid point between top and bottom + this.getVisibleCfiFromPoint = function (x, y, precisePoint) { + var document = self.getRootDocument(); + var firstVisibleCaretRange = getCaretRangeFromPoint(x, y, document); + var elementFromPoint = document.elementFromPoint(x, y); + var invalidElementFromPoint = !elementFromPoint || elementFromPoint === document.documentElement; - if (isVerticalWritingMode()) { - var x = rect.right - (rect.width / 2); - return [{x: x, y: rect.top}, {x: x, y: rect.bottom}]; - } else { - var y = rect.top + (rect.height / 2); - var result = [{x: rect.left, y: y}, {x: rect.right, y: y}] - return isPageProgressionRightToLeft() ? result.reverse() : result; - } - } + if (precisePoint) { + if (!elementFromPoint || invalidElementFromPoint) { + return null; + } + var testRect = getNodeContentsClientRect(elementFromPoint); + if (!isRectVisible(testRect, false)) { + return null; + } + if ((x < testRect.left || x > testRect.right) || (y < testRect.top || y > testRect.bottom)) { + return null; + } + } - var DEBUG = false; + if (!firstVisibleCaretRange) { + if (invalidElementFromPoint) { + console.error("Could not generate CFI no visible element on page"); + return null; + } + firstVisibleCaretRange = createRange(); + firstVisibleCaretRange.selectNode(elementFromPoint); + } - function getVisibleTextRangeOffsetsSelectedByFunc(textNode, pickerFunc, visibleContentOffsets, frameDimensions) { - visibleContentOffsets = visibleContentOffsets || getVisibleContentOffsets(); - - var textNodeFragments = getNodeClientRectList(textNode, visibleContentOffsets); + var range = firstVisibleCaretRange; + var cfi; + //if we get a text node we need to get an approximate range for the first visible character offsets. + var node = range.startContainer; + var startOffset, endOffset; + if (node.nodeType === Node.TEXT_NODE) { + if (precisePoint && node.parentNode !== elementFromPoint) { + return null; + } + if (node.length === 1 && range.startOffset === 1) { + startOffset = 0; + endOffset = 1; + } else if (range.startOffset === node.length) { + startOffset = range.startOffset - 1; + endOffset = range.startOffset; + } else { + startOffset = range.startOffset; + endOffset = range.startOffset + 1; + } + var wrappedRange = { + startContainer: node, + endContainer: node, + startOffset: startOffset, + endOffset: endOffset, + commonAncestorContainer: range.commonAncestorContainer + }; + + if (_DEBUG) { + drawDebugOverlayFromDomRange(wrappedRange); + } - var visibleFragments = _.filter(textNodeFragments, function (rect) { - return isRectVisible(rect, false, frameDimensions); - }); + cfi = generateCfiFromDomRange(wrappedRange); + } else if (node.nodeType === Node.ELEMENT_NODE) { + node = + range.startContainer.childNodes[range.startOffset] || + range.startContainer.childNodes[0] || + range.startContainer; + if (precisePoint && node !== elementFromPoint) { + return null; + } - var fragment = pickerFunc(visibleFragments); - if (!fragment) { - //no visible fragment, empty text node? - return null; - } - var fragmentCorner = pickerFunc(getTextNodeRectCornerPairs(fragment)); - // Reverse taking into account of visible content offsets - fragmentCorner.x -= visibleContentOffsets.left; - fragmentCorner.y -= visibleContentOffsets.top; - - var caretRange = getCaretRangeFromPoint(fragmentCorner.x, fragmentCorner.y); - - // Workaround for inconsistencies with the caretRangeFromPoint IE TextRange based shim. - if (caretRange && caretRange.startContainer !== textNode && caretRange.startContainer === textNode.parentNode) { - if (DEBUG) console.log('ieTextRangeWorkaround needed'); - var startOrEnd = pickerFunc([0, 1]); - - // #1 - if (caretRange.startOffset === caretRange.endOffset) { - var checkNode = caretRange.startContainer.childNodes[Math.max(caretRange.startOffset - 1, 0)]; - if (checkNode === textNode) { - caretRange = { - startContainer: textNode, - endContainer: textNode, - startOffset: startOrEnd === 0 ? 0 : textNode.nodeValue.length, - startOffset: startOrEnd === 0 ? 0 : textNode.nodeValue.length - }; - if (DEBUG) console.log('ieTextRangeWorkaround #1:', caretRange); + if (node.nodeType !== Node.ELEMENT_NODE) { + cfi = generateCfiFromDomRange(range); + } else { + cfi = self.getCfiForElement(node); } + } else { + if (precisePoint && node !== elementFromPoint) { + return null; + } + + cfi = self.getCfiForElement(elementFromPoint); } - // Failed - else if (DEBUG) { - console.log('ieTextRangeWorkaround didn\'t work :('); + //This should not happen but if it does print some output, just in case + if (cfi && cfi.indexOf('NaN') !== -1) { + console.log('Did not generate a valid CFI:' + cfi); + return undefined; } - } + return cfi; + }; - if (DEBUG) - console.log('getVisibleTextRangeOffsetsSelectedByFunc: ', 'a0'); - - // Desperately try to find it from all angles! Darn sub pixeling.. - //TODO: remove the need for this brute-force method, since it's making the result non-deterministic - if (!caretRange || caretRange.startContainer !== textNode) { - caretRange = getCaretRangeFromPoint(fragmentCorner.x - 1, fragmentCorner.y); - - if (DEBUG) - console.log('getVisibleTextRangeOffsetsSelectedByFunc: ', 'a1'); - } - if (!caretRange || caretRange.startContainer !== textNode) { - caretRange = getCaretRangeFromPoint(fragmentCorner.x, fragmentCorner.y - 1); - - if (DEBUG) - console.log('getVisibleTextRangeOffsetsSelectedByFunc: ', 'a2'); - } - if (!caretRange || caretRange.startContainer !== textNode) { - caretRange = getCaretRangeFromPoint(fragmentCorner.x - 1, fragmentCorner.y - 1); - - if (DEBUG) - console.log('getVisibleTextRangeOffsetsSelectedByFunc: ', 'a3'); - } - if (!caretRange || caretRange.startContainer !== textNode) { - fragmentCorner.x = Math.floor(fragmentCorner.x); - fragmentCorner.y = Math.floor(fragmentCorner.y); - caretRange = getCaretRangeFromPoint(fragmentCorner.x, fragmentCorner.y); - - if (DEBUG) - console.log('getVisibleTextRangeOffsetsSelectedByFunc: ', 'b0'); - } - // Desperately try to find it from all angles! Darn sub pixeling.. - if (!caretRange || caretRange.startContainer !== textNode) { - caretRange = getCaretRangeFromPoint(fragmentCorner.x - 1, fragmentCorner.y); - - if (DEBUG) - console.log('getVisibleTextRangeOffsetsSelectedByFunc: ', 'b1'); - } - if (!caretRange || caretRange.startContainer !== textNode) { - caretRange = getCaretRangeFromPoint(fragmentCorner.x, fragmentCorner.y - 1); - - if (DEBUG) - console.log('getVisibleTextRangeOffsetsSelectedByFunc: ', 'b2'); + this.getRangeCfiFromPoints = function (startX, startY, endX, endY) { + var document = self.getRootDocument(); + var start = getCaretRangeFromPoint(startX, startY, document), + end = getCaretRangeFromPoint(endX, endY, document), + range = createRange(); + range.setStart(start.startContainer, start.startOffset); + range.setEnd(end.startContainer, end.startOffset); + // if we're looking at a text node create a nice range (n, n+1) + if (start.startContainer === start.endContainer && start.startContainer.nodeType === Node.TEXT_NODE && end.startContainer.length > end.startOffset + 1) { + range.setEnd(end.startContainer, end.startOffset + 1); + } + return generateCfiFromDomRange(range); + }; + + function determineSplit(range, division) { + var percent = division / 100; + return Math.round((range.endOffset - range.startOffset ) * percent); } - if (!caretRange || caretRange.startContainer !== textNode) { - caretRange = getCaretRangeFromPoint(fragmentCorner.x - 1, fragmentCorner.y - 1); - - if (DEBUG) - console.log('getVisibleTextRangeOffsetsSelectedByFunc: ', 'b3'); + + function splitRange(range, division) { + if (range.endOffset - range.startOffset === 1) { + return [range]; + } + var length = determineSplit(range, division); + var textNode = range.startContainer; + var leftNodeRange = range.cloneRange(); + leftNodeRange.setStart(textNode, range.startOffset); + leftNodeRange.setEnd(textNode, range.startOffset + length); + var rightNodeRange = range.cloneRange(); + rightNodeRange.setStart(textNode, range.startOffset + length); + rightNodeRange.setEnd(textNode, range.endOffset); + + return [leftNodeRange, rightNodeRange]; + + } + + // create Range from target node and search for visibleOutput Range + function getVisibleTextRangeOffsets(textNode, pickerFunc, visibleContentOffsets, frameDimensions) { + visibleContentOffsets = visibleContentOffsets || getVisibleContentOffsets(); + + var nodeRange = createRangeFromNode(textNode); + var nodeClientRects = getRangeClientRectList(nodeRange, visibleContentOffsets); + var splitRatio = deterministicSplit(nodeClientRects, pickerFunc([0, 1])); + return getTextRangeOffset(splitRange(nodeRange, splitRatio), visibleContentOffsets, + pickerFunc([0, 1]), splitRatio, + function (rect) { + return (isVerticalWritingMode() ? rect.height : rect.width) && isRectVisible(rect, false, frameDimensions); + }); } - // Still nothing? fall through.. - if (!caretRange) { - - if (DEBUG) - console.warn('getVisibleTextRangeOffsetsSelectedByFunc: no caret range result'); - - return null; + function deterministicSplit(rectList, directionBit) { + var split = 0; + // Calculate total cumulative Height for both visible portions and invisible portions and find the split + var visibleRects = _.filter(rectList, function (rect) { + return (isVerticalWritingMode() ? rect.height : rect.width) && isRectVisible(rect, false, getFrameDimensions()); + }); + var visibleRectHeight = calculateCumulativeHeight(visibleRects); + var invisibleRectHeight = totalHeight - visibleRectHeight; + var totalHeight = calculateCumulativeHeight(rectList); + + if (visibleRectHeight === totalHeight) { + // either all visible or split + // heuristic: slight bias to increase likelihood of hits + return directionBit ? 55 : 45; + } else { + split = 100 * (visibleRectHeight / totalHeight); + return invisibleRectHeight > visibleRectHeight ? split + 5 : split - 5; + } } - if (caretRange.startContainer === textNode) { - return pickerFunc( - [{start: caretRange.startOffset, end: caretRange.startOffset + 1}, - {start: caretRange.startOffset - 1, end: caretRange.startOffset}] - ); - } else { - - if (DEBUG) - console.warn('getVisibleTextRangeOffsetsSelectedByFunc: incorrect caret range result'); - - return null; + function rectTopHash (rectList) { + // sort the rectangles by top value + var sortedList = rectList.sort(function (a, b) { + return a.top < b.top; + }); + var lineMap = []; + _.each(sortedList, function (rect) { + var key = rect.top; + if (!lineMap[key]) { + lineMap[key] = [rect.height]; + } else { + var currentLine = lineMap[key]; + currentLine.push(rect.height); + lineMap[key] = currentLine; + } + }); } - } - function findVisibleLeafNodeCfi(leafNodeList, pickerFunc, targetLeafNode, visibleContentOffsets, frameDimensions, startingParent) { - var index = 0; - if (!targetLeafNode) { - index = leafNodeList.indexOf(pickerFunc(leafNodeList)); - var leafNode = leafNodeList[index]; - if (leafNode) { - startingParent = leafNode.element; + function calculateCumulativeHeight (rectList) { + var lineMap = rectTopHash(rectList); + var height = 0; + _.each(lineMap, function (line) { + height = height + Math.max.apply(null, line); + }); + return height; + } + + function getTextRangeOffset(startingSet, visibleContentOffsets, directionBit, splitRatio, filterFunc) { + var runCount = 0; + var currRange = startingSet; + //begin iterative binary search, each iteration will check Range length and visibility + while (currRange.length !== 1) { + runCount++; + var currTextNodeFragments = getRangeClientRectList(currRange[directionBit], visibleContentOffsets); + if (hasVisibleFragments(currTextNodeFragments, filterFunc)) { + currRange = splitRange(currRange[directionBit], splitRatio); + } + // No visible fragment Look in other half + else { + currRange = splitRange(currRange[directionBit ? 0 : 1], splitRatio); + } } - } else { - index = leafNodeList.indexOf(targetLeafNode); - if (index === -1) { - //target leaf node not the right type? not in list? - return null; + if (_DEBUG) { + console.debug('getVisibleTextRangeOffsets:getTextRangeOffset:runCount', runCount); + window.top._DEBUG_visibleTextRangeOffsetsRuns.push(runCount); } - // use the next leaf node in the list - index += pickerFunc([1, -1]); + var resultRange = currRange[0]; + if (resultRange) { + resultRange.collapse(!directionBit); + } + return resultRange; } - var visibleLeafNode = leafNodeList[index]; - if (!visibleLeafNode) { - return null; + function hasVisibleFragments(fragments, filterFunc) { + var visibleFragments = _.filter(fragments, filterFunc); + return !!visibleFragments.length; } - var element = visibleLeafNode.element; - var textNode = visibleLeafNode.textNode; + function findVisibleLeafNodeCfi(visibleLeafNode, pickerFunc, visibleContentOffsets, frameDimensions) { + if (!visibleLeafNode) { + return null; + } - if (targetLeafNode && element !== startingParent && !_.contains($(textNode || element).parents(), startingParent)) { - if (DEBUG) console.warn("findVisibleLeafNodeCfi: stopped recursion early"); - return null; - } + var element = visibleLeafNode.element; + var textNode = visibleLeafNode.textNode; - //if a valid text node is found, try to generate a CFI with range offsets - if (textNode && isValidTextNode(textNode)) { - var visibleRange = getVisibleTextRangeOffsetsSelectedByFunc(textNode, pickerFunc, visibleContentOffsets, frameDimensions); - if (!visibleRange) { - //the text node is valid, but not visible.. - //let's try again with the next node in the list - return findVisibleLeafNodeCfi(leafNodeList, pickerFunc, visibleLeafNode, visibleContentOffsets, frameDimensions, startingParent); + //if a valid text node is found, try to generate a CFI with range offsets + if (textNode && isValidTextNode(textNode)) { + var visibleRange = getVisibleTextRangeOffsets(textNode, pickerFunc, visibleContentOffsets, frameDimensions); + if (!visibleRange) { + if (_DEBUG) console.warn("findVisibleLeafNodeCfi: failed to find text range offset"); + return null; + } + return generateCfiFromDomRange(visibleRange); + } else { + //if not then generate a CFI for the element + return self.getCfiForElement(element); } - var range = createRange(); - range.setStart(textNode, visibleRange.start); - range.setEnd(textNode, visibleRange.end); - return generateCfiFromDomRange(range); - } else { - //if not then generate a CFI for the element - return self.getCfiForElement(element); } - } - // get an array of visible text elements and then select one based on the func supplied - // and generate a CFI for the first visible text subrange. - function getVisibleTextRangeCfiForTextElementSelectedByFunc(pickerFunc, visibleContentOffsets, frameDimensions) { - var visibleLeafNodeList = self.getVisibleLeafNodes(visibleContentOffsets, frameDimensions); - return findVisibleLeafNodeCfi(visibleLeafNodeList, pickerFunc, null, visibleContentOffsets, frameDimensions); - } - - function getLastVisibleTextRangeCfi(visibleContentOffsets, frameDimensions) { - return getVisibleTextRangeCfiForTextElementSelectedByFunc(_.last, visibleContentOffsets, frameDimensions); - } - - function getFirstVisibleTextRangeCfi(visibleContentOffsets, frameDimensions) { - return getVisibleTextRangeCfiForTextElementSelectedByFunc(_.first, visibleContentOffsets, frameDimensions); - } - - this.getFirstVisibleCfi = function (visibleContentOffsets, frameDimensions) { - return getFirstVisibleTextRangeCfi(visibleContentOffsets, frameDimensions); - }; - - this.getLastVisibleCfi = function (visibleContentOffsets, frameDimensions) { - return getLastVisibleTextRangeCfi(visibleContentOffsets, frameDimensions); - }; + function getLastVisibleTextRangeCfi(visibleContentOffsets, frameDimensions) { + var visibleLeafNode = self.findLastVisibleElement(visibleContentOffsets, frameDimensions); + return findVisibleLeafNodeCfi(visibleLeafNode, _.last, visibleContentOffsets, frameDimensions); + } - function generateCfiFromDomRange(range) { - return EPUBcfi.generateRangeComponent( - range.startContainer, range.startOffset, - range.endContainer, range.endOffset, - self.getClassBlacklist(), self.getElementBlacklist(), self.getIdBlacklist()); - } + function getFirstVisibleTextRangeCfi(visibleContentOffsets, frameDimensions) { + var visibleLeafNode = self.findFirstVisibleElement(visibleContentOffsets, frameDimensions); + return findVisibleLeafNodeCfi(visibleLeafNode, _.first, visibleContentOffsets, frameDimensions); + } - function getRangeTargetNodes(rangeCfi) { - return EPUBcfi.getRangeTargetElements( - getWrappedCfiRelativeToContent(rangeCfi), - self.getRootDocument(), - self.getClassBlacklist(), self.getElementBlacklist(), self.getIdBlacklist()); - } + this.getFirstVisibleCfi = function (visibleContentOffsets, frameDimensions) { + return getFirstVisibleTextRangeCfi(visibleContentOffsets, frameDimensions); + }; - this.getDomRangeFromRangeCfi = function(rangeCfi, rangeCfi2, inclusive) { - var range = createRange(); + this.getLastVisibleCfi = function (visibleContentOffsets, frameDimensions) { + return getLastVisibleTextRangeCfi(visibleContentOffsets, frameDimensions); + }; - if (!rangeCfi2) { - if (self.isRangeCfi(rangeCfi)) { - var rangeInfo = getRangeTargetNodes(rangeCfi); - range.setStart(rangeInfo.startElement, rangeInfo.startOffset); - range.setEnd(rangeInfo.endElement, rangeInfo.endOffset); - } else { - var element = self.getElementByCfi(rangeCfi, - this.getClassBlacklist(), this.getElementBlacklist(), this.getIdBlacklist())[0]; - range.selectNode(element); - } - } else { - if (self.isRangeCfi(rangeCfi)) { - var rangeInfo1 = getRangeTargetNodes(rangeCfi); - range.setStart(rangeInfo1.startElement, rangeInfo1.startOffset); + function generateCfiFromDomRange(range) { + if (range.collapsed && range.startContainer.nodeType === Node.TEXT_NODE) { + return EPUBcfi.generateCharacterOffsetCFIComponent( + range.startContainer, range.startOffset, + ['cfi-marker'], [], ["MathJax_Message", "MathJax_SVG_Hidden"]); + } else if (range.collapsed) { + return self.getCfiForElement(range.startContainer); } else { - var startElement = self.getElementByCfi(rangeCfi, - this.getClassBlacklist(), this.getElementBlacklist(), this.getIdBlacklist())[0]; - range.setStart(startElement, 0); + return EPUBcfi.generateRangeComponent( + range.startContainer, range.startOffset, + range.endContainer, range.endOffset, + self.getClassBlacklist(), self.getElementBlacklist(), self.getIdBlacklist()); } + } - if (self.isRangeCfi(rangeCfi2)) { - var rangeInfo2 = getRangeTargetNodes(rangeCfi2); - if (inclusive) { - range.setEnd(rangeInfo2.endElement, rangeInfo2.endOffset); + this.getDomRangeFromRangeCfi = function (rangeCfi, rangeCfi2, inclusive) { + var range = createRange(); + + if (!rangeCfi2) { + if (self.isRangeCfi(rangeCfi)) { + var rangeInfo = self.getNodeRangeInfoFromCfi(rangeCfi); + range.setStart(rangeInfo.startInfo.node, rangeInfo.startInfo.offset); + range.setEnd(rangeInfo.endInfo.node, rangeInfo.endInfo.offset); } else { - range.setEnd(rangeInfo2.startElement, rangeInfo2.startOffset); + var element = self.getElementByCfi(rangeCfi, + this.getClassBlacklist(), this.getElementBlacklist(), this.getIdBlacklist())[0]; + range.selectNode(element); } } else { - var endElement = self.getElementByCfi(rangeCfi2, - this.getClassBlacklist(), this.getElementBlacklist(), this.getIdBlacklist())[0]; - range.setEnd(endElement, endElement.childNodes.length); - } - } - return range; - }; - - this.getRangeCfiFromDomRange = function(domRange) { - return generateCfiFromDomRange(domRange); - }; - - function getWrappedCfi(partialCfi) { - return "epubcfi(" + partialCfi + ")"; - } - - function getWrappedCfiRelativeToContent(partialCfi) { - return "epubcfi(/99!" + partialCfi + ")"; - } - - this.isRangeCfi = function (partialCfi) { - return EPUBcfi.Interpreter.isRangeCfi(getWrappedCfi(partialCfi)) || EPUBcfi.Interpreter.isRangeCfi(getWrappedCfiRelativeToContent(partialCfi)); - }; - - this.getPageForElementCfi = function (cfi, classBlacklist, elementBlacklist, idBlacklist) { - - var cfiParts = splitCfi(cfi); - var partialCfi = cfiParts.cfi; + if (self.isRangeCfi(rangeCfi)) { + var rangeInfo1 = self.getNodeRangeInfoFromCfi(rangeCfi); + range.setStart(rangeInfo1.startInfo.node, rangeInfo1.startInfo.offset); + } else { + var startElement = self.getElementByCfi(rangeCfi, + this.getClassBlacklist(), this.getElementBlacklist(), this.getIdBlacklist())[0]; + range.setStart(startElement, 0); + } - if (this.isRangeCfi(partialCfi)) { - //if given a range cfi the exact page index needs to be calculated by getting node info from the range cfi - var nodeRangeInfoFromCfi = this.getNodeRangeInfoFromCfi(partialCfi); - //the page index is calculated from the node's client rectangle - return findPageBySingleRectangle(nodeRangeInfoFromCfi.clientRect); - } + if (self.isRangeCfi(rangeCfi2)) { + var rangeInfo2 = self.getNodeRangeInfoFromCfi(rangeCfi2); + if (inclusive) { + range.setEnd(rangeInfo2.endInfo.node, rangeInfo2.endInfo.offset); + } else { + range.setEnd(rangeInfo2.startInfo.node, rangeInfo2.startInfo.offset); + } + } else { + var endElement = self.getElementByCfi(rangeCfi2, + this.getClassBlacklist(), this.getElementBlacklist(), this.getIdBlacklist())[0]; + range.setEnd(endElement, endElement.childNodes.length); + } + } + return range; + }; - var $element = getElementByPartialCfi(cfiParts.cfi, classBlacklist, elementBlacklist, idBlacklist); + this.getRangeCfiFromDomRange = function (domRange) { + return generateCfiFromDomRange(domRange); + }; - if (!$element) { - return -1; + function getWrappedCfi(partialCfi) { + return "epubcfi(/99!" + partialCfi + ")"; } - var pageIndex = this.getPageForPointOnElement($element, cfiParts.x, cfiParts.y); - - return pageIndex; - - }; + this.isRangeCfi = function (partialCfi) { + return _isRangeCfi(partialCfi) || _hasTextTerminus(partialCfi); + }; - function getElementByPartialCfi(cfi, classBlacklist, elementBlacklist, idBlacklist) { + function _isRangeCfi(partialCfi) { + return EPUBcfi.Interpreter.isRangeCfi(getWrappedCfi(partialCfi)); + } - var contentDoc = self.getRootDocument(); + function _hasTextTerminus(partialCfi) { + return EPUBcfi.Interpreter.hasTextTerminus(getWrappedCfi(partialCfi)); + } - var wrappedCfi = getWrappedCfi(cfi); + this.getPageIndexDeltaForCfi = function (partialCfi, classBlacklist, elementBlacklist, idBlacklist) { - try { - //noinspection JSUnresolvedVariable - var $element = EPUBcfi.getTargetElementWithPartialCFI(wrappedCfi, contentDoc, classBlacklist, elementBlacklist, idBlacklist); + if (this.isRangeCfi(partialCfi)) { + //if given a range cfi the exact page index needs to be calculated by getting node info from the range cfi + var nodeRangeInfoFromCfi = this.getNodeRangeInfoFromCfi(partialCfi); + //the page index is calculated from the node's client rectangle + return findPageIndexDeltaBySingleRectangle(nodeRangeInfoFromCfi.clientRect); + } - } catch (ex) { - //EPUBcfi.Interpreter can throw a SyntaxError - } + var $element = getElementByPartialCfi(partialCfi, classBlacklist, elementBlacklist, idBlacklist); - if (!$element || $element.length == 0) { - console.log("Can't find element for CFI: " + cfi); - return undefined; - } + if (!$element) { + return -1; + } - return $element; - } + return this.getPageIndexDeltaForElement($element); + }; - this.getElementFromPoint = function (x, y) { + function getElementByPartialCfi(cfi, classBlacklist, elementBlacklist, idBlacklist) { - var document = self.getRootDocument(); - return document.elementFromPoint(x, y); - }; + var contentDoc = self.getRootDocument(); - this.getNodeRangeInfoFromCfi = function (cfi) { - var contentDoc = self.getRootDocument(); - if (self.isRangeCfi(cfi)) { - var wrappedCfi = getWrappedCfiRelativeToContent(cfi); + var wrappedCfi = getWrappedCfi(cfi); try { //noinspection JSUnresolvedVariable - var nodeResult = EPUBcfi.Interpreter.getRangeTargetElements(wrappedCfi, contentDoc, - this.getClassBlacklist(), - this.getElementBlacklist(), - this.getIdBlacklist()); + var $element = EPUBcfi.getTargetElement(wrappedCfi, contentDoc, classBlacklist, elementBlacklist, idBlacklist); - if (debugMode) { - console.log(nodeResult); - } } catch (ex) { //EPUBcfi.Interpreter can throw a SyntaxError } - if (!nodeResult) { - console.log("Can't find nodes for range CFI: " + cfi); + if (!$element || $element.length == 0) { + console.log("Can't find element for CFI: " + cfi); return undefined; } - var startRangeInfo = {node: nodeResult.startElement, offset: nodeResult.startOffset}; - var endRangeInfo = {node: nodeResult.endElement, offset: nodeResult.endOffset}; - var nodeRangeClientRect = - startRangeInfo && endRangeInfo ? - getNodeRangeClientRect( - startRangeInfo.node, - startRangeInfo.offset, - endRangeInfo.node, - endRangeInfo.offset) - : null; + return $element; + } - if (debugMode) { - console.log(nodeRangeClientRect); - addOverlayRect(nodeRangeClientRect, 'purple', contentDoc); - } + this.getElementFromPoint = function (x, y) { - return {startInfo: startRangeInfo, endInfo: endRangeInfo, clientRect: nodeRangeClientRect} - } else { - var $element = self.getElementByCfi(cfi, - this.getClassBlacklist(), - this.getElementBlacklist(), - this.getIdBlacklist()); + var document = self.getRootDocument(); + return document.elementFromPoint(x, y); + }; - var visibleContentOffsets = getVisibleContentOffsets(); - return {startInfo: null, endInfo: null, clientRect: getNormalizedBoundingRect($element, visibleContentOffsets)}; - } - }; + this.getNodeRangeInfoFromCfi = function (cfi) { + var contentDoc = self.getRootDocument(); - this.isNodeFromRangeCfiVisible = function (cfi) { - var nodeRangeInfo = this.getNodeRangeInfoFromCfi(cfi); - if (nodeRangeInfo) { - return isRectVisible(nodeRangeInfo.clientRect, false); - } else { - return undefined; - } - }; + var wrappedCfi = getWrappedCfi(cfi); + if (_isRangeCfi(cfi)) { - this.getElementByCfi = function (cfi, classBlacklist, elementBlacklist, idBlacklist) { + try { + //noinspection JSUnresolvedVariable + var nodeResult = EPUBcfi.Interpreter.getRangeTargetElements(wrappedCfi, contentDoc, + this.getClassBlacklist(), + this.getElementBlacklist(), + this.getIdBlacklist()); - var cfiParts = splitCfi(cfi); - return getElementByPartialCfi(cfiParts.cfi, classBlacklist, elementBlacklist, idBlacklist); - }; + if (_DEBUG) { + console.log(nodeResult); + } + } catch (ex) { + //EPUBcfi.Interpreter can throw a SyntaxError + } - this.getPageForElement = function ($element) { + if (!nodeResult) { + console.log("Can't find nodes for range CFI: " + cfi); + return undefined; + } - return this.getPageForPointOnElement($element, 0, 0); - }; + var startRangeInfo = {node: nodeResult.startElement, offset: nodeResult.startOffset}; + var endRangeInfo = {node: nodeResult.endElement, offset: nodeResult.endOffset}; + var nodeRangeClientRect = + startRangeInfo && endRangeInfo ? + getNodeRangeClientRect( + startRangeInfo.node, + startRangeInfo.offset, + endRangeInfo.node, + endRangeInfo.offset) + : null; + + if (_DEBUG) { + console.log(nodeRangeClientRect); + addOverlayRect(nodeRangeClientRect, 'purple', contentDoc); + } - this.getPageForPointOnElement = function ($element, x, y) { + return {startInfo: startRangeInfo, endInfo: endRangeInfo, clientRect: nodeRangeClientRect}; + } else if (_hasTextTerminus(cfi)) { - var pageIndex = findPageByRectangles($element, y); - if (pageIndex === null) { - console.warn('Impossible to locate a hidden element: ', $element); - return 0; - } - return pageIndex; - }; + try { + //noinspection JSUnresolvedVariable + var textTerminusResult = EPUBcfi.Interpreter.getTextTerminusInfo(wrappedCfi, contentDoc, + this.getClassBlacklist(), + this.getElementBlacklist(), + this.getIdBlacklist()); - this.getVerticalOffsetForElement = function ($element) { - return this.getVerticalOffsetForPointOnElement($element, 0, 0); - }; + if (_DEBUG) { + console.log(textTerminusResult); + } + } catch (ex) { + //EPUBcfi.Interpreter can throw a SyntaxError + } - this.getVerticalOffsetForPointOnElement = function ($element, x, y) { - var elementRect = Helpers.Rect.fromElement($element); - return Math.ceil(elementRect.top + y * elementRect.height / 100); - }; + if (!textTerminusResult) { + console.log("Can't find node for text term CFI: " + cfi); + return undefined; + } - this.getElementById = function (id) { + var textTermRangeInfo = {node: textTerminusResult.textNode, offset: textTerminusResult.textOffset}; + var textTermClientRect = + getNodeRangeClientRect( + textTermRangeInfo.node, + textTermRangeInfo.offset, + textTermRangeInfo.node, + textTermRangeInfo.offset); + if (_DEBUG) { + console.log(textTermClientRect); + addOverlayRect(textTermClientRect, 'purple', contentDoc); + } - var contentDoc = this.getRootDocument(); + return {startInfo: textTermRangeInfo, endInfo: textTermRangeInfo, clientRect: textTermClientRect}; + } else { + var $element = self.getElementByCfi(cfi, + this.getClassBlacklist(), + this.getElementBlacklist(), + this.getIdBlacklist()); - var $element = $(contentDoc.getElementById(id)); - //$("#" + Helpers.escapeJQuerySelector(id), contentDoc); + var visibleContentOffsets = getVisibleContentOffsets(); + return { + startInfo: null, + endInfo: null, + clientRect: getNormalizedBoundingRect($element, visibleContentOffsets) + }; + } + }; - if($element.length == 0) { - return undefined; - } + this.isNodeFromRangeCfiVisible = function (cfi) { + var nodeRangeInfo = this.getNodeRangeInfoFromCfi(cfi); + if (nodeRangeInfo) { + return isRectVisible(nodeRangeInfo.clientRect, false); + } else { + return undefined; + } + }; - return $element; - }; + this.getNearestCfiFromElement = function (element) { + var collapseToStart; + var chosenNode; + var isTextNode; - this.getPageForElementId = function (id) { + var siblingTextNodesAndSelf = _.filter(element.parentNode.childNodes, function (n) { + return n === element || isValidTextNode(n); + }); - var $element = this.getElementById(id); - if (!$element) { - return -1; - } + var indexOfSelf = siblingTextNodesAndSelf.indexOf(element); + var nearestNode = siblingTextNodesAndSelf[indexOfSelf - 1]; + if (!nearestNode) { + nearestNode = siblingTextNodesAndSelf[indexOfSelf + 1]; + collapseToStart = true; + } + if (!nearestNode) { + nearestNode = _.last(this.getLeafNodeElements($(element.previousElementSibling))); + if (!nearestNode) { + collapseToStart = true; + nearestNode = _.first(this.getLeafNodeElements($(element.nextElementSibling))); + } + } - return this.getPageForElement($element); - }; + // Prioritize text node use + if (isValidTextNode(nearestNode)) { + chosenNode = nearestNode; + isTextNode = true; + } else if (isElementNode(nearestNode)) { + chosenNode = nearestNode; + } else if (isElementNode(element.previousElementSibling)) { + chosenNode = element.previousElementSibling; + } else if (isElementNode(element.nextElementSibling)) { + chosenNode = element.nextElementSibling; + } else { + chosenNode = element.parentNode; + } - function splitCfi(cfi) { + if (isTextNode) { + var range = chosenNode.ownerDocument.createRange(); + range.selectNodeContents(chosenNode); + range.collapse(collapseToStart); + return this.getRangeCfiFromDomRange(range); + } else { + return this.getCfiForElement(chosenNode); + } + }; - var ret = { - cfi: "", - x: 0, - y: 0 + this.getElementByCfi = function (partialCfi, classBlacklist, elementBlacklist, idBlacklist) { + return getElementByPartialCfi(partialCfi, classBlacklist, elementBlacklist, idBlacklist); }; - var ix = cfi.indexOf("@"); + this.getPageIndexDeltaForElement = function ($element) { - if (ix != -1) { - var terminus = cfi.substring(ix + 1); + // first try to get delta by rectangles + var pageIndex = findPageIndexDeltaByRectangles($element); - var colIx = terminus.indexOf(":"); - if (colIx != -1) { - ret.x = parseInt(terminus.substr(0, colIx)); - ret.y = parseInt(terminus.substr(colIx + 1)); + // for hidden elements (e.g., page breaks) there are no rectangles + if (pageIndex === null) { + + // get CFI of the nearest (to hidden) element, and then get CFI's element + var nearestVisibleElement = this.getElementByCfi(this.getNearestCfiFromElement($element[0])); + + // find page index by rectangles again, for the nearest element + return findPageIndexDeltaByRectangles(nearestVisibleElement); } - else { - console.log("Unexpected terminating step format"); + return pageIndex; + }; + + this.getElementById = function (id) { + + var contentDoc = this.getRootDocument(); + + var $element = $(contentDoc.getElementById(id)); + //$("#" + Helpers.escapeJQuerySelector(id), contentDoc); + + if ($element.length == 0) { + return undefined; } - ret.cfi = cfi.substring(0, ix); - } - else { + return $element; + }; - ret.cfi = cfi; - } + this.getPageIndexDeltaForElementId = function (id) { - return ret; - } + var $element = this.getElementById(id); + if (!$element) { + return -1; + } - // returns raw DOM element (not $ jQuery-wrapped) - this.getFirstVisibleMediaOverlayElement = function(visibleContentOffsets) { - var $root = $(this.getBodyElement()); - if (!$root || !$root.length || !$root[0]) return undefined; + return this.getPageIndexDeltaForElement($element); + }; + + // returns raw DOM element (not $ jQuery-wrapped) + this.getFirstVisibleMediaOverlayElement = function (visibleContentOffsets) { + var $root = $(this.getBodyElement()); + if (!$root || !$root.length || !$root[0]) return undefined; - var that = this; + var that = this; - var firstPartial = undefined; + var firstPartial = undefined; - function traverseArray(arr) { - if (!arr || !arr.length) return undefined; + function traverseArray(arr) { + if (!arr || !arr.length) return undefined; - for (var i = 0, count = arr.length; i < count; i++) { - var item = arr[i]; - if (!item) continue; + for (var i = 0, count = arr.length; i < count; i++) { + var item = arr[i]; + if (!item) continue; - var $item = $(item); + var $item = $(item); - if ($item.data("mediaOverlayData")) { - var visible = that.getElementVisibility($item, visibleContentOffsets); - if (visible) { - if (!firstPartial) firstPartial = item; + if ($item.data("mediaOverlayData")) { + var visible = that.getElementVisibility($item, visibleContentOffsets); + if (visible) { + if (!firstPartial) firstPartial = item; - if (visible == 100) return item; + if (visible == 100) return item; + } + } + else { + var elem = traverseArray(item.children); + if (elem) return elem; } } - else { - var elem = traverseArray(item.children); - if (elem) return elem; - } + + return undefined; } - return undefined; - } + var el = traverseArray([$root[0]]); + if (!el) el = firstPartial; + return el; - var el = traverseArray([$root[0]]); - if (!el) el = firstPartial; - return el; + // var $elements = this.getMediaOverlayElements($root); + // return this.getVisibleElements($elements, visibleContentOffsets); + }; - // var $elements = this.getMediaOverlayElements($root); - // return this.getVisibleElements($elements, visibleContentOffsets); - }; + this.getElementVisibility = function ($element, visibleContentOffsets) { + return checkVisibilityByRectangles($element, true, visibleContentOffsets); + }; - this.getElementVisibility = function ($element, visibleContentOffsets) { - return checkVisibilityByRectangles($element, true, visibleContentOffsets); - }; + this.isElementVisible = this.getElementVisibility; - this.isElementVisible = checkVisibilityByRectangles; + this.getVisibleElementsWithFilter = function (visibleContentOffsets, filterFunction) { + var $elements = this.getElementsWithFilter($(this.getBodyElement()), filterFunction); + return this.getVisibleElements($elements, visibleContentOffsets); + }; - this.getVisibleElementsWithFilter = function (visibleContentOffsets, filterFunction) { - var $elements = this.getElementsWithFilter($(this.getBodyElement()), filterFunction); - return this.getVisibleElements($elements, visibleContentOffsets); - }; + this.getAllElementsWithFilter = function (filterFunction) { + return this.getElementsWithFilter($(this.getBodyElement()), filterFunction); + }; - this.getAllElementsWithFilter = function (filterFunction) { - var $elements = this.getElementsWithFilter($(this.getBodyElement()), filterFunction); - return $elements; - }; + this.getAllVisibleElementsWithSelector = function (selector, visibleContentOffset) { + var elements = $(selector, this.getRootElement()); + var $newElements = []; + $.each(elements, function () { + $newElements.push($(this)); + }); + return this.getVisibleElements($newElements, visibleContentOffset); + }; - this.getAllVisibleElementsWithSelector = function (selector, visibleContentOffset) { - var elements = $(selector, this.getRootElement()); - var $newElements = []; - $.each(elements, function () { - $newElements.push($(this)); - }); - var visibleElements = this.getVisibleElements($newElements, visibleContentOffset); - return visibleElements; - }; + this.getVisibleElements = function ($elements, visibleContentOffsets, frameDimensions) { - this.getVisibleElements = function ($elements, visibleContentOffsets, frameDimensions) { + var visibleElements = []; - var visibleElements = []; + _.each($elements, function ($node) { + var isTextNode = ($node[0].nodeType === Node.TEXT_NODE); + var $element = isTextNode ? $node.parent() : $node; + var visibilityPercentage = checkVisibilityByRectangles( + $node, true, visibleContentOffsets, frameDimensions); - _.each($elements, function ($node) { - var node = $node[0]; - var isTextNode = (node.nodeType === Node.TEXT_NODE); - var element = isTextNode ? node.parentElement : node; - var visibilityPercentage = checkVisibilityByRectangles( - $node, true, visibleContentOffsets, frameDimensions); + if (visibilityPercentage) { + visibleElements.push({ + element: $element[0], // DOM Element is pushed + textNode: isTextNode ? $node[0] : null, + percentVisible: visibilityPercentage - if (visibilityPercentage) { - visibleElements.push({ - element: element, // DOM Element is pushed - textNode: isTextNode ? node : null, - percentVisible: visibilityPercentage - }); - } - }); + }); + } + }); - return visibleElements; - }; + return visibleElements; + }; - this.getVisibleLeafNodes = function (visibleContentOffsets, frameDimensions) { + this.getVisibleLeafNodes = function (visibleContentOffsets, frameDimensions) { - if (_cacheEnabled) { - var cacheKey = (options.paginationInfo || {}).currentSpreadIndex || 0; - var fromCache = _cache.visibleLeafNodes.get(cacheKey); - if (fromCache) { - return fromCache; + if (_cacheEnabled) { + var cacheKey = (options.paginationInfo || {}).currentSpreadIndex || 0; + var fromCache = _cache.visibleLeafNodes.get(cacheKey); + if (fromCache) { + return fromCache; + } } - } - var $elements = this.getLeafNodeElements($(this.getBodyElement())); + var $elements = this.getLeafNodeElements($(this.getBodyElement())); - var visibleElements = this.getVisibleElements($elements, visibleContentOffsets, frameDimensions); + var visibleElements = this.getVisibleElements($elements, visibleContentOffsets, frameDimensions); + if (_cacheEnabled) { + _cache.visibleLeafNodes.set(cacheKey, visibleElements); + } - if (_cacheEnabled) { - _cache.visibleLeafNodes.set(cacheKey, visibleElements); - } + return visibleElements; + }; - return visibleElements; - }; + function getBaseCfiSelectedByFunc(pickerFunc) { + var $elements = self.getLeafNodeElements($(self.getBodyElement())); + var $selectedNode = pickerFunc($elements); + var collapseToStart = pickerFunc([true, false]); + var range = createRange(); + range.selectNodeContents($selectedNode[0]); + range.collapse(collapseToStart); + return generateCfiFromDomRange(range); + } - this.getElementsWithFilter = function ($root, filterFunction) { + this.getStartCfi = function () { + return getBaseCfiSelectedByFunc(_.first); + }; - var $elements = []; - function traverseCollection(elements) { + this.getEndCfi = function () { + return getBaseCfiSelectedByFunc(_.last); + }; - if (elements == undefined) return; + this.getElementsWithFilter = function ($root, filterFunction) { - for (var i = 0, count = elements.length; i < count; i++) { + var $elements = []; - var $element = $(elements[i]); + function traverseCollection(elements) { - if (filterFunction($element)) { - $elements.push($element); - } - else { - traverseCollection($element[0].children); - } + if (elements == undefined) return; - } - } + for (var i = 0, count = elements.length; i < count; i++) { - traverseCollection([$root[0]]); + var $element = $(elements[i]); - return $elements; - }; + if (filterFunction($element)) { + $elements.push($element); + } + else { + traverseCollection($element[0].children); + } - function isElementBlacklisted(element) { - var isBlacklisted = false; - var classAttribute = element.className; - // check for SVGAnimatedString - if (classAttribute && typeof classAttribute.animVal !== "undefined") { - classAttribute = classAttribute.animVal; - } else if (classAttribute && typeof classAttribute.baseVal !== "undefined") { - classAttribute = classAttribute.baseVal; - } - var classList = classAttribute ? classAttribute.split(' ') : []; - var id = element.id; - - var classBlacklist = self.getClassBlacklist(); - if (classList.length === 1 && _.contains(classBlacklist, classList[0])) { - isBlacklisted = true; - return; - } else if (classList.length && _.intersection(classBlacklist, classList).length) { - isBlacklisted = true; - return; - } + } + } - if (id && id.length && _.contains(self.getIdBlacklist(), id)) { - isBlacklisted = true; - return; - } + traverseCollection([$root[0]]); - return isBlacklisted; - } + return $elements; + }; - this.getLeafNodeElements = function ($root) { + function isElementBlacklisted(element) { + var classAttribute = element.className; + // check for SVGAnimatedString + if (classAttribute && typeof classAttribute.animVal !== "undefined") { + classAttribute = classAttribute.animVal; + } else if (classAttribute && typeof classAttribute.baseVal !== "undefined") { + classAttribute = classAttribute.baseVal; + } + var classList = classAttribute ? classAttribute.split(' ') : []; + var id = element.id; + + var classBlacklist = self.getClassBlacklist(); + if (classList.length === 1 && _.contains(classBlacklist, classList[0])) { + return true; + } else if (classList.length && _.intersection(classBlacklist, classList).length) { + return true; + } - if (_cacheEnabled) { - var fromCache = _cache.leafNodeElements.get($root); - if (fromCache) { - return fromCache; + if (id && id.length && _.contains(self.getIdBlacklist(), id)) { + return true; } + + return false; } - var nodeIterator = document.createNodeIterator( - $root[0], - NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, - function() { - return NodeFilter.FILTER_ACCEPT; - }, - false - ); - - var $leafNodeElements = []; - - var node; - while ((node = nodeIterator.nextNode())) { - var isLeafNode = node.nodeType === Node.ELEMENT_NODE && !node.childElementCount && !isValidTextNodeContent(node.textContent); - if (isLeafNode || isValidTextNode(node)){ - var element = (node.nodeType === Node.TEXT_NODE) ? node.parentElement : node; - if (!isElementBlacklisted(element)) { - $leafNodeElements.push($(node)); + this.getLeafNodeElements = function ($root) { + + if (_cacheEnabled) { + var fromCache = _cache.leafNodeElements.get($root); + if (fromCache) { + return fromCache; } } - } - if (_cacheEnabled) { - _cache.leafNodeElements.set($root, $leafNodeElements); - } + //noinspection JSUnresolvedVariable,JSCheckFunctionSignatures + var nodeIterator = document.createNodeIterator( + $root[0], + NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, + function () { + //noinspection JSUnresolvedVariable + return NodeFilter.FILTER_ACCEPT; + }, + false + ); - return $leafNodeElements; - }; + var $leafNodeElements = []; - function isValidTextNode(node) { + var node; + while ((node = nodeIterator.nextNode())) { + var isLeafNode = node.nodeType === Node.ELEMENT_NODE && !node.childElementCount && !isValidTextNodeContent(node.textContent); + if (isLeafNode || isValidTextNode(node)){ + var element = (node.nodeType === Node.TEXT_NODE) ? node.parentNode : node; + if (!isElementBlacklisted(element)) { + $leafNodeElements.push($(node)); + } + } + } - if (node.nodeType === Node.TEXT_NODE) { + if (_cacheEnabled) { + _cache.leafNodeElements.set($root, $leafNodeElements); + } + return $leafNodeElements; + }; - return isValidTextNodeContent(node.nodeValue); + function isElementNode(node) { + if (!node) { + return false; + } + else { + return node.nodeType === Node.ELEMENT_NODE; + } } - return false; + function isValidTextNode(node) { + if (!node) { + return false; + } + if (node.nodeType === Node.TEXT_NODE) { - } + return isValidTextNodeContent(node.nodeValue); + } - function isValidTextNodeContent(text) { - // Heuristic to find a text node with actual text - // If we don't do this, we may get a reference to a node that doesn't get rendered - // (such as for example a node that has tab character and a bunch of spaces) - // this is would be bad! ask me why. - return !!text.trim().length; - } + return false; - this.getElements = function (selector) { - if (!selector) { - return $(this.getRootElement()).children(); } - return $(selector, this.getRootElement()); - }; - - this.getElement = function (selector) { - - var $element = this.getElements(selector); - if($element.length > 0) { - return $element; + function isValidTextNodeContent(text) { + // Heuristic to find a text node with actual text + // If we don't do this, we may get a reference to a node that doesn't get rendered + // (such as for example a node that has tab character and a bunch of spaces) + // this is would be bad! ask me why. + return !!text.trim().length; } - return undefined; - }; - - function Cache() { - var that = this; - - //true = survives invalidation - var props = { - leafNodeElements: true, - visibleLeafNodes: false + this.getElements = function (selector) { + if (!selector) { + return $(this.getRootElement()).children(); + } + return $(selector, this.getRootElement()); }; - _.each(props, function (val, key) { - that[key] = new Map(); - }); - - this._invalidate = function () { - _.each(props, function (val, key) { - if (!val) { - that[key] = new Map(); - } - }); - } - } - - var _cache = new Cache(); + this.getElement = function (selector) { - var _cacheEnabled = false; + var $element = this.getElements(selector); - this.invalidateCache = function () { - _cache._invalidate(); - }; + if ($element.length > 0) { + return $element; + } + return undefined; + }; - // dmitry debug - // dmitry debug - // dmitry debug - // dmitry debug - // dmitry debug - // dmitry debug + function Cache() { + var that = this; - var parseContentCfi = function(cont) { - return cont.replace(/\[(.*?)\]/, "").split(/[\/,:]/).map(function(n) { return parseInt(n); }).filter(Boolean); - }; + //true = survives invalidation + var props = { + leafNodeElements: true, + visibleLeafNodes: false + }; - var contentCfiComparator = function(cont1, cont2) { - cont1 = this.parseContentCfi(cont1); - cont2 = this.parseContentCfi(cont2); + _.each(props, function (val, key) { + that[key] = new Map(); + }); - //compare cont arrays looking for differences - for (var i=0; i cont2[i]) { - return 1; - } - else if (cont1[i] < cont2[i]) { - return -1; + this._invalidate = function () { + _.each(props, function (val, key) { + if (!val) { + that[key] = new Map(); + } + }); } } - //no differences found, so confirm that cont2 did not have values we didn't check - if (cont1.length < cont2.length) { - return -1; - } - - //cont arrays are identical - return 0; - }; + var _cache = new Cache(); + var _cacheEnabled = false; - // end dmitry debug + this.invalidateCache = function () { + _cache._invalidate(); + }; - //if (debugMode) { + //if (_DEBUG) { var $debugOverlays = []; @@ -1617,19 +1521,11 @@ var CfiNavigationLogic = function(options) { } function drawDebugOverlayFromRect(rect) { - var leftOffset, topOffset; - - if (isVerticalWritingMode()) { - leftOffset = 0; - topOffset = -getPaginationLeftOffset(); - } else { - leftOffset = -getPaginationLeftOffset(); - topOffset = 0; - } + var offsets = getPaginationOffsets(); addOverlayRect({ - left: rect.left + leftOffset, - top: rect.top + topOffset, + left: rect.left + offsets.left, + top: rect.top + offsets.top, width: rect.width, height: rect.height }, true, self.getRootDocument()); @@ -1649,24 +1545,11 @@ var CfiNavigationLogic = function(options) { drawDebugOverlayFromRect(getNodeClientRect(node)); } - function getPaginationLeftOffset() { - - var $htmlElement = $("html", self.getRootDocument()); - var offsetLeftPixels = $htmlElement.css(isVerticalWritingMode() ? "top" : (isPageProgressionRightToLeft() ? "right" : "left")); - var offsetLeft = parseInt(offsetLeftPixels.replace("px", "")); - if (isNaN(offsetLeft)) { - //for fixed layouts, $htmlElement.css("left") has no numerical value - offsetLeft = 0; - } - if (isPageProgressionRightToLeft() && !isVerticalWritingMode()) return -offsetLeft; - return offsetLeft; - } - function clearDebugOverlays() { - _.each($debugOverlays, function($el){ + _.each($debugOverlays, function ($el) { $el.remove(); }); - $debugOverlays.clear(); + $debugOverlays = []; } ReadiumSDK._DEBUG_CfiNavigationLogic = { @@ -1684,12 +1567,174 @@ var CfiNavigationLogic = function(options) { var cfi2 = ReadiumSDK.reader.getLastVisibleCfi(); var range2 = ReadiumSDK.reader.getDomRangeFromRangeCfi(cfi2); console.log(cfi2, range2, drawDebugOverlayFromDomRange(range2)); + }, + visibleTextRangeOffsetsRunsAvg: function () { + var arr = window.top._DEBUG_visibleTextRangeOffsetsRuns; + return arr.reduce(function (a, b) { + return a + b; + }) / arr.length; } }; // - // } + // } + + this.findFirstVisibleElement = function (visibleContentOffsets, frameDimensions) { + + var firstVisibleElement; + var percentVisible = 0; + var textNode; + + var treeWalker = document.createTreeWalker( + this.getBodyElement(), + NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, + function(node) { + if (node.nodeType === Node.ELEMENT_NODE && isElementBlacklisted(node)) + return NodeFilter.FILTER_REJECT; + + if (node.nodeType === Node.TEXT_NODE && !isValidTextNode(node)) + return NodeFilter.FILTER_REJECT; + + var visibilityResult = checkVisibilityByRectangles($(node), true, visibleContentOffsets, frameDimensions); + return visibilityResult ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; + }, + false + ); + + while (treeWalker.nextNode()) { + var node = treeWalker.currentNode; + + if (node.nodeType === Node.TEXT_NODE) { + firstVisibleElement = node.parentNode; + textNode = node; + percentVisible = 100; // not really used, assume this value unless otherwise + break; + } + + var hasChildElements = false; + var hasChildTextNodes = false; + + for (var i = node.childNodes.length - 1; i >= 0; i--) { + var childNode = node.childNodes[i]; + if (childNode.nodeType === Node.ELEMENT_NODE) { + hasChildElements = true; + break; + } + if (childNode.nodeType === Node.TEXT_NODE) + hasChildTextNodes = true; + } + + // potentially stop tree traversal when first element hit with no child element nodes + if (!hasChildElements && hasChildTextNodes) { + for (var i=node.childNodes.length-1; i>=0; i--) { + var childNode = node.childNodes[i]; + if (childNode.nodeType === Node.TEXT_NODE && isValidTextNode(childNode)) { + var visibilityResult = checkVisibilityByRectangles($(childNode), true, visibleContentOffsets, frameDimensions); + if (visibilityResult) { + firstVisibleElement = node; + textNode = childNode; + percentVisible = visibilityResult; + break; + } + } + } + } else if (!hasChildElements) { + firstVisibleElement = node; + percentVisible = 100; + textNode = null; + break; + } + } -}; + if (!firstVisibleElement) { + return null; + } + return { + element: firstVisibleElement, + textNode: textNode, + percentVisible: percentVisible + }; + }; + + this.findLastVisibleElement = function (visibleContentOffsets, frameDimensions) { + + var firstVisibleElement; + var percentVisible = 0; + var textNode; + + var treeWalker = document.createTreeWalker( + this.getBodyElement(), + NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, + function(node) { + if (node.nodeType === Node.ELEMENT_NODE && isElementBlacklisted(node)) + return NodeFilter.FILTER_REJECT; + + if (node.nodeType === Node.TEXT_NODE && !isValidTextNode(node)) + return NodeFilter.FILTER_REJECT; + + var visibilityResult = checkVisibilityByRectangles($(node), true, visibleContentOffsets, frameDimensions); + return visibilityResult ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; + }, + false + ); + + while (treeWalker.lastChild()) { } + + do { + var node = treeWalker.currentNode; + + if (node.nodeType === Node.TEXT_NODE) { + firstVisibleElement = node.parentNode; + textNode = node; + percentVisible = 100; // not really used, assume this value unless otherwise + break; + } + + var hasChildElements = false; + var hasChildTextNodes = false; + + for (var i = node.childNodes.length - 1; i >= 0; i--) { + var childNode = node.childNodes[i]; + if (childNode.nodeType === Node.ELEMENT_NODE) { + hasChildElements = true; + break; + } + if (childNode.nodeType === Node.TEXT_NODE) + hasChildTextNodes = true; + } + + // potentially stop tree traversal when first element hit with no child element nodes + if (!hasChildElements && hasChildTextNodes) { + for (var i=node.childNodes.length-1; i>=0; i--) { + var childNode = node.childNodes[i]; + if (childNode.nodeType === Node.TEXT_NODE && isValidTextNode(childNode)) { + var visibilityResult = checkVisibilityByRectangles($(childNode), true, visibleContentOffsets, frameDimensions); + if (visibilityResult) { + firstVisibleElement = node; + textNode = childNode; + percentVisible = visibilityResult; + break; + } + } + } + } else if (!hasChildElements) { + firstVisibleElement = node; + percentVisible = 100; + textNode = null; + break; + } + } while (treeWalker.previousNode()); + + if (!firstVisibleElement) { + return null; + } + return { + element: firstVisibleElement, + textNode: textNode, + percentVisible: percentVisible + }; + }; + + }; return CfiNavigationLogic; }); diff --git a/js/views/fixed_view.js b/js/views/fixed_view.js index 7a198d355..0deac9100 100644 --- a/js/views/fixed_view.js +++ b/js/views/fixed_view.js @@ -862,6 +862,27 @@ var FixedView = function(options, reader){ }); }; + this.getStartCfi = function () { + return getDisplayingViews()[0].getStartCfi(); + }; + + this.getEndCfi = function () { + return getDisplayingViews()[0].getEndCfi(); + }; + + this.getNearestCfiFromElement = function(element) { + var views = getDisplayingViews(); + + for (var i = 0, count = views.length; i < count; i++) { + + var view = views[i]; + if (view.getLoadedContentFrames()[0].$iframe[0].contentDocument === element.ownerDocument) { + return view.getNearestCfiFromElement(element); + } + } + + }; + }; return FixedView; }); diff --git a/js/views/one_page_view.js b/js/views/one_page_view.js index 15ea2e549..547159b51 100644 --- a/js/views/one_page_view.js +++ b/js/views/one_page_view.js @@ -974,17 +974,24 @@ var OnePageView = function (options, classes, enableBookStyleOverrides, reader) } function getFrameDimensions() { + if (reader.needsFixedLayoutScalerWorkAround()) { + var parentEl = _$el.parent()[0]; + return { + width: parentEl.clientWidth, + height: parentEl.clientHeight + }; + } return { - width: _$el.parent()[0].clientWidth, - height: _$el.parent()[0].clientHeight + width: _meta_size.width, + height: _meta_size.height }; } this.getNavigator = function () { return new CfiNavigationLogic({ $iframe: _$iframe, - frameDimensions: getFrameDimensions, - visibleContentOffsets: getVisibleContentOffsets, + frameDimensionsGetter: getFrameDimensions, + visibleContentOffsetsGetter: getVisibleContentOffsets, classBlacklist: ["cfi-marker", "mo-cfi-highlight", "resize-sensor", "resize-sensor-expand", "resize-sensor-shrink", "resize-sensor-inner"], elementBlacklist: [], idBlacklist: ["MathJax_Message", "MathJax_SVG_Hidden"] @@ -1117,6 +1124,17 @@ var OnePageView = function (options, classes, enableBookStyleOverrides, reader) return self.getNavigator().getElementFromPoint(x, y); }; + this.getStartCfi = function () { + return createBookmarkFromCfi(self.getNavigator().getStartCfi()); + }; + + this.getEndCfi = function () { + return createBookmarkFromCfi(self.getNavigator().getEndCfi()); + }; + + this.getNearestCfiFromElement = function(element) { + return createBookmarkFromCfi(self.getNavigator().getNearestCfiFromElement(element)); + }; }; OnePageView.Events = { diff --git a/js/views/reader_view.js b/js/views/reader_view.js index b709b4f35..7eb5e4e10 100644 --- a/js/views/reader_view.js +++ b/js/views/reader_view.js @@ -1628,6 +1628,29 @@ var ReaderView = function (options) { } return undefined; }; + + /** + * Get CFI of the first element from the base of the document + * @returns {ReadiumSDK.Models.BookmarkData} + */ + this.getStartCfi = function() { + if (_currentView) { + return _currentView.getStartCfi(); + } + return undefined; + }; + + /** + * Get CFI of the last element from the base of the document + * @returns {ReadiumSDK.Models.BookmarkData} + */ + this.getEndCfi = function() { + if (_currentView) { + return _currentView.getEndCfi(); + } + return undefined; + }; + /** * * @param {string} rangeCfi @@ -1733,6 +1756,19 @@ var ReaderView = function (options) { } return undefined; }; + + /** + * Useful for getting a CFI that's as close as possible to an invisible (not rendered, zero client rects) element + * @param {HTMLElement} element + * @returns {*} + */ + this.getNearestCfiFromElement = function(element) { + if (_currentView) { + return _currentView.getNearestCfiFromElement(element); + } + return undefined; + }; + }; /** diff --git a/js/views/reflowable_view.js b/js/views/reflowable_view.js index 607bc69d8..fd6436aa5 100644 --- a/js/views/reflowable_view.js +++ b/js/views/reflowable_view.js @@ -91,6 +91,7 @@ var ReflowableView = function(options, reader){ columnMinWidth: 400, spreadCount : 0, currentSpreadIndex : 0, + currentPageIndex: 0, columnWidth : undefined, pageOffset : 0, columnCount: 0 @@ -176,6 +177,27 @@ var ReflowableView = function(options, reader){ }; } + function getPageOffset() { + if (_paginationInfo.rightToLeft && !_paginationInfo.isVerticalWritingMode) { + return -_paginationInfo.pageOffset; + } + return _paginationInfo.pageOffset; + } + + function getPaginationOffsets() { + var offset = getPageOffset(); + if (_paginationInfo.isVerticalWritingMode) { + return { + top: offset, + left: 0 + }; + } + return { + top: 0, + left: offset + }; + } + function renderIframe() { if (_$contentFrame) { //destroy old contentFrame @@ -198,8 +220,9 @@ var ReflowableView = function(options, reader){ _navigationLogic = new CfiNavigationLogic({ $iframe: _$iframe, - frameDimensions: getFrameDimensions, + frameDimensionsGetter: getFrameDimensions, paginationInfo: _paginationInfo, + paginationOffsetsGetter: getPaginationOffsets, classBlacklist: _cfiClassBlacklist, elementBlacklist: _cfiElementBlacklist, idBlacklist: _cfiIdBlacklist @@ -221,6 +244,7 @@ var ReflowableView = function(options, reader){ _paginationInfo.pageOffset = 0; _paginationInfo.currentSpreadIndex = 0; + _paginationInfo.currentPageIndex = 0; _currentSpineItem = spineItem; // TODO: this is a dirty hack!! @@ -412,18 +436,18 @@ var ReflowableView = function(options, reader){ } - this.openPageInternal = function(pageRequest) { + function _openPageInternal(pageRequest) { if(_isWaitingFrameRender) { _deferredPageRequest = pageRequest; - return; + return false; } // if no spine item specified we are talking about current spine item if(pageRequest.spineItem && pageRequest.spineItem != _currentSpineItem) { _deferredPageRequest = pageRequest; loadSpineItem(pageRequest.spineItem); - return; + return true; } var pageIndex = undefined; @@ -433,21 +457,17 @@ var ReflowableView = function(options, reader){ pageIndex = pageRequest.spineItemPageIndex; } else if(pageRequest.elementId) { - pageIndex = _navigationLogic.getPageForElementId(pageRequest.elementId); - - if (pageIndex < 0) pageIndex = 0; + pageIndex = _paginationInfo.currentPageIndex + _navigationLogic.getPageIndexDeltaForElementId(pageRequest.elementId); } else if(pageRequest.firstVisibleCfi && pageRequest.lastVisibleCfi) { var firstPageIndex; var lastPageIndex; try { - firstPageIndex = _navigationLogic.getPageForElementCfi(pageRequest.firstVisibleCfi, + firstPageIndex = _navigationLogic.getPageIndexDeltaForCfi(pageRequest.firstVisibleCfi, _cfiClassBlacklist, _cfiElementBlacklist, _cfiIdBlacklist); - - if (firstPageIndex < 0) firstPageIndex = 0; } catch (e) { @@ -456,12 +476,10 @@ var ReflowableView = function(options, reader){ } try { - lastPageIndex = _navigationLogic.getPageForElementCfi(pageRequest.lastVisibleCfi, + lastPageIndex = _navigationLogic.getPageIndexDeltaForCfi(pageRequest.lastVisibleCfi, _cfiClassBlacklist, _cfiElementBlacklist, _cfiIdBlacklist); - - if (lastPageIndex < 0) lastPageIndex = 0; } catch (e) { @@ -469,17 +487,15 @@ var ReflowableView = function(options, reader){ console.error(e); } // Go to the page in the middle of the two elements - pageIndex = Math.round((firstPageIndex + lastPageIndex) / 2); + pageIndex = _paginationInfo.currentPageIndex + Math.round((firstPageIndex + lastPageIndex) / 2); } else if(pageRequest.elementCfi) { try { - pageIndex = _navigationLogic.getPageForElementCfi(pageRequest.elementCfi, + pageIndex = _paginationInfo.currentPageIndex + _navigationLogic.getPageIndexDeltaForCfi(pageRequest.elementCfi, _cfiClassBlacklist, _cfiElementBlacklist, _cfiIdBlacklist); - - if (pageIndex < 0) pageIndex = 0; } catch (e) { @@ -498,18 +514,20 @@ var ReflowableView = function(options, reader){ pageIndex = 0; } - if(pageIndex >= 0 && pageIndex < _paginationInfo.columnCount) { - _paginationInfo.currentSpreadIndex = Math.floor(pageIndex / _paginationInfo.visibleColumnCount) ; - onPaginationChanged(pageRequest.initiator, pageRequest.spineItem, pageRequest.elementId); - } - else { + if (pageIndex < 0 || pageIndex > _paginationInfo.columnCount) { console.log('Illegal pageIndex value: ', pageIndex, 'column count is ', _paginationInfo.columnCount); + pageIndex = pageIndex < 0 ? 0 : _paginationInfo.columnCount; } - }; + + _paginationInfo.currentPageIndex = pageIndex; + _paginationInfo.currentSpreadIndex = Math.floor(pageIndex / _paginationInfo.visibleColumnCount) ; + onPaginationChanged(pageRequest.initiator, pageRequest.spineItem, pageRequest.elementId); + return true; + } this.openPage = function(pageRequest) { // Go to request page, it will save the new position in onPaginationChanged - this.openPageInternal(pageRequest); + _openPageInternal(pageRequest); // Save it for when pagination is updated _lastPageRequest = pageRequest; }; @@ -533,7 +551,7 @@ var ReflowableView = function(options, reader){ this.restoreCurrentPosition = function() { if (_lastPageRequest) { - this.openPageInternal(_lastPageRequest); + _openPageInternal(_lastPageRequest); } }; @@ -578,7 +596,7 @@ var ReflowableView = function(options, reader){ } function onPaginationChanged_(initiator, paginationRequest_spineItem, paginationRequest_elementId) { - + _paginationInfo.currentPageIndex = _paginationInfo.currentSpreadIndex * _paginationInfo.visibleColumnCount; _paginationInfo.pageOffset = (_paginationInfo.columnWidth + _paginationInfo.columnGap) * _paginationInfo.visibleColumnCount * _paginationInfo.currentSpreadIndex; redraw(); @@ -839,6 +857,7 @@ var ReflowableView = function(options, reader){ if (_lastPageRequest) { // Make sure we stay on the same page after the content or the viewport // has been resized + _paginationInfo.currentPageIndex = 0; // current page index is not stable, reset it self.restoreCurrentPosition(); } else { onPaginationChanged(self); // => redraw() => showBook(), so the trick below is not needed @@ -1150,6 +1169,14 @@ var ReflowableView = function(options, reader){ return createBookmarkFromCfi(_navigationLogic.getLastVisibleCfi()); }; + this.getStartCfi = function () { + return createBookmarkFromCfi(_navigationLogic.getStartCfi()); + }; + + this.getEndCfi = function () { + return createBookmarkFromCfi(_navigationLogic.getEndCfi()); + }; + this.getDomRangeFromRangeCfi = function (rangeCfi, rangeCfi2, inclusive) { if (rangeCfi2 && rangeCfi.idref !== rangeCfi2.idref) { console.error("getDomRangeFromRangeCfi: both CFIs must be scoped under the same spineitem idref"); @@ -1177,6 +1204,10 @@ var ReflowableView = function(options, reader){ this.getElementFromPoint = function(x, y) { return _navigationLogic.getElementFromPoint(x,y); }; + + this.getNearestCfiFromElement = function(element) { + return createBookmarkFromCfi(_navigationLogic.getNearestCfiFromElement(element)); + }; }; return ReflowableView; }); diff --git a/js/views/scroll_view.js b/js/views/scroll_view.js index a2613ea14..cba61cb5c 100644 --- a/js/views/scroll_view.js +++ b/js/views/scroll_view.js @@ -1354,37 +1354,24 @@ var ScrollView = function (options, isContinuousScroll, reader) { function getFirstOrLastVisibleCfi(pickerFunc) { var pageViews = getVisiblePageViews(); var selectedPageView = pickerFunc(pageViews); - var pageViewTopOffset = selectedPageView.element().position().top; + var pageViewTopOffset =selectedPageView.element().position().top; var visibleContentOffsets, frameDimensions; - - var setupFunctions = [ - function () { - visibleContentOffsets = { - top: pageViewTopOffset, - left: 0 - }; - }, - function() { - var height = selectedPageView.element().height(); - - if (pageViewTopOffset >= 0) { - height = viewHeight() - pageViewTopOffset; - } - frameDimensions = { - width: selectedPageView.element().width(), - height: height - }; - - visibleContentOffsets = { - top: 0, - left: 0 - }; - } - ]; + visibleContentOffsets = { + top: Math.min(0, pageViewTopOffset), + left: 0 + }; + + var height = Math.min(selectedPageView.element().height(), viewHeight()); + + if (pageViewTopOffset >= 0) { + height = height - pageViewTopOffset; + } - //invoke setup function - pickerFunc(setupFunctions)(); + frameDimensions = { + width: selectedPageView.element().width(), + height: height + }; var cfiFunctions = [ selectedPageView.getFirstVisibleCfi, @@ -1420,6 +1407,10 @@ var ScrollView = function (options, isContinuousScroll, reader) { }); }; + function createBookmarkFromCfi(currentSpineItem, cfi){ + return new BookmarkData(currentSpineItem.idref, cfi); + } + this.getRangeCfiFromDomRange = function (domRange) { return callOnVisiblePageView(function (pageView) { return pageView.getRangeCfiFromDomRange(domRange); @@ -1428,20 +1419,20 @@ var ScrollView = function (options, isContinuousScroll, reader) { this.getVisibleCfiFromPoint = function (x, y, precisePoint) { return callOnVisiblePageView(function (pageView) { - return createBookmark(pageView.currentSpineItem(), pageView.getVisibleCfiFromPoint(x, y, precisePoint)); + return createBookmarkFromCfi(pageView.currentSpineItem(), pageView.getVisibleCfiFromPoint(x, y, precisePoint)); }); }; this.getRangeCfiFromPoints = function (startX, startY, endX, endY) { return callOnVisiblePageView(function (pageView) { - return createBookmark(pageView.currentSpineItem(), pageView.getRangeCfiFromPoints(startX, startY, endX, endY)); + return createBookmarkFromCfi(pageView.currentSpineItem(), pageView.getRangeCfiFromPoints(startX, startY, endX, endY)); }); }; this.getCfiForElement = function(element) { return callOnVisiblePageView(function (pageView) { - return createBookmark(pageView.currentSpineItem(), pageView.getCfiForElement(element)); - }); + return createBookmarkFromCfi(pageView.currentSpineItem(), pageView.getCfiForElement(element).contentCFI); + }) }; this.getElementFromPoint = function (x, y) { @@ -1449,6 +1440,24 @@ var ScrollView = function (options, isContinuousScroll, reader) { return pageView.getElementFromPoint(x, y); }); }; + + this.getStartCfi = function () { + return callOnVisiblePageView(function (pageView) { + return pageView.getStartCfi(); + }); + }; + + this.getEndCfi = function () { + return callOnVisiblePageView(function (pageView) { + return pageView.getEndCfi(); + }); + }; + + this.getNearestCfiFromElement = function (element) { + return callOnVisiblePageView(function (pageView) { + return pageView.getNearestCfiFromElement(element); + }); + }; }; return ScrollView; diff --git a/plugins/highlights/controller.js b/plugins/highlights/controller.js index a2a0aef38..45ee594a4 100644 --- a/plugins/highlights/controller.js +++ b/plugins/highlights/controller.js @@ -53,9 +53,7 @@ function($, _, Class, HighlightHelpers, HighlightGroup) { redraw: function() { var that = this; - var leftAddition = -this._getPaginationLeftOffset(); - - var isVerticalWritingMode = this.context.paginationInfo().isVerticalWritingMode; + var paginationOffsets = this._getPaginationOffsets(); var visibleCfiRange = this.getVisibleCfiRange(); @@ -75,11 +73,7 @@ function($, _, Class, HighlightHelpers, HighlightGroup) { visibleCfiRange.lastVisibleCfi.contentCFI); } highlightGroup.visible = visible; - highlightGroup.resetHighlights(that.readerBoundElement, - isVerticalWritingMode ? leftAddition : 0, - isVerticalWritingMode ? 0 : leftAddition - ); - + highlightGroup.resetHighlights(that.readerBoundElement, paginationOffsets.top, paginationOffsets.left); }); }, @@ -151,10 +145,7 @@ function($, _, Class, HighlightHelpers, HighlightGroup) { addHighlight: function(CFI, id, type, styles) { var CFIRangeInfo; var range; - var rangeStartNode; - var rangeEndNode; var selectedElements; - var leftAddition; var contentDoc = this.context.document; //get transform scale of content document @@ -208,16 +199,12 @@ function($, _, Class, HighlightHelpers, HighlightGroup) { range = null; } - leftAddition = -this._getPaginationLeftOffset(); - - var isVerticalWritingMode = this.context.paginationInfo().isVerticalWritingMode; + var paginationOffsets = this._getPaginationOffsets(); this._addHighlightHelper( CFI, id, type, styles, selectedElements, range, - startNode, endNode, - isVerticalWritingMode ? leftAddition : 0, - isVerticalWritingMode ? 0 : leftAddition - ); + startNode, endNode, paginationOffsets.top, paginationOffsets.left + ); return { selectedElements: selectedElements, @@ -429,7 +416,7 @@ function($, _, Class, HighlightHelpers, HighlightGroup) { var selectedElements = []; if (!elementType) { - var elementType = ["text"]; + elementType = ["text"]; } this._findSelectedElements( @@ -558,24 +545,31 @@ function($, _, Class, HighlightHelpers, HighlightGroup) { } }, - _getPaginationLeftOffset: function() { - - var $htmlElement = $(this.context.document.documentElement); - if (!$htmlElement || !$htmlElement.length) { - // if there is no html element, we might be dealing with a fxl with a svg spine item - return 0; + _getPaginationOffsets: function() { + if (!this.context.paginationInfo) { + return { + top: 0, + left: 0 + } + } + + var offset; + if (this.context.isRTL && !this.context.isVerticalWritingMode) { + offset = -this.context.paginationInfo.pageOffset; + } else { + offset = this.context.paginationInfo.pageOffset; } - var offsetLeftPixels = $htmlElement.css(this.context.paginationInfo().isVerticalWritingMode ? "top" : (this.context.isRTL ? "right" : "left")); - var offsetLeft = parseInt(offsetLeftPixels.replace("px", "")); - if (isNaN(offsetLeft)) { - //for fixed layouts, $htmlElement.css("left") has no numerical value - offsetLeft = 0; + if (this.context.isVerticalWritingMode) { + return { + top: offset, + left: 0 + }; } - - if (this.context.isRTL && !this.context.paginationInfo().isVerticalWritingMode) return -offsetLeft; - - return offsetLeft; + return { + top: 0, + left: offset + }; }, _injectAnnotationCSS: function(annotationCSSUrl) { diff --git a/readium-cfi-js b/readium-cfi-js index 5ba97e3a0..9bc98c968 160000 --- a/readium-cfi-js +++ b/readium-cfi-js @@ -1 +1 @@ -Subproject commit 5ba97e3a09150786d546400ecd6435114fcb4226 +Subproject commit 9bc98c9682c6cb514ff94124f8f7f7d72411b40d