mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
504 lines
18 KiB
JavaScript
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'
|
|
);
|
|
}
|