diff --git a/ext/js/accessibility/google-docs-util.js b/ext/js/accessibility/google-docs-util.js index e50ba652dc..12f2db2468 100644 --- a/ext/js/accessibility/google-docs-util.js +++ b/ext/js/accessibility/google-docs-util.js @@ -87,7 +87,7 @@ class GoogleDocsUtil { const range = this._getRangeWithPoint(content, x, y, normalizeCssZoom); this._setImportantStyle(textStyle, 'pointer-events', 'none'); this._setImportantStyle(textStyle, 'opacity', '0'); - return new TextSourceRange(range, '', svgText, element); + return TextSourceRange.createFromImposter(range, svgText, element); } static _getRangeWithPoint(textNode, x, y, normalizeCssZoom) { @@ -110,7 +110,7 @@ class GoogleDocsUtil { } } range.setStart(textNode, start); - range.setEnd(textNode, end); + range.setEnd(textNode, start); return range; } diff --git a/ext/js/app/frontend.js b/ext/js/app/frontend.js index 3a262c652e..229ec940c5 100644 --- a/ext/js/app/frontend.js +++ b/ext/js/app/frontend.js @@ -757,7 +757,7 @@ class Frontend { async _scanSelectedText(allowEmptyRange) { const range = this._getFirstSelectionRange(allowEmptyRange); if (range === null) { return false; } - const source = new TextSourceRange(range, range.toString(), null, null); + const source = TextSourceRange.create(range); await this._textScanner.search(source, {focus: true, restoreSelection: true}); return true; } diff --git a/ext/js/dom/document-util.js b/ext/js/dom/document-util.js index 76f551c754..73525ff57d 100644 --- a/ext/js/dom/document-util.js +++ b/ext/js/dom/document-util.js @@ -66,7 +66,7 @@ class DocumentUtil { case 'IMG': case 'BUTTON': case 'SELECT': - return new TextSourceElement(element); + return TextSourceElement.create(element); case 'INPUT': if (element.type === 'text') { imposterSourceElement = element; @@ -85,8 +85,9 @@ class DocumentUtil { if (imposter !== null) { this._setImposterStyle(imposterContainer.style, 'z-index', '-2147483646'); this._setImposterStyle(imposter.style, 'pointer-events', 'none'); + return TextSourceRange.createFromImposter(range, imposterContainer, imposterSourceElement); } - return new TextSourceRange(range, '', imposterContainer, imposterSourceElement); + return TextSourceRange.create(range); } else { if (imposterContainer !== null) { imposterContainer.parentNode.removeChild(imposterContainer); @@ -131,7 +132,7 @@ class DocumentUtil { // Scan text source = source.clone(); const startLength = source.setStartOffset(extent, layoutAwareScan); - const endLength = source.setEndOffset(extent * 2 - startLength, layoutAwareScan, true); + const endLength = source.setEndOffset(extent * 2 - startLength, true, layoutAwareScan); const text = source.text(); const textLength = text.length; const textEndAnchor = textLength - endLength; @@ -418,6 +419,58 @@ class DocumentUtil { } } + /** + * Offsets an array of DOMRects by a given amount. + * @param {DOMRect[]} rects The DOMRects to offset. + * @param {number} x The horizontal offset amount. + * @param {number} y The vertical offset amount. + * @returns {DOMRect} The DOMRects with the offset applied. + */ + static offsetDOMRects(rects, x, y) { + const results = []; + for (const rect of rects) { + results.push(new DOMRect(rect.left + x, rect.top + y, rect.width, rect.height)); + } + return results; + } + + /** + * Gets the parent writing mode of an element. + * See: https://developer.mozilla.org/en-US/docs/Web/CSS/writing-mode. + * @param {Element} element The HTML element to check. + * @returns {string} The writing mode. + */ + static getElementWritingMode(element) { + if (element !== null) { + const {writingMode} = getComputedStyle(element); + if (typeof writingMode === 'string') { + return this.normalizeWritingMode(writingMode); + } + } + return 'horizontal-tb'; + } + + /** + * Normalizes a CSS writing mode value by converting non-standard and deprecated values + * into their corresponding standard vaules. + * @param {string} writingMode The writing mode to normalize. + * @returns {string} The normalized writing mode. + */ + static normalizeWritingMode(writingMode) { + switch (writingMode) { + case 'lr': + case 'lr-tb': + case 'rl': + return 'horizontal-tb'; + case 'tb': + return 'vertical-lr'; + case 'tb-rl': + return 'vertical-rl'; + default: + return writingMode; + } + } + // Private static _getActiveButtons(event, array) { diff --git a/ext/js/dom/text-source-element.js b/ext/js/dom/text-source-element.js index b5fc168310..13c5cd8669 100644 --- a/ext/js/dom/text-source-element.js +++ b/ext/js/dom/text-source-element.js @@ -21,9 +21,9 @@ */ class TextSourceElement { - constructor(element, fullContent=null, startOffset=0, endOffset=0) { + constructor(element, fullContent, startOffset, endOffset) { this._element = element; - this._fullContent = (typeof fullContent === 'string' ? fullContent : TextSourceElement.getElementContent(element)); + this._fullContent = fullContent; this._startOffset = startOffset; this._endOffset = endOffset; this._content = this._fullContent.substring(this._startOffset, this._endOffset); @@ -49,10 +49,6 @@ class TextSourceElement { return this._endOffset; } - get isConnected() { - return this._element.isConnected; - } - clone() { return new TextSourceElement(this._element, this._fullContent, this._startOffset, this._endOffset); } @@ -65,7 +61,7 @@ class TextSourceElement { return this._content; } - setEndOffset(length, _layoutAwareScan, fromEnd) { + setEndOffset(length, fromEnd) { const offset = fromEnd ? this._endOffset : this._startOffset; length = Math.min(this._fullContent.length - offset, length); if (length > 0) { @@ -86,19 +82,6 @@ class TextSourceElement { return length; } - collapse(toStart) { - if (toStart) { - this._endOffset = this._startOffset; - } else { - this._startOffset = this._endOffset; - } - this._content = ''; - } - - getRect() { - return DocumentUtil.convertRectZoomCoordinates(this._element.getBoundingClientRect(), this._element); - } - getRects() { return DocumentUtil.convertMultipleRectZoomCoordinates(this._element.getClientRects(), this._element); } @@ -130,7 +113,11 @@ class TextSourceElement { return [this._element]; } - static getElementContent(element) { + static create(element) { + return new TextSourceElement(element, this._getElementContent(element), 0, 0); + } + + static _getElementContent(element) { let content; switch (element.nodeName.toUpperCase()) { case 'BUTTON': diff --git a/ext/js/dom/text-source-range.js b/ext/js/dom/text-source-range.js index 6c35c4cb30..e03783a529 100644 --- a/ext/js/dom/text-source-range.js +++ b/ext/js/dom/text-source-range.js @@ -21,12 +21,14 @@ */ class TextSourceRange { - constructor(range, content, imposterContainer, imposterSourceElement) { + constructor(range, rangeStartOffset, content, imposterElement, imposterSourceElement, cachedRects, cachedSourceRect) { this._range = range; - this._rangeStartOffset = range.startOffset; + this._rangeStartOffset = rangeStartOffset; this._content = content; - this._imposterContainer = imposterContainer; + this._imposterElement = imposterElement; this._imposterSourceElement = imposterSourceElement; + this._cachedRects = cachedRects; + this._cachedSourceRect = cachedSourceRect; } get type() { @@ -45,20 +47,21 @@ class TextSourceRange { return this._imposterSourceElement; } - get isConnected() { - return ( - this._range.startContainer.isConnected && - this._range.endContainer.isConnected - ); - } - clone() { - return new TextSourceRange(this._range.cloneRange(), this._content, this._imposterContainer, this._imposterSourceElement); + return new TextSourceRange( + this._range.cloneRange(), + this._rangeStartOffset, + this._content, + this._imposterElement, + this._imposterSourceElement, + this._cachedRects, + this._cachedSourceRect + ); } cleanup() { - if (this._imposterContainer !== null && this._imposterContainer.parentNode !== null) { - this._imposterContainer.parentNode.removeChild(this._imposterContainer); + if (this._imposterElement !== null && this._imposterElement.parentNode !== null) { + this._imposterElement.parentNode.removeChild(this._imposterElement); } } @@ -66,12 +69,17 @@ class TextSourceRange { return this._content; } - setEndOffset(length, layoutAwareScan, fromEnd) { - const state = ( - fromEnd ? - new DOMTextScanner(this._range.endContainer, this._range.endOffset, !layoutAwareScan, layoutAwareScan).seek(length) : - new DOMTextScanner(this._range.startContainer, this._range.startOffset, !layoutAwareScan, layoutAwareScan).seek(length) - ); + setEndOffset(length, fromEnd, layoutAwareScan) { + let node; + let offset; + if (fromEnd) { + node = this._range.endContainer; + offset = this._range.endOffset; + } else { + node = this._range.startContainer; + offset = this._range.startOffset; + } + const state = new DOMTextScanner(node, offset, !layoutAwareScan, layoutAwareScan).seek(length); this._range.setEnd(state.node, state.offset); this._content = (fromEnd ? this._content + state.content : state.content); return length - state.remainder; @@ -85,30 +93,25 @@ class TextSourceRange { return length - state.remainder; } - collapse(toStart) { - this._range.collapse(toStart); - this._content = ''; - } - - getRect() { - return DocumentUtil.convertRectZoomCoordinates(this._range.getBoundingClientRect(), this._range.startContainer); - } - getRects() { + if (this._isImposterDisconnected()) { return this._getCachedRects(); } return DocumentUtil.convertMultipleRectZoomCoordinates(this._range.getClientRects(), this._range.startContainer); } getWritingMode() { - return TextSourceRange.getElementWritingMode(TextSourceRange.getParentElement(this._range.startContainer)); + const node = this._isImposterDisconnected() ? this._imposterSourceElement : this._range.startContainer; + return DocumentUtil.getElementWritingMode(node !== null ? node.parentElement : null); } select() { + if (this._imposterElement !== null) { return; } const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(this._range); } deselect() { + if (this._imposterElement !== null) { return; } const selection = window.getSelection(); selection.removeAllRanges(); } @@ -143,36 +146,26 @@ class TextSourceRange { return DocumentUtil.getNodesInRange(this._range); } - static getParentElement(node) { - while (node !== null && node.nodeType !== Node.ELEMENT_NODE) { - node = node.parentNode; - } - return node; + static create(range) { + return new TextSourceRange(range, range.startOffset, range.toString(), null, null, null, null); } - static getElementWritingMode(element) { - if (element !== null) { - const style = window.getComputedStyle(element); - const writingMode = style.writingMode; - if (typeof writingMode === 'string') { - return TextSourceRange.normalizeWritingMode(writingMode); - } - } - return 'horizontal-tb'; - } - - static normalizeWritingMode(writingMode) { - switch (writingMode) { - case 'lr': - case 'lr-tb': - case 'rl': - return 'horizontal-tb'; - case 'tb': - return 'vertical-lr'; - case 'tb-rl': - return 'vertical-rl'; - default: - return writingMode; - } + static createFromImposter(range, imposterElement, imposterSourceElement) { + const cachedRects = DocumentUtil.convertMultipleRectZoomCoordinates(range.getClientRects(), range.startContainer); + const cachedSourceRect = DocumentUtil.convertRectZoomCoordinates(imposterSourceElement.getBoundingClientRect(), imposterSourceElement); + return new TextSourceRange(range, range.startOffset, range.toString(), imposterElement, imposterSourceElement, cachedRects, cachedSourceRect); + } + + _isImposterDisconnected() { + return this._imposterElement !== null && !this._imposterElement.isConnected; + } + + _getCachedRects() { + const sourceRect = DocumentUtil.convertRectZoomCoordinates(this._imposterSourceElement.getBoundingClientRect(), this._imposterSourceElement); + return DocumentUtil.offsetDOMRects( + this._cachedRects, + sourceRect.left - this._cachedSourceRect.left, + sourceRect.top - this._cachedSourceRect.top + ); } } diff --git a/ext/js/language/text-scanner.js b/ext/js/language/text-scanner.js index a5b35a264c..826c8c5b11 100644 --- a/ext/js/language/text-scanner.js +++ b/ext/js/language/text-scanner.js @@ -229,7 +229,7 @@ class TextScanner extends EventDispatcher { getTextSourceContent(textSource, length, layoutAwareScan) { const clonedTextSource = textSource.clone(); - clonedTextSource.setEndOffset(length, layoutAwareScan, false); + clonedTextSource.setEndOffset(length, false, layoutAwareScan); const includeSelector = this._includeSelector; const excludeSelector = this._excludeSelector; @@ -875,7 +875,7 @@ class TextScanner extends EventDispatcher { const {dictionaryEntries, originalTextLength} = await yomichan.api.termsFind(searchText, details, optionsContext); if (dictionaryEntries.length === 0) { return null; } - textSource.setEndOffset(originalTextLength, layoutAwareScan, false); + textSource.setEndOffset(originalTextLength, false, layoutAwareScan); const sentence = DocumentUtil.extractSentence( textSource, layoutAwareScan, @@ -902,7 +902,7 @@ class TextScanner extends EventDispatcher { const dictionaryEntries = await yomichan.api.kanjiFind(searchText, optionsContext); if (dictionaryEntries.length === 0) { return null; } - textSource.setEndOffset(1, layoutAwareScan, false); + textSource.setEndOffset(1, false, layoutAwareScan); const sentence = DocumentUtil.extractSentence( textSource, layoutAwareScan, @@ -1127,7 +1127,7 @@ class TextScanner extends EventDispatcher { (excludeSelector !== null && DocumentUtil.anyNodeMatchesSelector(nodes, excludeSelector)) ) { --length; - textSource.setEndOffset(length, layoutAwareScan, false); + textSource.setEndOffset(length, false, layoutAwareScan); } else { break; } diff --git a/ext/js/pages/settings/popup-preview-frame.js b/ext/js/pages/settings/popup-preview-frame.js index 68ec7de066..86d8633a42 100644 --- a/ext/js/pages/settings/popup-preview-frame.js +++ b/ext/js/pages/settings/popup-preview-frame.js @@ -212,7 +212,7 @@ class PopupPreviewFrame { const range = document.createRange(); range.selectNodeContents(textNode); - const source = new TextSourceRange(range, range.toString(), null, null); + const source = TextSourceRange.create(range); try { await this._frontend.setTextSource(source);