initial commit

This commit is contained in:
2026-02-09 19:04:19 -08:00
commit f92b57c7b6
531 changed files with 196294 additions and 0 deletions

View File

@@ -0,0 +1,203 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2021-2022 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/>.
*/
import {readResponseJson} from '../core/json.js';
import {log} from '../core/log.js';
import {toError} from '../core/to-error.js';
/**
* This class is used to apply CSS styles to elements using a consistent method
* that is the same across different browsers.
*/
export class CssStyleApplier {
/**
* Creates a new instance of the class.
* @param {string} styleDataUrl The local URL to the JSON file continaing the style rules.
* The style rules should follow the format of `CssStyleApplierRawStyleData`.
*/
constructor(styleDataUrl) {
/** @type {string} */
this._styleDataUrl = styleDataUrl;
/** @type {import('css-style-applier').CssRule[]} */
this._styleData = [];
/** @type {Map<string, import('css-style-applier').CssRule[]>} */
this._cachedRules = new Map();
/** @type {RegExp} */
this._patternHtmlWhitespace = /[\t\r\n\f ]+/g;
/** @type {RegExp} */
this._patternClassNameCharacter = /[0-9a-zA-Z-_]/;
}
/**
* Loads the data file for use.
*/
async prepare() {
/** @type {import('css-style-applier').RawStyleData} */
let rawData = [];
try {
rawData = await this._fetchJsonAsset(this._styleDataUrl);
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
}
const styleData = this._styleData;
styleData.length = 0;
for (const {selectors, styles} of rawData) {
const selectors2 = selectors.join(',');
const styles2 = [];
for (const [property, value] of styles) {
styles2.push({property, value});
}
styleData.push({
selectors: selectors2,
styles: styles2,
});
}
}
/**
* Applies CSS styles directly to the "style" attribute using the "class" attribute.
* This only works for elements with a single class.
* @param {Iterable<HTMLElement>} elements An iterable collection of HTMLElement objects.
*/
applyClassStyles(elements) {
const elementStyles = [];
for (const element of elements) {
const className = element.getAttribute('class');
if (className === null || className.length === 0) { continue; }
let cssTextNew = '';
for (const {selectors, styles} of this._getCandidateCssRulesForClass(className)) {
try { // `css-select` used by `linkedom` in the Yomitan API does not support some pseudo elements and may error
if (!element.matches(selectors)) { continue; }
} catch (e) {
log.log('Failed to match css selectors: ' + selectors + '\n' + toError(e).message);
continue;
}
cssTextNew += this._getCssText(styles);
}
cssTextNew += element.style.cssText;
elementStyles.push({element, style: cssTextNew});
}
for (const {element, style} of elementStyles) {
element.removeAttribute('class');
if (style.length > 0) {
element.setAttribute('style', style);
} else {
element.removeAttribute('style');
}
}
}
// Private
/**
* Fetches and parses a JSON file.
* @template [T=unknown]
* @param {string} url The URL to the file.
* @returns {Promise<T>} A JSON object.
* @throws {Error} An error is thrown if the fetch fails.
*/
async _fetchJsonAsset(url) {
const response = await fetch(url, {
method: 'GET',
mode: 'no-cors',
cache: 'default',
credentials: 'omit',
redirect: 'follow',
referrerPolicy: 'no-referrer',
});
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status}`);
}
return await readResponseJson(response);
}
/**
* Gets an array of candidate CSS rules which might match a specific class.
* @param {string} className A whitespace-separated list of classes.
* @returns {import('css-style-applier').CssRule[]} An array of candidate CSS rules.
*/
_getCandidateCssRulesForClass(className) {
let rules = this._cachedRules.get(className);
if (typeof rules !== 'undefined') { return rules; }
rules = [];
this._cachedRules.set(className, rules);
const classList = this._getTokens(className);
for (const {selectors, styles} of this._styleData) {
if (!this._selectorMightMatch(selectors, classList)) { continue; }
rules.push({selectors, styles});
}
return rules;
}
/**
* Converts an array of CSS rules to a CSS string.
* @param {import('css-style-applier').CssDeclaration[]} styles An array of CSS rules.
* @returns {string} The CSS string.
*/
_getCssText(styles) {
let cssText = '';
for (const {property, value} of styles) {
cssText += `${property}:${value};`;
}
return cssText;
}
/**
* Checks whether or not a CSS string might match at least one class in a list.
* @param {string} selectors A CSS selector string.
* @param {string[]} classList An array of CSS classes.
* @returns {boolean} `true` if the selector string might match one of the classes in `classList`, false otherwise.
*/
_selectorMightMatch(selectors, classList) {
const pattern = this._patternClassNameCharacter;
for (const item of classList) {
const prefixedItem = `.${item}`;
let start = 0;
while (true) {
const index = selectors.indexOf(prefixedItem, start);
if (index < 0) { break; }
start = index + prefixedItem.length;
if (start >= selectors.length || !pattern.test(selectors[start])) { return true; }
}
}
return false;
}
/**
* Gets the whitespace-delimited tokens from a string.
* @param {string} tokenListString The string to parse.
* @returns {string[]} An array of tokens.
*/
_getTokens(tokenListString) {
let start = 0;
const pattern = this._patternHtmlWhitespace;
pattern.lastIndex = 0;
const result = [];
while (true) {
const match = pattern.exec(tokenListString);
const end = match === null ? tokenListString.length : match.index;
if (end > start) { result.push(tokenListString.substring(start, end)); }
if (match === null) { return result; }
start = end + match[0].length;
}
}
}

View File

@@ -0,0 +1,151 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 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/>.
*/
/**
* This class is used to control the document focus when a non-body element contains the main scrollbar.
* Web browsers will not automatically focus a custom element with the scrollbar on load, which results in
* keyboard shortcuts (e.g. arrow keys) not controlling page scroll. Instead, this class will manually
* focus a dummy element inside the main content, which gives keyboard scroll focus to that element.
*/
export class DocumentFocusController {
/**
* Creates a new instance of the class.
* @param {?string} autofocusElementSelector A selector string which can be used to specify an element which
* should be automatically focused on prepare.
*/
constructor(autofocusElementSelector = null) {
/** @type {?HTMLElement} */
this._autofocusElement = (autofocusElementSelector !== null ? document.querySelector(autofocusElementSelector) : null);
/** @type {?HTMLElement} */
this._contentScrollFocusElement = document.querySelector('#content-scroll-focus');
}
/**
* Initializes the instance.
*/
prepare() {
window.addEventListener('focus', this._onWindowFocus.bind(this), false);
this._updateFocusedElement(false);
}
/**
* Removes focus from a given element.
* @param {HTMLElement} element The element to remove focus from.
*/
blurElement(element) {
if (document.activeElement !== element) { return; }
element.blur();
this._updateFocusedElement(false);
}
/** */
focusElement() {
if (this._autofocusElement !== null && document.activeElement !== this._autofocusElement) {
this._autofocusElement.focus({preventScroll: true});
}
}
// Private
/** */
_onWindowFocus() {
this._updateFocusedElement(false);
}
/**
* @param {boolean} force
*/
_updateFocusedElement(force) {
const target = this._contentScrollFocusElement;
if (target === null) { return; }
const {activeElement} = document;
if (
force ||
activeElement === null ||
activeElement === document.documentElement ||
activeElement === document.body
) {
// Get selection
const selection = window.getSelection();
if (selection === null) { return; }
const selectionRanges1 = this._getSelectionRanges(selection);
// Note: This function will cause any selected text to be deselected on Firefox.
target.focus({preventScroll: true});
// Restore selection
const selectionRanges2 = this._getSelectionRanges(selection);
if (!this._areRangesSame(selectionRanges1, selectionRanges2)) {
this._setSelectionRanges(selection, selectionRanges1);
}
}
}
/**
* @param {Selection} selection
* @returns {Range[]}
*/
_getSelectionRanges(selection) {
const ranges = [];
for (let i = 0, ii = selection.rangeCount; i < ii; ++i) {
ranges.push(selection.getRangeAt(i));
}
return ranges;
}
/**
* @param {Selection} selection
* @param {Range[]} ranges
*/
_setSelectionRanges(selection, ranges) {
selection.removeAllRanges();
for (const range of ranges) {
selection.addRange(range);
}
}
/**
* @param {Range[]} ranges1
* @param {Range[]} ranges2
* @returns {boolean}
*/
_areRangesSame(ranges1, ranges2) {
const ii = ranges1.length;
if (ii !== ranges2.length) {
return false;
}
for (let i = 0; i < ii; ++i) {
const range1 = ranges1[i];
const range2 = ranges2[i];
try {
if (
range1.compareBoundaryPoints(Range.START_TO_START, range2) !== 0 ||
range1.compareBoundaryPoints(Range.END_TO_END, range2) !== 0
) {
return false;
}
} catch (e) {
return false;
}
}
return true;
}
}

503
vendor/yomitan/js/dom/document-util.js vendored Normal file
View File

@@ -0,0 +1,503 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2016-2022 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/>.
*/
/**
* This variable is stateful, but it is only used to do feature detection,
* and its value should be constant for the lifetime of the extension.
* @type {?boolean}
*/
let cssZoomSupported = null;
/** @type {Set<?string>} */
const FIREFOX_RECT_EXCLUDED_LANGUAGES = new Set(['th']);
/**
* Computes the scaling adjustment that is necessary for client space coordinates based on the
* CSS zoom level.
* @param {?Node} node A node in the document.
* @returns {number} The scaling factor.
*/
export function computeZoomScale(node) {
if (cssZoomSupported === null) {
cssZoomSupported = computeCssZoomSupported();
}
if (!cssZoomSupported) { return 1; }
// documentElement must be excluded because the computer style of its zoom property is inconsistent.
// * If CSS `:root{zoom:X;}` is specified, the computed zoom will always report `X`.
// * If CSS `:root{zoom:X;}` is not specified, the computed zoom report the browser's zoom level.
// Therefor, if CSS root zoom is specified as a value other than 1, the adjusted {x, y} values
// would be incorrect, which is not new behaviour.
let scale = 1;
const {ELEMENT_NODE, DOCUMENT_FRAGMENT_NODE} = Node;
const {documentElement} = document;
for (; node !== null && node !== documentElement; node = node.parentNode) {
const {nodeType} = node;
if (nodeType === DOCUMENT_FRAGMENT_NODE) {
const {host} = /** @type {ShadowRoot} */ (node);
if (typeof host !== 'undefined') {
node = host;
}
continue;
} else if (nodeType !== ELEMENT_NODE) {
continue;
}
const zoomString = getComputedStyle(/** @type {HTMLElement} */ (node)).getPropertyValue('zoom');
if (typeof zoomString !== 'string' || zoomString.length === 0) { continue; }
const zoom = Number.parseFloat(zoomString);
if (!Number.isFinite(zoom) || zoom === 0) { continue; }
scale *= zoom;
}
return scale;
}
/**
* Converts a rect based on the CSS zoom scaling for a given node.
* @param {DOMRect} rect The rect to convert.
* @param {Node} node The node to compute the zoom from.
* @returns {DOMRect} The updated rect, or the same rect if no change is needed.
*/
export function convertRectZoomCoordinates(rect, node) {
const scale = computeZoomScale(node);
return (scale === 1 ? rect : new DOMRect(rect.left * scale, rect.top * scale, rect.width * scale, rect.height * scale));
}
/**
* Converts multiple rects based on the CSS zoom scaling for a given node.
* @param {DOMRect[]|DOMRectList} rects The rects to convert.
* @param {Node} node The node to compute the zoom from.
* @returns {DOMRect[]} The updated rects, or the same rects array if no change is needed.
*/
export function convertMultipleRectZoomCoordinates(rects, node) {
const scale = computeZoomScale(node);
if (scale === 1) { return [...rects]; }
const results = [];
for (const rect of rects) {
results.push(new DOMRect(rect.left * scale, rect.top * scale, rect.width * scale, rect.height * scale));
}
return results;
}
/**
* Checks whether a given point is contained within a rect.
* @param {number} x The horizontal coordinate.
* @param {number} y The vertical coordinate.
* @param {DOMRect} rect The rect to check.
* @returns {boolean} `true` if the point is inside the rect, `false` otherwise.
*/
export function isPointInRect(x, y, rect) {
return (
x >= rect.left && x < rect.right &&
y >= rect.top && y < rect.bottom
);
}
/**
* Checks whether a given point is contained within any rect in a list.
* @param {number} x The horizontal coordinate.
* @param {number} y The vertical coordinate.
* @param {DOMRect[]|DOMRectList} rects The rect to check.
* @param {?string} language
* @returns {boolean} `true` if the point is inside any of the rects, `false` otherwise.
*/
export function isPointInAnyRect(x, y, rects, language) {
// Always return true for Firefox due to inconsistencies with Range.getClientRects() implementation from unclear W3C spec
// https://drafts.csswg.org/cssom-view/#dom-range-getclientrects
// https://bugzilla.mozilla.org/show_bug.cgi?id=816238
// Firefox returns only the first level nodes, Chromium returns every text node
// This only affects specific languages
if (typeof browser !== 'undefined' && FIREFOX_RECT_EXCLUDED_LANGUAGES.has(language)) {
return true;
}
for (const rect of rects) {
if (isPointInRect(x, y, rect)) {
return true;
}
}
return false;
}
/**
* Checks whether a given point is contained within a selection range.
* @param {number} x The horizontal coordinate.
* @param {number} y The vertical coordinate.
* @param {Selection} selection The selection to check.
* @param {string} language
* @returns {boolean} `true` if the point is inside the selection, `false` otherwise.
*/
export function isPointInSelection(x, y, selection, language) {
for (let i = 0; i < selection.rangeCount; ++i) {
const range = selection.getRangeAt(i);
if (isPointInAnyRect(x, y, range.getClientRects(), language)) {
return true;
}
}
return false;
}
/**
* Gets an array of the active modifier keys.
* @param {KeyboardEvent|MouseEvent|TouchEvent} event The event to check.
* @returns {import('input').ModifierKey[]} An array of modifiers.
*/
export function getActiveModifiers(event) {
/** @type {import('input').ModifierKey[]} */
const modifiers = [];
if (event.altKey) { modifiers.push('alt'); }
if (event.ctrlKey) { modifiers.push('ctrl'); }
if (event.metaKey) { modifiers.push('meta'); }
if (event.shiftKey) { modifiers.push('shift'); }
// For KeyboardEvent, when modifiers are pressed on Firefox without any other keys, the keydown event does not always contain the last pressed modifier as event.{modifier}
// This occurs when the focus is in a textarea element, an input element, or when the raw keycode is not a modifier but the virtual keycode is (this often occurs due to OS level keyboard remapping)
// Chrome and Firefox (outside of textareas, inputs, and virtual keycodes) do report the modifier in both the event.{modifier} and the event.code
// We must check if the modifier has already been added to not duplicate it
if (event instanceof KeyboardEvent) {
if ((event.code === 'AltLeft' || event.code === 'AltRight' || event.key === 'Alt') && !modifiers.includes('alt')) { modifiers.push('alt'); }
if ((event.code === 'ControlLeft' || event.code === 'ControlRight' || event.key === 'Control') && !modifiers.includes('ctrl')) { modifiers.push('ctrl'); }
if ((event.code === 'MetaLeft' || event.code === 'MetaRight' || event.key === 'Meta') && !modifiers.includes('meta')) { modifiers.push('meta'); }
if ((event.code === 'ShiftLeft' || event.code === 'ShiftRight' || event.key === 'Shift') && !modifiers.includes('shift')) { modifiers.push('shift'); }
}
return modifiers;
}
/**
* Gets an array of the active modifier keys and buttons.
* @param {KeyboardEvent|MouseEvent|TouchEvent} event The event to check.
* @returns {import('input').Modifier[]} An array of modifiers and buttons.
*/
export function getActiveModifiersAndButtons(event) {
/** @type {import('input').Modifier[]} */
const modifiers = getActiveModifiers(event);
if (event instanceof MouseEvent) {
getActiveButtonsInternal(event, modifiers);
}
return modifiers;
}
/**
* Gets an array of the active buttons.
* @param {MouseEvent} event The event to check.
* @returns {import('input').ModifierMouseButton[]} An array of modifiers and buttons.
*/
export function getActiveButtons(event) {
/** @type {import('input').ModifierMouseButton[]} */
const buttons = [];
getActiveButtonsInternal(event, buttons);
return buttons;
}
/**
* Adds a fullscreen change event listener. This function handles all of the browser-specific variants.
* @param {EventListener} onFullscreenChanged The event callback.
* @param {?import('../core/event-listener-collection.js').EventListenerCollection} eventListenerCollection An optional `EventListenerCollection` to add the registration to.
*/
export function addFullscreenChangeEventListener(onFullscreenChanged, eventListenerCollection = null) {
const target = document;
const options = false;
const fullscreenEventNames = [
'fullscreenchange',
'MSFullscreenChange',
'mozfullscreenchange',
'webkitfullscreenchange',
];
for (const eventName of fullscreenEventNames) {
if (eventListenerCollection === null) {
target.addEventListener(eventName, onFullscreenChanged, options);
} else {
eventListenerCollection.addEventListener(target, eventName, onFullscreenChanged, options);
}
}
}
/**
* Returns the current fullscreen element. This function handles all of the browser-specific variants.
* @returns {?Element} The current fullscreen element, or `null` if the window is not fullscreen.
*/
export function getFullscreenElement() {
return (
document.fullscreenElement ||
// @ts-expect-error - vendor prefix
document.msFullscreenElement ||
// @ts-expect-error - vendor prefix
document.mozFullScreenElement ||
// @ts-expect-error - vendor prefix
document.webkitFullscreenElement ||
null
);
}
/**
* Gets all of the nodes within a `Range`.
* @param {Range} range The range to check.
* @returns {Node[]} The list of nodes.
*/
export function getNodesInRange(range) {
const end = range.endContainer;
const nodes = [];
for (let node = /** @type {?Node} */ (range.startContainer); node !== null; node = getNextNode(node)) {
nodes.push(node);
if (node === end) { break; }
}
return nodes;
}
/**
* Gets the next node after a specified node. This traverses the DOM in its logical order.
* @param {Node} node The node to start at.
* @returns {?Node} The next node, or `null` if there is no next node.
*/
export function getNextNode(node) {
let next = /** @type {?Node} */ (node.firstChild);
if (next === null) {
while (true) {
next = node.nextSibling;
if (next !== null) { break; }
next = node.parentNode;
if (next === null) { break; }
node = next;
}
}
return next;
}
/**
* Checks whether any node in a list of nodes matches a selector.
* @param {Node[]} nodes The list of ndoes to check.
* @param {string} selector The selector to test.
* @returns {boolean} `true` if any element node matches the selector, `false` otherwise.
*/
export function anyNodeMatchesSelector(nodes, selector) {
const ELEMENT_NODE = Node.ELEMENT_NODE;
// This is a rather ugly way of getting the "node" variable to be a nullable
for (let node of /** @type {(?Node)[]} */ (nodes)) {
while (node !== null) {
if (node.nodeType !== ELEMENT_NODE) {
node = node.parentNode;
continue;
}
if (/** @type {HTMLElement} */ (node).matches(selector)) { return true; }
break;
}
}
return false;
}
/**
* Checks whether every node in a list of nodes matches a selector.
* @param {Node[]} nodes The list of ndoes to check.
* @param {string} selector The selector to test.
* @returns {boolean} `true` if every element node matches the selector, `false` otherwise.
*/
export function everyNodeMatchesSelector(nodes, selector) {
const ELEMENT_NODE = Node.ELEMENT_NODE;
// This is a rather ugly way of getting the "node" variable to be a nullable
for (let node of /** @type {(?Node)[]} */ (nodes)) {
while (true) {
if (node === null) { return false; }
if (node.nodeType === ELEMENT_NODE && /** @type {HTMLElement} */ (node).matches(selector)) { break; }
node = node.parentNode;
}
}
return true;
}
/**
* Checks whether the meta key is supported in the browser on the specified operating system.
* @param {string} os The operating system to check.
* @param {string} browser The browser to check.
* @returns {boolean} `true` if supported, `false` otherwise.
*/
export function isMetaKeySupported(os, browser) {
return !(browser === 'firefox' || browser === 'firefox-mobile') || os === 'mac';
}
/**
* Checks whether an element on the page that can accept input is focused.
* @returns {boolean} `true` if an input element is focused, `false` otherwise.
*/
export function isInputElementFocused() {
const element = document.activeElement;
if (element === null) { return false; }
const type = element.nodeName.toUpperCase();
switch (type) {
case 'INPUT':
case 'TEXTAREA':
case 'SELECT':
return true;
default:
return element instanceof HTMLElement && element.isContentEditable;
}
}
/**
* 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.
*/
export function 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 {import('document-util').NormalizedWritingMode} The writing mode.
*/
export function getElementWritingMode(element) {
if (element !== null) {
const {writingMode} = getComputedStyle(element);
if (typeof writingMode === 'string') {
return 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 {import('document-util').NormalizedWritingMode} The normalized writing mode.
*/
export function normalizeWritingMode(writingMode) {
switch (writingMode) {
case 'tb':
return 'vertical-lr';
case 'tb-rl':
return 'vertical-rl';
case 'horizontal-tb':
case 'vertical-rl':
case 'vertical-lr':
case 'sideways-rl':
case 'sideways-lr':
return writingMode;
default: // 'lr', 'lr-tb', 'rl'
return 'horizontal-tb';
}
}
/**
* Converts a value from an element to a number.
* @param {string} valueString A string representation of a number.
* @param {import('document-util').ToNumberConstraints} constraints An object which might contain `min`, `max`, and `step` fields which are used to constrain the value.
* @returns {number} The parsed and constrained number.
*/
export function convertElementValueToNumber(valueString, constraints) {
let value = Number.parseFloat(valueString);
if (!Number.isFinite(value)) { value = 0; }
const min = convertToNumberOrNull(constraints.min);
const max = convertToNumberOrNull(constraints.max);
const step = convertToNumberOrNull(constraints.step);
if (typeof min === 'number') { value = Math.max(value, min); }
if (typeof max === 'number') { value = Math.min(value, max); }
if (typeof step === 'number' && step !== 0) { value = Math.round(value / step) * step; }
return value;
}
/**
* @param {string} value
* @returns {?import('input').Modifier}
*/
export function normalizeModifier(value) {
switch (value) {
case 'alt':
case 'ctrl':
case 'meta':
case 'shift':
case 'mouse0':
case 'mouse1':
case 'mouse2':
case 'mouse3':
case 'mouse4':
case 'mouse5':
return value;
default:
return null;
}
}
/**
* @param {string} value
* @returns {?import('input').ModifierKey}
*/
export function normalizeModifierKey(value) {
switch (value) {
case 'alt':
case 'ctrl':
case 'meta':
case 'shift':
return value;
default:
return null;
}
}
/**
* @param {MouseEvent} event The event to check.
* @param {import('input').ModifierMouseButton[]|import('input').Modifier[]} array
*/
function getActiveButtonsInternal(event, array) {
let {buttons} = event;
if (typeof buttons === 'number' && buttons > 0) {
for (let i = 0; i < 6; ++i) {
const buttonFlag = (1 << i);
if ((buttons & buttonFlag) !== 0) {
array.push(/** @type {import('input').ModifierMouseButton} */ (`mouse${i}`));
buttons &= ~buttonFlag;
if (buttons === 0) { break; }
}
}
}
}
/**
* @param {string|number|undefined} value
* @returns {?number}
*/
function convertToNumberOrNull(value) {
if (typeof value !== 'number') {
if (typeof value !== 'string' || value.length === 0) {
return null;
}
value = Number.parseFloat(value);
}
return !Number.isNaN(value) ? value : null;
}
/**
* Computes whether or not this browser and document supports CSS zoom, which is primarily a legacy Chromium feature.
* @returns {boolean}
*/
function computeCssZoomSupported() {
// 'style' can be undefined in certain contexts, such as when document is an SVG document.
const {style} = document.createElement('div');
return (
typeof style === 'object' &&
style !== null &&
typeof style.zoom === 'string'
);
}

319
vendor/yomitan/js/dom/dom-data-binder.js vendored Normal file
View File

@@ -0,0 +1,319 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 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/>.
*/
import {TaskAccumulator} from '../general/task-accumulator.js';
import {convertElementValueToNumber} from './document-util.js';
import {SelectorObserver} from './selector-observer.js';
/**
* @template [T=unknown]
*/
export class DOMDataBinder {
/**
* @param {string[]} selectors
* @param {import('dom-data-binder').CreateElementMetadataCallback<T>} createElementMetadata
* @param {import('dom-data-binder').CompareElementMetadataCallback<T>} compareElementMetadata
* @param {import('dom-data-binder').GetValuesCallback<T>} getValues
* @param {import('dom-data-binder').SetValuesCallback<T>} setValues
* @param {import('dom-data-binder').OnErrorCallback<T>|null} [onError]
*/
constructor(selectors, createElementMetadata, compareElementMetadata, getValues, setValues, onError = null) {
/** @type {string[]} */
this._selectors = selectors;
/** @type {import('dom-data-binder').CreateElementMetadataCallback<T>} */
this._createElementMetadata = createElementMetadata;
/** @type {import('dom-data-binder').CompareElementMetadataCallback<T>} */
this._compareElementMetadata = compareElementMetadata;
/** @type {import('dom-data-binder').GetValuesCallback<T>} */
this._getValues = getValues;
/** @type {import('dom-data-binder').SetValuesCallback<T>} */
this._setValues = setValues;
/** @type {?import('dom-data-binder').OnErrorCallback<T>} */
this._onError = onError;
/** @type {TaskAccumulator<import('dom-data-binder').ElementObserver<T>, import('dom-data-binder').UpdateTaskValue>} */
this._updateTasks = new TaskAccumulator(this._onBulkUpdate.bind(this));
/** @type {TaskAccumulator<import('dom-data-binder').ElementObserver<T>, import('dom-data-binder').AssignTaskValue>} */
this._assignTasks = new TaskAccumulator(this._onBulkAssign.bind(this));
/** @type {SelectorObserver<import('dom-data-binder').ElementObserver<T>>[]} */
this._selectorObservers = selectors.map((selector) => new SelectorObserver({
selector,
ignoreSelector: null,
onAdded: this._createObserver.bind(this),
onRemoved: this._removeObserver.bind(this),
onChildrenUpdated: this._onObserverChildrenUpdated.bind(this),
isStale: this._isObserverStale.bind(this),
}));
}
/**
* @param {Element} element
*/
observe(element) {
for (const selectorObserver of this._selectorObservers) {
selectorObserver.observe(element, true);
}
}
/** */
disconnect() {
for (const selectorObserver of this._selectorObservers) {
selectorObserver.disconnect();
}
}
/** */
async refresh() {
await this._updateTasks.enqueue(null, {all: true});
}
// Private
/**
* @param {import('dom-data-binder').UpdateTask<T>[]} tasks
*/
async _onBulkUpdate(tasks) {
let all = false;
/** @type {import('dom-data-binder').ApplyTarget<T>[]} */
const targets = [];
for (const [observer, task] of tasks) {
if (observer === null) {
if (task.data.all) {
all = true;
break;
}
} else {
targets.push([observer, task]);
}
}
if (all) {
targets.length = 0;
for (const selectorObserver of this._selectorObservers) {
for (const observer of selectorObserver.datas()) {
targets.push([observer, null]);
}
}
}
const args = targets.map(([observer]) => ({
element: observer.element,
metadata: observer.metadata,
}));
const responses = await this._getValues(args);
this._applyValues(targets, responses, true);
}
/**
* @param {import('dom-data-binder').AssignTask<T>[]} tasks
*/
async _onBulkAssign(tasks) {
/** @type {import('dom-data-binder').ApplyTarget<T>[]} */
const targets = [];
const args = [];
for (const [observer, task] of tasks) {
if (observer === null) { continue; }
args.push({
element: observer.element,
metadata: observer.metadata,
value: task.data.value,
});
targets.push([observer, task]);
}
const responses = await this._setValues(args);
this._applyValues(targets, responses, false);
}
/**
* @param {import('dom-data-binder').ElementObserver<T>} observer
*/
_onElementChange(observer) {
const value = this._getElementValue(observer.element);
observer.value = value;
observer.hasValue = true;
void this._assignTasks.enqueue(observer, {value});
}
/**
* @param {import('dom-data-binder').ApplyTarget<T>[]} targets
* @param {import('dom-data-binder').TaskResult[]} response
* @param {boolean} ignoreStale
*/
_applyValues(targets, response, ignoreStale) {
for (let i = 0, ii = targets.length; i < ii; ++i) {
const [observer, task] = targets[i];
const {error, result} = response[i];
const stale = (task !== null && task.stale);
if (error) {
if (typeof this._onError === 'function') {
this._onError(error, stale, observer.element, observer.metadata);
}
continue;
}
if (stale && !ignoreStale) { continue; }
observer.value = result;
observer.hasValue = true;
this._setElementValue(observer.element, result);
}
}
/**
* @param {Element} element
* @returns {import('dom-data-binder').ElementObserver<T>|undefined}
*/
_createObserver(element) {
const metadata = this._createElementMetadata(element);
if (typeof metadata === 'undefined') { return void 0; }
const type = this._getNormalizedElementType(element);
const eventType = 'change';
/** @type {import('dom-data-binder').ElementObserver<T>} */
const observer = {
element,
type,
value: null,
hasValue: false,
eventType,
onChange: null,
metadata,
};
observer.onChange = this._onElementChange.bind(this, observer);
element.addEventListener(eventType, observer.onChange, false);
void this._updateTasks.enqueue(observer, {all: false});
return observer;
}
/**
* @param {Element} element
* @param {import('dom-data-binder').ElementObserver<T>} observer
*/
_removeObserver(element, observer) {
if (observer.onChange === null) { return; }
element.removeEventListener(observer.eventType, observer.onChange, false);
observer.onChange = null;
}
/**
* @param {Element} element
* @param {import('dom-data-binder').ElementObserver<T>} observer
*/
_onObserverChildrenUpdated(element, observer) {
if (observer.hasValue && this._getNormalizedElementType(element) !== 'element') {
this._setElementValue(element, observer.value);
}
}
/**
* @param {Element} element
* @param {import('dom-data-binder').ElementObserver<T>} observer
* @returns {boolean}
*/
_isObserverStale(element, observer) {
const {type, metadata} = observer;
if (type !== this._getNormalizedElementType(element)) { return false; }
const newMetadata = this._createElementMetadata(element);
return typeof newMetadata === 'undefined' || !this._compareElementMetadata(metadata, newMetadata);
}
/**
* @param {Element} element
* @param {unknown} value
*/
_setElementValue(element, value) {
switch (this._getNormalizedElementType(element)) {
case 'checkbox':
/** @type {HTMLInputElement} */ (element).checked = typeof value === 'boolean' && value;
break;
case 'text':
case 'number':
case 'textarea':
case 'select':
/** @type {HTMLInputElement|HTMLTextAreaElement|HTMLSelectElement} */ (element).value = typeof value === 'string' ? value : `${value}`;
break;
case 'element':
element.textContent = typeof value === 'string' ? value : `${value}`;
break;
}
/** @type {number|string|boolean} */
let safeValue;
switch (typeof value) {
case 'number':
case 'string':
case 'boolean':
safeValue = value;
break;
default:
safeValue = `${value}`;
break;
}
/** @type {import('dom-data-binder').SettingChangedEvent} */
const event = new CustomEvent('settingChanged', {detail: {value: safeValue}});
element.dispatchEvent(event);
}
/**
* @param {Element} element
* @returns {boolean|string|number|null}
*/
_getElementValue(element) {
switch (this._getNormalizedElementType(element)) {
case 'checkbox':
return !!(/** @type {HTMLInputElement} */ (element).checked);
case 'text':
return `${/** @type {HTMLInputElement} */ (element).value}`;
case 'number':
return convertElementValueToNumber(/** @type {HTMLInputElement} */ (element).value, /** @type {HTMLInputElement} */ (element));
case 'textarea':
return /** @type {HTMLTextAreaElement} */ (element).value;
case 'select':
return /** @type {HTMLSelectElement} */ (element).value;
case 'element':
return element.textContent;
}
}
/**
* @param {Element} element
* @returns {import('dom-data-binder').NormalizedElementType}
*/
_getNormalizedElementType(element) {
switch (element.nodeName.toUpperCase()) {
case 'INPUT':
{
const {type} = /** @type {HTMLInputElement} */ (element);
switch (type) {
case 'text':
case 'password':
return 'text';
case 'number':
case 'checkbox':
return type;
}
break;
}
case 'TEXTAREA':
return 'textarea';
case 'SELECT':
return 'select';
}
return 'element';
}
}

View File

@@ -0,0 +1,625 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 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/>.
*/
import {readCodePointsBackward, readCodePointsForward} from '../data/string-util.js';
/**
* 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.
* @param {number} offset The character offset in to start at when node is a text node.
* Use 0 for non-text nodes.
* @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, stopAtWordBoundary = false) {
const ruby = DOMTextScanner.getParentRubyElement(node);
const resetOffset = (ruby !== null);
if (resetOffset) { node = ruby; }
/** @type {Node} */
this._initialNode = node;
/** @type {Node} */
this._node = node;
/** @type {number} */
this._offset = offset;
/** @type {string} */
this._content = '';
/** @type {number} */
this._remainder = 0;
/** @type {boolean} */
this._resetOffset = resetOffset;
/** @type {number} */
this._newlines = 0;
/** @type {boolean} */
this._lineHasWhitespace = false;
/** @type {boolean} */
this._lineHasContent = false;
/**
* @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;
}
/**
* Gets the current node being scanned.
* @type {Node}
*/
get node() {
return this._node;
}
/**
* Gets the current offset corresponding to the node being scanned.
* This value is only applicable for text nodes.
* @type {number}
*/
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.
* @type {number}
*/
get remainder() {
return this._remainder;
}
/**
* Gets the accumulated content string resulting from calls to seek().
* @type {string}
*/
get content() {
return this._content;
}
/**
* Seeks a given length in the document and accumulates the text content.
* @param {number} 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 {DOMTextScanner} 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 = /** @type {?Node} */ (this._node);
let lastNode = /** @type {Node} */ (node);
let resetOffset = this._resetOffset;
let newlines = 0;
seekLoop:
while (node !== null) {
let enterable = false;
const nodeType = node.nodeType;
if (nodeType === TEXT_NODE) {
lastNode = node;
const shouldContinueScanning = forward ?
this._seekTextNodeForward(/** @type {Text} */ (node), resetOffset) :
this._seekTextNodeBackward(/** @type {Text} */ (node), resetOffset);
if (!shouldContinueScanning) {
// Length reached or reached a word boundary
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;
this._offset = 0;
const isInitialNode = node === this._initialNode;
({enterable, newlines} = DOMTextScanner.getElementSeekInfo(/** @type {Element} */ (node)));
if (!isInitialNode && newlines > this._newlines && generateLayoutContent) {
this._newlines = newlines;
}
if (initialNodeAtBeginningOfNodeGoingBackwards || initialNodeAtEndOfNodeGoingForwards) {
enterable = false;
}
}
/** @type {Node[]} */
const exitedNodes = [];
node = DOMTextScanner.getNextNodeToProcess(node, forward, enterable, exitedNodes);
for (const exitedNode of exitedNodes) {
if (exitedNode.nodeType !== ELEMENT_NODE) { continue; }
({newlines} = DOMTextScanner.getElementSeekInfo(/** @type {Element} */ (exitedNode)));
if (newlines > this._newlines && generateLayoutContent) {
this._newlines = newlines;
}
if (newlines > 0 && this._stopAtWordBoundary && !forward) {
// Element nodes are considered word boundaries when scanning backwards
break seekLoop;
}
}
resetOffset = true;
}
this._node = lastNode;
this._resetOffset = resetOffset;
return this;
}
// Private
/**
* Seeks forward in a text node.
* @param {Text} textNode The text node to use.
* @param {boolean} resetOffset Whether or not the text offset should be reset.
* @returns {boolean} `true` if scanning should continue, or `false` if the scan length has been reached.
*/
_seekTextNodeForward(textNode, resetOffset) {
const nodeValue = /** @type {string} */ (textNode.nodeValue);
const nodeValueLength = nodeValue.length;
const {preserveNewlines, preserveWhitespace} = this._getWhitespaceSettings(textNode);
if (resetOffset) { this._offset = 0; }
while (this._offset < nodeValueLength) {
const char = readCodePointsForward(nodeValue, this._offset, 1);
this._offset += char.length;
const charAttributes = DOMTextScanner.getCharacterAttributes(char, preserveNewlines, preserveWhitespace);
if (this._checkCharacterForward(char, charAttributes)) { break; }
}
return this._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 {Text} textNode The text node to use.
* @param {boolean} resetOffset Whether or not the text offset should be reset.
* @returns {boolean} `true` if scanning should continue, or `false` if the scan length has been reached.
*/
_seekTextNodeBackward(textNode, resetOffset) {
const nodeValue = /** @type {string} */ (textNode.nodeValue);
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; }
}
return this._remainder > 0;
}
/**
* Gets information about how whitespace characters are treated.
* @param {Text} textNode
* @returns {import('dom-text-scanner').WhitespaceSettings}
*/
_getWhitespaceSettings(textNode) {
if (this._forcePreserveWhitespace) {
return {preserveNewlines: true, preserveWhitespace: true};
}
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 {preserveNewlines: true, preserveWhitespace: true};
case 'pre-line':
return {preserveNewlines: true, preserveWhitespace: false};
}
}
return {preserveNewlines: false, preserveWhitespace: false};
}
/**
* @param {string} char
* @param {import('dom-text-scanner').CharacterAttributes} charAttributes
* @returns {boolean} Whether or not to stop scanning.
*/
_checkCharacterForward(char, charAttributes) {
switch (charAttributes) {
// case 0: break; // NOP
case 1:
this._lineHasWhitespace = true;
break;
case 2:
case 3:
if (this._newlines > 0) {
const useNewlineCount = Math.min(this._remainder, this._newlines);
this._content += '\n'.repeat(useNewlineCount);
this._remainder -= useNewlineCount;
this._newlines -= useNewlineCount;
this._lineHasContent = false;
this._lineHasWhitespace = false;
if (this._remainder <= 0) {
this._offset -= char.length; // Revert character offset
return true;
}
}
this._lineHasContent = (charAttributes === 2); // 3 = character is a newline
if (this._lineHasWhitespace) {
if (this._lineHasContent) {
this._content += ' ';
this._lineHasWhitespace = false;
if (--this._remainder <= 0) {
this._offset -= char.length; // Revert character offset
return true;
}
} else {
this._lineHasWhitespace = false;
}
}
this._content += char;
if (--this._remainder <= 0) {
return true;
}
break;
}
return false;
}
/**
* @param {string} char
* @param {import('dom-text-scanner').CharacterAttributes} charAttributes
* @returns {boolean} Whether or not to stop scanning.
*/
_checkCharacterBackward(char, charAttributes) {
switch (charAttributes) {
// case 0: break; // NOP
case 1:
this._lineHasWhitespace = true;
break;
case 2:
case 3:
if (this._newlines > 0) {
const useNewlineCount = Math.min(this._remainder, this._newlines);
this._content = '\n'.repeat(useNewlineCount) + this._content;
this._remainder -= useNewlineCount;
this._newlines -= useNewlineCount;
this._lineHasContent = false;
this._lineHasWhitespace = false;
if (this._remainder <= 0) {
this._offset += char.length; // Revert character offset
return true;
}
}
this._lineHasContent = (charAttributes === 2); // 3 = character is a newline
if (this._lineHasWhitespace) {
if (this._lineHasContent) {
this._content = ' ' + this._content;
this._lineHasWhitespace = false;
if (--this._remainder <= 0) {
this._offset += char.length; // Revert character offset
return true;
}
} else {
this._lineHasWhitespace = false;
}
}
this._content = char + this._content;
if (--this._remainder <= 0) {
return true;
}
break;
}
return false;
}
// Static helpers
/**
* 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 getNextNodeToProcess(node, forward, visitChildren, exitedNodes) {
/** @type {?Node} */
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} node The node to check.
* @returns {?Element} The parent element if one exists, otherwise `null`.
*/
static getParentElement(node) {
while (node !== null) {
if (node.nodeType === Node.ELEMENT_NODE) {
return /** @type {Element} */ (node);
}
node = node.parentNode;
}
return null;
}
/**
* 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} node The node to check.
* @returns {?HTMLElement} A <ruby> node if the input node is contained in one, otherwise `null`.
*/
static getParentRubyElement(node) {
/** @type {?Node} */
let node2 = DOMTextScanner.getParentElement(node);
if (node2 !== null && node2.nodeName.toUpperCase() === 'RT') {
node2 = node2.parentNode;
if (node2 !== null && node2.nodeName.toUpperCase() === 'RUBY') {
return /** @type {HTMLElement} */ (node2);
}
}
return null;
}
/**
* @param {Element} element
* @returns {import('dom-text-scanner').ElementSeekInfo}
*/
static getElementSeekInfo(element) {
let enterable = true;
switch (element.nodeName.toUpperCase()) {
case 'HEAD':
case 'RT':
case 'SCRIPT':
case 'STYLE':
return {enterable: false, newlines: 0};
case 'RB':
return {enterable: true, newlines: 0};
case 'BR':
return {enterable: false, newlines: 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 attributes for the specified character.
* @param {string} character A string containing a single character.
* @param {boolean} preserveNewlines Whether or not newlines should be preserved.
* @param {boolean} preserveWhitespace Whether or not whitespace should be preserved.
* @returns {import('dom-text-scanner').CharacterAttributes} An enum representing the attributes of the character.
*/
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 0x200b: // Zero-width space
case 0x200c: // Zero-width non-joiner
case 0x00ad: // Soft hyphen
return 0;
default: // Other
return 2;
}
}
/**
* @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'`.
* @param {CSSStyleDeclaration} style An object implementing the CSSStyleDeclaration interface.
* @returns {boolean} `true` if the style should result in an element being visible, otherwise `false`.
*/
static isStyleVisible(style) {
return !(
style.visibility === 'hidden' ||
Number.parseFloat(style.opacity) <= 0 ||
Number.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 {CSSStyleDeclaration} style An object implementing the CSSStyleDeclaration interface.
* @returns {boolean} `true` if the style is selectable, otherwise `false`.
*/
static isStyleSelectable(style) {
return !(
style.userSelect === 'none' ||
style.webkitUserSelect === 'none' ||
// @ts-expect-error - vendor prefix
style.MozUserSelect === 'none' ||
// @ts-expect-error - vendor prefix
style.msUserSelect === 'none'
);
}
/**
* Checks whether a CSS color is transparent or not.
* @param {string} cssColor A CSS color string, expected to be encoded in rgb(a) form.
* @returns {boolean} `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 {string} cssDisplay A CSS string corresponding to the value of the display property.
* @returns {boolean} `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': // Also includes: list-item
case 'table': // Also includes: table, table-*
return true;
case 'ruby': // Also includes: ruby-*
return (pos >= 0);
default:
return false;
}
}
}

View File

@@ -0,0 +1,123 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 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/>.
*/
import {fetchText} from '../core/fetch-utilities.js';
export class HtmlTemplateCollection {
constructor() {
/** @type {Map<string, HTMLTemplateElement>} */
this._templates = new Map();
}
/**
* @param {string[]} urls
*/
async loadFromFiles(urls) {
const htmlRawArray = await Promise.all(urls.map((url) => fetchText(url)));
const domParser = new DOMParser();
for (const htmlRaw of htmlRawArray) {
const templatesDocument = domParser.parseFromString(htmlRaw, 'text/html');
this.load(templatesDocument);
}
}
/**
* @param {Document} source
*/
load(source) {
const pattern = /^([\w\W]+)-template$/;
for (const template of source.querySelectorAll('template')) {
const match = pattern.exec(template.id);
if (match === null) { continue; }
this._prepareTemplate(template);
this._templates.set(match[1], template);
}
}
/**
* @template {Element} T
* @param {string} name
* @returns {T}
* @throws {Error}
*/
instantiate(name) {
const {firstElementChild} = this.getTemplateContent(name);
if (firstElementChild === null) { throw new Error(`Failed to find template content element: ${name}`); }
return /** @type {T} */ (document.importNode(firstElementChild, true));
}
/**
* @param {string} name
* @returns {DocumentFragment}
*/
instantiateFragment(name) {
return document.importNode(this.getTemplateContent(name), true);
}
/**
* @param {string} name
* @returns {DocumentFragment}
* @throws {Error}
*/
getTemplateContent(name) {
const template = this._templates.get(name);
if (typeof template === 'undefined') { throw new Error(`Failed to find template: ${name}`); }
return template.content;
}
/**
* @returns {IterableIterator<HTMLTemplateElement>}
*/
getAllTemplates() {
return this._templates.values();
}
// Private
/**
* @param {HTMLTemplateElement} template
*/
_prepareTemplate(template) {
if (template.dataset.removeWhitespaceText === 'true') {
this._removeWhitespaceText(template);
}
}
/**
* @param {HTMLTemplateElement} template
*/
_removeWhitespaceText(template) {
const {content} = template;
const {TEXT_NODE} = Node;
const iterator = document.createNodeIterator(content, NodeFilter.SHOW_TEXT);
const removeNodes = [];
while (true) {
const node = iterator.nextNode();
if (node === null) { break; }
if (node.nodeType === TEXT_NODE && /** @type {string} */ (node.nodeValue).trim().length === 0) {
removeNodes.push(node);
}
}
for (const node of removeNodes) {
const {parentNode} = node;
if (parentNode !== null) {
parentNode.removeChild(node);
}
}
}
}

View File

@@ -0,0 +1,105 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 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/>.
*/
export class NativeSimpleDOMParser {
/**
* @param {string} content
*/
constructor(content) {
/** @type {Document} */
this._document = new DOMParser().parseFromString(content, 'text/html');
}
/**
* @param {string} id
* @param {import('simple-dom-parser').Element} [root]
* @returns {?import('simple-dom-parser').Element}
*/
getElementById(id, root) {
return this._convertElementOrDocument(root).querySelector(`[id='${id}']`);
}
/**
* @param {string} tagName
* @param {import('simple-dom-parser').Element} [root]
* @returns {?import('simple-dom-parser').Element}
*/
getElementByTagName(tagName, root) {
return this._convertElementOrDocument(root).querySelector(tagName);
}
/**
* @param {string} tagName
* @param {import('simple-dom-parser').Element} [root]
* @returns {import('simple-dom-parser').Element[]}
*/
getElementsByTagName(tagName, root) {
return [...this._convertElementOrDocument(root).querySelectorAll(tagName)];
}
/**
* @param {string} className
* @param {import('simple-dom-parser').Element} [root]
* @returns {import('simple-dom-parser').Element[]}
*/
getElementsByClassName(className, root) {
return [...this._convertElementOrDocument(root).querySelectorAll(`.${className}`)];
}
/**
* @param {import('simple-dom-parser').Element} element
* @param {string} attribute
* @returns {?string}
*/
getAttribute(element, attribute) {
const element2 = this._convertElement(element);
return element2.hasAttribute(attribute) ? element2.getAttribute(attribute) : null;
}
/**
* @param {import('simple-dom-parser').Element} element
* @returns {string}
*/
getTextContent(element) {
const {textContent} = this._convertElement(element);
return typeof textContent === 'string' ? textContent : '';
}
/**
* @returns {boolean}
*/
static isSupported() {
return typeof DOMParser !== 'undefined';
}
/**
* @param {import('simple-dom-parser').Element} element
* @returns {Element}
*/
_convertElement(element) {
return /** @type {Element} */ (element);
}
/**
* @param {import('simple-dom-parser').Element|undefined} element
* @returns {Element|Document}
*/
_convertElementOrDocument(element) {
return typeof element !== 'undefined' ? /** @type {Element} */ (element) : this._document;
}
}

138
vendor/yomitan/js/dom/panel-element.js vendored Normal file
View File

@@ -0,0 +1,138 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 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/>.
*/
import {EventDispatcher} from '../core/event-dispatcher.js';
/**
* @augments EventDispatcher<import('panel-element').Events>
*/
export class PanelElement extends EventDispatcher {
/**
* @param {HTMLElement} node
* @param {number} closingAnimationDuration
*/
constructor(node, closingAnimationDuration) {
super();
/** @type {HTMLElement} */
this._node = node;
/** @type {number} */
this._closingAnimationDuration = closingAnimationDuration;
/** @type {string} */
this._hiddenAnimatingClass = 'hidden-animating';
/** @type {?MutationObserver} */
this._mutationObserver = null;
/** @type {boolean} */
this._visible = false;
/** @type {?import('core').Timeout} */
this._closeTimer = null;
}
/** @type {HTMLElement} */
get node() {
return this._node;
}
/**
* @returns {boolean}
*/
isVisible() {
return !this._node.hidden;
}
/**
* @param {boolean} value
* @param {boolean} [animate]
*/
setVisible(value, animate = true) {
value = !!value;
if (this.isVisible() === value) { return; }
if (this._closeTimer !== null) {
clearTimeout(this._closeTimer);
this._completeClose(true);
}
const node = this._node;
const {classList} = node;
if (value) {
if (animate) { classList.add(this._hiddenAnimatingClass); }
getComputedStyle(node).getPropertyValue('display'); // Force update of CSS display property, allowing animation
classList.remove(this._hiddenAnimatingClass);
node.hidden = false;
node.focus();
} else {
if (animate) { classList.add(this._hiddenAnimatingClass); }
node.hidden = true;
if (animate) {
this._closeTimer = setTimeout(() => this._completeClose(false), this._closingAnimationDuration);
}
}
}
/**
* @template {import('core').EventNames<import('panel-element').Events>} TName
* @param {TName} eventName
* @param {(details: import('core').EventArgument<import('panel-element').Events, TName>) => void} callback
*/
on(eventName, callback) {
if (eventName === 'visibilityChanged' && this._mutationObserver === null) {
this._visible = this.isVisible();
this._mutationObserver = new MutationObserver(this._onMutation.bind(this));
this._mutationObserver.observe(this._node, {
attributes: true,
attributeFilter: ['hidden'],
attributeOldValue: true,
});
}
super.on(eventName, callback);
}
/**
* @template {import('core').EventNames<import('panel-element').Events>} TName
* @param {TName} eventName
* @param {(details: import('core').EventArgument<import('panel-element').Events, TName>) => void} callback
* @returns {boolean}
*/
off(eventName, callback) {
const result = super.off(eventName, callback);
if (eventName === 'visibilityChanged' && !this.hasListeners(eventName) && this._mutationObserver !== null) {
this._mutationObserver.disconnect();
this._mutationObserver = null;
}
return result;
}
// Private
/** */
_onMutation() {
const visible = this.isVisible();
if (this._visible === visible) { return; }
this._visible = visible;
this.trigger('visibilityChanged', {visible});
}
/**
* @param {boolean} reopening
*/
_completeClose(reopening) {
this._closeTimer = null;
this._node.classList.remove(this._hiddenAnimatingClass);
this.trigger('closeCompleted', {reopening});
}
}

284
vendor/yomitan/js/dom/popup-menu.js vendored Normal file
View File

@@ -0,0 +1,284 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 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/>.
*/
import {EventDispatcher} from '../core/event-dispatcher.js';
import {EventListenerCollection} from '../core/event-listener-collection.js';
import {querySelectorNotNull} from './query-selector.js';
/**
* @augments EventDispatcher<import('popup-menu').Events>
*/
export class PopupMenu extends EventDispatcher {
/**
* @param {HTMLElement} sourceElement
* @param {HTMLElement} containerNode
*/
constructor(sourceElement, containerNode) {
super();
/** @type {HTMLElement} */
this._sourceElement = sourceElement;
/** @type {HTMLElement} */
this._containerNode = containerNode;
/** @type {HTMLElement} */
this._node = querySelectorNotNull(containerNode, '.popup-menu');
/** @type {HTMLElement} */
this._bodyNode = querySelectorNotNull(containerNode, '.popup-menu-body');
/** @type {boolean} */
this._isClosed = false;
/** @type {EventListenerCollection} */
this._eventListeners = new EventListenerCollection();
/** @type {EventListenerCollection} */
this._itemEventListeners = new EventListenerCollection();
}
/** @type {HTMLElement} */
get sourceElement() {
return this._sourceElement;
}
/** @type {HTMLElement} */
get containerNode() {
return this._containerNode;
}
/** @type {HTMLElement} */
get node() {
return this._node;
}
/** @type {HTMLElement} */
get bodyNode() {
return this._bodyNode;
}
/** @type {boolean} */
get isClosed() {
return this._isClosed;
}
/** */
prepare() {
this._setPosition();
this._containerNode.focus();
this._eventListeners.addEventListener(window, 'resize', this._onWindowResize.bind(this), false);
this._eventListeners.addEventListener(this._containerNode, 'click', this._onMenuContainerClick.bind(this), false);
this.updateMenuItems();
PopupMenu.openMenus.add(this);
/** @type {import('popup-menu').MenuOpenEventDetails} */
const detail = {menu: this};
this._sourceElement.dispatchEvent(new CustomEvent('menuOpen', {
bubbles: false,
cancelable: false,
detail,
}));
}
/**
* @param {boolean} [cancelable]
* @returns {boolean}
*/
close(cancelable = true) {
return this._close(null, 'close', cancelable, null);
}
/** */
updateMenuItems() {
this._itemEventListeners.removeAllEventListeners();
const items = this._bodyNode.querySelectorAll('.popup-menu-item');
const onMenuItemClick = this._onMenuItemClick.bind(this);
for (const item of items) {
this._itemEventListeners.addEventListener(item, 'click', onMenuItemClick, false);
}
}
/** */
updatePosition() {
this._setPosition();
}
// Private
/**
* @param {MouseEvent} e
*/
_onMenuContainerClick(e) {
if (e.currentTarget !== e.target) { return; }
if (this._close(null, 'outside', true, e)) {
e.stopPropagation();
e.preventDefault();
}
}
/**
* @param {MouseEvent} e
*/
_onMenuItemClick(e) {
const item = /** @type {HTMLButtonElement} */ (e.currentTarget);
if (item.disabled) { return; }
if (this._close(item, 'item', true, e)) {
e.stopPropagation();
e.preventDefault();
}
}
/** */
_onWindowResize() {
this._close(null, 'resize', true, null);
}
/** */
_setPosition() {
// Get flags
let horizontal = 1;
let vertical = 1;
let horizontalCover = 1;
let verticalCover = 1;
const positionInfo = this._sourceElement.dataset.menuPosition;
if (typeof positionInfo === 'string') {
const positionInfoSet = new Set(positionInfo.split(' '));
if (positionInfoSet.has('left')) {
horizontal = -1;
} else if (positionInfoSet.has('right')) {
horizontal = 1;
} else if (positionInfoSet.has('h-center')) {
horizontal = 0;
}
if (positionInfoSet.has('above')) {
vertical = -1;
} else if (positionInfoSet.has('below')) {
vertical = 1;
} else if (positionInfoSet.has('v-center')) {
vertical = 0;
}
if (positionInfoSet.has('cover')) {
horizontalCover = 1;
verticalCover = 1;
} else if (positionInfoSet.has('no-cover')) {
horizontalCover = -1;
verticalCover = -1;
}
if (positionInfoSet.has('h-cover')) {
horizontalCover = 1;
} else if (positionInfoSet.has('no-h-cover')) {
horizontalCover = -1;
}
if (positionInfoSet.has('v-cover')) {
verticalCover = 1;
} else if (positionInfoSet.has('no-v-cover')) {
verticalCover = -1;
}
}
// Position
const menu = this._node;
const containerNodeRect = this._containerNode.getBoundingClientRect();
const sourceElementRect = this._sourceElement.getBoundingClientRect();
const menuRect = menu.getBoundingClientRect();
let top = menuRect.top;
let bottom = menuRect.bottom;
if (verticalCover === 1) {
const bodyRect = this._bodyNode.getBoundingClientRect();
top = bodyRect.top;
bottom = bodyRect.bottom;
}
let x = (
sourceElementRect.left +
sourceElementRect.width * ((-horizontal * horizontalCover + 1) * 0.5) +
menuRect.width * ((-horizontal + 1) * -0.5)
);
let y = (
sourceElementRect.top +
(menuRect.top - top) +
sourceElementRect.height * ((-vertical * verticalCover + 1) * 0.5) +
(bottom - top) * ((-vertical + 1) * -0.5)
);
x = Math.max(0, Math.min(containerNodeRect.width - menuRect.width, x));
y = Math.max(0, Math.min(containerNodeRect.height - menuRect.height, y));
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
}
/**
* @param {?HTMLElement} item
* @param {import('popup-menu').CloseReason} cause
* @param {boolean} cancelable
* @param {?MouseEvent} originalEvent
* @returns {boolean}
*/
_close(item, cause, cancelable, originalEvent) {
if (this._isClosed) { return true; }
/** @type {?string} */
let action = null;
if (item !== null) {
const {menuAction} = item.dataset;
if (typeof menuAction === 'string') { action = menuAction; }
}
const {altKey, ctrlKey, metaKey, shiftKey} = (
originalEvent !== null ?
originalEvent :
{altKey: false, ctrlKey: false, metaKey: false, shiftKey: false}
);
/** @type {import('popup-menu').EventArgument<'close'>} */
const detail = {
menu: this,
item,
action,
cause,
altKey,
ctrlKey,
metaKey,
shiftKey,
};
const result = this._sourceElement.dispatchEvent(new CustomEvent('menuClose', {bubbles: false, cancelable, detail}));
if (cancelable && !result) { return false; }
PopupMenu.openMenus.delete(this);
this._isClosed = true;
this._eventListeners.removeAllEventListeners();
this._itemEventListeners.removeAllEventListeners();
if (this._containerNode.parentNode !== null) {
this._containerNode.parentNode.removeChild(this._containerNode);
}
this.trigger('close', detail);
return true;
}
}
Object.defineProperty(PopupMenu, 'openMenus', {
configurable: false,
enumerable: true,
writable: false,
value: new Set(),
});

43
vendor/yomitan/js/dom/query-selector.js vendored Normal file
View File

@@ -0,0 +1,43 @@
/*
* Copyright (C) 2023-2025 Yomitan 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/>.
*/
import {ExtensionError} from '../core/extension-error.js';
/**
* @param {Element|Document|DocumentFragment} element
* @param {string} selector
* @returns {ExtensionError}
*/
function createError(element, selector) {
const error = new ExtensionError(`Performing querySelectorNotNull(element, ${JSON.stringify(selector)}) returned null`);
error.data = {element, selector};
return error;
}
/**
* @template {Element} T
* @param {Element|Document|DocumentFragment} element
* @param {string} selector
* @returns {T}
* @throws {Error}
*/
export function querySelectorNotNull(element, selector) {
/** @type {?T} */
const result = element.querySelector(selector);
if (result === null) { throw createError(element, selector); }
return result;
}

165
vendor/yomitan/js/dom/scroll-element.js vendored Normal file
View File

@@ -0,0 +1,165 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2019-2022 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/>.
*/
export class ScrollElement {
/**
* @param {Element} node
*/
constructor(node) {
/** @type {Element} */
this._node = node;
/** @type {?number} */
this._animationRequestId = null;
/** @type {number} */
this._animationStartTime = 0;
/** @type {number} */
this._animationStartX = 0;
/** @type {number} */
this._animationStartY = 0;
/** @type {number} */
this._animationEndTime = 0;
/** @type {number} */
this._animationEndX = 0;
/** @type {number} */
this._animationEndY = 0;
/** @type {(time: number) => void} */
this._requestAnimationFrameCallback = this._onAnimationFrame.bind(this);
}
/** @type {number} */
get x() {
return this._node !== null ? this._node.scrollLeft : window.scrollX || window.pageXOffset;
}
/** @type {number} */
get y() {
return this._node !== null ? this._node.scrollTop : window.scrollY || window.pageYOffset;
}
/**
* @param {number} y
*/
toY(y) {
this.to(this.x, y);
}
/**
* @param {number} x
*/
toX(x) {
this.to(x, this.y);
}
/**
* @param {number} x
* @param {number} y
*/
to(x, y) {
this.stop();
this._scroll(x, y);
}
/**
* @param {number} x
* @param {number} y
* @param {number} time
*/
animate(x, y, time) {
this._animationStartX = this.x;
this._animationStartY = this.y;
this._animationStartTime = window.performance.now();
this._animationEndX = x;
this._animationEndY = y;
this._animationEndTime = this._animationStartTime + time;
this._animationRequestId = window.requestAnimationFrame(this._requestAnimationFrameCallback);
}
/** */
stop() {
if (this._animationRequestId === null) {
return;
}
window.cancelAnimationFrame(this._animationRequestId);
this._animationRequestId = null;
}
/**
* @returns {DOMRect}
*/
getRect() {
return this._node.getBoundingClientRect();
}
// Private
/**
* @param {number} time
*/
_onAnimationFrame(time) {
if (time >= this._animationEndTime) {
this._scroll(this._animationEndX, this._animationEndY);
this._animationRequestId = null;
return;
}
const t = this._easeInOutCubic((time - this._animationStartTime) / (this._animationEndTime - this._animationStartTime));
this._scroll(
this._lerp(this._animationStartX, this._animationEndX, t),
this._lerp(this._animationStartY, this._animationEndY, t),
);
this._animationRequestId = window.requestAnimationFrame(this._requestAnimationFrameCallback);
}
/**
* @param {number} t
* @returns {number}
*/
_easeInOutCubic(t) {
if (t < 0.5) {
return (4 * t * t * t);
} else {
t = 1 - t;
return 1 - (4 * t * t * t);
}
}
/**
* @param {number} start
* @param {number} end
* @param {number} percent
* @returns {number}
*/
_lerp(start, end, percent) {
return (end - start) * percent + start;
}
/**
* @param {number} x
* @param {number} y
*/
_scroll(x, y) {
if (this._node !== null) {
this._node.scrollLeft = x;
this._node.scrollTop = y;
} else {
window.scroll(x, y);
}
}
}

View File

@@ -0,0 +1,300 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 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/>.
*/
/**
* Class which is used to observe elements matching a selector in specific element.
* @template [T=unknown]
*/
export class SelectorObserver {
/**
* Creates a new instance.
* @param {import('selector-observer').ConstructorDetails<T>} details The configuration for the object.
*/
constructor({
selector,
ignoreSelector = null,
onAdded = null,
onRemoved = null,
onChildrenUpdated = null,
isStale = null,
}) {
/** @type {string} */
this._selector = selector;
/** @type {?string} */
this._ignoreSelector = ignoreSelector;
/** @type {?import('selector-observer').OnAddedCallback<T>} */
this._onAdded = onAdded;
/** @type {?import('selector-observer').OnRemovedCallback<T>} */
this._onRemoved = onRemoved;
/** @type {?import('selector-observer').OnChildrenUpdatedCallback<T>} */
this._onChildrenUpdated = onChildrenUpdated;
/** @type {?import('selector-observer').IsStaleCallback<T>} */
this._isStale = isStale;
/** @type {?Element} */
this._observingElement = null;
/** @type {MutationObserver} */
this._mutationObserver = new MutationObserver(this._onMutation.bind(this));
/** @type {Map<Node, import('selector-observer').Observer<T>>} */
this._elementMap = new Map(); // Map([element => observer]...)
/** @type {Map<Node, Set<import('selector-observer').Observer<T>>>} */
this._elementAncestorMap = new Map(); // Map([element => Set([observer]...)]...)
/** @type {boolean} */
this._isObserving = false;
}
/**
* Returns whether or not an element is currently being observed.
* @returns {boolean} `true` if an element is being observed, `false` otherwise.
*/
get isObserving() {
return this._observingElement !== null;
}
/**
* Starts DOM mutation observing the target element.
* @param {Element} element The element to observe changes in.
* @param {boolean} [attributes] A boolean for whether or not attribute changes should be observed.
* @throws {Error} An error if element is null.
* @throws {Error} An error if an element is already being observed.
*/
observe(element, attributes = false) {
if (element === null) {
throw new Error('Invalid element');
}
if (this.isObserving) {
throw new Error('Instance is already observing an element');
}
this._observingElement = element;
this._mutationObserver.observe(element, {
attributes: !!attributes,
childList: true,
subtree: true,
});
const {parentNode} = element;
this._onMutation([{
type: 'childList',
target: parentNode !== null ? parentNode : element,
addedNodes: [element],
removedNodes: [],
}]);
}
/**
* Stops observing the target element.
*/
disconnect() {
if (!this.isObserving) { return; }
this._mutationObserver.disconnect();
this._observingElement = null;
for (const observer of this._elementMap.values()) {
this._removeObserver(observer);
}
}
/**
* Returns an iterable list of [element, data] pairs.
* @yields {[element: Element, data: T]} A sequence of [element, data] pairs.
* @returns {Generator<[element: Element, data: T], void, unknown>}
*/
*entries() {
for (const {element, data} of this._elementMap.values()) {
yield [element, data];
}
}
/**
* Returns an iterable list of data for every element.
* @yields {T} A sequence of data values.
* @returns {Generator<T, void, unknown>}
*/
*datas() {
for (const {data} of this._elementMap.values()) {
yield data;
}
}
// Private
/**
* @param {(MutationRecord|import('selector-observer').MutationRecordLike)[]} mutationList
*/
_onMutation(mutationList) {
for (const mutation of mutationList) {
switch (mutation.type) {
case 'childList':
this._onChildListMutation(mutation);
break;
case 'attributes':
this._onAttributeMutation(mutation);
break;
}
}
}
/**
* @param {MutationRecord|import('selector-observer').MutationRecordLike} record
*/
_onChildListMutation({addedNodes, removedNodes, target}) {
const selector = this._selector;
const ELEMENT_NODE = Node.ELEMENT_NODE;
for (const node of removedNodes) {
const observers = this._elementAncestorMap.get(node);
if (typeof observers === 'undefined') { continue; }
for (const observer of observers) {
this._removeObserver(observer);
}
}
for (const node of addedNodes) {
if (node.nodeType !== ELEMENT_NODE) { continue; }
if (/** @type {Element} */ (node).matches(selector)) {
this._createObserver(/** @type {Element} */ (node));
}
for (const childNode of /** @type {Element} */ (node).querySelectorAll(selector)) {
this._createObserver(childNode);
}
}
if (
this._onChildrenUpdated !== null &&
(removedNodes.length > 0 || addedNodes.length > 0)
) {
for (let node = /** @type {?Node} */ (target); node !== null; node = node.parentNode) {
const observer = this._elementMap.get(node);
if (typeof observer !== 'undefined') {
this._onObserverChildrenUpdated(observer);
}
}
}
}
/**
* @param {MutationRecord|import('selector-observer').MutationRecordLike} record
*/
_onAttributeMutation({target}) {
const selector = this._selector;
const observers = this._elementAncestorMap.get(/** @type {Element} */ (target));
if (typeof observers !== 'undefined') {
for (const observer of observers) {
const element = observer.element;
if (
!element.matches(selector) ||
this._shouldIgnoreElement(element) ||
this._isObserverStale(observer)
) {
this._removeObserver(observer);
}
}
}
if (/** @type {Element} */ (target).matches(selector)) {
this._createObserver(/** @type {Element} */ (target));
}
}
/**
* @param {Element} element
*/
_createObserver(element) {
if (this._elementMap.has(element) || this._shouldIgnoreElement(element) || this._onAdded === null) { return; }
const data = this._onAdded(element);
if (typeof data === 'undefined') { return; }
const ancestors = this._getAncestors(element);
const observer = {element, ancestors, data};
this._elementMap.set(element, observer);
for (const ancestor of ancestors) {
let observers = this._elementAncestorMap.get(ancestor);
if (typeof observers === 'undefined') {
observers = new Set();
this._elementAncestorMap.set(ancestor, observers);
}
observers.add(observer);
}
}
/**
* @param {import('selector-observer').Observer<T>} observer
*/
_removeObserver(observer) {
const {element, ancestors, data} = observer;
this._elementMap.delete(element);
for (const ancestor of ancestors) {
const observers = this._elementAncestorMap.get(ancestor);
if (typeof observers === 'undefined') { continue; }
observers.delete(observer);
if (observers.size === 0) {
this._elementAncestorMap.delete(ancestor);
}
}
if (this._onRemoved !== null) {
this._onRemoved(element, data);
}
}
/**
* @param {import('selector-observer').Observer<T>} observer
*/
_onObserverChildrenUpdated(observer) {
if (this._onChildrenUpdated === null) { return; }
this._onChildrenUpdated(observer.element, observer.data);
}
/**
* @param {import('selector-observer').Observer<T>} observer
* @returns {boolean}
*/
_isObserverStale(observer) {
return (this._isStale !== null && this._isStale(observer.element, observer.data));
}
/**
* @param {Element} element
* @returns {boolean}
*/
_shouldIgnoreElement(element) {
return (this._ignoreSelector !== null && element.matches(this._ignoreSelector));
}
/**
* @param {Node} node
* @returns {Node[]}
*/
_getAncestors(node) {
const root = this._observingElement;
const results = [];
let n = /** @type {?Node} */ (node);
while (n !== null) {
results.push(n);
if (n === root) { break; }
n = n.parentNode;
}
return results;
}
}

View File

@@ -0,0 +1,184 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 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/>.
*/
import * as parse5 from '../../lib/parse5.js';
/**
* @augments import('simple-dom-parser').ISimpleDomParser
*/
export class SimpleDOMParser {
/**
* @param {string} content
*/
constructor(content) {
/** @type {import('parse5')} */
// @ts-expect-error - parse5 global is not defined in typescript declaration
this._parse5Lib = /** @type {import('parse5')} */ (parse5);
/** @type {import('parse5').TreeAdapter<import('parse5').DefaultTreeAdapterMap>} */
this._treeAdapter = this._parse5Lib.defaultTreeAdapter;
/** @type {import('simple-dom-parser').Parse5Document} */
this._document = this._parse5Lib.parse(content, {
treeAdapter: this._treeAdapter,
});
/** @type {RegExp} */
this._patternHtmlWhitespace = /[\t\r\n\f ]+/g;
}
/**
* @param {string} id
* @param {import('simple-dom-parser').Element} [root]
* @returns {?import('simple-dom-parser').Element}
*/
getElementById(id, root) {
for (const node of this._allNodes(root)) {
if (!this._treeAdapter.isElementNode(node) || this.getAttribute(node, 'id') !== id) { continue; }
return node;
}
return null;
}
/**
* @param {string} tagName
* @param {import('simple-dom-parser').Element} [root]
* @returns {?import('simple-dom-parser').Element}
*/
getElementByTagName(tagName, root) {
for (const node of this._allNodes(root)) {
if (!this._treeAdapter.isElementNode(node) || node.tagName !== tagName) { continue; }
return node;
}
return null;
}
/**
* @param {string} tagName
* @param {import('simple-dom-parser').Element} [root]
* @returns {import('simple-dom-parser').Element[]}
*/
getElementsByTagName(tagName, root) {
const results = [];
for (const node of this._allNodes(root)) {
if (!this._treeAdapter.isElementNode(node) || node.tagName !== tagName) { continue; }
results.push(node);
}
return results;
}
/**
* @param {string} className
* @param {import('simple-dom-parser').Element} [root]
* @returns {import('simple-dom-parser').Element[]}
*/
getElementsByClassName(className, root) {
const results = [];
for (const node of this._allNodes(root)) {
if (!this._treeAdapter.isElementNode(node)) { continue; }
const nodeClassName = this.getAttribute(node, 'class');
if (nodeClassName !== null && this._hasToken(nodeClassName, className)) {
results.push(node);
}
}
return results;
}
/**
* @param {import('simple-dom-parser').Element} element
* @param {string} attribute
* @returns {?string}
*/
getAttribute(element, attribute) {
for (const attr of /** @type {import('simple-dom-parser').Parse5Element} */ (element).attrs) {
if (
attr.name === attribute &&
typeof attr.namespace === 'undefined'
) {
return attr.value;
}
}
return null;
}
/**
* @param {import('simple-dom-parser').Element} element
* @returns {string}
*/
getTextContent(element) {
let source = '';
for (const node of this._allNodes(element)) {
if (this._treeAdapter.isTextNode(node)) {
source += node.value;
}
}
return source;
}
/**
* @returns {boolean}
*/
static isSupported() {
return typeof parse5 !== 'undefined';
}
// Private
/**
* @param {import('simple-dom-parser').Element|undefined} root
* @returns {Generator<import('simple-dom-parser').Parse5ChildNode, void, unknown>}
* @yields {import('simple-dom-parser').Parse5ChildNode}
*/
*_allNodes(root) {
// Depth-first pre-order traversal
/** @type {import('simple-dom-parser').Parse5ChildNode[]} */
const nodeQueue = [];
if (typeof root !== 'undefined') {
nodeQueue.push(/** @type {import('simple-dom-parser').Parse5Element} */ (root));
} else {
nodeQueue.push(...this._document.childNodes);
}
while (nodeQueue.length > 0) {
const node = /** @type {import('simple-dom-parser').Parse5ChildNode} */ (nodeQueue.pop());
yield node;
if (this._treeAdapter.isElementNode(node)) {
const {childNodes} = node;
if (typeof childNodes !== 'undefined') {
for (let i = childNodes.length - 1; i >= 0; --i) {
nodeQueue.push(childNodes[i]);
}
}
}
}
}
/**
* @param {string} tokenListString
* @param {string} token
* @returns {boolean}
*/
_hasToken(tokenListString, token) {
let start = 0;
const pattern = this._patternHtmlWhitespace;
pattern.lastIndex = 0;
while (true) {
const match = pattern.exec(tokenListString);
const end = match === null ? tokenListString.length : match.index;
if (end > start && tokenListString.substring(start, end) === token) { return true; }
if (match === null) { return false; }
start = end + match[0].length;
}
}
}

132
vendor/yomitan/js/dom/style-util.js vendored Normal file
View File

@@ -0,0 +1,132 @@
/*
* Copyright (C) 2023-2025 Yomitan 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/>.
*/
/** @type {Map<string, ?HTMLStyleElement|HTMLLinkElement>} */
const injectedStylesheets = new Map();
/** @type {WeakMap<Node, Map<string, ?HTMLStyleElement|HTMLLinkElement>>} */
const injectedStylesheetsWithParent = new WeakMap();
/**
* @param {string} id
* @param {?Node} parentNode
* @returns {?HTMLStyleElement|HTMLLinkElement|undefined}
*/
function getInjectedStylesheet(id, parentNode) {
if (parentNode === null) {
return injectedStylesheets.get(id);
}
const map = injectedStylesheetsWithParent.get(parentNode);
return typeof map !== 'undefined' ? map.get(id) : void 0;
}
/**
* @param {string} id
* @param {?Node} parentNode
* @param {?HTMLStyleElement|HTMLLinkElement} value
*/
function setInjectedStylesheet(id, parentNode, value) {
if (parentNode === null) {
injectedStylesheets.set(id, value);
return;
}
let map = injectedStylesheetsWithParent.get(parentNode);
if (typeof map === 'undefined') {
map = new Map();
injectedStylesheetsWithParent.set(parentNode, map);
}
map.set(id, value);
}
/**
* @param {import('../application.js').Application} application
* @param {string} id
* @param {'code'|'file'|'file-content'} type
* @param {string} value
* @param {boolean} [useWebExtensionApi]
* @param {?Node} [parentNode]
* @returns {Promise<?HTMLStyleElement|HTMLLinkElement>}
* @throws {Error}
*/
export async function loadStyle(application, id, type, value, useWebExtensionApi = false, parentNode = null) {
if (useWebExtensionApi && application.webExtension.isExtensionUrl(window.location.href)) {
// Permissions error will occur if trying to use the WebExtension API to inject into an extension page
useWebExtensionApi = false;
}
let styleNode = getInjectedStylesheet(id, parentNode);
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;
}
if (type === 'file-content') {
value = await application.api.getStylesheetContent(value);
type = 'code';
useWebExtensionApi = false;
}
if (useWebExtensionApi) {
// Inject via WebExtension API
if (styleNode !== null && styleNode.parentNode !== null) {
styleNode.parentNode.removeChild(styleNode);
}
setInjectedStylesheet(id, parentNode, null);
await application.api.injectStylesheet(type, value);
return null;
}
// Create node in document
let parentNode2 = parentNode;
if (parentNode2 === null) {
parentNode2 = document.head;
if (parentNode2 === 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);
}
// Update node style
if (isFile) {
/** @type {HTMLLinkElement} */ (styleNode).rel = 'stylesheet';
/** @type {HTMLLinkElement} */ (styleNode).href = value;
} else {
styleNode.textContent = value;
}
// Update parent
if (styleNode.parentNode !== parentNode2) {
parentNode2.appendChild(styleNode);
}
// Add to map
setInjectedStylesheet(id, parentNode, styleNode);
return styleNode;
}

View File

@@ -0,0 +1,262 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2016-2022 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/>.
*/
import {readCodePointsBackward, readCodePointsForward} from '../data/string-util.js';
import {convertMultipleRectZoomCoordinates} from './document-util.js';
/**
* This class represents a text source that is attached to a HTML element, such as an <img>
* with alt text or a <button>.
*/
export class TextSourceElement {
/**
* Creates a new instance of the class.
* @param {Element} element The source element.
* @param {string} fullContent The string representing the element's full text value.
* @param {number} startOffset The text start offset position within the full content.
* @param {number} endOffset The text end offset position within the full content.
*/
constructor(element, fullContent, startOffset, endOffset) {
/** @type {Element} */
this._element = element;
/** @type {string} */
this._fullContent = fullContent;
/** @type {number} */
this._startOffset = startOffset;
/** @type {number} */
this._endOffset = endOffset;
/** @type {string} */
this._content = this._fullContent.substring(this._startOffset, this._endOffset);
}
/**
* Gets the type name of this instance.
* @type {'element'}
*/
get type() {
return 'element';
}
/**
* The source element.
* @type {Element}
*/
get element() {
return this._element;
}
/**
* The string representing the element's full text value.
* @type {string}
*/
get fullContent() {
return this._fullContent;
}
/**
* The string representing the element's constrained text value.
* @type {string}
*/
get content() {
return this._content;
}
/**
* The text start offset position within the full content.
* @type {number}
*/
get startOffset() {
return this._startOffset;
}
/**
* The text end offset position within the full content.
* @type {number}
*/
get endOffset() {
return this._endOffset;
}
/**
* Creates a clone of the instance.
* @returns {TextSourceElement} The new clone.
*/
clone() {
return new TextSourceElement(this._element, this._fullContent, this._startOffset, this._endOffset);
}
/**
* Performs any cleanup that is necessary after the element has been used.
*/
cleanup() {
// NOP
}
/**
* Gets the selected text of element, which is a substring of the full content
* starting at `startOffset` and ending at `endOffset`.
* @returns {string} The text content.
*/
text() {
return this._content;
}
/**
* Moves the end offset of the text by a set amount of unicode codepoints.
* @param {number} length The maximum number of codepoints to move by.
* @param {boolean} fromEnd Whether to move the offset from the current end position (if `true`) or the start position (if `false`).
* @returns {number} The actual number of characters (not codepoints) that were read.
*/
setEndOffset(length, fromEnd) {
const offset = fromEnd ? this._endOffset : this._startOffset;
length = Math.min(this._fullContent.length - offset, length);
if (length > 0) {
length = readCodePointsForward(this._fullContent, offset, length).length;
}
this._endOffset = offset + length;
this._content = this._fullContent.substring(this._startOffset, this._endOffset);
return length;
}
/**
* Moves the start offset of the text by a set amount of unicode codepoints.
* @param {number} length The maximum number of codepoints to move by.
* @returns {number} The actual number of characters (not codepoints) that were read.
*/
setStartOffset(length) {
length = Math.min(this._startOffset, length);
if (length > 0) {
length = readCodePointsBackward(this._fullContent, this._startOffset - 1, length).length;
}
this._startOffset -= length;
this._content = this._fullContent.substring(this._startOffset, this._endOffset);
return length;
}
/**
* Gets the rects that represent the position and bounds of the text source.
* @returns {DOMRect[]} The rects.
*/
getRects() {
return convertMultipleRectZoomCoordinates(this._element.getClientRects(), this._element);
}
/**
* Gets writing mode that is used for this element.
* See: https://developer.mozilla.org/en-US/docs/Web/CSS/writing-mode.
* @returns {import('document-util').NormalizedWritingMode} The writing mode.
*/
getWritingMode() {
return 'horizontal-tb';
}
/**
* Selects the text source in the document.
*/
select() {
// NOP
}
/**
* Deselects the text source in the document.
*/
deselect() {
// NOP
}
/**
* Checks whether another text source has the same starting point.
* @param {import('text-source').TextSource} other The other source to test.
* @returns {boolean} `true` if the starting points are equivalent, `false` otherwise.
*/
hasSameStart(other) {
return (
typeof other === 'object' &&
other !== null &&
other instanceof TextSourceElement &&
this._element === other.element &&
this._fullContent === other.fullContent &&
this._startOffset === other.startOffset
);
}
/**
* Gets a list of the nodes in this text source's range.
* @returns {Node[]} The nodes in the range.
*/
getNodesInRange() {
return [this._element];
}
/**
* Creates a new instance for a given element.
* @param {Element} element The source element.
* @returns {TextSourceElement} A new instance of the class corresponding to the element.
*/
static create(element) {
return new TextSourceElement(element, this._getElementContent(element), 0, 0);
}
/**
* Gets the full content string for a given element.
* @param {Element} element The element to get the full content of.
* @returns {string} The content string.
*/
static _getElementContent(element) {
let content = '';
switch (element.nodeName.toUpperCase()) {
case 'BUTTON':
{
const {textContent} = /** @type {HTMLButtonElement} */ (element);
if (textContent !== null) {
content = textContent;
}
}
break;
case 'IMG':
{
const alt = /** @type {HTMLImageElement} */ (element).getAttribute('alt');
if (typeof alt === 'string') {
content = alt;
}
}
break;
case 'SELECT':
{
const {selectedIndex, options} = /** @type {HTMLSelectElement} */ (element);
if (selectedIndex >= 0 && selectedIndex < options.length) {
const {textContent} = options[selectedIndex];
if (textContent !== null) {
content = textContent;
}
}
}
break;
case 'INPUT':
{
content = /** @type {HTMLInputElement} */ (element).value;
}
break;
}
// Remove zero-width space, zero-width non-joiner, soft hyphen
content = content.replace(/[\u200b\u200c\u00ad]/g, '');
return content;
}
}

View File

@@ -0,0 +1,690 @@
/*
* Copyright (C) 2024-2025 Yomitan 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/>.
*/
import {computeZoomScale, isPointInAnyRect} from './document-util.js';
import {DOMTextScanner} from './dom-text-scanner.js';
import {TextSourceElement} from './text-source-element.js';
import {TextSourceRange} from './text-source-range.js';
export class TextSourceGenerator {
constructor() {
/** @type {RegExp} @readonly */
this._transparentColorPattern = /rgba\s*\([^)]*,\s*0(?:\.0+)?\s*\)/;
/** @type {import('text-source-generator').GetRangeFromPointHandler[]} @readonly */
this._getRangeFromPointHandlers = [];
}
/**
* @param {number} x
* @param {number} y
* @param {import('document-util').GetRangeFromPointOptions} options
* @returns {?import('text-source').TextSource}
*/
getRangeFromPoint(x, y, options) {
for (const handler of this._getRangeFromPointHandlers) {
const result = handler(x, y, options);
if (result !== null) { return result; }
}
return this._getRangeFromPointInternal(x, y, options);
}
/**
* Registers a custom handler for scanning for text or elements at the input position.
* @param {import('text-source-generator').GetRangeFromPointHandler} handler The handler callback which will be invoked when calling `getRangeFromPoint`.
*/
registerGetRangeFromPointHandler(handler) {
this._getRangeFromPointHandlers.push(handler);
}
/**
* Extract a sentence from a document.
* @param {import('text-source').TextSource} source The text source object, either `TextSourceRange` or `TextSourceElement`.
* @param {boolean} layoutAwareScan Whether or not layout-aware scan mode should be used.
* @param {number} extent The length of the sentence to extract.
* @param {boolean} terminateAtNewlines Whether or not a sentence should be terminated at newline characters.
* @param {import('text-scanner').SentenceTerminatorMap} terminatorMap A mapping of characters that terminate a sentence.
* @param {import('text-scanner').SentenceForwardQuoteMap} forwardQuoteMap A mapping of quote characters that delimit a sentence.
* @param {import('text-scanner').SentenceBackwardQuoteMap} backwardQuoteMap A mapping of quote characters that delimit a sentence, which is the inverse of forwardQuoteMap.
* @returns {{text: string, offset: number}} The sentence and the offset to the original source.
*/
extractSentence(source, layoutAwareScan, extent, terminateAtNewlines, terminatorMap, forwardQuoteMap, backwardQuoteMap) {
// Scan text
source = source.clone();
const startLength = source.setStartOffset(extent, layoutAwareScan);
const endLength = source.setEndOffset(extent * 2 - startLength, true, layoutAwareScan);
const text = [...source.text()];
const textLength = text.length;
const textEndAnchor = textLength - endLength;
/** Relative start position of the sentence (inclusive). */
let cursorStart = startLength;
/** Relative end position of the sentence (exclusive). */
let cursorEnd = textEndAnchor;
// Move backward
let quoteStack = [];
for (; cursorStart > 0; --cursorStart) {
// Check if the previous character should be included.
let c = text[cursorStart - 1];
if (c === '\n' && terminateAtNewlines) { break; }
if (quoteStack.length === 0) {
let terminatorInfo = terminatorMap.get(c);
if (typeof terminatorInfo !== 'undefined') {
// Include the previous character while it is a terminator character and is included at start.
while (terminatorInfo[0] && cursorStart > 0) {
--cursorStart;
if (cursorStart === 0) { break; }
c = text[cursorStart - 1];
terminatorInfo = terminatorMap.get(c);
if (typeof terminatorInfo === 'undefined') { break; }
}
break;
}
}
let quoteInfo = forwardQuoteMap.get(c);
if (typeof quoteInfo !== 'undefined') {
if (quoteStack.length === 0) {
// Include the previous character while it is a quote character and is included at start.
while (quoteInfo[1] && cursorStart > 0) {
--cursorStart;
if (cursorStart === 0) { break; }
c = text[cursorStart - 1];
quoteInfo = forwardQuoteMap.get(c);
if (typeof quoteInfo === 'undefined') { break; }
}
break;
} else if (quoteStack[0] === c) {
quoteStack.pop();
continue;
}
}
quoteInfo = backwardQuoteMap.get(c);
if (typeof quoteInfo !== 'undefined') {
quoteStack.unshift(quoteInfo[0]);
}
}
// Move forward
quoteStack = [];
for (; cursorEnd < textLength; ++cursorEnd) {
// Check if the following character should be included.
let c = text[cursorEnd];
if (c === '\n' && terminateAtNewlines) { break; }
if (quoteStack.length === 0) {
let terminatorInfo = terminatorMap.get(c);
if (typeof terminatorInfo !== 'undefined') {
// Include the following character while it is a terminator character and is included at end.
while (terminatorInfo[1] && cursorEnd < textLength) {
++cursorEnd;
if (cursorEnd === textLength) { break; }
c = text[cursorEnd];
terminatorInfo = terminatorMap.get(c);
if (typeof terminatorInfo === 'undefined') { break; }
}
break;
}
}
let quoteInfo = backwardQuoteMap.get(c);
if (typeof quoteInfo !== 'undefined') {
if (quoteStack.length === 0) {
// Include the following character while it is a quote character and is included at end.
while (quoteInfo[1] && cursorEnd < textLength) {
++cursorEnd;
if (cursorEnd === textLength) { break; }
c = text[cursorEnd];
quoteInfo = forwardQuoteMap.get(c);
if (typeof quoteInfo === 'undefined') { break; }
}
break;
} else if (quoteStack[0] === c) {
quoteStack.pop();
continue;
}
}
quoteInfo = forwardQuoteMap.get(c);
if (typeof quoteInfo !== 'undefined') {
quoteStack.unshift(quoteInfo[0]);
}
}
// Trim whitespace
for (; cursorStart < startLength && this._isWhitespace(text[cursorStart]); ++cursorStart) { /* NOP */ }
for (; cursorEnd > textEndAnchor && this._isWhitespace(text[cursorEnd - 1]); --cursorEnd) { /* NOP */ }
// Result
return {
text: text.slice(cursorStart, cursorEnd).join(''),
offset: startLength - cursorStart,
};
}
/**
* Scans the document for text or elements with text information at the given coordinate.
* Coordinates are provided in [client space](https://developer.mozilla.org/en-US/docs/Web/CSS/CSSOM_View/Coordinate_systems).
* @param {number} x The x coordinate to search at.
* @param {number} y The y coordinate to search at.
* @param {import('document-util').GetRangeFromPointOptions} options Options to configure how element detection is performed.
* @returns {?import('text-source').TextSource} A range for the hovered text or element, or `null` if no applicable content was found.
*/
_getRangeFromPointInternal(x, y, options) {
const {deepContentScan, normalizeCssZoom, language} = options;
const elements = this._getElementsFromPoint(x, y, deepContentScan);
/** @type {?HTMLDivElement} */
let imposter = null;
/** @type {?HTMLDivElement} */
let imposterContainer = null;
/** @type {?Element} */
let imposterSourceElement = null;
if (elements.length > 0) {
const element = elements[0];
switch (element.nodeName.toUpperCase()) {
case 'IMG':
case 'BUTTON':
case 'SELECT':
return TextSourceElement.create(element);
case 'INPUT':
if (
/** @type {HTMLInputElement} */ (element).type === 'text' ||
/** @type {HTMLInputElement} */ (element).type === 'search'
) {
imposterSourceElement = element;
[imposter, imposterContainer] = this._createImposter(/** @type {HTMLInputElement} */ (element), false);
}
break;
case 'TEXTAREA':
imposterSourceElement = element;
[imposter, imposterContainer] = this._createImposter(/** @type {HTMLTextAreaElement} */ (element), true);
break;
}
}
const range = this._caretRangeFromPointExt(x, y, deepContentScan ? elements : [], normalizeCssZoom, language);
if (range !== null) {
if (imposter !== null) {
this._setImposterStyle(/** @type {HTMLDivElement} */ (imposterContainer).style, 'z-index', '-2147483646');
this._setImposterStyle(imposter.style, 'pointer-events', 'none');
return TextSourceRange.createFromImposter(range, /** @type {HTMLDivElement} */ (imposterContainer), /** @type {HTMLElement} */ (imposterSourceElement));
}
return TextSourceRange.create(range);
} else {
if (imposterContainer !== null) {
const {parentNode} = imposterContainer;
if (parentNode !== null) {
parentNode.removeChild(imposterContainer);
}
}
return null;
}
}
/**
* @param {CSSStyleDeclaration} style
* @param {string} propertyName
* @param {string} value
*/
_setImposterStyle(style, propertyName, value) {
style.setProperty(propertyName, value, 'important');
}
/**
* @param {HTMLInputElement|HTMLTextAreaElement} element
* @param {boolean} isTextarea
* @returns {[imposter: ?HTMLDivElement, container: ?HTMLDivElement]}
*/
_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 = Number.parseFloat(elementStyle.width) + (elementRect.width - imposterRect.width);
const height = Number.parseFloat(elementStyle.height) + (elementRect.height - imposterRect.height);
this._setImposterStyle(imposterStyle, 'width', `${width}px`);
this._setImposterStyle(imposterStyle, 'height', `${height}px`);
}
if (imposterRect.left !== elementRect.left || imposterRect.top !== elementRect.top) {
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];
}
/**
* @param {number} x
* @param {number} y
* @param {boolean} all
* @returns {Element[]}
*/
_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] : [];
}
/**
* @param {number} x
* @param {number} y
* @param {Range} range
* @param {boolean} normalizeCssZoom
* @param {?string} language
* @returns {boolean}
*/
_isPointInRange(x, y, range, normalizeCssZoom, language) {
// Require a text node to start
const {startContainer} = range;
if (startContainer.nodeType !== Node.TEXT_NODE) {
return false;
}
// Convert CSS zoom coordinates
if (normalizeCssZoom) {
const scale = computeZoomScale(startContainer);
x /= scale;
y /= scale;
}
// Scan forward
const nodePre = range.endContainer;
const offsetPre = range.endOffset;
try {
const {node, offset, content} = new DOMTextScanner(nodePre, offsetPre, true, false).seek(1);
range.setEnd(node, offset);
if (!this._isWhitespace(content) && isPointInAnyRect(x, y, range.getClientRects(), language)) {
return true;
}
} finally {
range.setEnd(nodePre, offsetPre);
}
// Scan backward
const {node, offset, content} = new DOMTextScanner(startContainer, range.startOffset, true, false).seek(-1);
range.setStart(node, offset);
if (!this._isWhitespace(content) && isPointInAnyRect(x, y, range.getClientRects(), language)) {
// 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;
}
/**
* @param {number} x
* @param {number} y
* @returns {?Range}
*/
_caretRangeFromPoint(x, y) {
if (typeof document.caretPositionFromPoint === 'function') {
// Firefox
// 128+ Chrome, Edge
const caretPositionFromPointResult = this._caretPositionFromPoint(x, y);
// Older Chromium based browsers (such as Kiwi) pretend to support `caretPositionFromPoint` but it doesn't work
// Allow falling through if `caretPositionFromPointResult` is null to let `caretRangeFromPoint` be used in these cases
if (caretPositionFromPointResult) {
return caretPositionFromPointResult;
}
}
if (typeof document.caretRangeFromPoint === 'function') {
// Fallback Chrome, Edge
return document.caretRangeFromPoint(x, y);
}
// No support
return null;
}
/**
* @param {Element | ShadowRoot} inputElement
* @returns {ShadowRoot[]}
*/
_findShadowRoots(inputElement) {
const allElements = [inputElement, ...inputElement.querySelectorAll('*')];
/** @type {Element[]} */
const shadowRootContainingElements = [];
for (const element of allElements) {
if (!(element instanceof ShadowRoot) && !!element.shadowRoot) {
shadowRootContainingElements.push(element);
}
}
/** @type {ShadowRoot[]} */
const shadowRoots = [];
for (const element of shadowRootContainingElements) {
if (element.shadowRoot) {
shadowRoots.push(element.shadowRoot);
const nestedShadowRoots = this._findShadowRoots(element.shadowRoot);
if (nestedShadowRoots) {
shadowRoots.push(...nestedShadowRoots);
}
}
}
return shadowRoots;
}
/**
* @param {number} x
* @param {number} y
* @returns {?Range}
*/
_caretPositionFromPoint(x, y) {
const documentCaretPositionFromPoint = document.caretPositionFromPoint(x, y);
const documentCaretPositionOffsetNode = documentCaretPositionFromPoint?.offsetNode;
// nodeName `#text` indicates we have already drilled down as far as required to scan the text
const shadowRootSearchRequired = documentCaretPositionOffsetNode instanceof Element && documentCaretPositionOffsetNode.nodeName !== '#text';
const shadowRoots = shadowRootSearchRequired ? this._findShadowRoots(documentCaretPositionOffsetNode) : [];
const position = shadowRoots.length > 0 ? document.caretPositionFromPoint(x, y, {shadowRoots: shadowRoots}) : documentCaretPositionFromPoint;
if (position === null) {
return null;
}
const node = position.offsetNode;
if (node === null) {
return null;
}
let offset = 0;
const {nodeType} = node;
switch (nodeType) {
case Node.TEXT_NODE:
offset = position.offset;
break;
case Node.ELEMENT_NODE:
// Elements with user-select: all will return the element
// instead of a text point inside the element.
if (this._isElementUserSelectAll(/** @type {Element} */ (node))) {
return this._caretPositionFromPointNormalizeStyles(x, y, /** @type {Element} */ (node));
}
break;
}
try {
const range = document.createRange();
range.setStart(node, offset);
range.setEnd(node, offset);
return range;
} catch (e) {
// Firefox throws new DOMException("The operation is insecure.")
// when trying to select a node from within a ShadowRoot.
return null;
}
}
/**
* @param {number} x
* @param {number} y
* @param {Element} nextElement
* @returns {?Range}
*/
_caretPositionFromPointNormalizeStyles(x, y, nextElement) {
/** @type {Map<Element, ?string>} */
const previousStyles = new Map();
try {
while (true) {
if (nextElement instanceof HTMLElement) {
this._recordPreviousStyle(previousStyles, nextElement);
nextElement.style.setProperty('user-select', 'text', 'important');
}
const position = /** @type {(x: number, y: number) => ?{offsetNode: Node, offset: number}} */ (document.caretPositionFromPoint)(x, y);
if (position === null) {
return null;
}
const node = position.offsetNode;
if (node === null) {
return null;
}
let offset = 0;
const {nodeType} = node;
switch (nodeType) {
case Node.TEXT_NODE:
offset = position.offset;
break;
case Node.ELEMENT_NODE:
// Elements with user-select: all will return the element
// instead of a text point inside the element.
if (this._isElementUserSelectAll(/** @type {Element} */ (node))) {
if (previousStyles.has(/** @type {Element} */ (node))) {
// Recursive
return null;
}
nextElement = /** @type {Element} */ (node);
continue;
}
break;
}
try {
const range = document.createRange();
range.setStart(node, offset);
range.setEnd(node, offset);
return range;
} catch (e) {
// Firefox throws new DOMException("The operation is insecure.")
// when trying to select a node from within a ShadowRoot.
return null;
}
}
} finally {
this._revertStyles(previousStyles);
}
}
/**
* @param {number} x
* @param {number} y
* @param {Element[]} elements
* @param {boolean} normalizeCssZoom
* @param {?string} language
* @returns {?Range}
*/
_caretRangeFromPointExt(x, y, elements, normalizeCssZoom, language) {
/** @type {?Map<Element, ?string>} */
let previousStyles = null;
try {
let i = 0;
let startContainerPre = null;
while (true) {
const range = this._caretRangeFromPoint(x, y);
if (range === null) {
return null;
}
const startContainer = range.startContainer;
if (startContainerPre !== startContainer) {
if (this._isPointInRange(x, y, range, normalizeCssZoom, language)) {
return range;
}
startContainerPre = startContainer;
}
if (previousStyles === null) { previousStyles = new Map(); }
i = this._disableTransparentElement(elements, i, previousStyles);
if (i < 0) {
return null;
}
}
} finally {
if (previousStyles !== null && previousStyles.size > 0) {
this._revertStyles(previousStyles);
}
}
}
/**
* @param {Element[]} elements
* @param {number} i
* @param {Map<Element, ?string>} previousStyles
* @returns {number}
*/
_disableTransparentElement(elements, i, previousStyles) {
while (true) {
if (i >= elements.length) {
return -1;
}
const element = elements[i++];
if (this._isElementTransparent(element)) {
if (element instanceof HTMLElement) {
this._recordPreviousStyle(previousStyles, element);
element.style.setProperty('pointer-events', 'none', 'important');
}
return i;
}
}
}
/**
* @param {Map<Element, ?string>} previousStyles
* @param {Element} element
*/
_recordPreviousStyle(previousStyles, element) {
if (previousStyles.has(element)) { return; }
const style = element.hasAttribute('style') ? element.getAttribute('style') : null;
previousStyles.set(element, style);
}
/**
* @param {Map<Element, ?string>} previousStyles
*/
_revertStyles(previousStyles) {
for (const [element, style] of previousStyles.entries()) {
if (style === null) {
element.removeAttribute('style');
} else {
element.setAttribute('style', style);
}
}
}
/**
* @param {Element} element
* @returns {boolean}
*/
_isElementTransparent(element) {
if (
element === document.body ||
element === document.documentElement
) {
return false;
}
const style = window.getComputedStyle(element);
return (
Number.parseFloat(style.opacity) <= 0 ||
style.visibility === 'hidden' ||
(style.backgroundImage === 'none' && this._isColorTransparent(style.backgroundColor))
);
}
/**
* @param {string} cssColor
* @returns {boolean}
*/
_isColorTransparent(cssColor) {
return this._transparentColorPattern.test(cssColor);
}
/**
* @param {Element} element
* @returns {boolean}
*/
_isElementUserSelectAll(element) {
return getComputedStyle(element).userSelect === 'all';
}
/**
* @param {string} string
* @returns {boolean}
*/
_isWhitespace(string) {
return string.trim().length === 0;
}
}

View File

@@ -0,0 +1,318 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2016-2022 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/>.
*/
import {toError} from '../core/to-error.js';
import {convertMultipleRectZoomCoordinates, convertRectZoomCoordinates, getElementWritingMode, getNodesInRange, offsetDOMRects} from './document-util.js';
import {DOMTextScanner} from './dom-text-scanner.js';
/**
* This class represents a text source that comes from text nodes in the document.
* Sometimes a temporary "imposter" element is created and used to store the text.
* This element is typically hidden from the page and removed after scanning has completed.
*/
export class TextSourceRange {
/**
* Creates a new instance of the class.
* @param {Range} range The selection range.
* @param {number} rangeStartOffset The `startOffset` of the range. This is somewhat redundant
* with the `range` parameter, but it is used when for when imposter elements are removed.
* @param {string} content The `toString()` value of the range. This is somewhat redundant
* with the `range` parameter, but it is used when for when imposter elements are removed.
* @param {?Element} imposterElement The temporary imposter element.
* @param {?Element} imposterSourceElement The source element which the imposter is imitating.
* Must not be `null` if imposterElement is specified.
* @param {?DOMRect[]} cachedRects A set of cached `DOMRect`s representing the rects of the text source,
* which can be used after the imposter element is removed from the page.
* Must not be `null` if imposterElement is specified.
* @param {?DOMRect} cachedSourceRect A cached `DOMRect` representing the rect of the `imposterSourceElement`,
* which can be used after the imposter element is removed from the page.
* Must not be `null` if imposterElement is specified.
* @param {boolean} disallowExpandSelection
*/
constructor(range, rangeStartOffset, content, imposterElement, imposterSourceElement, cachedRects, cachedSourceRect, disallowExpandSelection) {
/** @type {Range} */
this._range = range;
/** @type {number} */
this._rangeStartOffset = rangeStartOffset;
/** @type {string} */
this._content = content;
/** @type {?Element} */
this._imposterElement = imposterElement;
/** @type {?Element} */
this._imposterSourceElement = imposterSourceElement;
/** @type {?DOMRect[]} */
this._cachedRects = cachedRects;
/** @type {?DOMRect} */
this._cachedSourceRect = cachedSourceRect;
/** @type {boolean} */
this._disallowExpandSelection = disallowExpandSelection;
}
/**
* Gets the type name of this instance.
* @type {'range'}
*/
get type() {
return 'range';
}
/**
* The internal range object.
* @type {Range}
*/
get range() {
return this._range;
}
/**
* The starting offset for the range.
* @type {number}
*/
get rangeStartOffset() {
return this._rangeStartOffset;
}
/**
* The source element that the imposter element is imitating, if present.
* @type {?Element}
*/
get imposterSourceElement() {
return this._imposterSourceElement;
}
/**
* Creates a clone of the instance.
* @returns {TextSourceRange} The new clone.
*/
clone() {
return new TextSourceRange(
this._range.cloneRange(),
this._rangeStartOffset,
this._content,
this._imposterElement,
this._imposterSourceElement,
this._cachedRects,
this._cachedSourceRect,
this._disallowExpandSelection,
);
}
/**
* Performs any cleanup that is necessary after the element has been used.
*/
cleanup() {
if (this._imposterElement !== null && this._imposterElement.parentNode !== null) {
this._imposterElement.parentNode.removeChild(this._imposterElement);
}
}
/**
* Gets the selected text of element, which is the `toString()` version of the range.
* @returns {string} The text content.
*/
text() {
return this._content;
}
/**
* Moves the end offset of the text by a set amount of unicode codepoints.
* @param {number} length The maximum number of codepoints to move by.
* @param {boolean} fromEnd Whether to move the offset from the current end position (if `true`) or the start position (if `false`).
* @param {boolean} layoutAwareScan Whether or not HTML layout information should be used to generate
* the string content when scanning.
* @returns {number} The actual number of codepoints that were read.
*/
setEndOffset(length, fromEnd, layoutAwareScan) {
if (this._disallowExpandSelection) { return 0; }
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);
const expandedContent = fromEnd ? this._content + state.content : state.content;
this._content = expandedContent;
return length - state.remainder;
}
/**
* 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, stopAtWordBoundary = false) {
if (this._disallowExpandSelection) { return 0; }
let state = new DOMTextScanner(this._range.startContainer, this._range.startOffset, !layoutAwareScan, layoutAwareScan, stopAtWordBoundary);
state = state.seek(-length);
this._range.setStart(state.node, state.offset);
this._rangeStartOffset = this._range.startOffset;
this._content = state.content + this._content;
return length - state.remainder;
}
/**
* Gets the rects that represent the position and bounds of the text source.
* @returns {DOMRect[]} The rects.
*/
getRects() {
if (this._isImposterDisconnected()) { return this._getCachedRects(); }
return convertMultipleRectZoomCoordinates(this._range.getClientRects(), this._range.startContainer);
}
/**
* Gets writing mode that is used for this element.
* See: https://developer.mozilla.org/en-US/docs/Web/CSS/writing-mode.
* @returns {import('document-util').NormalizedWritingMode} The writing mode.
*/
getWritingMode() {
let node = this._isImposterDisconnected() ? this._imposterSourceElement : this._range.startContainer;
if (node !== null && node.nodeType !== Node.ELEMENT_NODE) { node = node.parentElement; }
return getElementWritingMode(/** @type {?Element} */ (node));
}
/**
* Selects the text source in the document.
*/
select() {
if (this._imposterElement !== null) { return; }
const selection = window.getSelection();
if (selection === null) { return; }
selection.removeAllRanges();
selection.addRange(this._range);
}
/**
* Deselects the text source in the document.
*/
deselect() {
if (this._imposterElement !== null) { return; }
const selection = window.getSelection();
if (selection === null) { return; }
selection.removeAllRanges();
}
/**
* Checks whether another text source has the same starting point.
* @param {import('text-source').TextSource} other The other source to test.
* @returns {boolean} `true` if the starting points are equivalent, `false` otherwise.
* @throws {Error} An exception can be thrown if `Range.compareBoundaryPoints` fails,
* which shouldn't happen, but the handler is kept in case of unexpected errors.
*/
hasSameStart(other) {
if (!(
typeof other === 'object' &&
other !== null &&
other instanceof TextSourceRange
)) {
return false;
}
if (this._imposterSourceElement !== null) {
return (
this._imposterSourceElement === other.imposterSourceElement &&
this._rangeStartOffset === other.rangeStartOffset
);
} else {
try {
return this._range.compareBoundaryPoints(Range.START_TO_START, other.range) === 0;
} catch (e) {
if (toError(e).name === 'WrongDocumentError') {
// This can happen with shadow DOMs if the ranges are in different documents.
return false;
}
throw e;
}
}
}
/**
* Gets a list of the nodes in this text source's range.
* @returns {Node[]} The nodes in the range.
*/
getNodesInRange() {
return getNodesInRange(this._range);
}
/**
* Creates a new instance for a given range.
* @param {Range} range The source range.
* @returns {TextSourceRange} A new instance of the class corresponding to the range.
*/
static create(range) {
return new TextSourceRange(range, range.startOffset, range.toString(), null, null, null, null, false);
}
/**
* Creates a new instance for a given range without expanding the search.
* @param {Range} range The source range.
* @returns {TextSourceRange} A new instance of the class corresponding to the range.
*/
static createLazy(range) {
return new TextSourceRange(range, range.startOffset, range.toString(), null, null, null, null, true);
}
/**
* Creates a new instance for a given range using an imposter element.
* @param {Range} range The source range.
* @param {Element} imposterElement The temporary imposter element.
* @param {Element} imposterSourceElement The source element which the imposter is imitating.
* @returns {TextSourceRange} A new instance of the class corresponding to the range.
*/
static createFromImposter(range, imposterElement, imposterSourceElement) {
const cachedRects = convertMultipleRectZoomCoordinates(range.getClientRects(), range.startContainer);
const cachedSourceRect = convertRectZoomCoordinates(imposterSourceElement.getBoundingClientRect(), imposterSourceElement);
return new TextSourceRange(range, range.startOffset, range.toString(), imposterElement, imposterSourceElement, cachedRects, cachedSourceRect, false);
}
/**
* Checks whether the imposter element has been removed, if the instance is using one.
* @returns {boolean} `true` if the instance has an imposter and it's no longer connected to the document, `false` otherwise.
*/
_isImposterDisconnected() {
return this._imposterElement !== null && !this._imposterElement.isConnected;
}
/**
* Gets the cached rects for a disconnected imposter element.
* @returns {DOMRect[]} The rects for the element.
* @throws {Error}
*/
_getCachedRects() {
if (
this._cachedRects === null ||
this._cachedSourceRect === null ||
this._imposterSourceElement === null
) {
throw new Error('Cached rects not valid for this instance');
}
const sourceRect = convertRectZoomCoordinates(this._imposterSourceElement.getBoundingClientRect(), this._imposterSourceElement);
return offsetDOMRects(
this._cachedRects,
sourceRect.left - this._cachedSourceRect.left,
sourceRect.top - this._cachedSourceRect.top,
);
}
}