/* Copyright 2024 Mozilla Foundation * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // Used to compare floats: there is no exact equality due to rounding errors. const PRECISION = 1e-1; class CaretBrowsingMode { #mainContainer; #toolBarHeight; #viewerContainer; constructor(mainContainer, viewerContainer, toolbarContainer) { this.#mainContainer = mainContainer; this.#viewerContainer = viewerContainer; this.#toolBarHeight = toolbarContainer?.getBoundingClientRect().height ?? 0; } /** * Return true if the two rectangles are on the same line. * @param {DOMRect} rect1 * @param {DOMRect} rect2 * @returns {boolean} */ #isOnSameLine(rect1, rect2) { const top1 = rect1.y; const bot1 = rect1.bottom; const mid1 = rect1.y + rect1.height / 2; const top2 = rect2.y; const bot2 = rect2.bottom; const mid2 = rect2.y + rect2.height / 2; return (top1 <= mid2 && mid2 <= bot1) || (top2 <= mid1 && mid1 <= bot2); } /** * Return `true` if the rectangle is: * - under the caret when `isUp === false`. * - over the caret when `isUp === true`. * @param {DOMRect} rect * @param {number} x * @param {number} y * @param {boolean} isUp * @returns {boolean} */ #isUnderOver(rect, x, y, isUp) { const midY = rect.y + rect.height / 2; return ( (isUp ? y >= midY : y <= midY) && rect.x - PRECISION <= x && x <= rect.right + PRECISION ); } /** * Check if the rectangle is visible. * @param {DOMRect} rect * @returns {boolean} */ #isVisible(rect) { return ( rect.top >= this.#toolBarHeight && rect.left >= 0 && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && rect.right <= (window.innerWidth || document.documentElement.clientWidth) ); } /** * Get the position of the caret. * @param {Selection} selection * @param {boolean} isUp * @returns {Array} */ #getCaretPosition(selection, isUp) { const { focusNode, focusOffset } = selection; const range = document.createRange(); range.setStart(focusNode, focusOffset); range.setEnd(focusNode, focusOffset); const rect = range.getBoundingClientRect(); return [rect.x, isUp ? rect.top : rect.bottom]; } static #caretPositionFromPoint(x, y) { if ( (typeof PDFJSDev === "undefined" || !PDFJSDev.test("MOZCENTRAL")) && !document.caretPositionFromPoint ) { const { startContainer: offsetNode, startOffset: offset } = document.caretRangeFromPoint(x, y); return { offsetNode, offset }; } return document.caretPositionFromPoint(x, y); } #setCaretPositionHelper(selection, caretX, select, element, rect) { rect ||= element.getBoundingClientRect(); if (caretX <= rect.x + PRECISION) { if (select) { selection.extend(element.firstChild, 0); } else { selection.setPosition(element.firstChild, 0); } return; } if (rect.right - PRECISION <= caretX) { const { lastChild } = element; if (select) { selection.extend(lastChild, lastChild.length); } else { selection.setPosition(lastChild, lastChild.length); } return; } const midY = rect.y + rect.height / 2; let caretPosition = CaretBrowsingMode.#caretPositionFromPoint(caretX, midY); let parentElement = caretPosition.offsetNode?.parentElement; if (parentElement && parentElement !== element) { // There is an element on top of the one in the text layer, so we // need to hide all the elements (except the one in the text layer) // at this position in order to get the correct caret position. const elementsAtPoint = document.elementsFromPoint(caretX, midY); const savedVisibilities = []; for (const el of elementsAtPoint) { if (el === element) { break; } const { style } = el; savedVisibilities.push([el, style.visibility]); style.visibility = "hidden"; } caretPosition = CaretBrowsingMode.#caretPositionFromPoint(caretX, midY); parentElement = caretPosition.offsetNode?.parentElement; for (const [el, visibility] of savedVisibilities) { el.style.visibility = visibility; } } if (parentElement !== element) { // The element targeted by caretPositionFromPoint isn't in the text // layer. if (select) { selection.extend(element.firstChild, 0); } else { selection.setPosition(element.firstChild, 0); } return; } if (select) { selection.extend(caretPosition.offsetNode, caretPosition.offset); } else { selection.setPosition(caretPosition.offsetNode, caretPosition.offset); } } /** * Set the caret position or extend the selection (it depends on the select * parameter). * @param {boolean} select * @param {Selection} selection * @param {Element} newLineElement * @param {DOMRect} newLineElementRect * @param {number} caretX */ #setCaretPosition( select, selection, newLineElement, newLineElementRect, caretX ) { if (this.#isVisible(newLineElementRect)) { this.#setCaretPositionHelper( selection, caretX, select, newLineElement, newLineElementRect ); return; } this.#mainContainer.addEventListener( "scrollend", this.#setCaretPositionHelper.bind( this, selection, caretX, select, newLineElement, null ), { once: true } ); newLineElement.scrollIntoView(); } /** * Get the node on the next page. * @param {Element} textLayer * @param {boolean} isUp * @returns {Node} */ #getNodeOnNextPage(textLayer, isUp) { while (true) { const page = textLayer.closest(".page"); const pageNumber = parseInt(page.getAttribute("data-page-number")); const nextPage = isUp ? pageNumber - 1 : pageNumber + 1; textLayer = this.#viewerContainer.querySelector( `.page[data-page-number="${nextPage}"] .textLayer` ); if (!textLayer) { return null; } const walker = document.createTreeWalker(textLayer, NodeFilter.SHOW_TEXT); const node = isUp ? walker.lastChild() : walker.firstChild(); if (node) { return node; } } } /** * Move the caret in the given direction. * @param {boolean} isUp * @param {boolean} select */ moveCaret(isUp, select) { const selection = document.getSelection(); if (selection.rangeCount === 0) { return; } const { focusNode } = selection; const focusElement = focusNode.nodeType !== Node.ELEMENT_NODE ? focusNode.parentElement : focusNode; const root = focusElement.closest(".textLayer"); if (!root) { return; } const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); walker.currentNode = focusNode; // Move to the next element which is not on the same line as the focus // element. const focusRect = focusElement.getBoundingClientRect(); let newLineElement = null; const nodeIterator = ( isUp ? walker.previousSibling : walker.nextSibling ).bind(walker); while (nodeIterator()) { const element = walker.currentNode.parentElement; if (!this.#isOnSameLine(focusRect, element.getBoundingClientRect())) { newLineElement = element; break; } } if (!newLineElement) { // Need to find the next line on the next page. const node = this.#getNodeOnNextPage(root, isUp); if (!node) { return; } if (select) { const lastNode = (isUp ? walker.firstChild() : walker.lastChild()) || focusNode; selection.extend(lastNode, isUp ? 0 : lastNode.length); const range = document.createRange(); range.setStart(node, isUp ? node.length : 0); range.setEnd(node, isUp ? node.length : 0); selection.addRange(range); return; } const [caretX] = this.#getCaretPosition(selection, isUp); const { parentElement } = node; this.#setCaretPosition( select, selection, parentElement, parentElement.getBoundingClientRect(), caretX ); return; } // We've a candidate for the next line now we want to find the first element // which is under/over the caret. const [caretX, caretY] = this.#getCaretPosition(selection, isUp); const newLineElementRect = newLineElement.getBoundingClientRect(); // Maybe the element on the new line is a valid candidate. if (this.#isUnderOver(newLineElementRect, caretX, caretY, isUp)) { this.#setCaretPosition( select, selection, newLineElement, newLineElementRect, caretX ); return; } while (nodeIterator()) { // Search an element on the same line as newLineElement // which could be under/over the caret. const element = walker.currentNode.parentElement; const elementRect = element.getBoundingClientRect(); if (!this.#isOnSameLine(newLineElementRect, elementRect)) { break; } if (this.#isUnderOver(elementRect, caretX, caretY, isUp)) { // We found the element. this.#setCaretPosition(select, selection, element, elementRect, caretX); return; } } // No element has been found so just put the caret on the element on the new // line. this.#setCaretPosition( select, selection, newLineElement, newLineElementRect, caretX ); } } export { CaretBrowsingMode };