summaryrefslogtreecommitdiff
path: root/ext/js/dom
diff options
context:
space:
mode:
authorDarius Jahandarie <djahandarie@gmail.com>2023-12-06 03:53:16 +0000
committerGitHub <noreply@github.com>2023-12-06 03:53:16 +0000
commitbd5bc1a5db29903bc098995cd9262c4576bf76af (patch)
treec9214189e0214480fcf6539ad1c6327aef6cbd1c /ext/js/dom
parentfd6bba8a2a869eaf2b2c1fa49001f933fce3c618 (diff)
parent23e6fb76319c9ed7c9bcdc3efba39bc5dd38f288 (diff)
Merge pull request #339 from toasted-nutbread/type-annotations
Type annotations
Diffstat (limited to 'ext/js/dom')
-rw-r--r--ext/js/dom/document-focus-controller.js22
-rw-r--r--ext/js/dom/document-util.js293
-rw-r--r--ext/js/dom/dom-data-binder.js144
-rw-r--r--ext/js/dom/dom-text-scanner.js54
-rw-r--r--ext/js/dom/html-template-collection.js39
-rw-r--r--ext/js/dom/native-simple-dom-parser.js74
-rw-r--r--ext/js/dom/panel-element.js34
-rw-r--r--ext/js/dom/popup-menu.js71
-rw-r--r--ext/js/dom/sandbox/css-style-applier.js43
-rw-r--r--ext/js/dom/scroll-element.js50
-rw-r--r--ext/js/dom/selector-observer.js113
-rw-r--r--ext/js/dom/simple-dom-parser.js121
-rw-r--r--ext/js/dom/text-source-element.js44
-rw-r--r--ext/js/dom/text-source-range.js36
14 files changed, 855 insertions, 283 deletions
diff --git a/ext/js/dom/document-focus-controller.js b/ext/js/dom/document-focus-controller.js
index 501d1fad..32ea2ce8 100644
--- a/ext/js/dom/document-focus-controller.js
+++ b/ext/js/dom/document-focus-controller.js
@@ -29,7 +29,9 @@ export class DocumentFocusController {
* 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');
}
@@ -46,7 +48,7 @@ export class DocumentFocusController {
/**
* Removes focus from a given element.
- * @param {Element} element The element to remove focus from.
+ * @param {HTMLElement} element The element to remove focus from.
*/
blurElement(element) {
if (document.activeElement !== element) { return; }
@@ -56,10 +58,14 @@ export class DocumentFocusController {
// Private
+ /** */
_onWindowFocus() {
this._updateFocusedElement(false);
}
+ /**
+ * @param {boolean} force
+ */
_updateFocusedElement(force) {
const target = this._contentScrollFocusElement;
if (target === null) { return; }
@@ -73,6 +79,7 @@ export class DocumentFocusController {
) {
// 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.
@@ -86,6 +93,10 @@ export class DocumentFocusController {
}
}
+ /**
+ * @param {Selection} selection
+ * @returns {Range[]}
+ */
_getSelectionRanges(selection) {
const ranges = [];
for (let i = 0, ii = selection.rangeCount; i < ii; ++i) {
@@ -94,6 +105,10 @@ export class DocumentFocusController {
return ranges;
}
+ /**
+ * @param {Selection} selection
+ * @param {Range[]} ranges
+ */
_setSelectionRanges(selection, ranges) {
selection.removeAllRanges();
for (const range of ranges) {
@@ -101,6 +116,11 @@ export class DocumentFocusController {
}
}
+ /**
+ * @param {Range[]} ranges1
+ * @param {Range[]} ranges2
+ * @returns {boolean}
+ */
_areRangesSame(ranges1, ranges2) {
const ii = ranges1.length;
if (ii !== ranges2.length) {
diff --git a/ext/js/dom/document-util.js b/ext/js/dom/document-util.js
index d379192c..b2aa8f81 100644
--- a/ext/js/dom/document-util.js
+++ b/ext/js/dom/document-util.js
@@ -16,7 +16,6 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
-import {EventListenerCollection} from '../core.js';
import {DOMTextScanner} from './dom-text-scanner.js';
import {TextSourceElement} from './text-source-element.js';
import {TextSourceRange} from './text-source-range.js';
@@ -26,29 +25,11 @@ import {TextSourceRange} from './text-source-range.js';
*/
export class DocumentUtil {
/**
- * Options to configure how element detection is performed.
- * @typedef {object} GetRangeFromPointOptions
- * @property {boolean} deepContentScan Whether or deep content scanning should be performed. When deep content scanning is enabled,
- * some transparent overlay elements will be ignored when looking for the element at the input position.
- * @property {boolean} normalizeCssZoom Whether or not zoom coordinates should be normalized.
- */
-
- /**
- * 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).
- * @callback GetRangeFromPointHandler
- * @param {number} x The x coordinate to search at.
- * @param {number} y The y coordinate to search at.
- * @param {GetRangeFromPointOptions} options Options to configure how element detection is performed.
- * @returns {?TextSourceRange|TextSourceElement} A range for the hovered text or element, or `null` if no applicable content was found.
- */
-
- /**
* 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 {GetRangeFromPointOptions} options Options to configure how element detection is performed.
+ * @param {import('document-util').GetRangeFromPointOptions} options Options to configure how element detection is performed.
* @returns {?TextSourceRange|TextSourceElement} A range for the hovered text or element, or `null` if no applicable content was found.
*/
static getRangeFromPoint(x, y, options) {
@@ -60,8 +41,11 @@ export class DocumentUtil {
const {deepContentScan, normalizeCssZoom} = 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];
@@ -71,14 +55,14 @@ export class DocumentUtil {
case 'SELECT':
return TextSourceElement.create(element);
case 'INPUT':
- if (element.type === 'text') {
+ if (/** @type {HTMLInputElement} */ (element).type === 'text') {
imposterSourceElement = element;
- [imposter, imposterContainer] = this._createImposter(element, false);
+ [imposter, imposterContainer] = this._createImposter(/** @type {HTMLInputElement} */ (element), false);
}
break;
case 'TEXTAREA':
imposterSourceElement = element;
- [imposter, imposterContainer] = this._createImposter(element, true);
+ [imposter, imposterContainer] = this._createImposter(/** @type {HTMLTextAreaElement} */ (element), true);
break;
}
}
@@ -86,14 +70,17 @@ export class DocumentUtil {
const range = this._caretRangeFromPointExt(x, y, deepContentScan ? elements : [], normalizeCssZoom);
if (range !== null) {
if (imposter !== null) {
- this._setImposterStyle(imposterContainer.style, 'z-index', '-2147483646');
+ this._setImposterStyle(/** @type {HTMLDivElement} */ (imposterContainer).style, 'z-index', '-2147483646');
this._setImposterStyle(imposter.style, 'pointer-events', 'none');
- return TextSourceRange.createFromImposter(range, imposterContainer, imposterSourceElement);
+ return TextSourceRange.createFromImposter(range, /** @type {HTMLDivElement} */ (imposterContainer), /** @type {HTMLElement} */ (imposterSourceElement));
}
return TextSourceRange.create(range);
} else {
if (imposterContainer !== null) {
- imposterContainer.parentNode.removeChild(imposterContainer);
+ const {parentNode} = imposterContainer;
+ if (parentNode !== null) {
+ parentNode.removeChild(imposterContainer);
+ }
}
return null;
}
@@ -101,7 +88,7 @@ export class DocumentUtil {
/**
* Registers a custom handler for scanning for text or elements at the input position.
- * @param {GetRangeFromPointHandler} handler The handler callback which will be invoked when calling `getRangeFromPoint`.
+ * @param {import('document-util').GetRangeFromPointHandler} handler The handler callback which will be invoked when calling `getRangeFromPoint`.
*/
static registerGetRangeFromPointHandler(handler) {
this._getRangeFromPointHandlers.push(handler);
@@ -129,7 +116,7 @@ export class DocumentUtil {
* ```js
* new Map([ [character: string, [otherCharacter: string, includeCharacterAtEnd: boolean]], ... ])
* ```
- * @returns {{sentence: string, offset: number}} The sentence and the offset to the original source.
+ * @returns {{text: string, offset: number}} The sentence and the offset to the original source.
*/
static extractSentence(source, layoutAwareScan, extent, terminateAtNewlines, terminatorMap, forwardQuoteMap, backwardQuoteMap) {
// Scan text
@@ -218,11 +205,12 @@ export class DocumentUtil {
/**
* 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.
+ * @param {?Node} node A node in the document.
* @returns {number} The scaling factor.
*/
static computeZoomScale(node) {
if (this._cssZoomSupported === null) {
+ // @ts-expect-error - zoom is a non-standard property that exists in Chromium-based browsers
this._cssZoomSupported = (typeof document.createElement('div').style.zoom === 'string');
}
if (!this._cssZoomSupported) { return 1; }
@@ -237,7 +225,7 @@ export class DocumentUtil {
for (; node !== null && node !== documentElement; node = node.parentNode) {
const {nodeType} = node;
if (nodeType === DOCUMENT_FRAGMENT_NODE) {
- const {host} = node;
+ const {host} = /** @type {ShadowRoot} */ (node);
if (typeof host !== 'undefined') {
node = host;
}
@@ -245,9 +233,9 @@ export class DocumentUtil {
} else if (nodeType !== ELEMENT_NODE) {
continue;
}
- let {zoom} = getComputedStyle(node);
- if (typeof zoom !== 'string') { continue; }
- zoom = Number.parseFloat(zoom);
+ 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;
}
@@ -267,13 +255,13 @@ export class DocumentUtil {
/**
* Converts multiple rects based on the CSS zoom scaling for a given node.
- * @param {DOMRect[]} rects The rects to convert.
+ * @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.
*/
static convertMultipleRectZoomCoordinates(rects, node) {
const scale = this.computeZoomScale(node);
- if (scale === 1) { return rects; }
+ 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));
@@ -299,7 +287,7 @@ export class DocumentUtil {
* 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[]} rects The rect to check.
+ * @param {DOMRect[]|DOMRectList} rects The rect to check.
* @returns {boolean} `true` if the point is inside any of the rects, `false` otherwise.
*/
static isPointInAnyRect(x, y, rects) {
@@ -331,9 +319,10 @@ export class DocumentUtil {
/**
* Gets an array of the active modifier keys.
* @param {KeyboardEvent|MouseEvent|TouchEvent} event The event to check.
- * @returns {string[]} An array of modifiers.
+ * @returns {import('input').ModifierKey[]} An array of modifiers.
*/
static getActiveModifiers(event) {
+ /** @type {import('input').ModifierKey[]} */
const modifiers = [];
if (event.altKey) { modifiers.push('alt'); }
if (event.ctrlKey) { modifiers.push('ctrl'); }
@@ -345,20 +334,24 @@ export class DocumentUtil {
/**
* Gets an array of the active modifier keys and buttons.
* @param {KeyboardEvent|MouseEvent|TouchEvent} event The event to check.
- * @returns {string[]} An array of modifiers and buttons.
+ * @returns {import('input').Modifier[]} An array of modifiers and buttons.
*/
static getActiveModifiersAndButtons(event) {
+ /** @type {import('input').Modifier[]} */
const modifiers = this.getActiveModifiers(event);
- this._getActiveButtons(event, modifiers);
+ if (event instanceof MouseEvent) {
+ this._getActiveButtons(event, modifiers);
+ }
return modifiers;
}
/**
* Gets an array of the active buttons.
- * @param {KeyboardEvent|MouseEvent|TouchEvent} event The event to check.
- * @returns {string[]} An array of modifiers and buttons.
+ * @param {MouseEvent} event The event to check.
+ * @returns {import('input').ModifierMouseButton[]} An array of modifiers and buttons.
*/
static getActiveButtons(event) {
+ /** @type {import('input').ModifierMouseButton[]} */
const buttons = [];
this._getActiveButtons(event, buttons);
return buttons;
@@ -366,8 +359,8 @@ export class DocumentUtil {
/**
* Adds a fullscreen change event listener. This function handles all of the browser-specific variants.
- * @param {Function} onFullscreenChanged The event callback.
- * @param {?EventListenerCollection} eventListenerCollection An optional `EventListenerCollection` to add the registration to.
+ * @param {EventListener} onFullscreenChanged The event callback.
+ * @param {?import('../core.js').EventListenerCollection} eventListenerCollection An optional `EventListenerCollection` to add the registration to.
*/
static addFullscreenChangeEventListener(onFullscreenChanged, eventListenerCollection=null) {
const target = document;
@@ -394,8 +387,11 @@ export class DocumentUtil {
static 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
);
@@ -409,7 +405,7 @@ export class DocumentUtil {
static getNodesInRange(range) {
const end = range.endContainer;
const nodes = [];
- for (let node = range.startContainer; node !== null; node = this.getNextNode(node)) {
+ for (let node = /** @type {?Node} */ (range.startContainer); node !== null; node = this.getNextNode(node)) {
nodes.push(node);
if (node === end) { break; }
}
@@ -422,7 +418,7 @@ export class DocumentUtil {
* @returns {?Node} The next node, or `null` if there is no next node.
*/
static getNextNode(node) {
- let next = node.firstChild;
+ let next = /** @type {?Node} */ (node.firstChild);
if (next === null) {
while (true) {
next = node.nextSibling;
@@ -445,10 +441,14 @@ export class DocumentUtil {
*/
static anyNodeMatchesSelector(nodes, selector) {
const ELEMENT_NODE = Node.ELEMENT_NODE;
- for (let node of nodes) {
- for (; node !== null; node = node.parentNode) {
- if (node.nodeType !== ELEMENT_NODE) { continue; }
- if (node.matches(selector)) { return true; }
+ // 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;
}
}
@@ -463,10 +463,11 @@ export class DocumentUtil {
*/
static everyNodeMatchesSelector(nodes, selector) {
const ELEMENT_NODE = Node.ELEMENT_NODE;
- for (let node of nodes) {
+ // 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 && node.matches(selector)) { break; }
+ if (node.nodeType === ELEMENT_NODE && /** @type {HTMLElement} */ (node).matches(selector)) { break; }
node = node.parentNode;
}
}
@@ -497,7 +498,7 @@ export class DocumentUtil {
case 'SELECT':
return true;
default:
- return element.isContentEditable;
+ return element instanceof HTMLElement && element.isContentEditable;
}
}
@@ -506,7 +507,7 @@ export class DocumentUtil {
* @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.
+ * @returns {DOMRect[]} The DOMRects with the offset applied.
*/
static offsetDOMRects(rects, x, y) {
const results = [];
@@ -519,8 +520,8 @@ export class DocumentUtil {
/**
* 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 {string} The writing mode.
+ * @param {?Element} element The HTML element to check.
+ * @returns {import('document-util').NormalizedWritingMode} The writing mode.
*/
static getElementWritingMode(element) {
if (element !== null) {
@@ -536,52 +537,95 @@ export class DocumentUtil {
* 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 {string} The normalized writing mode.
+ * @returns {import('document-util').NormalizedWritingMode} The normalized writing mode.
*/
static normalizeWritingMode(writingMode) {
switch (writingMode) {
- case 'lr':
- case 'lr-tb':
- case 'rl':
- return 'horizontal-tb';
case 'tb':
return 'vertical-lr';
case 'tb-rl':
return 'vertical-rl';
- default:
+ 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} value A string representation of a number.
- * @param {object} constraints An object which might contain `min`, `max`, and `step` fields which are used to constrain the value.
+ * @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.
*/
- static convertElementValueToNumber(value, constraints) {
- value = parseFloat(value);
+ static convertElementValueToNumber(valueString, constraints) {
+ let value = Number.parseFloat(valueString);
if (!Number.isFinite(value)) { value = 0; }
- let {min, max, step} = constraints;
- min = this._convertToNumberOrNull(min);
- max = this._convertToNumberOrNull(max);
- step = this._convertToNumberOrNull(step);
+ const min = this._convertToNumberOrNull(constraints.min);
+ const max = this._convertToNumberOrNull(constraints.max);
+ const step = this._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}
+ */
+ static 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}
+ */
+ static normalizeModifierKey(value) {
+ switch (value) {
+ case 'alt':
+ case 'ctrl':
+ case 'meta':
+ case 'shift':
+ return value;
+ default:
+ return null;
+ }
+ }
+
// Private
+ /**
+ * @param {MouseEvent} event The event to check.
+ * @param {import('input').ModifierMouseButton[]|import('input').Modifier[]} array
+ */
static _getActiveButtons(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(`mouse${i}`);
+ array.push(/** @type {import('input').ModifierMouseButton} */ (`mouse${i}`));
buttons &= ~buttonFlag;
if (buttons === 0) { break; }
}
@@ -589,10 +633,20 @@ export class DocumentUtil {
}
}
+ /**
+ * @param {CSSStyleDeclaration} style
+ * @param {string} propertyName
+ * @param {string} value
+ */
static _setImposterStyle(style, propertyName, value) {
style.setProperty(propertyName, value, 'important');
}
+ /**
+ * @param {HTMLInputElement|HTMLTextAreaElement} element
+ * @param {boolean} isTextarea
+ * @returns {[imposter: ?HTMLDivElement, container: ?HTMLDivElement]}
+ */
static _createImposter(element, isTextarea) {
const body = document.body;
if (body === null) { return [null, null]; }
@@ -669,6 +723,12 @@ export class DocumentUtil {
return [imposter, container];
}
+ /**
+ * @param {number} x
+ * @param {number} y
+ * @param {boolean} all
+ * @returns {Element[]}
+ */
static _getElementsFromPoint(x, y, all) {
if (all) {
// document.elementsFromPoint can return duplicates which must be removed.
@@ -680,6 +740,13 @@ export class DocumentUtil {
return e !== null ? [e] : [];
}
+ /**
+ * @param {number} x
+ * @param {number} y
+ * @param {Range} range
+ * @param {boolean} normalizeCssZoom
+ * @returns {boolean}
+ */
static _isPointInRange(x, y, range, normalizeCssZoom) {
// Require a text node to start
const {startContainer} = range;
@@ -722,16 +789,26 @@ export class DocumentUtil {
return false;
}
+ /**
+ * @param {string} string
+ * @returns {boolean}
+ */
static _isWhitespace(string) {
return string.trim().length === 0;
}
+ /**
+ * @param {number} x
+ * @param {number} y
+ * @returns {?Range}
+ */
static _caretRangeFromPoint(x, y) {
if (typeof document.caretRangeFromPoint === 'function') {
// Chrome, Edge
return document.caretRangeFromPoint(x, y);
}
+ // @ts-expect-error - caretPositionFromPoint is non-standard
if (typeof document.caretPositionFromPoint === 'function') {
// Firefox
return this._caretPositionFromPoint(x, y);
@@ -741,8 +818,14 @@ export class DocumentUtil {
return null;
}
+ /**
+ * @param {number} x
+ * @param {number} y
+ * @returns {?Range}
+ */
static _caretPositionFromPoint(x, y) {
- const position = document.caretPositionFromPoint(x, y);
+ // @ts-expect-error - caretPositionFromPoint is non-standard
+ const position = /** @type {(x: number, y: number) => ?{offsetNode: Node, offset: number}} */ (document.caretPositionFromPoint)(x, y);
if (position === null) {
return null;
}
@@ -760,8 +843,8 @@ export class DocumentUtil {
case Node.ELEMENT_NODE:
// Elements with user-select: all will return the element
// instead of a text point inside the element.
- if (this._isElementUserSelectAll(node)) {
- return this._caretPositionFromPointNormalizeStyles(x, y, node);
+ if (this._isElementUserSelectAll(/** @type {Element} */ (node))) {
+ return this._caretPositionFromPointNormalizeStyles(x, y, /** @type {Element} */ (node));
}
break;
}
@@ -778,14 +861,23 @@ export class DocumentUtil {
}
}
+ /**
+ * @param {number} x
+ * @param {number} y
+ * @param {Element} nextElement
+ * @returns {?Range}
+ */
static _caretPositionFromPointNormalizeStyles(x, y, nextElement) {
const previousStyles = new Map();
try {
while (true) {
- this._recordPreviousStyle(previousStyles, nextElement);
- nextElement.style.setProperty('user-select', 'text', 'important');
+ if (nextElement instanceof HTMLElement) {
+ this._recordPreviousStyle(previousStyles, nextElement);
+ nextElement.style.setProperty('user-select', 'text', 'important');
+ }
- const position = document.caretPositionFromPoint(x, y);
+ // @ts-expect-error - caretPositionFromPoint is non-standard
+ const position = /** @type {(x: number, y: number) => ?{offsetNode: Node, offset: number}} */ (document.caretPositionFromPoint)(x, y);
if (position === null) {
return null;
}
@@ -803,12 +895,12 @@ export class DocumentUtil {
case Node.ELEMENT_NODE:
// Elements with user-select: all will return the element
// instead of a text point inside the element.
- if (this._isElementUserSelectAll(node)) {
+ if (this._isElementUserSelectAll(/** @type {Element} */ (node))) {
if (previousStyles.has(node)) {
// Recursive
return null;
}
- nextElement = node;
+ nextElement = /** @type {Element} */ (node);
continue;
}
break;
@@ -830,6 +922,13 @@ export class DocumentUtil {
}
}
+ /**
+ * @param {number} x
+ * @param {number} y
+ * @param {Element[]} elements
+ * @param {boolean} normalizeCssZoom
+ * @returns {?Range}
+ */
static _caretRangeFromPointExt(x, y, elements, normalizeCssZoom) {
let previousStyles = null;
try {
@@ -862,6 +961,12 @@ export class DocumentUtil {
}
}
+ /**
+ * @param {Element[]} elements
+ * @param {number} i
+ * @param {Map<Element, ?string>} previousStyles
+ * @returns {number}
+ */
static _disableTransparentElement(elements, i, previousStyles) {
while (true) {
if (i >= elements.length) {
@@ -870,19 +975,28 @@ export class DocumentUtil {
const element = elements[i++];
if (this._isElementTransparent(element)) {
- this._recordPreviousStyle(previousStyles, element);
- element.style.setProperty('pointer-events', 'none', 'important');
+ 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
+ */
static _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
+ */
static _revertStyles(previousStyles) {
for (const [element, style] of previousStyles.entries()) {
if (style === null) {
@@ -893,6 +1007,10 @@ export class DocumentUtil {
}
}
+ /**
+ * @param {Element} element
+ * @returns {boolean}
+ */
static _isElementTransparent(element) {
if (
element === document.body ||
@@ -908,14 +1026,26 @@ export class DocumentUtil {
);
}
+ /**
+ * @param {string} cssColor
+ * @returns {boolean}
+ */
static _isColorTransparent(cssColor) {
return this._transparentColorPattern.test(cssColor);
}
+ /**
+ * @param {Element} element
+ * @returns {boolean}
+ */
static _isElementUserSelectAll(element) {
return getComputedStyle(element).userSelect === 'all';
}
+ /**
+ * @param {string|number|undefined} value
+ * @returns {?number}
+ */
static _convertToNumberOrNull(value) {
if (typeof value !== 'number') {
if (typeof value !== 'string' || value.length === 0) {
@@ -926,9 +1056,12 @@ export class DocumentUtil {
return !Number.isNaN(value) ? value : null;
}
}
+/** @type {RegExp} */
// eslint-disable-next-line no-underscore-dangle
DocumentUtil._transparentColorPattern = /rgba\s*\([^)]*,\s*0(?:\.0+)?\s*\)/;
+/** @type {?boolean} */
// eslint-disable-next-line no-underscore-dangle
DocumentUtil._cssZoomSupported = null;
+/** @type {import('document-util').GetRangeFromPointHandler[]} */
// eslint-disable-next-line no-underscore-dangle
DocumentUtil._getRangeFromPointHandlers = [];
diff --git a/ext/js/dom/dom-data-binder.js b/ext/js/dom/dom-data-binder.js
index 4da5b0c2..cf98a243 100644
--- a/ext/js/dom/dom-data-binder.js
+++ b/ext/js/dom/dom-data-binder.js
@@ -20,42 +20,66 @@ import {TaskAccumulator} from '../general/task-accumulator.js';
import {DocumentUtil} from './document-util.js';
import {SelectorObserver} from './selector-observer.js';
+/**
+ * @template [T=unknown]
+ */
export class DOMDataBinder {
+ /**
+ * @param {import('dom-data-binder').ConstructorDetails<T>} details
+ */
constructor({selector, createElementMetadata, compareElementMetadata, getValues, setValues, onError=null}) {
+ /** @type {string} */
this._selector = selector;
+ /** @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));
- this._selectorObserver = new SelectorObserver({
+ /** @type {SelectorObserver<import('dom-data-binder').ElementObserver<T>>} */
+ this._selectorObserver = /** @type {SelectorObserver<import('dom-data-binder').ElementObserver<T>>} */ (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) {
this._selectorObserver.observe(element, true);
}
+ /** */
disconnect() {
this._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) {
@@ -82,17 +106,29 @@ export class DOMDataBinder {
this._applyValues(targets, responses, true);
}
+ /**
+ * @param {import('dom-data-binder').AssignTask<T>[]} tasks
+ */
async _onBulkAssign(tasks) {
- const targets = tasks;
- const args = targets.map(([observer, task]) => ({
- element: observer.element,
- metadata: observer.metadata,
- value: task.data.value
- }));
+ /** @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;
@@ -100,9 +136,12 @@ export class DOMDataBinder {
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) {
- if (!Array.isArray(response)) { return; }
-
for (let i = 0, ii = targets.length; i < ii; ++i) {
const [observer, task] = targets[i];
const {error, result} = response[i];
@@ -123,8 +162,14 @@ export class DOMDataBinder {
}
}
+ /**
+ * @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; }
+ /** @type {import('dom-data-binder').ElementObserver<T>} */
const observer = {
element,
type: this._getNormalizedElementType(element),
@@ -137,76 +182,121 @@ export class DOMDataBinder {
element.addEventListener('change', observer.onChange, false);
- this._updateTasks.enqueue(observer);
+ 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('change', observer.onChange, false);
observer.onChange = null;
}
+ /**
+ * @param {Element} element
+ * @param {import('dom-data-binder').ElementObserver<T>} observer
+ */
_onObserverChildrenUpdated(element, observer) {
if (observer.hasValue) {
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;
- return !(
- type === this._getNormalizedElementType(element) &&
- this._compareElementMetadata(metadata, this._createElementMetadata(element))
- );
+ 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':
- element.checked = value;
+ /** @type {HTMLInputElement} */ (element).checked = typeof value === 'boolean' && value;
break;
case 'text':
case 'number':
case 'textarea':
case 'select':
- element.value = value;
+ /** @type {HTMLInputElement|HTMLTextAreaElement|HTMLSelectElement} */ (element).value = typeof value === 'string' ? value : `${value}`;
break;
}
- const event = new CustomEvent('settingChanged', {detail: {value}});
+ /** @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 !!element.checked;
+ return !!(/** @type {HTMLInputElement} */ (element).checked);
case 'text':
- return `${element.value}`;
+ return `${/** @type {HTMLInputElement} */ (element).value}`;
case 'number':
- return DocumentUtil.convertElementValueToNumber(element.value, element);
+ return DocumentUtil.convertElementValueToNumber(/** @type {HTMLInputElement} */ (element).value, /** @type {HTMLInputElement} */ (element));
case 'textarea':
+ return /** @type {HTMLTextAreaElement} */ (element).value;
case 'select':
- return element.value;
+ return /** @type {HTMLSelectElement} */ (element).value;
}
return null;
}
+ /**
+ * @param {Element} element
+ * @returns {import('dom-data-binder').NormalizedElementType}
+ */
_getNormalizedElementType(element) {
switch (element.nodeName.toUpperCase()) {
case 'INPUT':
{
- let {type} = element;
- if (type === 'password') { type = 'text'; }
- return type;
+ 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';
- default:
- return null;
}
+ return null;
}
}
diff --git a/ext/js/dom/dom-text-scanner.js b/ext/js/dom/dom-text-scanner.js
index ccd1c90b..42e0acc9 100644
--- a/ext/js/dom/dom-text-scanner.js
+++ b/ext/js/dom/dom-text-scanner.js
@@ -36,15 +36,25 @@ export class DOMTextScanner {
const resetOffset = (ruby !== null);
if (resetOffset) { node = ruby; }
+ /** @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} */
this._forcePreserveWhitespace = forcePreserveWhitespace;
+ /** @type {boolean} */
this._generateLayoutContent = generateLayoutContent;
}
@@ -99,8 +109,8 @@ export class DOMTextScanner {
const ELEMENT_NODE = Node.ELEMENT_NODE;
const generateLayoutContent = this._generateLayoutContent;
- let node = this._node;
- let lastNode = node;
+ let node = /** @type {?Node} */ (this._node);
+ let lastNode = /** @type {Node} */ (node);
let resetOffset = this._resetOffset;
let newlines = 0;
while (node !== null) {
@@ -111,8 +121,8 @@ export class DOMTextScanner {
lastNode = node;
if (!(
forward ?
- this._seekTextNodeForward(node, resetOffset) :
- this._seekTextNodeBackward(node, resetOffset)
+ this._seekTextNodeForward(/** @type {Text} */ (node), resetOffset) :
+ this._seekTextNodeBackward(/** @type {Text} */ (node), resetOffset)
)) {
// Length reached
break;
@@ -120,18 +130,19 @@ export class DOMTextScanner {
} else if (nodeType === ELEMENT_NODE) {
lastNode = node;
this._offset = 0;
- ({enterable, newlines} = DOMTextScanner.getElementSeekInfo(node));
+ ({enterable, newlines} = DOMTextScanner.getElementSeekInfo(/** @type {Element} */ (node)));
if (newlines > this._newlines && generateLayoutContent) {
this._newlines = newlines;
}
}
+ /** @type {Node[]} */
const exitedNodes = [];
node = DOMTextScanner.getNextNode(node, forward, enterable, exitedNodes);
for (const exitedNode of exitedNodes) {
if (exitedNode.nodeType !== ELEMENT_NODE) { continue; }
- ({newlines} = DOMTextScanner.getElementSeekInfo(exitedNode));
+ ({newlines} = DOMTextScanner.getElementSeekInfo(/** @type {Element} */ (exitedNode)));
if (newlines > this._newlines && generateLayoutContent) {
this._newlines = newlines;
}
@@ -155,7 +166,7 @@ export class DOMTextScanner {
* @returns {boolean} `true` if scanning should continue, or `false` if the scan length has been reached.
*/
_seekTextNodeForward(textNode, resetOffset) {
- const nodeValue = textNode.nodeValue;
+ const nodeValue = /** @type {string} */ (textNode.nodeValue);
const nodeValueLength = nodeValue.length;
const {preserveNewlines, preserveWhitespace} = this._getWhitespaceSettings(textNode);
@@ -241,7 +252,7 @@ export class DOMTextScanner {
* @returns {boolean} `true` if scanning should continue, or `false` if the scan length has been reached.
*/
_seekTextNodeBackward(textNode, resetOffset) {
- const nodeValue = textNode.nodeValue;
+ const nodeValue = /** @type {string} */ (textNode.nodeValue);
const nodeValueLength = nodeValue.length;
const {preserveNewlines, preserveWhitespace} = this._getWhitespaceSettings(textNode);
@@ -350,6 +361,7 @@ export class DOMTextScanner {
* @returns {?Node} The next node in the document, or `null` if there is no next node.
*/
static getNextNode(node, forward, visitChildren, exitedNodes) {
+ /** @type {?Node} */
let next = visitChildren ? (forward ? node.firstChild : node.lastChild) : null;
if (next === null) {
while (true) {
@@ -369,14 +381,17 @@ export class DOMTextScanner {
/**
* Gets the parent element of a given Node.
- * @param {Node} node The node to check.
- * @returns {?Node} The parent element if one exists, otherwise `null`.
+ * @param {?Node} node The node to check.
+ * @returns {?Element} The parent element if one exists, otherwise `null`.
*/
static getParentElement(node) {
- while (node !== null && node.nodeType !== Node.ELEMENT_NODE) {
+ while (node !== null) {
+ if (node.nodeType === Node.ELEMENT_NODE) {
+ return /** @type {Element} */ (node);
+ }
node = node.parentNode;
}
- return node;
+ return null;
}
/**
@@ -387,11 +402,12 @@ export class DOMTextScanner {
* @returns {?HTMLElement} A <ruby> node if the input node is contained in one, otherwise `null`.
*/
static getParentRubyElement(node) {
- node = DOMTextScanner.getParentElement(node);
- if (node !== null && node.nodeName.toUpperCase() === 'RT') {
- node = node.parentNode;
- if (node !== null && node.nodeName.toUpperCase() === 'RUBY') {
- return 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;
@@ -505,7 +521,9 @@ export class DOMTextScanner {
return !(
style.userSelect === 'none' ||
style.webkitUserSelect === 'none' ||
+ // @ts-expect-error - vendor prefix
style.MozUserSelect === 'none' ||
+ // @ts-expect-error - vendor prefix
style.msUserSelect === 'none'
);
}
@@ -513,7 +531,7 @@ export class DOMTextScanner {
/**
* 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 {false} `true` if the color is transparent, otherwise `false`.
+ * @returns {boolean} `true` if the color is transparent, otherwise `false`.
*/
static isCSSColorTransparent(cssColor) {
return (
diff --git a/ext/js/dom/html-template-collection.js b/ext/js/dom/html-template-collection.js
index 100ba55c..62d18224 100644
--- a/ext/js/dom/html-template-collection.js
+++ b/ext/js/dom/html-template-collection.js
@@ -17,9 +17,15 @@
*/
export class HtmlTemplateCollection {
- constructor(source) {
+ constructor() {
+ /** @type {Map<string, HTMLTemplateElement>} */
this._templates = new Map();
+ }
+ /**
+ * @param {string|Document} source
+ */
+ load(source) {
const sourceNode = (
typeof source === 'string' ?
new DOMParser().parseFromString(source, 'text/html') :
@@ -35,28 +41,53 @@ export class HtmlTemplateCollection {
}
}
+ /**
+ * @template {Element} T
+ * @param {string} name
+ * @returns {T}
+ * @throws {Error}
+ */
instantiate(name) {
const template = this._templates.get(name);
- return document.importNode(template.content.firstChild, true);
+ if (typeof template === 'undefined') { throw new Error(`Failed to find template: ${name}`); }
+ const {firstElementChild} = template.content;
+ 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}
+ * @throws {Error}
+ */
instantiateFragment(name) {
const template = this._templates.get(name);
- return document.importNode(template.content, true);
+ if (typeof template === 'undefined') { throw new Error(`Failed to find template: ${name}`); }
+ const {content} = template;
+ return document.importNode(content, true);
}
+ /**
+ * @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;
@@ -65,7 +96,7 @@ export class HtmlTemplateCollection {
while (true) {
const node = iterator.nextNode();
if (node === null) { break; }
- if (node.nodeType === TEXT_NODE && node.nodeValue.trim().length === 0) {
+ if (node.nodeType === TEXT_NODE && /** @type {string} */ (node.nodeValue).trim().length === 0) {
removeNodes.push(node);
}
}
diff --git a/ext/js/dom/native-simple-dom-parser.js b/ext/js/dom/native-simple-dom-parser.js
index 882469a0..418a4e3c 100644
--- a/ext/js/dom/native-simple-dom-parser.js
+++ b/ext/js/dom/native-simple-dom-parser.js
@@ -17,35 +17,89 @@
*/
export class NativeSimpleDOMParser {
+ /**
+ * @param {string} content
+ */
constructor(content) {
+ /** @type {Document} */
this._document = new DOMParser().parseFromString(content, 'text/html');
}
- getElementById(id, root=null) {
- return (root || this._document).querySelector(`[id='${id}']`);
+ /**
+ * @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}']`);
}
- getElementByTagName(tagName, root=null) {
- return (root || this._document).querySelector(tagName);
+ /**
+ * @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);
}
- getElementsByTagName(tagName, root=null) {
- return [...(root || this._document).querySelectorAll(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)];
}
- getElementsByClassName(className, root=null) {
- return [...(root || this._document).querySelectorAll(`.${className}`)];
+ /**
+ * @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) {
- return element.hasAttribute(attribute) ? element.getAttribute(attribute) : null;
+ const element2 = this._convertElement(element);
+ return element2.hasAttribute(attribute) ? element2.getAttribute(attribute) : null;
}
+ /**
+ * @param {import('simple-dom-parser').Element} element
+ * @returns {string}
+ */
getTextContent(element) {
- return element.textContent;
+ 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;
+ }
}
diff --git a/ext/js/dom/panel-element.js b/ext/js/dom/panel-element.js
index 9b056920..748c3a36 100644
--- a/ext/js/dom/panel-element.js
+++ b/ext/js/dom/panel-element.js
@@ -18,25 +18,45 @@
import {EventDispatcher} from '../core.js';
+/**
+ * @augments EventDispatcher<import('panel-element').EventType>
+ */
export class PanelElement extends EventDispatcher {
+ /**
+ * @param {import('panel-element').ConstructorDetails} details
+ */
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; }
@@ -63,6 +83,11 @@ export class PanelElement extends EventDispatcher {
}
}
+ /**
+ * @param {import('panel-element').EventType} eventName
+ * @param {(details: import('core').SafeAny) => void} callback
+ * @returns {void}
+ */
on(eventName, callback) {
if (eventName === 'visibilityChanged') {
if (this._mutationObserver === null) {
@@ -78,6 +103,11 @@ export class PanelElement extends EventDispatcher {
return super.on(eventName, callback);
}
+ /**
+ * @param {import('panel-element').EventType} eventName
+ * @param {(details: import('core').SafeAny) => void} callback
+ * @returns {boolean}
+ */
off(eventName, callback) {
const result = super.off(eventName, callback);
if (eventName === 'visibilityChanged' && !this.hasListeners(eventName)) {
@@ -91,6 +121,7 @@ export class PanelElement extends EventDispatcher {
// Private
+ /** */
_onMutation() {
const visible = this.isVisible();
if (this._visible === visible) { return; }
@@ -98,6 +129,9 @@ export class PanelElement extends EventDispatcher {
this.trigger('visibilityChanged', {visible});
}
+ /**
+ * @param {boolean} reopening
+ */
_completeClose(reopening) {
this._closeTimer = null;
this._node.classList.remove(this._hiddenAnimatingClass);
diff --git a/ext/js/dom/popup-menu.js b/ext/js/dom/popup-menu.js
index 7cae1dff..78394c93 100644
--- a/ext/js/dom/popup-menu.js
+++ b/ext/js/dom/popup-menu.js
@@ -18,38 +18,58 @@
import {EventDispatcher, EventListenerCollection} from '../core.js';
+/**
+ * @augments EventDispatcher<import('popup-menu').EventType>
+ */
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;
- this._node = containerNode.querySelector('.popup-menu');
- this._bodyNode = containerNode.querySelector('.popup-menu-body');
+ /** @type {HTMLElement} */
+ this._node = /** @type {HTMLElement} */ (containerNode.querySelector('.popup-menu'));
+ /** @type {HTMLElement} */
+ this._bodyNode = /** @type {HTMLElement} */ (containerNode.querySelector('.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();
@@ -61,17 +81,25 @@ export class PopupMenu extends EventDispatcher {
PopupMenu.openMenus.add(this);
+ /** @type {import('popup-menu').MenuOpenEventDetails} */
+ const detail = {menu: this};
+
this._sourceElement.dispatchEvent(new CustomEvent('menuOpen', {
bubbles: false,
cancelable: false,
- detail: {menu: this}
+ detail
}));
}
+ /**
+ * @param {boolean} [cancelable]
+ * @returns {boolean}
+ */
close(cancelable=true) {
- return this._close(null, 'close', cancelable, {});
+ return this._close(null, 'close', cancelable, null);
}
+ /** */
updateMenuItems() {
this._itemEventListeners.removeAllEventListeners();
const items = this._bodyNode.querySelectorAll('.popup-menu-item');
@@ -81,12 +109,16 @@ export class PopupMenu extends EventDispatcher {
}
}
+ /** */
updatePosition() {
this._setPosition();
}
// Private
+ /**
+ * @param {MouseEvent} e
+ */
_onMenuContainerClick(e) {
if (e.currentTarget !== e.target) { return; }
if (this._close(null, 'outside', true, e)) {
@@ -95,8 +127,11 @@ export class PopupMenu extends EventDispatcher {
}
}
+ /**
+ * @param {MouseEvent} e
+ */
_onMenuItemClick(e) {
- const item = e.currentTarget;
+ const item = /** @type {HTMLButtonElement} */ (e.currentTarget);
if (item.disabled) { return; }
if (this._close(item, 'item', true, e)) {
e.stopPropagation();
@@ -104,10 +139,12 @@ export class PopupMenu extends EventDispatcher {
}
}
+ /** */
_onWindowResize() {
- this._close(null, 'resize', true, {});
+ this._close(null, 'resize', true, null);
}
+ /** */
_setPosition() {
// Get flags
let horizontal = 1;
@@ -187,11 +224,29 @@ export class PopupMenu extends EventDispatcher {
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; }
- const action = (item !== null ? item.dataset.menuAction : null);
+ /** @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}
+ );
- const {altKey=false, ctrlKey=false, metaKey=false, shiftKey=false} = originalEvent;
+ /** @type {import('popup-menu').MenuCloseEventDetails} */
const detail = {
menu: this,
item,
diff --git a/ext/js/dom/sandbox/css-style-applier.js b/ext/js/dom/sandbox/css-style-applier.js
index 4f570bb7..ea36a02d 100644
--- a/ext/js/dom/sandbox/css-style-applier.js
+++ b/ext/js/dom/sandbox/css-style-applier.js
@@ -22,40 +22,20 @@
*/
export class CssStyleApplier {
/**
- * A CSS rule.
- * @typedef {object} CssRule
- * @property {string} selectors A CSS selector string representing one or more selectors.
- * @property {CssDeclaration[]} styles A list of CSS property and value pairs.
- */
-
- /**
- * A single CSS property declaration.
- * @typedef {object} CssDeclaration
- * @property {string} property A CSS property's name, using kebab-case.
- * @property {string} value The property's value.
- */
-
- /* eslint-disable jsdoc/check-line-alignment */
- /**
* Creates a new instance of the class.
- * @param {string} styleDataUrl The local URL to the JSON file containing the style rules.
- * The style rules should be of the format:
- * ```
- * [
- * {
- * selectors: [(selector:string)...],
- * styles: [
- * [(property:string), (value:string)]...
- * ]
- * }...
- * ]
- * ```
+ * @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-_]/;
}
/* eslint-enable jsdoc/check-line-alignment */
@@ -64,7 +44,8 @@ export class CssStyleApplier {
* Loads the data file for use.
*/
async prepare() {
- let rawData;
+ /** @type {import('css-style-applier').RawStyleData} */
+ let rawData = [];
try {
rawData = await this._fetchJsonAsset(this._styleDataUrl);
} catch (e) {
@@ -94,7 +75,7 @@ export class CssStyleApplier {
const elementStyles = [];
for (const element of elements) {
const className = element.getAttribute('class');
- if (className.length === 0) { continue; }
+ if (className === null || className.length === 0) { continue; }
let cssTextNew = '';
for (const {selectors, styles} of this._getCandidateCssRulesForClass(className)) {
if (!element.matches(selectors)) { continue; }
@@ -139,7 +120,7 @@ export class CssStyleApplier {
/**
* Gets an array of candidate CSS rules which might match a specific class.
* @param {string} className A whitespace-separated list of classes.
- * @returns {CssRule[]} An array of candidate CSS rules.
+ * @returns {import('css-style-applier').CssRule[]} An array of candidate CSS rules.
*/
_getCandidateCssRulesForClass(className) {
let rules = this._cachedRules.get(className);
@@ -159,7 +140,7 @@ export class CssStyleApplier {
/**
* Converts an array of CSS rules to a CSS string.
- * @param {CssRule[]} styles An array of CSS rules.
+ * @param {import('css-style-applier').CssDeclaration[]} styles An array of CSS rules.
* @returns {string} The CSS string.
*/
_getCssText(styles) {
diff --git a/ext/js/dom/scroll-element.js b/ext/js/dom/scroll-element.js
index 4b2ac70d..95f5ce5b 100644
--- a/ext/js/dom/scroll-element.js
+++ b/ext/js/dom/scroll-element.js
@@ -17,39 +17,68 @@
*/
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;
@@ -60,6 +89,7 @@ export class ScrollElement {
this._animationRequestId = window.requestAnimationFrame(this._requestAnimationFrameCallback);
}
+ /** */
stop() {
if (this._animationRequestId === null) {
return;
@@ -69,12 +99,18 @@ export class ScrollElement {
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);
@@ -91,6 +127,10 @@ export class ScrollElement {
this._animationRequestId = window.requestAnimationFrame(this._requestAnimationFrameCallback);
}
+ /**
+ * @param {number} t
+ * @returns {number}
+ */
_easeInOutCubic(t) {
if (t < 0.5) {
return (4.0 * t * t * t);
@@ -100,10 +140,20 @@ export class ScrollElement {
}
}
+ /**
+ * @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;
diff --git a/ext/js/dom/selector-observer.js b/ext/js/dom/selector-observer.js
index e0e3d4ff..2cf46543 100644
--- a/ext/js/dom/selector-observer.js
+++ b/ext/js/dom/selector-observer.js
@@ -18,55 +18,35 @@
/**
* Class which is used to observe elements matching a selector in specific element.
+ * @template T
*/
export class SelectorObserver {
/**
- * @function OnAddedCallback
- * @param {Element} element The element which was added.
- * @returns {*} Custom data which is assigned to element and passed to callbacks.
- */
-
- /**
- * @function OnRemovedCallback
- * @param {Element} element The element which was removed.
- * @param {*} data The custom data corresponding to the element.
- */
-
- /**
- * @function OnChildrenUpdatedCallback
- * @param {Element} element The element which had its children updated.
- * @param {*} data The custom data corresponding to the element.
- */
-
- /**
- * @function IsStaleCallback
- * @param {Element} element The element which had its children updated.
- * @param {*} data The custom data corresponding to the element.
- * @returns {boolean} Whether or not the data is stale for the element.
- */
-
- /**
* Creates a new instance.
- * @param {object} details The configuration for the object.
- * @param {string} details.selector A string CSS selector used to find elements.
- * @param {?string} details.ignoreSelector A string CSS selector used to filter elements, or `null` for no filtering.
- * @param {OnAddedCallback} details.onAdded A function which is invoked for each element that is added that matches the selector.
- * @param {?OnRemovedCallback} details.onRemoved A function which is invoked for each element that is removed, or `null`.
- * @param {?OnChildrenUpdatedCallback} details.onChildrenUpdated A function which is invoked for each element which has its children updated, or `null`.
- * @param {?IsStaleCallback} details.isStale A function which checks if the data is stale for a given element, or `null`.
- * If the element is stale, it will be removed and potentially re-added.
+ * @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;
}
@@ -100,9 +80,10 @@ export class SelectorObserver {
subtree: true
});
+ const {parentNode} = element;
this._onMutation([{
type: 'childList',
- target: element.parentNode,
+ target: parentNode !== null ? parentNode : element,
addedNodes: [element],
removedNodes: []
}]);
@@ -124,17 +105,19 @@ export class SelectorObserver {
/**
* Returns an iterable list of [element, data] pairs.
- * @yields A sequence 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) {
+ for (const {element, data} of this._elementMap.values()) {
yield [element, data];
}
}
/**
* Returns an iterable list of data for every element.
- * @yields A sequence of data values.
+ * @yields {T} A sequence of data values.
+ * @returns {Generator<T, void, unknown>}
*/
*datas() {
for (const {data} of this._elementMap.values()) {
@@ -144,6 +127,9 @@ export class SelectorObserver {
// Private
+ /**
+ * @param {(MutationRecord|import('selector-observer').MutationRecordLike)[]} mutationList
+ */
_onMutation(mutationList) {
for (const mutation of mutationList) {
switch (mutation.type) {
@@ -157,6 +143,9 @@ export class SelectorObserver {
}
}
+ /**
+ * @param {MutationRecord|import('selector-observer').MutationRecordLike} record
+ */
_onChildListMutation({addedNodes, removedNodes, target}) {
const selector = this._selector;
const ELEMENT_NODE = Node.ELEMENT_NODE;
@@ -171,10 +160,10 @@ export class SelectorObserver {
for (const node of addedNodes) {
if (node.nodeType !== ELEMENT_NODE) { continue; }
- if (node.matches(selector)) {
- this._createObserver(node);
+ if (/** @type {Element} */ (node).matches(selector)) {
+ this._createObserver(/** @type {Element} */ (node));
}
- for (const childNode of node.querySelectorAll(selector)) {
+ for (const childNode of /** @type {Element} */ (node).querySelectorAll(selector)) {
this._createObserver(childNode);
}
}
@@ -183,7 +172,7 @@ export class SelectorObserver {
this._onChildrenUpdated !== null &&
(addedNodes.length !== 0 || addedNodes.length !== 0)
) {
- for (let node = target; node !== null; node = node.parentNode) {
+ for (let node = /** @type {?Node} */ (target); node !== null; node = node.parentNode) {
const observer = this._elementMap.get(node);
if (typeof observer !== 'undefined') {
this._onObserverChildrenUpdated(observer);
@@ -192,9 +181,12 @@ export class SelectorObserver {
}
}
+ /**
+ * @param {MutationRecord|import('selector-observer').MutationRecordLike} record
+ */
_onAttributeMutation({target}) {
const selector = this._selector;
- const observers = this._elementAncestorMap.get(target);
+ const observers = this._elementAncestorMap.get(/** @type {Element} */ (target));
if (typeof observers !== 'undefined') {
for (const observer of observers) {
const element = observer.element;
@@ -208,15 +200,19 @@ export class SelectorObserver {
}
}
- if (target.matches(selector)) {
- this._createObserver(target);
+ 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};
@@ -232,6 +228,9 @@ export class SelectorObserver {
}
}
+ /**
+ * @param {import('selector-observer').Observer<T>} observer
+ */
_removeObserver(observer) {
const {element, ancestors, data} = observer;
@@ -252,26 +251,42 @@ export class SelectorObserver {
}
}
+ /**
+ * @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 = [];
- while (true) {
- results.push(node);
- if (node === root) { break; }
- node = node.parentNode;
- if (node === null) { break; }
+ let n = /** @type {?Node} */ (node);
+ while (n !== null) {
+ results.push(n);
+ if (n === root) { break; }
+ n = n.parentNode;
}
return results;
}
diff --git a/ext/js/dom/simple-dom-parser.js b/ext/js/dom/simple-dom-parser.js
index 3e84b783..bca1cd88 100644
--- a/ext/js/dom/simple-dom-parser.js
+++ b/ext/js/dom/simple-dom-parser.js
@@ -18,55 +18,91 @@
import * as parse5 from '../../lib/parse5.js';
+/**
+ * @augments import('simple-dom-parser').ISimpleDomParser
+ */
export class SimpleDOMParser {
+ /**
+ * @param {string} content
+ */
constructor(content) {
- this._document = parse5.parse(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;
}
- getElementById(id, root=null) {
+ /**
+ * @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 (typeof node.tagName === 'string' && this.getAttribute(node, 'id') === id) {
- return node;
- }
+ if (!this._treeAdapter.isElementNode(node) || this.getAttribute(node, 'id') !== id) { continue; }
+ return node;
}
return null;
}
- getElementByTagName(tagName, root=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 (node.tagName === tagName) {
- return node;
- }
+ if (!this._treeAdapter.isElementNode(node) || node.tagName !== tagName) { continue; }
+ return node;
}
return null;
}
- getElementsByTagName(tagName, root=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 (node.tagName === tagName) {
- results.push(node);
- }
+ if (!this._treeAdapter.isElementNode(node) || node.tagName !== tagName) { continue; }
+ results.push(node);
}
return results;
}
- getElementsByClassName(className, root=null) {
+ /**
+ * @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 (typeof node.tagName === 'string') {
- const nodeClassName = this.getAttribute(node, 'class');
- if (nodeClassName !== null && this._hasToken(nodeClassName, className)) {
- results.push(node);
- }
+ 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 element.attrs) {
+ for (const attr of /** @type {import('simple-dom-parser').Parse5Element} */ (element).attrs) {
if (
attr.name === attribute &&
typeof attr.namespace === 'undefined'
@@ -77,43 +113,62 @@ export class SimpleDOMParser {
return null;
}
+ /**
+ * @param {import('simple-dom-parser').Element} element
+ * @returns {string}
+ */
getTextContent(element) {
let source = '';
for (const node of this._allNodes(element)) {
- if (node.nodeName === '#text') {
+ 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) {
- if (root === null) {
- root = this._document;
- }
-
// Depth-first pre-order traversal
- const nodeQueue = [root];
+ /** @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 = nodeQueue.pop();
-
+ const node = /** @type {import('simple-dom-parser').Parse5ChildNode} */ (nodeQueue.pop());
yield node;
-
- const childNodes = node.childNodes;
- if (typeof childNodes !== 'undefined') {
- for (let i = childNodes.length - 1; i >= 0; --i) {
- nodeQueue.push(childNodes[i]);
+ 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;
diff --git a/ext/js/dom/text-source-element.js b/ext/js/dom/text-source-element.js
index 95534975..40ff5cc9 100644
--- a/ext/js/dom/text-source-element.js
+++ b/ext/js/dom/text-source-element.js
@@ -18,7 +18,6 @@
import {StringUtil} from '../data/sandbox/string-util.js';
import {DocumentUtil} from './document-util.js';
-import {TextSourceRange} from './text-source-range.js';
/**
* This class represents a text source that is attached to a HTML element, such as an <img>
@@ -33,16 +32,21 @@ export class TextSourceElement {
* @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 {string}
+ * @type {'element'}
*/
get type() {
return 'element';
@@ -147,7 +151,7 @@ export class TextSourceElement {
/**
* Gets writing mode that is used for this element.
* See: https://developer.mozilla.org/en-US/docs/Web/CSS/writing-mode.
- * @returns {string} The rects.
+ * @returns {import('document-util').NormalizedWritingMode} The writing mode.
*/
getWritingMode() {
return 'horizontal-tb';
@@ -169,7 +173,7 @@ export class TextSourceElement {
/**
* Checks whether another text source has the same starting point.
- * @param {TextSourceElement|TextSourceRange} other The other source to test.
+ * @param {import('text-source').TextSource} other The other source to test.
* @returns {boolean} `true` if the starting points are equivalent, `false` otherwise.
*/
hasSameStart(other) {
@@ -206,23 +210,39 @@ export class TextSourceElement {
* @returns {string} The content string.
*/
static _getElementContent(element) {
- let content;
+ let content = '';
switch (element.nodeName.toUpperCase()) {
case 'BUTTON':
- content = element.textContent;
+ {
+ const {textContent} = /** @type {HTMLButtonElement} */ (element);
+ if (textContent !== null) {
+ content = textContent;
+ }
+ }
break;
case 'IMG':
- content = element.getAttribute('alt') || '';
+ {
+ const alt = /** @type {HTMLImageElement} */ (element).getAttribute('alt');
+ if (typeof alt === 'string') {
+ content = alt;
+ }
+ }
break;
case 'SELECT':
{
- const {selectedIndex, options} = element;
- const option = (selectedIndex >= 0 && selectedIndex < options.length ? options[selectedIndex] : null);
- content = (option !== null ? option.textContent : '');
+ const {selectedIndex, options} = /** @type {HTMLSelectElement} */ (element);
+ if (selectedIndex >= 0 && selectedIndex < options.length) {
+ const {textContent} = options[selectedIndex];
+ if (textContent !== null) {
+ content = textContent;
+ }
+ }
}
break;
- default:
- content = `${element.value}`;
+ case 'INPUT':
+ {
+ content = /** @type {HTMLInputElement} */ (element).value;
+ }
break;
}
diff --git a/ext/js/dom/text-source-range.js b/ext/js/dom/text-source-range.js
index d5e70052..fd09fdda 100644
--- a/ext/js/dom/text-source-range.js
+++ b/ext/js/dom/text-source-range.js
@@ -18,7 +18,6 @@
import {DocumentUtil} from './document-util.js';
import {DOMTextScanner} from './dom-text-scanner.js';
-import {TextSourceElement} from './text-source-element.js';
/**
* This class represents a text source that comes from text nodes in the document.
@@ -36,26 +35,33 @@ export class TextSourceRange {
* @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 {?Rect[]} cachedRects A set of cached `DOMRect`s representing the rects of the text source,
+ * @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 {?Rect} cachedSourceRect A cached `DOMRect` representing the rect of the `imposterSourceElement`,
+ * @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.
*/
constructor(range, rangeStartOffset, content, imposterElement, imposterSourceElement, cachedRects, cachedSourceRect) {
+ /** @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;
}
/**
* Gets the type name of this instance.
- * @type {string}
+ * @type {'range'}
*/
get type() {
return 'range';
@@ -71,7 +77,7 @@ export class TextSourceRange {
/**
* The starting offset for the range.
- * @type {Range}
+ * @type {number}
*/
get rangeStartOffset() {
return this._rangeStartOffset;
@@ -169,12 +175,12 @@ export class TextSourceRange {
/**
* Gets writing mode that is used for this element.
* See: https://developer.mozilla.org/en-US/docs/Web/CSS/writing-mode.
- * @returns {string} The rects.
+ * @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 DocumentUtil.getElementWritingMode(node);
+ return DocumentUtil.getElementWritingMode(/** @type {?Element} */ (node));
}
/**
@@ -183,6 +189,7 @@ export class TextSourceRange {
select() {
if (this._imposterElement !== null) { return; }
const selection = window.getSelection();
+ if (selection === null) { return; }
selection.removeAllRanges();
selection.addRange(this._range);
}
@@ -193,12 +200,13 @@ export class TextSourceRange {
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 {TextSourceElement|TextSourceRange} other The other source to test.
+ * @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.
@@ -220,7 +228,7 @@ export class TextSourceRange {
try {
return this._range.compareBoundaryPoints(Range.START_TO_START, other.range) === 0;
} catch (e) {
- if (e.name === 'WrongDocumentError') {
+ if (e instanceof Error && e.name === 'WrongDocumentError') {
// This can happen with shadow DOMs if the ranges are in different documents.
return false;
}
@@ -269,9 +277,17 @@ export class TextSourceRange {
/**
* Gets the cached rects for a disconnected imposter element.
- * @returns {Rect[]} The rects for the 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 = DocumentUtil.convertRectZoomCoordinates(this._imposterSourceElement.getBoundingClientRect(), this._imposterSourceElement);
return DocumentUtil.offsetDOMRects(
this._cachedRects,