summaryrefslogtreecommitdiff
path: root/ext/fg/js
diff options
context:
space:
mode:
authorAlex Yatskov <alex@foosoft.net>2020-05-22 17:46:16 -0700
committerAlex Yatskov <alex@foosoft.net>2020-05-22 17:46:16 -0700
commit1480288561cb8b9fb87ad711d970c548329fea98 (patch)
tree87c2247f6d144407afcc6de316bbacc264582248 /ext/fg/js
parentf2186c51e4ef219d158735d30a32bbf3e49c4e1a (diff)
parentd0dcff765f740bf6f0f6523b09cb8b21eb85cd93 (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.js7
-rw-r--r--ext/fg/js/dom-text-scanner.js551
-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.js152
-rw-r--r--ext/fg/js/frame-offset-forwarder.js73
-rw-r--r--ext/fg/js/frontend-api-receiver.js55
-rw-r--r--ext/fg/js/frontend-api-sender.js90
-rw-r--r--ext/fg/js/frontend.js338
-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.js58
-rw-r--r--ext/fg/js/popup.js486
-rw-r--r--ext/fg/js/source.js126
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;
+ }
}