diff options
author | Alex Yatskov <alex@foosoft.net> | 2020-05-22 17:46:16 -0700 |
---|---|---|
committer | Alex Yatskov <alex@foosoft.net> | 2020-05-22 17:46:16 -0700 |
commit | 1480288561cb8b9fb87ad711d970c548329fea98 (patch) | |
tree | 87c2247f6d144407afcc6de316bbacc264582248 /ext/fg/js | |
parent | f2186c51e4ef219d158735d30a32bbf3e49c4e1a (diff) | |
parent | d0dcff765f740bf6f0f6523b09cb8b21eb85cd93 (diff) |
Merge branch 'master' into testing
Diffstat (limited to 'ext/fg/js')
-rw-r--r-- | ext/fg/js/content-script-main.js (renamed from ext/fg/js/frontend-initialize.js) | 68 | ||||
-rw-r--r-- | ext/fg/js/document.js | 7 | ||||
-rw-r--r-- | ext/fg/js/dom-text-scanner.js | 551 | ||||
-rw-r--r-- | ext/fg/js/float-main.js (renamed from ext/fg/js/popup-nested.js) | 42 | ||||
-rw-r--r-- | ext/fg/js/float.js | 152 | ||||
-rw-r--r-- | ext/fg/js/frame-offset-forwarder.js | 73 | ||||
-rw-r--r-- | ext/fg/js/frontend-api-receiver.js | 55 | ||||
-rw-r--r-- | ext/fg/js/frontend-api-sender.js | 90 | ||||
-rw-r--r-- | ext/fg/js/frontend.js | 338 | ||||
-rw-r--r-- | ext/fg/js/popup-factory.js (renamed from ext/fg/js/popup-proxy-host.js) | 75 | ||||
-rw-r--r-- | ext/fg/js/popup-proxy.js | 58 | ||||
-rw-r--r-- | ext/fg/js/popup.js | 486 | ||||
-rw-r--r-- | ext/fg/js/source.js | 126 |
13 files changed, 1504 insertions, 617 deletions
diff --git a/ext/fg/js/frontend-initialize.js b/ext/fg/js/content-script-main.js index 2b942258..57386b85 100644 --- a/ext/fg/js/frontend-initialize.js +++ b/ext/fg/js/content-script-main.js @@ -16,15 +16,18 @@ */ /* global + * DOM * FrameOffsetForwarder * Frontend + * PopupFactory * PopupProxy - * PopupProxyHost * apiBroadcastTab + * apiForwardLogsToBackend + * apiFrameInformationGet * apiOptionsGet */ -async function createIframePopupProxy(url, frameOffsetForwarder) { +async function createIframePopupProxy(frameOffsetForwarder, setDisabled) { const rootPopupInformationPromise = yomichan.getTemporaryListenerResult( chrome.runtime.onMessage, ({action, params}, {resolve}) => { @@ -34,33 +37,41 @@ async function createIframePopupProxy(url, frameOffsetForwarder) { } ); apiBroadcastTab('rootPopupRequestInformationBroadcast'); - const {popupId, frameId} = await rootPopupInformationPromise; + const {popupId, frameId: parentFrameId} = await rootPopupInformationPromise; const getFrameOffset = frameOffsetForwarder.getOffset.bind(frameOffsetForwarder); - const popup = new PopupProxy(popupId, 0, null, frameId, url, getFrameOffset); + const popup = new PopupProxy(popupId, 0, null, parentFrameId, getFrameOffset, setDisabled); await popup.prepare(); return popup; } async function getOrCreatePopup(depth) { - const popupHost = new PopupProxyHost(); - await popupHost.prepare(); + const {frameId} = await apiFrameInformationGet(); + if (typeof frameId !== 'number') { + const error = new Error('Failed to get frameId'); + yomichan.logError(error); + throw error; + } - const popup = popupHost.getOrCreatePopup(null, null, depth); + const popupFactory = new PopupFactory(frameId); + await popupFactory.prepare(); + + const popup = popupFactory.getOrCreatePopup(null, null, depth); return popup; } -async function createPopupProxy(depth, id, parentFrameId, url) { - const popup = new PopupProxy(null, depth + 1, id, parentFrameId, url); +async function createPopupProxy(depth, id, parentFrameId) { + const popup = new PopupProxy(null, depth + 1, id, parentFrameId); await popup.prepare(); return popup; } -async function main() { +(async () => { + apiForwardLogsToBackend(); await yomichan.prepare(); const data = window.frontendInitializationData || {}; @@ -78,8 +89,29 @@ async function main() { let frontendPreparePromise = null; let frameOffsetForwarder = null; + let iframePopupsInRootFrameAvailable = true; + + const disableIframePopupsInRootFrame = () => { + iframePopupsInRootFrameAvailable = false; + applyOptions(); + }; + + let urlUpdatedAt = 0; + let popupProxyUrlCached = url; + const getPopupProxyUrl = async () => { + const now = Date.now(); + if (popups.proxy !== null && now - urlUpdatedAt > 500) { + popupProxyUrlCached = await popups.proxy.getUrl(); + urlUpdatedAt = now; + } + return popupProxyUrlCached; + }; + const applyOptions = async () => { - const optionsContext = {depth: isSearchPage ? 0 : depth, url}; + const optionsContext = { + depth: isSearchPage ? 0 : depth, + url: proxy ? await getPopupProxyUrl() : window.location.href + }; const options = await apiOptionsGet(optionsContext); if (!proxy && frameOffsetForwarder === null) { @@ -88,11 +120,11 @@ async function main() { } let popup; - if (isIframe && options.general.showIframePopupsInRootFrame) { - popup = popups.iframe || await createIframePopupProxy(url, frameOffsetForwarder); + if (isIframe && options.general.showIframePopupsInRootFrame && DOM.getFullscreenElement() === null && iframePopupsInRootFrameAvailable) { + popup = popups.iframe || await createIframePopupProxy(frameOffsetForwarder, disableIframePopupsInRootFrame); popups.iframe = popup; } else if (proxy) { - popup = popups.proxy || await createPopupProxy(depth, id, parentFrameId, url); + popup = popups.proxy || await createPopupProxy(depth, id, parentFrameId); popups.proxy = popup; } else { popup = popups.normal || await getOrCreatePopup(depth); @@ -100,7 +132,8 @@ async function main() { } if (frontend === null) { - frontend = new Frontend(popup); + const getUrl = proxy ? getPopupProxyUrl : null; + frontend = new Frontend(popup, getUrl); frontendPreparePromise = frontend.prepare(); await frontendPreparePromise; } else { @@ -117,8 +150,7 @@ async function main() { }; yomichan.on('optionsUpdated', applyOptions); + window.addEventListener('fullscreenchange', applyOptions, false); await applyOptions(); -} - -main(); +})(); diff --git a/ext/fg/js/document.js b/ext/fg/js/document.js index 3b4cc28f..d639bc86 100644 --- a/ext/fg/js/document.js +++ b/ext/fg/js/document.js @@ -28,6 +28,9 @@ function docSetImposterStyle(style, propertyName, value) { } function docImposterCreate(element, isTextarea) { + const body = document.body; + if (body === null) { return [null, null]; } + const elementStyle = window.getComputedStyle(element); const elementRect = element.getBoundingClientRect(); const documentRect = document.documentElement.getBoundingClientRect(); @@ -78,7 +81,7 @@ function docImposterCreate(element, isTextarea) { } container.appendChild(imposter); - document.body.appendChild(container); + body.appendChild(container); // Adjust size const imposterRect = imposter.getBoundingClientRect(); @@ -156,7 +159,7 @@ function docSentenceExtract(source, extent) { const sourceLocal = source.clone(); const position = sourceLocal.setStartOffset(extent); - sourceLocal.setEndOffset(position + extent); + sourceLocal.setEndOffset(extent * 2 - position, true); const content = sourceLocal.text(); let quoteStack = []; diff --git a/ext/fg/js/dom-text-scanner.js b/ext/fg/js/dom-text-scanner.js new file mode 100644 index 00000000..8fa67ede --- /dev/null +++ b/ext/fg/js/dom-text-scanner.js @@ -0,0 +1,551 @@ +/* + * Copyright (C) 2020 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 <https://www.gnu.org/licenses/>. + */ + +/** + * 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 <ruby> 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 <ruby> 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 <display-outside> 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/popup-nested.js b/ext/fg/js/float-main.js index c140f9c8..20771910 100644 --- a/ext/fg/js/popup-nested.js +++ b/ext/fg/js/float-main.js @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019-2020 Yomichan Authors + * Copyright (C) 2020 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 @@ -16,24 +16,21 @@ */ /* global + * DisplayFloat + * apiForwardLogsToBackend * apiOptionsGet + * dynamicLoader */ -function injectPopupNested() { - const scriptSrcs = [ +async function injectPopupNested() { + await dynamicLoader.loadScripts([ '/mixed/js/text-scanner.js', '/fg/js/frontend-api-sender.js', '/fg/js/popup.js', '/fg/js/popup-proxy.js', '/fg/js/frontend.js', - '/fg/js/frontend-initialize.js' - ]; - for (const src of scriptSrcs) { - const script = document.createElement('script'); - script.async = false; - script.src = src; - document.body.appendChild(script); - } + '/fg/js/content-script-main.js' + ]); } async function popupNestedInitialize(id, depth, parentFrameId, url) { @@ -42,26 +39,23 @@ async function popupNestedInitialize(id, depth, parentFrameId, url) { const applyOptions = async () => { const optionsContext = {depth, url}; const options = await apiOptionsGet(optionsContext); - const popupNestingMaxDepth = options.scanning.popupNestingMaxDepth; - - const maxPopupDepthExceeded = !( - typeof popupNestingMaxDepth === 'number' && - typeof depth === 'number' && - depth < popupNestingMaxDepth - ); - if (maxPopupDepthExceeded || optionsApplied) { - return; - } + const maxPopupDepthExceeded = !(typeof depth === 'number' && depth < options.scanning.popupNestingMaxDepth); + if (maxPopupDepthExceeded || optionsApplied) { return; } optionsApplied = true; + yomichan.off('optionsUpdated', applyOptions); window.frontendInitializationData = {id, depth, parentFrameId, url, proxy: true}; - injectPopupNested(); - - yomichan.off('optionsUpdated', applyOptions); + await injectPopupNested(); }; yomichan.on('optionsUpdated', applyOptions); await applyOptions(); } + +(async () => { + apiForwardLogsToBackend(); + const display = new DisplayFloat(); + await display.prepare(); +})(); diff --git a/ext/fg/js/float.js b/ext/fg/js/float.js index 5c2c50c2..845bf7f6 100644 --- a/ext/fg/js/float.js +++ b/ext/fg/js/float.js @@ -18,7 +18,7 @@ /* global * Display * apiBroadcastTab - * apiGetMessageToken + * apiSendMessageToFrame * popupNestedInitialize */ @@ -27,17 +27,11 @@ class DisplayFloat extends Display { super(document.querySelector('#spinner'), document.querySelector('#definitions')); this.autoPlayAudioTimer = null; - this._popupId = null; - - this.optionsContext = { - depth: 0, - url: window.location.href - }; + this._secret = yomichan.generateId(16); + this._token = null; this._orphaned = false; - this._prepareInvoked = false; - this._messageToken = null; - this._messageTokenPromise = null; + this._initializedNestedPopups = false; this._onKeyDownHandlers = new Map([ ['C', (e) => { @@ -51,42 +45,30 @@ class DisplayFloat extends Display { ]); this._windowMessageHandlers = new Map([ - ['setContent', ({type, details}) => this.setContent(type, details)], - ['clearAutoPlayTimer', () => this.clearAutoPlayTimer()], - ['setCustomCss', ({css}) => this.setCustomCss(css)], - ['prepare', ({popupInfo, url, childrenSupported, scale}) => this.prepare(popupInfo, url, childrenSupported, scale)], - ['setContentScale', ({scale}) => this.setContentScale(scale)] + ['initialize', {handler: this._initialize.bind(this), authenticate: false}], + ['configure', {handler: this._configure.bind(this)}], + ['setOptionsContext', {handler: ({optionsContext}) => this.setOptionsContext(optionsContext)}], + ['setContent', {handler: ({type, details}) => this.setContent(type, details)}], + ['clearAutoPlayTimer', {handler: () => this.clearAutoPlayTimer()}], + ['setCustomCss', {handler: ({css}) => this.setCustomCss(css)}], + ['setContentScale', {handler: ({scale}) => this.setContentScale(scale)}] ]); - - yomichan.on('orphaned', this.onOrphaned.bind(this)); - window.addEventListener('message', this.onMessage.bind(this), false); } - async prepare(popupInfo, url, childrenSupported, scale) { - if (this._prepareInvoked) { return; } - this._prepareInvoked = true; - - const {id, depth, parentFrameId} = popupInfo; - this._popupId = id; - this.optionsContext.depth = depth; - this.optionsContext.url = url; - + async prepare() { await super.prepare(); - if (childrenSupported) { - popupNestedInitialize(id, depth, parentFrameId, url); - } - - this.setContentScale(scale); + yomichan.on('orphaned', this.onOrphaned.bind(this)); + window.addEventListener('message', this.onMessage.bind(this), false); - apiBroadcastTab('popupPrepareCompleted', {targetPopupId: this._popupId}); + apiBroadcastTab('popupPrepared', {secret: this._secret}); } onError(error) { if (this._orphaned) { this.setContent('orphaned'); } else { - logError(error, true); + yomichan.logError(error); } } @@ -94,7 +76,7 @@ class DisplayFloat extends Display { this._orphaned = true; } - onSearchClear() { + onEscape() { window.parent.postMessage('popupClose', '*'); } @@ -104,46 +86,30 @@ class DisplayFloat extends Display { onMessage(e) { const data = e.data; - if (typeof data !== 'object' || data === null) { return; } // Invalid data - - const token = data.token; - if (typeof token !== 'string') { return; } // Invalid data - - if (this._messageToken === null) { - // Async - this.getMessageToken() - .then( - () => { this.handleAction(token, data); }, - () => {} - ); - } else { - // Sync - this.handleAction(token, data); + if (typeof data !== 'object' || data === null) { + this._logMessageError(e, 'Invalid data'); + return; } - } - async getMessageToken() { - // this._messageTokenPromise is used to ensure that only one call to apiGetMessageToken is made. - if (this._messageTokenPromise === null) { - this._messageTokenPromise = apiGetMessageToken(); - } - const messageToken = await this._messageTokenPromise; - if (this._messageToken === null) { - this._messageToken = messageToken; + const action = data.action; + if (typeof action !== 'string') { + this._logMessageError(e, 'Invalid data'); + return; } - this._messageTokenPromise = null; - } - handleAction(token, {action, params}) { - if (token !== this._messageToken) { - // Invalid token + const handlerInfo = this._windowMessageHandlers.get(action); + if (typeof handlerInfo === 'undefined') { + this._logMessageError(e, `Invalid action: ${JSON.stringify(action)}`); return; } - const handler = this._windowMessageHandlers.get(action); - if (typeof handler !== 'function') { return; } + if (handlerInfo.authenticate !== false && !this._isMessageAuthenticated(data)) { + this._logMessageError(e, 'Invalid authentication'); + return; + } - handler(params); + const handler = handlerInfo.handler; + handler(data.params); } autoPlayAudio() { @@ -158,8 +124,15 @@ class DisplayFloat extends Display { } } + async setOptionsContext(optionsContext) { + this.optionsContext = optionsContext; + await this.updateOptions(); + } + setContentScale(scale) { - document.body.style.fontSize = `${scale}em`; + const body = document.body; + if (body === null) { return; } + body.style.fontSize = `${scale}em`; } async getDocumentTitle() { @@ -188,6 +161,45 @@ class DisplayFloat extends Display { return ''; } } -} -DisplayFloat.instance = new DisplayFloat(); + _logMessageError(event, type) { + yomichan.logWarning(new Error(`Popup received invalid message from origin ${JSON.stringify(event.origin)}: ${type}`)); + } + + _initialize(params) { + if (this._token !== null) { return; } // Already initialized + if (!isObject(params)) { return; } // Invalid data + + const secret = params.secret; + if (secret !== this._secret) { return; } // Invalid authentication + + const {token, frameId} = params; + this._token = token; + + apiSendMessageToFrame(frameId, 'popupInitialized', {secret, token}); + } + + async _configure({messageId, frameId, popupId, optionsContext, childrenSupported, scale}) { + this.optionsContext = optionsContext; + + await this.updateOptions(); + + if (childrenSupported && !this._initializedNestedPopups) { + const {depth, url} = optionsContext; + popupNestedInitialize(popupId, depth, frameId, url); + this._initializedNestedPopups = true; + } + + this.setContentScale(scale); + + apiSendMessageToFrame(frameId, 'popupConfigured', {messageId}); + } + + _isMessageAuthenticated(message) { + return ( + this._token !== null && + this._token === message.token && + this._secret === message.secret + ); + } +} diff --git a/ext/fg/js/frame-offset-forwarder.js b/ext/fg/js/frame-offset-forwarder.js index c658c55a..9b68d34e 100644 --- a/ext/fg/js/frame-offset-forwarder.js +++ b/ext/fg/js/frame-offset-forwarder.js @@ -23,6 +23,10 @@ class FrameOffsetForwarder { constructor() { this._started = false; + this._cacheMaxSize = 1000; + this._frameCache = new Set(); + this._unreachableContentWindowCache = new Set(); + this._forwardFrameOffset = ( window !== window.parent ? this._forwardFrameOffsetParent.bind(this) : @@ -74,12 +78,12 @@ class FrameOffsetForwarder { _onGetFrameOffset(offset, uniqueId, e) { let sourceFrame = null; - for (const frame of document.querySelectorAll('frame, iframe:not(.yomichan-float)')) { - if (frame.contentWindow !== e.source) { continue; } - sourceFrame = frame; - break; + if (!this._unreachableContentWindowCache.has(e.source)) { + sourceFrame = this._findFrameWithContentWindow(e.source); } if (sourceFrame === null) { + // closed shadow root etc. + this._addToCache(this._unreachableContentWindowCache, e.source); this._forwardFrameOffsetOrigin(null, uniqueId); return; } @@ -91,6 +95,67 @@ class FrameOffsetForwarder { this._forwardFrameOffset(offset, uniqueId); } + _findFrameWithContentWindow(contentWindow) { + const ELEMENT_NODE = Node.ELEMENT_NODE; + for (const elements of this._getFrameElementSources()) { + while (elements.length > 0) { + const element = elements.shift(); + if (element.contentWindow === contentWindow) { + this._addToCache(this._frameCache, element); + return element; + } + + const shadowRoot = ( + element.shadowRoot || + element.openOrClosedShadowRoot // Available to Firefox 63+ for WebExtensions + ); + if (shadowRoot) { + for (const child of shadowRoot.children) { + if (child.nodeType === ELEMENT_NODE) { + elements.push(child); + } + } + } + + for (const child of element.children) { + if (child.nodeType === ELEMENT_NODE) { + elements.push(child); + } + } + } + } + + return null; + } + + *_getFrameElementSources() { + const frameCache = []; + for (const frame of this._frameCache) { + // removed from DOM + if (!frame.isConnected) { + this._frameCache.delete(frame); + continue; + } + frameCache.push(frame); + } + yield frameCache; + // will contain duplicates, but frame elements are cheap to handle + yield [...document.querySelectorAll('frame, iframe:not(.yomichan-float)')]; + yield [document.documentElement]; + } + + _addToCache(cache, value) { + let freeSlots = this._cacheMaxSize - cache.size; + if (freeSlots <= 0) { + for (const cachedValue of cache) { + cache.delete(cachedValue); + ++freeSlots; + if (freeSlots > 0) { break; } + } + } + cache.add(value); + } + _forwardFrameOffsetParent(offset, uniqueId) { window.parent.postMessage({action: 'getFrameOffset', params: {offset, uniqueId}}, '*'); } diff --git a/ext/fg/js/frontend-api-receiver.js b/ext/fg/js/frontend-api-receiver.js index 4abd4e81..3fa9e8b6 100644 --- a/ext/fg/js/frontend-api-receiver.js +++ b/ext/fg/js/frontend-api-receiver.js @@ -17,41 +17,60 @@ class FrontendApiReceiver { - constructor(source='', handlers=new Map()) { + constructor(source, messageHandlers) { this._source = source; - this._handlers = handlers; + this._messageHandlers = messageHandlers; + } - chrome.runtime.onConnect.addListener(this.onConnect.bind(this)); + prepare() { + chrome.runtime.onConnect.addListener(this._onConnect.bind(this)); } - onConnect(port) { + _onConnect(port) { if (port.name !== 'frontend-api-receiver') { return; } - port.onMessage.addListener(this.onMessage.bind(this, port)); + port.onMessage.addListener(this._onMessage.bind(this, port)); } - onMessage(port, {id, action, params, target, senderId}) { + _onMessage(port, {id, action, params, target, senderId}) { if (target !== this._source) { return; } - const handler = this._handlers.get(action); - if (typeof handler !== 'function') { return; } + const messageHandler = this._messageHandlers.get(action); + if (typeof messageHandler === 'undefined') { return; } + + const {handler, async} = messageHandler; - this.sendAck(port, id, senderId); + this._sendAck(port, id, senderId); + if (async) { + this._invokeHandlerAsync(handler, params, port, id, senderId); + } else { + this._invokeHandler(handler, params, port, id, senderId); + } + } + + _invokeHandler(handler, params, port, id, senderId) { + try { + const result = handler(params); + this._sendResult(port, id, senderId, {result}); + } catch (error) { + this._sendResult(port, id, senderId, {error: errorToJson(error)}); + } + } - handler(params).then( - (result) => { - this.sendResult(port, id, senderId, {result}); - }, - (error) => { - this.sendResult(port, id, senderId, {error: errorToJson(error)}); - }); + async _invokeHandlerAsync(handler, params, port, id, senderId) { + try { + const result = await handler(params); + this._sendResult(port, id, senderId, {result}); + } catch (error) { + this._sendResult(port, id, senderId, {error: errorToJson(error)}); + } } - sendAck(port, id, senderId) { + _sendAck(port, id, senderId) { port.postMessage({type: 'ack', id, senderId}); } - sendResult(port, id, senderId, data) { + _sendResult(port, id, senderId, data) { port.postMessage({type: 'result', id, senderId, data}); } } diff --git a/ext/fg/js/frontend-api-sender.js b/ext/fg/js/frontend-api-sender.js index 1d539cab..4dcde638 100644 --- a/ext/fg/js/frontend-api-sender.js +++ b/ext/fg/js/frontend-api-sender.js @@ -17,97 +17,97 @@ class FrontendApiSender { - constructor() { - this.senderId = yomichan.generateId(16); - this.ackTimeout = 3000; // 3 seconds - this.responseTimeout = 10000; // 10 seconds - this.callbacks = new Map(); - this.disconnected = false; - this.nextId = 0; - - this.port = null; + constructor(target) { + this._target = target; + this._senderId = yomichan.generateId(16); + this._ackTimeout = 3000; // 3 seconds + this._responseTimeout = 10000; // 10 seconds + this._callbacks = new Map(); + this._disconnected = false; + this._nextId = 0; + this._port = null; } - invoke(action, params, target) { - if (this.disconnected) { + invoke(action, params) { + if (this._disconnected) { // attempt to reconnect the next time - this.disconnected = false; + this._disconnected = false; return Promise.reject(new Error('Disconnected')); } - if (this.port === null) { - this.createPort(); + if (this._port === null) { + this._createPort(); } - const id = `${this.nextId}`; - ++this.nextId; + const id = `${this._nextId}`; + ++this._nextId; return new Promise((resolve, reject) => { const info = {id, resolve, reject, ack: false, timer: null}; - this.callbacks.set(id, info); - info.timer = setTimeout(() => this.onError(id, 'Timeout (ack)'), this.ackTimeout); + this._callbacks.set(id, info); + info.timer = setTimeout(() => this._onError(id, 'Timeout (ack)'), this._ackTimeout); - this.port.postMessage({id, action, params, target, senderId: this.senderId}); + this._port.postMessage({id, action, params, target: this._target, senderId: this._senderId}); }); } - createPort() { - this.port = chrome.runtime.connect(null, {name: 'backend-api-forwarder'}); - this.port.onDisconnect.addListener(this.onDisconnect.bind(this)); - this.port.onMessage.addListener(this.onMessage.bind(this)); + _createPort() { + this._port = chrome.runtime.connect(null, {name: 'backend-api-forwarder'}); + this._port.onDisconnect.addListener(this._onDisconnect.bind(this)); + this._port.onMessage.addListener(this._onMessage.bind(this)); } - onMessage({type, id, data, senderId}) { - if (senderId !== this.senderId) { return; } + _onMessage({type, id, data, senderId}) { + if (senderId !== this._senderId) { return; } switch (type) { case 'ack': - this.onAck(id); + this._onAck(id); break; case 'result': - this.onResult(id, data); + this._onResult(id, data); break; } } - onDisconnect() { - this.disconnected = true; - this.port = null; + _onDisconnect() { + this._disconnected = true; + this._port = null; - for (const id of this.callbacks.keys()) { - this.onError(id, 'Disconnected'); + for (const id of this._callbacks.keys()) { + this._onError(id, 'Disconnected'); } } - onAck(id) { - const info = this.callbacks.get(id); + _onAck(id) { + const info = this._callbacks.get(id); if (typeof info === 'undefined') { - console.warn(`ID ${id} not found for ack`); + yomichan.logWarning(new Error(`ID ${id} not found for ack`)); return; } if (info.ack) { - console.warn(`Request ${id} already ack'd`); + yomichan.logWarning(new Error(`Request ${id} already ack'd`)); return; } info.ack = true; clearTimeout(info.timer); - info.timer = setTimeout(() => this.onError(id, 'Timeout (response)'), this.responseTimeout); + info.timer = setTimeout(() => this._onError(id, 'Timeout (response)'), this._responseTimeout); } - onResult(id, data) { - const info = this.callbacks.get(id); + _onResult(id, data) { + const info = this._callbacks.get(id); if (typeof info === 'undefined') { - console.warn(`ID ${id} not found`); + yomichan.logWarning(new Error(`ID ${id} not found`)); return; } if (!info.ack) { - console.warn(`Request ${id} not ack'd`); + yomichan.logWarning(new Error(`Request ${id} not ack'd`)); return; } - this.callbacks.delete(id); + this._callbacks.delete(id); clearTimeout(info.timer); info.timer = null; @@ -118,10 +118,10 @@ class FrontendApiSender { } } - onError(id, reason) { - const info = this.callbacks.get(id); + _onError(id, reason) { + const info = this._callbacks.get(id); if (typeof info === 'undefined') { return; } - this.callbacks.delete(id); + this._callbacks.delete(id); info.timer = null; info.reject(new Error(reason)); } diff --git a/ext/fg/js/frontend.js b/ext/fg/js/frontend.js index eecfe2e1..575dc413 100644 --- a/ext/fg/js/frontend.js +++ b/ext/fg/js/frontend.js @@ -25,73 +25,155 @@ * docSentenceExtract */ -class Frontend extends TextScanner { - constructor(popup) { - super( - window, - () => this.popup.isProxy() ? [] : [this.popup.getContainer()], - [(x, y) => this.popup.containsPoint(x, y)] - ); - - this.popup = popup; - +class Frontend { + constructor(popup, getUrl=null) { + this._id = yomichan.generateId(16); + this._popup = popup; + this._getUrl = getUrl; this._disabledOverride = false; - - this.options = null; - - this.optionsContext = { - depth: popup.depth, - url: popup.url - }; - + this._options = null; this._pageZoomFactor = 1.0; this._contentScale = 1.0; this._orphaned = false; this._lastShowPromise = Promise.resolve(); + this._enabledEventListeners = new EventListenerCollection(); + this._activeModifiers = new Set(); + this._optionsUpdatePending = false; + this._textScanner = new TextScanner({ + node: window, + ignoreElements: () => this._popup.isProxy() ? [] : [this._popup.getFrame()], + ignorePoint: (x, y) => this._popup.containsPoint(x, y), + search: this._search.bind(this) + }); this._windowMessageHandlers = new Map([ - ['popupClose', () => this.onSearchClear(true)], - ['selectionCopy', () => document.execCommand('copy')] + ['popupClose', this._onMessagePopupClose.bind(this)], + ['selectionCopy', this._onMessageSelectionCopy.bind()] ]); this._runtimeMessageHandlers = new Map([ - ['popupSetVisibleOverride', ({visible}) => { this.popup.setVisibleOverride(visible); }], - ['rootPopupRequestInformationBroadcast', () => { this._broadcastRootPopupInformation(); }], - ['requestDocumentInformationBroadcast', ({uniqueId}) => { this._broadcastDocumentInformation(uniqueId); }] + ['popupSetVisibleOverride', this._onMessagePopupSetVisibleOverride.bind(this)], + ['rootPopupRequestInformationBroadcast', this._onMessageRootPopupRequestInformationBroadcast.bind(this)], + ['requestDocumentInformationBroadcast', this._onMessageRequestDocumentInformationBroadcast.bind(this)] ]); } + get canClearSelection() { + return this._textScanner.canClearSelection; + } + + set canClearSelection(value) { + this._textScanner.canClearSelection = value; + } + async prepare() { try { await this.updateOptions(); const {zoomFactor} = await apiGetZoom(); this._pageZoomFactor = zoomFactor; - window.addEventListener('resize', this.onResize.bind(this), false); + window.addEventListener('resize', this._onResize.bind(this), false); const visualViewport = window.visualViewport; if (visualViewport !== null && typeof visualViewport === 'object') { - window.visualViewport.addEventListener('scroll', this.onVisualViewportScroll.bind(this)); - window.visualViewport.addEventListener('resize', this.onVisualViewportResize.bind(this)); + window.visualViewport.addEventListener('scroll', this._onVisualViewportScroll.bind(this)); + window.visualViewport.addEventListener('resize', this._onVisualViewportResize.bind(this)); } - yomichan.on('orphaned', this.onOrphaned.bind(this)); + yomichan.on('orphaned', this._onOrphaned.bind(this)); yomichan.on('optionsUpdated', this.updateOptions.bind(this)); - yomichan.on('zoomChanged', this.onZoomChanged.bind(this)); - chrome.runtime.onMessage.addListener(this.onRuntimeMessage.bind(this)); + yomichan.on('zoomChanged', this._onZoomChanged.bind(this)); + chrome.runtime.onMessage.addListener(this._onRuntimeMessage.bind(this)); + + this._textScanner.on('clearSelection', this._onClearSelection.bind(this)); + this._textScanner.on('activeModifiersChanged', this._onActiveModifiersChanged.bind(this)); this._updateContentScale(); this._broadcastRootPopupInformation(); } catch (e) { - this.onError(e); + yomichan.logError(e); + } + } + + async setPopup(popup) { + this._textScanner.clearSelection(true); + this._popup = popup; + await popup.setOptionsContext(await this.getOptionsContext(), this._id); + } + + setDisabledOverride(disabled) { + this._disabledOverride = disabled; + this._updateTextScannerEnabled(); + } + + async setTextSource(textSource) { + await this._search(textSource, 'script'); + this._textScanner.setCurrentTextSource(textSource); + } + + async getOptionsContext() { + const url = this._getUrl !== null ? await this._getUrl() : window.location.href; + const depth = this._popup.depth; + const modifierKeys = [...this._activeModifiers]; + return {depth, url, modifierKeys}; + } + + async updateOptions() { + const optionsContext = await this.getOptionsContext(); + this._options = await apiOptionsGet(optionsContext); + this._textScanner.setOptions(this._options); + this._updateTextScannerEnabled(); + + const ignoreNodes = ['.scan-disable', '.scan-disable *']; + if (!this._options.scanning.enableOnPopupExpressions) { + ignoreNodes.push('.source-text', '.source-text *'); + } + this._textScanner.ignoreNodes = ignoreNodes.join(','); + + await this._popup.setOptionsContext(optionsContext, this._id); + + this._updateContentScale(); + + const textSourceCurrent = this._textScanner.getCurrentTextSource(); + const causeCurrent = this._textScanner.causeCurrent; + if (textSourceCurrent !== null && causeCurrent !== null) { + await this._search(textSourceCurrent, causeCurrent); } } - onResize() { + showContentCompleted() { + return this._lastShowPromise; + } + + // Message handlers + + _onMessagePopupClose() { + this._textScanner.clearSelection(false); + } + + _onMessageSelectionCopy() { + document.execCommand('copy'); + } + + _onMessagePopupSetVisibleOverride({visible}) { + this._popup.setVisibleOverride(visible); + } + + _onMessageRootPopupRequestInformationBroadcast() { + this._broadcastRootPopupInformation(); + } + + _onMessageRequestDocumentInformationBroadcast({uniqueId}) { + this._broadcastDocumentInformation(uniqueId); + } + + // Private + + _onResize() { this._updatePopupPosition(); } - onWindowMessage(e) { + _onWindowMessage(e) { const action = e.data; const handler = this._windowMessageHandlers.get(action); if (typeof handler !== 'function') { return false; } @@ -99,10 +181,7 @@ class Frontend extends TextScanner { handler(); } - onRuntimeMessage({action, params}, sender, callback) { - const {targetPopupId} = params || {}; - if (typeof targetPopupId !== 'undefined' && targetPopupId !== this.popup.id) { return; } - + _onRuntimeMessage({action, params}, sender, callback) { const handler = this._runtimeMessageHandlers.get(action); if (typeof handler !== 'function') { return false; } @@ -111,112 +190,78 @@ class Frontend extends TextScanner { return false; } - onOrphaned() { + _onOrphaned() { this._orphaned = true; } - onZoomChanged({newZoomFactor}) { + _onZoomChanged({newZoomFactor}) { this._pageZoomFactor = newZoomFactor; this._updateContentScale(); } - onVisualViewportScroll() { + _onVisualViewportScroll() { this._updatePopupPosition(); } - onVisualViewportResize() { + _onVisualViewportResize() { this._updateContentScale(); } - getMouseEventListeners() { - return [ - ...super.getMouseEventListeners(), - [window, 'message', this.onWindowMessage.bind(this)] - ]; - } - - setDisabledOverride(disabled) { - this._disabledOverride = disabled; - this.setEnabled(this.options.general.enable, this._canEnable()); + _onClearSelection({passive}) { + this._popup.hide(!passive); + this._popup.clearAutoPlayTimer(); + this._updatePendingOptions(); } - async setPopup(popup) { - this.onSearchClear(false); - this.popup = popup; - await popup.setOptions(this.options); - } - - async updateOptions() { - this.options = await apiOptionsGet(this.getOptionsContext()); - this.setOptions(this.options, this._canEnable()); - - const ignoreNodes = ['.scan-disable', '.scan-disable *']; - if (!this.options.scanning.enableOnPopupExpressions) { - ignoreNodes.push('.source-text', '.source-text *'); - } - this.ignoreNodes = ignoreNodes.join(','); - - await this.popup.setOptions(this.options); - - this._updateContentScale(); - - if (this.textSourceCurrent !== null && this.causeCurrent !== null) { - await this.onSearchSource(this.textSourceCurrent, this.causeCurrent); + async _onActiveModifiersChanged({modifiers}) { + if (areSetsEqual(modifiers, this._activeModifiers)) { return; } + this._activeModifiers = modifiers; + if (await this._popup.isVisible()) { + this._optionsUpdatePending = true; + return; } + await this.updateOptions(); } - async onSearchSource(textSource, cause) { + async _search(textSource, cause) { + await this._updatePendingOptions(); + let results = null; try { if (textSource !== null) { + const optionsContext = await this.getOptionsContext(); results = ( - await this.findTerms(textSource) || - await this.findKanji(textSource) + await this._findTerms(textSource, optionsContext) || + await this._findKanji(textSource, optionsContext) ); if (results !== null) { const focus = (cause === 'mouse'); - this.showContent(textSource, focus, results.definitions, results.type); + this._showContent(textSource, focus, results.definitions, results.type, optionsContext); } } } catch (e) { if (this._orphaned) { - if (textSource !== null && this.options.scanning.modifier !== 'none') { - this._showPopupContent(textSource, 'orphaned'); + if (textSource !== null && this._options.scanning.modifier !== 'none') { + this._showPopupContent(textSource, await this.getOptionsContext(), 'orphaned'); } } else { - this.onError(e); + yomichan.logError(e); } } finally { - if (results === null && this.options.scanning.autoHideResults) { - this.onSearchClear(true); + if (results === null && this._options.scanning.autoHideResults) { + this._textScanner.clearSelection(false); } } return results; } - showContent(textSource, focus, definitions, type) { - const sentence = docSentenceExtract(textSource, this.options.anki.sentenceExt); - const url = window.location.href; - this._showPopupContent( - textSource, - type, - {definitions, context: {sentence, url, focus, disableHistory: true}} - ); - } - - showContentCompleted() { - return this._lastShowPromise; - } - - async findTerms(textSource) { - this.setTextSourceScanLength(textSource, this.options.scanning.length); - - const searchText = textSource.text(); + async _findTerms(textSource, optionsContext) { + const searchText = this._textScanner.getTextSourceContent(textSource, this._options.scanning.length); if (searchText.length === 0) { return null; } - const {definitions, length} = await apiTermsFind(searchText, {}, this.getOptionsContext()); + const {definitions, length} = await apiTermsFind(searchText, {}, optionsContext); if (definitions.length === 0) { return null; } textSource.setEndOffset(length); @@ -224,82 +269,97 @@ class Frontend extends TextScanner { return {definitions, type: 'terms'}; } - async findKanji(textSource) { - this.setTextSourceScanLength(textSource, 1); - - const searchText = textSource.text(); + async _findKanji(textSource, optionsContext) { + const searchText = this._textScanner.getTextSourceContent(textSource, 1); if (searchText.length === 0) { return null; } - const definitions = await apiKanjiFind(searchText, this.getOptionsContext()); + const definitions = await apiKanjiFind(searchText, optionsContext); if (definitions.length === 0) { return null; } - return {definitions, type: 'kanji'}; - } + textSource.setEndOffset(1); - onSearchClear(changeFocus) { - this.popup.hide(changeFocus); - this.popup.clearAutoPlayTimer(); - super.onSearchClear(changeFocus); + return {definitions, type: 'kanji'}; } - getOptionsContext() { - this.optionsContext.url = this.popup.url; - return this.optionsContext; + _showContent(textSource, focus, definitions, type, optionsContext) { + const {url} = optionsContext; + const sentence = docSentenceExtract(textSource, this._options.anki.sentenceExt); + this._showPopupContent( + textSource, + optionsContext, + type, + {definitions, context: {sentence, url, focus, disableHistory: true}} + ); } - _showPopupContent(textSource, type=null, details=null) { - this._lastShowPromise = this.popup.showContent( + _showPopupContent(textSource, optionsContext, type=null, details=null) { + const context = {optionsContext, source: this._id}; + this._lastShowPromise = this._popup.showContent( textSource.getRect(), textSource.getWritingMode(), type, - details + details, + context ); return this._lastShowPromise; } + async _updatePendingOptions() { + if (this._optionsUpdatePending) { + this._optionsUpdatePending = false; + await this.updateOptions(); + } + } + + _updateTextScannerEnabled() { + const enabled = ( + this._options.general.enable && + this._popup.depth <= this._options.scanning.popupNestingMaxDepth && + !this._disabledOverride + ); + this._enabledEventListeners.removeAllEventListeners(); + this._textScanner.setEnabled(enabled); + if (enabled) { + this._enabledEventListeners.addEventListener(window, 'message', this._onWindowMessage.bind(this)); + } + } + _updateContentScale() { - const {popupScalingFactor, popupScaleRelativeToPageZoom, popupScaleRelativeToVisualViewport} = this.options.general; + const {popupScalingFactor, popupScaleRelativeToPageZoom, popupScaleRelativeToVisualViewport} = this._options.general; let contentScale = popupScalingFactor; if (popupScaleRelativeToPageZoom) { contentScale /= this._pageZoomFactor; } if (popupScaleRelativeToVisualViewport) { - contentScale /= Frontend._getVisualViewportScale(); + 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; - this.popup.setContentScale(this._contentScale); + this._popup.setContentScale(this._contentScale); this._updatePopupPosition(); } + async _updatePopupPosition() { + const textSource = this._textScanner.getCurrentTextSource(); + if (textSource !== null && await this._popup.isVisible()) { + this._showPopupContent(textSource, await this.getOptionsContext()); + } + } + _broadcastRootPopupInformation() { - if (!this.popup.isProxy() && this.popup.depth === 0 && this.popup.frameId === 0) { - apiBroadcastTab('rootPopupInformation', {popupId: this.popup.id, frameId: this.popup.frameId}); + if (!this._popup.isProxy() && this._popup.depth === 0 && this._popup.frameId === 0) { + apiBroadcastTab('rootPopupInformation', {popupId: this._popup.id, frameId: this._popup.frameId}); } } _broadcastDocumentInformation(uniqueId) { apiBroadcastTab('documentInformationBroadcast', { uniqueId, - frameId: this.popup.frameId, + frameId: this._popup.frameId, title: document.title }); } - - _canEnable() { - return this.popup.depth <= this.options.scanning.popupNestingMaxDepth && !this._disabledOverride; - } - - async _updatePopupPosition() { - const textSource = this.getCurrentTextSource(); - if (textSource !== null && await this.popup.isVisible()) { - this._showPopupContent(textSource); - } - } - - static _getVisualViewportScale() { - const visualViewport = window.visualViewport; - return visualViewport !== null && typeof visualViewport === 'object' ? visualViewport.scale : 1.0; - } } diff --git a/ext/fg/js/popup-proxy-host.js b/ext/fg/js/popup-factory.js index 958462ff..b10acbaf 100644 --- a/ext/fg/js/popup-proxy-host.js +++ b/ext/fg/js/popup-factory.js @@ -18,35 +18,31 @@ /* global * FrontendApiReceiver * Popup - * apiFrameInformationGet */ -class PopupProxyHost { - constructor() { +class PopupFactory { + constructor(frameId) { this._popups = new Map(); - this._apiReceiver = null; - this._frameId = null; + this._frameId = frameId; } // Public functions async prepare() { - const {frameId} = await apiFrameInformationGet(); - if (typeof frameId !== 'number') { return; } - this._frameId = frameId; - - this._apiReceiver = new FrontendApiReceiver(`popup-proxy-host#${this._frameId}`, new Map([ - ['getOrCreatePopup', this._onApiGetOrCreatePopup.bind(this)], - ['setOptions', this._onApiSetOptions.bind(this)], - ['hide', this._onApiHide.bind(this)], - ['isVisible', this._onApiIsVisibleAsync.bind(this)], - ['setVisibleOverride', this._onApiSetVisibleOverride.bind(this)], - ['containsPoint', this._onApiContainsPoint.bind(this)], - ['showContent', this._onApiShowContent.bind(this)], - ['setCustomCss', this._onApiSetCustomCss.bind(this)], - ['clearAutoPlayTimer', this._onApiClearAutoPlayTimer.bind(this)], - ['setContentScale', this._onApiSetContentScale.bind(this)] + const apiReceiver = new FrontendApiReceiver(`popup-factory#${this._frameId}`, new Map([ + ['getOrCreatePopup', {async: false, 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)}], + ['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)}], + ['getUrl', {async: false, handler: this._onApiGetUrl.bind(this)}] ])); + apiReceiver.prepare(); } getOrCreatePopup(id=null, parentId=null, depth=null) { @@ -91,24 +87,25 @@ class PopupProxyHost { popup.setParent(parent); } this._popups.set(id, popup); + popup.prepare(); return popup; } // API message handlers - async _onApiGetOrCreatePopup({id, parentId}) { + _onApiGetOrCreatePopup({id, parentId}) { const popup = this.getOrCreatePopup(id, parentId); return { id: popup.id }; } - async _onApiSetOptions({id, options}) { + async _onApiSetOptionsContext({id, optionsContext, source}) { const popup = this._getPopup(id); - return await popup.setOptions(options); + return await popup.setOptionsContext(optionsContext, source); } - async _onApiHide({id, changeFocus}) { + _onApiHide({id, changeFocus}) { const popup = this._getPopup(id); return popup.hide(changeFocus); } @@ -125,32 +122,36 @@ class PopupProxyHost { async _onApiContainsPoint({id, x, y}) { const popup = this._getPopup(id); - [x, y] = PopupProxyHost._convertPopupPointToRootPagePoint(popup, x, y); + [x, y] = this._convertPopupPointToRootPagePoint(popup, x, y); return await popup.containsPoint(x, y); } - async _onApiShowContent({id, elementRect, writingMode, type, details}) { + async _onApiShowContent({id, elementRect, writingMode, type, details, context}) { const popup = this._getPopup(id); - elementRect = PopupProxyHost._convertJsonRectToDOMRect(popup, elementRect); - if (!PopupProxyHost._popupCanShow(popup)) { return; } - return await popup.showContent(elementRect, writingMode, type, details); + elementRect = this._convertJsonRectToDOMRect(popup, elementRect); + if (!this._popupCanShow(popup)) { return; } + return await popup.showContent(elementRect, writingMode, type, details, context); } - async _onApiSetCustomCss({id, css}) { + _onApiSetCustomCss({id, css}) { const popup = this._getPopup(id); return popup.setCustomCss(css); } - async _onApiClearAutoPlayTimer({id}) { + _onApiClearAutoPlayTimer({id}) { const popup = this._getPopup(id); return popup.clearAutoPlayTimer(); } - async _onApiSetContentScale({id, scale}) { + _onApiSetContentScale({id, scale}) { const popup = this._getPopup(id); return popup.setContentScale(scale); } + _onApiGetUrl() { + return window.location.href; + } + // Private functions _getPopup(id) { @@ -161,21 +162,21 @@ class PopupProxyHost { return popup; } - static _convertJsonRectToDOMRect(popup, jsonRect) { - const [x, y] = PopupProxyHost._convertPopupPointToRootPagePoint(popup, jsonRect.x, jsonRect.y); + _convertJsonRectToDOMRect(popup, jsonRect) { + const [x, y] = this._convertPopupPointToRootPagePoint(popup, jsonRect.x, jsonRect.y); return new DOMRect(x, y, jsonRect.width, jsonRect.height); } - static _convertPopupPointToRootPagePoint(popup, x, y) { + _convertPopupPointToRootPagePoint(popup, x, y) { if (popup.parent !== null) { - const popupRect = popup.parent.getContainerRect(); + const popupRect = popup.parent.getFrameRect(); x += popupRect.x; y += popupRect.y; } return [x, y]; } - static _popupCanShow(popup) { + _popupCanShow(popup) { return popup.parent === null || popup.parent.isVisibleSync(); } } diff --git a/ext/fg/js/popup-proxy.js b/ext/fg/js/popup-proxy.js index 82ad9a8f..82da839a 100644 --- a/ext/fg/js/popup-proxy.js +++ b/ext/fg/js/popup-proxy.js @@ -20,14 +20,13 @@ */ class PopupProxy { - constructor(id, depth, parentId, parentFrameId, url, getFrameOffset=null) { - this._parentId = parentId; - this._parentFrameId = parentFrameId; + constructor(id, depth, parentPopupId, parentFrameId, getFrameOffset=null, setDisabled=null) { this._id = id; this._depth = depth; - this._url = url; - this._apiSender = new FrontendApiSender(); + this._parentPopupId = parentPopupId; + this._apiSender = new FrontendApiSender(`popup-factory#${parentFrameId}`); this._getFrameOffset = getFrameOffset; + this._setDisabled = setDisabled; this._frameOffset = null; this._frameOffsetPromise = null; @@ -48,14 +47,10 @@ class PopupProxy { return this._depth; } - get url() { - return this._url; - } - // Public functions async prepare() { - const {id} = await this._invokeHostApi('getOrCreatePopup', {id: this._id, parentId: this._parentId}); + const {id} = await this._invoke('getOrCreatePopup', {id: this._id, parentId: this._parentPopupId}); this._id = id; } @@ -63,20 +58,20 @@ class PopupProxy { return true; } - async setOptions(options) { - return await this._invokeHostApi('setOptions', {id: this._id, options}); + async setOptionsContext(optionsContext, source) { + return await this._invoke('setOptionsContext', {id: this._id, optionsContext, source}); } hide(changeFocus) { - this._invokeHostApi('hide', {id: this._id, changeFocus}); + this._invoke('hide', {id: this._id, changeFocus}); } async isVisible() { - return await this._invokeHostApi('isVisible', {id: this._id}); + return await this._invoke('isVisible', {id: this._id}); } setVisibleOverride(visible) { - this._invokeHostApi('setVisibleOverride', {id: this._id, visible}); + this._invoke('setVisibleOverride', {id: this._id, visible}); } async containsPoint(x, y) { @@ -84,38 +79,39 @@ class PopupProxy { await this._updateFrameOffset(); [x, y] = this._applyFrameOffset(x, y); } - return await this._invokeHostApi('containsPoint', {id: this._id, x, y}); + return await this._invoke('containsPoint', {id: this._id, x, y}); } - async showContent(elementRect, writingMode, type=null, details=null) { + async showContent(elementRect, writingMode, type, details, context) { let {x, y, width, height} = elementRect; if (this._getFrameOffset !== null) { await this._updateFrameOffset(); [x, y] = this._applyFrameOffset(x, y); } elementRect = {x, y, width, height}; - return await this._invokeHostApi('showContent', {id: this._id, elementRect, writingMode, type, details}); + return await this._invoke('showContent', {id: this._id, elementRect, writingMode, type, details, context}); } - async setCustomCss(css) { - return await this._invokeHostApi('setCustomCss', {id: this._id, css}); + setCustomCss(css) { + this._invoke('setCustomCss', {id: this._id, css}); } clearAutoPlayTimer() { - this._invokeHostApi('clearAutoPlayTimer', {id: this._id}); + this._invoke('clearAutoPlayTimer', {id: this._id}); + } + + setContentScale(scale) { + this._invoke('setContentScale', {id: this._id, scale}); } - async setContentScale(scale) { - this._invokeHostApi('setContentScale', {id: this._id, scale}); + async getUrl() { + return await this._invoke('getUrl', {}); } // Private - _invokeHostApi(action, params={}) { - if (typeof this._parentFrameId !== 'number') { - return Promise.reject(new Error('Invalid frame')); - } - return this._apiSender.invoke(action, params, `popup-proxy-host#${this._parentFrameId}`); + _invoke(action, params={}) { + return this._apiSender.invoke(action, params); } async _updateFrameOffset() { @@ -142,9 +138,13 @@ class PopupProxy { try { const offset = await this._frameOffsetPromise; this._frameOffset = offset !== null ? offset : [0, 0]; + if (offset === null && this._setDisabled !== null) { + this._setDisabled(); + return; + } this._frameOffsetUpdatedAt = now; } catch (e) { - logError(e); + yomichan.logError(e); } finally { this._frameOffsetPromise = null; } diff --git a/ext/fg/js/popup.js b/ext/fg/js/popup.js index 42f08afa..b7d4b57e 100644 --- a/ext/fg/js/popup.js +++ b/ext/fg/js/popup.js @@ -16,8 +16,9 @@ */ /* global - * apiGetMessageToken - * apiInjectStylesheet + * DOM + * apiOptionsGet + * dynamicLoader */ class Popup { @@ -29,24 +30,24 @@ class Popup { this._child = null; this._childrenSupported = true; this._injectPromise = null; + this._injectPromiseComplete = false; this._visible = false; this._visibleOverride = null; this._options = null; + this._optionsContext = null; this._contentScale = 1.0; - this._containerSizeContentScale = null; this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, ''); - this._messageToken = null; + this._previousOptionsContextSource = null; - this._container = document.createElement('iframe'); - this._container.className = 'yomichan-float'; - this._container.addEventListener('mousedown', (e) => e.stopPropagation()); - this._container.addEventListener('scroll', (e) => e.stopPropagation()); - this._container.style.width = '0px'; - this._container.style.height = '0px'; + this._frameSizeContentScale = null; + this._frameSecret = null; + this._frameToken = null; + this._frame = document.createElement('iframe'); + this._frame.className = 'yomichan-float'; + this._frame.style.width = '0'; + this._frame.style.height = '0'; this._fullscreenEventListeners = new EventListenerCollection(); - - this._updateVisibility(); } // Public properties @@ -71,19 +72,27 @@ class Popup { return this._frameId; } - get url() { - return window.location.href; - } - // Public functions + prepare() { + this._updateVisibility(); + this._frame.addEventListener('mousedown', (e) => e.stopPropagation()); + this._frame.addEventListener('scroll', (e) => e.stopPropagation()); + this._frame.addEventListener('load', this._onFrameLoad.bind(this)); + } + isProxy() { return false; } - async setOptions(options) { - this._options = options; + async setOptionsContext(optionsContext, source) { + this._optionsContext = optionsContext; + this._previousOptionsContextSource = source; + + this._options = await apiOptionsGet(optionsContext); this.updateTheme(); + + this._invokeApi('setOptionsContext', {optionsContext}); } hide(changeFocus) { @@ -111,7 +120,7 @@ class Popup { async containsPoint(x, y) { for (let popup = this; popup !== null && popup.isVisibleSync(); popup = popup._child) { - const rect = popup._container.getBoundingClientRect(); + const rect = popup._frame.getBoundingClientRect(); if (x >= rect.left && y >= rect.top && x < rect.right && y < rect.bottom) { return true; } @@ -119,14 +128,20 @@ class Popup { return false; } - async showContent(elementRect, writingMode, type=null, details=null) { + async showContent(elementRect, writingMode, type, details, context) { if (this._options === null) { throw new Error('Options not assigned'); } + + const {optionsContext, source} = context; + if (source !== this._previousOptionsContextSource) { + await this.setOptionsContext(optionsContext, source); + } + await this._show(elementRect, writingMode); if (type === null) { return; } this._invokeApi('setContent', {type, details}); } - async setCustomCss(css) { + setCustomCss(css) { this._invokeApi('setCustomCss', {css}); } @@ -160,82 +175,218 @@ class Popup { } updateTheme() { - this._container.dataset.yomichanTheme = this._options.general.popupOuterTheme; - this._container.dataset.yomichanSiteColor = this._getSiteColor(); + this._frame.dataset.yomichanTheme = this._options.general.popupOuterTheme; + this._frame.dataset.yomichanSiteColor = this._getSiteColor(); } async setCustomOuterCss(css, useWebExtensionApi) { - return await Popup._injectStylesheet( - 'yomichan-popup-outer-user-stylesheet', - 'code', - css, - useWebExtensionApi - ); + return await dynamicLoader.loadStyle('yomichan-popup-outer-user-stylesheet', 'code', css, useWebExtensionApi); } setChildrenSupported(value) { this._childrenSupported = value; } - getContainer() { - return this._container; + getFrame() { + return this._frame; } - getContainerRect() { - return this._container.getBoundingClientRect(); + getFrameRect() { + return this._frame.getBoundingClientRect(); } // Private functions _inject() { - if (this._injectPromise === null) { - this._injectPromise = this._createInjectPromise(); + 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 this._injectPromise; + return injectPromise; + } + + _initializeFrame(frame, targetOrigin, frameId, setupFrame, timeout=10000) { + return new Promise((resolve, reject) => { + const tokenMap = new Map(); + let timer = null; + let frameLoadedResolve = null; + let frameLoadedReject = null; + const frameLoaded = new Promise((resolve2, reject2) => { + frameLoadedResolve = resolve2; + frameLoadedReject = reject2; + }); + + const postMessage = (action, params) => { + const contentWindow = frame.contentWindow; + if (contentWindow === null) { throw new Error('Frame missing content window'); } + + let validOrigin = true; + try { + validOrigin = (contentWindow.location.origin === targetOrigin); + } catch (e) { + // NOP + } + if (!validOrigin) { throw new Error('Unexpected frame origin'); } + + contentWindow.postMessage({action, params}, targetOrigin); + }; + + const onMessage = (message) => { + onMessageInner(message); + return false; + }; + + const onMessageInner = async (message) => { + try { + if (!isObject(message)) { return; } + const {action, params} = message; + if (!isObject(params)) { return; } + await frameLoaded; + if (timer === null) { return; } // Done + + switch (action) { + case 'popupPrepared': + { + const {secret} = params; + const token = yomichan.generateId(16); + tokenMap.set(secret, token); + postMessage('initialize', {secret, token, frameId}); + } + break; + case 'popupInitialized': + { + const {secret, token} = params; + const token2 = tokenMap.get(secret); + if (typeof token2 !== 'undefined' && token === token2) { + cleanup(); + resolve({secret, token}); + } + } + break; + } + } catch (e) { + cleanup(); + reject(e); + } + }; + + const onLoad = () => { + if (frameLoadedResolve === null) { + cleanup(); + reject(new Error('Unexpected load event')); + return; + } + + if (Popup.isFrameAboutBlank(frame)) { + return; + } + + frameLoadedResolve(); + frameLoadedResolve = null; + frameLoadedReject = null; + }; + + const cleanup = () => { + if (timer === null) { return; } // Done + clearTimeout(timer); + timer = null; + + frameLoadedResolve = null; + if (frameLoadedReject !== null) { + frameLoadedReject(new Error('Terminated')); + frameLoadedReject = null; + } + + chrome.runtime.onMessage.removeListener(onMessage); + frame.removeEventListener('load', onLoad); + }; + + // Start + timer = setTimeout(() => { + cleanup(); + reject(new Error('Timeout')); + }, timeout); + + chrome.runtime.onMessage.addListener(onMessage); + frame.addEventListener('load', onLoad); + + // Prevent unhandled rejections + frameLoaded.catch(() => {}); // NOP + + setupFrame(frame); + }); } async _createInjectPromise() { - if (this._messageToken === null) { - this._messageToken = await apiGetMessageToken(); - } + this._injectStyles(); + + const {secret, token} = await this._initializeFrame(this._frame, this._targetOrigin, this._frameId, (frame) => { + frame.removeAttribute('src'); + frame.removeAttribute('srcdoc'); + this._observeFullscreen(true); + this._onFullscreenChanged(); + frame.contentDocument.location.href = chrome.runtime.getURL('/fg/float.html'); + }); + this._frameSecret = secret; + this._frameToken = token; + // Configure + const messageId = yomichan.generateId(16); const popupPreparedPromise = yomichan.getTemporaryListenerResult( chrome.runtime.onMessage, - ({action, params}, {resolve}) => { + (message, {resolve}) => { if ( - action === 'popupPrepareCompleted' && - isObject(params) && - params.targetPopupId === this._id + isObject(message) && + message.action === 'popupConfigured' && + isObject(message.params) && + message.params.messageId === messageId ) { resolve(); } } ); - - const parentFrameId = (typeof this._frameId === 'number' ? this._frameId : null); - this._container.setAttribute('src', chrome.runtime.getURL('/fg/float.html')); - this._container.addEventListener('load', () => { - this._invokeApi('prepare', { - popupInfo: { - id: this._id, - depth: this._depth, - parentFrameId - }, - url: this.url, - childrenSupported: this._childrenSupported, - scale: this._contentScale - }); + this._invokeApi('configure', { + messageId, + frameId: this._frameId, + popupId: this._id, + optionsContext: this._optionsContext, + childrenSupported: this._childrenSupported, + scale: this._contentScale }); - this._observeFullscreen(true); - this._onFullscreenChanged(); - this._injectStyles(); return popupPreparedPromise; } + _onFrameLoad() { + if (!this._injectPromiseComplete) { return; } + this._resetFrame(); + } + + _resetFrame() { + const parent = this._frame.parentNode; + if (parent !== null) { + parent.removeChild(this._frame); + } + this._frame.removeAttribute('src'); + this._frame.removeAttribute('srcdoc'); + + this._frameSecret = null; + this._frameToken = null; + this._injectPromise = null; + this._injectPromiseComplete = false; + } + async _injectStyles() { try { - await Popup._injectStylesheet('yomichan-popup-outer-stylesheet', 'file', '/fg/css/client.css', true); + await dynamicLoader.loadStyle('yomichan-popup-outer-stylesheet', 'file', '/fg/css/client.css', true); } catch (e) { // NOP } @@ -271,9 +422,9 @@ class Popup { } _onFullscreenChanged() { - const parent = (Popup._getFullscreenElement() || document.body || null); - if (parent !== null && this._container.parentNode !== parent) { - parent.appendChild(this._container); + const parent = this._getFrameParentElement(); + if (parent !== null && this._frame.parentNode !== parent) { + parent.appendChild(this._frame); } } @@ -281,31 +432,31 @@ class Popup { await this._inject(); const optionsGeneral = this._options.general; - const container = this._container; - const containerRect = container.getBoundingClientRect(); - const getPosition = ( - writingMode === 'horizontal-tb' || optionsGeneral.popupVerticalTextPosition === 'default' ? - Popup._getPositionForHorizontalText : - Popup._getPositionForVerticalText - ); + const frame = this._frame; + const frameRect = frame.getBoundingClientRect(); - const viewport = Popup._getViewport(optionsGeneral.popupScaleRelativeToVisualViewport); + const viewport = this._getViewport(optionsGeneral.popupScaleRelativeToVisualViewport); const scale = this._contentScale; - const scaleRatio = this._containerSizeContentScale === null ? 1.0 : scale / this._containerSizeContentScale; - this._containerSizeContentScale = scale; - let [x, y, width, height, below] = getPosition( + const scaleRatio = this._frameSizeContentScale === null ? 1.0 : scale / this._frameSizeContentScale; + this._frameSizeContentScale = scale; + const getPositionArgs = [ elementRect, - Math.max(containerRect.width * scaleRatio, optionsGeneral.popupWidth * scale), - Math.max(containerRect.height * scaleRatio, optionsGeneral.popupHeight * scale), + 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) ); const fullWidth = (optionsGeneral.popupDisplayMode === 'full-width'); - container.classList.toggle('yomichan-float-full-width', fullWidth); - container.classList.toggle('yomichan-float-above', !below); + frame.classList.toggle('yomichan-float-full-width', fullWidth); + frame.classList.toggle('yomichan-float-above', !below); if (optionsGeneral.popupDisplayMode === 'full-width') { x = viewport.left; @@ -313,10 +464,10 @@ class Popup { width = viewport.right - viewport.left; } - container.style.left = `${x}px`; - container.style.top = `${y}px`; - container.style.width = `${width}px`; - container.style.height = `${height}px`; + frame.style.left = `${x}px`; + frame.style.top = `${y}px`; + frame.style.width = `${width}px`; + frame.style.height = `${height}px`; this._setVisible(true); if (this._child !== null) { @@ -330,20 +481,20 @@ class Popup { } _updateVisibility() { - this._container.style.setProperty('visibility', this.isVisibleSync() ? 'visible' : 'hidden', 'important'); + this._frame.style.setProperty('visibility', this.isVisibleSync() ? 'visible' : 'hidden', 'important'); } _focusParent() { if (this._parent !== null) { // Chrome doesn't like focusing iframe without contentWindow. - const contentWindow = this._parent._container.contentWindow; + const contentWindow = this._parent.getFrame().contentWindow; if (contentWindow !== null) { contentWindow.focus(); } } else { // Firefox doesn't like focusing window without first blurring the iframe. - // this.container.contentWindow.blur() doesn't work on Firefox for some reason. - this._container.blur(); + // this._frame.contentWindow.blur() doesn't work on Firefox for some reason. + this._frame.blur(); // This is needed for Chrome. window.focus(); } @@ -351,36 +502,52 @@ class Popup { _getSiteColor() { const color = [255, 255, 255]; - Popup._addColor(color, Popup._getColorInfo(window.getComputedStyle(document.documentElement).backgroundColor)); - Popup._addColor(color, Popup._getColorInfo(window.getComputedStyle(document.body).backgroundColor)); + 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'; } _invokeApi(action, params={}) { - const token = this._messageToken; - const contentWindow = this._container.contentWindow; - if (token === null || contentWindow === null) { return; } + const secret = this._frameSecret; + const token = this._frameToken; + const contentWindow = this._frame.contentWindow; + if (secret === null || token === null || contentWindow === null) { return; } - contentWindow.postMessage({action, params, token}, this._targetOrigin); + contentWindow.postMessage({action, params, secret, token}, this._targetOrigin); } - static _getFullscreenElement() { - return ( - document.fullscreenElement || - document.msFullscreenElement || - document.mozFullScreenElement || - document.webkitFullscreenElement || - null - ); + _getFrameParentElement() { + const defaultParent = document.body; + const fullscreenElement = DOM.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; } - static _getPositionForHorizontalText(elementRect, width, height, viewport, offsetScale, optionsGeneral) { + _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] = Popup._getConstrainedPosition( + const [x, w] = this._getConstrainedPosition( elementRect.right - horizontalOffset, elementRect.left + horizontalOffset, width, @@ -388,7 +555,7 @@ class Popup { viewport.right, true ); - const [y, h, below] = Popup._getConstrainedPositionBinary( + const [y, h, below] = this._getConstrainedPositionBinary( elementRect.top - verticalOffset, elementRect.bottom + verticalOffset, height, @@ -399,12 +566,12 @@ class Popup { return [x, y, w, h, below]; } - static _getPositionForVerticalText(elementRect, width, height, viewport, offsetScale, optionsGeneral, writingMode) { - const preferRight = Popup._isVerticalTextPopupOnRight(optionsGeneral.popupVerticalTextPosition, writingMode); + _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] = Popup._getConstrainedPositionBinary( + const [x, w] = this._getConstrainedPositionBinary( elementRect.left - horizontalOffset, elementRect.right + horizontalOffset, width, @@ -412,7 +579,7 @@ class Popup { viewport.right, preferRight ); - const [y, h, below] = Popup._getConstrainedPosition( + const [y, h, below] = this._getConstrainedPosition( elementRect.bottom - verticalOffset, elementRect.top + verticalOffset, height, @@ -423,20 +590,22 @@ class Popup { return [x, y, w, h, below]; } - static _isVerticalTextPopupOnRight(positionPreference, writingMode) { + _isVerticalTextPopupOnRight(positionPreference, writingMode) { switch (positionPreference) { case 'before': - return !Popup._isWritingModeLeftToRight(writingMode); + return !this._isWritingModeLeftToRight(writingMode); case 'after': - return Popup._isWritingModeLeftToRight(writingMode); + return this._isWritingModeLeftToRight(writingMode); case 'left': return false; case 'right': return true; + default: + return false; } } - static _isWritingModeLeftToRight(writingMode) { + _isWritingModeLeftToRight(writingMode) { switch (writingMode) { case 'vertical-lr': case 'sideways-lr': @@ -446,7 +615,7 @@ class Popup { } } - static _getConstrainedPosition(positionBefore, positionAfter, size, minLimit, maxLimit, after) { + _getConstrainedPosition(positionBefore, positionAfter, size, minLimit, maxLimit, after) { size = Math.min(size, maxLimit - minLimit); let position; @@ -461,7 +630,7 @@ class Popup { return [position, size, after]; } - static _getConstrainedPositionBinary(positionBefore, positionAfter, size, minLimit, maxLimit, after) { + _getConstrainedPositionBinary(positionBefore, positionAfter, size, minLimit, maxLimit, after) { const overflowBefore = minLimit - (positionBefore - size); const overflowAfter = (positionAfter + size) - maxLimit; @@ -481,7 +650,10 @@ class Popup { return [position, size, after]; } - static _addColor(target, color) { + _addColor(target, cssColor) { + if (typeof cssColor !== 'string') { return; } + + const color = this._getColorInfo(cssColor); if (color === null) { return; } const a = color[3]; @@ -493,7 +665,7 @@ class Popup { } } - static _getColorInfo(cssColor) { + _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; } @@ -506,7 +678,7 @@ class Popup { ]; } - static _getViewport(useVisualViewport) { + _getViewport(useVisualViewport) { const visualViewport = window.visualViewport; if (visualViewport !== null && typeof visualViewport === 'object') { const left = visualViewport.offsetLeft; @@ -531,87 +703,23 @@ class Popup { } } + const body = document.body; return { left: 0, top: 0, - right: document.body.clientWidth, + right: (body !== null ? body.clientWidth : 0), bottom: window.innerHeight }; } - static _isOnExtensionPage() { + static isFrameAboutBlank(frame) { try { - const url = chrome.runtime.getURL('/'); - return window.location.href.substring(0, url.length) === url; + const contentDocument = frame.contentDocument; + if (contentDocument === null) { return false; } + const url = contentDocument.location.href; + return /^about:blank(?:[#?]|$)/.test(url); } catch (e) { - // NOP - } - } - - static async _injectStylesheet(id, type, value, useWebExtensionApi) { - const injectedStylesheets = Popup._injectedStylesheets; - - if (Popup._isOnExtensionPage()) { - // Permissions error will occur if trying to use the WebExtension API to inject - // into an extension page. - useWebExtensionApi = false; - } - - let styleNode = injectedStylesheets.get(id); - if (typeof styleNode !== 'undefined') { - if (styleNode === null) { - // Previously injected via WebExtension API - throw new Error(`Stylesheet with id ${id} has already been injected using the WebExtension API`); - } - } else { - styleNode = null; + return false; } - - if (useWebExtensionApi) { - // Inject via WebExtension API - if (styleNode !== null && styleNode.parentNode !== null) { - styleNode.parentNode.removeChild(styleNode); - } - - await apiInjectStylesheet(type, value); - - injectedStylesheets.set(id, null); - return null; - } - - // Create node in document - const parentNode = document.head; - if (parentNode === null) { - throw new Error('No parent node'); - } - - // Create or reuse node - const isFile = (type === 'file'); - const tagName = isFile ? 'link' : 'style'; - if (styleNode === null || styleNode.nodeName.toLowerCase() !== tagName) { - if (styleNode !== null && styleNode.parentNode !== null) { - styleNode.parentNode.removeChild(styleNode); - } - styleNode = document.createElement(tagName); - styleNode.id = id; - } - - // Update node style - if (isFile) { - styleNode.rel = value; - } else { - styleNode.textContent = value; - } - - // Update parent - if (styleNode.parentNode !== parentNode) { - parentNode.appendChild(styleNode); - } - - // Add to map - injectedStylesheets.set(id, styleNode); - return styleNode; } } - -Popup._injectedStylesheets = new Map(); diff --git a/ext/fg/js/source.js b/ext/fg/js/source.js index 3d9afe0f..fa4706f2 100644 --- a/ext/fg/js/source.js +++ b/ext/fg/js/source.js @@ -46,10 +46,14 @@ class TextSourceRange { return this.content; } - setEndOffset(length) { - const state = TextSourceRange.seekForward(this.range.startContainer, this.range.startOffset, length); + setEndOffset(length, fromEnd=false) { + const state = ( + fromEnd ? + TextSourceRange.seekForward(this.range.endContainer, this.range.endOffset, length) : + TextSourceRange.seekForward(this.range.startContainer, this.range.startOffset, length) + ); this.range.setEnd(state.node, state.offset); - this.content = state.content; + this.content = (fromEnd ? this.content + state.content : state.content); return length - state.remainder; } @@ -57,7 +61,7 @@ class TextSourceRange { const state = TextSourceRange.seekBackward(this.range.startContainer, this.range.startOffset, length); this.range.setStart(state.node, state.offset); this.rangeStartOffset = this.range.startOffset; - this.content = state.content; + this.content = state.content + this.content; return length - state.remainder; } @@ -94,7 +98,15 @@ class TextSourceRange { this.rangeStartOffset === other.rangeStartOffset ); } else { - return this.range.compareBoundaryPoints(Range.START_TO_START, other.range) === 0; + 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; + } } } @@ -110,7 +122,8 @@ class TextSourceRange { return !( style.visibility === 'hidden' || style.display === 'none' || - parseFloat(style.fontSize) === 0); + parseFloat(style.fontSize) === 0 + ); } static getRubyElement(node) { @@ -345,13 +358,32 @@ class TextSourceRange { */ class TextSourceElement { - constructor(element, content='') { - this.element = element; - this.content = content; + 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; } clone() { - return new TextSourceElement(this.element, this.content); + return new TextSourceElement(this._element, this._fullContent, this._startOffset, this._endOffset); } cleanup() { @@ -359,44 +391,32 @@ class TextSourceElement { } text() { - return this.content; + return this._content; } - setEndOffset(length) { - switch (this.element.nodeName.toUpperCase()) { - case 'BUTTON': - this.content = this.element.textContent; - break; - case 'IMG': - this.content = this.element.getAttribute('alt'); - break; - default: - this.content = this.element.value; - break; - } - - let consumed = 0; - let content = ''; - for (const currentChar of this.content || '') { - if (consumed >= length) { - break; - } else if (!currentChar.match(IGNORE_TEXT_PATTERN)) { - consumed++; - content += currentChar; - } + 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; } - - this.content = content; - - return this.content.length; } - setStartOffset() { - return 0; + setStartOffset(length) { + const delta = Math.min(this._startOffset, length); + this._startOffset -= delta; + this._content = this._fullContent.substring(this._startOffset, this._endOffset); + return delta; } getRect() { - return this.element.getBoundingClientRect(); + return this._element.getBoundingClientRect(); } getWritingMode() { @@ -416,8 +436,30 @@ class TextSourceElement { typeof other === 'object' && other !== null && other instanceof TextSourceElement && - other.element === this.element && - other.content === this.content + this._element === other.element && + this._fullContent === other.fullContent && + this._startOffset === other.startOffset && + this._endOffset === other.endOffset ); } + + 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; + } } |