diff --git a/ext/data/schemas/options-schema.json b/ext/data/schemas/options-schema.json index 5bd8c5ca15..561ca60062 100644 --- a/ext/data/schemas/options-schema.json +++ b/ext/data/schemas/options-schema.json @@ -473,7 +473,8 @@ "hidePopupOnCursorExit", "hidePopupOnCursorExitDelay", "normalizeCssZoom", - "scanWithoutMousemove" + "scanWithoutMousemove", + "scanResolution" ], "properties": { "inputs": { @@ -764,6 +765,14 @@ "scanWithoutMousemove": { "type": "boolean", "default": true + }, + "scanResolution": { + "type": "string", + "enum": [ + "character", + "word" + ], + "default": "character" } } }, diff --git a/ext/js/app/frontend.js b/ext/js/app/frontend.js index 018f449564..858f690d5f 100644 --- a/ext/js/app/frontend.js +++ b/ext/js/app/frontend.js @@ -515,6 +515,7 @@ export class Frontend { sentenceParsingOptions, scanAltText: scanningOptions.scanAltText, scanWithoutMousemove: scanningOptions.scanWithoutMousemove, + scanResolution: scanningOptions.scanResolution, }); this._updateTextScannerEnabled(); diff --git a/ext/js/data/options-util.js b/ext/js/data/options-util.js index e4757deff4..9ee0e16c21 100644 --- a/ext/js/data/options-util.js +++ b/ext/js/data/options-util.js @@ -562,6 +562,7 @@ export class OptionsUtil { this._updateVersion48, this._updateVersion49, this._updateVersion50, + this._updateVersion51, ]; /* eslint-enable @typescript-eslint/unbound-method */ if (typeof targetVersion === 'number' && targetVersion < result.length) { @@ -1476,6 +1477,16 @@ export class OptionsUtil { } } + /** + * - Add scanning.scanResolution + * @type {import('options-util').UpdateFunction} + */ + async _updateVersion51(options) { + for (const profile of options.profiles) { + profile.options.scanning.scanResolution = 'character'; + } + } + /** * @param {string} url * @returns {Promise} diff --git a/ext/js/display/display.js b/ext/js/display/display.js index 85c763653f..76e1f4fa18 100644 --- a/ext/js/display/display.js +++ b/ext/js/display/display.js @@ -466,6 +466,7 @@ export class Display extends EventDispatcher { sentenceParsingOptions, scanAltText: scanningOptions.scanAltText, scanWithoutMousemove: scanningOptions.scanWithoutMousemove, + scanResolution: scanningOptions.scanResolution, }, }); diff --git a/ext/js/dom/dom-text-scanner.js b/ext/js/dom/dom-text-scanner.js index 5325b89455..8171a37140 100644 --- a/ext/js/dom/dom-text-scanner.js +++ b/ext/js/dom/dom-text-scanner.js @@ -22,6 +22,14 @@ import {readCodePointsBackward, readCodePointsForward} from '../data/string-util * A class used to scan text in a document. */ export class DOMTextScanner { + /** + * A regular expression used to match word delimiters. + * \p{L} matches any kind of letter from any language + * \p{N} matches any kind of numeric character in any script + * @type {RegExp} + */ + static WORD_DELIMITER_REGEX = /[^\w\p{L}\p{N}]/u; + /** * Creates a new instance of a DOMTextScanner. * @param {Node} node The DOM Node to start at. @@ -30,8 +38,9 @@ export class DOMTextScanner { * @param {boolean} forcePreserveWhitespace Whether or not whitespace should be forced to be preserved, * regardless of CSS styling. * @param {boolean} generateLayoutContent Whether or not newlines should be added based on CSS styling. + * @param {boolean} stopAtWordBoundary Whether to pause scanning when whitespace is encountered when scanning backwards. */ - constructor(node, offset, forcePreserveWhitespace = false, generateLayoutContent = true) { + constructor(node, offset, forcePreserveWhitespace = false, generateLayoutContent = true, stopAtWordBoundary = false) { const ruby = DOMTextScanner.getParentRubyElement(node); const resetOffset = (ruby !== null); if (resetOffset) { node = ruby; } @@ -54,10 +63,17 @@ export class DOMTextScanner { this._lineHasWhitespace = false; /** @type {boolean} */ this._lineHasContent = false; - /** @type {boolean} */ + /** + * @type {boolean} Whether or not whitespace should be forced to be preserved, + * regardless of CSS styling. + */ this._forcePreserveWhitespace = forcePreserveWhitespace; /** @type {boolean} */ this._generateLayoutContent = generateLayoutContent; + /** + * @type {boolean} Whether or not to stop scanning when word boundaries are encountered. + */ + this._stopAtWordBoundary = stopAtWordBoundary; } /** @@ -130,6 +146,10 @@ export class DOMTextScanner { break; } } else if (nodeType === ELEMENT_NODE) { + if (this._stopAtWordBoundary && !forward) { + // Element nodes are considered word boundaries when scanning backwards + break; + } lastNode = node; const initialNodeAtBeginningOfNodeGoingBackwards = node === this._initialNode && this._offset === 0 && !forward; const initialNodeAtEndOfNodeGoingForwards = node === this._initialNode && this._offset === node.childNodes.length && forward; @@ -145,7 +165,7 @@ export class DOMTextScanner { /** @type {Node[]} */ const exitedNodes = []; - node = DOMTextScanner.getNextNode(node, forward, enterable, exitedNodes); + node = DOMTextScanner.getNextNodeToProcess(node, forward, enterable, exitedNodes); for (const exitedNode of exitedNodes) { if (exitedNode.nodeType !== ELEMENT_NODE) { continue; } @@ -206,9 +226,19 @@ export class DOMTextScanner { const nodeValueLength = nodeValue.length; const {preserveNewlines, preserveWhitespace} = this._getWhitespaceSettings(textNode); if (resetOffset) { this._offset = nodeValueLength; } - while (this._offset > 0) { const char = readCodePointsBackward(nodeValue, this._offset - 1, 1); + if (this._stopAtWordBoundary && DOMTextScanner.isWordDelimiter(char)) { + if (DOMTextScanner.isSingleQuote(char) && this._offset > 1) { + // Check to see if char before single quote is a word character (e.g. "don't") + const prevChar = readCodePointsBackward(nodeValue, this._offset - 2, 1); + if (DOMTextScanner.isWordDelimiter(prevChar)) { + return false; + } + } else { + return false; + } + } this._offset -= char.length; const charAttributes = DOMTextScanner.getCharacterAttributes(char, preserveNewlines, preserveWhitespace); if (this._checkCharacterBackward(char, charAttributes)) { break; } @@ -244,7 +274,7 @@ export class DOMTextScanner { /** * @param {string} char * @param {import('dom-text-scanner').CharacterAttributes} charAttributes - * @returns {boolean} + * @returns {boolean} Whether or not to stop scanning. */ _checkCharacterForward(char, charAttributes) { switch (charAttributes) { @@ -300,7 +330,7 @@ export class DOMTextScanner { /** * @param {string} char * @param {import('dom-text-scanner').CharacterAttributes} charAttributes - * @returns {boolean} + * @returns {boolean} Whether or not to stop scanning. */ _checkCharacterBackward(char, charAttributes) { switch (charAttributes) { @@ -356,14 +386,14 @@ export class DOMTextScanner { // Static helpers /** - * Gets the next node in the document for a specified scanning direction. + * Gets the next node to process in the document for a specified scanning direction. * @param {Node} node The current DOM Node. * @param {boolean} forward Whether to scan forward in the document or backward. * @param {boolean} visitChildren Whether the children of the current node should be visited. * @param {Node[]} exitedNodes An array which stores nodes which were exited. * @returns {?Node} The next node in the document, or `null` if there is no next node. */ - static getNextNode(node, forward, visitChildren, exitedNodes) { + static getNextNodeToProcess(node, forward, visitChildren, exitedNodes) { /** @type {?Node} */ let next = visitChildren ? (forward ? node.firstChild : node.lastChild) : null; if (next === null) { @@ -488,6 +518,31 @@ export class DOMTextScanner { } } + /** + * @param {string} character + * @returns {boolean} + */ + static isWordDelimiter(character) { + return DOMTextScanner.WORD_DELIMITER_REGEX.test(character); + } + + /** + * @param {string} character + * @returns {boolean} + */ + static isSingleQuote(character) { + switch (character.charCodeAt(0)) { + case 0x27: // Single quote ('') + case 0x2019: // Right single quote (’) + case 0x2032: // Prime (′) + case 0x2035: // Reversed prime (‵) + case 0x02bc: // Modifier letter apostrophe (ʼ) + return true; + default: + return false; + } + } + /** * Checks whether a given style is visible or not. * This function does not check `style.display === 'none'`. diff --git a/ext/js/dom/text-source-generator.js b/ext/js/dom/text-source-generator.js index 2413b0bc3b..ca14204921 100644 --- a/ext/js/dom/text-source-generator.js +++ b/ext/js/dom/text-source-generator.js @@ -531,7 +531,7 @@ export class TextSourceGenerator { let previousStyles = null; try { let i = 0; - let startContinerPre = null; + let startContainerPre = null; while (true) { const range = this._caretRangeFromPoint(x, y); if (range === null) { @@ -539,11 +539,11 @@ export class TextSourceGenerator { } const startContainer = range.startContainer; - if (startContinerPre !== startContainer) { + if (startContainerPre !== startContainer) { if (this._isPointInRange(x, y, range, normalizeCssZoom, language)) { return range; } - startContinerPre = startContainer; + startContainerPre = startContainer; } if (previousStyles === null) { previousStyles = new Map(); } diff --git a/ext/js/dom/text-source-range.js b/ext/js/dom/text-source-range.js index 2d906842ec..4450d32845 100644 --- a/ext/js/dom/text-source-range.js +++ b/ext/js/dom/text-source-range.js @@ -157,15 +157,16 @@ export class TextSourceRange { /** - * Moves the start offset of the text by a set amount of unicode codepoints. + * Moves the start offset of the text backwards by a set amount of unicode codepoints. * @param {number} length The maximum number of codepoints to move by. * @param {boolean} layoutAwareScan Whether or not HTML layout information should be used to generate * the string content when scanning. + * @param {boolean} stopAtWordBoundary Whether to stop at whitespace characters. * @returns {number} The actual number of codepoints that were read. */ - setStartOffset(length, layoutAwareScan) { + setStartOffset(length, layoutAwareScan, stopAtWordBoundary = false) { if (this._disallowExpandSelection) { return 0; } - const state = new DOMTextScanner(this._range.startContainer, this._range.startOffset, !layoutAwareScan, layoutAwareScan).seek(-length); + const state = new DOMTextScanner(this._range.startContainer, this._range.startOffset, !layoutAwareScan, layoutAwareScan, stopAtWordBoundary).seek(-length); this._range.setStart(state.node, state.offset); this._rangeStartOffset = this._range.startOffset; this._content = state.content + this._content; diff --git a/ext/js/language/text-scanner.js b/ext/js/language/text-scanner.js index 2665664a51..a072342737 100644 --- a/ext/js/language/text-scanner.js +++ b/ext/js/language/text-scanner.js @@ -259,6 +259,7 @@ export class TextScanner extends EventDispatcher { matchTypePrefix, scanAltText, scanWithoutMousemove, + scanResolution, }) { if (Array.isArray(inputs)) { this._inputs = inputs.map((input) => this._convertInput(input)); @@ -299,6 +300,9 @@ export class TextScanner extends EventDispatcher { if (typeof scanWithoutMousemove === 'boolean') { this._scanWithoutMousemove = scanWithoutMousemove; } + if (typeof scanResolution === 'string') { + this._scanResolution = scanResolution; + } if (typeof sentenceParsingOptions === 'object' && sentenceParsingOptions !== null) { const {scanExtent, terminationCharacterMode, terminationCharacters} = sentenceParsingOptions; if (typeof scanExtent === 'number') { @@ -465,6 +469,11 @@ export class TextScanner extends EventDispatcher { null ); + if (this._scanResolution === 'word') { + // Move the start offset to the beginning of the word + textSource.setStartOffset(this._scanLength, this._layoutAwareScan, true); + } + if (this._textSourceCurrent !== null && this._textSourceCurrent.hasSameStart(textSource)) { return; } diff --git a/ext/settings.html b/ext/settings.html index 1774faad17..4ae0e9f31a 100644 --- a/ext/settings.html +++ b/ext/settings.html @@ -370,6 +370,33 @@

Yomitan Settings

+
+
+
+
Scan resolution
+
+ Start the lookup scan at the word or character of the cursor position. + More… +
+
+
+ +
+
+ +
Scan using middle mouse button
diff --git a/test/options-util.test.js b/test/options-util.test.js index 3614c7c56c..0eca28cea0 100644 --- a/test/options-util.test.js +++ b/test/options-util.test.js @@ -356,6 +356,7 @@ function createProfileOptionsUpdatedTestData1() { onSearchQuery: false, }, scanWithoutMousemove: true, + scanResolution: 'character', inputs: [ { include: 'shift', @@ -644,7 +645,7 @@ function createOptionsUpdatedTestData1() { }, ], profileCurrent: 0, - version: 50, + version: 51, global: { database: { prefixWildcardsSupported: false, diff --git a/types/ext/dom-text-scanner.d.ts b/types/ext/dom-text-scanner.d.ts index 24a49974bf..6ff552d0f7 100644 --- a/types/ext/dom-text-scanner.d.ts +++ b/types/ext/dom-text-scanner.d.ts @@ -33,7 +33,15 @@ export type CharacterAttributes = 0 | 1 | 2 | 3; * - 2 newlines corresponds to a significant visual distinction since the previous content. */ export type ElementSeekInfo = { + /** + * Indicates whether the content of this node should be entered. + */ enterable: boolean; + /** + * 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. + */ newlines: number; }; @@ -43,6 +51,12 @@ export type ElementSeekInfo = { * `preserveWhitespace` indicates whether or not sequences of whitespace characters are collapsed. */ export type WhitespaceSettings = { + /** + * Indicates whether or not newline characters are treated as line breaks. + */ preserveNewlines: boolean; + /** + * Indicates whether or not sequences of whitespace characters are collapsed.¬ + */ preserveWhitespace: boolean; }; diff --git a/types/ext/settings.d.ts b/types/ext/settings.d.ts index ed9dd089af..c29c47f0e4 100644 --- a/types/ext/settings.d.ts +++ b/types/ext/settings.d.ts @@ -196,6 +196,7 @@ export type ScanningOptions = { normalizeCssZoom: boolean; scanAltText: boolean; scanWithoutMousemove: boolean; + scanResolution: string; }; export type ScanningInput = { diff --git a/types/ext/text-scanner.d.ts b/types/ext/text-scanner.d.ts index 801bdd6c76..bc660d6e31 100644 --- a/types/ext/text-scanner.d.ts +++ b/types/ext/text-scanner.d.ts @@ -43,6 +43,7 @@ export type Options = { sentenceParsingOptions?: SentenceParsingOptions; scanAltText?: boolean; scanWithoutMousemove?: boolean; + scanResolution?: string; }; export type InputOptionsOuter = {