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, |