mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-01 06:22:44 -08:00
initial commit
This commit is contained in:
203
vendor/yomitan/js/dom/css-style-applier.js
vendored
Normal file
203
vendor/yomitan/js/dom/css-style-applier.js
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
151
vendor/yomitan/js/dom/document-focus-controller.js
vendored
Normal file
151
vendor/yomitan/js/dom/document-focus-controller.js
vendored
Normal 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
503
vendor/yomitan/js/dom/document-util.js
vendored
Normal 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
319
vendor/yomitan/js/dom/dom-data-binder.js
vendored
Normal 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';
|
||||
}
|
||||
}
|
||||
625
vendor/yomitan/js/dom/dom-text-scanner.js
vendored
Normal file
625
vendor/yomitan/js/dom/dom-text-scanner.js
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
123
vendor/yomitan/js/dom/html-template-collection.js
vendored
Normal file
123
vendor/yomitan/js/dom/html-template-collection.js
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
105
vendor/yomitan/js/dom/native-simple-dom-parser.js
vendored
Normal file
105
vendor/yomitan/js/dom/native-simple-dom-parser.js
vendored
Normal 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
138
vendor/yomitan/js/dom/panel-element.js
vendored
Normal 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
284
vendor/yomitan/js/dom/popup-menu.js
vendored
Normal 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
43
vendor/yomitan/js/dom/query-selector.js
vendored
Normal 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
165
vendor/yomitan/js/dom/scroll-element.js
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
300
vendor/yomitan/js/dom/selector-observer.js
vendored
Normal file
300
vendor/yomitan/js/dom/selector-observer.js
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
184
vendor/yomitan/js/dom/simple-dom-parser.js
vendored
Normal file
184
vendor/yomitan/js/dom/simple-dom-parser.js
vendored
Normal 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
132
vendor/yomitan/js/dom/style-util.js
vendored
Normal 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;
|
||||
}
|
||||
262
vendor/yomitan/js/dom/text-source-element.js
vendored
Normal file
262
vendor/yomitan/js/dom/text-source-element.js
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
690
vendor/yomitan/js/dom/text-source-generator.js
vendored
Normal file
690
vendor/yomitan/js/dom/text-source-generator.js
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
318
vendor/yomitan/js/dom/text-source-range.js
vendored
Normal file
318
vendor/yomitan/js/dom/text-source-range.js
vendored
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user