From 6da02c6eee803756d9a9075bfde333eeb31ce64b Mon Sep 17 00:00:00 2001 From: toasted-nutbread Date: Sun, 9 Aug 2020 13:27:21 -0400 Subject: document.js refactor (#719) * Refactor document.js into a class * Move public functions first * Rename private functions * Rename * Rename argument * Use instance of DocumentUtil * Update tests * Refactor * Rename (test-)document.js to (test-)document-util.js --- dev/data/manifest-variants.json | 2 +- ext/bg/js/query-parser.js | 9 +- ext/bg/search.html | 2 +- ext/bg/settings-popup-preview.html | 2 +- ext/fg/float.html | 2 +- ext/fg/js/document-util.js | 381 +++++++++++++++++++++++++++++++++++++ ext/fg/js/document.js | 374 ------------------------------------ ext/fg/js/frontend.js | 8 +- ext/manifest.json | 2 +- ext/mixed/js/display.js | 11 +- ext/mixed/js/text-scanner.js | 6 +- package.json | 2 +- test/test-document-util.js | 241 +++++++++++++++++++++++ test/test-document.js | 241 ----------------------- 14 files changed, 647 insertions(+), 636 deletions(-) create mode 100644 ext/fg/js/document-util.js delete mode 100644 ext/fg/js/document.js create mode 100644 test/test-document-util.js delete mode 100644 test/test-document.js diff --git a/dev/data/manifest-variants.json b/dev/data/manifest-variants.json index bac781da..c8135baf 100644 --- a/dev/data/manifest-variants.json +++ b/dev/data/manifest-variants.json @@ -47,7 +47,7 @@ "mixed/js/dynamic-loader.js", "mixed/js/frame-client.js", "mixed/js/text-scanner.js", - "fg/js/document.js", + "fg/js/document-util.js", "fg/js/dom-text-scanner.js", "fg/js/popup.js", "fg/js/source.js", diff --git a/ext/bg/js/query-parser.js b/ext/bg/js/query-parser.js index 6ca09a82..2ad88601 100644 --- a/ext/bg/js/query-parser.js +++ b/ext/bg/js/query-parser.js @@ -19,11 +19,10 @@ * QueryParserGenerator * TextScanner * api - * docSentenceExtract */ class QueryParser extends EventDispatcher { - constructor({getOptionsContext, setSpinnerVisible}) { + constructor({getOptionsContext, setSpinnerVisible, documentUtil}) { super(); this._getOptionsContext = getOptionsContext; this._setSpinnerVisible = setSpinnerVisible; @@ -31,6 +30,7 @@ class QueryParser extends EventDispatcher { this._scanLength = 1; this._sentenceExtent = 1; this._layoutAwareScan = false; + this._documentUtil = documentUtil; this._parseResults = []; this._queryParser = document.querySelector('#query-parser-content'); this._queryParserSelect = document.querySelector('#query-parser-select-container'); @@ -39,7 +39,8 @@ class QueryParser extends EventDispatcher { node: this._queryParser, ignoreElements: () => [], ignorePoint: null, - search: this._search.bind(this) + search: this._search.bind(this), + documentUtil }); } @@ -104,7 +105,7 @@ class QueryParser extends EventDispatcher { const {definitions, length} = await api.termsFind(searchText, {}, optionsContext); if (definitions.length === 0) { return null; } - const sentence = docSentenceExtract(textSource, sentenceExtent, layoutAwareScan); + const sentence = this._documentUtil.extractSentence(textSource, sentenceExtent, layoutAwareScan); textSource.setEndOffset(length, layoutAwareScan); diff --git a/ext/bg/search.html b/ext/bg/search.html index eb85e368..aff71835 100644 --- a/ext/bg/search.html +++ b/ext/bg/search.html @@ -79,7 +79,7 @@ - + diff --git a/ext/bg/settings-popup-preview.html b/ext/bg/settings-popup-preview.html index 5b3a9692..3479efa4 100644 --- a/ext/bg/settings-popup-preview.html +++ b/ext/bg/settings-popup-preview.html @@ -127,7 +127,7 @@ - + diff --git a/ext/fg/float.html b/ext/fg/float.html index 17378713..51aa2350 100644 --- a/ext/fg/float.html +++ b/ext/fg/float.html @@ -51,7 +51,7 @@ - + diff --git a/ext/fg/js/document-util.js b/ext/fg/js/document-util.js new file mode 100644 index 00000000..d3bba30f --- /dev/null +++ b/ext/fg/js/document-util.js @@ -0,0 +1,381 @@ +/* + * Copyright (C) 2016-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 . + */ + +/* global + * DOM + * DOMTextScanner + * TextSourceElement + * TextSourceRange + */ + +class DocumentUtil { + constructor() { + this._transparentColorPattern = /rgba\s*\([^)]*,\s*0(?:\.0+)?\s*\)/; + } + + getRangeFromPoint(x, y, deepContentScan) { + const elements = this._getElementsFromPoint(x, y, deepContentScan); + let imposter = null; + let imposterContainer = null; + let imposterSourceElement = null; + if (elements.length > 0) { + const element = elements[0]; + switch (element.nodeName.toUpperCase()) { + case 'IMG': + case 'BUTTON': + return new TextSourceElement(element); + case 'INPUT': + imposterSourceElement = element; + [imposter, imposterContainer] = this._createImposter(element, false); + break; + case 'TEXTAREA': + imposterSourceElement = element; + [imposter, imposterContainer] = this._createImposter(element, true); + break; + } + } + + const range = this._caretRangeFromPointExt(x, y, deepContentScan ? elements : []); + if (range !== null) { + if (imposter !== null) { + this._setImposterStyle(imposterContainer.style, 'z-index', '-2147483646'); + this._setImposterStyle(imposter.style, 'pointer-events', 'none'); + } + return new TextSourceRange(range, '', imposterContainer, imposterSourceElement); + } else { + if (imposterContainer !== null) { + imposterContainer.parentNode.removeChild(imposterContainer); + } + return null; + } + } + + extractSentence(source, extent, layoutAwareScan) { + const quotesFwd = {'「': '」', '『': '』', "'": "'", '"': '"'}; + const quotesBwd = {'」': '「', '』': '『', "'": "'", '"': '"'}; + const terminators = '…。..??!!'; + + const sourceLocal = source.clone(); + const position = sourceLocal.setStartOffset(extent, layoutAwareScan); + sourceLocal.setEndOffset(extent * 2 - position, layoutAwareScan, true); + const content = sourceLocal.text(); + + let quoteStack = []; + + let startPos = 0; + for (let i = position; i >= startPos; --i) { + const c = content[i]; + + if (c === '\n') { + startPos = i + 1; + break; + } + + if (quoteStack.length === 0 && (terminators.includes(c) || c in quotesFwd)) { + startPos = i + 1; + break; + } + + if (quoteStack.length > 0 && c === quoteStack[0]) { + quoteStack.pop(); + } else if (c in quotesBwd) { + quoteStack.unshift(quotesBwd[c]); + } + } + + quoteStack = []; + + let endPos = content.length; + for (let i = position; i <= endPos; ++i) { + const c = content[i]; + + if (c === '\n') { + endPos = i + 1; + break; + } + + if (quoteStack.length === 0) { + if (terminators.includes(c)) { + endPos = i + 1; + break; + } else if (c in quotesBwd) { + endPos = i; + break; + } + } + + if (quoteStack.length > 0 && c === quoteStack[0]) { + quoteStack.pop(); + } else if (c in quotesFwd) { + quoteStack.unshift(quotesFwd[c]); + } + } + + const text = content.substring(startPos, endPos); + const padding = text.length - text.replace(/^\s+/, '').length; + + return { + text: text.trim(), + offset: position - startPos - padding + }; + } + + // Private + + _setImposterStyle(style, propertyName, value) { + style.setProperty(propertyName, value, 'important'); + } + + _createImposter(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(); + let left = elementRect.left - documentRect.left; + let top = elementRect.top - documentRect.top; + + // Container + const container = document.createElement('div'); + const containerStyle = container.style; + this._setImposterStyle(containerStyle, 'all', 'initial'); + this._setImposterStyle(containerStyle, 'position', 'absolute'); + this._setImposterStyle(containerStyle, 'left', '0'); + this._setImposterStyle(containerStyle, 'top', '0'); + this._setImposterStyle(containerStyle, 'width', `${documentRect.width}px`); + this._setImposterStyle(containerStyle, 'height', `${documentRect.height}px`); + this._setImposterStyle(containerStyle, 'overflow', 'hidden'); + this._setImposterStyle(containerStyle, 'opacity', '0'); + this._setImposterStyle(containerStyle, 'pointer-events', 'none'); + this._setImposterStyle(containerStyle, 'z-index', '2147483646'); + + // Imposter + const imposter = document.createElement('div'); + const imposterStyle = imposter.style; + + let value = element.value; + if (value.endsWith('\n')) { value += '\n'; } + imposter.textContent = value; + + for (let i = 0, ii = elementStyle.length; i < ii; ++i) { + const property = elementStyle[i]; + this._setImposterStyle(imposterStyle, property, elementStyle.getPropertyValue(property)); + } + this._setImposterStyle(imposterStyle, 'position', 'absolute'); + this._setImposterStyle(imposterStyle, 'top', `${top}px`); + this._setImposterStyle(imposterStyle, 'left', `${left}px`); + this._setImposterStyle(imposterStyle, 'margin', '0'); + this._setImposterStyle(imposterStyle, 'pointer-events', 'auto'); + + if (isTextarea) { + if (elementStyle.overflow === 'visible') { + this._setImposterStyle(imposterStyle, 'overflow', 'auto'); + } + } else { + this._setImposterStyle(imposterStyle, 'overflow', 'hidden'); + this._setImposterStyle(imposterStyle, 'white-space', 'nowrap'); + this._setImposterStyle(imposterStyle, 'line-height', elementStyle.height); + } + + container.appendChild(imposter); + body.appendChild(container); + + // Adjust size + const imposterRect = imposter.getBoundingClientRect(); + if (imposterRect.width !== elementRect.width || imposterRect.height !== elementRect.height) { + const width = parseFloat(elementStyle.width) + (elementRect.width - imposterRect.width); + const height = parseFloat(elementStyle.height) + (elementRect.height - imposterRect.height); + this._setImposterStyle(imposterStyle, 'width', `${width}px`); + this._setImposterStyle(imposterStyle, 'height', `${height}px`); + } + if (imposterRect.x !== elementRect.x || imposterRect.y !== elementRect.y) { + left += (elementRect.left - imposterRect.left); + top += (elementRect.top - imposterRect.top); + this._setImposterStyle(imposterStyle, 'left', `${left}px`); + this._setImposterStyle(imposterStyle, 'top', `${top}px`); + } + + imposter.scrollTop = element.scrollTop; + imposter.scrollLeft = element.scrollLeft; + + return [imposter, container]; + } + + _getElementsFromPoint(x, y, all) { + if (all) { + // document.elementsFromPoint can return duplicates which must be removed. + const elements = document.elementsFromPoint(x, y); + return elements.filter((e, i) => elements.indexOf(e) === i); + } + + const e = document.elementFromPoint(x, y); + return e !== null ? [e] : []; + } + + _isPointInRange(x, y, range) { + // Require a text node to start + if (range.startContainer.nodeType !== Node.TEXT_NODE) { + return false; + } + + // Scan forward + const nodePre = range.endContainer; + const offsetPre = range.endOffset; + try { + const {node, offset, content} = new DOMTextScanner(range.endContainer, range.endOffset, true, false).seek(1); + range.setEnd(node, offset); + + if (!this._isWhitespace(content) && DOM.isPointInAnyRect(x, y, range.getClientRects())) { + return true; + } + } finally { + range.setEnd(nodePre, offsetPre); + } + + // Scan backward + const {node, offset, content} = new DOMTextScanner(range.startContainer, range.startOffset, true, false).seek(-1); + range.setStart(node, offset); + + if (!this._isWhitespace(content) && DOM.isPointInAnyRect(x, y, range.getClientRects())) { + // This purposefully leaves the starting offset as modified and sets the range length to 0. + range.setEnd(node, offset); + return true; + } + + // No match + return false; + } + + _isWhitespace(string) { + return string.trim().length === 0; + } + + _caretRangeFromPoint(x, y) { + if (typeof document.caretRangeFromPoint === 'function') { + // Chrome, Edge + return document.caretRangeFromPoint(x, y); + } + + if (typeof document.caretPositionFromPoint === 'function') { + // Firefox + return this._caretPositionFromPoint(x, y); + } + + // No support + return null; + } + + _caretPositionFromPoint(x, y) { + const position = document.caretPositionFromPoint(x, y); + if (position === null) { + return null; + } + const node = position.offsetNode; + if (node === null) { + return null; + } + + const range = document.createRange(); + const offset = (node.nodeType === Node.TEXT_NODE ? position.offset : 0); + try { + range.setStart(node, offset); + range.setEnd(node, offset); + } catch (e) { + // Firefox throws new DOMException("The operation is insecure.") + // when trying to select a node from within a ShadowRoot. + return null; + } + return range; + } + + _caretRangeFromPointExt(x, y, elements) { + const modifications = []; + try { + let i = 0; + let startContinerPre = null; + while (true) { + const range = this._caretRangeFromPoint(x, y); + if (range === null) { + return null; + } + + const startContainer = range.startContainer; + if (startContinerPre !== startContainer) { + if (this._isPointInRange(x, y, range)) { + return range; + } + startContinerPre = startContainer; + } + + i = this._disableTransparentElement(elements, i, modifications); + if (i < 0) { + return null; + } + } + } finally { + if (modifications.length > 0) { + this._restoreElementStyleModifications(modifications); + } + } + } + + _disableTransparentElement(elements, i, modifications) { + while (true) { + if (i >= elements.length) { + return -1; + } + + const element = elements[i++]; + if (this._isElementTransparent(element)) { + const style = element.hasAttribute('style') ? element.getAttribute('style') : null; + modifications.push({element, style}); + element.style.setProperty('pointer-events', 'none', 'important'); + return i; + } + } + } + + _restoreElementStyleModifications(modifications) { + for (const {element, style} of modifications) { + if (style === null) { + element.removeAttribute('style'); + } else { + element.setAttribute('style', style); + } + } + } + + _isElementTransparent(element) { + if ( + element === document.body || + element === document.documentElement + ) { + return false; + } + const style = window.getComputedStyle(element); + return ( + parseFloat(style.opacity) <= 0 || + style.visibility === 'hidden' || + (style.backgroundImage === 'none' && this._isColorTransparent(style.backgroundColor)) + ); + } + + _isColorTransparent(cssColor) { + return this._transparentColorPattern.test(cssColor); + } +} diff --git a/ext/fg/js/document.js b/ext/fg/js/document.js deleted file mode 100644 index c288502c..00000000 --- a/ext/fg/js/document.js +++ /dev/null @@ -1,374 +0,0 @@ -/* - * Copyright (C) 2016-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 . - */ - -/* global - * DOM - * DOMTextScanner - * TextSourceElement - * TextSourceRange - */ - -const REGEX_TRANSPARENT_COLOR = /rgba\s*\([^)]*,\s*0(?:\.0+)?\s*\)/; - -function docSetImposterStyle(style, propertyName, value) { - style.setProperty(propertyName, value, 'important'); -} - -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(); - let left = elementRect.left - documentRect.left; - let top = elementRect.top - documentRect.top; - - // Container - const container = document.createElement('div'); - const containerStyle = container.style; - docSetImposterStyle(containerStyle, 'all', 'initial'); - docSetImposterStyle(containerStyle, 'position', 'absolute'); - docSetImposterStyle(containerStyle, 'left', '0'); - docSetImposterStyle(containerStyle, 'top', '0'); - docSetImposterStyle(containerStyle, 'width', `${documentRect.width}px`); - docSetImposterStyle(containerStyle, 'height', `${documentRect.height}px`); - docSetImposterStyle(containerStyle, 'overflow', 'hidden'); - docSetImposterStyle(containerStyle, 'opacity', '0'); - - docSetImposterStyle(containerStyle, 'pointer-events', 'none'); - docSetImposterStyle(containerStyle, 'z-index', '2147483646'); - - // Imposter - const imposter = document.createElement('div'); - const imposterStyle = imposter.style; - - let value = element.value; - if (value.endsWith('\n')) { value += '\n'; } - imposter.textContent = value; - - for (let i = 0, ii = elementStyle.length; i < ii; ++i) { - const property = elementStyle[i]; - docSetImposterStyle(imposterStyle, property, elementStyle.getPropertyValue(property)); - } - docSetImposterStyle(imposterStyle, 'position', 'absolute'); - docSetImposterStyle(imposterStyle, 'top', `${top}px`); - docSetImposterStyle(imposterStyle, 'left', `${left}px`); - docSetImposterStyle(imposterStyle, 'margin', '0'); - docSetImposterStyle(imposterStyle, 'pointer-events', 'auto'); - - if (isTextarea) { - if (elementStyle.overflow === 'visible') { - docSetImposterStyle(imposterStyle, 'overflow', 'auto'); - } - } else { - docSetImposterStyle(imposterStyle, 'overflow', 'hidden'); - docSetImposterStyle(imposterStyle, 'white-space', 'nowrap'); - docSetImposterStyle(imposterStyle, 'line-height', elementStyle.height); - } - - container.appendChild(imposter); - body.appendChild(container); - - // Adjust size - const imposterRect = imposter.getBoundingClientRect(); - if (imposterRect.width !== elementRect.width || imposterRect.height !== elementRect.height) { - const width = parseFloat(elementStyle.width) + (elementRect.width - imposterRect.width); - const height = parseFloat(elementStyle.height) + (elementRect.height - imposterRect.height); - docSetImposterStyle(imposterStyle, 'width', `${width}px`); - docSetImposterStyle(imposterStyle, 'height', `${height}px`); - } - if (imposterRect.x !== elementRect.x || imposterRect.y !== elementRect.y) { - left += (elementRect.left - imposterRect.left); - top += (elementRect.top - imposterRect.top); - docSetImposterStyle(imposterStyle, 'left', `${left}px`); - docSetImposterStyle(imposterStyle, 'top', `${top}px`); - } - - imposter.scrollTop = element.scrollTop; - imposter.scrollLeft = element.scrollLeft; - - return [imposter, container]; -} - -function docElementsFromPoint(x, y, all) { - if (all) { - // document.elementsFromPoint can return duplicates which must be removed. - const elements = document.elementsFromPoint(x, y); - return elements.filter((e, i) => elements.indexOf(e) === i); - } - - const e = document.elementFromPoint(x, y); - return e !== null ? [e] : []; -} - -function docRangeFromPoint(x, y, deepDomScan) { - const elements = docElementsFromPoint(x, y, deepDomScan); - let imposter = null; - let imposterContainer = null; - let imposterSourceElement = null; - if (elements.length > 0) { - const element = elements[0]; - switch (element.nodeName.toUpperCase()) { - case 'IMG': - case 'BUTTON': - return new TextSourceElement(element); - case 'INPUT': - imposterSourceElement = element; - [imposter, imposterContainer] = docImposterCreate(element, false); - break; - case 'TEXTAREA': - imposterSourceElement = element; - [imposter, imposterContainer] = docImposterCreate(element, true); - break; - } - } - - const range = caretRangeFromPointExt(x, y, deepDomScan ? elements : []); - if (range !== null) { - if (imposter !== null) { - docSetImposterStyle(imposterContainer.style, 'z-index', '-2147483646'); - docSetImposterStyle(imposter.style, 'pointer-events', 'none'); - } - return new TextSourceRange(range, '', imposterContainer, imposterSourceElement); - } else { - if (imposterContainer !== null) { - imposterContainer.parentNode.removeChild(imposterContainer); - } - return null; - } -} - -function docSentenceExtract(source, extent, layoutAwareScan) { - const quotesFwd = {'「': '」', '『': '』', "'": "'", '"': '"'}; - const quotesBwd = {'」': '「', '』': '『', "'": "'", '"': '"'}; - const terminators = '…。..??!!'; - - const sourceLocal = source.clone(); - const position = sourceLocal.setStartOffset(extent, layoutAwareScan); - sourceLocal.setEndOffset(extent * 2 - position, layoutAwareScan, true); - const content = sourceLocal.text(); - - let quoteStack = []; - - let startPos = 0; - for (let i = position; i >= startPos; --i) { - const c = content[i]; - - if (c === '\n') { - startPos = i + 1; - break; - } - - if (quoteStack.length === 0 && (terminators.includes(c) || c in quotesFwd)) { - startPos = i + 1; - break; - } - - if (quoteStack.length > 0 && c === quoteStack[0]) { - quoteStack.pop(); - } else if (c in quotesBwd) { - quoteStack.unshift(quotesBwd[c]); - } - } - - quoteStack = []; - - let endPos = content.length; - for (let i = position; i <= endPos; ++i) { - const c = content[i]; - - if (c === '\n') { - endPos = i + 1; - break; - } - - if (quoteStack.length === 0) { - if (terminators.includes(c)) { - endPos = i + 1; - break; - } else if (c in quotesBwd) { - endPos = i; - break; - } - } - - if (quoteStack.length > 0 && c === quoteStack[0]) { - quoteStack.pop(); - } else if (c in quotesFwd) { - quoteStack.unshift(quotesFwd[c]); - } - } - - const text = content.substring(startPos, endPos); - const padding = text.length - text.replace(/^\s+/, '').length; - - return { - text: text.trim(), - offset: position - startPos - padding - }; -} - -function isPointInRange(x, y, range) { - // Require a text node to start - if (range.startContainer.nodeType !== Node.TEXT_NODE) { - return false; - } - - // Scan forward - const nodePre = range.endContainer; - const offsetPre = range.endOffset; - try { - const {node, offset, content} = new DOMTextScanner(range.endContainer, range.endOffset, true, false).seek(1); - range.setEnd(node, offset); - - if (!isWhitespace(content) && DOM.isPointInAnyRect(x, y, range.getClientRects())) { - return true; - } - } finally { - range.setEnd(nodePre, offsetPre); - } - - // Scan backward - const {node, offset, content} = new DOMTextScanner(range.startContainer, range.startOffset, true, false).seek(-1); - range.setStart(node, offset); - - if (!isWhitespace(content) && DOM.isPointInAnyRect(x, y, range.getClientRects())) { - // This purposefully leaves the starting offset as modified and sets the range length to 0. - range.setEnd(node, offset); - return true; - } - - // No match - return false; -} - -function isWhitespace(string) { - return string.trim().length === 0; -} - -const caretRangeFromPoint = (() => { - if (typeof document.caretRangeFromPoint === 'function') { - // Chrome, Edge - return (x, y) => document.caretRangeFromPoint(x, y); - } - - if (typeof document.caretPositionFromPoint === 'function') { - // Firefox - return (x, y) => { - const position = document.caretPositionFromPoint(x, y); - if (position === null) { - return null; - } - const node = position.offsetNode; - if (node === null) { - return null; - } - - const range = document.createRange(); - const offset = (node.nodeType === Node.TEXT_NODE ? position.offset : 0); - try { - range.setStart(node, offset); - range.setEnd(node, offset); - } catch (e) { - // Firefox throws new DOMException("The operation is insecure.") - // when trying to select a node from within a ShadowRoot. - return null; - } - return range; - }; - } - - // No support - return () => null; -})(); - -function caretRangeFromPointExt(x, y, elements) { - const modifications = []; - try { - let i = 0; - let startContinerPre = null; - while (true) { - const range = caretRangeFromPoint(x, y); - if (range === null) { - return null; - } - - const startContainer = range.startContainer; - if (startContinerPre !== startContainer) { - if (isPointInRange(x, y, range)) { - return range; - } - startContinerPre = startContainer; - } - - i = disableTransparentElement(elements, i, modifications); - if (i < 0) { - return null; - } - } - } finally { - if (modifications.length > 0) { - restoreElementStyleModifications(modifications); - } - } -} - -function disableTransparentElement(elements, i, modifications) { - while (true) { - if (i >= elements.length) { - return -1; - } - - const element = elements[i++]; - if (isElementTransparent(element)) { - const style = element.hasAttribute('style') ? element.getAttribute('style') : null; - modifications.push({element, style}); - element.style.setProperty('pointer-events', 'none', 'important'); - return i; - } - } -} - -function restoreElementStyleModifications(modifications) { - for (const {element, style} of modifications) { - if (style === null) { - element.removeAttribute('style'); - } else { - element.setAttribute('style', style); - } - } -} - -function isElementTransparent(element) { - if ( - element === document.body || - element === document.documentElement - ) { - return false; - } - const style = window.getComputedStyle(element); - return ( - parseFloat(style.opacity) <= 0 || - style.visibility === 'hidden' || - (style.backgroundImage === 'none' && isColorTransparent(style.backgroundColor)) - ); -} - -function isColorTransparent(cssColor) { - return REGEX_TRANSPARENT_COLOR.test(cssColor); -} diff --git a/ext/fg/js/frontend.js b/ext/fg/js/frontend.js index 1214e74b..ccffbab6 100644 --- a/ext/fg/js/frontend.js +++ b/ext/fg/js/frontend.js @@ -17,12 +17,12 @@ /* global * DOM + * DocumentUtil * FrameOffsetForwarder * PopupProxy * TextScanner * TextSourceElement * api - * docSentenceExtract */ class Frontend { @@ -36,11 +36,13 @@ class Frontend { this._lastShowPromise = Promise.resolve(); this._activeModifiers = new Set(); this._optionsUpdatePending = false; + this._documentUtil = new DocumentUtil(); this._textScanner = new TextScanner({ node: window, ignoreElements: this._ignoreElements.bind(this), ignorePoint: this._ignorePoint.bind(this), - search: this._search.bind(this) + search: this._search.bind(this), + documentUtil: this._documentUtil }); const { @@ -432,7 +434,7 @@ class Frontend { const {url} = optionsContext; const sentenceExtent = this._options.anki.sentenceExt; const layoutAwareScan = this._options.scanning.layoutAwareScan; - const sentence = docSentenceExtract(textSource, sentenceExtent, layoutAwareScan); + const sentence = this._documentUtil.extractSentence(textSource, sentenceExtent, layoutAwareScan); const query = textSource.text(); const details = { focus, diff --git a/ext/manifest.json b/ext/manifest.json index 91516751..b22e75c6 100644 --- a/ext/manifest.json +++ b/ext/manifest.json @@ -46,7 +46,7 @@ "mixed/js/dynamic-loader.js", "mixed/js/frame-client.js", "mixed/js/text-scanner.js", - "fg/js/document.js", + "fg/js/document-util.js", "fg/js/dom-text-scanner.js", "fg/js/popup.js", "fg/js/source.js", diff --git a/ext/mixed/js/display.js b/ext/mixed/js/display.js index 927c2c2c..170b9d23 100644 --- a/ext/mixed/js/display.js +++ b/ext/mixed/js/display.js @@ -20,14 +20,13 @@ * DOM * DisplayGenerator * DisplayHistory + * DocumentUtil * Frontend * MediaLoader * PopupFactory * QueryParser * WindowScroll * api - * docRangeFromPoint - * docSentenceExtract * dynamicLoader */ @@ -74,12 +73,14 @@ class Display extends EventDispatcher { this._defaultTitle = 'Yomichan Search'; this._defaultTitleMaxLength = 1000; this._fullQuery = ''; + this._documentUtil = new DocumentUtil(); this._queryParserVisible = false; this._queryParserVisibleOverride = null; this._queryParserContainer = document.querySelector('#query-parser-container'); this._queryParser = new QueryParser({ getOptionsContext: this.getOptionsContext.bind(this), - setSpinnerVisible: this.setSpinnerVisible.bind(this) + setSpinnerVisible: this.setSpinnerVisible.bind(this), + documentUtil: this._documentUtil }); this._mode = null; @@ -588,7 +589,7 @@ class Display extends EventDispatcher { const scannedElement = e.target; const sentenceExtent = this._options.anki.sentenceExt; const layoutAwareScan = this._options.scanning.layoutAwareScan; - const sentence = docSentenceExtract(textSource, sentenceExtent, layoutAwareScan); + const sentence = this._documentUtil.extractSentence(textSource, sentenceExtent, layoutAwareScan); state.focusEntry = this._entryIndexFind(scannedElement); state.scrollX = this._windowScroll.x; @@ -616,7 +617,7 @@ class Display extends EventDispatcher { e.preventDefault(); const {length: scanLength, deepDomScan: deepScan, layoutAwareScan} = this._options.scanning; - const textSource = docRangeFromPoint(e.clientX, e.clientY, deepScan); + const textSource = this._documentUtil.getRangeFromPoint(e.clientX, e.clientY, deepScan); if (textSource === null) { return false; } diff --git a/ext/mixed/js/text-scanner.js b/ext/mixed/js/text-scanner.js index 61e9256d..923784b3 100644 --- a/ext/mixed/js/text-scanner.js +++ b/ext/mixed/js/text-scanner.js @@ -17,16 +17,16 @@ /* global * DOM - * docRangeFromPoint */ class TextScanner extends EventDispatcher { - constructor({node, ignoreElements, ignorePoint, search}) { + constructor({node, ignoreElements, ignorePoint, search, documentUtil}) { super(); this._node = node; this._ignoreElements = ignoreElements; this._ignorePoint = ignorePoint; this._search = search; + this._documentUtil = documentUtil; this._isPrepared = false; this._ignoreNodes = null; @@ -124,7 +124,7 @@ class TextScanner extends EventDispatcher { return; } - const textSource = docRangeFromPoint(x, y, this._deepContentScan); + const textSource = this._documentUtil.getRangeFromPoint(x, y, this._deepContentScan); try { if (this._textSourceCurrent !== null && this._textSourceCurrent.equals(textSource)) { return; diff --git a/package.json b/package.json index 5704ddb3..4a788980 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "build": "node ./dev/build.js", "test": "npm run test-lint && npm run test-code && npm run test-manifest", "test-lint": "eslint . && node ./test/lint/global-declarations.js", - "test-code": "node ./test/test-schema.js && node ./test/test-dictionary.js && node ./test/test-database.js && node ./test/test-document.js && node ./test/test-object-property-accessor.js && node ./test/test-japanese.js && node ./test/test-text-source-map.js && node ./test/test-dom-text-scanner.js && node ./test/test-cache-map.js", + "test-code": "node ./test/test-schema.js && node ./test/test-dictionary.js && node ./test/test-database.js && node ./test/test-document-util.js && node ./test/test-object-property-accessor.js && node ./test/test-japanese.js && node ./test/test-text-source-map.js && node ./test/test-dom-text-scanner.js && node ./test/test-cache-map.js", "test-manifest": "node ./test/test-manifest.js" }, "repository": { diff --git a/test/test-document-util.js b/test/test-document-util.js new file mode 100644 index 00000000..40b65ed1 --- /dev/null +++ b/test/test-document-util.js @@ -0,0 +1,241 @@ +/* + * 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 . + */ + +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); +const {JSDOM} = require('jsdom'); +const {VM} = require('./yomichan-vm'); + + +// DOMRect class definition +class DOMRect { + constructor(x, y, width, height) { + this._x = x; + this._y = y; + this._width = width; + this._height = height; + } + + get x() { return this._x; } + get y() { return this._y; } + get width() { return this._width; } + get height() { return this._height; } + get left() { return this._x + Math.min(0, this._width); } + get right() { return this._x + Math.max(0, this._width); } + get top() { return this._y + Math.min(0, this._height); } + get bottom() { return this._y + Math.max(0, this._height); } +} + + +function createJSDOM(fileName) { + const domSource = fs.readFileSync(fileName, {encoding: 'utf8'}); + const dom = new JSDOM(domSource); + const document = dom.window.document; + const window = dom.window; + + // Define innerText setter as an alias for textContent setter + Object.defineProperty(window.HTMLDivElement.prototype, 'innerText', { + set(value) { this.textContent = value; } + }); + + // Placeholder for feature detection + document.caretRangeFromPoint = () => null; + + return dom; +} + +function querySelectorChildOrSelf(element, selector) { + return selector ? element.querySelector(selector) : element; +} + +function getChildTextNodeOrSelf(dom, node) { + if (node === null) { return null; } + const Node = dom.window.Node; + const childNode = node.firstChild; + return (childNode !== null && childNode.nodeType === Node.TEXT_NODE ? childNode : node); +} + +function getPrototypeOfOrNull(value) { + try { + return Object.getPrototypeOf(value); + } catch (e) { + return null; + } +} + +function findImposterElement(document) { + // Finds the imposter element based on it's z-index style + return document.querySelector('div[style*="2147483646"]>*'); +} + + +async function testDocument1() { + const dom = createJSDOM(path.join(__dirname, 'data', 'html', 'test-document1.html')); + const window = dom.window; + const document = window.document; + const Node = window.Node; + const Range = window.Range; + + const vm = new VM({document, window, Range, Node}); + vm.execute([ + 'mixed/js/dom.js', + 'fg/js/dom-text-scanner.js', + 'fg/js/source.js', + 'fg/js/document-util.js' + ]); + const [DOMTextScanner, TextSourceRange, TextSourceElement, DocumentUtil] = vm.get([ + 'DOMTextScanner', + 'TextSourceRange', + 'TextSourceElement', + 'DocumentUtil' + ]); + + try { + await testDocumentTextScanningFunctions(dom, {DocumentUtil, TextSourceRange, TextSourceElement}); + await testTextSourceRangeSeekFunctions(dom, {DOMTextScanner}); + } finally { + window.close(); + } +} + +async function testDocumentTextScanningFunctions(dom, {DocumentUtil, TextSourceRange, TextSourceElement}) { + const document = dom.window.document; + + for (const testElement of document.querySelectorAll('.test[data-test-type=scan]')) { + // Get test parameters + let { + elementFromPointSelector, + caretRangeFromPointSelector, + startNodeSelector, + startOffset, + endNodeSelector, + endOffset, + resultType, + sentenceExtent, + sentence, + hasImposter + } = testElement.dataset; + + const elementFromPointValue = querySelectorChildOrSelf(testElement, elementFromPointSelector); + const caretRangeFromPointValue = querySelectorChildOrSelf(testElement, caretRangeFromPointSelector); + const startNode = getChildTextNodeOrSelf(dom, querySelectorChildOrSelf(testElement, startNodeSelector)); + const endNode = getChildTextNodeOrSelf(dom, querySelectorChildOrSelf(testElement, endNodeSelector)); + + startOffset = parseInt(startOffset, 10); + endOffset = parseInt(endOffset, 10); + sentenceExtent = parseInt(sentenceExtent, 10); + + assert.notStrictEqual(elementFromPointValue, null); + assert.notStrictEqual(caretRangeFromPointValue, null); + assert.notStrictEqual(startNode, null); + assert.notStrictEqual(endNode, null); + + // Setup functions + document.elementFromPoint = () => elementFromPointValue; + + document.caretRangeFromPoint = (x, y) => { + const imposter = getChildTextNodeOrSelf(dom, findImposterElement(document)); + assert.strictEqual(!!imposter, hasImposter === 'true'); + + const range = document.createRange(); + range.setStart(imposter ? imposter : startNode, startOffset); + range.setEnd(imposter ? imposter : startNode, endOffset); + + // Override getClientRects to return a rect guaranteed to contain (x, y) + range.getClientRects = () => [new DOMRect(x - 1, y - 1, 2, 2)]; + return range; + }; + + // Test docRangeFromPoint + const documentUtil = new DocumentUtil(); + const source = documentUtil.getRangeFromPoint(0, 0, false); + switch (resultType) { + case 'TextSourceRange': + assert.strictEqual(getPrototypeOfOrNull(source), TextSourceRange.prototype); + break; + case 'TextSourceElement': + assert.strictEqual(getPrototypeOfOrNull(source), TextSourceElement.prototype); + break; + case 'null': + assert.strictEqual(source, null); + break; + default: + assert.ok(false); + break; + } + if (source === null) { continue; } + + // Test docSentenceExtract + const sentenceActual = documentUtil.extractSentence(source, sentenceExtent, false).text; + assert.strictEqual(sentenceActual, sentence); + + // Clean + source.cleanup(); + } +} + +async function testTextSourceRangeSeekFunctions(dom, {DOMTextScanner}) { + const document = dom.window.document; + + for (const testElement of document.querySelectorAll('.test[data-test-type=text-source-range-seek]')) { + // Get test parameters + let { + seekNodeSelector, + seekNodeIsText, + seekOffset, + seekLength, + seekDirection, + expectedResultNodeSelector, + expectedResultNodeIsText, + expectedResultOffset, + expectedResultContent + } = testElement.dataset; + + seekOffset = parseInt(seekOffset, 10); + seekLength = parseInt(seekLength, 10); + expectedResultOffset = parseInt(expectedResultOffset, 10); + + let seekNode = testElement.querySelector(seekNodeSelector); + if (seekNodeIsText === 'true') { + seekNode = seekNode.firstChild; + } + + let expectedResultNode = testElement.querySelector(expectedResultNodeSelector); + if (expectedResultNodeIsText === 'true') { + expectedResultNode = expectedResultNode.firstChild; + } + + const {node, offset, content} = ( + seekDirection === 'forward' ? + new DOMTextScanner(seekNode, seekOffset, true, false).seek(seekLength) : + new DOMTextScanner(seekNode, seekOffset, true, false).seek(-seekLength) + ); + + assert.strictEqual(node, expectedResultNode); + assert.strictEqual(offset, expectedResultOffset); + assert.strictEqual(content, expectedResultContent); + } +} + + +async function main() { + await testDocument1(); +} + + +if (require.main === module) { main(); } diff --git a/test/test-document.js b/test/test-document.js deleted file mode 100644 index ba7acc49..00000000 --- a/test/test-document.js +++ /dev/null @@ -1,241 +0,0 @@ -/* - * 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 . - */ - -const fs = require('fs'); -const path = require('path'); -const assert = require('assert'); -const {JSDOM} = require('jsdom'); -const {VM} = require('./yomichan-vm'); - - -// DOMRect class definition -class DOMRect { - constructor(x, y, width, height) { - this._x = x; - this._y = y; - this._width = width; - this._height = height; - } - - get x() { return this._x; } - get y() { return this._y; } - get width() { return this._width; } - get height() { return this._height; } - get left() { return this._x + Math.min(0, this._width); } - get right() { return this._x + Math.max(0, this._width); } - get top() { return this._y + Math.min(0, this._height); } - get bottom() { return this._y + Math.max(0, this._height); } -} - - -function createJSDOM(fileName) { - const domSource = fs.readFileSync(fileName, {encoding: 'utf8'}); - const dom = new JSDOM(domSource); - const document = dom.window.document; - const window = dom.window; - - // Define innerText setter as an alias for textContent setter - Object.defineProperty(window.HTMLDivElement.prototype, 'innerText', { - set(value) { this.textContent = value; } - }); - - // Placeholder for feature detection - document.caretRangeFromPoint = () => null; - - return dom; -} - -function querySelectorChildOrSelf(element, selector) { - return selector ? element.querySelector(selector) : element; -} - -function getChildTextNodeOrSelf(dom, node) { - if (node === null) { return null; } - const Node = dom.window.Node; - const childNode = node.firstChild; - return (childNode !== null && childNode.nodeType === Node.TEXT_NODE ? childNode : node); -} - -function getPrototypeOfOrNull(value) { - try { - return Object.getPrototypeOf(value); - } catch (e) { - return null; - } -} - -function findImposterElement(document) { - // Finds the imposter element based on it's z-index style - return document.querySelector('div[style*="2147483646"]>*'); -} - - -async function testDocument1() { - const dom = createJSDOM(path.join(__dirname, 'data', 'html', 'test-document1.html')); - const window = dom.window; - const document = window.document; - const Node = window.Node; - const Range = window.Range; - - const vm = new VM({document, window, Range, Node}); - vm.execute([ - 'mixed/js/dom.js', - 'fg/js/dom-text-scanner.js', - 'fg/js/source.js', - 'fg/js/document.js' - ]); - const [DOMTextScanner, TextSourceRange, TextSourceElement, docRangeFromPoint, docSentenceExtract] = vm.get([ - 'DOMTextScanner', - 'TextSourceRange', - 'TextSourceElement', - 'docRangeFromPoint', - 'docSentenceExtract' - ]); - - try { - await testDocumentTextScanningFunctions(dom, {docRangeFromPoint, docSentenceExtract, TextSourceRange, TextSourceElement}); - await testTextSourceRangeSeekFunctions(dom, {DOMTextScanner}); - } finally { - window.close(); - } -} - -async function testDocumentTextScanningFunctions(dom, {docRangeFromPoint, docSentenceExtract, TextSourceRange, TextSourceElement}) { - const document = dom.window.document; - - for (const testElement of document.querySelectorAll('.test[data-test-type=scan]')) { - // Get test parameters - let { - elementFromPointSelector, - caretRangeFromPointSelector, - startNodeSelector, - startOffset, - endNodeSelector, - endOffset, - resultType, - sentenceExtent, - sentence, - hasImposter - } = testElement.dataset; - - const elementFromPointValue = querySelectorChildOrSelf(testElement, elementFromPointSelector); - const caretRangeFromPointValue = querySelectorChildOrSelf(testElement, caretRangeFromPointSelector); - const startNode = getChildTextNodeOrSelf(dom, querySelectorChildOrSelf(testElement, startNodeSelector)); - const endNode = getChildTextNodeOrSelf(dom, querySelectorChildOrSelf(testElement, endNodeSelector)); - - startOffset = parseInt(startOffset, 10); - endOffset = parseInt(endOffset, 10); - sentenceExtent = parseInt(sentenceExtent, 10); - - assert.notStrictEqual(elementFromPointValue, null); - assert.notStrictEqual(caretRangeFromPointValue, null); - assert.notStrictEqual(startNode, null); - assert.notStrictEqual(endNode, null); - - // Setup functions - document.elementFromPoint = () => elementFromPointValue; - - document.caretRangeFromPoint = (x, y) => { - const imposter = getChildTextNodeOrSelf(dom, findImposterElement(document)); - assert.strictEqual(!!imposter, hasImposter === 'true'); - - const range = document.createRange(); - range.setStart(imposter ? imposter : startNode, startOffset); - range.setEnd(imposter ? imposter : startNode, endOffset); - - // Override getClientRects to return a rect guaranteed to contain (x, y) - range.getClientRects = () => [new DOMRect(x - 1, y - 1, 2, 2)]; - return range; - }; - - // Test docRangeFromPoint - const source = docRangeFromPoint(0, 0, false); - switch (resultType) { - case 'TextSourceRange': - assert.strictEqual(getPrototypeOfOrNull(source), TextSourceRange.prototype); - break; - case 'TextSourceElement': - assert.strictEqual(getPrototypeOfOrNull(source), TextSourceElement.prototype); - break; - case 'null': - assert.strictEqual(source, null); - break; - default: - assert.ok(false); - break; - } - if (source === null) { continue; } - - // Test docSentenceExtract - const sentenceActual = docSentenceExtract(source, sentenceExtent, false).text; - assert.strictEqual(sentenceActual, sentence); - - // Clean - source.cleanup(); - } -} - -async function testTextSourceRangeSeekFunctions(dom, {DOMTextScanner}) { - const document = dom.window.document; - - for (const testElement of document.querySelectorAll('.test[data-test-type=text-source-range-seek]')) { - // Get test parameters - let { - seekNodeSelector, - seekNodeIsText, - seekOffset, - seekLength, - seekDirection, - expectedResultNodeSelector, - expectedResultNodeIsText, - expectedResultOffset, - expectedResultContent - } = testElement.dataset; - - seekOffset = parseInt(seekOffset, 10); - seekLength = parseInt(seekLength, 10); - expectedResultOffset = parseInt(expectedResultOffset, 10); - - let seekNode = testElement.querySelector(seekNodeSelector); - if (seekNodeIsText === 'true') { - seekNode = seekNode.firstChild; - } - - let expectedResultNode = testElement.querySelector(expectedResultNodeSelector); - if (expectedResultNodeIsText === 'true') { - expectedResultNode = expectedResultNode.firstChild; - } - - const {node, offset, content} = ( - seekDirection === 'forward' ? - new DOMTextScanner(seekNode, seekOffset, true, false).seek(seekLength) : - new DOMTextScanner(seekNode, seekOffset, true, false).seek(-seekLength) - ); - - assert.strictEqual(node, expectedResultNode); - assert.strictEqual(offset, expectedResultOffset); - assert.strictEqual(content, expectedResultContent); - } -} - - -async function main() { - await testDocument1(); -} - - -if (require.main === module) { main(); } -- cgit v1.2.3