diff options
Diffstat (limited to 'ext/js/dom')
-rw-r--r-- | ext/js/dom/document-focus-controller.js | 22 | ||||
-rw-r--r-- | ext/js/dom/document-util.js | 293 | ||||
-rw-r--r-- | ext/js/dom/dom-data-binder.js | 144 | ||||
-rw-r--r-- | ext/js/dom/dom-text-scanner.js | 54 | ||||
-rw-r--r-- | ext/js/dom/html-template-collection.js | 39 | ||||
-rw-r--r-- | ext/js/dom/native-simple-dom-parser.js | 74 | ||||
-rw-r--r-- | ext/js/dom/panel-element.js | 34 | ||||
-rw-r--r-- | ext/js/dom/popup-menu.js | 71 | ||||
-rw-r--r-- | ext/js/dom/sandbox/css-style-applier.js | 43 | ||||
-rw-r--r-- | ext/js/dom/scroll-element.js | 50 | ||||
-rw-r--r-- | ext/js/dom/selector-observer.js | 113 | ||||
-rw-r--r-- | ext/js/dom/simple-dom-parser.js | 121 | ||||
-rw-r--r-- | ext/js/dom/text-source-element.js | 44 | ||||
-rw-r--r-- | ext/js/dom/text-source-range.js | 36 |
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, |