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…
+
+
+
+
+
+
+
+
+ The scan resolution determines where the scan starts when the cursor is moved.
+ The Character option will start scanning at the cursor's current position,
+ while the Word option will start scanning at the beginning of the word.
+