From 7a74c3c31ece7788e82c46f22cb4327ffe08307a Mon Sep 17 00:00:00 2001 From: toasted-nutbread Date: Sat, 13 Feb 2021 23:13:53 -0500 Subject: Move fg/js (#1384) * Move fg/js/frame-ancestry-handler.js to js/comm/frame-ancestry-handler.js * Move fg/js/frame-offset-forwarder.js to js/comm/frame-offset-forwarder.js * Move fg/js/dom-text-scanner.js to js/dom/dom-text-scanner.js * Move fg/js/text-source-element.js to js/dom/text-source-element.js * Move fg/js/text-source-range.js to js/dom/text-source-range.js * Move fg/js/float-main.js to js/display/popup-main.js * Move fg/js/content-script-main.js to js/app/content-script-main.js * Move fg/js/frontend.js to js/app/frontend.js * Move fg/js/popup-factory.js to js/app/popup-factory.js * Move fg/js/popup-proxy.js to js/app/popup-proxy.js * Move fg/js/popup-window.js to js/app/popup-window.js * Move fg/js/popup.js to js/app/popup.js --- ext/fg/js/content-script-main.js | 59 --- ext/fg/js/dom-text-scanner.js | 551 --------------------------- ext/fg/js/float-main.js | 56 --- ext/fg/js/frame-ancestry-handler.js | 269 ------------- ext/fg/js/frame-offset-forwarder.js | 70 ---- ext/fg/js/frontend.js | 691 ---------------------------------- ext/fg/js/popup-factory.js | 319 ---------------- ext/fg/js/popup-proxy.js | 218 ----------- ext/fg/js/popup-window.js | 169 --------- ext/fg/js/popup.js | 687 --------------------------------- ext/fg/js/text-source-element.js | 139 ------- ext/fg/js/text-source-range.js | 170 --------- ext/js/app/content-script-main.js | 59 +++ ext/js/app/frontend.js | 691 ++++++++++++++++++++++++++++++++++ ext/js/app/popup-factory.js | 319 ++++++++++++++++ ext/js/app/popup-proxy.js | 218 +++++++++++ ext/js/app/popup-window.js | 169 +++++++++ ext/js/app/popup.js | 687 +++++++++++++++++++++++++++++++++ ext/js/comm/frame-ancestry-handler.js | 269 +++++++++++++ ext/js/comm/frame-offset-forwarder.js | 70 ++++ ext/js/display/display.js | 14 +- ext/js/display/popup-main.js | 56 +++ ext/js/dom/dom-text-scanner.js | 551 +++++++++++++++++++++++++++ ext/js/dom/text-source-element.js | 139 +++++++ ext/js/dom/text-source-range.js | 170 +++++++++ ext/manifest.json | 22 +- ext/popup-preview.html | 16 +- ext/popup.html | 8 +- ext/search.html | 6 +- 29 files changed, 3431 insertions(+), 3431 deletions(-) delete mode 100644 ext/fg/js/content-script-main.js delete mode 100644 ext/fg/js/dom-text-scanner.js delete mode 100644 ext/fg/js/float-main.js delete mode 100644 ext/fg/js/frame-ancestry-handler.js delete mode 100644 ext/fg/js/frame-offset-forwarder.js delete mode 100644 ext/fg/js/frontend.js delete mode 100644 ext/fg/js/popup-factory.js delete mode 100644 ext/fg/js/popup-proxy.js delete mode 100644 ext/fg/js/popup-window.js delete mode 100644 ext/fg/js/popup.js delete mode 100644 ext/fg/js/text-source-element.js delete mode 100644 ext/fg/js/text-source-range.js create mode 100644 ext/js/app/content-script-main.js create mode 100644 ext/js/app/frontend.js create mode 100644 ext/js/app/popup-factory.js create mode 100644 ext/js/app/popup-proxy.js create mode 100644 ext/js/app/popup-window.js create mode 100644 ext/js/app/popup.js create mode 100644 ext/js/comm/frame-ancestry-handler.js create mode 100644 ext/js/comm/frame-offset-forwarder.js create mode 100644 ext/js/display/popup-main.js create mode 100644 ext/js/dom/dom-text-scanner.js create mode 100644 ext/js/dom/text-source-element.js create mode 100644 ext/js/dom/text-source-range.js (limited to 'ext') diff --git a/ext/fg/js/content-script-main.js b/ext/fg/js/content-script-main.js deleted file mode 100644 index 5dee4c56..00000000 --- a/ext/fg/js/content-script-main.js +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright (C) 2019-2021 Yomichan Authors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -/* global - * Frontend - * HotkeyHandler - * PopupFactory - * api - */ - -(async () => { - try { - api.forwardLogsToBackend(); - await yomichan.backendReady(); - - const {tabId, frameId} = await api.frameInformationGet(); - if (typeof frameId !== 'number') { - throw new Error('Failed to get frameId'); - } - - const hotkeyHandler = new HotkeyHandler(); - hotkeyHandler.prepare(); - - const popupFactory = new PopupFactory(frameId); - popupFactory.prepare(); - - const frontend = new Frontend({ - tabId, - frameId, - popupFactory, - depth: 0, - parentPopupId: null, - parentFrameId: null, - useProxyPopup: false, - pageType: 'web', - allowRootFramePopupProxy: true, - hotkeyHandler - }); - await frontend.prepare(); - - yomichan.ready(); - } catch (e) { - yomichan.logError(e); - } -})(); diff --git a/ext/fg/js/dom-text-scanner.js b/ext/fg/js/dom-text-scanner.js deleted file mode 100644 index 71e74fc3..00000000 --- a/ext/fg/js/dom-text-scanner.js +++ /dev/null @@ -1,551 +0,0 @@ -/* - * Copyright (C) 2020-2021 Yomichan Authors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -/** - * A class used to scan text in a document. - */ -class DOMTextScanner { - /** - * Creates a new instance of a DOMTextScanner. - * @param node The DOM Node to start at. - * @param offset The character offset in to start at when node is a text node. - * Use 0 for non-text nodes. - */ - constructor(node, offset, forcePreserveWhitespace=false, generateLayoutContent=true) { - const ruby = DOMTextScanner.getParentRubyElement(node); - const resetOffset = (ruby !== null); - if (resetOffset) { node = ruby; } - - this._node = node; - this._offset = offset; - this._content = ''; - this._remainder = 0; - this._resetOffset = resetOffset; - this._newlines = 0; - this._lineHasWhitespace = false; - this._lineHasContent = false; - this._forcePreserveWhitespace = forcePreserveWhitespace; - this._generateLayoutContent = generateLayoutContent; - } - - /** - * Gets the current node being scanned. - * @returns A DOM Node. - */ - get node() { - return this._node; - } - - /** - * Gets the current offset corresponding to the node being scanned. - * This value is only applicable for text nodes. - * @returns An integer. - */ - get offset() { - return this._offset; - } - - /** - * Gets the remaining number of characters that weren't scanned in the last seek() call. - * This value is usually 0 unless the end of the document was reached. - * @returns An integer. - */ - get remainder() { - return this._remainder; - } - - /** - * Gets the accumulated content string resulting from calls to seek(). - * @returns A string. - */ - get content() { - return this._content; - } - - /** - * Seeks a given length in the document and accumulates the text content. - * @param length A positive or negative integer corresponding to how many characters - * should be added to content. Content is only added to the accumulation string, - * never removed, so mixing seek calls with differently signed length values - * may give unexpected results. - * @returns this - */ - seek(length) { - const forward = (length >= 0); - this._remainder = (forward ? length : -length); - if (length === 0) { return this; } - - const TEXT_NODE = Node.TEXT_NODE; - const ELEMENT_NODE = Node.ELEMENT_NODE; - - const generateLayoutContent = this._generateLayoutContent; - let node = this._node; - let lastNode = node; - let resetOffset = this._resetOffset; - let newlines = 0; - while (node !== null) { - let enterable = false; - const nodeType = node.nodeType; - - if (nodeType === TEXT_NODE) { - lastNode = node; - if (!( - forward ? - this._seekTextNodeForward(node, resetOffset) : - this._seekTextNodeBackward(node, resetOffset) - )) { - // Length reached - break; - } - } else if (nodeType === ELEMENT_NODE) { - lastNode = node; - this._offset = 0; - [enterable, newlines] = DOMTextScanner.getElementSeekInfo(node); - if (newlines > this._newlines && generateLayoutContent) { - this._newlines = newlines; - } - } - - const exitedNodes = []; - node = DOMTextScanner.getNextNode(node, forward, enterable, exitedNodes); - - for (const exitedNode of exitedNodes) { - if (exitedNode.nodeType !== ELEMENT_NODE) { continue; } - newlines = DOMTextScanner.getElementSeekInfo(exitedNode)[1]; - if (newlines > this._newlines && generateLayoutContent) { - this._newlines = newlines; - } - } - - resetOffset = true; - } - - this._node = lastNode; - this._resetOffset = resetOffset; - - return this; - } - - // Private - - /** - * Seeks forward in a text node. - * @param textNode The text node to use. - * @param resetOffset Whether or not the text offset should be reset. - * @returns true if scanning should continue, or false if the scan length has been reached. - */ - _seekTextNodeForward(textNode, resetOffset) { - const nodeValue = textNode.nodeValue; - const nodeValueLength = nodeValue.length; - const [preserveNewlines, preserveWhitespace] = ( - this._forcePreserveWhitespace ? - [true, true] : - DOMTextScanner.getWhitespaceSettings(textNode) - ); - - let lineHasWhitespace = this._lineHasWhitespace; - let lineHasContent = this._lineHasContent; - let content = this._content; - let offset = resetOffset ? 0 : this._offset; - let remainder = this._remainder; - let newlines = this._newlines; - - while (offset < nodeValueLength) { - const char = nodeValue[offset]; - const charAttributes = DOMTextScanner.getCharacterAttributes(char, preserveNewlines, preserveWhitespace); - ++offset; - - if (charAttributes === 0) { - // Character should be ignored - continue; - } else if (charAttributes === 1) { - // Character is collapsable whitespace - lineHasWhitespace = true; - } else { - // Character should be added to the content - if (newlines > 0) { - if (content.length > 0) { - const useNewlineCount = Math.min(remainder, newlines); - content += '\n'.repeat(useNewlineCount); - remainder -= useNewlineCount; - newlines -= useNewlineCount; - } else { - newlines = 0; - } - lineHasContent = false; - lineHasWhitespace = false; - if (remainder <= 0) { - --offset; // Revert character offset - break; - } - } - - lineHasContent = (charAttributes === 2); // 3 = character is a newline - - if (lineHasWhitespace) { - if (lineHasContent) { - content += ' '; - lineHasWhitespace = false; - if (--remainder <= 0) { - --offset; // Revert character offset - break; - } - } else { - lineHasWhitespace = false; - } - } - - content += char; - - if (--remainder <= 0) { break; } - } - } - - this._lineHasWhitespace = lineHasWhitespace; - this._lineHasContent = lineHasContent; - this._content = content; - this._offset = offset; - this._remainder = remainder; - this._newlines = newlines; - - return (remainder > 0); - } - - /** - * Seeks backward in a text node. - * This function is nearly the same as _seekTextNodeForward, with the following differences: - * - Iteration condition is reversed to check if offset is greater than 0. - * - offset is reset to nodeValueLength instead of 0. - * - offset is decremented instead of incremented. - * - offset is decremented before getting the character. - * - offset is reverted by incrementing instead of decrementing. - * - content string is prepended instead of appended. - * @param textNode The text node to use. - * @param resetOffset Whether or not the text offset should be reset. - * @returns true if scanning should continue, or false if the scan length has been reached. - */ - _seekTextNodeBackward(textNode, resetOffset) { - const nodeValue = textNode.nodeValue; - const nodeValueLength = nodeValue.length; - const [preserveNewlines, preserveWhitespace] = ( - this._forcePreserveWhitespace ? - [true, true] : - DOMTextScanner.getWhitespaceSettings(textNode) - ); - - let lineHasWhitespace = this._lineHasWhitespace; - let lineHasContent = this._lineHasContent; - let content = this._content; - let offset = resetOffset ? nodeValueLength : this._offset; - let remainder = this._remainder; - let newlines = this._newlines; - - while (offset > 0) { - --offset; - const char = nodeValue[offset]; - const charAttributes = DOMTextScanner.getCharacterAttributes(char, preserveNewlines, preserveWhitespace); - - if (charAttributes === 0) { - // Character should be ignored - continue; - } else if (charAttributes === 1) { - // Character is collapsable whitespace - lineHasWhitespace = true; - } else { - // Character should be added to the content - if (newlines > 0) { - if (content.length > 0) { - const useNewlineCount = Math.min(remainder, newlines); - content = '\n'.repeat(useNewlineCount) + content; - remainder -= useNewlineCount; - newlines -= useNewlineCount; - } else { - newlines = 0; - } - lineHasContent = false; - lineHasWhitespace = false; - if (remainder <= 0) { - ++offset; // Revert character offset - break; - } - } - - lineHasContent = (charAttributes === 2); // 3 = character is a newline - - if (lineHasWhitespace) { - if (lineHasContent) { - content = ' ' + content; - lineHasWhitespace = false; - if (--remainder <= 0) { - ++offset; // Revert character offset - break; - } - } else { - lineHasWhitespace = false; - } - } - - content = char + content; - - if (--remainder <= 0) { break; } - } - } - - this._lineHasWhitespace = lineHasWhitespace; - this._lineHasContent = lineHasContent; - this._content = content; - this._offset = offset; - this._remainder = remainder; - this._newlines = newlines; - - return (remainder > 0); - } - - // Static helpers - - /** - * Gets the next node in the document for a specified scanning direction. - * @param node The current DOM Node. - * @param forward Whether to scan forward in the document or backward. - * @param visitChildren Whether the children of the current node should be visited. - * @param exitedNodes An array which stores nodes which were exited. - * @returns The next node in the document, or null if there is no next node. - */ - static getNextNode(node, forward, visitChildren, exitedNodes) { - let next = visitChildren ? (forward ? node.firstChild : node.lastChild) : null; - if (next === null) { - while (true) { - exitedNodes.push(node); - - next = (forward ? node.nextSibling : node.previousSibling); - if (next !== null) { break; } - - next = node.parentNode; - if (next === null) { break; } - - node = next; - } - } - return next; - } - - /** - * Gets the parent element of a given Node. - * @param node The node to check. - * @returns The parent element if one exists, otherwise null. - */ - static getParentElement(node) { - while (node !== null && node.nodeType !== Node.ELEMENT_NODE) { - node = node.parentNode; - } - return node; - } - - /** - * Gets the parent element of a given node, if one exists. For efficiency purposes, - * this only checks the immediate parent elements and does not check all ancestors, so - * there are cases where the node may be in a ruby element but it is not returned. - * @param node The node to check. - * @returns A node if the input node is contained in one, otherwise null. - */ - static getParentRubyElement(node) { - node = DOMTextScanner.getParentElement(node); - if (node !== null && node.nodeName.toUpperCase() === 'RT') { - node = node.parentNode; - if (node !== null && node.nodeName.toUpperCase() === 'RUBY') { - return node; - } - } - return null; - } - - /** - * @returns [enterable: boolean, newlines: integer] - * The enterable value indicates whether the content of this node should be entered. - * The newlines value corresponds to the number of newline characters that should be added. - * 1 newline corresponds to a simple new line in the layout. - * 2 newlines corresponds to a significant visual distinction since the previous content. - */ - static getElementSeekInfo(element) { - let enterable = true; - switch (element.nodeName.toUpperCase()) { - case 'HEAD': - case 'RT': - case 'SCRIPT': - case 'STYLE': - return [false, 0]; - case 'BR': - return [false, 1]; - case 'TEXTAREA': - case 'INPUT': - case 'BUTTON': - enterable = false; - break; - } - - const style = window.getComputedStyle(element); - const display = style.display; - - const visible = (display !== 'none' && DOMTextScanner.isStyleVisible(style)); - let newlines = 0; - - if (!visible) { - enterable = false; - } else { - switch (style.position) { - case 'absolute': - case 'fixed': - case 'sticky': - newlines = 2; - break; - } - if (newlines === 0 && DOMTextScanner.doesCSSDisplayChangeLayout(display)) { - newlines = 1; - } - } - - return [enterable, newlines]; - } - - /** - * Gets information about how whitespace characters are treated. - * @param textNode The Text node to check. - * @returns [preserveNewlines: boolean, preserveWhitespace: boolean] - * The value of preserveNewlines indicates whether or not newline characters are treated as line breaks. - * The value of preserveWhitespace indicates whether or not sequences of whitespace characters are collapsed. - */ - static getWhitespaceSettings(textNode) { - const element = DOMTextScanner.getParentElement(textNode); - if (element !== null) { - const style = window.getComputedStyle(element); - switch (style.whiteSpace) { - case 'pre': - case 'pre-wrap': - case 'break-spaces': - return [true, true]; - case 'pre-line': - return [true, false]; - } - } - return [false, false]; - } - - /** - * Gets attributes for the specified character. - * @param character A string containing a single character. - * @returns An integer representing the attributes of the character. - * 0: Character should be ignored. - * 1: Character is collapsable whitespace. - * 2: Character should be added to the content. - * 3: Character should be added to the content and is a newline. - */ - static getCharacterAttributes(character, preserveNewlines, preserveWhitespace) { - switch (character.charCodeAt(0)) { - case 0x09: // Tab ('\t') - case 0x0c: // Form feed ('\f') - case 0x0d: // Carriage return ('\r') - case 0x20: // Space (' ') - return preserveWhitespace ? 2 : 1; - case 0x0a: // Line feed ('\n') - return preserveNewlines ? 3 : 1; - case 0x200c: // Zero-width non-joiner ('\u200c') - return 0; - default: // Other - return 2; - } - } - - /** - * Checks whether a given style is visible or not. - * This function does not check style.display === 'none'. - * @param style An object implementing the CSSStyleDeclaration interface. - * @returns true if the style should result in an element being visible, otherwise false. - */ - static isStyleVisible(style) { - return !( - style.visibility === 'hidden' || - parseFloat(style.opacity) <= 0 || - parseFloat(style.fontSize) <= 0 || - ( - !DOMTextScanner.isStyleSelectable(style) && - ( - DOMTextScanner.isCSSColorTransparent(style.color) || - DOMTextScanner.isCSSColorTransparent(style.webkitTextFillColor) - ) - ) - ); - } - - /** - * Checks whether a given style is selectable or not. - * @param style An object implementing the CSSStyleDeclaration interface. - * @returns true if the style is selectable, otherwise false. - */ - static isStyleSelectable(style) { - return !( - style.userSelect === 'none' || - style.webkitUserSelect === 'none' || - style.MozUserSelect === 'none' || - style.msUserSelect === 'none' - ); - } - - /** - * Checks whether a CSS color is transparent or not. - * @param cssColor A CSS color string, expected to be encoded in rgb(a) form. - * @returns true if the color is transparent, otherwise false. - */ - static isCSSColorTransparent(cssColor) { - return ( - typeof cssColor === 'string' && - cssColor.startsWith('rgba(') && - /,\s*0.?0*\)$/.test(cssColor) - ); - } - - /** - * Checks whether a CSS display value will cause a layout change for text. - * @param cssDisplay A CSS string corresponding to the value of the display property. - * @returns true if the layout is changed by this value, otherwise false. - */ - static doesCSSDisplayChangeLayout(cssDisplay) { - let pos = cssDisplay.indexOf(' '); - if (pos >= 0) { - // Truncate to part - cssDisplay = cssDisplay.substring(0, pos); - } - - pos = cssDisplay.indexOf('-'); - if (pos >= 0) { - // Truncate to first part of kebab-case value - cssDisplay = cssDisplay.substring(0, pos); - } - - switch (cssDisplay) { - case 'block': - case 'flex': - case 'grid': - case 'list': // list-item - case 'table': // table, table-* - return true; - case 'ruby': // rubt-* - return (pos >= 0); - default: - return false; - } - } -} diff --git a/ext/fg/js/float-main.js b/ext/fg/js/float-main.js deleted file mode 100644 index 7c048b62..00000000 --- a/ext/fg/js/float-main.js +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright (C) 2020-2021 Yomichan Authors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -/* global - * Display - * DisplayProfileSelection - * DocumentFocusController - * HotkeyHandler - * JapaneseUtil - * api - */ - -(async () => { - try { - const documentFocusController = new DocumentFocusController(); - documentFocusController.prepare(); - - api.forwardLogsToBackend(); - await yomichan.backendReady(); - - const {tabId, frameId} = await api.frameInformationGet(); - - const japaneseUtil = new JapaneseUtil(null); - - const hotkeyHandler = new HotkeyHandler(); - hotkeyHandler.prepare(); - - const display = new Display(tabId, frameId, 'popup', japaneseUtil, documentFocusController, hotkeyHandler); - await display.prepare(); - - const displayProfileSelection = new DisplayProfileSelection(display); - displayProfileSelection.prepare(); - - display.initializeState(); - - document.documentElement.dataset.loaded = 'true'; - - yomichan.ready(); - } catch (e) { - yomichan.logError(e); - } -})(); diff --git a/ext/fg/js/frame-ancestry-handler.js b/ext/fg/js/frame-ancestry-handler.js deleted file mode 100644 index b1ed7114..00000000 --- a/ext/fg/js/frame-ancestry-handler.js +++ /dev/null @@ -1,269 +0,0 @@ -/* - * Copyright (C) 2021 Yomichan Authors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -/* global - * api - */ - -/** - * This class is used to return the ancestor frame IDs for the current frame. - * This is a workaround to using the `webNavigation.getAllFrames` API, which - * would require an additional permission that is otherwise unnecessary. - * It is also used to track the correlation between child frame elements and their IDs. - */ -class FrameAncestryHandler { - /** - * Creates a new instance. - * @param frameId The frame ID of the current frame the instance is instantiated in. - */ - constructor(frameId) { - this._frameId = frameId; - this._isPrepared = false; - this._requestMessageId = 'FrameAncestryHandler.requestFrameInfo'; - this._responseMessageIdBase = `${this._requestMessageId}.response.`; - this._getFrameAncestryInfoPromise = null; - this._childFrameMap = new Map(); - } - - /** - * Gets the frame ID that the instance is instantiated in. - */ - get frameId() { - return this._frameId; - } - - /** - * Initializes event event listening. - */ - prepare() { - if (this._isPrepared) { return; } - window.addEventListener('message', this._onWindowMessage.bind(this), false); - this._isPrepared = true; - } - - /** - * Returns whether or not this frame is the root frame in the tab. - * @returns `true` if it is the root, otherwise `false`. - */ - isRootFrame() { - return (window === window.parent); - } - - /** - * Gets the frame ancestry information for the current frame. If the frame is the - * root frame, an empty array is returned. Otherwise, an array of frame IDs is returned, - * starting from the nearest ancestor. - * @param timeout The maximum time to wait to receive a response to frame information requests. - * @returns An array of frame IDs corresponding to the ancestors of the current frame. - */ - async getFrameAncestryInfo() { - if (this._getFrameAncestryInfoPromise === null) { - this._getFrameAncestryInfoPromise = this._getFrameAncestryInfo(5000); - } - return await this._getFrameAncestryInfoPromise; - } - - /** - * Gets the frame element of a child frame given a frame ID. - * For this function to work, the `getFrameAncestryInfo` function needs to have - * been invoked previously. - * @param frameId The frame ID of the child frame to get. - * @returns The element corresponding to the frame with ID `frameId`, otherwise `null`. - */ - getChildFrameElement(frameId) { - const frameInfo = this._childFrameMap.get(frameId); - if (typeof frameInfo === 'undefined') { return null; } - - let {frameElement} = frameInfo; - if (typeof frameElement === 'undefined') { - frameElement = this._findFrameElementWithContentWindow(frameInfo.window); - frameInfo.frameElement = frameElement; - } - - return frameElement; - } - - // Private - - _getFrameAncestryInfo(timeout=5000) { - return new Promise((resolve, reject) => { - const targetWindow = window.parent; - if (window === targetWindow) { - resolve([]); - return; - } - - const uniqueId = generateId(16); - let nonce = generateId(16); - const responseMessageId = `${this._responseMessageIdBase}${uniqueId}`; - const results = []; - let timer = null; - - const cleanup = () => { - if (timer !== null) { - clearTimeout(timer); - timer = null; - } - api.crossFrame.unregisterHandler(responseMessageId); - }; - const onMessage = (params) => { - if (params.nonce !== nonce) { return null; } - - // Add result - const {frameId, more} = params; - results.push(frameId); - nonce = generateId(16); - - if (!more) { - // Cleanup - cleanup(); - - // Finish - resolve(results); - } - return {nonce}; - }; - const onTimeout = () => { - timer = null; - cleanup(); - reject(new Error(`Request for parent frame ID timed out after ${timeout}ms`)); - }; - const resetTimeout = () => { - if (timer !== null) { clearTimeout(timer); } - timer = setTimeout(onTimeout, timeout); - }; - - // Start - api.crossFrame.registerHandlers([[responseMessageId, {async: false, handler: onMessage}]]); - resetTimeout(); - const frameId = this._frameId; - this._requestFrameInfo(targetWindow, frameId, frameId, uniqueId, nonce); - }); - } - - _onWindowMessage(event) { - const {source} = event; - if (source === window || source.parent !== window) { return; } - - const {data} = event; - if ( - typeof data === 'object' && - data !== null && - data.action === this._requestMessageId - ) { - this._onRequestFrameInfo(data.params, source); - } - } - - async _onRequestFrameInfo(params, source) { - try { - let {originFrameId, childFrameId, uniqueId, nonce} = params; - if ( - !this._isNonNegativeInteger(originFrameId) || - typeof uniqueId !== 'string' || - typeof nonce !== 'string' - ) { - return; - } - - const frameId = this._frameId; - const {parent} = window; - const more = (window !== parent); - const responseParams = {frameId, nonce, more}; - const responseMessageId = `${this._responseMessageIdBase}${uniqueId}`; - - try { - const response = await api.crossFrame.invoke(originFrameId, responseMessageId, responseParams); - if (response === null) { return; } - nonce = response.nonce; - } catch (e) { - return; - } - - if (!this._childFrameMap.has(childFrameId)) { - this._childFrameMap.set(childFrameId, {window: source, frameElement: void 0}); - } - - if (more) { - this._requestFrameInfo(parent, originFrameId, frameId, uniqueId, nonce); - } - } catch (e) { - // NOP - } - } - - _requestFrameInfo(targetWindow, originFrameId, childFrameId, uniqueId, nonce) { - targetWindow.postMessage({ - action: this._requestMessageId, - params: {originFrameId, childFrameId, uniqueId, nonce} - }, '*'); - } - - _isNonNegativeInteger(value) { - return ( - typeof value === 'number' && - Number.isFinite(value) && - value >= 0 && - Math.floor(value) === value - ); - } - - _findFrameElementWithContentWindow(contentWindow) { - // Check frameElement, for non-null same-origin frames - try { - const {frameElement} = contentWindow; - if (frameElement !== null) { return frameElement; } - } catch (e) { - // NOP - } - - // Check frames - const frameTypes = ['iframe', 'frame', 'embed']; - for (const frameType of frameTypes) { - for (const frame of document.getElementsByTagName(frameType)) { - if (frame.contentWindow === contentWindow) { - return frame; - } - } - } - - // Check for shadow roots - const rootElements = [document.documentElement]; - while (rootElements.length > 0) { - const rootElement = rootElements.shift(); - const walker = document.createTreeWalker(rootElement, NodeFilter.SHOW_ELEMENT); - while (walker.nextNode()) { - const element = walker.currentNode; - - if (element.contentWindow === contentWindow) { - return element; - } - - const shadowRoot = ( - element.shadowRoot || - element.openOrClosedShadowRoot // Available to Firefox 63+ for WebExtensions - ); - if (shadowRoot) { - rootElements.push(shadowRoot); - } - } - } - - // Not found - return null; - } -} diff --git a/ext/fg/js/frame-offset-forwarder.js b/ext/fg/js/frame-offset-forwarder.js deleted file mode 100644 index 0a0b4a18..00000000 --- a/ext/fg/js/frame-offset-forwarder.js +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright (C) 2020-2021 Yomichan Authors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -/* global - * FrameAncestryHandler - * api - */ - -class FrameOffsetForwarder { - constructor(frameId) { - this._frameId = frameId; - this._frameAncestryHandler = new FrameAncestryHandler(frameId); - } - - prepare() { - this._frameAncestryHandler.prepare(); - api.crossFrame.registerHandlers([ - ['FrameOffsetForwarder.getChildFrameRect', {async: false, handler: this._onMessageGetChildFrameRect.bind(this)}] - ]); - } - - async getOffset() { - if (this._frameAncestryHandler.isRootFrame()) { - return [0, 0]; - } - - const ancestorFrameIds = await this._frameAncestryHandler.getFrameAncestryInfo(); - - let childFrameId = this._frameId; - const promises = []; - for (const frameId of ancestorFrameIds) { - promises.push(api.crossFrame.invoke(frameId, 'FrameOffsetForwarder.getChildFrameRect', {frameId: childFrameId})); - childFrameId = frameId; - } - - const results = await Promise.all(promises); - - let xOffset = 0; - let yOffset = 0; - for (const {x, y} of results) { - xOffset += x; - yOffset += y; - } - return [xOffset, yOffset]; - } - - // Private - - _onMessageGetChildFrameRect({frameId}) { - const frameElement = this._frameAncestryHandler.getChildFrameElement(frameId); - if (frameElement === null) { return null; } - - const {x, y, width, height} = frameElement.getBoundingClientRect(); - return {x, y, width, height}; - } -} diff --git a/ext/fg/js/frontend.js b/ext/fg/js/frontend.js deleted file mode 100644 index a62b06bf..00000000 --- a/ext/fg/js/frontend.js +++ /dev/null @@ -1,691 +0,0 @@ -/* - * Copyright (C) 2016-2021 Yomichan Authors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -/* global - * DocumentUtil - * TextScanner - * TextSourceElement - * TextSourceRange - * api - */ - -class Frontend { - constructor({ - pageType, - popupFactory, - depth, - tabId, - frameId, - parentPopupId, - parentFrameId, - useProxyPopup, - canUseWindowPopup=true, - allowRootFramePopupProxy, - childrenSupported=true, - hotkeyHandler - }) { - this._pageType = pageType; - this._popupFactory = popupFactory; - this._depth = depth; - this._tabId = tabId; - this._frameId = frameId; - this._parentPopupId = parentPopupId; - this._parentFrameId = parentFrameId; - this._useProxyPopup = useProxyPopup; - this._canUseWindowPopup = canUseWindowPopup; - this._allowRootFramePopupProxy = allowRootFramePopupProxy; - this._childrenSupported = childrenSupported; - this._hotkeyHandler = hotkeyHandler; - this._popup = null; - this._disabledOverride = false; - this._options = null; - this._pageZoomFactor = 1.0; - this._contentScale = 1.0; - this._lastShowPromise = Promise.resolve(); - this._documentUtil = new DocumentUtil(); - this._textScanner = new TextScanner({ - node: window, - ignoreElements: this._ignoreElements.bind(this), - ignorePoint: this._ignorePoint.bind(this), - getSearchContext: this._getSearchContext.bind(this), - documentUtil: this._documentUtil, - searchTerms: true, - searchKanji: true - }); - this._popupCache = new Map(); - this._popupEventListeners = new EventListenerCollection(); - this._updatePopupToken = null; - this._clearSelectionTimer = null; - this._isPointerOverPopup = false; - this._optionsContextOverride = null; - - this._runtimeMessageHandlers = new Map([ - ['requestFrontendReadyBroadcast', {async: false, handler: this._onMessageRequestFrontendReadyBroadcast.bind(this)}], - ['setAllVisibleOverride', {async: true, handler: this._onApiSetAllVisibleOverride.bind(this)}], - ['clearAllVisibleOverride', {async: true, handler: this._onApiClearAllVisibleOverride.bind(this)}] - ]); - - this._hotkeyHandler.registerActions([ - ['scanSelectedText', this._onActionScanSelectedText.bind(this)] - ]); - } - - get canClearSelection() { - return this._textScanner.canClearSelection; - } - - set canClearSelection(value) { - this._textScanner.canClearSelection = value; - } - - get popup() { - return this._popup; - } - - async prepare() { - await this.updateOptions(); - try { - const {zoomFactor} = await api.getZoom(); - this._pageZoomFactor = zoomFactor; - } catch (e) { - // Ignore exceptions which may occur due to being on an unsupported page (e.g. about:blank) - } - - this._textScanner.prepare(); - - window.addEventListener('resize', this._onResize.bind(this), false); - DocumentUtil.addFullscreenChangeEventListener(this._updatePopup.bind(this)); - - const visualViewport = window.visualViewport; - if (visualViewport !== null && typeof visualViewport === 'object') { - visualViewport.addEventListener('scroll', this._onVisualViewportScroll.bind(this)); - visualViewport.addEventListener('resize', this._onVisualViewportResize.bind(this)); - } - - yomichan.on('optionsUpdated', this.updateOptions.bind(this)); - yomichan.on('zoomChanged', this._onZoomChanged.bind(this)); - yomichan.on('closePopups', this._onClosePopups.bind(this)); - chrome.runtime.onMessage.addListener(this._onRuntimeMessage.bind(this)); - - this._textScanner.on('clearSelection', this._onClearSelection.bind(this)); - this._textScanner.on('searched', this._onSearched.bind(this)); - - api.crossFrame.registerHandlers([ - ['closePopup', {async: false, handler: this._onApiClosePopup.bind(this)}], - ['copySelection', {async: false, handler: this._onApiCopySelection.bind(this)}], - ['getSelectionText', {async: false, handler: this._onApiGetSelectionText.bind(this)}], - ['getPopupInfo', {async: false, handler: this._onApiGetPopupInfo.bind(this)}], - ['getPageInfo', {async: false, handler: this._onApiGetPageInfo.bind(this)}], - ['getFrameSize', {async: true, handler: this._onApiGetFrameSize.bind(this)}], - ['setFrameSize', {async: true, handler: this._onApiSetFrameSize.bind(this)}] - ]); - - this._updateContentScale(); - this._signalFrontendReady(); - } - - setDisabledOverride(disabled) { - this._disabledOverride = disabled; - this._updateTextScannerEnabled(); - } - - setOptionsContextOverride(optionsContext) { - this._optionsContextOverride = optionsContext; - } - - async setTextSource(textSource) { - this._textScanner.setCurrentTextSource(null); - await this._textScanner.search(textSource); - } - - async updateOptions() { - try { - await this._updateOptionsInternal(); - } catch (e) { - if (!yomichan.isExtensionUnloaded) { - throw e; - } - } - } - - showContentCompleted() { - return this._lastShowPromise; - } - - // Message handlers - - _onMessageRequestFrontendReadyBroadcast({frameId}) { - this._signalFrontendReady(frameId); - } - - // Action handlers - - _onActionScanSelectedText() { - this._scanSelectedText(); - } - - // API message handlers - - _onApiGetUrl() { - return window.location.href; - } - - _onApiClosePopup() { - this._clearSelection(false); - } - - _onApiCopySelection() { - // This will not work on Firefox if a popup has focus, which is usually the case when this function is called. - document.execCommand('copy'); - } - - _onApiGetSelectionText() { - return document.getSelection().toString(); - } - - _onApiGetPopupInfo() { - return { - popupId: (this._popup !== null ? this._popup.id : null) - }; - } - - _onApiGetPageInfo() { - return { - url: window.location.href, - documentTitle: document.title - }; - } - - async _onApiSetAllVisibleOverride({value, priority, awaitFrame}) { - const result = await this._popupFactory.setAllVisibleOverride(value, priority); - if (awaitFrame) { - await promiseAnimationFrame(100); - } - return result; - } - - async _onApiClearAllVisibleOverride({token}) { - return await this._popupFactory.clearAllVisibleOverride(token); - } - - async _onApiGetFrameSize() { - return await this._popup.getFrameSize(); - } - - async _onApiSetFrameSize({width, height}) { - return await this._popup.setFrameSize(width, height); - } - - // Private - - _onResize() { - this._updatePopupPosition(); - } - - _onRuntimeMessage({action, params}, sender, callback) { - const messageHandler = this._runtimeMessageHandlers.get(action); - if (typeof messageHandler === 'undefined') { return false; } - return yomichan.invokeMessageHandler(messageHandler, params, callback, sender); - } - - _onZoomChanged({newZoomFactor}) { - this._pageZoomFactor = newZoomFactor; - this._updateContentScale(); - } - - _onClosePopups() { - this._clearSelection(true); - } - - _onVisualViewportScroll() { - this._updatePopupPosition(); - } - - _onVisualViewportResize() { - this._updateContentScale(); - } - - _onClearSelection({passive}) { - this._stopClearSelectionDelayed(); - if (this._popup !== null) { - this._popup.hide(!passive); - this._popup.clearAutoPlayTimer(); - this._isPointerOverPopup = false; - } - } - - _onSearched({type, definitions, sentence, inputInfo: {eventType, passive, detail}, textSource, optionsContext, detail: {documentTitle}, error}) { - const scanningOptions = this._options.scanning; - - if (error !== null) { - if (yomichan.isExtensionUnloaded) { - if (textSource !== null && !passive) { - this._showExtensionUnloaded(textSource); - } - } else { - yomichan.logError(error); - } - } if (type !== null) { - this._stopClearSelectionDelayed(); - let focus = (eventType === 'mouseMove'); - if (isObject(detail)) { - const focus2 = detail.focus; - if (typeof focus2 === 'boolean') { focus = focus2; } - } - this._showContent(textSource, focus, definitions, type, sentence, documentTitle, optionsContext); - } else { - if (scanningOptions.autoHideResults) { - this._clearSelectionDelayed(scanningOptions.hideDelay, false); - } - } - } - - _onPopupFramePointerOver() { - this._isPointerOverPopup = true; - this._stopClearSelectionDelayed(); - } - - _onPopupFramePointerOut() { - this._isPointerOverPopup = false; - } - - _clearSelection(passive) { - this._stopClearSelectionDelayed(); - this._textScanner.clearSelection(passive); - } - - _clearSelectionDelayed(delay, restart, passive) { - if (!this._textScanner.hasSelection()) { return; } - if (delay > 0) { - if (this._clearSelectionTimer !== null && !restart) { return; } // Already running - this._stopClearSelectionDelayed(); - this._clearSelectionTimer = setTimeout(() => { - this._clearSelectionTimer = null; - if (this._isPointerOverPopup) { return; } - this._clearSelection(passive); - }, delay); - } else { - this._clearSelection(passive); - } - } - - _stopClearSelectionDelayed() { - if (this._clearSelectionTimer !== null) { - clearTimeout(this._clearSelectionTimer); - this._clearSelectionTimer = null; - } - } - - async _updateOptionsInternal() { - const optionsContext = await this._getOptionsContext(); - const options = await api.optionsGet(optionsContext); - const {scanning: scanningOptions, sentenceParsing: sentenceParsingOptions} = options; - this._options = options; - - this._hotkeyHandler.setHotkeys('web', options.inputs.hotkeys); - - await this._updatePopup(); - - const preventMiddleMouse = this._getPreventMiddleMouseValueForPageType(scanningOptions.preventMiddleMouse); - this._textScanner.setOptions({ - inputs: scanningOptions.inputs, - deepContentScan: scanningOptions.deepDomScan, - selectText: scanningOptions.selectText, - delay: scanningOptions.delay, - touchInputEnabled: scanningOptions.touchInputEnabled, - pointerEventsEnabled: scanningOptions.pointerEventsEnabled, - scanLength: scanningOptions.length, - layoutAwareScan: scanningOptions.layoutAwareScan, - preventMiddleMouse, - sentenceParsingOptions - }); - this._updateTextScannerEnabled(); - - if (this._pageType !== 'web') { - const excludeSelectors = ['.scan-disable', '.scan-disable *']; - if (!scanningOptions.enableOnPopupExpressions) { - excludeSelectors.push('.source-text', '.source-text *'); - } - this._textScanner.excludeSelector = excludeSelectors.join(','); - } - - this._updateContentScale(); - - await this._textScanner.searchLast(); - } - - async _updatePopup() { - const {usePopupWindow, showIframePopupsInRootFrame} = this._options.general; - const isIframe = !this._useProxyPopup && (window !== window.parent); - - const currentPopup = this._popup; - - let popupPromise; - if (usePopupWindow && this._canUseWindowPopup) { - popupPromise = this._popupCache.get('window'); - if (typeof popupPromise === 'undefined') { - popupPromise = this._getPopupWindow(); - this._popupCache.set('window', popupPromise); - } - } else if ( - isIframe && - showIframePopupsInRootFrame && - DocumentUtil.getFullscreenElement() === null && - this._allowRootFramePopupProxy - ) { - popupPromise = this._popupCache.get('iframe'); - if (typeof popupPromise === 'undefined') { - popupPromise = this._getIframeProxyPopup(); - this._popupCache.set('iframe', popupPromise); - } - } else if (this._useProxyPopup) { - popupPromise = this._popupCache.get('proxy'); - if (typeof popupPromise === 'undefined') { - popupPromise = this._getProxyPopup(); - this._popupCache.set('proxy', popupPromise); - } - } else { - popupPromise = this._popupCache.get('default'); - if (typeof popupPromise === 'undefined') { - popupPromise = this._getDefaultPopup(); - this._popupCache.set('default', popupPromise); - } - } - - // The token below is used as a unique identifier to ensure that a new _updatePopup call - // hasn't been started during the await. - const token = {}; - this._updatePopupToken = token; - const popup = await popupPromise; - const optionsContext = await this._getOptionsContext(); - if (this._updatePopupToken !== token) { return; } - if (popup !== null) { - await popup.setOptionsContext(optionsContext); - } - if (this._updatePopupToken !== token) { return; } - - if (popup !== currentPopup) { - this._clearSelection(true); - } - - this._popupEventListeners.removeAllEventListeners(); - this._popup = popup; - if (popup !== null) { - this._popupEventListeners.on(popup, 'framePointerOver', this._onPopupFramePointerOver.bind(this)); - this._popupEventListeners.on(popup, 'framePointerOut', this._onPopupFramePointerOut.bind(this)); - } - this._isPointerOverPopup = false; - } - - async _getDefaultPopup() { - const isXmlDocument = (typeof XMLDocument !== 'undefined' && document instanceof XMLDocument); - if (isXmlDocument) { - return null; - } - - return await this._popupFactory.getOrCreatePopup({ - frameId: this._frameId, - depth: this._depth, - childrenSupported: this._childrenSupported - }); - } - - async _getProxyPopup() { - return await this._popupFactory.getOrCreatePopup({ - frameId: this._parentFrameId, - depth: this._depth, - parentPopupId: this._parentPopupId, - childrenSupported: this._childrenSupported - }); - } - - async _getIframeProxyPopup() { - const targetFrameId = 0; // Root frameId - try { - await this._waitForFrontendReady(targetFrameId); - } catch (e) { - // Root frame not available - return await this._getDefaultPopup(); - } - - const {popupId} = await api.crossFrame.invoke(targetFrameId, 'getPopupInfo'); - if (popupId === null) { - return null; - } - - const popup = await this._popupFactory.getOrCreatePopup({ - frameId: targetFrameId, - id: popupId, - childrenSupported: this._childrenSupported - }); - popup.on('offsetNotFound', () => { - this._allowRootFramePopupProxy = false; - this._updatePopup(); - }); - return popup; - } - - async _getPopupWindow() { - return await this._popupFactory.getOrCreatePopup({ - depth: this._depth, - popupWindow: true, - childrenSupported: this._childrenSupported - }); - } - - _ignoreElements() { - if (this._popup !== null) { - const container = this._popup.container; - if (container !== null) { - return [container]; - } - } - return []; - } - - async _ignorePoint(x, y) { - try { - return this._popup !== null && await this._popup.containsPoint(x, y); - } catch (e) { - if (!yomichan.isExtensionUnloaded) { - throw e; - } - return false; - } - } - - _showExtensionUnloaded(textSource) { - if (textSource === null) { - textSource = this._textScanner.getCurrentTextSource(); - if (textSource === null) { return; } - } - this._showPopupContent(textSource, null); - } - - _showContent(textSource, focus, definitions, type, sentence, documentTitle, optionsContext) { - const query = textSource.text(); - const {url} = optionsContext; - const details = { - focus, - history: false, - params: { - type, - query, - wildcards: 'off' - }, - state: { - focusEntry: 0, - optionsContext, - url, - sentence, - documentTitle - }, - content: { - definitions, - contentOrigin: { - tabId: this._tabId, - frameId: this._frameId - } - } - }; - if (textSource instanceof TextSourceElement && textSource.fullContent !== query) { - details.params.full = textSource.fullContent; - details.params['full-visible'] = 'true'; - } - this._showPopupContent(textSource, optionsContext, details); - } - - _showPopupContent(textSource, optionsContext, details=null) { - this._lastShowPromise = ( - this._popup !== null ? - this._popup.showContent( - { - optionsContext, - elementRect: textSource.getRect(), - writingMode: textSource.getWritingMode() - }, - details - ) : - Promise.resolve() - ); - this._lastShowPromise.catch((error) => { - if (yomichan.isExtensionUnloaded) { return; } - yomichan.logError(error); - }); - return this._lastShowPromise; - } - - _updateTextScannerEnabled() { - const enabled = (this._options !== null && this._options.general.enable && !this._disabledOverride); - this._textScanner.setEnabled(enabled); - } - - _updateContentScale() { - const {popupScalingFactor, popupScaleRelativeToPageZoom, popupScaleRelativeToVisualViewport} = this._options.general; - let contentScale = popupScalingFactor; - if (popupScaleRelativeToPageZoom) { - contentScale /= this._pageZoomFactor; - } - if (popupScaleRelativeToVisualViewport) { - const visualViewport = window.visualViewport; - const visualViewportScale = (visualViewport !== null && typeof visualViewport === 'object' ? visualViewport.scale : 1.0); - contentScale /= visualViewportScale; - } - if (contentScale === this._contentScale) { return; } - - this._contentScale = contentScale; - if (this._popup !== null) { - this._popup.setContentScale(this._contentScale); - } - this._updatePopupPosition(); - } - - async _updatePopupPosition() { - const textSource = this._textScanner.getCurrentTextSource(); - if ( - textSource !== null && - this._popup !== null && - await this._popup.isVisible() - ) { - this._showPopupContent(textSource, null); - } - } - - _signalFrontendReady(targetFrameId=null) { - const params = {frameId: this._frameId}; - if (targetFrameId === null) { - api.broadcastTab('frontendReady', params); - } else { - api.sendMessageToFrame(targetFrameId, 'frontendReady', params); - } - } - - async _waitForFrontendReady(frameId) { - const promise = yomichan.getTemporaryListenerResult( - chrome.runtime.onMessage, - ({action, params}, {resolve}) => { - if ( - action === 'frontendReady' && - params.frameId === frameId - ) { - resolve(); - } - }, - 10000 - ); - api.broadcastTab('requestFrontendReadyBroadcast', {frameId: this._frameId}); - await promise; - } - - _getPreventMiddleMouseValueForPageType(preventMiddleMouseOptions) { - switch (this._pageType) { - case 'web': return preventMiddleMouseOptions.onWebPages; - case 'popup': return preventMiddleMouseOptions.onPopupPages; - case 'search': return preventMiddleMouseOptions.onSearchPages; - default: return false; - } - } - - async _getOptionsContext() { - let optionsContext = this._optionsContextOverride; - if (optionsContext === null) { - optionsContext = (await this._getSearchContext()).optionsContext; - } - return optionsContext; - } - - async _getSearchContext() { - let url = window.location.href; - let documentTitle = document.title; - if (this._useProxyPopup) { - try { - ({url, documentTitle} = await api.crossFrame.invoke(this._parentFrameId, 'getPageInfo', {})); - } catch (e) { - // NOP - } - } - - let optionsContext = this._optionsContextOverride; - if (optionsContext === null) { - optionsContext = {depth: this._depth, url}; - } - - return { - optionsContext, - detail: {documentTitle} - }; - } - - async _scanSelectedText() { - const range = this._getFirstNonEmptySelectionRange(); - if (range === null) { return false; } - const source = new TextSourceRange(range, range.toString(), null, null); - await this._textScanner.search(source, {focus: true}); - return true; - } - - _getFirstNonEmptySelectionRange() { - const selection = window.getSelection(); - for (let i = 0, ii = selection.rangeCount; i < ii; ++i) { - const range = selection.getRangeAt(i); - if (range.toString().length > 0) { - return range; - } - } - return null; - } -} diff --git a/ext/fg/js/popup-factory.js b/ext/fg/js/popup-factory.js deleted file mode 100644 index 7571d7ab..00000000 --- a/ext/fg/js/popup-factory.js +++ /dev/null @@ -1,319 +0,0 @@ -/* - * Copyright (C) 2019-2021 Yomichan Authors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -/* global - * FrameOffsetForwarder - * Popup - * PopupProxy - * PopupWindow - * api - */ - -class PopupFactory { - constructor(frameId) { - this._frameId = frameId; - this._frameOffsetForwarder = new FrameOffsetForwarder(frameId); - this._popups = new Map(); - this._allPopupVisibilityTokenMap = new Map(); - } - - // Public functions - - prepare() { - this._frameOffsetForwarder.prepare(); - api.crossFrame.registerHandlers([ - ['getOrCreatePopup', {async: true, handler: this._onApiGetOrCreatePopup.bind(this)}], - ['setOptionsContext', {async: true, handler: this._onApiSetOptionsContext.bind(this)}], - ['hide', {async: false, handler: this._onApiHide.bind(this)}], - ['isVisible', {async: true, handler: this._onApiIsVisibleAsync.bind(this)}], - ['setVisibleOverride', {async: true, handler: this._onApiSetVisibleOverride.bind(this)}], - ['clearVisibleOverride', {async: true, handler: this._onApiClearVisibleOverride.bind(this)}], - ['containsPoint', {async: true, handler: this._onApiContainsPoint.bind(this)}], - ['showContent', {async: true, handler: this._onApiShowContent.bind(this)}], - ['setCustomCss', {async: false, handler: this._onApiSetCustomCss.bind(this)}], - ['clearAutoPlayTimer', {async: false, handler: this._onApiClearAutoPlayTimer.bind(this)}], - ['setContentScale', {async: false, handler: this._onApiSetContentScale.bind(this)}], - ['updateTheme', {async: false, handler: this._onApiUpdateTheme.bind(this)}], - ['setCustomOuterCss', {async: false, handler: this._onApiSetCustomOuterCss.bind(this)}], - ['popup.getFrameSize', {async: true, handler: this._onApiGetFrameSize.bind(this)}], - ['popup.setFrameSize', {async: true, handler: this._onApiSetFrameSize.bind(this)}] - ]); - } - - async getOrCreatePopup({ - frameId=null, - id=null, - parentPopupId=null, - depth=null, - popupWindow=false, - childrenSupported=false - }) { - // Find by existing id - if (id !== null) { - const popup = this._popups.get(id); - if (typeof popup !== 'undefined') { - return popup; - } - } - - // Find by existing parent id - let parent = null; - if (parentPopupId !== null) { - parent = this._popups.get(parentPopupId); - if (typeof parent !== 'undefined') { - const popup = parent.child; - if (popup !== null) { - return popup; - } - } else { - parent = null; - } - } - - // Depth - if (parent !== null) { - if (depth !== null) { - throw new Error('Depth cannot be set when parent exists'); - } - depth = parent.depth + 1; - } else if (depth === null) { - depth = 0; - } - - if (popupWindow) { - // New unique id - if (id === null) { - id = generateId(16); - } - const popup = new PopupWindow({ - id, - depth, - frameId: this._frameId - }); - this._popups.set(id, popup); - return popup; - } else if (frameId === this._frameId) { - // New unique id - if (id === null) { - id = generateId(16); - } - const popup = new Popup({ - id, - depth, - frameId: this._frameId, - childrenSupported - }); - if (parent !== null) { - if (parent.child !== null) { - throw new Error('Parent popup already has a child'); - } - popup.parent = parent; - parent.child = popup; - } - this._popups.set(id, popup); - popup.prepare(); - return popup; - } else { - if (frameId === null) { - throw new Error('Invalid frameId'); - } - const useFrameOffsetForwarder = (parentPopupId === null); - ({id, depth, frameId} = await api.crossFrame.invoke(frameId, 'getOrCreatePopup', { - id, - parentPopupId, - frameId, - childrenSupported - })); - const popup = new PopupProxy({ - id, - depth, - frameId, - frameOffsetForwarder: useFrameOffsetForwarder ? this._frameOffsetForwarder : null - }); - this._popups.set(id, popup); - return popup; - } - } - - async setAllVisibleOverride(value, priority) { - const promises = []; - const errors = []; - for (const popup of this._popups.values()) { - const promise = popup.setVisibleOverride(value, priority) - .then( - (token) => ({popup, token}), - (error) => { errors.push(error); return null; } - ); - promises.push(promise); - } - - const results = (await Promise.all(promises)).filter(({token}) => token !== null); - - if (errors.length === 0) { - const token = generateId(16); - this._allPopupVisibilityTokenMap.set(token, results); - return token; - } - - // Revert on error - await this._revertPopupVisibilityOverrides(results); - throw errors[0]; - } - - async clearAllVisibleOverride(token) { - const results = this._allPopupVisibilityTokenMap.get(token); - if (typeof results === 'undefined') { return false; } - - this._allPopupVisibilityTokenMap.delete(token); - await this._revertPopupVisibilityOverrides(results); - return true; - } - - // API message handlers - - async _onApiGetOrCreatePopup(details) { - const popup = await this.getOrCreatePopup(details); - return { - id: popup.id, - depth: popup.depth, - frameId: popup.frameId - }; - } - - async _onApiSetOptionsContext({id, optionsContext, source}) { - const popup = this._getPopup(id); - return await popup.setOptionsContext(optionsContext, source); - } - - _onApiHide({id, changeFocus}) { - const popup = this._getPopup(id); - return popup.hide(changeFocus); - } - - async _onApiIsVisibleAsync({id}) { - const popup = this._getPopup(id); - return await popup.isVisible(); - } - - async _onApiSetVisibleOverride({id, value, priority}) { - const popup = this._getPopup(id); - return await popup.setVisibleOverride(value, priority); - } - - async _onApiClearVisibleOverride({id, token}) { - const popup = this._getPopup(id); - return await popup.clearVisibleOverride(token); - } - - async _onApiContainsPoint({id, x, y}) { - const popup = this._getPopup(id); - [x, y] = this._convertPopupPointToRootPagePoint(popup, x, y); - return await popup.containsPoint(x, y); - } - - async _onApiShowContent({id, details, displayDetails}) { - const popup = this._getPopup(id); - if (!this._popupCanShow(popup)) { return; } - - const {elementRect} = details; - if (typeof elementRect !== 'undefined') { - details.elementRect = this._convertJsonRectToDOMRect(popup, elementRect); - } - - return await popup.showContent(details, displayDetails); - } - - _onApiSetCustomCss({id, css}) { - const popup = this._getPopup(id); - return popup.setCustomCss(css); - } - - _onApiClearAutoPlayTimer({id}) { - const popup = this._getPopup(id); - return popup.clearAutoPlayTimer(); - } - - _onApiSetContentScale({id, scale}) { - const popup = this._getPopup(id); - return popup.setContentScale(scale); - } - - _onApiUpdateTheme({id}) { - const popup = this._getPopup(id); - return popup.updateTheme(); - } - - _onApiSetCustomOuterCss({id, css, useWebExtensionApi}) { - const popup = this._getPopup(id); - return popup.setCustomOuterCss(css, useWebExtensionApi); - } - - async _onApiGetFrameSize({id}) { - const popup = this._getPopup(id); - return await popup.getFrameSize(); - } - - async _onApiSetFrameSize({id, width, height}) { - const popup = this._getPopup(id); - return await popup.setFrameSize(width, height); - } - - // Private functions - - _getPopup(id) { - const popup = this._popups.get(id); - if (typeof popup === 'undefined') { - throw new Error(`Invalid popup ID ${id}`); - } - return popup; - } - - _convertJsonRectToDOMRect(popup, jsonRect) { - const [x, y] = this._convertPopupPointToRootPagePoint(popup, jsonRect.x, jsonRect.y); - return new DOMRect(x, y, jsonRect.width, jsonRect.height); - } - - _convertPopupPointToRootPagePoint(popup, x, y) { - const parent = popup.parent; - if (parent !== null) { - const popupRect = parent.getFrameRect(); - x += popupRect.x; - y += popupRect.y; - } - return [x, y]; - } - - _popupCanShow(popup) { - const parent = popup.parent; - return parent === null || parent.isVisibleSync(); - } - - async _revertPopupVisibilityOverrides(overrides) { - const promises = []; - for (const value of overrides) { - if (value === null) { continue; } - const {popup, token} = value; - const promise = popup.clearVisibleOverride(token) - .then( - (v) => v, - () => false - ); - promises.push(promise); - } - return await Promise.all(promises); - } -} diff --git a/ext/fg/js/popup-proxy.js b/ext/fg/js/popup-proxy.js deleted file mode 100644 index b2e81824..00000000 --- a/ext/fg/js/popup-proxy.js +++ /dev/null @@ -1,218 +0,0 @@ -/* - * Copyright (C) 2019-2021 Yomichan Authors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -/* global - * api - */ - -class PopupProxy extends EventDispatcher { - constructor({ - id, - depth, - frameId, - frameOffsetForwarder - }) { - super(); - this._id = id; - this._depth = depth; - this._frameId = frameId; - this._frameOffsetForwarder = frameOffsetForwarder; - - this._frameOffset = [0, 0]; - this._frameOffsetPromise = null; - this._frameOffsetUpdatedAt = null; - this._frameOffsetExpireTimeout = 1000; - } - - // Public properties - - get id() { - return this._id; - } - - get parent() { - return null; - } - - set parent(value) { - throw new Error('Not supported on PopupProxy'); - } - - get child() { - return null; - } - - set child(value) { - throw new Error('Not supported on PopupProxy'); - } - - get depth() { - return this._depth; - } - - get frameContentWindow() { - return null; - } - - get container() { - return null; - } - - get frameId() { - return this._frameId; - } - - // Public functions - - setOptionsContext(optionsContext, source) { - return this._invokeSafe('setOptionsContext', {id: this._id, optionsContext, source}); - } - - hide(changeFocus) { - return this._invokeSafe('hide', {id: this._id, changeFocus}); - } - - isVisible() { - return this._invokeSafe('isVisible', {id: this._id}, false); - } - - setVisibleOverride(value, priority) { - return this._invokeSafe('setVisibleOverride', {id: this._id, value, priority}, null); - } - - clearVisibleOverride(token) { - return this._invokeSafe('clearVisibleOverride', {id: this._id, token}, false); - } - - async containsPoint(x, y) { - if (this._frameOffsetForwarder !== null) { - await this._updateFrameOffset(); - [x, y] = this._applyFrameOffset(x, y); - } - return await this._invokeSafe('containsPoint', {id: this._id, x, y}, false); - } - - async showContent(details, displayDetails) { - const {elementRect} = details; - if (typeof elementRect !== 'undefined') { - let {x, y, width, height} = elementRect; - if (this._frameOffsetForwarder !== null) { - await this._updateFrameOffset(); - [x, y] = this._applyFrameOffset(x, y); - } - details.elementRect = {x, y, width, height}; - } - return await this._invokeSafe('showContent', {id: this._id, details, displayDetails}); - } - - setCustomCss(css) { - return this._invokeSafe('setCustomCss', {id: this._id, css}); - } - - clearAutoPlayTimer() { - return this._invokeSafe('clearAutoPlayTimer', {id: this._id}); - } - - setContentScale(scale) { - return this._invokeSafe('setContentScale', {id: this._id, scale}); - } - - isVisibleSync() { - throw new Error('Not supported on PopupProxy'); - } - - updateTheme() { - return this._invokeSafe('updateTheme', {id: this._id}); - } - - setCustomOuterCss(css, useWebExtensionApi) { - return this._invokeSafe('setCustomOuterCss', {id: this._id, css, useWebExtensionApi}); - } - - getFrameRect() { - return new DOMRect(0, 0, 0, 0); - } - - getFrameSize() { - return this._invokeSafe('popup.getFrameSize', {id: this._id}, {width: 0, height: 0, valid: false}); - } - - setFrameSize(width, height) { - return this._invokeSafe('popup.setFrameSize', {id: this._id, width, height}); - } - - // Private - - _invoke(action, params={}) { - return api.crossFrame.invoke(this._frameId, action, params); - } - - async _invokeSafe(action, params={}, defaultReturnValue) { - try { - return await this._invoke(action, params); - } catch (e) { - if (!yomichan.isExtensionUnloaded) { throw e; } - return defaultReturnValue; - } - } - - async _updateFrameOffset() { - const now = Date.now(); - const firstRun = this._frameOffsetUpdatedAt === null; - const expired = firstRun || this._frameOffsetUpdatedAt < now - this._frameOffsetExpireTimeout; - if (this._frameOffsetPromise === null && !expired) { return; } - - if (this._frameOffsetPromise !== null) { - if (firstRun) { - await this._frameOffsetPromise; - } - return; - } - - const promise = this._updateFrameOffsetInner(now); - if (firstRun) { - await promise; - } - } - - async _updateFrameOffsetInner(now) { - this._frameOffsetPromise = this._frameOffsetForwarder.getOffset(); - try { - let offset = null; - try { - offset = await this._frameOffsetPromise; - } catch (e) { - // NOP - } - this._frameOffset = offset !== null ? offset : [0, 0]; - if (offset === null) { - this.trigger('offsetNotFound'); - return; - } - this._frameOffsetUpdatedAt = now; - } catch (e) { - yomichan.logError(e); - } finally { - this._frameOffsetPromise = null; - } - } - - _applyFrameOffset(x, y) { - const [offsetX, offsetY] = this._frameOffset; - return [x + offsetX, y + offsetY]; - } -} diff --git a/ext/fg/js/popup-window.js b/ext/fg/js/popup-window.js deleted file mode 100644 index 5fa0c647..00000000 --- a/ext/fg/js/popup-window.js +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Copyright (C) 2020-2021 Yomichan Authors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -/* global - * api - */ - -class PopupWindow extends EventDispatcher { - constructor({ - id, - depth, - frameId - }) { - super(); - this._id = id; - this._depth = depth; - this._frameId = frameId; - this._popupTabId = null; - } - - // Public properties - - get id() { - return this._id; - } - - get parent() { - return null; - } - - set parent(value) { - throw new Error('Not supported on PopupProxy'); - } - - get child() { - return null; - } - - set child(value) { - throw new Error('Not supported on PopupProxy'); - } - - get depth() { - return this._depth; - } - - get frameContentWindow() { - return null; - } - - get container() { - return null; - } - - get frameId() { - return this._frameId; - } - - - // Public functions - - setOptionsContext(optionsContext, source) { - return this._invoke(false, 'setOptionsContext', {id: this._id, optionsContext, source}); - } - - hide(_changeFocus) { - // NOP - } - - async isVisible() { - return (this._popupTabId !== null && await api.isTabSearchPopup(this._popupTabId)); - } - - async setVisibleOverride(_value, _priority) { - return null; - } - - clearVisibleOverride(_token) { - return false; - } - - async containsPoint(_x, _y) { - return false; - } - - async showContent(_details, displayDetails) { - if (displayDetails === null) { return; } - await this._invoke(true, 'setContent', {id: this._id, details: displayDetails}); - } - - setCustomCss(css) { - return this._invoke(false, 'setCustomCss', {id: this._id, css}); - } - - clearAutoPlayTimer() { - return this._invoke(false, 'clearAutoPlayTimer', {id: this._id}); - } - - setContentScale(_scale) { - // NOP - } - - isVisibleSync() { - throw new Error('Not supported on PopupWindow'); - } - - updateTheme() { - // NOP - } - - async setCustomOuterCss(_css, _useWebExtensionApi) { - // NOP - } - - getFrameRect() { - return new DOMRect(0, 0, 0, 0); - } - - async getFrameSize() { - return {width: 0, height: 0, valid: false}; - } - - async setFrameSize(_width, _height) { - return false; - } - - // Private - - async _invoke(open, action, params={}, defaultReturnValue) { - if (yomichan.isExtensionUnloaded) { - return defaultReturnValue; - } - - const frameId = 0; - if (this._popupTabId !== null) { - try { - return await api.crossFrame.invokeTab(this._popupTabId, frameId, 'popupMessage', {action, params}); - } catch (e) { - if (yomichan.isExtensionUnloaded) { - open = false; - } - } - this._popupTabId = null; - } - - if (!open) { - return defaultReturnValue; - } - - const {tabId} = await api.getOrCreateSearchPopup({focus: 'ifCreated'}); - this._popupTabId = tabId; - - return await api.crossFrame.invokeTab(this._popupTabId, frameId, 'popupMessage', {action, params}); - } -} diff --git a/ext/fg/js/popup.js b/ext/fg/js/popup.js deleted file mode 100644 index 75b74257..00000000 --- a/ext/fg/js/popup.js +++ /dev/null @@ -1,687 +0,0 @@ -/* - * Copyright (C) 2016-2021 Yomichan Authors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -/* global - * DocumentUtil - * FrameClient - * api - * dynamicLoader - */ - -class Popup extends EventDispatcher { - constructor({ - id, - depth, - frameId, - childrenSupported - }) { - super(); - this._id = id; - this._depth = depth; - this._frameId = frameId; - this._childrenSupported = childrenSupported; - this._parent = null; - this._child = null; - this._injectPromise = null; - this._injectPromiseComplete = false; - this._visible = new DynamicProperty(false); - this._options = null; - this._optionsContext = null; - this._contentScale = 1.0; - this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, ''); - - this._frameSizeContentScale = null; - this._frameClient = null; - this._frame = document.createElement('iframe'); - this._frame.className = 'yomichan-popup'; - this._frame.style.width = '0'; - this._frame.style.height = '0'; - - this._container = this._frame; - this._shadow = null; - - this._fullscreenEventListeners = new EventListenerCollection(); - } - - // Public properties - - get id() { - return this._id; - } - - get parent() { - return this._parent; - } - - set parent(value) { - this._parent = value; - } - - get child() { - return this._child; - } - - set child(value) { - this._child = value; - } - - get depth() { - return this._depth; - } - - get frameContentWindow() { - return this._frame.contentWindow; - } - - get container() { - return this._container; - } - - get frameId() { - return this._frameId; - } - - // Public functions - - prepare() { - this._frame.addEventListener('mouseover', this._onFrameMouseOver.bind(this)); - this._frame.addEventListener('mouseout', this._onFrameMouseOut.bind(this)); - this._frame.addEventListener('mousedown', (e) => e.stopPropagation()); - this._frame.addEventListener('scroll', (e) => e.stopPropagation()); - this._frame.addEventListener('load', this._onFrameLoad.bind(this)); - this._visible.on('change', this._onVisibleChange.bind(this)); - yomichan.on('extensionUnloaded', this._onExtensionUnloaded.bind(this)); - this._onVisibleChange({value: this.isVisibleSync()}); - } - - async setOptionsContext(optionsContext) { - await this._setOptionsContext(optionsContext); - await this._invokeSafe('setOptionsContext', {optionsContext}); - } - - hide(changeFocus) { - if (!this.isVisibleSync()) { - return; - } - - this._setVisible(false); - if (this._child !== null) { - this._child.hide(false); - } - if (changeFocus) { - this._focusParent(); - } - } - - async isVisible() { - return this.isVisibleSync(); - } - - async setVisibleOverride(value, priority) { - return this._visible.setOverride(value, priority); - } - - async clearVisibleOverride(token) { - return this._visible.clearOverride(token); - } - - async containsPoint(x, y) { - for (let popup = this; popup !== null && popup.isVisibleSync(); popup = popup.child) { - const rect = popup.getFrameRect(); - if (x >= rect.left && y >= rect.top && x < rect.right && y < rect.bottom) { - return true; - } - } - return false; - } - - async showContent(details, displayDetails) { - if (this._options === null) { throw new Error('Options not assigned'); } - - const {optionsContext, elementRect, writingMode} = details; - if (optionsContext !== null) { - await this._setOptionsContextIfDifferent(optionsContext); - } - - if (typeof elementRect !== 'undefined' && typeof writingMode !== 'undefined') { - await this._show(elementRect, writingMode); - } - - if (displayDetails !== null) { - this._invokeSafe('setContent', {details: displayDetails}); - } - } - - setCustomCss(css) { - this._invokeSafe('setCustomCss', {css}); - } - - clearAutoPlayTimer() { - this._invokeSafe('clearAutoPlayTimer'); - } - - setContentScale(scale) { - this._contentScale = scale; - this._frame.style.fontSize = `${scale}px`; - this._invokeSafe('setContentScale', {scale}); - } - - isVisibleSync() { - return this._visible.value; - } - - updateTheme() { - const {popupTheme, popupOuterTheme} = this._options.general; - this._frame.dataset.theme = popupTheme; - this._frame.dataset.outerTheme = popupOuterTheme; - this._frame.dataset.siteColor = this._getSiteColor(); - } - - async setCustomOuterCss(css, useWebExtensionApi) { - let parentNode = null; - const inShadow = (this._shadow !== null); - if (inShadow) { - useWebExtensionApi = false; - parentNode = this._shadow; - } - const node = await dynamicLoader.loadStyle('yomichan-popup-outer-user-stylesheet', 'code', css, useWebExtensionApi, parentNode); - this.trigger('customOuterCssChanged', {node, useWebExtensionApi, inShadow}); - } - - getFrameRect() { - return this._frame.getBoundingClientRect(); - } - - async getFrameSize() { - const rect = this._frame.getBoundingClientRect(); - return {width: rect.width, height: rect.height, valid: true}; - } - - async setFrameSize(width, height) { - this._setFrameSize(width, height); - return true; - } - - // Private functions - - _onFrameMouseOver() { - this.trigger('framePointerOver', {}); - } - - _onFrameMouseOut() { - this.trigger('framePointerOut', {}); - } - - _inject() { - let injectPromise = this._injectPromise; - if (injectPromise === null) { - injectPromise = this._createInjectPromise(); - this._injectPromise = injectPromise; - injectPromise.then( - () => { - if (injectPromise !== this._injectPromise) { return; } - this._injectPromiseComplete = true; - }, - () => { this._resetFrame(); } - ); - } - return injectPromise; - } - - async _createInjectPromise() { - if (this._options === null) { - throw new Error('Options not initialized'); - } - - const {useSecurePopupFrameUrl, usePopupShadowDom} = this._options.general; - - await this._setUpContainer(usePopupShadowDom); - - const setupFrame = (frame) => { - frame.removeAttribute('src'); - frame.removeAttribute('srcdoc'); - this._observeFullscreen(true); - this._onFullscreenChanged(); - const url = chrome.runtime.getURL('/popup.html'); - if (useSecurePopupFrameUrl) { - frame.contentDocument.location.href = url; - } else { - frame.setAttribute('src', url); - } - }; - - const frameClient = new FrameClient(); - this._frameClient = frameClient; - await frameClient.connect(this._frame, this._targetOrigin, this._frameId, setupFrame); - - // Configure - await this._invokeSafe('configure', { - depth: this._depth, - parentPopupId: this._id, - parentFrameId: this._frameId, - childrenSupported: this._childrenSupported, - scale: this._contentScale, - optionsContext: this._optionsContext - }); - } - - _onFrameLoad() { - if (!this._injectPromiseComplete) { return; } - this._resetFrame(); - } - - _resetFrame() { - const parent = this._container.parentNode; - if (parent !== null) { - parent.removeChild(this._container); - } - this._frame.removeAttribute('src'); - this._frame.removeAttribute('srcdoc'); - - this._frameClient = null; - this._injectPromise = null; - this._injectPromiseComplete = false; - } - - async _setUpContainer(usePopupShadowDom) { - if (usePopupShadowDom && typeof this._frame.attachShadow === 'function') { - const container = document.createElement('div'); - container.style.setProperty('all', 'initial', 'important'); - const shadow = container.attachShadow({mode: 'closed', delegatesFocus: true}); - shadow.appendChild(this._frame); - - this._container = container; - this._shadow = shadow; - } else { - const frameParentNode = this._frame.parentNode; - if (frameParentNode !== null) { - frameParentNode.removeChild(this._frame); - } - - this._container = this._frame; - this._shadow = null; - } - - await this._injectStyles(); - } - - async _injectStyles() { - try { - await this._injectPopupOuterStylesheet(); - } catch (e) { - // NOP - } - - try { - await this.setCustomOuterCss(this._options.general.customPopupOuterCss, true); - } catch (e) { - // NOP - } - } - - async _injectPopupOuterStylesheet() { - let fileType = 'file'; - let useWebExtensionApi = true; - let parentNode = null; - if (this._shadow !== null) { - fileType = 'file-content'; - useWebExtensionApi = false; - parentNode = this._shadow; - } - await dynamicLoader.loadStyle('yomichan-popup-outer-stylesheet', fileType, '/css/popup-outer.css', useWebExtensionApi, parentNode); - } - - _observeFullscreen(observe) { - if (!observe) { - this._fullscreenEventListeners.removeAllEventListeners(); - return; - } - - if (this._fullscreenEventListeners.size > 0) { - // Already observing - return; - } - - DocumentUtil.addFullscreenChangeEventListener(this._onFullscreenChanged.bind(this), this._fullscreenEventListeners); - } - - _onFullscreenChanged() { - const parent = this._getFrameParentElement(); - if (parent !== null && this._container.parentNode !== parent) { - parent.appendChild(this._container); - } - } - - async _show(elementRect, writingMode) { - await this._inject(); - - const optionsGeneral = this._options.general; - const {popupDisplayMode} = optionsGeneral; - const frame = this._frame; - const frameRect = frame.getBoundingClientRect(); - - const viewport = this._getViewport(optionsGeneral.popupScaleRelativeToVisualViewport); - const scale = this._contentScale; - const scaleRatio = this._frameSizeContentScale === null ? 1.0 : scale / this._frameSizeContentScale; - this._frameSizeContentScale = scale; - const getPositionArgs = [ - elementRect, - Math.max(frameRect.width * scaleRatio, optionsGeneral.popupWidth * scale), - Math.max(frameRect.height * scaleRatio, optionsGeneral.popupHeight * scale), - viewport, - scale, - optionsGeneral, - writingMode - ]; - let [x, y, width, height, below] = ( - writingMode === 'horizontal-tb' || optionsGeneral.popupVerticalTextPosition === 'default' ? - this._getPositionForHorizontalText(...getPositionArgs) : - this._getPositionForVerticalText(...getPositionArgs) - ); - - frame.dataset.popupDisplayMode = popupDisplayMode; - frame.dataset.below = `${below}`; - - if (popupDisplayMode === 'full-width') { - x = viewport.left; - y = below ? viewport.bottom - height : viewport.top; - width = viewport.right - viewport.left; - } - - frame.style.left = `${x}px`; - frame.style.top = `${y}px`; - this._setFrameSize(width, height); - - this._setVisible(true); - if (this._child !== null) { - this._child.hide(true); - } - } - - _setFrameSize(width, height) { - const {style} = this._frame; - style.width = `${width}px`; - style.height = `${height}px`; - } - - _setVisible(visible) { - this._visible.defaultValue = visible; - } - - _onVisibleChange({value}) { - this._frame.style.setProperty('visibility', value ? 'visible' : 'hidden', 'important'); - } - - _focusParent() { - if (this._parent !== null) { - // Chrome doesn't like focusing iframe without contentWindow. - const contentWindow = this._parent.frameContentWindow; - if (contentWindow !== null) { - contentWindow.focus(); - } - } else { - // Firefox doesn't like focusing window without first blurring the iframe. - // this._frame.contentWindow.blur() doesn't work on Firefox for some reason. - this._frame.blur(); - // This is needed for Chrome. - window.focus(); - } - } - - _getSiteColor() { - const color = [255, 255, 255]; - const {documentElement, body} = document; - if (documentElement !== null) { - this._addColor(color, window.getComputedStyle(documentElement).backgroundColor); - } - if (body !== null) { - this._addColor(color, window.getComputedStyle(body).backgroundColor); - } - const dark = (color[0] < 128 && color[1] < 128 && color[2] < 128); - return dark ? 'dark' : 'light'; - } - - async _invoke(action, params={}) { - const contentWindow = this._frame.contentWindow; - if (this._frameClient === null || !this._frameClient.isConnected() || contentWindow === null) { return; } - - const message = this._frameClient.createMessage({action, params}); - return await api.crossFrame.invoke(this._frameClient.frameId, 'popupMessage', message); - } - - async _invokeSafe(action, params={}, defaultReturnValue) { - try { - return await this._invoke(action, params); - } catch (e) { - if (!yomichan.isExtensionUnloaded) { throw e; } - return defaultReturnValue; - } - } - - _invokeWindow(action, params={}) { - const contentWindow = this._frame.contentWindow; - if (this._frameClient === null || !this._frameClient.isConnected() || contentWindow === null) { return; } - - const message = this._frameClient.createMessage({action, params}); - contentWindow.postMessage(message, this._targetOrigin); - } - - _onExtensionUnloaded() { - this._invokeWindow('extensionUnloaded'); - } - - _getFrameParentElement() { - const defaultParent = document.body; - const fullscreenElement = DocumentUtil.getFullscreenElement(); - if ( - fullscreenElement === null || - fullscreenElement.shadowRoot || - fullscreenElement.openOrClosedShadowRoot // Available to Firefox 63+ for WebExtensions - ) { - return defaultParent; - } - - switch (fullscreenElement.nodeName.toUpperCase()) { - case 'IFRAME': - case 'FRAME': - return defaultParent; - } - - return fullscreenElement; - } - - _getPositionForHorizontalText(elementRect, width, height, viewport, offsetScale, optionsGeneral) { - const preferBelow = (optionsGeneral.popupHorizontalTextPosition === 'below'); - const horizontalOffset = optionsGeneral.popupHorizontalOffset * offsetScale; - const verticalOffset = optionsGeneral.popupVerticalOffset * offsetScale; - - const [x, w] = this._getConstrainedPosition( - elementRect.right - horizontalOffset, - elementRect.left + horizontalOffset, - width, - viewport.left, - viewport.right, - true - ); - const [y, h, below] = this._getConstrainedPositionBinary( - elementRect.top - verticalOffset, - elementRect.bottom + verticalOffset, - height, - viewport.top, - viewport.bottom, - preferBelow - ); - return [x, y, w, h, below]; - } - - _getPositionForVerticalText(elementRect, width, height, viewport, offsetScale, optionsGeneral, writingMode) { - const preferRight = this._isVerticalTextPopupOnRight(optionsGeneral.popupVerticalTextPosition, writingMode); - const horizontalOffset = optionsGeneral.popupHorizontalOffset2 * offsetScale; - const verticalOffset = optionsGeneral.popupVerticalOffset2 * offsetScale; - - const [x, w] = this._getConstrainedPositionBinary( - elementRect.left - horizontalOffset, - elementRect.right + horizontalOffset, - width, - viewport.left, - viewport.right, - preferRight - ); - const [y, h, below] = this._getConstrainedPosition( - elementRect.bottom - verticalOffset, - elementRect.top + verticalOffset, - height, - viewport.top, - viewport.bottom, - true - ); - return [x, y, w, h, below]; - } - - _isVerticalTextPopupOnRight(positionPreference, writingMode) { - switch (positionPreference) { - case 'before': - return !this._isWritingModeLeftToRight(writingMode); - case 'after': - return this._isWritingModeLeftToRight(writingMode); - case 'left': - return false; - case 'right': - return true; - default: - return false; - } - } - - _isWritingModeLeftToRight(writingMode) { - switch (writingMode) { - case 'vertical-lr': - case 'sideways-lr': - return true; - default: - return false; - } - } - - _getConstrainedPosition(positionBefore, positionAfter, size, minLimit, maxLimit, after) { - size = Math.min(size, maxLimit - minLimit); - - let position; - if (after) { - position = Math.max(minLimit, positionAfter); - position = position - Math.max(0, (position + size) - maxLimit); - } else { - position = Math.min(maxLimit, positionBefore) - size; - position = position + Math.max(0, minLimit - position); - } - - return [position, size, after]; - } - - _getConstrainedPositionBinary(positionBefore, positionAfter, size, minLimit, maxLimit, after) { - const overflowBefore = minLimit - (positionBefore - size); - const overflowAfter = (positionAfter + size) - maxLimit; - - if (overflowAfter > 0 || overflowBefore > 0) { - after = (overflowAfter < overflowBefore); - } - - let position; - if (after) { - size -= Math.max(0, overflowAfter); - position = Math.max(minLimit, positionAfter); - } else { - size -= Math.max(0, overflowBefore); - position = Math.min(maxLimit, positionBefore) - size; - } - - return [position, size, after]; - } - - _addColor(target, cssColor) { - if (typeof cssColor !== 'string') { return; } - - const color = this._getColorInfo(cssColor); - if (color === null) { return; } - - const a = color[3]; - if (a <= 0.0) { return; } - - const aInv = 1.0 - a; - for (let i = 0; i < 3; ++i) { - target[i] = target[i] * aInv + color[i] * a; - } - } - - _getColorInfo(cssColor) { - const m = /^\s*rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)\s*$/.exec(cssColor); - if (m === null) { return null; } - - const m4 = m[4]; - return [ - Number.parseInt(m[1], 10), - Number.parseInt(m[2], 10), - Number.parseInt(m[3], 10), - m4 ? Math.max(0.0, Math.min(1.0, Number.parseFloat(m4))) : 1.0 - ]; - } - - _getViewport(useVisualViewport) { - const visualViewport = window.visualViewport; - if (visualViewport !== null && typeof visualViewport === 'object') { - const left = visualViewport.offsetLeft; - const top = visualViewport.offsetTop; - const width = visualViewport.width; - const height = visualViewport.height; - if (useVisualViewport) { - return { - left, - top, - right: left + width, - bottom: top + height - }; - } else { - const scale = visualViewport.scale; - return { - left: 0, - top: 0, - right: Math.max(left + width, width * scale), - bottom: Math.max(top + height, height * scale) - }; - } - } - - const body = document.body; - return { - left: 0, - top: 0, - right: (body !== null ? body.clientWidth : 0), - bottom: window.innerHeight - }; - } - - async _setOptionsContext(optionsContext) { - this._optionsContext = optionsContext; - this._options = await api.optionsGet(optionsContext); - this.updateTheme(); - } - - async _setOptionsContextIfDifferent(optionsContext) { - if (deepEqual(this._optionsContext, optionsContext)) { return; } - await this._setOptionsContext(optionsContext); - } -} diff --git a/ext/fg/js/text-source-element.js b/ext/fg/js/text-source-element.js deleted file mode 100644 index 45186636..00000000 --- a/ext/fg/js/text-source-element.js +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright (C) 2016-2021 Yomichan Authors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -class TextSourceElement { - constructor(element, fullContent=null, startOffset=0, endOffset=0) { - this._element = element; - this._fullContent = (typeof fullContent === 'string' ? fullContent : TextSourceElement.getElementContent(element)); - this._startOffset = startOffset; - this._endOffset = endOffset; - this._content = this._fullContent.substring(this._startOffset, this._endOffset); - } - - get element() { - return this._element; - } - - get fullContent() { - return this._fullContent; - } - - get startOffset() { - return this._startOffset; - } - - get endOffset() { - return this._endOffset; - } - - get isConnected() { - return this._element.isConnected; - } - - clone() { - return new TextSourceElement(this._element, this._fullContent, this._startOffset, this._endOffset); - } - - cleanup() { - // NOP - } - - text() { - return this._content; - } - - setEndOffset(length, fromEnd=false) { - if (fromEnd) { - const delta = Math.min(this._fullContent.length - this._endOffset, length); - this._endOffset += delta; - this._content = this._fullContent.substring(this._startOffset, this._endOffset); - return delta; - } else { - const delta = Math.min(this._fullContent.length - this._startOffset, length); - this._endOffset = this._startOffset + delta; - this._content = this._fullContent.substring(this._startOffset, this._endOffset); - return delta; - } - } - - setStartOffset(length) { - const delta = Math.min(this._startOffset, length); - this._startOffset -= delta; - this._content = this._fullContent.substring(this._startOffset, this._endOffset); - return delta; - } - - collapse(toStart) { - if (toStart) { - this._endOffset = this._startOffset; - } else { - this._startOffset = this._endOffset; - } - this._content = ''; - } - - getRect() { - return this._element.getBoundingClientRect(); - } - - getWritingMode() { - return 'horizontal-tb'; - } - - select() { - // NOP - } - - deselect() { - // NOP - } - - hasSameStart(other) { - return ( - typeof other === 'object' && - other !== null && - other instanceof TextSourceElement && - this._element === other.element && - this._fullContent === other.fullContent && - this._startOffset === other.startOffset - ); - } - - getNodesInRange() { - return [this._element]; - } - - static getElementContent(element) { - let content; - switch (element.nodeName.toUpperCase()) { - case 'BUTTON': - content = element.textContent; - break; - case 'IMG': - content = element.getAttribute('alt') || ''; - break; - default: - content = `${element.value}`; - break; - } - - // Remove zero-width non-joiner - content = content.replace(/\u200c/g, ''); - - return content; - } -} diff --git a/ext/fg/js/text-source-range.js b/ext/fg/js/text-source-range.js deleted file mode 100644 index 377016da..00000000 --- a/ext/fg/js/text-source-range.js +++ /dev/null @@ -1,170 +0,0 @@ -/* - * Copyright (C) 2016-2021 Yomichan Authors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -/* global - * DOMTextScanner - * DocumentUtil - */ - -class TextSourceRange { - constructor(range, content, imposterContainer, imposterSourceElement) { - this._range = range; - this._rangeStartOffset = range.startOffset; - this._content = content; - this._imposterContainer = imposterContainer; - this._imposterSourceElement = imposterSourceElement; - } - - get range() { - return this._range; - } - - get rangeStartOffset() { - return this._rangeStartOffset; - } - - get imposterSourceElement() { - return this._imposterSourceElement; - } - - get isConnected() { - return ( - this._range.startContainer.isConnected && - this._range.endContainer.isConnected - ); - } - - clone() { - return new TextSourceRange(this._range.cloneRange(), this._content, this._imposterContainer, this._imposterSourceElement); - } - - cleanup() { - if (this._imposterContainer !== null && this._imposterContainer.parentNode !== null) { - this._imposterContainer.parentNode.removeChild(this._imposterContainer); - } - } - - text() { - return this._content; - } - - setEndOffset(length, layoutAwareScan, fromEnd=false) { - const state = ( - fromEnd ? - new DOMTextScanner(this._range.endContainer, this._range.endOffset, !layoutAwareScan, layoutAwareScan).seek(length) : - new DOMTextScanner(this._range.startContainer, this._range.startOffset, !layoutAwareScan, layoutAwareScan).seek(length) - ); - this._range.setEnd(state.node, state.offset); - this._content = (fromEnd ? this._content + state.content : state.content); - return length - state.remainder; - } - - setStartOffset(length, layoutAwareScan) { - const state = new DOMTextScanner(this._range.startContainer, this._range.startOffset, !layoutAwareScan, layoutAwareScan).seek(-length); - this._range.setStart(state.node, state.offset); - this._rangeStartOffset = this._range.startOffset; - this._content = state.content + this._content; - return length - state.remainder; - } - - collapse(toStart) { - this._range.collapse(toStart); - this._content = ''; - } - - getRect() { - return this._range.getBoundingClientRect(); - } - - getWritingMode() { - return TextSourceRange.getElementWritingMode(TextSourceRange.getParentElement(this._range.startContainer)); - } - - select() { - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(this._range); - } - - deselect() { - const selection = window.getSelection(); - selection.removeAllRanges(); - } - - hasSameStart(other) { - if (!( - typeof other === 'object' && - other !== null && - other instanceof TextSourceRange - )) { - return false; - } - if (this._imposterSourceElement !== null) { - return ( - this._imposterSourceElement === other.imposterSourceElement && - this._rangeStartOffset === other.rangeStartOffset - ); - } else { - try { - return this._range.compareBoundaryPoints(Range.START_TO_START, other.range) === 0; - } catch (e) { - if (e.name === 'WrongDocumentError') { - // This can happen with shadow DOMs if the ranges are in different documents. - return false; - } - throw e; - } - } - } - - getNodesInRange() { - return DocumentUtil.getNodesInRange(this._range); - } - - static getParentElement(node) { - while (node !== null && node.nodeType !== Node.ELEMENT_NODE) { - node = node.parentNode; - } - return node; - } - - static getElementWritingMode(element) { - if (element !== null) { - const style = window.getComputedStyle(element); - const writingMode = style.writingMode; - if (typeof writingMode === 'string') { - return TextSourceRange.normalizeWritingMode(writingMode); - } - } - return 'horizontal-tb'; - } - - static normalizeWritingMode(writingMode) { - switch (writingMode) { - case 'lr': - case 'lr-tb': - case 'rl': - return 'horizontal-tb'; - case 'tb': - return 'vertical-lr'; - case 'tb-rl': - return 'vertical-rl'; - default: - return writingMode; - } - } -} diff --git a/ext/js/app/content-script-main.js b/ext/js/app/content-script-main.js new file mode 100644 index 00000000..5dee4c56 --- /dev/null +++ b/ext/js/app/content-script-main.js @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2019-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/* global + * Frontend + * HotkeyHandler + * PopupFactory + * api + */ + +(async () => { + try { + api.forwardLogsToBackend(); + await yomichan.backendReady(); + + const {tabId, frameId} = await api.frameInformationGet(); + if (typeof frameId !== 'number') { + throw new Error('Failed to get frameId'); + } + + const hotkeyHandler = new HotkeyHandler(); + hotkeyHandler.prepare(); + + const popupFactory = new PopupFactory(frameId); + popupFactory.prepare(); + + const frontend = new Frontend({ + tabId, + frameId, + popupFactory, + depth: 0, + parentPopupId: null, + parentFrameId: null, + useProxyPopup: false, + pageType: 'web', + allowRootFramePopupProxy: true, + hotkeyHandler + }); + await frontend.prepare(); + + yomichan.ready(); + } catch (e) { + yomichan.logError(e); + } +})(); diff --git a/ext/js/app/frontend.js b/ext/js/app/frontend.js new file mode 100644 index 00000000..a62b06bf --- /dev/null +++ b/ext/js/app/frontend.js @@ -0,0 +1,691 @@ +/* + * Copyright (C) 2016-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/* global + * DocumentUtil + * TextScanner + * TextSourceElement + * TextSourceRange + * api + */ + +class Frontend { + constructor({ + pageType, + popupFactory, + depth, + tabId, + frameId, + parentPopupId, + parentFrameId, + useProxyPopup, + canUseWindowPopup=true, + allowRootFramePopupProxy, + childrenSupported=true, + hotkeyHandler + }) { + this._pageType = pageType; + this._popupFactory = popupFactory; + this._depth = depth; + this._tabId = tabId; + this._frameId = frameId; + this._parentPopupId = parentPopupId; + this._parentFrameId = parentFrameId; + this._useProxyPopup = useProxyPopup; + this._canUseWindowPopup = canUseWindowPopup; + this._allowRootFramePopupProxy = allowRootFramePopupProxy; + this._childrenSupported = childrenSupported; + this._hotkeyHandler = hotkeyHandler; + this._popup = null; + this._disabledOverride = false; + this._options = null; + this._pageZoomFactor = 1.0; + this._contentScale = 1.0; + this._lastShowPromise = Promise.resolve(); + this._documentUtil = new DocumentUtil(); + this._textScanner = new TextScanner({ + node: window, + ignoreElements: this._ignoreElements.bind(this), + ignorePoint: this._ignorePoint.bind(this), + getSearchContext: this._getSearchContext.bind(this), + documentUtil: this._documentUtil, + searchTerms: true, + searchKanji: true + }); + this._popupCache = new Map(); + this._popupEventListeners = new EventListenerCollection(); + this._updatePopupToken = null; + this._clearSelectionTimer = null; + this._isPointerOverPopup = false; + this._optionsContextOverride = null; + + this._runtimeMessageHandlers = new Map([ + ['requestFrontendReadyBroadcast', {async: false, handler: this._onMessageRequestFrontendReadyBroadcast.bind(this)}], + ['setAllVisibleOverride', {async: true, handler: this._onApiSetAllVisibleOverride.bind(this)}], + ['clearAllVisibleOverride', {async: true, handler: this._onApiClearAllVisibleOverride.bind(this)}] + ]); + + this._hotkeyHandler.registerActions([ + ['scanSelectedText', this._onActionScanSelectedText.bind(this)] + ]); + } + + get canClearSelection() { + return this._textScanner.canClearSelection; + } + + set canClearSelection(value) { + this._textScanner.canClearSelection = value; + } + + get popup() { + return this._popup; + } + + async prepare() { + await this.updateOptions(); + try { + const {zoomFactor} = await api.getZoom(); + this._pageZoomFactor = zoomFactor; + } catch (e) { + // Ignore exceptions which may occur due to being on an unsupported page (e.g. about:blank) + } + + this._textScanner.prepare(); + + window.addEventListener('resize', this._onResize.bind(this), false); + DocumentUtil.addFullscreenChangeEventListener(this._updatePopup.bind(this)); + + const visualViewport = window.visualViewport; + if (visualViewport !== null && typeof visualViewport === 'object') { + visualViewport.addEventListener('scroll', this._onVisualViewportScroll.bind(this)); + visualViewport.addEventListener('resize', this._onVisualViewportResize.bind(this)); + } + + yomichan.on('optionsUpdated', this.updateOptions.bind(this)); + yomichan.on('zoomChanged', this._onZoomChanged.bind(this)); + yomichan.on('closePopups', this._onClosePopups.bind(this)); + chrome.runtime.onMessage.addListener(this._onRuntimeMessage.bind(this)); + + this._textScanner.on('clearSelection', this._onClearSelection.bind(this)); + this._textScanner.on('searched', this._onSearched.bind(this)); + + api.crossFrame.registerHandlers([ + ['closePopup', {async: false, handler: this._onApiClosePopup.bind(this)}], + ['copySelection', {async: false, handler: this._onApiCopySelection.bind(this)}], + ['getSelectionText', {async: false, handler: this._onApiGetSelectionText.bind(this)}], + ['getPopupInfo', {async: false, handler: this._onApiGetPopupInfo.bind(this)}], + ['getPageInfo', {async: false, handler: this._onApiGetPageInfo.bind(this)}], + ['getFrameSize', {async: true, handler: this._onApiGetFrameSize.bind(this)}], + ['setFrameSize', {async: true, handler: this._onApiSetFrameSize.bind(this)}] + ]); + + this._updateContentScale(); + this._signalFrontendReady(); + } + + setDisabledOverride(disabled) { + this._disabledOverride = disabled; + this._updateTextScannerEnabled(); + } + + setOptionsContextOverride(optionsContext) { + this._optionsContextOverride = optionsContext; + } + + async setTextSource(textSource) { + this._textScanner.setCurrentTextSource(null); + await this._textScanner.search(textSource); + } + + async updateOptions() { + try { + await this._updateOptionsInternal(); + } catch (e) { + if (!yomichan.isExtensionUnloaded) { + throw e; + } + } + } + + showContentCompleted() { + return this._lastShowPromise; + } + + // Message handlers + + _onMessageRequestFrontendReadyBroadcast({frameId}) { + this._signalFrontendReady(frameId); + } + + // Action handlers + + _onActionScanSelectedText() { + this._scanSelectedText(); + } + + // API message handlers + + _onApiGetUrl() { + return window.location.href; + } + + _onApiClosePopup() { + this._clearSelection(false); + } + + _onApiCopySelection() { + // This will not work on Firefox if a popup has focus, which is usually the case when this function is called. + document.execCommand('copy'); + } + + _onApiGetSelectionText() { + return document.getSelection().toString(); + } + + _onApiGetPopupInfo() { + return { + popupId: (this._popup !== null ? this._popup.id : null) + }; + } + + _onApiGetPageInfo() { + return { + url: window.location.href, + documentTitle: document.title + }; + } + + async _onApiSetAllVisibleOverride({value, priority, awaitFrame}) { + const result = await this._popupFactory.setAllVisibleOverride(value, priority); + if (awaitFrame) { + await promiseAnimationFrame(100); + } + return result; + } + + async _onApiClearAllVisibleOverride({token}) { + return await this._popupFactory.clearAllVisibleOverride(token); + } + + async _onApiGetFrameSize() { + return await this._popup.getFrameSize(); + } + + async _onApiSetFrameSize({width, height}) { + return await this._popup.setFrameSize(width, height); + } + + // Private + + _onResize() { + this._updatePopupPosition(); + } + + _onRuntimeMessage({action, params}, sender, callback) { + const messageHandler = this._runtimeMessageHandlers.get(action); + if (typeof messageHandler === 'undefined') { return false; } + return yomichan.invokeMessageHandler(messageHandler, params, callback, sender); + } + + _onZoomChanged({newZoomFactor}) { + this._pageZoomFactor = newZoomFactor; + this._updateContentScale(); + } + + _onClosePopups() { + this._clearSelection(true); + } + + _onVisualViewportScroll() { + this._updatePopupPosition(); + } + + _onVisualViewportResize() { + this._updateContentScale(); + } + + _onClearSelection({passive}) { + this._stopClearSelectionDelayed(); + if (this._popup !== null) { + this._popup.hide(!passive); + this._popup.clearAutoPlayTimer(); + this._isPointerOverPopup = false; + } + } + + _onSearched({type, definitions, sentence, inputInfo: {eventType, passive, detail}, textSource, optionsContext, detail: {documentTitle}, error}) { + const scanningOptions = this._options.scanning; + + if (error !== null) { + if (yomichan.isExtensionUnloaded) { + if (textSource !== null && !passive) { + this._showExtensionUnloaded(textSource); + } + } else { + yomichan.logError(error); + } + } if (type !== null) { + this._stopClearSelectionDelayed(); + let focus = (eventType === 'mouseMove'); + if (isObject(detail)) { + const focus2 = detail.focus; + if (typeof focus2 === 'boolean') { focus = focus2; } + } + this._showContent(textSource, focus, definitions, type, sentence, documentTitle, optionsContext); + } else { + if (scanningOptions.autoHideResults) { + this._clearSelectionDelayed(scanningOptions.hideDelay, false); + } + } + } + + _onPopupFramePointerOver() { + this._isPointerOverPopup = true; + this._stopClearSelectionDelayed(); + } + + _onPopupFramePointerOut() { + this._isPointerOverPopup = false; + } + + _clearSelection(passive) { + this._stopClearSelectionDelayed(); + this._textScanner.clearSelection(passive); + } + + _clearSelectionDelayed(delay, restart, passive) { + if (!this._textScanner.hasSelection()) { return; } + if (delay > 0) { + if (this._clearSelectionTimer !== null && !restart) { return; } // Already running + this._stopClearSelectionDelayed(); + this._clearSelectionTimer = setTimeout(() => { + this._clearSelectionTimer = null; + if (this._isPointerOverPopup) { return; } + this._clearSelection(passive); + }, delay); + } else { + this._clearSelection(passive); + } + } + + _stopClearSelectionDelayed() { + if (this._clearSelectionTimer !== null) { + clearTimeout(this._clearSelectionTimer); + this._clearSelectionTimer = null; + } + } + + async _updateOptionsInternal() { + const optionsContext = await this._getOptionsContext(); + const options = await api.optionsGet(optionsContext); + const {scanning: scanningOptions, sentenceParsing: sentenceParsingOptions} = options; + this._options = options; + + this._hotkeyHandler.setHotkeys('web', options.inputs.hotkeys); + + await this._updatePopup(); + + const preventMiddleMouse = this._getPreventMiddleMouseValueForPageType(scanningOptions.preventMiddleMouse); + this._textScanner.setOptions({ + inputs: scanningOptions.inputs, + deepContentScan: scanningOptions.deepDomScan, + selectText: scanningOptions.selectText, + delay: scanningOptions.delay, + touchInputEnabled: scanningOptions.touchInputEnabled, + pointerEventsEnabled: scanningOptions.pointerEventsEnabled, + scanLength: scanningOptions.length, + layoutAwareScan: scanningOptions.layoutAwareScan, + preventMiddleMouse, + sentenceParsingOptions + }); + this._updateTextScannerEnabled(); + + if (this._pageType !== 'web') { + const excludeSelectors = ['.scan-disable', '.scan-disable *']; + if (!scanningOptions.enableOnPopupExpressions) { + excludeSelectors.push('.source-text', '.source-text *'); + } + this._textScanner.excludeSelector = excludeSelectors.join(','); + } + + this._updateContentScale(); + + await this._textScanner.searchLast(); + } + + async _updatePopup() { + const {usePopupWindow, showIframePopupsInRootFrame} = this._options.general; + const isIframe = !this._useProxyPopup && (window !== window.parent); + + const currentPopup = this._popup; + + let popupPromise; + if (usePopupWindow && this._canUseWindowPopup) { + popupPromise = this._popupCache.get('window'); + if (typeof popupPromise === 'undefined') { + popupPromise = this._getPopupWindow(); + this._popupCache.set('window', popupPromise); + } + } else if ( + isIframe && + showIframePopupsInRootFrame && + DocumentUtil.getFullscreenElement() === null && + this._allowRootFramePopupProxy + ) { + popupPromise = this._popupCache.get('iframe'); + if (typeof popupPromise === 'undefined') { + popupPromise = this._getIframeProxyPopup(); + this._popupCache.set('iframe', popupPromise); + } + } else if (this._useProxyPopup) { + popupPromise = this._popupCache.get('proxy'); + if (typeof popupPromise === 'undefined') { + popupPromise = this._getProxyPopup(); + this._popupCache.set('proxy', popupPromise); + } + } else { + popupPromise = this._popupCache.get('default'); + if (typeof popupPromise === 'undefined') { + popupPromise = this._getDefaultPopup(); + this._popupCache.set('default', popupPromise); + } + } + + // The token below is used as a unique identifier to ensure that a new _updatePopup call + // hasn't been started during the await. + const token = {}; + this._updatePopupToken = token; + const popup = await popupPromise; + const optionsContext = await this._getOptionsContext(); + if (this._updatePopupToken !== token) { return; } + if (popup !== null) { + await popup.setOptionsContext(optionsContext); + } + if (this._updatePopupToken !== token) { return; } + + if (popup !== currentPopup) { + this._clearSelection(true); + } + + this._popupEventListeners.removeAllEventListeners(); + this._popup = popup; + if (popup !== null) { + this._popupEventListeners.on(popup, 'framePointerOver', this._onPopupFramePointerOver.bind(this)); + this._popupEventListeners.on(popup, 'framePointerOut', this._onPopupFramePointerOut.bind(this)); + } + this._isPointerOverPopup = false; + } + + async _getDefaultPopup() { + const isXmlDocument = (typeof XMLDocument !== 'undefined' && document instanceof XMLDocument); + if (isXmlDocument) { + return null; + } + + return await this._popupFactory.getOrCreatePopup({ + frameId: this._frameId, + depth: this._depth, + childrenSupported: this._childrenSupported + }); + } + + async _getProxyPopup() { + return await this._popupFactory.getOrCreatePopup({ + frameId: this._parentFrameId, + depth: this._depth, + parentPopupId: this._parentPopupId, + childrenSupported: this._childrenSupported + }); + } + + async _getIframeProxyPopup() { + const targetFrameId = 0; // Root frameId + try { + await this._waitForFrontendReady(targetFrameId); + } catch (e) { + // Root frame not available + return await this._getDefaultPopup(); + } + + const {popupId} = await api.crossFrame.invoke(targetFrameId, 'getPopupInfo'); + if (popupId === null) { + return null; + } + + const popup = await this._popupFactory.getOrCreatePopup({ + frameId: targetFrameId, + id: popupId, + childrenSupported: this._childrenSupported + }); + popup.on('offsetNotFound', () => { + this._allowRootFramePopupProxy = false; + this._updatePopup(); + }); + return popup; + } + + async _getPopupWindow() { + return await this._popupFactory.getOrCreatePopup({ + depth: this._depth, + popupWindow: true, + childrenSupported: this._childrenSupported + }); + } + + _ignoreElements() { + if (this._popup !== null) { + const container = this._popup.container; + if (container !== null) { + return [container]; + } + } + return []; + } + + async _ignorePoint(x, y) { + try { + return this._popup !== null && await this._popup.containsPoint(x, y); + } catch (e) { + if (!yomichan.isExtensionUnloaded) { + throw e; + } + return false; + } + } + + _showExtensionUnloaded(textSource) { + if (textSource === null) { + textSource = this._textScanner.getCurrentTextSource(); + if (textSource === null) { return; } + } + this._showPopupContent(textSource, null); + } + + _showContent(textSource, focus, definitions, type, sentence, documentTitle, optionsContext) { + const query = textSource.text(); + const {url} = optionsContext; + const details = { + focus, + history: false, + params: { + type, + query, + wildcards: 'off' + }, + state: { + focusEntry: 0, + optionsContext, + url, + sentence, + documentTitle + }, + content: { + definitions, + contentOrigin: { + tabId: this._tabId, + frameId: this._frameId + } + } + }; + if (textSource instanceof TextSourceElement && textSource.fullContent !== query) { + details.params.full = textSource.fullContent; + details.params['full-visible'] = 'true'; + } + this._showPopupContent(textSource, optionsContext, details); + } + + _showPopupContent(textSource, optionsContext, details=null) { + this._lastShowPromise = ( + this._popup !== null ? + this._popup.showContent( + { + optionsContext, + elementRect: textSource.getRect(), + writingMode: textSource.getWritingMode() + }, + details + ) : + Promise.resolve() + ); + this._lastShowPromise.catch((error) => { + if (yomichan.isExtensionUnloaded) { return; } + yomichan.logError(error); + }); + return this._lastShowPromise; + } + + _updateTextScannerEnabled() { + const enabled = (this._options !== null && this._options.general.enable && !this._disabledOverride); + this._textScanner.setEnabled(enabled); + } + + _updateContentScale() { + const {popupScalingFactor, popupScaleRelativeToPageZoom, popupScaleRelativeToVisualViewport} = this._options.general; + let contentScale = popupScalingFactor; + if (popupScaleRelativeToPageZoom) { + contentScale /= this._pageZoomFactor; + } + if (popupScaleRelativeToVisualViewport) { + const visualViewport = window.visualViewport; + const visualViewportScale = (visualViewport !== null && typeof visualViewport === 'object' ? visualViewport.scale : 1.0); + contentScale /= visualViewportScale; + } + if (contentScale === this._contentScale) { return; } + + this._contentScale = contentScale; + if (this._popup !== null) { + this._popup.setContentScale(this._contentScale); + } + this._updatePopupPosition(); + } + + async _updatePopupPosition() { + const textSource = this._textScanner.getCurrentTextSource(); + if ( + textSource !== null && + this._popup !== null && + await this._popup.isVisible() + ) { + this._showPopupContent(textSource, null); + } + } + + _signalFrontendReady(targetFrameId=null) { + const params = {frameId: this._frameId}; + if (targetFrameId === null) { + api.broadcastTab('frontendReady', params); + } else { + api.sendMessageToFrame(targetFrameId, 'frontendReady', params); + } + } + + async _waitForFrontendReady(frameId) { + const promise = yomichan.getTemporaryListenerResult( + chrome.runtime.onMessage, + ({action, params}, {resolve}) => { + if ( + action === 'frontendReady' && + params.frameId === frameId + ) { + resolve(); + } + }, + 10000 + ); + api.broadcastTab('requestFrontendReadyBroadcast', {frameId: this._frameId}); + await promise; + } + + _getPreventMiddleMouseValueForPageType(preventMiddleMouseOptions) { + switch (this._pageType) { + case 'web': return preventMiddleMouseOptions.onWebPages; + case 'popup': return preventMiddleMouseOptions.onPopupPages; + case 'search': return preventMiddleMouseOptions.onSearchPages; + default: return false; + } + } + + async _getOptionsContext() { + let optionsContext = this._optionsContextOverride; + if (optionsContext === null) { + optionsContext = (await this._getSearchContext()).optionsContext; + } + return optionsContext; + } + + async _getSearchContext() { + let url = window.location.href; + let documentTitle = document.title; + if (this._useProxyPopup) { + try { + ({url, documentTitle} = await api.crossFrame.invoke(this._parentFrameId, 'getPageInfo', {})); + } catch (e) { + // NOP + } + } + + let optionsContext = this._optionsContextOverride; + if (optionsContext === null) { + optionsContext = {depth: this._depth, url}; + } + + return { + optionsContext, + detail: {documentTitle} + }; + } + + async _scanSelectedText() { + const range = this._getFirstNonEmptySelectionRange(); + if (range === null) { return false; } + const source = new TextSourceRange(range, range.toString(), null, null); + await this._textScanner.search(source, {focus: true}); + return true; + } + + _getFirstNonEmptySelectionRange() { + const selection = window.getSelection(); + for (let i = 0, ii = selection.rangeCount; i < ii; ++i) { + const range = selection.getRangeAt(i); + if (range.toString().length > 0) { + return range; + } + } + return null; + } +} diff --git a/ext/js/app/popup-factory.js b/ext/js/app/popup-factory.js new file mode 100644 index 00000000..7571d7ab --- /dev/null +++ b/ext/js/app/popup-factory.js @@ -0,0 +1,319 @@ +/* + * Copyright (C) 2019-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/* global + * FrameOffsetForwarder + * Popup + * PopupProxy + * PopupWindow + * api + */ + +class PopupFactory { + constructor(frameId) { + this._frameId = frameId; + this._frameOffsetForwarder = new FrameOffsetForwarder(frameId); + this._popups = new Map(); + this._allPopupVisibilityTokenMap = new Map(); + } + + // Public functions + + prepare() { + this._frameOffsetForwarder.prepare(); + api.crossFrame.registerHandlers([ + ['getOrCreatePopup', {async: true, handler: this._onApiGetOrCreatePopup.bind(this)}], + ['setOptionsContext', {async: true, handler: this._onApiSetOptionsContext.bind(this)}], + ['hide', {async: false, handler: this._onApiHide.bind(this)}], + ['isVisible', {async: true, handler: this._onApiIsVisibleAsync.bind(this)}], + ['setVisibleOverride', {async: true, handler: this._onApiSetVisibleOverride.bind(this)}], + ['clearVisibleOverride', {async: true, handler: this._onApiClearVisibleOverride.bind(this)}], + ['containsPoint', {async: true, handler: this._onApiContainsPoint.bind(this)}], + ['showContent', {async: true, handler: this._onApiShowContent.bind(this)}], + ['setCustomCss', {async: false, handler: this._onApiSetCustomCss.bind(this)}], + ['clearAutoPlayTimer', {async: false, handler: this._onApiClearAutoPlayTimer.bind(this)}], + ['setContentScale', {async: false, handler: this._onApiSetContentScale.bind(this)}], + ['updateTheme', {async: false, handler: this._onApiUpdateTheme.bind(this)}], + ['setCustomOuterCss', {async: false, handler: this._onApiSetCustomOuterCss.bind(this)}], + ['popup.getFrameSize', {async: true, handler: this._onApiGetFrameSize.bind(this)}], + ['popup.setFrameSize', {async: true, handler: this._onApiSetFrameSize.bind(this)}] + ]); + } + + async getOrCreatePopup({ + frameId=null, + id=null, + parentPopupId=null, + depth=null, + popupWindow=false, + childrenSupported=false + }) { + // Find by existing id + if (id !== null) { + const popup = this._popups.get(id); + if (typeof popup !== 'undefined') { + return popup; + } + } + + // Find by existing parent id + let parent = null; + if (parentPopupId !== null) { + parent = this._popups.get(parentPopupId); + if (typeof parent !== 'undefined') { + const popup = parent.child; + if (popup !== null) { + return popup; + } + } else { + parent = null; + } + } + + // Depth + if (parent !== null) { + if (depth !== null) { + throw new Error('Depth cannot be set when parent exists'); + } + depth = parent.depth + 1; + } else if (depth === null) { + depth = 0; + } + + if (popupWindow) { + // New unique id + if (id === null) { + id = generateId(16); + } + const popup = new PopupWindow({ + id, + depth, + frameId: this._frameId + }); + this._popups.set(id, popup); + return popup; + } else if (frameId === this._frameId) { + // New unique id + if (id === null) { + id = generateId(16); + } + const popup = new Popup({ + id, + depth, + frameId: this._frameId, + childrenSupported + }); + if (parent !== null) { + if (parent.child !== null) { + throw new Error('Parent popup already has a child'); + } + popup.parent = parent; + parent.child = popup; + } + this._popups.set(id, popup); + popup.prepare(); + return popup; + } else { + if (frameId === null) { + throw new Error('Invalid frameId'); + } + const useFrameOffsetForwarder = (parentPopupId === null); + ({id, depth, frameId} = await api.crossFrame.invoke(frameId, 'getOrCreatePopup', { + id, + parentPopupId, + frameId, + childrenSupported + })); + const popup = new PopupProxy({ + id, + depth, + frameId, + frameOffsetForwarder: useFrameOffsetForwarder ? this._frameOffsetForwarder : null + }); + this._popups.set(id, popup); + return popup; + } + } + + async setAllVisibleOverride(value, priority) { + const promises = []; + const errors = []; + for (const popup of this._popups.values()) { + const promise = popup.setVisibleOverride(value, priority) + .then( + (token) => ({popup, token}), + (error) => { errors.push(error); return null; } + ); + promises.push(promise); + } + + const results = (await Promise.all(promises)).filter(({token}) => token !== null); + + if (errors.length === 0) { + const token = generateId(16); + this._allPopupVisibilityTokenMap.set(token, results); + return token; + } + + // Revert on error + await this._revertPopupVisibilityOverrides(results); + throw errors[0]; + } + + async clearAllVisibleOverride(token) { + const results = this._allPopupVisibilityTokenMap.get(token); + if (typeof results === 'undefined') { return false; } + + this._allPopupVisibilityTokenMap.delete(token); + await this._revertPopupVisibilityOverrides(results); + return true; + } + + // API message handlers + + async _onApiGetOrCreatePopup(details) { + const popup = await this.getOrCreatePopup(details); + return { + id: popup.id, + depth: popup.depth, + frameId: popup.frameId + }; + } + + async _onApiSetOptionsContext({id, optionsContext, source}) { + const popup = this._getPopup(id); + return await popup.setOptionsContext(optionsContext, source); + } + + _onApiHide({id, changeFocus}) { + const popup = this._getPopup(id); + return popup.hide(changeFocus); + } + + async _onApiIsVisibleAsync({id}) { + const popup = this._getPopup(id); + return await popup.isVisible(); + } + + async _onApiSetVisibleOverride({id, value, priority}) { + const popup = this._getPopup(id); + return await popup.setVisibleOverride(value, priority); + } + + async _onApiClearVisibleOverride({id, token}) { + const popup = this._getPopup(id); + return await popup.clearVisibleOverride(token); + } + + async _onApiContainsPoint({id, x, y}) { + const popup = this._getPopup(id); + [x, y] = this._convertPopupPointToRootPagePoint(popup, x, y); + return await popup.containsPoint(x, y); + } + + async _onApiShowContent({id, details, displayDetails}) { + const popup = this._getPopup(id); + if (!this._popupCanShow(popup)) { return; } + + const {elementRect} = details; + if (typeof elementRect !== 'undefined') { + details.elementRect = this._convertJsonRectToDOMRect(popup, elementRect); + } + + return await popup.showContent(details, displayDetails); + } + + _onApiSetCustomCss({id, css}) { + const popup = this._getPopup(id); + return popup.setCustomCss(css); + } + + _onApiClearAutoPlayTimer({id}) { + const popup = this._getPopup(id); + return popup.clearAutoPlayTimer(); + } + + _onApiSetContentScale({id, scale}) { + const popup = this._getPopup(id); + return popup.setContentScale(scale); + } + + _onApiUpdateTheme({id}) { + const popup = this._getPopup(id); + return popup.updateTheme(); + } + + _onApiSetCustomOuterCss({id, css, useWebExtensionApi}) { + const popup = this._getPopup(id); + return popup.setCustomOuterCss(css, useWebExtensionApi); + } + + async _onApiGetFrameSize({id}) { + const popup = this._getPopup(id); + return await popup.getFrameSize(); + } + + async _onApiSetFrameSize({id, width, height}) { + const popup = this._getPopup(id); + return await popup.setFrameSize(width, height); + } + + // Private functions + + _getPopup(id) { + const popup = this._popups.get(id); + if (typeof popup === 'undefined') { + throw new Error(`Invalid popup ID ${id}`); + } + return popup; + } + + _convertJsonRectToDOMRect(popup, jsonRect) { + const [x, y] = this._convertPopupPointToRootPagePoint(popup, jsonRect.x, jsonRect.y); + return new DOMRect(x, y, jsonRect.width, jsonRect.height); + } + + _convertPopupPointToRootPagePoint(popup, x, y) { + const parent = popup.parent; + if (parent !== null) { + const popupRect = parent.getFrameRect(); + x += popupRect.x; + y += popupRect.y; + } + return [x, y]; + } + + _popupCanShow(popup) { + const parent = popup.parent; + return parent === null || parent.isVisibleSync(); + } + + async _revertPopupVisibilityOverrides(overrides) { + const promises = []; + for (const value of overrides) { + if (value === null) { continue; } + const {popup, token} = value; + const promise = popup.clearVisibleOverride(token) + .then( + (v) => v, + () => false + ); + promises.push(promise); + } + return await Promise.all(promises); + } +} diff --git a/ext/js/app/popup-proxy.js b/ext/js/app/popup-proxy.js new file mode 100644 index 00000000..b2e81824 --- /dev/null +++ b/ext/js/app/popup-proxy.js @@ -0,0 +1,218 @@ +/* + * Copyright (C) 2019-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/* global + * api + */ + +class PopupProxy extends EventDispatcher { + constructor({ + id, + depth, + frameId, + frameOffsetForwarder + }) { + super(); + this._id = id; + this._depth = depth; + this._frameId = frameId; + this._frameOffsetForwarder = frameOffsetForwarder; + + this._frameOffset = [0, 0]; + this._frameOffsetPromise = null; + this._frameOffsetUpdatedAt = null; + this._frameOffsetExpireTimeout = 1000; + } + + // Public properties + + get id() { + return this._id; + } + + get parent() { + return null; + } + + set parent(value) { + throw new Error('Not supported on PopupProxy'); + } + + get child() { + return null; + } + + set child(value) { + throw new Error('Not supported on PopupProxy'); + } + + get depth() { + return this._depth; + } + + get frameContentWindow() { + return null; + } + + get container() { + return null; + } + + get frameId() { + return this._frameId; + } + + // Public functions + + setOptionsContext(optionsContext, source) { + return this._invokeSafe('setOptionsContext', {id: this._id, optionsContext, source}); + } + + hide(changeFocus) { + return this._invokeSafe('hide', {id: this._id, changeFocus}); + } + + isVisible() { + return this._invokeSafe('isVisible', {id: this._id}, false); + } + + setVisibleOverride(value, priority) { + return this._invokeSafe('setVisibleOverride', {id: this._id, value, priority}, null); + } + + clearVisibleOverride(token) { + return this._invokeSafe('clearVisibleOverride', {id: this._id, token}, false); + } + + async containsPoint(x, y) { + if (this._frameOffsetForwarder !== null) { + await this._updateFrameOffset(); + [x, y] = this._applyFrameOffset(x, y); + } + return await this._invokeSafe('containsPoint', {id: this._id, x, y}, false); + } + + async showContent(details, displayDetails) { + const {elementRect} = details; + if (typeof elementRect !== 'undefined') { + let {x, y, width, height} = elementRect; + if (this._frameOffsetForwarder !== null) { + await this._updateFrameOffset(); + [x, y] = this._applyFrameOffset(x, y); + } + details.elementRect = {x, y, width, height}; + } + return await this._invokeSafe('showContent', {id: this._id, details, displayDetails}); + } + + setCustomCss(css) { + return this._invokeSafe('setCustomCss', {id: this._id, css}); + } + + clearAutoPlayTimer() { + return this._invokeSafe('clearAutoPlayTimer', {id: this._id}); + } + + setContentScale(scale) { + return this._invokeSafe('setContentScale', {id: this._id, scale}); + } + + isVisibleSync() { + throw new Error('Not supported on PopupProxy'); + } + + updateTheme() { + return this._invokeSafe('updateTheme', {id: this._id}); + } + + setCustomOuterCss(css, useWebExtensionApi) { + return this._invokeSafe('setCustomOuterCss', {id: this._id, css, useWebExtensionApi}); + } + + getFrameRect() { + return new DOMRect(0, 0, 0, 0); + } + + getFrameSize() { + return this._invokeSafe('popup.getFrameSize', {id: this._id}, {width: 0, height: 0, valid: false}); + } + + setFrameSize(width, height) { + return this._invokeSafe('popup.setFrameSize', {id: this._id, width, height}); + } + + // Private + + _invoke(action, params={}) { + return api.crossFrame.invoke(this._frameId, action, params); + } + + async _invokeSafe(action, params={}, defaultReturnValue) { + try { + return await this._invoke(action, params); + } catch (e) { + if (!yomichan.isExtensionUnloaded) { throw e; } + return defaultReturnValue; + } + } + + async _updateFrameOffset() { + const now = Date.now(); + const firstRun = this._frameOffsetUpdatedAt === null; + const expired = firstRun || this._frameOffsetUpdatedAt < now - this._frameOffsetExpireTimeout; + if (this._frameOffsetPromise === null && !expired) { return; } + + if (this._frameOffsetPromise !== null) { + if (firstRun) { + await this._frameOffsetPromise; + } + return; + } + + const promise = this._updateFrameOffsetInner(now); + if (firstRun) { + await promise; + } + } + + async _updateFrameOffsetInner(now) { + this._frameOffsetPromise = this._frameOffsetForwarder.getOffset(); + try { + let offset = null; + try { + offset = await this._frameOffsetPromise; + } catch (e) { + // NOP + } + this._frameOffset = offset !== null ? offset : [0, 0]; + if (offset === null) { + this.trigger('offsetNotFound'); + return; + } + this._frameOffsetUpdatedAt = now; + } catch (e) { + yomichan.logError(e); + } finally { + this._frameOffsetPromise = null; + } + } + + _applyFrameOffset(x, y) { + const [offsetX, offsetY] = this._frameOffset; + return [x + offsetX, y + offsetY]; + } +} diff --git a/ext/js/app/popup-window.js b/ext/js/app/popup-window.js new file mode 100644 index 00000000..5fa0c647 --- /dev/null +++ b/ext/js/app/popup-window.js @@ -0,0 +1,169 @@ +/* + * Copyright (C) 2020-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/* global + * api + */ + +class PopupWindow extends EventDispatcher { + constructor({ + id, + depth, + frameId + }) { + super(); + this._id = id; + this._depth = depth; + this._frameId = frameId; + this._popupTabId = null; + } + + // Public properties + + get id() { + return this._id; + } + + get parent() { + return null; + } + + set parent(value) { + throw new Error('Not supported on PopupProxy'); + } + + get child() { + return null; + } + + set child(value) { + throw new Error('Not supported on PopupProxy'); + } + + get depth() { + return this._depth; + } + + get frameContentWindow() { + return null; + } + + get container() { + return null; + } + + get frameId() { + return this._frameId; + } + + + // Public functions + + setOptionsContext(optionsContext, source) { + return this._invoke(false, 'setOptionsContext', {id: this._id, optionsContext, source}); + } + + hide(_changeFocus) { + // NOP + } + + async isVisible() { + return (this._popupTabId !== null && await api.isTabSearchPopup(this._popupTabId)); + } + + async setVisibleOverride(_value, _priority) { + return null; + } + + clearVisibleOverride(_token) { + return false; + } + + async containsPoint(_x, _y) { + return false; + } + + async showContent(_details, displayDetails) { + if (displayDetails === null) { return; } + await this._invoke(true, 'setContent', {id: this._id, details: displayDetails}); + } + + setCustomCss(css) { + return this._invoke(false, 'setCustomCss', {id: this._id, css}); + } + + clearAutoPlayTimer() { + return this._invoke(false, 'clearAutoPlayTimer', {id: this._id}); + } + + setContentScale(_scale) { + // NOP + } + + isVisibleSync() { + throw new Error('Not supported on PopupWindow'); + } + + updateTheme() { + // NOP + } + + async setCustomOuterCss(_css, _useWebExtensionApi) { + // NOP + } + + getFrameRect() { + return new DOMRect(0, 0, 0, 0); + } + + async getFrameSize() { + return {width: 0, height: 0, valid: false}; + } + + async setFrameSize(_width, _height) { + return false; + } + + // Private + + async _invoke(open, action, params={}, defaultReturnValue) { + if (yomichan.isExtensionUnloaded) { + return defaultReturnValue; + } + + const frameId = 0; + if (this._popupTabId !== null) { + try { + return await api.crossFrame.invokeTab(this._popupTabId, frameId, 'popupMessage', {action, params}); + } catch (e) { + if (yomichan.isExtensionUnloaded) { + open = false; + } + } + this._popupTabId = null; + } + + if (!open) { + return defaultReturnValue; + } + + const {tabId} = await api.getOrCreateSearchPopup({focus: 'ifCreated'}); + this._popupTabId = tabId; + + return await api.crossFrame.invokeTab(this._popupTabId, frameId, 'popupMessage', {action, params}); + } +} diff --git a/ext/js/app/popup.js b/ext/js/app/popup.js new file mode 100644 index 00000000..75b74257 --- /dev/null +++ b/ext/js/app/popup.js @@ -0,0 +1,687 @@ +/* + * Copyright (C) 2016-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/* global + * DocumentUtil + * FrameClient + * api + * dynamicLoader + */ + +class Popup extends EventDispatcher { + constructor({ + id, + depth, + frameId, + childrenSupported + }) { + super(); + this._id = id; + this._depth = depth; + this._frameId = frameId; + this._childrenSupported = childrenSupported; + this._parent = null; + this._child = null; + this._injectPromise = null; + this._injectPromiseComplete = false; + this._visible = new DynamicProperty(false); + this._options = null; + this._optionsContext = null; + this._contentScale = 1.0; + this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, ''); + + this._frameSizeContentScale = null; + this._frameClient = null; + this._frame = document.createElement('iframe'); + this._frame.className = 'yomichan-popup'; + this._frame.style.width = '0'; + this._frame.style.height = '0'; + + this._container = this._frame; + this._shadow = null; + + this._fullscreenEventListeners = new EventListenerCollection(); + } + + // Public properties + + get id() { + return this._id; + } + + get parent() { + return this._parent; + } + + set parent(value) { + this._parent = value; + } + + get child() { + return this._child; + } + + set child(value) { + this._child = value; + } + + get depth() { + return this._depth; + } + + get frameContentWindow() { + return this._frame.contentWindow; + } + + get container() { + return this._container; + } + + get frameId() { + return this._frameId; + } + + // Public functions + + prepare() { + this._frame.addEventListener('mouseover', this._onFrameMouseOver.bind(this)); + this._frame.addEventListener('mouseout', this._onFrameMouseOut.bind(this)); + this._frame.addEventListener('mousedown', (e) => e.stopPropagation()); + this._frame.addEventListener('scroll', (e) => e.stopPropagation()); + this._frame.addEventListener('load', this._onFrameLoad.bind(this)); + this._visible.on('change', this._onVisibleChange.bind(this)); + yomichan.on('extensionUnloaded', this._onExtensionUnloaded.bind(this)); + this._onVisibleChange({value: this.isVisibleSync()}); + } + + async setOptionsContext(optionsContext) { + await this._setOptionsContext(optionsContext); + await this._invokeSafe('setOptionsContext', {optionsContext}); + } + + hide(changeFocus) { + if (!this.isVisibleSync()) { + return; + } + + this._setVisible(false); + if (this._child !== null) { + this._child.hide(false); + } + if (changeFocus) { + this._focusParent(); + } + } + + async isVisible() { + return this.isVisibleSync(); + } + + async setVisibleOverride(value, priority) { + return this._visible.setOverride(value, priority); + } + + async clearVisibleOverride(token) { + return this._visible.clearOverride(token); + } + + async containsPoint(x, y) { + for (let popup = this; popup !== null && popup.isVisibleSync(); popup = popup.child) { + const rect = popup.getFrameRect(); + if (x >= rect.left && y >= rect.top && x < rect.right && y < rect.bottom) { + return true; + } + } + return false; + } + + async showContent(details, displayDetails) { + if (this._options === null) { throw new Error('Options not assigned'); } + + const {optionsContext, elementRect, writingMode} = details; + if (optionsContext !== null) { + await this._setOptionsContextIfDifferent(optionsContext); + } + + if (typeof elementRect !== 'undefined' && typeof writingMode !== 'undefined') { + await this._show(elementRect, writingMode); + } + + if (displayDetails !== null) { + this._invokeSafe('setContent', {details: displayDetails}); + } + } + + setCustomCss(css) { + this._invokeSafe('setCustomCss', {css}); + } + + clearAutoPlayTimer() { + this._invokeSafe('clearAutoPlayTimer'); + } + + setContentScale(scale) { + this._contentScale = scale; + this._frame.style.fontSize = `${scale}px`; + this._invokeSafe('setContentScale', {scale}); + } + + isVisibleSync() { + return this._visible.value; + } + + updateTheme() { + const {popupTheme, popupOuterTheme} = this._options.general; + this._frame.dataset.theme = popupTheme; + this._frame.dataset.outerTheme = popupOuterTheme; + this._frame.dataset.siteColor = this._getSiteColor(); + } + + async setCustomOuterCss(css, useWebExtensionApi) { + let parentNode = null; + const inShadow = (this._shadow !== null); + if (inShadow) { + useWebExtensionApi = false; + parentNode = this._shadow; + } + const node = await dynamicLoader.loadStyle('yomichan-popup-outer-user-stylesheet', 'code', css, useWebExtensionApi, parentNode); + this.trigger('customOuterCssChanged', {node, useWebExtensionApi, inShadow}); + } + + getFrameRect() { + return this._frame.getBoundingClientRect(); + } + + async getFrameSize() { + const rect = this._frame.getBoundingClientRect(); + return {width: rect.width, height: rect.height, valid: true}; + } + + async setFrameSize(width, height) { + this._setFrameSize(width, height); + return true; + } + + // Private functions + + _onFrameMouseOver() { + this.trigger('framePointerOver', {}); + } + + _onFrameMouseOut() { + this.trigger('framePointerOut', {}); + } + + _inject() { + let injectPromise = this._injectPromise; + if (injectPromise === null) { + injectPromise = this._createInjectPromise(); + this._injectPromise = injectPromise; + injectPromise.then( + () => { + if (injectPromise !== this._injectPromise) { return; } + this._injectPromiseComplete = true; + }, + () => { this._resetFrame(); } + ); + } + return injectPromise; + } + + async _createInjectPromise() { + if (this._options === null) { + throw new Error('Options not initialized'); + } + + const {useSecurePopupFrameUrl, usePopupShadowDom} = this._options.general; + + await this._setUpContainer(usePopupShadowDom); + + const setupFrame = (frame) => { + frame.removeAttribute('src'); + frame.removeAttribute('srcdoc'); + this._observeFullscreen(true); + this._onFullscreenChanged(); + const url = chrome.runtime.getURL('/popup.html'); + if (useSecurePopupFrameUrl) { + frame.contentDocument.location.href = url; + } else { + frame.setAttribute('src', url); + } + }; + + const frameClient = new FrameClient(); + this._frameClient = frameClient; + await frameClient.connect(this._frame, this._targetOrigin, this._frameId, setupFrame); + + // Configure + await this._invokeSafe('configure', { + depth: this._depth, + parentPopupId: this._id, + parentFrameId: this._frameId, + childrenSupported: this._childrenSupported, + scale: this._contentScale, + optionsContext: this._optionsContext + }); + } + + _onFrameLoad() { + if (!this._injectPromiseComplete) { return; } + this._resetFrame(); + } + + _resetFrame() { + const parent = this._container.parentNode; + if (parent !== null) { + parent.removeChild(this._container); + } + this._frame.removeAttribute('src'); + this._frame.removeAttribute('srcdoc'); + + this._frameClient = null; + this._injectPromise = null; + this._injectPromiseComplete = false; + } + + async _setUpContainer(usePopupShadowDom) { + if (usePopupShadowDom && typeof this._frame.attachShadow === 'function') { + const container = document.createElement('div'); + container.style.setProperty('all', 'initial', 'important'); + const shadow = container.attachShadow({mode: 'closed', delegatesFocus: true}); + shadow.appendChild(this._frame); + + this._container = container; + this._shadow = shadow; + } else { + const frameParentNode = this._frame.parentNode; + if (frameParentNode !== null) { + frameParentNode.removeChild(this._frame); + } + + this._container = this._frame; + this._shadow = null; + } + + await this._injectStyles(); + } + + async _injectStyles() { + try { + await this._injectPopupOuterStylesheet(); + } catch (e) { + // NOP + } + + try { + await this.setCustomOuterCss(this._options.general.customPopupOuterCss, true); + } catch (e) { + // NOP + } + } + + async _injectPopupOuterStylesheet() { + let fileType = 'file'; + let useWebExtensionApi = true; + let parentNode = null; + if (this._shadow !== null) { + fileType = 'file-content'; + useWebExtensionApi = false; + parentNode = this._shadow; + } + await dynamicLoader.loadStyle('yomichan-popup-outer-stylesheet', fileType, '/css/popup-outer.css', useWebExtensionApi, parentNode); + } + + _observeFullscreen(observe) { + if (!observe) { + this._fullscreenEventListeners.removeAllEventListeners(); + return; + } + + if (this._fullscreenEventListeners.size > 0) { + // Already observing + return; + } + + DocumentUtil.addFullscreenChangeEventListener(this._onFullscreenChanged.bind(this), this._fullscreenEventListeners); + } + + _onFullscreenChanged() { + const parent = this._getFrameParentElement(); + if (parent !== null && this._container.parentNode !== parent) { + parent.appendChild(this._container); + } + } + + async _show(elementRect, writingMode) { + await this._inject(); + + const optionsGeneral = this._options.general; + const {popupDisplayMode} = optionsGeneral; + const frame = this._frame; + const frameRect = frame.getBoundingClientRect(); + + const viewport = this._getViewport(optionsGeneral.popupScaleRelativeToVisualViewport); + const scale = this._contentScale; + const scaleRatio = this._frameSizeContentScale === null ? 1.0 : scale / this._frameSizeContentScale; + this._frameSizeContentScale = scale; + const getPositionArgs = [ + elementRect, + Math.max(frameRect.width * scaleRatio, optionsGeneral.popupWidth * scale), + Math.max(frameRect.height * scaleRatio, optionsGeneral.popupHeight * scale), + viewport, + scale, + optionsGeneral, + writingMode + ]; + let [x, y, width, height, below] = ( + writingMode === 'horizontal-tb' || optionsGeneral.popupVerticalTextPosition === 'default' ? + this._getPositionForHorizontalText(...getPositionArgs) : + this._getPositionForVerticalText(...getPositionArgs) + ); + + frame.dataset.popupDisplayMode = popupDisplayMode; + frame.dataset.below = `${below}`; + + if (popupDisplayMode === 'full-width') { + x = viewport.left; + y = below ? viewport.bottom - height : viewport.top; + width = viewport.right - viewport.left; + } + + frame.style.left = `${x}px`; + frame.style.top = `${y}px`; + this._setFrameSize(width, height); + + this._setVisible(true); + if (this._child !== null) { + this._child.hide(true); + } + } + + _setFrameSize(width, height) { + const {style} = this._frame; + style.width = `${width}px`; + style.height = `${height}px`; + } + + _setVisible(visible) { + this._visible.defaultValue = visible; + } + + _onVisibleChange({value}) { + this._frame.style.setProperty('visibility', value ? 'visible' : 'hidden', 'important'); + } + + _focusParent() { + if (this._parent !== null) { + // Chrome doesn't like focusing iframe without contentWindow. + const contentWindow = this._parent.frameContentWindow; + if (contentWindow !== null) { + contentWindow.focus(); + } + } else { + // Firefox doesn't like focusing window without first blurring the iframe. + // this._frame.contentWindow.blur() doesn't work on Firefox for some reason. + this._frame.blur(); + // This is needed for Chrome. + window.focus(); + } + } + + _getSiteColor() { + const color = [255, 255, 255]; + const {documentElement, body} = document; + if (documentElement !== null) { + this._addColor(color, window.getComputedStyle(documentElement).backgroundColor); + } + if (body !== null) { + this._addColor(color, window.getComputedStyle(body).backgroundColor); + } + const dark = (color[0] < 128 && color[1] < 128 && color[2] < 128); + return dark ? 'dark' : 'light'; + } + + async _invoke(action, params={}) { + const contentWindow = this._frame.contentWindow; + if (this._frameClient === null || !this._frameClient.isConnected() || contentWindow === null) { return; } + + const message = this._frameClient.createMessage({action, params}); + return await api.crossFrame.invoke(this._frameClient.frameId, 'popupMessage', message); + } + + async _invokeSafe(action, params={}, defaultReturnValue) { + try { + return await this._invoke(action, params); + } catch (e) { + if (!yomichan.isExtensionUnloaded) { throw e; } + return defaultReturnValue; + } + } + + _invokeWindow(action, params={}) { + const contentWindow = this._frame.contentWindow; + if (this._frameClient === null || !this._frameClient.isConnected() || contentWindow === null) { return; } + + const message = this._frameClient.createMessage({action, params}); + contentWindow.postMessage(message, this._targetOrigin); + } + + _onExtensionUnloaded() { + this._invokeWindow('extensionUnloaded'); + } + + _getFrameParentElement() { + const defaultParent = document.body; + const fullscreenElement = DocumentUtil.getFullscreenElement(); + if ( + fullscreenElement === null || + fullscreenElement.shadowRoot || + fullscreenElement.openOrClosedShadowRoot // Available to Firefox 63+ for WebExtensions + ) { + return defaultParent; + } + + switch (fullscreenElement.nodeName.toUpperCase()) { + case 'IFRAME': + case 'FRAME': + return defaultParent; + } + + return fullscreenElement; + } + + _getPositionForHorizontalText(elementRect, width, height, viewport, offsetScale, optionsGeneral) { + const preferBelow = (optionsGeneral.popupHorizontalTextPosition === 'below'); + const horizontalOffset = optionsGeneral.popupHorizontalOffset * offsetScale; + const verticalOffset = optionsGeneral.popupVerticalOffset * offsetScale; + + const [x, w] = this._getConstrainedPosition( + elementRect.right - horizontalOffset, + elementRect.left + horizontalOffset, + width, + viewport.left, + viewport.right, + true + ); + const [y, h, below] = this._getConstrainedPositionBinary( + elementRect.top - verticalOffset, + elementRect.bottom + verticalOffset, + height, + viewport.top, + viewport.bottom, + preferBelow + ); + return [x, y, w, h, below]; + } + + _getPositionForVerticalText(elementRect, width, height, viewport, offsetScale, optionsGeneral, writingMode) { + const preferRight = this._isVerticalTextPopupOnRight(optionsGeneral.popupVerticalTextPosition, writingMode); + const horizontalOffset = optionsGeneral.popupHorizontalOffset2 * offsetScale; + const verticalOffset = optionsGeneral.popupVerticalOffset2 * offsetScale; + + const [x, w] = this._getConstrainedPositionBinary( + elementRect.left - horizontalOffset, + elementRect.right + horizontalOffset, + width, + viewport.left, + viewport.right, + preferRight + ); + const [y, h, below] = this._getConstrainedPosition( + elementRect.bottom - verticalOffset, + elementRect.top + verticalOffset, + height, + viewport.top, + viewport.bottom, + true + ); + return [x, y, w, h, below]; + } + + _isVerticalTextPopupOnRight(positionPreference, writingMode) { + switch (positionPreference) { + case 'before': + return !this._isWritingModeLeftToRight(writingMode); + case 'after': + return this._isWritingModeLeftToRight(writingMode); + case 'left': + return false; + case 'right': + return true; + default: + return false; + } + } + + _isWritingModeLeftToRight(writingMode) { + switch (writingMode) { + case 'vertical-lr': + case 'sideways-lr': + return true; + default: + return false; + } + } + + _getConstrainedPosition(positionBefore, positionAfter, size, minLimit, maxLimit, after) { + size = Math.min(size, maxLimit - minLimit); + + let position; + if (after) { + position = Math.max(minLimit, positionAfter); + position = position - Math.max(0, (position + size) - maxLimit); + } else { + position = Math.min(maxLimit, positionBefore) - size; + position = position + Math.max(0, minLimit - position); + } + + return [position, size, after]; + } + + _getConstrainedPositionBinary(positionBefore, positionAfter, size, minLimit, maxLimit, after) { + const overflowBefore = minLimit - (positionBefore - size); + const overflowAfter = (positionAfter + size) - maxLimit; + + if (overflowAfter > 0 || overflowBefore > 0) { + after = (overflowAfter < overflowBefore); + } + + let position; + if (after) { + size -= Math.max(0, overflowAfter); + position = Math.max(minLimit, positionAfter); + } else { + size -= Math.max(0, overflowBefore); + position = Math.min(maxLimit, positionBefore) - size; + } + + return [position, size, after]; + } + + _addColor(target, cssColor) { + if (typeof cssColor !== 'string') { return; } + + const color = this._getColorInfo(cssColor); + if (color === null) { return; } + + const a = color[3]; + if (a <= 0.0) { return; } + + const aInv = 1.0 - a; + for (let i = 0; i < 3; ++i) { + target[i] = target[i] * aInv + color[i] * a; + } + } + + _getColorInfo(cssColor) { + const m = /^\s*rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)\s*$/.exec(cssColor); + if (m === null) { return null; } + + const m4 = m[4]; + return [ + Number.parseInt(m[1], 10), + Number.parseInt(m[2], 10), + Number.parseInt(m[3], 10), + m4 ? Math.max(0.0, Math.min(1.0, Number.parseFloat(m4))) : 1.0 + ]; + } + + _getViewport(useVisualViewport) { + const visualViewport = window.visualViewport; + if (visualViewport !== null && typeof visualViewport === 'object') { + const left = visualViewport.offsetLeft; + const top = visualViewport.offsetTop; + const width = visualViewport.width; + const height = visualViewport.height; + if (useVisualViewport) { + return { + left, + top, + right: left + width, + bottom: top + height + }; + } else { + const scale = visualViewport.scale; + return { + left: 0, + top: 0, + right: Math.max(left + width, width * scale), + bottom: Math.max(top + height, height * scale) + }; + } + } + + const body = document.body; + return { + left: 0, + top: 0, + right: (body !== null ? body.clientWidth : 0), + bottom: window.innerHeight + }; + } + + async _setOptionsContext(optionsContext) { + this._optionsContext = optionsContext; + this._options = await api.optionsGet(optionsContext); + this.updateTheme(); + } + + async _setOptionsContextIfDifferent(optionsContext) { + if (deepEqual(this._optionsContext, optionsContext)) { return; } + await this._setOptionsContext(optionsContext); + } +} diff --git a/ext/js/comm/frame-ancestry-handler.js b/ext/js/comm/frame-ancestry-handler.js new file mode 100644 index 00000000..b1ed7114 --- /dev/null +++ b/ext/js/comm/frame-ancestry-handler.js @@ -0,0 +1,269 @@ +/* + * Copyright (C) 2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/* global + * api + */ + +/** + * This class is used to return the ancestor frame IDs for the current frame. + * This is a workaround to using the `webNavigation.getAllFrames` API, which + * would require an additional permission that is otherwise unnecessary. + * It is also used to track the correlation between child frame elements and their IDs. + */ +class FrameAncestryHandler { + /** + * Creates a new instance. + * @param frameId The frame ID of the current frame the instance is instantiated in. + */ + constructor(frameId) { + this._frameId = frameId; + this._isPrepared = false; + this._requestMessageId = 'FrameAncestryHandler.requestFrameInfo'; + this._responseMessageIdBase = `${this._requestMessageId}.response.`; + this._getFrameAncestryInfoPromise = null; + this._childFrameMap = new Map(); + } + + /** + * Gets the frame ID that the instance is instantiated in. + */ + get frameId() { + return this._frameId; + } + + /** + * Initializes event event listening. + */ + prepare() { + if (this._isPrepared) { return; } + window.addEventListener('message', this._onWindowMessage.bind(this), false); + this._isPrepared = true; + } + + /** + * Returns whether or not this frame is the root frame in the tab. + * @returns `true` if it is the root, otherwise `false`. + */ + isRootFrame() { + return (window === window.parent); + } + + /** + * Gets the frame ancestry information for the current frame. If the frame is the + * root frame, an empty array is returned. Otherwise, an array of frame IDs is returned, + * starting from the nearest ancestor. + * @param timeout The maximum time to wait to receive a response to frame information requests. + * @returns An array of frame IDs corresponding to the ancestors of the current frame. + */ + async getFrameAncestryInfo() { + if (this._getFrameAncestryInfoPromise === null) { + this._getFrameAncestryInfoPromise = this._getFrameAncestryInfo(5000); + } + return await this._getFrameAncestryInfoPromise; + } + + /** + * Gets the frame element of a child frame given a frame ID. + * For this function to work, the `getFrameAncestryInfo` function needs to have + * been invoked previously. + * @param frameId The frame ID of the child frame to get. + * @returns The element corresponding to the frame with ID `frameId`, otherwise `null`. + */ + getChildFrameElement(frameId) { + const frameInfo = this._childFrameMap.get(frameId); + if (typeof frameInfo === 'undefined') { return null; } + + let {frameElement} = frameInfo; + if (typeof frameElement === 'undefined') { + frameElement = this._findFrameElementWithContentWindow(frameInfo.window); + frameInfo.frameElement = frameElement; + } + + return frameElement; + } + + // Private + + _getFrameAncestryInfo(timeout=5000) { + return new Promise((resolve, reject) => { + const targetWindow = window.parent; + if (window === targetWindow) { + resolve([]); + return; + } + + const uniqueId = generateId(16); + let nonce = generateId(16); + const responseMessageId = `${this._responseMessageIdBase}${uniqueId}`; + const results = []; + let timer = null; + + const cleanup = () => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + api.crossFrame.unregisterHandler(responseMessageId); + }; + const onMessage = (params) => { + if (params.nonce !== nonce) { return null; } + + // Add result + const {frameId, more} = params; + results.push(frameId); + nonce = generateId(16); + + if (!more) { + // Cleanup + cleanup(); + + // Finish + resolve(results); + } + return {nonce}; + }; + const onTimeout = () => { + timer = null; + cleanup(); + reject(new Error(`Request for parent frame ID timed out after ${timeout}ms`)); + }; + const resetTimeout = () => { + if (timer !== null) { clearTimeout(timer); } + timer = setTimeout(onTimeout, timeout); + }; + + // Start + api.crossFrame.registerHandlers([[responseMessageId, {async: false, handler: onMessage}]]); + resetTimeout(); + const frameId = this._frameId; + this._requestFrameInfo(targetWindow, frameId, frameId, uniqueId, nonce); + }); + } + + _onWindowMessage(event) { + const {source} = event; + if (source === window || source.parent !== window) { return; } + + const {data} = event; + if ( + typeof data === 'object' && + data !== null && + data.action === this._requestMessageId + ) { + this._onRequestFrameInfo(data.params, source); + } + } + + async _onRequestFrameInfo(params, source) { + try { + let {originFrameId, childFrameId, uniqueId, nonce} = params; + if ( + !this._isNonNegativeInteger(originFrameId) || + typeof uniqueId !== 'string' || + typeof nonce !== 'string' + ) { + return; + } + + const frameId = this._frameId; + const {parent} = window; + const more = (window !== parent); + const responseParams = {frameId, nonce, more}; + const responseMessageId = `${this._responseMessageIdBase}${uniqueId}`; + + try { + const response = await api.crossFrame.invoke(originFrameId, responseMessageId, responseParams); + if (response === null) { return; } + nonce = response.nonce; + } catch (e) { + return; + } + + if (!this._childFrameMap.has(childFrameId)) { + this._childFrameMap.set(childFrameId, {window: source, frameElement: void 0}); + } + + if (more) { + this._requestFrameInfo(parent, originFrameId, frameId, uniqueId, nonce); + } + } catch (e) { + // NOP + } + } + + _requestFrameInfo(targetWindow, originFrameId, childFrameId, uniqueId, nonce) { + targetWindow.postMessage({ + action: this._requestMessageId, + params: {originFrameId, childFrameId, uniqueId, nonce} + }, '*'); + } + + _isNonNegativeInteger(value) { + return ( + typeof value === 'number' && + Number.isFinite(value) && + value >= 0 && + Math.floor(value) === value + ); + } + + _findFrameElementWithContentWindow(contentWindow) { + // Check frameElement, for non-null same-origin frames + try { + const {frameElement} = contentWindow; + if (frameElement !== null) { return frameElement; } + } catch (e) { + // NOP + } + + // Check frames + const frameTypes = ['iframe', 'frame', 'embed']; + for (const frameType of frameTypes) { + for (const frame of document.getElementsByTagName(frameType)) { + if (frame.contentWindow === contentWindow) { + return frame; + } + } + } + + // Check for shadow roots + const rootElements = [document.documentElement]; + while (rootElements.length > 0) { + const rootElement = rootElements.shift(); + const walker = document.createTreeWalker(rootElement, NodeFilter.SHOW_ELEMENT); + while (walker.nextNode()) { + const element = walker.currentNode; + + if (element.contentWindow === contentWindow) { + return element; + } + + const shadowRoot = ( + element.shadowRoot || + element.openOrClosedShadowRoot // Available to Firefox 63+ for WebExtensions + ); + if (shadowRoot) { + rootElements.push(shadowRoot); + } + } + } + + // Not found + return null; + } +} diff --git a/ext/js/comm/frame-offset-forwarder.js b/ext/js/comm/frame-offset-forwarder.js new file mode 100644 index 00000000..0a0b4a18 --- /dev/null +++ b/ext/js/comm/frame-offset-forwarder.js @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2020-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/* global + * FrameAncestryHandler + * api + */ + +class FrameOffsetForwarder { + constructor(frameId) { + this._frameId = frameId; + this._frameAncestryHandler = new FrameAncestryHandler(frameId); + } + + prepare() { + this._frameAncestryHandler.prepare(); + api.crossFrame.registerHandlers([ + ['FrameOffsetForwarder.getChildFrameRect', {async: false, handler: this._onMessageGetChildFrameRect.bind(this)}] + ]); + } + + async getOffset() { + if (this._frameAncestryHandler.isRootFrame()) { + return [0, 0]; + } + + const ancestorFrameIds = await this._frameAncestryHandler.getFrameAncestryInfo(); + + let childFrameId = this._frameId; + const promises = []; + for (const frameId of ancestorFrameIds) { + promises.push(api.crossFrame.invoke(frameId, 'FrameOffsetForwarder.getChildFrameRect', {frameId: childFrameId})); + childFrameId = frameId; + } + + const results = await Promise.all(promises); + + let xOffset = 0; + let yOffset = 0; + for (const {x, y} of results) { + xOffset += x; + yOffset += y; + } + return [xOffset, yOffset]; + } + + // Private + + _onMessageGetChildFrameRect({frameId}) { + const frameElement = this._frameAncestryHandler.getChildFrameElement(frameId); + if (frameElement === null) { return null; } + + const {x, y, width, height} = frameElement.getBoundingClientRect(); + return {x, y, width, height}; + } +} diff --git a/ext/js/display/display.js b/ext/js/display/display.js index ffadd055..c522fe14 100644 --- a/ext/js/display/display.js +++ b/ext/js/display/display.js @@ -1573,13 +1573,13 @@ class Display extends EventDispatcher { await dynamicLoader.loadScripts([ '/js/language/text-scanner.js', '/js/comm/frame-client.js', - '/fg/js/popup.js', - '/fg/js/popup-proxy.js', - '/fg/js/popup-window.js', - '/fg/js/popup-factory.js', - '/fg/js/frame-ancestry-handler.js', - '/fg/js/frame-offset-forwarder.js', - '/fg/js/frontend.js' + '/js/app/popup.js', + '/js/app/popup-proxy.js', + '/js/app/popup-window.js', + '/js/app/popup-factory.js', + '/js/comm/frame-ancestry-handler.js', + '/js/comm/frame-offset-forwarder.js', + '/js/app/frontend.js' ]); const popupFactory = new PopupFactory(this._frameId); diff --git a/ext/js/display/popup-main.js b/ext/js/display/popup-main.js new file mode 100644 index 00000000..7c048b62 --- /dev/null +++ b/ext/js/display/popup-main.js @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2020-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/* global + * Display + * DisplayProfileSelection + * DocumentFocusController + * HotkeyHandler + * JapaneseUtil + * api + */ + +(async () => { + try { + const documentFocusController = new DocumentFocusController(); + documentFocusController.prepare(); + + api.forwardLogsToBackend(); + await yomichan.backendReady(); + + const {tabId, frameId} = await api.frameInformationGet(); + + const japaneseUtil = new JapaneseUtil(null); + + const hotkeyHandler = new HotkeyHandler(); + hotkeyHandler.prepare(); + + const display = new Display(tabId, frameId, 'popup', japaneseUtil, documentFocusController, hotkeyHandler); + await display.prepare(); + + const displayProfileSelection = new DisplayProfileSelection(display); + displayProfileSelection.prepare(); + + display.initializeState(); + + document.documentElement.dataset.loaded = 'true'; + + yomichan.ready(); + } catch (e) { + yomichan.logError(e); + } +})(); diff --git a/ext/js/dom/dom-text-scanner.js b/ext/js/dom/dom-text-scanner.js new file mode 100644 index 00000000..71e74fc3 --- /dev/null +++ b/ext/js/dom/dom-text-scanner.js @@ -0,0 +1,551 @@ +/* + * Copyright (C) 2020-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * A class used to scan text in a document. + */ +class DOMTextScanner { + /** + * Creates a new instance of a DOMTextScanner. + * @param node The DOM Node to start at. + * @param offset The character offset in to start at when node is a text node. + * Use 0 for non-text nodes. + */ + constructor(node, offset, forcePreserveWhitespace=false, generateLayoutContent=true) { + const ruby = DOMTextScanner.getParentRubyElement(node); + const resetOffset = (ruby !== null); + if (resetOffset) { node = ruby; } + + this._node = node; + this._offset = offset; + this._content = ''; + this._remainder = 0; + this._resetOffset = resetOffset; + this._newlines = 0; + this._lineHasWhitespace = false; + this._lineHasContent = false; + this._forcePreserveWhitespace = forcePreserveWhitespace; + this._generateLayoutContent = generateLayoutContent; + } + + /** + * Gets the current node being scanned. + * @returns A DOM Node. + */ + get node() { + return this._node; + } + + /** + * Gets the current offset corresponding to the node being scanned. + * This value is only applicable for text nodes. + * @returns An integer. + */ + get offset() { + return this._offset; + } + + /** + * Gets the remaining number of characters that weren't scanned in the last seek() call. + * This value is usually 0 unless the end of the document was reached. + * @returns An integer. + */ + get remainder() { + return this._remainder; + } + + /** + * Gets the accumulated content string resulting from calls to seek(). + * @returns A string. + */ + get content() { + return this._content; + } + + /** + * Seeks a given length in the document and accumulates the text content. + * @param length A positive or negative integer corresponding to how many characters + * should be added to content. Content is only added to the accumulation string, + * never removed, so mixing seek calls with differently signed length values + * may give unexpected results. + * @returns this + */ + seek(length) { + const forward = (length >= 0); + this._remainder = (forward ? length : -length); + if (length === 0) { return this; } + + const TEXT_NODE = Node.TEXT_NODE; + const ELEMENT_NODE = Node.ELEMENT_NODE; + + const generateLayoutContent = this._generateLayoutContent; + let node = this._node; + let lastNode = node; + let resetOffset = this._resetOffset; + let newlines = 0; + while (node !== null) { + let enterable = false; + const nodeType = node.nodeType; + + if (nodeType === TEXT_NODE) { + lastNode = node; + if (!( + forward ? + this._seekTextNodeForward(node, resetOffset) : + this._seekTextNodeBackward(node, resetOffset) + )) { + // Length reached + break; + } + } else if (nodeType === ELEMENT_NODE) { + lastNode = node; + this._offset = 0; + [enterable, newlines] = DOMTextScanner.getElementSeekInfo(node); + if (newlines > this._newlines && generateLayoutContent) { + this._newlines = newlines; + } + } + + const exitedNodes = []; + node = DOMTextScanner.getNextNode(node, forward, enterable, exitedNodes); + + for (const exitedNode of exitedNodes) { + if (exitedNode.nodeType !== ELEMENT_NODE) { continue; } + newlines = DOMTextScanner.getElementSeekInfo(exitedNode)[1]; + if (newlines > this._newlines && generateLayoutContent) { + this._newlines = newlines; + } + } + + resetOffset = true; + } + + this._node = lastNode; + this._resetOffset = resetOffset; + + return this; + } + + // Private + + /** + * Seeks forward in a text node. + * @param textNode The text node to use. + * @param resetOffset Whether or not the text offset should be reset. + * @returns true if scanning should continue, or false if the scan length has been reached. + */ + _seekTextNodeForward(textNode, resetOffset) { + const nodeValue = textNode.nodeValue; + const nodeValueLength = nodeValue.length; + const [preserveNewlines, preserveWhitespace] = ( + this._forcePreserveWhitespace ? + [true, true] : + DOMTextScanner.getWhitespaceSettings(textNode) + ); + + let lineHasWhitespace = this._lineHasWhitespace; + let lineHasContent = this._lineHasContent; + let content = this._content; + let offset = resetOffset ? 0 : this._offset; + let remainder = this._remainder; + let newlines = this._newlines; + + while (offset < nodeValueLength) { + const char = nodeValue[offset]; + const charAttributes = DOMTextScanner.getCharacterAttributes(char, preserveNewlines, preserveWhitespace); + ++offset; + + if (charAttributes === 0) { + // Character should be ignored + continue; + } else if (charAttributes === 1) { + // Character is collapsable whitespace + lineHasWhitespace = true; + } else { + // Character should be added to the content + if (newlines > 0) { + if (content.length > 0) { + const useNewlineCount = Math.min(remainder, newlines); + content += '\n'.repeat(useNewlineCount); + remainder -= useNewlineCount; + newlines -= useNewlineCount; + } else { + newlines = 0; + } + lineHasContent = false; + lineHasWhitespace = false; + if (remainder <= 0) { + --offset; // Revert character offset + break; + } + } + + lineHasContent = (charAttributes === 2); // 3 = character is a newline + + if (lineHasWhitespace) { + if (lineHasContent) { + content += ' '; + lineHasWhitespace = false; + if (--remainder <= 0) { + --offset; // Revert character offset + break; + } + } else { + lineHasWhitespace = false; + } + } + + content += char; + + if (--remainder <= 0) { break; } + } + } + + this._lineHasWhitespace = lineHasWhitespace; + this._lineHasContent = lineHasContent; + this._content = content; + this._offset = offset; + this._remainder = remainder; + this._newlines = newlines; + + return (remainder > 0); + } + + /** + * Seeks backward in a text node. + * This function is nearly the same as _seekTextNodeForward, with the following differences: + * - Iteration condition is reversed to check if offset is greater than 0. + * - offset is reset to nodeValueLength instead of 0. + * - offset is decremented instead of incremented. + * - offset is decremented before getting the character. + * - offset is reverted by incrementing instead of decrementing. + * - content string is prepended instead of appended. + * @param textNode The text node to use. + * @param resetOffset Whether or not the text offset should be reset. + * @returns true if scanning should continue, or false if the scan length has been reached. + */ + _seekTextNodeBackward(textNode, resetOffset) { + const nodeValue = textNode.nodeValue; + const nodeValueLength = nodeValue.length; + const [preserveNewlines, preserveWhitespace] = ( + this._forcePreserveWhitespace ? + [true, true] : + DOMTextScanner.getWhitespaceSettings(textNode) + ); + + let lineHasWhitespace = this._lineHasWhitespace; + let lineHasContent = this._lineHasContent; + let content = this._content; + let offset = resetOffset ? nodeValueLength : this._offset; + let remainder = this._remainder; + let newlines = this._newlines; + + while (offset > 0) { + --offset; + const char = nodeValue[offset]; + const charAttributes = DOMTextScanner.getCharacterAttributes(char, preserveNewlines, preserveWhitespace); + + if (charAttributes === 0) { + // Character should be ignored + continue; + } else if (charAttributes === 1) { + // Character is collapsable whitespace + lineHasWhitespace = true; + } else { + // Character should be added to the content + if (newlines > 0) { + if (content.length > 0) { + const useNewlineCount = Math.min(remainder, newlines); + content = '\n'.repeat(useNewlineCount) + content; + remainder -= useNewlineCount; + newlines -= useNewlineCount; + } else { + newlines = 0; + } + lineHasContent = false; + lineHasWhitespace = false; + if (remainder <= 0) { + ++offset; // Revert character offset + break; + } + } + + lineHasContent = (charAttributes === 2); // 3 = character is a newline + + if (lineHasWhitespace) { + if (lineHasContent) { + content = ' ' + content; + lineHasWhitespace = false; + if (--remainder <= 0) { + ++offset; // Revert character offset + break; + } + } else { + lineHasWhitespace = false; + } + } + + content = char + content; + + if (--remainder <= 0) { break; } + } + } + + this._lineHasWhitespace = lineHasWhitespace; + this._lineHasContent = lineHasContent; + this._content = content; + this._offset = offset; + this._remainder = remainder; + this._newlines = newlines; + + return (remainder > 0); + } + + // Static helpers + + /** + * Gets the next node in the document for a specified scanning direction. + * @param node The current DOM Node. + * @param forward Whether to scan forward in the document or backward. + * @param visitChildren Whether the children of the current node should be visited. + * @param exitedNodes An array which stores nodes which were exited. + * @returns The next node in the document, or null if there is no next node. + */ + static getNextNode(node, forward, visitChildren, exitedNodes) { + let next = visitChildren ? (forward ? node.firstChild : node.lastChild) : null; + if (next === null) { + while (true) { + exitedNodes.push(node); + + next = (forward ? node.nextSibling : node.previousSibling); + if (next !== null) { break; } + + next = node.parentNode; + if (next === null) { break; } + + node = next; + } + } + return next; + } + + /** + * Gets the parent element of a given Node. + * @param node The node to check. + * @returns The parent element if one exists, otherwise null. + */ + static getParentElement(node) { + while (node !== null && node.nodeType !== Node.ELEMENT_NODE) { + node = node.parentNode; + } + return node; + } + + /** + * Gets the parent element of a given node, if one exists. For efficiency purposes, + * this only checks the immediate parent elements and does not check all ancestors, so + * there are cases where the node may be in a ruby element but it is not returned. + * @param node The node to check. + * @returns A node if the input node is contained in one, otherwise null. + */ + static getParentRubyElement(node) { + node = DOMTextScanner.getParentElement(node); + if (node !== null && node.nodeName.toUpperCase() === 'RT') { + node = node.parentNode; + if (node !== null && node.nodeName.toUpperCase() === 'RUBY') { + return node; + } + } + return null; + } + + /** + * @returns [enterable: boolean, newlines: integer] + * The enterable value indicates whether the content of this node should be entered. + * The newlines value corresponds to the number of newline characters that should be added. + * 1 newline corresponds to a simple new line in the layout. + * 2 newlines corresponds to a significant visual distinction since the previous content. + */ + static getElementSeekInfo(element) { + let enterable = true; + switch (element.nodeName.toUpperCase()) { + case 'HEAD': + case 'RT': + case 'SCRIPT': + case 'STYLE': + return [false, 0]; + case 'BR': + return [false, 1]; + case 'TEXTAREA': + case 'INPUT': + case 'BUTTON': + enterable = false; + break; + } + + const style = window.getComputedStyle(element); + const display = style.display; + + const visible = (display !== 'none' && DOMTextScanner.isStyleVisible(style)); + let newlines = 0; + + if (!visible) { + enterable = false; + } else { + switch (style.position) { + case 'absolute': + case 'fixed': + case 'sticky': + newlines = 2; + break; + } + if (newlines === 0 && DOMTextScanner.doesCSSDisplayChangeLayout(display)) { + newlines = 1; + } + } + + return [enterable, newlines]; + } + + /** + * Gets information about how whitespace characters are treated. + * @param textNode The Text node to check. + * @returns [preserveNewlines: boolean, preserveWhitespace: boolean] + * The value of preserveNewlines indicates whether or not newline characters are treated as line breaks. + * The value of preserveWhitespace indicates whether or not sequences of whitespace characters are collapsed. + */ + static getWhitespaceSettings(textNode) { + const element = DOMTextScanner.getParentElement(textNode); + if (element !== null) { + const style = window.getComputedStyle(element); + switch (style.whiteSpace) { + case 'pre': + case 'pre-wrap': + case 'break-spaces': + return [true, true]; + case 'pre-line': + return [true, false]; + } + } + return [false, false]; + } + + /** + * Gets attributes for the specified character. + * @param character A string containing a single character. + * @returns An integer representing the attributes of the character. + * 0: Character should be ignored. + * 1: Character is collapsable whitespace. + * 2: Character should be added to the content. + * 3: Character should be added to the content and is a newline. + */ + static getCharacterAttributes(character, preserveNewlines, preserveWhitespace) { + switch (character.charCodeAt(0)) { + case 0x09: // Tab ('\t') + case 0x0c: // Form feed ('\f') + case 0x0d: // Carriage return ('\r') + case 0x20: // Space (' ') + return preserveWhitespace ? 2 : 1; + case 0x0a: // Line feed ('\n') + return preserveNewlines ? 3 : 1; + case 0x200c: // Zero-width non-joiner ('\u200c') + return 0; + default: // Other + return 2; + } + } + + /** + * Checks whether a given style is visible or not. + * This function does not check style.display === 'none'. + * @param style An object implementing the CSSStyleDeclaration interface. + * @returns true if the style should result in an element being visible, otherwise false. + */ + static isStyleVisible(style) { + return !( + style.visibility === 'hidden' || + parseFloat(style.opacity) <= 0 || + parseFloat(style.fontSize) <= 0 || + ( + !DOMTextScanner.isStyleSelectable(style) && + ( + DOMTextScanner.isCSSColorTransparent(style.color) || + DOMTextScanner.isCSSColorTransparent(style.webkitTextFillColor) + ) + ) + ); + } + + /** + * Checks whether a given style is selectable or not. + * @param style An object implementing the CSSStyleDeclaration interface. + * @returns true if the style is selectable, otherwise false. + */ + static isStyleSelectable(style) { + return !( + style.userSelect === 'none' || + style.webkitUserSelect === 'none' || + style.MozUserSelect === 'none' || + style.msUserSelect === 'none' + ); + } + + /** + * Checks whether a CSS color is transparent or not. + * @param cssColor A CSS color string, expected to be encoded in rgb(a) form. + * @returns true if the color is transparent, otherwise false. + */ + static isCSSColorTransparent(cssColor) { + return ( + typeof cssColor === 'string' && + cssColor.startsWith('rgba(') && + /,\s*0.?0*\)$/.test(cssColor) + ); + } + + /** + * Checks whether a CSS display value will cause a layout change for text. + * @param cssDisplay A CSS string corresponding to the value of the display property. + * @returns true if the layout is changed by this value, otherwise false. + */ + static doesCSSDisplayChangeLayout(cssDisplay) { + let pos = cssDisplay.indexOf(' '); + if (pos >= 0) { + // Truncate to part + cssDisplay = cssDisplay.substring(0, pos); + } + + pos = cssDisplay.indexOf('-'); + if (pos >= 0) { + // Truncate to first part of kebab-case value + cssDisplay = cssDisplay.substring(0, pos); + } + + switch (cssDisplay) { + case 'block': + case 'flex': + case 'grid': + case 'list': // list-item + case 'table': // table, table-* + return true; + case 'ruby': // rubt-* + return (pos >= 0); + default: + return false; + } + } +} diff --git a/ext/js/dom/text-source-element.js b/ext/js/dom/text-source-element.js new file mode 100644 index 00000000..45186636 --- /dev/null +++ b/ext/js/dom/text-source-element.js @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2016-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +class TextSourceElement { + constructor(element, fullContent=null, startOffset=0, endOffset=0) { + this._element = element; + this._fullContent = (typeof fullContent === 'string' ? fullContent : TextSourceElement.getElementContent(element)); + this._startOffset = startOffset; + this._endOffset = endOffset; + this._content = this._fullContent.substring(this._startOffset, this._endOffset); + } + + get element() { + return this._element; + } + + get fullContent() { + return this._fullContent; + } + + get startOffset() { + return this._startOffset; + } + + get endOffset() { + return this._endOffset; + } + + get isConnected() { + return this._element.isConnected; + } + + clone() { + return new TextSourceElement(this._element, this._fullContent, this._startOffset, this._endOffset); + } + + cleanup() { + // NOP + } + + text() { + return this._content; + } + + setEndOffset(length, fromEnd=false) { + if (fromEnd) { + const delta = Math.min(this._fullContent.length - this._endOffset, length); + this._endOffset += delta; + this._content = this._fullContent.substring(this._startOffset, this._endOffset); + return delta; + } else { + const delta = Math.min(this._fullContent.length - this._startOffset, length); + this._endOffset = this._startOffset + delta; + this._content = this._fullContent.substring(this._startOffset, this._endOffset); + return delta; + } + } + + setStartOffset(length) { + const delta = Math.min(this._startOffset, length); + this._startOffset -= delta; + this._content = this._fullContent.substring(this._startOffset, this._endOffset); + return delta; + } + + collapse(toStart) { + if (toStart) { + this._endOffset = this._startOffset; + } else { + this._startOffset = this._endOffset; + } + this._content = ''; + } + + getRect() { + return this._element.getBoundingClientRect(); + } + + getWritingMode() { + return 'horizontal-tb'; + } + + select() { + // NOP + } + + deselect() { + // NOP + } + + hasSameStart(other) { + return ( + typeof other === 'object' && + other !== null && + other instanceof TextSourceElement && + this._element === other.element && + this._fullContent === other.fullContent && + this._startOffset === other.startOffset + ); + } + + getNodesInRange() { + return [this._element]; + } + + static getElementContent(element) { + let content; + switch (element.nodeName.toUpperCase()) { + case 'BUTTON': + content = element.textContent; + break; + case 'IMG': + content = element.getAttribute('alt') || ''; + break; + default: + content = `${element.value}`; + break; + } + + // Remove zero-width non-joiner + content = content.replace(/\u200c/g, ''); + + return content; + } +} diff --git a/ext/js/dom/text-source-range.js b/ext/js/dom/text-source-range.js new file mode 100644 index 00000000..377016da --- /dev/null +++ b/ext/js/dom/text-source-range.js @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2016-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/* global + * DOMTextScanner + * DocumentUtil + */ + +class TextSourceRange { + constructor(range, content, imposterContainer, imposterSourceElement) { + this._range = range; + this._rangeStartOffset = range.startOffset; + this._content = content; + this._imposterContainer = imposterContainer; + this._imposterSourceElement = imposterSourceElement; + } + + get range() { + return this._range; + } + + get rangeStartOffset() { + return this._rangeStartOffset; + } + + get imposterSourceElement() { + return this._imposterSourceElement; + } + + get isConnected() { + return ( + this._range.startContainer.isConnected && + this._range.endContainer.isConnected + ); + } + + clone() { + return new TextSourceRange(this._range.cloneRange(), this._content, this._imposterContainer, this._imposterSourceElement); + } + + cleanup() { + if (this._imposterContainer !== null && this._imposterContainer.parentNode !== null) { + this._imposterContainer.parentNode.removeChild(this._imposterContainer); + } + } + + text() { + return this._content; + } + + setEndOffset(length, layoutAwareScan, fromEnd=false) { + const state = ( + fromEnd ? + new DOMTextScanner(this._range.endContainer, this._range.endOffset, !layoutAwareScan, layoutAwareScan).seek(length) : + new DOMTextScanner(this._range.startContainer, this._range.startOffset, !layoutAwareScan, layoutAwareScan).seek(length) + ); + this._range.setEnd(state.node, state.offset); + this._content = (fromEnd ? this._content + state.content : state.content); + return length - state.remainder; + } + + setStartOffset(length, layoutAwareScan) { + const state = new DOMTextScanner(this._range.startContainer, this._range.startOffset, !layoutAwareScan, layoutAwareScan).seek(-length); + this._range.setStart(state.node, state.offset); + this._rangeStartOffset = this._range.startOffset; + this._content = state.content + this._content; + return length - state.remainder; + } + + collapse(toStart) { + this._range.collapse(toStart); + this._content = ''; + } + + getRect() { + return this._range.getBoundingClientRect(); + } + + getWritingMode() { + return TextSourceRange.getElementWritingMode(TextSourceRange.getParentElement(this._range.startContainer)); + } + + select() { + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(this._range); + } + + deselect() { + const selection = window.getSelection(); + selection.removeAllRanges(); + } + + hasSameStart(other) { + if (!( + typeof other === 'object' && + other !== null && + other instanceof TextSourceRange + )) { + return false; + } + if (this._imposterSourceElement !== null) { + return ( + this._imposterSourceElement === other.imposterSourceElement && + this._rangeStartOffset === other.rangeStartOffset + ); + } else { + try { + return this._range.compareBoundaryPoints(Range.START_TO_START, other.range) === 0; + } catch (e) { + if (e.name === 'WrongDocumentError') { + // This can happen with shadow DOMs if the ranges are in different documents. + return false; + } + throw e; + } + } + } + + getNodesInRange() { + return DocumentUtil.getNodesInRange(this._range); + } + + static getParentElement(node) { + while (node !== null && node.nodeType !== Node.ELEMENT_NODE) { + node = node.parentNode; + } + return node; + } + + static getElementWritingMode(element) { + if (element !== null) { + const style = window.getComputedStyle(element); + const writingMode = style.writingMode; + if (typeof writingMode === 'string') { + return TextSourceRange.normalizeWritingMode(writingMode); + } + } + return 'horizontal-tb'; + } + + static normalizeWritingMode(writingMode) { + switch (writingMode) { + case 'lr': + case 'lr-tb': + case 'rl': + return 'horizontal-tb'; + case 'tb': + return 'vertical-lr'; + case 'tb-rl': + return 'vertical-rl'; + default: + return writingMode; + } + } +} diff --git a/ext/manifest.json b/ext/manifest.json index 4ac53273..85aecc9c 100644 --- a/ext/manifest.json +++ b/ext/manifest.json @@ -47,17 +47,17 @@ "js/language/text-scanner.js", "js/dom/document-util.js", "js/input/hotkey-handler.js", - "fg/js/dom-text-scanner.js", - "fg/js/popup.js", - "fg/js/text-source-range.js", - "fg/js/text-source-element.js", - "fg/js/popup-factory.js", - "fg/js/frame-ancestry-handler.js", - "fg/js/frame-offset-forwarder.js", - "fg/js/popup-proxy.js", - "fg/js/popup-window.js", - "fg/js/frontend.js", - "fg/js/content-script-main.js" + "js/dom/dom-text-scanner.js", + "js/app/popup.js", + "js/dom/text-source-range.js", + "js/dom/text-source-element.js", + "js/app/popup-factory.js", + "js/comm/frame-ancestry-handler.js", + "js/comm/frame-offset-forwarder.js", + "js/app/popup-proxy.js", + "js/app/popup-window.js", + "js/app/frontend.js", + "js/app/content-script-main.js" ], "match_about_blank": true, "all_frames": true diff --git a/ext/popup-preview.html b/ext/popup-preview.html index 18aec551..6f6ff8b1 100644 --- a/ext/popup-preview.html +++ b/ext/popup-preview.html @@ -46,14 +46,14 @@ - - - - - - - - + + + + + + + + diff --git a/ext/popup.html b/ext/popup.html index 274340ca..e31237dd 100644 --- a/ext/popup.html +++ b/ext/popup.html @@ -96,9 +96,9 @@ - - - + + + @@ -126,7 +126,7 @@ - + diff --git a/ext/search.html b/ext/search.html index 384493c8..1efef61e 100644 --- a/ext/search.html +++ b/ext/search.html @@ -83,9 +83,9 @@ - - - + + + -- cgit v1.2.3