Files
SubMiner/vendor/yomitan/js/dom/document-util.js
2026-02-09 19:04:19 -08:00

504 lines
18 KiB
JavaScript

/*
* 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'
);
}