diff options
Diffstat (limited to 'ext/js/display')
| -rw-r--r-- | ext/js/display/display-anki.js | 328 | ||||
| -rw-r--r-- | ext/js/display/display-audio.js | 320 | ||||
| -rw-r--r-- | ext/js/display/display-content-manager.js | 101 | ||||
| -rw-r--r-- | ext/js/display/display-generator.js | 429 | ||||
| -rw-r--r-- | ext/js/display/display-history.js | 73 | ||||
| -rw-r--r-- | ext/js/display/display-notification.js | 32 | ||||
| -rw-r--r-- | ext/js/display/display-profile-selection.js | 50 | ||||
| -rw-r--r-- | ext/js/display/display-resizer.js | 58 | ||||
| -rw-r--r-- | ext/js/display/display.js | 678 | ||||
| -rw-r--r-- | ext/js/display/element-overflow-controller.js | 39 | ||||
| -rw-r--r-- | ext/js/display/option-toggle-hotkey-handler.js | 62 | ||||
| -rw-r--r-- | ext/js/display/query-parser.js | 127 | ||||
| -rw-r--r-- | ext/js/display/sandbox/pronunciation-generator.js | 56 | ||||
| -rw-r--r-- | ext/js/display/sandbox/structured-content-generator.js | 101 | ||||
| -rw-r--r-- | ext/js/display/search-action-popup-controller.js | 5 | ||||
| -rw-r--r-- | ext/js/display/search-display-controller.js | 171 | ||||
| -rw-r--r-- | ext/js/display/search-main.js | 4 | ||||
| -rw-r--r-- | ext/js/display/search-persistent-state-controller.js | 27 | 
18 files changed, 2190 insertions, 471 deletions
| diff --git a/ext/js/display/display-anki.js b/ext/js/display/display-anki.js index 2f94e414..c0173697 100644 --- a/ext/js/display/display-anki.js +++ b/ext/js/display/display-anki.js @@ -16,54 +16,95 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -import {EventListenerCollection, deferPromise, isObject} from '../core.js'; +import {EventListenerCollection, deferPromise} from '../core.js';  import {AnkiNoteBuilder} from '../data/anki-note-builder.js';  import {AnkiUtil} from '../data/anki-util.js';  import {PopupMenu} from '../dom/popup-menu.js';  import {yomitan} from '../yomitan.js';  export class DisplayAnki { +    /** +     * @param {Display} display +     * @param {DisplayAudio} displayAudio +     * @param {JapaneseUtil} japaneseUtil +     */      constructor(display, displayAudio, japaneseUtil) { +        /** @type {Display} */          this._display = display; +        /** @type {DisplayAudio} */          this._displayAudio = displayAudio; +        /** @type {?string} */          this._ankiFieldTemplates = null; +        /** @type {?string} */          this._ankiFieldTemplatesDefault = null; +        /** @type {AnkiNoteBuilder} */          this._ankiNoteBuilder = new AnkiNoteBuilder({japaneseUtil}); +        /** @type {?DisplayNotification} */          this._errorNotification = null; +        /** @type {?EventListenerCollection} */          this._errorNotificationEventListeners = null; +        /** @type {?DisplayNotification} */          this._tagsNotification = null; -        this._updateAdderButtonsPromise = Promise.resolve(); +        /** @type {?Promise<void>} */ +        this._updateAdderButtonsPromise = null; +        /** @type {?import('core').TokenObject} */          this._updateDictionaryEntryDetailsToken = null; +        /** @type {EventListenerCollection} */          this._eventListeners = new EventListenerCollection(); +        /** @type {?import('display-anki').DictionaryEntryDetails[]} */          this._dictionaryEntryDetails = null; +        /** @type {?import('anki-templates-internal').Context} */          this._noteContext = null; +        /** @type {boolean} */          this._checkForDuplicates = false; +        /** @type {boolean} */          this._suspendNewCards = false; +        /** @type {boolean} */          this._compactTags = false; +        /** @type {import('settings').ResultOutputMode} */          this._resultOutputMode = 'split'; +        /** @type {import('settings').GlossaryLayoutMode} */          this._glossaryLayoutMode = 'default'; +        /** @type {import('settings').AnkiDisplayTags} */          this._displayTags = 'never'; +        /** @type {import('settings').AnkiDuplicateScope} */          this._duplicateScope = 'collection'; +        /** @type {boolean} */          this._duplicateScopeCheckAllModels = false; +        /** @type {import('settings').AnkiScreenshotFormat} */          this._screenshotFormat = 'png'; +        /** @type {number} */          this._screenshotQuality = 100; +        /** @type {number} */          this._scanLength = 10; +        /** @type {import('settings').AnkiNoteGuiMode} */          this._noteGuiMode = 'browse'; +        /** @type {?number} */          this._audioDownloadIdleTimeout = null; +        /** @type {string[]} */          this._noteTags = []; +        /** @type {Map<import('display-anki').CreateMode, import('settings').AnkiNoteOptions>} */          this._modeOptions = new Map(); +        /** @type {Map<import('dictionary').DictionaryEntryType, import('display-anki').CreateMode[]>} */          this._dictionaryEntryTypeModeMap = new Map([              ['kanji', ['kanji']],              ['term', ['term-kanji', 'term-kana']]          ]); -        this._menuContainer = document.querySelector('#popup-menus'); +        /** @type {HTMLElement} */ +        this._menuContainer = /** @type {HTMLElement} */ (document.querySelector('#popup-menus')); +        /** @type {(event: MouseEvent) => void} */          this._onShowTagsBind = this._onShowTags.bind(this); +        /** @type {(event: MouseEvent) => void} */          this._onNoteAddBind = this._onNoteAdd.bind(this); +        /** @type {(event: MouseEvent) => void} */          this._onViewNoteButtonClickBind = this._onViewNoteButtonClick.bind(this); +        /** @type {(event: MouseEvent) => void} */          this._onViewNoteButtonContextMenuBind = this._onViewNoteButtonContextMenu.bind(this); +        /** @type {(event: import('popup-menu').MenuCloseEvent) => void} */          this._onViewNoteButtonMenuCloseBind = this._onViewNoteButtonMenuClose.bind(this);      } +    /** */      prepare() {          this._noteContext = this._getNoteContext();          this._display.hotkeyHandler.registerActions([ @@ -80,13 +121,16 @@ export class DisplayAnki {          this._display.on('logDictionaryEntryData', this._onLogDictionaryEntryData.bind(this));      } +    /** +     * @param {import('dictionary').DictionaryEntry} dictionaryEntry +     * @returns {Promise<import('display-anki').LogData>} +     */      async getLogData(dictionaryEntry) { -        const result = {}; -          // Anki note data          let ankiNoteData;          let ankiNoteDataException;          try { +            if (this._noteContext === null) { throw new Error('Note context not initialized'); }              ankiNoteData = await this._ankiNoteBuilder.getRenderingData({                  dictionaryEntry,                  mode: 'test', @@ -99,12 +143,9 @@ export class DisplayAnki {          } catch (e) {              ankiNoteDataException = e;          } -        result.ankiNoteData = ankiNoteData; -        if (typeof ankiNoteDataException !== 'undefined') { -            result.ankiNoteDataException = ankiNoteDataException; -        }          // Anki notes +        /** @type {import('display-anki').AnkiNoteLogData[]} */          const ankiNotes = [];          const modes = this._getModes(dictionaryEntry.type === 'term');          for (const mode of modes) { @@ -114,8 +155,9 @@ export class DisplayAnki {              try {                  ({note: note, errors, requirements} = await this._createNote(dictionaryEntry, mode, []));              } catch (e) { -                errors = [e]; +                errors = [e instanceof Error ? e : new Error(`${e}`)];              } +            /** @type {import('display-anki').AnkiNoteLogData} */              const entry = {mode, note};              if (Array.isArray(errors) && errors.length > 0) {                  entry.errors = errors; @@ -125,13 +167,19 @@ export class DisplayAnki {              }              ankiNotes.push(entry);          } -        result.ankiNotes = ankiNotes; -        return result; +        return { +            ankiNoteData, +            ankiNoteDataException: ankiNoteDataException instanceof Error ? ankiNoteDataException : new Error(`${ankiNoteDataException}`), +            ankiNotes +        };      }      // Private +    /** +     * @param {import('display').OptionsUpdatedEvent} details +     */      _onOptionsUpdated({options}) {          const {              general: {resultOutputMode, glossaryLayoutMode, compactTags}, @@ -173,16 +221,21 @@ export class DisplayAnki {          this._updateAnkiFieldTemplates(options);      } +    /** */      _onContentClear() {          this._updateDictionaryEntryDetailsToken = null;          this._dictionaryEntryDetails = null;          this._hideErrorNotification(false);      } +    /** */      _onContentUpdateStart() {          this._noteContext = this._getNoteContext();      } +    /** +     * @param {import('display').ContentUpdateEntryEvent} details +     */      _onContentUpdateEntry({element}) {          const eventListeners = this._eventListeners;          for (const node of element.querySelectorAll('.action-button[data-action=view-tags]')) { @@ -198,45 +251,77 @@ export class DisplayAnki {          }      } +    /** */      _onContentUpdateComplete() {          this._updateDictionaryEntryDetails();      } +    /** +     * @param {import('display').LogDictionaryEntryDataEvent} details +     */      _onLogDictionaryEntryData({dictionaryEntry, promises}) {          promises.push(this.getLogData(dictionaryEntry));      } +    /** +     * @param {MouseEvent} e +     */      _onNoteAdd(e) {          e.preventDefault(); -        const node = e.currentTarget; -        const index = this._display.getElementDictionaryEntryIndex(node); -        this._addAnkiNote(index, node.dataset.mode); +        const element = /** @type {HTMLElement} */ (e.currentTarget); +        const mode = this._getValidCreateMode(element.dataset.mode); +        if (mode === null) { return; } +        const index = this._display.getElementDictionaryEntryIndex(element); +        this._addAnkiNote(index, mode);      } +    /** +     * @param {MouseEvent} e +     */      _onShowTags(e) {          e.preventDefault(); -        const tags = e.currentTarget.title; +        const element = /** @type {HTMLElement} */ (e.currentTarget); +        const tags = element.title;          this._showTagsNotification(tags);      } +    /** +     * @param {number} index +     * @param {import('display-anki').CreateMode} mode +     * @returns {?HTMLButtonElement} +     */      _adderButtonFind(index, mode) {          const entry = this._getEntry(index);          return entry !== null ? entry.querySelector(`.action-button[data-action=add-note][data-mode="${mode}"]`) : null;      } +    /** +     * @param {number} index +     * @returns {?HTMLButtonElement} +     */      _tagsIndicatorFind(index) {          const entry = this._getEntry(index);          return entry !== null ? entry.querySelector('.action-button[data-action=view-tags]') : null;      } +    /** +     * @param {number} index +     * @returns {?HTMLElement} +     */      _getEntry(index) {          const entries = this._display.dictionaryEntryNodes;          return index >= 0 && index < entries.length ? entries[index] : null;      } +    /** +     * @returns {?import('anki-templates-internal').Context} +     */      _getNoteContext() {          const {state} = this._display.history; -        let {documentTitle, url, sentence} = (isObject(state) ? state : {}); +        let documentTitle, url, sentence; +        if (typeof state === 'object' && state !== null) { +            ({documentTitle, url, sentence} = state); +        }          if (typeof documentTitle !== 'string') {              documentTitle = document.title;          } @@ -254,8 +339,10 @@ export class DisplayAnki {          };      } +    /** */      async _updateDictionaryEntryDetails() {          const {dictionaryEntries} = this._display; +        /** @type {?import('core').TokenObject} */          const token = {};          this._updateDictionaryEntryDetailsToken = token;          if (this._updateAdderButtonsPromise !== null) { @@ -263,13 +350,13 @@ export class DisplayAnki {          }          if (this._updateDictionaryEntryDetailsToken !== token) { return; } -        const {promise, resolve} = deferPromise(); +        const {promise, resolve} = /** @type {import('core').DeferredPromiseDetails<void>} */ (deferPromise());          try {              this._updateAdderButtonsPromise = promise;              const dictionaryEntryDetails = await this._getDictionaryEntryDetails(dictionaryEntries);              if (this._updateDictionaryEntryDetailsToken !== token) { return; }              this._dictionaryEntryDetails = dictionaryEntryDetails; -            this._updateAdderButtons(); +            this._updateAdderButtons(dictionaryEntryDetails);          } finally {              resolve();              if (this._updateAdderButtonsPromise === promise) { @@ -278,9 +365,11 @@ export class DisplayAnki {          }      } -    _updateAdderButtons() { +    /** +     * @param {import('display-anki').DictionaryEntryDetails[]} dictionaryEntryDetails +     */ +    _updateAdderButtons(dictionaryEntryDetails) {          const displayTags = this._displayTags; -        const dictionaryEntryDetails = this._dictionaryEntryDetails;          for (let i = 0, ii = dictionaryEntryDetails.length; i < ii; ++i) {              let allNoteIds = null;              for (const {mode, canAdd, noteIds, noteInfos, ankiError} of dictionaryEntryDetails[i].modeMap.values()) { @@ -303,6 +392,10 @@ export class DisplayAnki {          }      } +    /** +     * @param {number} i +     * @param {(?import('anki').NoteInfo)[]} noteInfos +     */      _setupTagsIndicator(i, noteInfos) {          const tagsIndicator = this._tagsIndicatorFind(i);          if (tagsIndicator === null) { @@ -310,8 +403,9 @@ export class DisplayAnki {          }          const displayTags = new Set(); -        for (const {tags} of noteInfos) { -            for (const tag of tags) { +        for (const item of noteInfos) { +            if (item === null) { continue; } +            for (const tag of item.tags) {                  displayTags.add(tag);              }          } @@ -328,6 +422,9 @@ export class DisplayAnki {          }      } +    /** +     * @param {string} message +     */      _showTagsNotification(message) {          if (this._tagsNotification === null) {              this._tagsNotification = this._display.createNotification(true); @@ -337,11 +434,18 @@ export class DisplayAnki {          this._tagsNotification.open();      } +    /** +     * @param {import('display-anki').CreateMode} mode +     */      _tryAddAnkiNoteForSelectedEntry(mode) {          const index = this._display.selectedIndex;          this._addAnkiNote(index, mode);      } +    /** +     * @param {number} dictionaryEntryIndex +     * @param {import('display-anki').CreateMode} mode +     */      async _addAnkiNote(dictionaryEntryIndex, mode) {          const dictionaryEntries = this._display.dictionaryEntries;          const dictionaryEntryDetails = this._dictionaryEntryDetails; @@ -364,6 +468,7 @@ export class DisplayAnki {          this._hideErrorNotification(true); +        /** @type {Error[]} */          const allErrors = [];          const progressIndicatorVisible = this._display.progressIndicatorVisible;          const overrideToken = progressIndicatorVisible.setOverride(true); @@ -381,7 +486,7 @@ export class DisplayAnki {                  addNoteOkay = true;              } catch (e) {                  allErrors.length = 0; -                allErrors.push(e); +                allErrors.push(e instanceof Error ? e : new Error(`${e}`));              }              if (addNoteOkay) { @@ -392,7 +497,7 @@ export class DisplayAnki {                          try {                              await yomitan.api.suspendAnkiCardsForNote(noteId);                          } catch (e) { -                            allErrors.push(e); +                            allErrors.push(e instanceof Error ? e : new Error(`${e}`));                          }                      }                      button.disabled = true; @@ -400,7 +505,7 @@ export class DisplayAnki {                  }              }          } catch (e) { -            allErrors.push(e); +            allErrors.push(e instanceof Error ? e : new Error(`${e}`));          } finally {              progressIndicatorVisible.clearOverride(overrideToken);          } @@ -412,6 +517,11 @@ export class DisplayAnki {          }      } +    /** +     * @param {import('anki-note-builder').Requirement[]} requirements +     * @param {import('anki-note-builder').Requirement[]} outputRequirements +     * @returns {?DisplayAnkiError} +     */      _getAddNoteRequirementsError(requirements, outputRequirements) {          if (outputRequirements.length === 0) { return null; } @@ -429,12 +539,16 @@ export class DisplayAnki {          }          if (count === 0) { return null; } -        const error = new Error('The created card may not have some content'); +        const error = new DisplayAnkiError('The created card may not have some content');          error.requirements = requirements;          error.outputRequirements = outputRequirements;          return error;      } +    /** +     * @param {Error[]} errors +     * @param {(DocumentFragment|Node|Error)[]} [displayErrors] +     */      _showErrorNotification(errors, displayErrors) {          if (typeof displayErrors === 'undefined') { displayErrors = errors; } @@ -449,7 +563,7 @@ export class DisplayAnki {          const content = this._display.displayGenerator.createAnkiNoteErrorsNotificationContent(displayErrors);          for (const node of content.querySelectorAll('.anki-note-error-log-link')) { -            this._errorNotificationEventListeners.addEventListener(node, 'click', () => { +            /** @type {EventListenerCollection} */ (this._errorNotificationEventListeners).addEventListener(node, 'click', () => {                  console.log({ankiNoteErrors: errors});              }, false);          } @@ -458,16 +572,26 @@ export class DisplayAnki {          this._errorNotification.open();      } +    /** +     * @param {boolean} animate +     */      _hideErrorNotification(animate) {          if (this._errorNotification === null) { return; }          this._errorNotification.close(animate); -        this._errorNotificationEventListeners.removeAllEventListeners(); +        /** @type {EventListenerCollection} */ (this._errorNotificationEventListeners).removeAllEventListeners();      } +    /** +     * @param {import('settings').ProfileOptions} options +     */      async _updateAnkiFieldTemplates(options) {          this._ankiFieldTemplates = await this._getAnkiFieldTemplates(options);      } +    /** +     * @param {import('settings').ProfileOptions} options +     * @returns {Promise<string>} +     */      async _getAnkiFieldTemplates(options) {          let templates = options.anki.fieldTemplates;          if (typeof templates === 'string') { return templates; } @@ -480,6 +604,10 @@ export class DisplayAnki {          return templates;      } +    /** +     * @param {import('dictionary').DictionaryEntry[]} dictionaryEntries +     * @returns {Promise<import('display-anki').DictionaryEntryDetails[]>} +     */      async _getDictionaryEntryDetails(dictionaryEntries) {          const forceCanAddValue = (this._checkForDuplicates ? null : true);          const fetchAdditionalInfo = (this._displayTags !== 'never'); @@ -514,9 +642,10 @@ export class DisplayAnki {              }          } catch (e) {              infos = this._getAnkiNoteInfoForceValue(notes, false); -            ankiError = e; +            ankiError = e instanceof Error ? e : new Error(`${e}`);          } +        /** @type {import('display-anki').DictionaryEntryDetails[]} */          const results = [];          for (let i = 0, ii = dictionaryEntries.length; i < ii; ++i) {              results.push({ @@ -533,6 +662,11 @@ export class DisplayAnki {          return results;      } +    /** +     * @param {import('anki').Note[]} notes +     * @param {boolean} canAdd +     * @returns {import('anki').NoteInfoWrapper[]} +     */      _getAnkiNoteInfoForceValue(notes, canAdd) {          const results = [];          for (const note of notes) { @@ -542,11 +676,19 @@ export class DisplayAnki {          return results;      } +    /** +     * @param {import('dictionary').DictionaryEntry} dictionaryEntry +     * @param {import('display-anki').CreateMode} mode +     * @param {import('anki-note-builder').Requirement[]} requirements +     * @returns {Promise<import('display-anki').CreateNoteResult>} +     */      async _createNote(dictionaryEntry, mode, requirements) {          const context = this._noteContext; +        if (context === null) { throw new Error('Note context not initialized'); }          const modeOptions = this._modeOptions.get(mode);          if (typeof modeOptions === 'undefined') { throw new Error(`Unsupported note type: ${mode}`); }          const template = this._ankiFieldTemplates; +        if (typeof template !== 'string') { throw new Error('Invalid template'); }          const {deck: deckName, model: modelName} = modeOptions;          const fields = Object.entries(modeOptions.fields);          const contentOrigin = this._display.getContentOrigin(); @@ -586,12 +728,26 @@ export class DisplayAnki {          return {note, errors, requirements: outputRequirements};      } +    /** +     * @param {boolean} isTerms +     * @returns {import('display-anki').CreateMode[]} +     */      _getModes(isTerms) {          return isTerms ? ['term-kanji', 'term-kana'] : ['kanji'];      } +    /** +     * @param {unknown} sentence +     * @param {string} fallback +     * @param {number} fallbackOffset +     * @returns {import('anki-templates-internal').ContextSentence} +     */      _getValidSentenceData(sentence, fallback, fallbackOffset) { -        let {text, offset} = (isObject(sentence) ? sentence : {}); +        let text; +        let offset; +        if (typeof sentence === 'object' && sentence !== null) { +            ({text, offset} = /** @type {import('core').UnknownObject} */ (sentence)); +        }          if (typeof text !== 'string') {              text = fallback;              offset = fallbackOffset; @@ -601,6 +757,10 @@ export class DisplayAnki {          return {text, offset};      } +    /** +     * @param {import('api').InjectAnkiNoteMediaDefinitionDetails} details +     * @returns {?import('anki-note-builder').AudioMediaOptions} +     */      _getAnkiNoteMediaAudioDetails(details) {          if (details.type !== 'term') { return null; }          const {sources, preferredAudioIndex} = this._displayAudio.getAnkiNoteMediaAudioDetails(details.term, details.reading); @@ -609,56 +769,79 @@ export class DisplayAnki {      // View note functions +    /** +     * @param {MouseEvent} e +     */      _onViewNoteButtonClick(e) { +        const element = /** @type {HTMLElement} */ (e.currentTarget);          e.preventDefault();          if (e.shiftKey) { -            this._showViewNoteMenu(e.currentTarget); +            this._showViewNoteMenu(element);          } else { -            this._viewNote(e.currentTarget); +            this._viewNote(element);          }      } +    /** +     * @param {MouseEvent} e +     */      _onViewNoteButtonContextMenu(e) { +        const element = /** @type {HTMLElement} */ (e.currentTarget);          e.preventDefault(); -        this._showViewNoteMenu(e.currentTarget); +        this._showViewNoteMenu(element);      } +    /** +     * @param {import('popup-menu').MenuCloseEvent} e +     */      _onViewNoteButtonMenuClose(e) {          const {detail: {action, item}} = e;          switch (action) {              case 'viewNote': -                this._viewNote(item); +                if (item !== null) { +                    this._viewNote(item); +                }                  break;          }      } +    /** +     * @param {number} index +     * @param {number[]} noteIds +     * @param {boolean} prepend +     */      _updateViewNoteButton(index, noteIds, prepend) {          const button = this._getViewNoteButton(index);          if (button === null) { return; } +        /** @type {(number|string)[]} */ +        let allNoteIds = noteIds;          if (prepend) {              const currentNoteIds = button.dataset.noteIds;              if (typeof currentNoteIds === 'string' && currentNoteIds.length > 0) { -                noteIds = [...noteIds, currentNoteIds.split(' ')]; +                allNoteIds = [...allNoteIds, ...currentNoteIds.split(' ')];              }          } -        const disabled = (noteIds.length === 0); +        const disabled = (allNoteIds.length === 0);          button.disabled = disabled;          button.hidden = disabled; -        button.dataset.noteIds = noteIds.join(' '); +        button.dataset.noteIds = allNoteIds.join(' '); -        const badge = button.querySelector('.action-button-badge'); +        const badge = /** @type {?HTMLElement} */ (button.querySelector('.action-button-badge'));          if (badge !== null) {              const badgeData = badge.dataset; -            if (noteIds.length > 1) { +            if (allNoteIds.length > 1) {                  badgeData.icon = 'plus-thick'; -                badgeData.hidden = false; +                badge.hidden = false;              } else {                  delete badgeData.icon; -                badgeData.hidden = true; +                badge.hidden = true;              }          }      } +    /** +     * @param {HTMLElement} node +     */      async _viewNote(node) {          const noteIds = this._getNodeNoteIds(node);          if (noteIds.length === 0) { return; } @@ -666,26 +849,30 @@ export class DisplayAnki {              await yomitan.api.noteView(noteIds[0], this._noteGuiMode, false);          } catch (e) {              const displayErrors = ( -                e.message === 'Mode not supported' ? +                e instanceof Error && e.message === 'Mode not supported' ?                  [this._display.displayGenerator.instantiateTemplateFragment('footer-notification-anki-view-note-error')] :                  void 0              ); -            this._showErrorNotification([e], displayErrors); +            this._showErrorNotification([e instanceof Error ? e : new Error(`${e}`)], displayErrors);              return;          }      } +    /** +     * @param {HTMLElement} node +     */      _showViewNoteMenu(node) {          const noteIds = this._getNodeNoteIds(node);          if (noteIds.length === 0) { return; } -        const menuContainerNode = this._display.displayGenerator.instantiateTemplate('view-note-button-popup-menu'); -        const menuBodyNode = menuContainerNode.querySelector('.popup-menu-body'); +        const menuContainerNode = /** @type {HTMLElement} */ (this._display.displayGenerator.instantiateTemplate('view-note-button-popup-menu')); +        const menuBodyNode = /** @type {HTMLElement} */ (menuContainerNode.querySelector('.popup-menu-body'));          for (let i = 0, ii = noteIds.length; i < ii; ++i) {              const noteId = noteIds[i]; -            const item = this._display.displayGenerator.instantiateTemplate('view-note-button-popup-menu-item'); -            item.querySelector('.popup-menu-item-label').textContent = `Note ${i + 1}: ${noteId}`; +            const item = /** @type {HTMLElement} */ (this._display.displayGenerator.instantiateTemplate('view-note-button-popup-menu-item')); +            const label = /** @type {Element} */ (item.querySelector('.popup-menu-item-label')); +            label.textContent = `Note ${i + 1}: ${noteId}`;              item.dataset.menuAction = 'viewNote';              item.dataset.noteIds = `${noteId}`;              menuBodyNode.appendChild(item); @@ -696,6 +883,10 @@ export class DisplayAnki {          popupMenu.prepare();      } +    /** +     * @param {HTMLElement} node +     * @returns {number[]} +     */      _getNodeNoteIds(node) {          const {noteIds} = node.dataset;          const results = []; @@ -710,11 +901,16 @@ export class DisplayAnki {          return results;      } +    /** +     * @param {number} index +     * @returns {?HTMLButtonElement} +     */      _getViewNoteButton(index) {          const entry = this._getEntry(index);          return entry !== null ? entry.querySelector('.action-button[data-action=view-note]') : null;      } +    /** */      _viewNoteForSelectedEntry() {          const index = this._display.selectedIndex;          const button = this._getViewNoteButton(index); @@ -722,4 +918,40 @@ export class DisplayAnki {              this._viewNote(button);          }      } + +    /** +     * @param {string|undefined} value +     * @returns {?import('display-anki').CreateMode} +     */ +    _getValidCreateMode(value) { +        switch (value) { +            case 'kanji': +            case 'term-kanji': +            case 'term-kana': +                return value; +            default: +                return null; +        } +    } +} + +class DisplayAnkiError extends Error { +    /** +     * @param {string} message +     */ +    constructor(message) { +        super(message); +        /** @type {?import('anki-note-builder').Requirement[]} */ +        this._requirements = null; +        /** @type {?import('anki-note-builder').Requirement[]} */ +        this._outputRequirements = null; +    } + +    /** @type {?import('anki-note-builder').Requirement[]} */ +    get requirements() { return this._requirements; } +    set requirements(value) { this._requirements = value; } + +    /** @type {?import('anki-note-builder').Requirement[]} */ +    get outputRequirements() { return this._outputRequirements; } +    set outputRequirements(value) { this._outputRequirements = value; }  } diff --git a/ext/js/display/display-audio.js b/ext/js/display/display-audio.js index faed88bc..8d917e81 100644 --- a/ext/js/display/display-audio.js +++ b/ext/js/display/display-audio.js @@ -22,20 +22,37 @@ import {AudioSystem} from '../media/audio-system.js';  import {yomitan} from '../yomitan.js';  export class DisplayAudio { +    /** +     * @param {Display} display +     */      constructor(display) { +        /** @type {Display} */          this._display = display; +        /** @type {?import('display-audio').GenericAudio} */          this._audioPlaying = null; +        /** @type {AudioSystem} */          this._audioSystem = new AudioSystem(); +        /** @type {number} */          this._playbackVolume = 1.0; +        /** @type {boolean} */          this._autoPlay = false; +        /** @type {?number} */          this._autoPlayAudioTimer = null; +        /** @type {number} */          this._autoPlayAudioDelay = 400; +        /** @type {EventListenerCollection} */          this._eventListeners = new EventListenerCollection(); +        /** @type {Map<string, import('display-audio').CacheItem>} */          this._cache = new Map(); -        this._menuContainer = document.querySelector('#popup-menus'); +        /** @type {Element} */ +        this._menuContainer = /** @type {Element} */ (document.querySelector('#popup-menus')); +        /** @type {import('core').TokenObject} */          this._entriesToken = {}; +        /** @type {Set<PopupMenu>} */          this._openMenus = new Set(); +        /** @type {import('display-audio').AudioSource[]} */          this._audioSources = []; +        /** @type {Map<import('settings').AudioSourceType, string>} */          this._audioSourceTypeNames = new Map([              ['jpod101', 'JapanesePod101'],              ['jpod101-alternate', 'JapanesePod101 (Alternate)'], @@ -45,11 +62,15 @@ export class DisplayAudio {              ['custom', 'Custom URL'],              ['custom-json', 'Custom URL (JSON)']          ]); +        /** @type {(event: MouseEvent) => void} */          this._onAudioPlayButtonClickBind = this._onAudioPlayButtonClick.bind(this); +        /** @type {(event: MouseEvent) => void} */          this._onAudioPlayButtonContextMenuBind = this._onAudioPlayButtonContextMenu.bind(this); +        /** @type {(event: import('popup-menu').MenuCloseEvent) => void} */          this._onAudioPlayMenuCloseClickBind = this._onAudioPlayMenuCloseClick.bind(this);      } +    /** @type {number} */      get autoPlayAudioDelay() {          return this._autoPlayAudioDelay;      } @@ -58,6 +79,7 @@ export class DisplayAudio {          this._autoPlayAudioDelay = value;      } +    /** */      prepare() {          this._audioSystem.prepare();          this._display.hotkeyHandler.registerActions([ @@ -72,21 +94,31 @@ export class DisplayAudio {          this._display.on('contentUpdateEntry', this._onContentUpdateEntry.bind(this));          this._display.on('contentUpdateComplete', this._onContentUpdateComplete.bind(this));          this._display.on('frameVisibilityChange', this._onFrameVisibilityChange.bind(this)); -        this._onOptionsUpdated({options: this._display.getOptions()}); +        const options = this._display.getOptions(); +        if (options !== null) { +            this._onOptionsUpdated({options}); +        }      } +    /** */      clearAutoPlayTimer() {          if (this._autoPlayAudioTimer === null) { return; }          clearTimeout(this._autoPlayAudioTimer);          this._autoPlayAudioTimer = null;      } +    /** */      stopAudio() {          if (this._audioPlaying === null) { return; }          this._audioPlaying.pause();          this._audioPlaying = null;      } +    /** +     * @param {number} dictionaryEntryIndex +     * @param {number} headwordIndex +     * @param {?string} [sourceType] +     */      async playAudio(dictionaryEntryIndex, headwordIndex, sourceType=null) {          let sources = this._audioSources;          if (sourceType !== null) { @@ -100,7 +132,13 @@ export class DisplayAudio {          await this._playAudio(dictionaryEntryIndex, headwordIndex, sources, null);      } +    /** +     * @param {string} term +     * @param {string} reading +     * @returns {import('display-audio').AudioMediaOptions} +     */      getAnkiNoteMediaAudioDetails(term, reading) { +        /** @type {import('display-audio').AudioSourceShort[]} */          const sources = [];          let preferredAudioIndex = null;          const primaryCardAudio = this._getPrimaryCardAudio(term, reading); @@ -120,17 +158,21 @@ export class DisplayAudio {      // Private +    /** +     * @param {import('display').OptionsUpdatedEvent} details +     */      _onOptionsUpdated({options}) { -        if (options === null) { return; }          const {enabled, autoPlay, volume, sources} = options.audio;          this._autoPlay = enabled && autoPlay;          this._playbackVolume = Number.isFinite(volume) ? Math.max(0.0, Math.min(1.0, volume / 100.0)) : 1.0; +        /** @type {Set<import('settings').AudioSourceType>} */          const requiredAudioSources = new Set([              'jpod101',              'jpod101-alternate',              'jisho'          ]); +        /** @type {Map<string, import('display-audio').AudioSource[]>} */          const nameMap = new Map();          this._audioSources.length = 0;          for (const {type, url, voice} of sources) { @@ -147,6 +189,7 @@ export class DisplayAudio {          this._cache.clear();      } +    /** */      _onContentClear() {          this._entriesToken = {};          this._cache.clear(); @@ -154,6 +197,9 @@ export class DisplayAudio {          this._eventListeners.removeAllEventListeners();      } +    /** +     * @param {import('display').ContentUpdateEntryEvent} details +     */      _onContentUpdateEntry({element}) {          const eventListeners = this._eventListeners;          for (const button of element.querySelectorAll('.action-button[data-action=play-audio]')) { @@ -163,6 +209,7 @@ export class DisplayAudio {          }      } +    /** */      _onContentUpdateComplete() {          if (!this._autoPlay || !this._display.frameVisible) { return; } @@ -186,6 +233,9 @@ export class DisplayAudio {          }      } +    /** +     * @param {import('display').FrameVisibilityChangeEvent} details +     */      _onFrameVisibilityChange({value}) {          if (!value) {              // The auto-play timer is stopped, but any audio that has already started playing @@ -194,18 +244,31 @@ export class DisplayAudio {          }      } +    /** */      _onHotkeyActionPlayAudio() {          this.playAudio(this._display.selectedIndex, 0);      } +    /** +     * @param {unknown} source +     */      _onHotkeyActionPlayAudioFromSource(source) { +        if (!(typeof source === 'string' || typeof source === 'undefined' || source === null)) { return; }          this.playAudio(this._display.selectedIndex, 0, source);      } +    /** */      _onMessageClearAutoPlayTimer() {          this.clearAutoPlayTimer();      } +    /** +     * @param {import('settings').AudioSourceType} type +     * @param {string} url +     * @param {string} voice +     * @param {boolean} isInOptions +     * @param {Map<string, import('display-audio').AudioSource[]>} nameMap +     */      _addAudioSourceInfo(type, url, voice, isInOptions, nameMap) {          const index = this._audioSources.length;          const downloadable = this._sourceIsDownloadable(type); @@ -222,6 +285,7 @@ export class DisplayAudio {              entries[0].nameUnique = false;          } +        /** @type {import('display-audio').AudioSource} */          const source = {              index,              type, @@ -238,32 +302,41 @@ export class DisplayAudio {          this._audioSources.push(source);      } +    /** +     * @param {MouseEvent} e +     */      _onAudioPlayButtonClick(e) {          e.preventDefault(); -        const button = e.currentTarget; +        const button = /** @type {HTMLButtonElement} */ (e.currentTarget);          const headwordIndex = this._getAudioPlayButtonHeadwordIndex(button);          const dictionaryEntryIndex = this._display.getElementDictionaryEntryIndex(button);          if (e.shiftKey) { -            this._showAudioMenu(e.currentTarget, dictionaryEntryIndex, headwordIndex); +            this._showAudioMenu(button, dictionaryEntryIndex, headwordIndex);          } else {              this.playAudio(dictionaryEntryIndex, headwordIndex);          }      } +    /** +     * @param {MouseEvent} e +     */      _onAudioPlayButtonContextMenu(e) {          e.preventDefault(); -        const button = e.currentTarget; +        const button = /** @type {HTMLButtonElement} */ (e.currentTarget);          const headwordIndex = this._getAudioPlayButtonHeadwordIndex(button);          const dictionaryEntryIndex = this._display.getElementDictionaryEntryIndex(button); -        this._showAudioMenu(e.currentTarget, dictionaryEntryIndex, headwordIndex); +        this._showAudioMenu(button, dictionaryEntryIndex, headwordIndex);      } +    /** +     * @param {import('popup-menu').MenuCloseEvent} e +     */      _onAudioPlayMenuCloseClick(e) { -        const button = e.currentTarget; +        const button = /** @type {Element} */ (e.currentTarget);          const headwordIndex = this._getAudioPlayButtonHeadwordIndex(button);          const dictionaryEntryIndex = this._display.getElementDictionaryEntryIndex(button); @@ -282,6 +355,12 @@ export class DisplayAudio {          }      } +    /** +     * @param {string} term +     * @param {string} reading +     * @param {boolean} create +     * @returns {import('display-audio').CacheItem|undefined} +     */      _getCacheItem(term, reading, create) {          const key = this._getTermReadingKey(term, reading);          let cacheEntry = this._cache.get(key); @@ -295,31 +374,41 @@ export class DisplayAudio {          return cacheEntry;      } +    /** +     * @param {Element} item +     * @returns {import('display-audio').SourceInfo} +     */      _getMenuItemSourceInfo(item) { -        const group = item.closest('.popup-menu-item-group'); +        const group = /** @type {?HTMLElement} */ (item.closest('.popup-menu-item-group'));          if (group !== null) { -            let {index, subIndex} = group.dataset; -            index = Number.parseInt(index, 10); -            if (index >= 0 && index < this._audioSources.length) { -                const source = this._audioSources[index]; -                if (typeof subIndex === 'string') { -                    subIndex = Number.parseInt(subIndex, 10); -                } else { -                    subIndex = null; +            const {index, subIndex} = group.dataset; +            if (typeof index === 'string') { +                const indexNumber = Number.parseInt(index, 10); +                if (indexNumber >= 0 && indexNumber < this._audioSources.length) { +                    return { +                        source: this._audioSources[indexNumber], +                        subIndex: typeof subIndex === 'string' ? Number.parseInt(subIndex, 10) : null +                    };                  } -                return {source, subIndex};              }          }          return {source: null, subIndex: null};      } +    /** +     * @param {number} dictionaryEntryIndex +     * @param {number} headwordIndex +     * @param {import('display-audio').AudioSource[]} sources +     * @param {?number} audioInfoListIndex +     * @returns {Promise<import('display-audio').PlayAudioResult>} +     */      async _playAudio(dictionaryEntryIndex, headwordIndex, sources, audioInfoListIndex) {          this.stopAudio();          this.clearAutoPlayTimer();          const headword = this._getHeadword(dictionaryEntryIndex, headwordIndex);          if (headword === null) { -            return {audio: null, source: null, valid: false}; +            return {audio: null, source: null, subIndex: 0, valid: false};          }          const buttons = this._getAudioPlayButtons(dictionaryEntryIndex, headwordIndex); @@ -377,7 +466,13 @@ export class DisplayAudio {          }      } +    /** +     * @param {number} dictionaryEntryIndex +     * @param {number} headwordIndex +     * @param {?HTMLElement} item +     */      async _playAudioFromSource(dictionaryEntryIndex, headwordIndex, item) { +        if (item === null) { return; }          const {source, subIndex} = this._getMenuItemSourceInfo(item);          if (source === null) { return; } @@ -392,7 +487,15 @@ export class DisplayAudio {          }      } +    /** +     * @param {number} dictionaryEntryIndex +     * @param {number} headwordIndex +     * @param {?HTMLElement} item +     * @param {?PopupMenu} menu +     * @param {boolean} canToggleOff +     */      _setPrimaryAudio(dictionaryEntryIndex, headwordIndex, item, menu, canToggleOff) { +        if (item === null) { return; }          const {source, subIndex} = this._getMenuItemSourceInfo(item);          if (source === null || !source.downloadable) { return; } @@ -402,6 +505,7 @@ export class DisplayAudio {          const {index} = source;          const {term, reading} = headword;          const cacheEntry = this._getCacheItem(term, reading, true); +        if (typeof cacheEntry === 'undefined') { return; }          let {primaryCardAudio} = cacheEntry;          primaryCardAudio = ( @@ -417,39 +521,59 @@ export class DisplayAudio {          }      } +    /** +     * @param {Element} button +     * @returns {number} +     */      _getAudioPlayButtonHeadwordIndex(button) { -        const headwordNode = button.closest('.headword'); +        const headwordNode = /** @type {?HTMLElement} */ (button.closest('.headword'));          if (headwordNode !== null) { -            const headwordIndex = parseInt(headwordNode.dataset.index, 10); -            if (Number.isFinite(headwordIndex)) { return headwordIndex; } +            const {index} = headwordNode.dataset; +            if (typeof index === 'string') { +                const headwordIndex = parseInt(index, 10); +                if (Number.isFinite(headwordIndex)) { return headwordIndex; } +            }          }          return 0;      } +    /** +     * @param {number} dictionaryEntryIndex +     * @param {number} headwordIndex +     * @returns {HTMLButtonElement[]} +     */      _getAudioPlayButtons(dictionaryEntryIndex, headwordIndex) {          const results = [];          const {dictionaryEntryNodes} = this._display;          if (dictionaryEntryIndex >= 0 && dictionaryEntryIndex < dictionaryEntryNodes.length) {              const node = dictionaryEntryNodes[dictionaryEntryIndex]; -            const button1 = (headwordIndex === 0 ? node.querySelector('.action-button[data-action=play-audio]') : null); -            const button2 = node.querySelector(`.headword:nth-of-type(${headwordIndex + 1}) .action-button[data-action=play-audio]`); +            const button1 = /** @type {?HTMLButtonElement} */ ((headwordIndex === 0 ? node.querySelector('.action-button[data-action=play-audio]') : null)); +            const button2 = /** @type {?HTMLButtonElement} */ (node.querySelector(`.headword:nth-of-type(${headwordIndex + 1}) .action-button[data-action=play-audio]`));              if (button1 !== null) { results.push(button1); }              if (button2 !== null) { results.push(button2); }          }          return results;      } +    /** +     * @param {string} term +     * @param {string} reading +     * @param {import('display-audio').AudioSource[]} sources +     * @param {?number} audioInfoListIndex +     * @returns {Promise<?import('display-audio').TermAudio>} +     */      async _createTermAudio(term, reading, sources, audioInfoListIndex) { -        const {sourceMap} = this._getCacheItem(term, reading, true); +        const cacheItem = this._getCacheItem(term, reading, true); +        if (typeof cacheItem === 'undefined') { return null; } +        const {sourceMap} = cacheItem;          for (const source of sources) {              const {index} = source;              let cacheUpdated = false; -            let infoListPromise;              let sourceInfo = sourceMap.get(index);              if (typeof sourceInfo === 'undefined') { -                infoListPromise = this._getTermAudioInfoList(source, term, reading); +                const infoListPromise = this._getTermAudioInfoList(source, term, reading);                  sourceInfo = {infoListPromise, infoList: null};                  sourceMap.set(index, sourceInfo);                  cacheUpdated = true; @@ -457,7 +581,7 @@ export class DisplayAudio {              let {infoList} = sourceInfo;              if (infoList === null) { -                infoList = await infoListPromise; +                infoList = await sourceInfo.infoListPromise;                  sourceInfo.infoList = infoList;              } @@ -471,6 +595,12 @@ export class DisplayAudio {          return null;      } +    /** +     * @param {import('display-audio').AudioSource} source +     * @param {import('display-audio').AudioInfoList} infoList +     * @param {?number} audioInfoListIndex +     * @returns {Promise<import('display-audio').CreateAudioResult>} +     */      async _createAudioFromInfoList(source, infoList, audioInfoListIndex) {          let start = 0;          let end = infoList.length; @@ -479,6 +609,7 @@ export class DisplayAudio {              end = Math.max(0, Math.min(end, audioInfoListIndex + 1));          } +        /** @type {import('display-audio').CreateAudioResult} */          const result = {              audio: null,              index: -1, @@ -518,6 +649,11 @@ export class DisplayAudio {          return result;      } +    /** +     * @param {import('audio-downloader').Info} info +     * @param {import('display-audio').AudioSource} source +     * @returns {Promise<import('display-audio').GenericAudio>} +     */      async _createAudioFromInfo(info, source) {          switch (info.type) {              case 'url': @@ -525,16 +661,27 @@ export class DisplayAudio {              case 'tts':                  return this._audioSystem.createTextToSpeechAudio(info.text, info.voice);              default: -                throw new Error(`Unsupported type: ${info.type}`); +                throw new Error(`Unsupported type: ${/** @type {import('core').SafeAny} */ (info).type}`);          }      } +    /** +     * @param {import('display-audio').AudioSource} source +     * @param {string} term +     * @param {string} reading +     * @returns {Promise<import('display-audio').AudioInfoList>} +     */      async _getTermAudioInfoList(source, term, reading) {          const sourceData = this._getSourceData(source);          const infoList = await yomitan.api.getTermAudioInfoList(sourceData, term, reading);          return infoList.map((info) => ({info, audioPromise: null, audioResolved: false, audio: null}));      } +    /** +     * @param {number} dictionaryEntryIndex +     * @param {number} headwordIndex +     * @returns {?import('dictionary').TermHeadword} +     */      _getHeadword(dictionaryEntryIndex, headwordIndex) {          const {dictionaryEntries} = this._display;          if (dictionaryEntryIndex < 0 || dictionaryEntryIndex >= dictionaryEntries.length) { return null; } @@ -548,10 +695,19 @@ export class DisplayAudio {          return headwords[headwordIndex];      } +    /** +     * @param {string} term +     * @param {string} reading +     * @returns {string} +     */      _getTermReadingKey(term, reading) {          return JSON.stringify([term, reading]);      } +    /** +     * @param {HTMLButtonElement} button +     * @param {?number} potentialAvailableAudioCount +     */      _updateAudioPlayButtonBadge(button, potentialAvailableAudioCount) {          if (potentialAvailableAudioCount === null) {              delete button.dataset.potentialAvailableAudioCount; @@ -559,27 +715,32 @@ export class DisplayAudio {              button.dataset.potentialAvailableAudioCount = `${potentialAvailableAudioCount}`;          } -        const badge = button.querySelector('.action-button-badge'); +        const badge = /** @type {?HTMLElement} */ (button.querySelector('.action-button-badge'));          if (badge === null) { return; }          const badgeData = badge.dataset;          switch (potentialAvailableAudioCount) {              case 0:                  badgeData.icon = 'cross'; -                badgeData.hidden = false; +                badge.hidden = false;                  break;              case 1:              case null:                  delete badgeData.icon; -                badgeData.hidden = true; +                badge.hidden = true;                  break;              default:                  badgeData.icon = 'plus-thick'; -                badgeData.hidden = false; +                badge.hidden = false;                  break;          }      } +    /** +     * @param {string} term +     * @param {string} reading +     * @returns {?number} +     */      _getPotentialAvailableAudioCount(term, reading) {          const cacheEntry = this._getCacheItem(term, reading, false);          if (typeof cacheEntry === 'undefined') { return null; } @@ -597,6 +758,11 @@ export class DisplayAudio {          return count;      } +    /** +     * @param {HTMLButtonElement} button +     * @param {number} dictionaryEntryIndex +     * @param {number} headwordIndex +     */      _showAudioMenu(button, dictionaryEntryIndex, headwordIndex) {          const headword = this._getHeadword(dictionaryEntryIndex, headwordIndex);          if (headword === null) { return; } @@ -608,10 +774,17 @@ export class DisplayAudio {          popupMenu.on('close', this._onPopupMenuClose.bind(this));      } +    /** +     * @param {import('popup-menu').MenuCloseEventDetails} details +     */      _onPopupMenuClose({menu}) {          this._openMenus.delete(menu);      } +    /** +     * @param {import('settings').AudioSourceType} source +     * @returns {boolean} +     */      _sourceIsDownloadable(source) {          switch (source) {              case 'text-to-speech': @@ -622,10 +795,16 @@ export class DisplayAudio {          }      } +    /** +     * @param {HTMLButtonElement} sourceButton +     * @param {string} term +     * @param {string} reading +     * @returns {PopupMenu} +     */      _createMenu(sourceButton, term, reading) {          // Create menu -        const menuContainerNode = this._display.displayGenerator.instantiateTemplate('audio-button-popup-menu'); -        const menuBodyNode = menuContainerNode.querySelector('.popup-menu-body'); +        const menuContainerNode = /** @type {HTMLElement} */ (this._display.displayGenerator.instantiateTemplate('audio-button-popup-menu')); +        const menuBodyNode = /** @type {HTMLElement} */ (menuContainerNode.querySelector('.popup-menu-body'));          menuContainerNode.dataset.term = term;          menuContainerNode.dataset.reading = reading; @@ -640,6 +819,12 @@ export class DisplayAudio {          return new PopupMenu(sourceButton, menuContainerNode);      } +    /** +     * @param {HTMLElement} menuContainerNode +     * @param {HTMLElement} menuItemContainer +     * @param {string} term +     * @param {string} reading +     */      _createMenuItems(menuContainerNode, menuItemContainer, term, reading) {          const {displayGenerator} = this._display;          let showIcons = false; @@ -649,12 +834,10 @@ export class DisplayAudio {              const entries = this._getMenuItemEntries(source, term, reading);              for (let i = 0, ii = entries.length; i < ii; ++i) {                  const {valid, index: subIndex, name: subName} = entries[i]; -                let node = this._getOrCreateMenuItem(currentItems, index, subIndex); -                if (node === null) { -                    node = displayGenerator.instantiateTemplate('audio-button-popup-menu-item'); -                } +                const existingNode = this._getOrCreateMenuItem(currentItems, index, subIndex); +                const node = existingNode !== null ? existingNode : /** @type {HTMLElement} */ (displayGenerator.instantiateTemplate('audio-button-popup-menu-item')); -                const labelNode = node.querySelector('.popup-menu-item-audio-button .popup-menu-item-label'); +                const labelNode = /** @type {HTMLElement} */ (node.querySelector('.popup-menu-item-audio-button .popup-menu-item-label'));                  let label = name;                  if (!nameUnique) {                      label = `${label} ${nameIndex + 1}`; @@ -664,11 +847,11 @@ export class DisplayAudio {                  if (typeof subName === 'string' && subName.length > 0) { label += `: ${subName}`; }                  labelNode.textContent = label; -                const cardButton = node.querySelector('.popup-menu-item-set-primary-audio-button'); +                const cardButton = /** @type {HTMLElement} */ (node.querySelector('.popup-menu-item-set-primary-audio-button'));                  cardButton.hidden = !downloadable;                  if (valid !== null) { -                    const icon = node.querySelector('.popup-menu-item-audio-button .popup-menu-item-icon'); +                    const icon = /** @type {HTMLElement} */ (node.querySelector('.popup-menu-item-audio-button .popup-menu-item-icon'));                      icon.dataset.icon = valid ? 'checkmark' : 'cross';                      showIcons = true;                  } @@ -691,16 +874,22 @@ export class DisplayAudio {          menuContainerNode.dataset.showIcons = `${showIcons}`;      } +    /** +     * @param {Element[]} currentItems +     * @param {number} index +     * @param {?number} subIndex +     * @returns {?HTMLElement} +     */      _getOrCreateMenuItem(currentItems, index, subIndex) { -        index = `${index}`; -        subIndex = `${subIndex !== null ? subIndex : 0}`; +        const indexNumber = `${index}`; +        const subIndexNumber = `${subIndex !== null ? subIndex : 0}`;          for (let i = 0, ii = currentItems.length; i < ii; ++i) {              const node = currentItems[i]; -            if (index !== node.dataset.index) { continue; } +            if (!(node instanceof HTMLElement) || indexNumber !== node.dataset.index) { continue; }              let subIndex2 = node.dataset.subIndex;              if (typeof subIndex2 === 'undefined') { subIndex2 = '0'; } -            if (subIndex !== subIndex2) { continue; } +            if (subIndexNumber !== subIndex2) { continue; }              currentItems.splice(i, 1);              return node; @@ -708,6 +897,12 @@ export class DisplayAudio {          return null;      } +    /** +     * @param {import('display-audio').AudioSource} source +     * @param {string} term +     * @param {string} reading +     * @returns {import('display-audio').MenuItemEntry[]} +     */      _getMenuItemEntries(source, term, reading) {          const cacheEntry = this._getCacheItem(term, reading, false);          if (typeof cacheEntry !== 'undefined') { @@ -721,11 +916,12 @@ export class DisplayAudio {                          return [{valid: false, index: null, name: null}];                      } +                    /** @type {import('display-audio').MenuItemEntry[]} */                      const results = [];                      for (let i = 0; i < ii; ++i) {                          const {audio, audioResolved, info: {name}} = infoList[i];                          const valid = audioResolved ? (audio !== null) : null; -                        const entry = {valid, index: i, name}; +                        const entry = {valid, index: i, name: typeof name === 'string' ? name : null};                          results.push(entry);                      }                      return results; @@ -735,34 +931,52 @@ export class DisplayAudio {          return [{valid: null, index: null, name: null}];      } +    /** +     * @param {string} term +     * @param {string} reading +     * @returns {?import('display-audio').PrimaryCardAudio} +     */      _getPrimaryCardAudio(term, reading) {          const cacheEntry = this._getCacheItem(term, reading, false);          return typeof cacheEntry !== 'undefined' ? cacheEntry.primaryCardAudio : null;      } +    /** +     * @param {HTMLElement} menuBodyNode +     * @param {string} term +     * @param {string} reading +     */      _updateMenuPrimaryCardAudio(menuBodyNode, term, reading) {          const primaryCardAudio = this._getPrimaryCardAudio(term, reading);          const primaryCardAudioIndex = (primaryCardAudio !== null ? primaryCardAudio.index : null);          const primaryCardAudioSubIndex = (primaryCardAudio !== null ? primaryCardAudio.subIndex : null); -        const itemGroups = menuBodyNode.querySelectorAll('.popup-menu-item-group'); +        const itemGroups = /** @type {NodeListOf<HTMLElement>} */ (menuBodyNode.querySelectorAll('.popup-menu-item-group'));          for (const node of itemGroups) { -            let {index, subIndex} = node.dataset; -            index = Number.parseInt(index, 10); -            subIndex = typeof subIndex === 'string' ? Number.parseInt(subIndex, 10) : null; -            const isPrimaryCardAudio = (index === primaryCardAudioIndex && subIndex === primaryCardAudioSubIndex); +            const {index, subIndex} = node.dataset; +            if (typeof index !== 'string') { continue; } +            const indexNumber = Number.parseInt(index, 10); +            const subIndexNumber = typeof subIndex === 'string' ? Number.parseInt(subIndex, 10) : null; +            const isPrimaryCardAudio = (indexNumber === primaryCardAudioIndex && subIndexNumber === primaryCardAudioSubIndex);              node.dataset.isPrimaryCardAudio = `${isPrimaryCardAudio}`;          }      } +    /** */      _updateOpenMenu() {          for (const menu of this._openMenus) {              const menuContainerNode = menu.containerNode;              const {term, reading} = menuContainerNode.dataset; -            this._createMenuItems(menuContainerNode, menu.bodyNode, term, reading); +            if (typeof term === 'string' && typeof reading === 'string') { +                this._createMenuItems(menuContainerNode, menu.bodyNode, term, reading); +            }              menu.updatePosition();          }      } +    /** +     * @param {import('display-audio').AudioSource} source +     * @returns {import('display-audio').AudioSourceShort} +     */      _getSourceData(source) {          const {type, url, voice} = source;          return {type, url, voice}; diff --git a/ext/js/display/display-content-manager.js b/ext/js/display/display-content-manager.js index fb2e7db5..fa8ad0fa 100644 --- a/ext/js/display/display-content-manager.js +++ b/ext/js/display/display-content-manager.js @@ -21,18 +21,6 @@ import {ArrayBufferUtil} from '../data/sandbox/array-buffer-util.js';  import {yomitan} from '../yomitan.js';  /** - * A callback used when a media file has been loaded. - * @callback DisplayContentManager.OnLoadCallback - * @param {string} url The URL of the media that was loaded. - */ - -/** - * A callback used when a media file should be unloaded. - * @callback DisplayContentManager.OnUnloadCallback - * @param {boolean} fullyLoaded Whether or not the media was fully loaded. - */ - -/**   * The content manager which is used when generating HTML display content.   */  export class DisplayContentManager { @@ -41,10 +29,15 @@ export class DisplayContentManager {       * @param {Display} display The display instance that owns this object.       */      constructor(display) { +        /** @type {Display} */          this._display = display; +        /** @type {import('core').TokenObject} */          this._token = {}; +        /** @type {Map<string, Map<string, Promise<?import('display-content-manager').CachedMediaDataLoaded>>>} */          this._mediaCache = new Map(); +        /** @type {import('display-content-manager').LoadMediaDataInfo[]} */          this._loadMediaData = []; +        /** @type {EventListenerCollection} */          this._eventListeners = new EventListenerCollection();      } @@ -52,9 +45,9 @@ export class DisplayContentManager {       * Attempts to load the media file from a given dictionary.       * @param {string} path The path to the media file in the dictionary.       * @param {string} dictionary The name of the dictionary. -     * @param {DisplayContentManager.OnLoadCallback} onLoad The callback that is executed if the media was loaded successfully. +     * @param {import('display-content-manager').OnLoadCallback} onLoad The callback that is executed if the media was loaded successfully.       *   No assumptions should be made about the synchronicity of this callback. -     * @param {DisplayContentManager.OnUnloadCallback} onUnload The callback that is executed when the media should be unloaded. +     * @param {import('display-content-manager').OnUnloadCallback} onUnload The callback that is executed when the media should be unloaded.       */      loadMedia(path, dictionary, onLoad, onUnload) {          this._loadMedia(path, dictionary, onLoad, onUnload); @@ -72,10 +65,8 @@ export class DisplayContentManager {          this._loadMediaData = [];          for (const map of this._mediaCache.values()) { -            for (const {url} of map.values()) { -                if (url !== null) { -                    URL.revokeObjectURL(url); -                } +            for (const result of map.values()) { +                this._revokeUrl(result);              }          }          this._mediaCache.clear(); @@ -87,7 +78,7 @@ export class DisplayContentManager {      /**       * Sets up attributes and events for a link element. -     * @param {Element} element The link element. +     * @param {HTMLAnchorElement} element The link element.       * @param {string} href The URL.       * @param {boolean} internal Whether or not the URL is an internal or external link.       */ @@ -100,57 +91,71 @@ export class DisplayContentManager {          this._eventListeners.addEventListener(element, 'click', this._onLinkClick.bind(this));      } +    /** +     * @param {string} path +     * @param {string} dictionary +     * @param {import('display-content-manager').OnLoadCallback} onLoad +     * @param {import('display-content-manager').OnUnloadCallback} onUnload +     */      async _loadMedia(path, dictionary, onLoad, onUnload) {          const token = this._token; -        const data = {onUnload, loaded: false}; - -        this._loadMediaData.push(data); -          const media = await this._getMedia(path, dictionary); -        if (token !== this._token) { return; } +        if (token !== this._token || media === null) { return; } +        /** @type {import('display-content-manager').LoadMediaDataInfo} */ +        const data = {onUnload, loaded: false}; +        this._loadMediaData.push(data);          onLoad(media.url);          data.loaded = true;      } -    async _getMedia(path, dictionary) { -        let cachedData; +    /** +     * @param {string} path +     * @param {string} dictionary +     * @returns {Promise<?import('display-content-manager').CachedMediaDataLoaded>} +     */ +    _getMedia(path, dictionary) { +        /** @type {Promise<?import('display-content-manager').CachedMediaDataLoaded>|undefined} */ +        let promise;          let dictionaryCache = this._mediaCache.get(dictionary);          if (typeof dictionaryCache !== 'undefined') { -            cachedData = dictionaryCache.get(path); +            promise = dictionaryCache.get(path);          } else {              dictionaryCache = new Map();              this._mediaCache.set(dictionary, dictionaryCache);          } -        if (typeof cachedData === 'undefined') { -            cachedData = { -                promise: null, -                data: null, -                url: null -            }; -            dictionaryCache.set(path, cachedData); -            cachedData.promise = this._getMediaData(path, dictionary, cachedData); +        if (typeof promise === 'undefined') { +            promise = this._getMediaData(path, dictionary); +            dictionaryCache.set(path, promise);          } -        return cachedData.promise; +        return promise;      } -    async _getMediaData(path, dictionary, cachedData) { +    /** +     * @param {string} path +     * @param {string} dictionary +     * @returns {Promise<?import('display-content-manager').CachedMediaDataLoaded>} +     */ +    async _getMediaData(path, dictionary) {          const token = this._token; -        const data = (await yomitan.api.getMedia([{path, dictionary}]))[0]; -        if (token === this._token && data !== null) { +        const datas = await yomitan.api.getMedia([{path, dictionary}]); +        if (token === this._token && datas.length > 0) { +            const data = datas[0];              const buffer = ArrayBufferUtil.base64ToArrayBuffer(data.content);              const blob = new Blob([buffer], {type: data.mediaType});              const url = URL.createObjectURL(blob); -            cachedData.data = data; -            cachedData.url = url; +            return {data, url};          } -        return cachedData; +        return null;      } +    /** +     * @param {MouseEvent} e +     */      _onLinkClick(e) { -        const {href} = e.currentTarget; +        const {href} = /** @type {HTMLAnchorElement} */ (e.currentTarget);          if (typeof href !== 'string') { return; }          const baseUrl = new URL(location.href); @@ -160,6 +165,7 @@ export class DisplayContentManager {          e.preventDefault(); +        /** @type {import('display').HistoryParams} */          const params = {};          for (const [key, value] of url.searchParams.entries()) {              params[key] = value; @@ -172,4 +178,13 @@ export class DisplayContentManager {              content: null          });      } + +    /** +     * @param {Promise<?import('display-content-manager').CachedMediaDataLoaded>} data +     */ +    async _revokeUrl(data) { +        const result = await data; +        if (result === null) { return; } +        URL.revokeObjectURL(result.url); +    }  } diff --git a/ext/js/display/display-generator.js b/ext/js/display/display-generator.js index e8a2104f..9fc700f3 100644 --- a/ext/js/display/display-generator.js +++ b/ext/js/display/display-generator.js @@ -24,21 +24,32 @@ import {PronunciationGenerator} from './sandbox/pronunciation-generator.js';  import {StructuredContentGenerator} from './sandbox/structured-content-generator.js';  export class DisplayGenerator { +    /** +     * @param {import('display').DisplayGeneratorConstructorDetails} details +     */      constructor({japaneseUtil, contentManager, hotkeyHelpController=null}) { +        /** @type {JapaneseUtil} */          this._japaneseUtil = japaneseUtil; +        /** @type {DisplayContentManager} */          this._contentManager = contentManager; +        /** @type {?HotkeyHelpController} */          this._hotkeyHelpController = hotkeyHelpController; -        this._templates = null; +        /** @type {HtmlTemplateCollection} */ +        this._templates = new HtmlTemplateCollection(); +        /** @type {StructuredContentGenerator} */          this._structuredContentGenerator = new StructuredContentGenerator(this._contentManager, japaneseUtil, document); +        /** @type {PronunciationGenerator} */          this._pronunciationGenerator = new PronunciationGenerator(japaneseUtil);      } +    /** */      async prepare() {          const html = await yomitan.api.getDisplayTemplatesHtml(); -        this._templates = new HtmlTemplateCollection(html); +        this._templates.load(html);          this.updateHotkeys();      } +    /** */      updateHotkeys() {          const hotkeyHelpController = this._hotkeyHelpController;          if (hotkeyHelpController === null) { return; } @@ -47,15 +58,19 @@ export class DisplayGenerator {          }      } +    /** +     * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry +     * @returns {HTMLElement} +     */      createTermEntry(dictionaryEntry) { -        const node = this._templates.instantiate('term-entry'); +        const node = this._instantiate('term-entry'); -        const headwordsContainer = node.querySelector('.headword-list'); -        const inflectionsContainer = node.querySelector('.inflection-list'); -        const groupedPronunciationsContainer = node.querySelector('.pronunciation-group-list'); -        const frequencyGroupListContainer = node.querySelector('.frequency-group-list'); -        const definitionsContainer = node.querySelector('.definition-list'); -        const headwordTagsContainer = node.querySelector('.headword-list-tag-list'); +        const headwordsContainer = this._querySelector(node, '.headword-list'); +        const inflectionsContainer = this._querySelector(node, '.inflection-list'); +        const groupedPronunciationsContainer = this._querySelector(node, '.pronunciation-group-list'); +        const frequencyGroupListContainer = this._querySelector(node, '.frequency-group-list'); +        const definitionsContainer = this._querySelector(node, '.definition-list'); +        const headwordTagsContainer = this._querySelector(node, '.headword-list-tag-list');          const {headwords, type, inflections, definitions, frequencies, pronunciations} = dictionaryEntry;          const groupedPronunciations = DictionaryDataUtil.getGroupedPronunciations(dictionaryEntry); @@ -63,8 +78,11 @@ export class DisplayGenerator {          const groupedFrequencies = DictionaryDataUtil.groupTermFrequencies(dictionaryEntry);          const termTags = DictionaryDataUtil.groupTermTags(dictionaryEntry); +        /** @type {Set<string>} */          const uniqueTerms = new Set(); +        /** @type {Set<string>} */          const uniqueReadings = new Set(); +        /** @type {Set<import('dictionary').TermSourceMatchType>} */          const primaryMatchTypes = new Set();          for (const {term, reading, sources} of headwords) {              uniqueTerms.add(term); @@ -107,16 +125,16 @@ export class DisplayGenerator {          }          // Add definitions -        const dictionaryTag = this._createDictionaryTag(null); +        const dictionaryTag = this._createDictionaryTag('');          for (let i = 0, ii = definitions.length; i < ii; ++i) {              const definition = definitions[i];              const {dictionary} = definition; -            if (dictionaryTag.dictionary === dictionary) { +            if (dictionaryTag.dictionaries.includes(dictionary)) {                  dictionaryTag.redundant = true;              } else {                  dictionaryTag.redundant = false; -                dictionaryTag.dictionary = dictionary; +                dictionaryTag.dictionaries.push(dictionary);                  dictionaryTag.name = dictionary;              } @@ -129,19 +147,23 @@ export class DisplayGenerator {          return node;      } +    /** +     * @param {import('dictionary').KanjiDictionaryEntry} dictionaryEntry +     * @returns {HTMLElement} +     */      createKanjiEntry(dictionaryEntry) { -        const node = this._templates.instantiate('kanji-entry'); - -        const glyphContainer = node.querySelector('.kanji-glyph'); -        const frequencyGroupListContainer = node.querySelector('.frequency-group-list'); -        const tagContainer = node.querySelector('.kanji-tag-list'); -        const definitionsContainer = node.querySelector('.kanji-gloss-list'); -        const chineseReadingsContainer = node.querySelector('.kanji-readings-chinese'); -        const japaneseReadingsContainer = node.querySelector('.kanji-readings-japanese'); -        const statisticsContainer = node.querySelector('.kanji-statistics'); -        const classificationsContainer = node.querySelector('.kanji-classifications'); -        const codepointsContainer = node.querySelector('.kanji-codepoints'); -        const dictionaryIndicesContainer = node.querySelector('.kanji-dictionary-indices'); +        const node = this._instantiate('kanji-entry'); + +        const glyphContainer = this._querySelector(node, '.kanji-glyph'); +        const frequencyGroupListContainer = this._querySelector(node, '.frequency-group-list'); +        const tagContainer = this._querySelector(node, '.kanji-tag-list'); +        const definitionsContainer = this._querySelector(node, '.kanji-gloss-list'); +        const chineseReadingsContainer = this._querySelector(node, '.kanji-readings-chinese'); +        const japaneseReadingsContainer = this._querySelector(node, '.kanji-readings-japanese'); +        const statisticsContainer = this._querySelector(node, '.kanji-statistics'); +        const classificationsContainer = this._querySelector(node, '.kanji-classifications'); +        const codepointsContainer = this._querySelector(node, '.kanji-codepoints'); +        const dictionaryIndicesContainer = this._querySelector(node, '.kanji-dictionary-indices');          this._setTextContent(glyphContainer, dictionaryEntry.character, 'ja');          const groupedFrequencies = DictionaryDataUtil.groupKanjiFrequencies(dictionaryEntry.frequencies); @@ -162,27 +184,36 @@ export class DisplayGenerator {          return node;      } +    /** +     * @returns {HTMLElement} +     */      createEmptyFooterNotification() { -        return this._templates.instantiate('footer-notification'); +        return this._instantiate('footer-notification');      } +    /** +     * @param {HTMLElement} tagNode +     * @param {?import('dictionary').DictionaryEntry} dictionaryEntry +     * @returns {DocumentFragment} +     */      createTagFooterNotificationDetails(tagNode, dictionaryEntry) {          const node = this._templates.instantiateFragment('footer-notification-tag-details');          let details = tagNode.dataset.details;          if (typeof details !== 'string') {              const label = tagNode.querySelector('.tag-label-content'); -            details = label !== null ? label.textContent : ''; +            details = label !== null && label.textContent !== null ? label.textContent : '';          } -        this._setTextContent(node.querySelector('.tag-details'), details); +        const tagDetails = this._querySelector(node, '.tag-details'); +        this._setTextContent(tagDetails, details); -        if (dictionaryEntry !== null) { +        if (dictionaryEntry !== null && dictionaryEntry.type === 'term') {              const {headwords} = dictionaryEntry;              const disambiguationHeadwords = [];              const {headwords: headwordIndices} = tagNode.dataset;              if (typeof headwordIndices === 'string' && headwordIndices.length > 0) { -                for (let headwordIndex of headwordIndices.split(' ')) { -                    headwordIndex = Number.parseInt(headwordIndex, 10); +                for (const headwordIndexString of headwordIndices.split(' ')) { +                    const headwordIndex = Number.parseInt(headwordIndexString, 10);                      if (!Number.isNaN(headwordIndex) && headwordIndex >= 0 && headwordIndex < headwords.length) {                          disambiguationHeadwords.push(headwords[headwordIndex]);                      } @@ -190,7 +221,7 @@ export class DisplayGenerator {              }              if (disambiguationHeadwords.length > 0 && disambiguationHeadwords.length < headwords.length) { -                const disambiguationContainer = node.querySelector('.tag-details-disambiguation-list'); +                const disambiguationContainer = this._querySelector(node, '.tag-details-disambiguation-list');                  const copyAttributes = ['totalHeadwordCount', 'matchedHeadwordCount', 'unmatchedHeadwordCount'];                  for (const attribute of copyAttributes) {                      const value = tagNode.dataset[attribute]; @@ -211,13 +242,17 @@ export class DisplayGenerator {          return node;      } +    /** +     * @param {(DocumentFragment|Node|Error)[]} errors +     * @returns {HTMLElement} +     */      createAnkiNoteErrorsNotificationContent(errors) { -        const content = this._templates.instantiate('footer-notification-anki-errors-content'); +        const content = this._instantiate('footer-notification-anki-errors-content'); -        const header = content.querySelector('.anki-note-error-header'); +        const header = this._querySelector(content, '.anki-note-error-header');          this._setTextContent(header, (errors.length === 1 ? 'An error occurred:' : `${errors.length} errors occurred:`), 'en'); -        const list = content.querySelector('.anki-note-error-list'); +        const list = this._querySelector(content, '.anki-note-error-list');          for (const error of errors) {              const div = document.createElement('li');              div.className = 'anki-note-error-message'; @@ -226,11 +261,11 @@ export class DisplayGenerator {              } else {                  let message = isObject(error) && typeof error.message === 'string' ? error.message : `${error}`;                  let link = null; -                if (isObject(error) && isObject(error.data)) { -                    const {referenceUrl} = error.data; +                if (error instanceof ExtensionError && error.data !== null && typeof error.data === 'object') { +                    const {referenceUrl} = /** @type {import('core').UnknownObject} */ (error.data);                      if (typeof referenceUrl === 'string') {                          message = message.trimEnd(); -                        if (!/[.!?]^/.test()) { message += '.'; } +                        if (!/[.!?]^/.test(message)) { message += '.'; }                          message += ' ';                          link = document.createElement('a');                          link.href = referenceUrl; @@ -248,20 +283,37 @@ export class DisplayGenerator {          return content;      } +    /** +     * @returns {HTMLElement} +     */      createProfileListItem() { -        return this._templates.instantiate('profile-list-item'); +        return this._instantiate('profile-list-item');      } +    /** +     * @param {string} name +     * @returns {HTMLElement} +     */      instantiateTemplate(name) { -        return this._templates.instantiate(name); +        return this._instantiate(name);      } +    /** +     * @param {string} name +     * @returns {DocumentFragment} +     */      instantiateTemplateFragment(name) {          return this._templates.instantiateFragment(name);      }      // Private +    /** +     * @param {import('dictionary').TermHeadword} headword +     * @param {number} headwordIndex +     * @param {import('dictionary').TermPronunciation[]} pronunciations +     * @returns {HTMLElement} +     */      _createTermHeadword(headword, headwordIndex, pronunciations) {          const {term, reading, tags, sources} = headword; @@ -276,9 +328,9 @@ export class DisplayGenerator {              matchSources.add(matchSource);          } -        const node = this._templates.instantiate('headword'); +        const node = this._instantiate('headword'); -        const termContainer = node.querySelector('.headword-term'); +        const termContainer = this._querySelector(node, '.headword-term');          node.dataset.isPrimary = `${isPrimaryAny}`;          node.dataset.readingIsSame = `${reading === term}`; @@ -295,30 +347,43 @@ export class DisplayGenerator {              node.dataset.wordClasses = wordClasses.join(' ');          } -        this._setTextContent(node.querySelector('.headword-reading'), reading); +        const headwordReading = this._querySelector(node, '.headword-reading'); +        this._setTextContent(headwordReading, reading);          this._appendFurigana(termContainer, term, reading, this._appendKanjiLinks.bind(this));          return node;      } +    /** +     * @param {string} inflection +     * @returns {DocumentFragment} +     */      _createTermInflection(inflection) {          const fragment = this._templates.instantiateFragment('inflection'); -        const node = fragment.querySelector('.inflection'); +        const node = this._querySelector(fragment, '.inflection');          this._setTextContent(node, inflection);          node.dataset.reason = inflection;          return fragment;      } +    /** +     * @param {import('dictionary').TermDefinition} definition +     * @param {import('dictionary').Tag} dictionaryTag +     * @param {import('dictionary').TermHeadword[]} headwords +     * @param {Set<string>} uniqueTerms +     * @param {Set<string>} uniqueReadings +     * @returns {HTMLElement} +     */      _createTermDefinition(definition, dictionaryTag, headwords, uniqueTerms, uniqueReadings) {          const {dictionary, tags, headwordIndices, entries} = definition;          const disambiguations = DictionaryDataUtil.getDisambiguations(headwords, headwordIndices, uniqueTerms, uniqueReadings); -        const node = this._templates.instantiate('definition-item'); +        const node = this._instantiate('definition-item'); -        const tagListContainer = node.querySelector('.definition-tag-list'); -        const onlyListContainer = node.querySelector('.definition-disambiguation-list'); -        const entriesContainer = node.querySelector('.gloss-list'); +        const tagListContainer = this._querySelector(node, '.definition-tag-list'); +        const onlyListContainer = this._querySelector(node, '.definition-disambiguation-list'); +        const entriesContainer = this._querySelector(node, '.gloss-list');          node.dataset.dictionary = dictionary; @@ -329,6 +394,11 @@ export class DisplayGenerator {          return node;      } +    /** +     * @param {import('dictionary-data').TermGlossary} entry +     * @param {string} dictionary +     * @returns {?HTMLElement} +     */      _createTermDefinitionEntry(entry, dictionary) {          if (typeof entry === 'string') {              return this._createTermDefinitionEntryText(entry); @@ -344,25 +414,34 @@ export class DisplayGenerator {          return null;      } +    /** +     * @param {string} text +     * @returns {HTMLElement} +     */      _createTermDefinitionEntryText(text) { -        const node = this._templates.instantiate('gloss-item'); -        const container = node.querySelector('.gloss-content'); +        const node = this._instantiate('gloss-item'); +        const container = this._querySelector(node, '.gloss-content');          this._setMultilineTextContent(container, text);          return node;      } +    /** +     * @param {import('dictionary-data').TermGlossaryImage} data +     * @param {string} dictionary +     * @returns {HTMLElement} +     */      _createTermDefinitionEntryImage(data, dictionary) {          const {description} = data; -        const node = this._templates.instantiate('gloss-item'); +        const node = this._instantiate('gloss-item'); -        const contentContainer = node.querySelector('.gloss-content'); +        const contentContainer = this._querySelector(node, '.gloss-content');          const image = this._structuredContentGenerator.createDefinitionImage(data, dictionary);          contentContainer.appendChild(image);          if (typeof description === 'string') {              const fragment = this._templates.instantiateFragment('gloss-item-image-description'); -            const container = fragment.querySelector('.gloss-image-description'); +            const container = this._querySelector(fragment, '.gloss-image-description');              this._setMultilineTextContent(container, description);              contentContainer.appendChild(fragment);          } @@ -370,20 +449,33 @@ export class DisplayGenerator {          return node;      } +    /** +     * @param {import('structured-content').Content} content +     * @param {string} dictionary +     * @returns {HTMLElement} +     */      _createTermDefinitionEntryStructuredContent(content, dictionary) { -        const node = this._templates.instantiate('gloss-item'); -        const contentContainer = node.querySelector('.gloss-content'); +        const node = this._instantiate('gloss-item'); +        const contentContainer = this._querySelector(node, '.gloss-content');          this._structuredContentGenerator.appendStructuredContent(contentContainer, content, dictionary);          return node;      } +    /** +     * @param {string} disambiguation +     * @returns {HTMLElement} +     */      _createTermDisambiguation(disambiguation) { -        const node = this._templates.instantiate('definition-disambiguation'); +        const node = this._instantiate('definition-disambiguation');          node.dataset.term = disambiguation;          this._setTextContent(node, disambiguation, 'ja');          return node;      } +    /** +     * @param {string} character +     * @returns {HTMLAnchorElement} +     */      _createKanjiLink(character) {          const node = document.createElement('a');          node.className = 'headword-kanji-link'; @@ -391,22 +483,34 @@ export class DisplayGenerator {          return node;      } +    /** +     * @param {string} text +     * @returns {HTMLElement} +     */      _createKanjiDefinition(text) { -        const node = this._templates.instantiate('kanji-gloss-item'); -        const container = node.querySelector('.kanji-gloss-content'); +        const node = this._instantiate('kanji-gloss-item'); +        const container = this._querySelector(node, '.kanji-gloss-content');          this._setMultilineTextContent(container, text);          return node;      } +    /** +     * @param {string} reading +     * @returns {HTMLElement} +     */      _createKanjiReading(reading) { -        const node = this._templates.instantiate('kanji-reading'); +        const node = this._instantiate('kanji-reading');          this._setTextContent(node, reading, 'ja');          return node;      } +    /** +     * @param {import('dictionary').KanjiStat[]} details +     * @returns {HTMLElement} +     */      _createKanjiInfoTable(details) { -        const node = this._templates.instantiate('kanji-info-table'); -        const container = node.querySelector('.kanji-info-table-body'); +        const node = this._instantiate('kanji-info-table'); +        const container = this._querySelector(node, '.kanji-info-table-body');          const count = this._appendMultiple(container, this._createKanjiInfoTableItem.bind(this), details);          if (count === 0) { @@ -417,25 +521,36 @@ export class DisplayGenerator {          return node;      } +    /** +     * @param {import('dictionary').KanjiStat} details +     * @returns {HTMLElement} +     */      _createKanjiInfoTableItem(details) {          const {content, name, value} = details; -        const node = this._templates.instantiate('kanji-info-table-item'); -        const nameNode = node.querySelector('.kanji-info-table-item-header'); -        const valueNode = node.querySelector('.kanji-info-table-item-value'); +        const node = this._instantiate('kanji-info-table-item'); +        const nameNode = this._querySelector(node, '.kanji-info-table-item-header'); +        const valueNode = this._querySelector(node, '.kanji-info-table-item-value');          this._setTextContent(nameNode, content.length > 0 ? content : name); -        this._setTextContent(valueNode, value); +        this._setTextContent(valueNode, typeof value === 'string' ? value : `${value}`);          return node;      } +    /** +     * @returns {HTMLElement} +     */      _createKanjiInfoTableItemEmpty() { -        return this._templates.instantiate('kanji-info-table-empty'); +        return this._instantiate('kanji-info-table-empty');      } +    /** +     * @param {import('dictionary').Tag} tag +     * @returns {HTMLElement} +     */      _createTag(tag) {          const {content, name, category, redundant} = tag; -        const node = this._templates.instantiate('tag'); +        const node = this._instantiate('tag'); -        const inner = node.querySelector('.tag-label-content'); +        const inner = this._querySelector(node, '.tag-label-content');          const contentString = content.join('\n'); @@ -448,6 +563,11 @@ export class DisplayGenerator {          return node;      } +    /** +     * @param {import('dictionary-data-util').TagGroup} tagInfo +     * @param {number} totalHeadwordCount +     * @returns {HTMLElement} +     */      _createTermTag(tagInfo, totalHeadwordCount) {          const {tag, headwordIndices} = tagInfo;          const node = this._createTag(tag); @@ -458,6 +578,11 @@ export class DisplayGenerator {          return node;      } +    /** +     * @param {string} name +     * @param {string} category +     * @returns {import('dictionary').Tag} +     */      _createTagData(name, category) {          return {              name, @@ -470,20 +595,29 @@ export class DisplayGenerator {          };      } +    /** +     * @param {string} text +     * @returns {HTMLElement} +     */      _createSearchTag(text) {          return this._createTag(this._createTagData(text, 'search'));      } +    /** +     * @param {import('dictionary-data-util').DictionaryGroupedPronunciations} details +     * @returns {HTMLElement} +     */      _createGroupedPronunciation(details) {          const {dictionary, pronunciations} = details; -        const node = this._templates.instantiate('pronunciation-group'); +        const node = this._instantiate('pronunciation-group');          node.dataset.dictionary = dictionary;          node.dataset.pronunciationsMulti = 'true';          node.dataset.pronunciationsCount = `${pronunciations.length}`; +        const n1 = this._querySelector(node, '.pronunciation-group-tag-list');          const tag = this._createTag(this._createTagData(dictionary, 'pronunciation-dictionary')); -        node.querySelector('.pronunciation-group-tag-list').appendChild(tag); +        n1.appendChild(tag);          let hasTags = false;          for (const {tags} of pronunciations) { @@ -493,54 +627,64 @@ export class DisplayGenerator {              }          } -        const n = node.querySelector('.pronunciation-list'); +        const n = this._querySelector(node, '.pronunciation-list');          n.dataset.hasTags = `${hasTags}`;          this._appendMultiple(n, this._createPronunciation.bind(this), pronunciations);          return node;      } +    /** +     * @param {import('dictionary-data-util').GroupedPronunciation} details +     * @returns {HTMLElement} +     */      _createPronunciation(details) {          const jp = this._japaneseUtil;          const {reading, position, nasalPositions, devoicePositions, tags, exclusiveTerms, exclusiveReadings} = details;          const morae = jp.getKanaMorae(reading); -        const node = this._templates.instantiate('pronunciation'); +        const node = this._instantiate('pronunciation');          node.dataset.pitchAccentDownstepPosition = `${position}`;          if (nasalPositions.length > 0) { node.dataset.nasalMoraPosition = nasalPositions.join(' '); }          if (devoicePositions.length > 0) { node.dataset.devoiceMoraPosition = devoicePositions.join(' '); }          node.dataset.tagCount = `${tags.length}`; -        let n = node.querySelector('.pronunciation-tag-list'); +        let n = this._querySelector(node, '.pronunciation-tag-list');          this._appendMultiple(n, this._createTag.bind(this), tags); -        n = node.querySelector('.pronunciation-disambiguation-list'); +        n = this._querySelector(node, '.pronunciation-disambiguation-list');          this._createPronunciationDisambiguations(n, exclusiveTerms, exclusiveReadings); -        n = node.querySelector('.pronunciation-downstep-notation-container'); +        n = this._querySelector(node, '.pronunciation-downstep-notation-container');          n.appendChild(this._pronunciationGenerator.createPronunciationDownstepPosition(position)); -        n = node.querySelector('.pronunciation-text-container'); +        n = this._querySelector(node, '.pronunciation-text-container');          n.lang = 'ja';          n.appendChild(this._pronunciationGenerator.createPronunciationText(morae, position, nasalPositions, devoicePositions)); -        node.querySelector('.pronunciation-graph-container').appendChild(this._pronunciationGenerator.createPronunciationGraph(morae, position)); +        n = this._querySelector(node, '.pronunciation-graph-container'); +        n.appendChild(this._pronunciationGenerator.createPronunciationGraph(morae, position));          return node;      } +    /** +     * @param {HTMLElement} container +     * @param {string[]} exclusiveTerms +     * @param {string[]} exclusiveReadings +     */      _createPronunciationDisambiguations(container, exclusiveTerms, exclusiveReadings) {          const templateName = 'pronunciation-disambiguation';          for (const term of exclusiveTerms) { -            const node = this._templates.instantiate(templateName); +            const node = this._instantiate(templateName);              node.dataset.type = 'term';              this._setTextContent(node, term, 'ja');              container.appendChild(node);          }          for (const exclusiveReading of exclusiveReadings) { -            const node = this._templates.instantiate(templateName); +            const node = this._instantiate(templateName);              node.dataset.type = 'reading';              this._setTextContent(node, exclusiveReading, 'ja');              container.appendChild(node); @@ -551,19 +695,29 @@ export class DisplayGenerator {          container.dataset.readingCount = `${exclusiveReadings.length}`;      } +    /** +     * @param {import('dictionary-data-util').DictionaryFrequency<import('dictionary-data-util').TermFrequency>|import('dictionary-data-util').DictionaryFrequency<import('dictionary-data-util').KanjiFrequency>} details +     * @param {boolean} kanji +     * @returns {HTMLElement} +     */      _createFrequencyGroup(details, kanji) {          const {dictionary, frequencies} = details; -        const node = this._templates.instantiate('frequency-group-item'); -        const body = node.querySelector('.tag-body-content'); +        const node = this._instantiate('frequency-group-item'); +        const body = this._querySelector(node, '.tag-body-content'); -        this._setTextContent(node.querySelector('.tag-label-content'), dictionary); +        const tagLabel = this._querySelector(node, '.tag-label-content'); +        this._setTextContent(tagLabel, dictionary);          node.dataset.details = dictionary;          const ii = frequencies.length;          for (let i = 0; i < ii; ++i) {              const item = frequencies[i]; -            const itemNode = (kanji ? this._createKanjiFrequency(item, dictionary) : this._createTermFrequency(item, dictionary)); +            const itemNode = ( +                kanji ? +                this._createKanjiFrequency(/** @type {import('dictionary-data-util').KanjiFrequency} */ (item), dictionary) : +                this._createTermFrequency(/** @type {import('dictionary-data-util').TermFrequency} */ (item), dictionary) +            );              itemNode.dataset.index = `${i}`;              body.appendChild(itemNode);          } @@ -575,18 +729,28 @@ export class DisplayGenerator {          return node;      } +    /** +     * @param {import('dictionary-data-util').TermFrequency} details +     * @param {string} dictionary +     * @returns {HTMLElement} +     */      _createTermFrequency(details, dictionary) {          const {term, reading, values} = details; -        const node = this._templates.instantiate('term-frequency-item'); +        const node = this._instantiate('term-frequency-item'); +        const tagLabel = this._querySelector(node, '.tag-label-content'); +        const disambiguationTerm = this._querySelector(node, '.frequency-disambiguation-term'); +        const disambiguationReading = this._querySelector(node, '.frequency-disambiguation-reading'); +        const frequencyValueList = this._querySelector(node, '.frequency-value-list'); -        this._setTextContent(node.querySelector('.tag-label-content'), dictionary); - -        this._setTextContent(node.querySelector('.frequency-disambiguation-term'), term, 'ja'); -        this._setTextContent(node.querySelector('.frequency-disambiguation-reading'), (reading !== null ? reading : ''), 'ja'); -        this._populateFrequencyValueList(node.querySelector('.frequency-value-list'), values); +        this._setTextContent(tagLabel, dictionary); +        this._setTextContent(disambiguationTerm, term, 'ja'); +        this._setTextContent(disambiguationReading, (reading !== null ? reading : ''), 'ja'); +        this._populateFrequencyValueList(frequencyValueList, values);          node.dataset.term = term; -        node.dataset.reading = reading; +        if (typeof reading === 'string') { +            node.dataset.reading = reading; +        }          node.dataset.hasReading = `${reading !== null}`;          node.dataset.readingIsSame = `${reading === term}`;          node.dataset.dictionary = dictionary; @@ -595,12 +759,19 @@ export class DisplayGenerator {          return node;      } +    /** +     * @param {import('dictionary-data-util').KanjiFrequency} details +     * @param {string} dictionary +     * @returns {HTMLElement} +     */      _createKanjiFrequency(details, dictionary) {          const {character, values} = details; -        const node = this._templates.instantiate('kanji-frequency-item'); +        const node = this._instantiate('kanji-frequency-item'); +        const tagLabel = this._querySelector(node, '.tag-label-content'); +        const frequencyValueList = this._querySelector(node, '.frequency-value-list'); -        this._setTextContent(node.querySelector('.tag-label-content'), dictionary); -        this._populateFrequencyValueList(node.querySelector('.frequency-value-list'), values); +        this._setTextContent(tagLabel, dictionary); +        this._populateFrequencyValueList(frequencyValueList, values);          node.dataset.character = character;          node.dataset.dictionary = dictionary; @@ -609,12 +780,16 @@ export class DisplayGenerator {          return node;      } +    /** +     * @param {HTMLElement} node +     * @param {import('dictionary-data-util').FrequencyData[]} values +     */      _populateFrequencyValueList(node, values) {          let fullFrequency = '';          for (let i = 0, ii = values.length; i < ii; ++i) {              const {frequency, displayValue} = values[i];              const frequencyString = `${frequency}`; -            const text = displayValue !== null ? displayValue : frequency; +            const text = displayValue !== null ? displayValue : `${frequency}`;              if (i > 0) {                  const node2 = document.createElement('span'); @@ -643,11 +818,15 @@ export class DisplayGenerator {          node.dataset.frequency = fullFrequency;      } +    /** +     * @param {HTMLElement} container +     * @param {string} text +     */      _appendKanjiLinks(container, text) {          const jp = this._japaneseUtil;          let part = '';          for (const c of text) { -            if (jp.isCodePointKanji(c.codePointAt(0))) { +            if (jp.isCodePointKanji(/** @type {number} */ (c.codePointAt(0)))) {                  if (part.length > 0) {                      container.appendChild(document.createTextNode(part));                      part = ''; @@ -664,16 +843,25 @@ export class DisplayGenerator {          }      } -    _appendMultiple(container, createItem, detailsArray, ...args) { +    /** +     * @template TItem +     * @template [TExtraArg=void] +     * @param {HTMLElement} container +     * @param {(item: TItem, arg: TExtraArg) => ?Node} createItem +     * @param {TItem[]} detailsArray +     * @param {TExtraArg} [arg] +     * @returns {number} +     */ +    _appendMultiple(container, createItem, detailsArray, arg) {          let count = 0;          const {ELEMENT_NODE} = Node;          if (Array.isArray(detailsArray)) {              for (const details of detailsArray) { -                const item = createItem(details, ...args); +                const item = createItem(details, /** @type {TExtraArg} */ (arg));                  if (item === null) { continue; }                  container.appendChild(item);                  if (item.nodeType === ELEMENT_NODE) { -                    item.dataset.index = `${count}`; +                    /** @type {HTMLElement} */ (item).dataset.index = `${count}`;                  }                  ++count;              } @@ -684,6 +872,12 @@ export class DisplayGenerator {          return count;      } +    /** +     * @param {HTMLElement} container +     * @param {string} term +     * @param {string} reading +     * @param {(element: HTMLElement, text: string) => void} addText +     */      _appendFurigana(container, term, reading, addText) {          container.lang = 'ja';          const segments = this._japaneseUtil.distributeFurigana(term, reading); @@ -701,10 +895,19 @@ export class DisplayGenerator {          }      } +    /** +     * @param {string} dictionary +     * @returns {import('dictionary').Tag} +     */      _createDictionaryTag(dictionary) {          return this._createTagData(dictionary, 'dictionary');      } +    /** +     * @param {HTMLElement} node +     * @param {string} value +     * @param {string} [language] +     */      _setTextContent(node, value, language) {          if (typeof language === 'string') {              node.lang = language; @@ -715,6 +918,11 @@ export class DisplayGenerator {          node.textContent = value;      } +    /** +     * @param {HTMLElement} node +     * @param {string} value +     * @param {string} [language] +     */      _setMultilineTextContent(node, value, language) {          // This can't just call _setTextContent because the lack of <br> elements will          // cause the text to not copy correctly. @@ -738,9 +946,17 @@ export class DisplayGenerator {          }      } +    /** +     * @param {string} reading +     * @param {import('dictionary').TermPronunciation[]} pronunciations +     * @param {string[]} wordClasses +     * @param {number} headwordIndex +     * @returns {?string} +     */      _getPronunciationCategories(reading, pronunciations, wordClasses, headwordIndex) {          if (pronunciations.length === 0) { return null; }          const isVerbOrAdjective = DictionaryDataUtil.isNonNounVerbOrAdjective(wordClasses); +        /** @type {Set<import('japanese-util').PitchCategory>} */          const categories = new Set();          for (const pronunciation of pronunciations) {              if (pronunciation.headwordIndex !== headwordIndex) { continue; } @@ -753,4 +969,23 @@ export class DisplayGenerator {          }          return categories.size > 0 ? [...categories].join(' ') : null;      } + +    /** +     * @template {HTMLElement} T +     * @param {string} name +     * @returns {T} +     */ +    _instantiate(name) { +        return /** @type {T} */ (this._templates.instantiate(name)); +    } + +    /** +     * @template {HTMLElement} T +     * @param {Element|DocumentFragment} element +     * @param {string} selector +     * @returns {T} +     */ +    _querySelector(element, selector) { +        return /** @type {T} */ (element.querySelector(selector)); +    }  } diff --git a/ext/js/display/display-history.js b/ext/js/display/display-history.js index a983346c..f9d2e35d 100644 --- a/ext/js/display/display-history.js +++ b/ext/js/display/display-history.js @@ -18,26 +18,39 @@  import {EventDispatcher, generateId, isObject} from '../core.js'; +/** + * @augments EventDispatcher<import('display-history').EventType> + */  export class DisplayHistory extends EventDispatcher { +    /** +     * @param {{clearable?: boolean, useBrowserHistory?: boolean}} details +     */      constructor({clearable=true, useBrowserHistory=false}) {          super(); +        /** @type {boolean} */          this._clearable = clearable; +        /** @type {boolean} */          this._useBrowserHistory = useBrowserHistory; +        /** @type {Map<string, import('display-history').Entry>} */          this._historyMap = new Map();          const historyState = history.state;          const {id, state} = isObject(historyState) ? historyState : {id: null, state: null}; +        /** @type {import('display-history').Entry} */          this._current = this._createHistoryEntry(id, location.href, state, null, null);      } +    /** @type {?import('display-history').EntryState} */      get state() {          return this._current.state;      } +    /** @type {?import('display-history').EntryContent} */      get content() {          return this._current.content;      } +    /** @type {boolean} */      get useBrowserHistory() {          return this._useBrowserHistory;      } @@ -46,31 +59,54 @@ export class DisplayHistory extends EventDispatcher {          this._useBrowserHistory = value;      } +    /** @type {boolean} */ +    get clearable() { return this._clearable; } +    set clearable(value) { this._clearable = value; } + +    /** */      prepare() {          window.addEventListener('popstate', this._onPopState.bind(this), false);      } +    /** +     * @returns {boolean} +     */      hasNext() {          return this._current.next !== null;      } +    /** +     * @returns {boolean} +     */      hasPrevious() {          return this._current.previous !== null;      } +    /** */      clear() {          if (!this._clearable) { return; }          this._clear();      } +    /** +     * @returns {boolean} +     */      back() {          return this._go(false);      } +    /** +     * @returns {boolean} +     */      forward() {          return this._go(true);      } +    /** +     * @param {?import('display-history').EntryState} state +     * @param {?import('display-history').EntryContent} content +     * @param {string} [url] +     */      pushState(state, content, url) {          if (typeof url === 'undefined') { url = location.href; } @@ -80,6 +116,11 @@ export class DisplayHistory extends EventDispatcher {          this._updateHistoryFromCurrent(!this._useBrowserHistory);      } +    /** +     * @param {?import('display-history').EntryState} state +     * @param {?import('display-history').EntryContent} content +     * @param {string} [url] +     */      replaceState(state, content, url) {          if (typeof url === 'undefined') { url = location.href; } @@ -89,11 +130,16 @@ export class DisplayHistory extends EventDispatcher {          this._updateHistoryFromCurrent(true);      } +    /** */      _onPopState() {          this._updateStateFromHistory();          this._triggerStateChanged(false);      } +    /** +     * @param {boolean} forward +     * @returns {boolean} +     */      _go(forward) {          if (this._useBrowserHistory) {              if (forward) { @@ -111,10 +157,16 @@ export class DisplayHistory extends EventDispatcher {          return true;      } +    /** +     * @param {boolean} synthetic +     */      _triggerStateChanged(synthetic) { -        this.trigger('stateChanged', {synthetic}); +        this.trigger('stateChanged', /** @type {import('display-history').StateChangedEvent} */ ({synthetic}));      } +    /** +     * @param {boolean} replace +     */      _updateHistoryFromCurrent(replace) {          const {id, state, url} = this._current;          if (replace) { @@ -125,6 +177,7 @@ export class DisplayHistory extends EventDispatcher {          this._triggerStateChanged(true);      } +    /** */      _updateStateFromHistory() {          let state = history.state;          let id = null; @@ -151,24 +204,36 @@ export class DisplayHistory extends EventDispatcher {          this._clear();      } +    /** +     * @param {unknown} id +     * @param {string} url +     * @param {?import('display-history').EntryState} state +     * @param {?import('display-history').EntryContent} content +     * @param {?import('display-history').Entry} previous +     * @returns {import('display-history').Entry} +     */      _createHistoryEntry(id, url, state, content, previous) { -        if (typeof id !== 'string') { id = this._generateId(); } +        /** @type {import('display-history').Entry} */          const entry = { -            id, +            id: typeof id === 'string' ? id : this._generateId(),              url,              next: null,              previous,              state,              content          }; -        this._historyMap.set(id, entry); +        this._historyMap.set(entry.id, entry);          return entry;      } +    /** +     * @returns {string} +     */      _generateId() {          return generateId(16);      } +    /** */      _clear() {          this._historyMap.clear();          this._historyMap.set(this._current.id, this._current); diff --git a/ext/js/display/display-notification.js b/ext/js/display/display-notification.js index 0c26c613..b3f20700 100644 --- a/ext/js/display/display-notification.js +++ b/ext/js/display/display-notification.js @@ -19,23 +19,36 @@  import {EventListenerCollection} from '../core.js';  export class DisplayNotification { +    /** +     * @param {HTMLElement} container +     * @param {HTMLElement} node +     */      constructor(container, node) { +        /** @type {HTMLElement} */          this._container = container; +        /** @type {HTMLElement} */          this._node = node; -        this._body = node.querySelector('.footer-notification-body'); -        this._closeButton = node.querySelector('.footer-notification-close-button'); +        /** @type {HTMLElement} */ +        this._body = /** @type {HTMLElement} */ (node.querySelector('.footer-notification-body')); +        /** @type {HTMLElement} */ +        this._closeButton = /** @type {HTMLElement} */ (node.querySelector('.footer-notification-close-button')); +        /** @type {EventListenerCollection} */          this._eventListeners = new EventListenerCollection(); +        /** @type {?number} */          this._closeTimer = null;      } +    /** @type {HTMLElement} */      get container() {          return this._container;      } +    /** @type {HTMLElement} */      get node() {          return this._node;      } +    /** */      open() {          if (!this.isClosed()) { return; } @@ -50,6 +63,9 @@ export class DisplayNotification {          this._eventListeners.addEventListener(this._closeButton, 'click', this._onCloseButtonClick.bind(this), false);      } +    /** +     * @param {boolean} [animate] +     */      close(animate=false) {          if (this.isClosed()) { return; } @@ -69,6 +85,9 @@ export class DisplayNotification {          }      } +    /** +     * @param {string|Node} value +     */      setContent(value) {          if (typeof value === 'string') {              this._body.textContent = value; @@ -78,25 +97,34 @@ export class DisplayNotification {          }      } +    /** +     * @returns {boolean} +     */      isClosing() {          return this._closeTimer !== null;      } +    /** +     * @returns {boolean} +     */      isClosed() {          return this._node.parentNode === null;      }      // Private +    /** */      _onCloseButtonClick() {          this.close(true);      } +    /** */      _onDelayClose() {          this._closeTimer = null;          this.close(false);      } +    /** */      _clearTimer() {          if (this._closeTimer !== null) {              clearTimeout(this._closeTimer); diff --git a/ext/js/display/display-profile-selection.js b/ext/js/display/display-profile-selection.js index d8b7185c..619d07aa 100644 --- a/ext/js/display/display-profile-selection.js +++ b/ext/js/display/display-profile-selection.js @@ -21,19 +21,30 @@ import {PanelElement} from '../dom/panel-element.js';  import {yomitan} from '../yomitan.js';  export class DisplayProfileSelection { +    /** +     * @param {Display} display +     */      constructor(display) { +        /** @type {Display} */          this._display = display; -        this._profielList = document.querySelector('#profile-list'); -        this._profileButton = document.querySelector('#profile-button'); +        /** @type {HTMLElement} */ +        this._profielList = /** @type {HTMLElement} */ (document.querySelector('#profile-list')); +        /** @type {HTMLButtonElement} */ +        this._profileButton = /** @type {HTMLButtonElement} */ (document.querySelector('#profile-button')); +        /** @type {PanelElement} */          this._profilePanel = new PanelElement({ -            node: document.querySelector('#profile-panel'), +            node: /** @type {HTMLElement} */ (document.querySelector('#profile-panel')),              closingAnimationDuration: 375 // Milliseconds; includes buffer          }); +        /** @type {boolean} */          this._profileListNeedsUpdate = false; +        /** @type {EventListenerCollection} */          this._eventListeners = new EventListenerCollection(); +        /** @type {string} */          this._source = generateId(16);      } +    /** */      async prepare() {          yomitan.on('optionsUpdated', this._onOptionsUpdated.bind(this));          this._profileButton.addEventListener('click', this._onProfileButtonClick.bind(this), false); @@ -42,6 +53,9 @@ export class DisplayProfileSelection {      // Private +    /** +     * @param {{source: string}} details +     */      _onOptionsUpdated({source}) {          if (source === this._source) { return; }          this._profileListNeedsUpdate = true; @@ -50,12 +64,18 @@ export class DisplayProfileSelection {          }      } +    /** +     * @param {MouseEvent} e +     */      _onProfileButtonClick(e) {          e.preventDefault();          e.stopPropagation();          this._setProfilePanelVisible(!this._profilePanel.isVisible());      } +    /** +     * @param {boolean} visible +     */      _setProfilePanelVisible(visible) {          this._profilePanel.setVisible(visible);          this._profileButton.classList.toggle('sidebar-button-highlight', visible); @@ -65,6 +85,7 @@ export class DisplayProfileSelection {          }      } +    /** */      async _updateProfileList() {          this._profileListNeedsUpdate = false;          const options = await yomitan.api.optionsGetFull(); @@ -77,9 +98,9 @@ export class DisplayProfileSelection {          for (let i = 0, ii = profiles.length; i < ii; ++i) {              const {name} = profiles[i];              const entry = displayGenerator.createProfileListItem(); -            const radio = entry.querySelector('.profile-entry-is-default-radio'); +            const radio = /** @type {HTMLInputElement} */ (entry.querySelector('.profile-entry-is-default-radio'));              radio.checked = (i === profileCurrent); -            const nameNode = entry.querySelector('.profile-list-item-name'); +            const nameNode = /** @type {Element} */ (entry.querySelector('.profile-list-item-name'));              nameNode.textContent = name;              fragment.appendChild(entry);              this._eventListeners.addEventListener(radio, 'change', this._onProfileRadioChange.bind(this, i), false); @@ -88,19 +109,30 @@ export class DisplayProfileSelection {          this._profielList.appendChild(fragment);      } +    /** +     * @param {number} index +     * @param {Event} e +     */      _onProfileRadioChange(index, e) { -        if (e.currentTarget.checked) { +        const element = /** @type {HTMLInputElement} */ (e.currentTarget); +        if (element.checked) {              this._setProfileCurrent(index);          }      } +    /** +     * @param {number} index +     */      async _setProfileCurrent(index) { -        await yomitan.api.modifySettings([{ +        /** @type {import('settings-modifications').ScopedModificationSet} */ +        const modification = {              action: 'set',              path: 'profileCurrent',              value: index, -            scope: 'global' -        }], this._source); +            scope: 'global', +            optionsContext: null +        }; +        await yomitan.api.modifySettings([modification], this._source);          this._setProfilePanelVisible(false);      }  } diff --git a/ext/js/display/display-resizer.js b/ext/js/display/display-resizer.js index 2925561f..6280286d 100644 --- a/ext/js/display/display-resizer.js +++ b/ext/js/display/display-resizer.js @@ -19,16 +19,27 @@  import {EventListenerCollection} from '../core.js';  export class DisplayResizer { +    /** +     * @param {Display} display +     */      constructor(display) { +        /** @type {Display} */          this._display = display; +        /** @type {?import('core').TokenObject} */          this._token = null; +        /** @type {?HTMLElement} */          this._handle = null; +        /** @type {?number} */          this._touchIdentifier = null; +        /** @type {?{width: number, height: number}} */          this._startSize = null; +        /** @type {?{x: number, y: number}} */          this._startOffset = null; +        /** @type {EventListenerCollection} */          this._eventListeners = new EventListenerCollection();      } +    /** */      prepare() {          this._handle = document.querySelector('#frame-resizer-handle');          if (this._handle === null) { return; } @@ -39,6 +50,9 @@ export class DisplayResizer {      // Private +    /** +     * @param {MouseEvent} e +     */      _onFrameResizerMouseDown(e) {          if (e.button !== 0) { return; }          // Don't do e.preventDefault() here; this allows mousemove events to be processed @@ -46,19 +60,27 @@ export class DisplayResizer {          this._startFrameResize(e);      } +    /** +     * @param {TouchEvent} e +     */      _onFrameResizerTouchStart(e) {          e.preventDefault();          this._startFrameResizeTouch(e);      } +    /** */      _onFrameResizerMouseUp() {          this._stopFrameResize();      } +    /** */      _onFrameResizerWindowBlur() {          this._stopFrameResize();      } +    /** +     * @param {MouseEvent} e +     */      _onFrameResizerMouseMove(e) {          if ((e.buttons & 0x1) === 0x0) {              this._stopFrameResize(); @@ -69,16 +91,25 @@ export class DisplayResizer {          }      } +    /** +     * @param {TouchEvent} e +     */      _onFrameResizerTouchEnd(e) {          if (this._getTouch(e.changedTouches, this._touchIdentifier) === null) { return; }          this._stopFrameResize();      } +    /** +     * @param {TouchEvent} e +     */      _onFrameResizerTouchCancel(e) {          if (this._getTouch(e.changedTouches, this._touchIdentifier) === null) { return; }          this._stopFrameResize();      } +    /** +     * @param {TouchEvent} e +     */      _onFrameResizerTouchMove(e) {          if (this._startSize === null) { return; }          const primaryTouch = this._getTouch(e.changedTouches, this._touchIdentifier); @@ -87,10 +118,14 @@ export class DisplayResizer {          this._updateFrameSize(x, y);      } +    /** +     * @param {MouseEvent} e +     */      _startFrameResize(e) {          if (this._token !== null) { return; }          const {clientX: x, clientY: y} = e; +        /** @type {?import('core').TokenObject} */          const token = {};          this._token = token;          this._startOffset = {x, y}; @@ -106,10 +141,14 @@ export class DisplayResizer {          this._initializeFrameResize(token);      } +    /** +     * @param {TouchEvent} e +     */      _startFrameResizeTouch(e) {          if (this._token !== null) { return; }          const {clientX: x, clientY: y, identifier} = e.changedTouches[0]; +        /** @type {?import('core').TokenObject} */          const token = {};          this._token = token;          this._startOffset = {x, y}; @@ -127,15 +166,21 @@ export class DisplayResizer {          this._initializeFrameResize(token);      } +    /** +     * @param {import('core').TokenObject} token +     */      async _initializeFrameResize(token) {          const {parentPopupId} = this._display;          if (parentPopupId === null) { return; } +        /** @type {import('popup').ValidSize} */          const size = await this._display.invokeParentFrame('PopupFactory.getFrameSize', {id: parentPopupId});          if (this._token !== token) { return; } -        this._startSize = size; +        const {width, height} = size; +        this._startSize = {width, height};      } +    /** */      _stopFrameResize() {          if (this._token === null) { return; } @@ -151,9 +196,13 @@ export class DisplayResizer {          }      } +    /** +     * @param {number} x +     * @param {number} y +     */      async _updateFrameSize(x, y) {          const {parentPopupId} = this._display; -        if (parentPopupId === null) { return; } +        if (parentPopupId === null || this._handle === null || this._startOffset === null || this._startSize === null) { return; }          const handleSize = this._handle.getBoundingClientRect();          let {width, height} = this._startSize; @@ -164,6 +213,11 @@ export class DisplayResizer {          await this._display.invokeParentFrame('PopupFactory.setFrameSize', {id: parentPopupId, width, height});      } +    /** +     * @param {TouchList} touchList +     * @param {?number} identifier +     * @returns {?Touch} +     */      _getTouch(touchList, identifier) {          for (const touch of touchList) {              if (touch.identifier === identifier) { diff --git a/ext/js/display/display.js b/ext/js/display/display.js index 038a76bb..f9c36a67 100644 --- a/ext/js/display/display.js +++ b/ext/js/display/display.js @@ -20,7 +20,7 @@ import {Frontend} from '../app/frontend.js';  import {PopupFactory} from '../app/popup-factory.js';  import {ThemeController} from '../app/theme-controller.js';  import {FrameEndpoint} from '../comm/frame-endpoint.js'; -import {DynamicProperty, EventDispatcher, EventListenerCollection, clone, deepEqual, invokeMessageHandler, isObject, log, promiseTimeout} from '../core.js'; +import {DynamicProperty, EventDispatcher, EventListenerCollection, clone, deepEqual, invokeMessageHandler, log, promiseTimeout} from '../core.js';  import {PopupMenu} from '../dom/popup-menu.js';  import {ScrollElement} from '../dom/scroll-element.js';  import {HotkeyHelpController} from '../input/hotkey-help-controller.js'; @@ -35,132 +35,164 @@ import {ElementOverflowController} from './element-overflow-controller.js';  import {OptionToggleHotkeyHandler} from './option-toggle-hotkey-handler.js';  import {QueryParser} from './query-parser.js'; +/** + * @augments EventDispatcher<import('display').DisplayEventType> + */  export class Display extends EventDispatcher {      /** -     * Information about how popup content should be shown, specifically related to the inner popup content. -     * @typedef {object} ContentDetails -     * @property {boolean} focus Whether or not the frame should be `focus()`'d. -     * @property {HistoryParams} params An object containing key-value pairs representing the URL search params. -     * @property {?HistoryState} state The semi-persistent state assigned to the navigation entry. -     * @property {?HistoryContent} content The non-persistent content assigned to the navigation entry. -     * @property {'clear'|'overwrite'|'new'} historyMode How the navigation history should be modified. -     */ - -    /** -     * An object containing key-value pairs representing the URL search params. -     * @typedef {object} HistoryParams -     * @property {'terms'|'kanji'|'unloaded'|'clear'} [type] The type of content that is being shown. -     * @property {string} [query] The search query. -     * @property {'on'|'off'} [wildcards] Whether or not wildcards can be used for the search query. -     * @property {string} [offset] The start position of the `query` string as an index into the `full` query string. -     * @property {string} [full] The full search text. If absent, `query` is the full search text. -     * @property {'true'|'false'} [full-visible] Whether or not the full search query should be forced to be visible. -     * @property {'true'|'false'} [lookup] Whether or not the query should be looked up. If it is not looked up, -     *   the content should be provided. -     */ - -    /** -     * The semi-persistent state assigned to the navigation entry. -     * @typedef {object} HistoryState -     * @property {'queryParser'} [cause] What was the cause of the navigation. -     * @property {object} [sentence] The sentence context. -     * @property {string} sentence.text The full string. -     * @property {number} sentence.offset The offset from the start of `text` to the full search query. -     * @property {number} [focusEntry] The index of the dictionary entry to focus. -     * @property {number} [scrollX] The horizontal scroll position. -     * @property {number} [scrollY] The vertical scroll position. -     * @property {object} [optionsContext] The options context which should be used for lookups. -     * @property {string} [url] The originating URL of the content. -     * @property {string} [documentTitle] The originating document title of the content. -     */ - -    /** -     * The non-persistent content assigned to the navigation entry. -     * @typedef {object} HistoryContent -     * @property {boolean} [animate] Whether or not any CSS animations should occur. -     * @property {object[]} [dictionaryEntries] An array of dictionary entries to display as content. -     * @property {object} [contentOrigin] The identifying information for the frame the content originated from. -     * @property {number} [contentOrigin.tabId] The tab id. -     * @property {number} [contentOrigin.frameId] The frame id within the tab. +     * @param {number|undefined} tabId +     * @param {number|undefined} frameId +     * @param {import('display').DisplayPageType} pageType +     * @param {JapaneseUtil} japaneseUtil +     * @param {DocumentFocusController} documentFocusController +     * @param {HotkeyHandler} hotkeyHandler       */ -      constructor(tabId, frameId, pageType, japaneseUtil, documentFocusController, hotkeyHandler) {          super(); +        /** @type {number|undefined} */          this._tabId = tabId; +        /** @type {number|undefined} */          this._frameId = frameId; +        /** @type {import('display').DisplayPageType} */          this._pageType = pageType; +        /** @type {JapaneseUtil} */          this._japaneseUtil = japaneseUtil; +        /** @type {DocumentFocusController} */          this._documentFocusController = documentFocusController; +        /** @type {HotkeyHandler} */          this._hotkeyHandler = hotkeyHandler; -        this._container = document.querySelector('#dictionary-entries'); +        /** @type {HTMLElement} */ +        this._container = /** @type {HTMLElement} */ (document.querySelector('#dictionary-entries')); +        /** @type {import('dictionary').DictionaryEntry[]} */          this._dictionaryEntries = []; +        /** @type {HTMLElement[]} */          this._dictionaryEntryNodes = []; +        /** @type {import('settings').OptionsContext} */          this._optionsContext = {depth: 0, url: window.location.href}; +        /** @type {?import('settings').ProfileOptions} */          this._options = null; +        /** @type {number} */          this._index = 0; +        /** @type {?HTMLStyleElement} */          this._styleNode = null; +        /** @type {EventListenerCollection} */          this._eventListeners = new EventListenerCollection(); +        /** @type {?import('core').TokenObject} */          this._setContentToken = null; +        /** @type {DisplayContentManager} */          this._contentManager = new DisplayContentManager(this); +        /** @type {HotkeyHelpController} */          this._hotkeyHelpController = new HotkeyHelpController(); +        /** @type {DisplayGenerator} */          this._displayGenerator = new DisplayGenerator({              japaneseUtil,              contentManager: this._contentManager,              hotkeyHelpController: this._hotkeyHelpController          }); +        /** @type {import('core').MessageHandlerMap} */          this._messageHandlers = new Map(); +        /** @type {import('core').MessageHandlerMap} */          this._directMessageHandlers = new Map(); +        /** @type {import('core').MessageHandlerMap} */          this._windowMessageHandlers = new Map(); +        /** @type {DisplayHistory} */          this._history = new DisplayHistory({clearable: true, useBrowserHistory: false}); +        /** @type {boolean} */          this._historyChangeIgnore = false; +        /** @type {boolean} */          this._historyHasChanged = false; +        /** @type {?Element} */          this._navigationHeader = document.querySelector('#navigation-header'); +        /** @type {import('display').PageType} */          this._contentType = 'clear'; +        /** @type {string} */          this._defaultTitle = document.title; +        /** @type {number} */          this._titleMaxLength = 1000; +        /** @type {string} */          this._query = ''; +        /** @type {string} */          this._fullQuery = ''; +        /** @type {number} */          this._queryOffset = 0; -        this._progressIndicator = document.querySelector('#progress-indicator'); +        /** @type {HTMLElement} */ +        this._progressIndicator = /** @type {HTMLElement} */ (document.querySelector('#progress-indicator')); +        /** @type {?number} */          this._progressIndicatorTimer = null; +        /** @type {DynamicProperty<boolean>} */          this._progressIndicatorVisible = new DynamicProperty(false); +        /** @type {boolean} */          this._queryParserVisible = false; +        /** @type {?boolean} */          this._queryParserVisibleOverride = null; -        this._queryParserContainer = document.querySelector('#query-parser-container'); +        /** @type {HTMLElement} */ +        this._queryParserContainer = /** @type {HTMLElement} */ (document.querySelector('#query-parser-container')); +        /** @type {QueryParser} */          this._queryParser = new QueryParser({              getSearchContext: this._getSearchContext.bind(this),              japaneseUtil          }); -        this._contentScrollElement = document.querySelector('#content-scroll'); -        this._contentScrollBodyElement = document.querySelector('#content-body'); +        /** @type {HTMLElement} */ +        this._contentScrollElement = /** @type {HTMLElement} */ (document.querySelector('#content-scroll')); +        /** @type {HTMLElement} */ +        this._contentScrollBodyElement = /** @type {HTMLElement} */ (document.querySelector('#content-body')); +        /** @type {ScrollElement} */          this._windowScroll = new ScrollElement(this._contentScrollElement); -        this._closeButton = document.querySelector('#close-button'); -        this._navigationPreviousButton = document.querySelector('#navigate-previous-button'); -        this._navigationNextButton = document.querySelector('#navigate-next-button'); +        /** @type {HTMLButtonElement} */ +        this._closeButton = /** @type {HTMLButtonElement} */ (document.querySelector('#close-button')); +        /** @type {HTMLButtonElement} */ +        this._navigationPreviousButton = /** @type {HTMLButtonElement} */ (document.querySelector('#navigate-previous-button')); +        /** @type {HTMLButtonElement} */ +        this._navigationNextButton = /** @type {HTMLButtonElement} */ (document.querySelector('#navigate-next-button')); +        /** @type {?Frontend} */          this._frontend = null; +        /** @type {?Promise<void>} */          this._frontendSetupPromise = null; +        /** @type {number} */          this._depth = 0; +        /** @type {?string} */          this._parentPopupId = null; +        /** @type {?number} */          this._parentFrameId = null; +        /** @type {number|undefined} */          this._contentOriginTabId = tabId; +        /** @type {number|undefined} */          this._contentOriginFrameId = frameId; +        /** @type {boolean} */          this._childrenSupported = true; +        /** @type {?FrameEndpoint} */          this._frameEndpoint = (pageType === 'popup' ? new FrameEndpoint() : null); +        /** @type {?import('environment').Browser} */          this._browser = null; +        /** @type {?HTMLTextAreaElement} */          this._copyTextarea = null; +        /** @type {?TextScanner} */          this._contentTextScanner = null; +        /** @type {?DisplayNotification} */          this._tagNotification = null; -        this._footerNotificationContainer = document.querySelector('#content-footer'); +        /** @type {HTMLElement} */ +        this._footerNotificationContainer = /** @type {HTMLElement} */ (document.querySelector('#content-footer')); +        /** @type {OptionToggleHotkeyHandler} */          this._optionToggleHotkeyHandler = new OptionToggleHotkeyHandler(this); +        /** @type {ElementOverflowController} */          this._elementOverflowController = new ElementOverflowController(); +        /** @type {boolean} */          this._frameVisible = (pageType === 'search'); -        this._menuContainer = document.querySelector('#popup-menus'); +        /** @type {HTMLElement} */ +        this._menuContainer = /** @type {HTMLElement} */ (document.querySelector('#popup-menus')); +        /** @type {(event: MouseEvent) => void} */          this._onEntryClickBind = this._onEntryClick.bind(this); +        /** @type {(event: MouseEvent) => void} */          this._onKanjiLookupBind = this._onKanjiLookup.bind(this); +        /** @type {(event: MouseEvent) => void} */          this._onDebugLogClickBind = this._onDebugLogClick.bind(this); +        /** @type {(event: MouseEvent) => void} */          this._onTagClickBind = this._onTagClick.bind(this); +        /** @type {(event: MouseEvent) => void} */          this._onMenuButtonClickBind = this._onMenuButtonClick.bind(this); +        /** @type {(event: import('popup-menu').MenuCloseEvent) => void} */          this._onMenuButtonMenuCloseBind = this._onMenuButtonMenuClose.bind(this); +        /** @type {ThemeController} */          this._themeController = new ThemeController(document.documentElement);          this._hotkeyHandler.registerActions([ @@ -188,10 +220,12 @@ export class Display extends EventDispatcher {          ]);      } +    /** @type {DisplayGenerator} */      get displayGenerator() {          return this._displayGenerator;      } +    /** @type {boolean} */      get queryParserVisible() {          return this._queryParserVisible;      } @@ -201,58 +235,72 @@ export class Display extends EventDispatcher {          this._updateQueryParser();      } +    /** @type {JapaneseUtil} */      get japaneseUtil() {          return this._japaneseUtil;      } +    /** @type {number} */      get depth() {          return this._depth;      } +    /** @type {HotkeyHandler} */      get hotkeyHandler() {          return this._hotkeyHandler;      } +    /** @type {import('dictionary').DictionaryEntry[]} */      get dictionaryEntries() {          return this._dictionaryEntries;      } +    /** @type {HTMLElement[]} */      get dictionaryEntryNodes() {          return this._dictionaryEntryNodes;      } +    /** @type {DynamicProperty<boolean>} */      get progressIndicatorVisible() {          return this._progressIndicatorVisible;      } +    /** @type {?string} */      get parentPopupId() {          return this._parentPopupId;      } +    /** @type {number} */      get selectedIndex() {          return this._index;      } +    /** @type {DisplayHistory} */      get history() {          return this._history;      } +    /** @type {string} */      get query() {          return this._query;      } +    /** @type {string} */      get fullQuery() {          return this._fullQuery;      } +    /** @type {number} */      get queryOffset() {          return this._queryOffset;      } +    /** @type {boolean} */      get frameVisible() {          return this._frameVisible;      } +    /** */      async prepare() {          // Theme          this._themeController.siteTheme = 'light'; @@ -302,6 +350,9 @@ export class Display extends EventDispatcher {          }      } +    /** +     * @returns {import('extension').ContentOrigin} +     */      getContentOrigin() {          return {              tabId: this._contentOriginTabId, @@ -309,6 +360,7 @@ export class Display extends EventDispatcher {          };      } +    /** */      initializeState() {          this._onStateChanged();          if (this._frameEndpoint !== null) { @@ -316,6 +368,9 @@ export class Display extends EventDispatcher {          }      } +    /** +     * @param {{clearable?: boolean, useBrowserHistory?: boolean}} details +     */      setHistorySettings({clearable, useBrowserHistory}) {          if (typeof clearable !== 'undefined') {              this._history.clearable = clearable; @@ -325,24 +380,37 @@ export class Display extends EventDispatcher {          }      } +    /** +     * @param {Error} error +     */      onError(error) {          if (yomitan.isExtensionUnloaded) { return; }          log.error(error);      } +    /** +     * @returns {?import('settings').ProfileOptions} +     */      getOptions() {          return this._options;      } +    /** +     * @returns {import('settings').OptionsContext} +     */      getOptionsContext() {          return this._optionsContext;      } +    /** +     * @param {import('settings').OptionsContext} optionsContext +     */      async setOptionsContext(optionsContext) {          this._optionsContext = optionsContext;          await this.updateOptions();      } +    /** */      async updateOptions() {          const options = await yomitan.api.optionsGet(this.getOptionsContext());          const {scanning: scanningOptions, sentenceParsing: sentenceParsingOptions} = options; @@ -381,12 +449,14 @@ export class Display extends EventDispatcher {          this._updateNestedFrontend(options);          this._updateContentTextScanner(options); -        this.trigger('optionsUpdated', {options}); +        /** @type {import('display').OptionsUpdatedEvent} */ +        const event = {options}; +        this.trigger('optionsUpdated', event);      }      /**       * Updates the content of the display. -     * @param {ContentDetails} details Information about the content to show. +     * @param {import('display').ContentDetails} details Information about the content to show.       */      setContent(details) {          const {focus, params, state, content} = details; @@ -398,6 +468,7 @@ export class Display extends EventDispatcher {          const urlSearchParams = new URLSearchParams();          for (const [key, value] of Object.entries(params)) { +            if (typeof value !== 'string') { continue; }              urlSearchParams.append(key, value);          }          const url = `${location.protocol}//${location.host}${location.pathname}?${urlSearchParams.toString()}`; @@ -417,6 +488,9 @@ export class Display extends EventDispatcher {          }      } +    /** +     * @param {string} css +     */      setCustomCss(css) {          if (this._styleNode === null) {              if (css.length === 0) { return; } @@ -431,18 +505,25 @@ export class Display extends EventDispatcher {          }      } +    /** +     * @param {import('core').MessageHandlerArray} handlers +     */      registerDirectMessageHandlers(handlers) {          for (const [name, handlerInfo] of handlers) {              this._directMessageHandlers.set(name, handlerInfo);          }      } +    /** +     * @param {import('core').MessageHandlerArray} handlers +     */      registerWindowMessageHandlers(handlers) {          for (const [name, handlerInfo] of handlers) {              this._windowMessageHandlers.set(name, handlerInfo);          }      } +    /** */      close() {          switch (this._pageType) {              case 'popup': @@ -454,49 +535,72 @@ export class Display extends EventDispatcher {          }      } +    /** +     * @param {HTMLElement} element +     */      blurElement(element) {          this._documentFocusController.blurElement(element);      } +    /** +     * @param {boolean} updateOptionsContext +     */      searchLast(updateOptionsContext) {          const type = this._contentType;          if (type === 'clear') { return; }          const query = this._query; -        const hasState = this._historyHasState(); -        const state = ( +        const {state} = this._history; +        const hasState = typeof state === 'object' && state !== null; +        /** @type {import('display').HistoryState} */ +        const newState = (              hasState ? -            clone(this._history.state) : +            clone(state) :              {                  focusEntry: 0, -                optionsContext: null, +                optionsContext: void 0,                  url: window.location.href,                  sentence: {text: query, offset: 0},                  documentTitle: document.title              }          );          if (!hasState || updateOptionsContext) { -            state.optionsContext = clone(this._optionsContext); +            newState.optionsContext = clone(this._optionsContext);          } +        /** @type {import('display').ContentDetails} */          const details = {              focus: false,              historyMode: 'clear',              params: this._createSearchParams(type, query, false, this._queryOffset), -            state, +            state: newState,              content: { -                dictionaryEntries: null,                  contentOrigin: this.getContentOrigin()              }          };          this.setContent(details);      } +    /** +     * @template [TReturn=unknown] +     * @param {string} action +     * @param {import('core').SerializableObject} [params] +     * @returns {Promise<TReturn>} +     */      async invokeContentOrigin(action, params={}) {          if (this._contentOriginTabId === this._tabId && this._contentOriginFrameId === this._frameId) {              throw new Error('Content origin is same page');          } +        if (typeof this._contentOriginTabId !== 'number' || typeof this._contentOriginFrameId !== 'number') { +            throw new Error('No content origin is assigned'); +        }          return await yomitan.crossFrame.invokeTab(this._contentOriginTabId, this._contentOriginFrameId, action, params);      } +    /** +     * @template [TReturn=unknown] +     * @param {string} action +     * @param {import('core').SerializableObject} [params] +     * @returns {Promise<TReturn>} +     */      async invokeParentFrame(action, params={}) {          if (this._parentFrameId === null || this._parentFrameId === this._frameId) {              throw new Error('Invalid parent frame'); @@ -504,11 +608,17 @@ export class Display extends EventDispatcher {          return await yomitan.crossFrame.invoke(this._parentFrameId, action, params);      } +    /** +     * @param {Element} element +     * @returns {number} +     */      getElementDictionaryEntryIndex(element) { -        const node = element.closest('.entry'); +        const node = /** @type {?HTMLElement} */ (element.closest('.entry'));          if (node === null) { return -1; } -        const index = parseInt(node.dataset.index, 10); -        return Number.isFinite(index) ? index : -1; +        const {index} = node.dataset; +        if (typeof index !== 'string') { return -1; } +        const indexNumber = parseInt(index, 10); +        return Number.isFinite(indexNumber) ? indexNumber : -1;      }      /** @@ -526,9 +636,13 @@ export class Display extends EventDispatcher {      // Message handlers +    /** +     * @param {import('frame-client').Message<import('display').MessageDetails>} data +     * @returns {import('core').MessageHandlerAsyncResult} +     * @throws {Error} +     */      _onDirectMessage(data) { -        data = this._authenticateMessageData(data); -        const {action, params} = data; +        const {action, params} = this._authenticateMessageData(data);          const handlerInfo = this._directMessageHandlers.get(action);          if (typeof handlerInfo === 'undefined') {              throw new Error(`Invalid action: ${action}`); @@ -536,17 +650,24 @@ export class Display extends EventDispatcher {          const {async, handler} = handlerInfo;          const result = handler(params); -        return {async, result}; +        return { +            async: typeof async === 'boolean' && async, +            result +        };      } +    /** +     * @param {MessageEvent<import('frame-client').Message<import('display').MessageDetails>>} details +     */      _onWindowMessage({data}) { +        let data2;          try { -            data = this._authenticateMessageData(data); +            data2 = this._authenticateMessageData(data);          } catch (e) {              return;          } -        const {action, params} = data; +        const {action, params} = data2;          const messageHandler = this._windowMessageHandlers.get(action);          if (typeof messageHandler === 'undefined') { return; } @@ -554,23 +675,38 @@ export class Display extends EventDispatcher {          invokeMessageHandler(messageHandler, params, callback);      } +    /** +     * @param {{optionsContext: import('settings').OptionsContext}} details +     */      async _onMessageSetOptionsContext({optionsContext}) {          await this.setOptionsContext(optionsContext);          this.searchLast(true);      } +    /** +     * @param {{details: import('display').ContentDetails}} details +     */      _onMessageSetContent({details}) {          this.setContent(details);      } +    /** +     * @param {{css: string}} details +     */      _onMessageSetCustomCss({css}) {          this.setCustomCss(css);      } +    /** +     * @param {{scale: number}} details +     */      _onMessageSetContentScale({scale}) {          this._setContentScale(scale);      } +    /** +     * @param {import('display').ConfigureMessageDetails} details +     */      async _onMessageConfigure({depth, parentPopupId, parentFrameId, childrenSupported, scale, optionsContext}) {          this._depth = depth;          this._parentPopupId = parentPopupId; @@ -580,11 +716,17 @@ export class Display extends EventDispatcher {          await this.setOptionsContext(optionsContext);      } +    /** +     * @param {{value: boolean}} details +     */      _onMessageVisibilityChanged({value}) {          this._frameVisible = value; -        this.trigger('frameVisibilityChange', {value}); +        /** @type {import('display').FrameVisibilityChangeEvent} */ +        const event = {value}; +        this.trigger('frameVisibilityChange', event);      } +    /** */      _onMessageExtensionUnloaded() {          if (yomitan.isExtensionUnloaded) { return; }          yomitan.triggerExtensionUnloaded(); @@ -592,19 +734,27 @@ export class Display extends EventDispatcher {      // Private +    /** +     * @template [T=unknown] +     * @param {T|import('frame-client').Message<T>} data +     * @returns {T} +     * @throws {Error} +     */      _authenticateMessageData(data) {          if (this._frameEndpoint === null) { -            return data; +            return /** @type {T} */ (data);          }          if (!this._frameEndpoint.authenticate(data)) {              throw new Error('Invalid authentication');          } -        return data.data; +        return /** @type {import('frame-client').Message<T>} */ (data).data;      } +    /** */      async _onStateChanged() {          if (this._historyChangeIgnore) { return; } +        /** @type {?import('core').TokenObject} */          const token = {}; // Unique identifier token          this._setContentToken = token;          try { @@ -628,15 +778,16 @@ export class Display extends EventDispatcher {              this._queryParserVisibleOverride = (fullVisible === null ? null : (fullVisible !== 'false'));              this._historyHasChanged = true; -            this._contentType = type;              // Set content              switch (type) {                  case 'terms':                  case 'kanji': +                    this._contentType = type;                      await this._setContentTermsOrKanji(type, urlSearchParams, token);                      break;                  case 'unloaded': +                    this._contentType = type;                      this._setContentExtensionUnloaded();                      break;                  default: @@ -645,18 +796,22 @@ export class Display extends EventDispatcher {                      break;              }          } catch (e) { -            this.onError(e); +            this.onError(e instanceof Error ? e : new Error(`${e}`));          }      } +    /** +     * @param {import('display').QueryParserSearchedEvent} details +     */      _onQueryParserSearch({type, dictionaryEntries, sentence, inputInfo: {eventType}, textSource, optionsContext, sentenceOffset}) {          const query = textSource.text();          const historyState = this._history.state;          const historyMode = (              eventType === 'click' || -            !isObject(historyState) || +            !(typeof historyState === 'object' && historyState !== null) ||              historyState.cause !== 'queryParser'          ) ? 'new' : 'overwrite'; +        /** @type {import('display').ContentDetails} */          const details = {              focus: false,              historyMode, @@ -674,9 +829,11 @@ export class Display extends EventDispatcher {          this.setContent(details);      } +    /** */      _onExtensionUnloaded() {          const type = 'unloaded';          if (this._contentType === type) { return; } +        /** @type {import('display').ContentDetails} */          const details = {              focus: false,              historyMode: 'clear', @@ -692,21 +849,33 @@ export class Display extends EventDispatcher {          this.setContent(details);      } +    /** +     * @param {MouseEvent} e +     */      _onCloseButtonClick(e) {          e.preventDefault();          this.close();      } +    /** +     * @param {MouseEvent} e +     */      _onSourceTermView(e) {          e.preventDefault();          this._sourceTermView();      } +    /** +     * @param {MouseEvent} e +     */      _onNextTermView(e) {          e.preventDefault();          this._nextTermView();      } +    /** +     * @param {import('dynamic-property').ChangeEventDetails<boolean>} details +     */      _onProgressIndicatorVisibleChanged({value}) {          if (this._progressIndicatorTimer !== null) {              clearTimeout(this._progressIndicatorTimer); @@ -726,17 +895,24 @@ export class Display extends EventDispatcher {          }      } +    /** +     * @param {MouseEvent} e +     */      async _onKanjiLookup(e) {          try {              e.preventDefault(); -            if (!this._historyHasState()) { return; } +            const {state} = this._history; +            if (!(typeof state === 'object' && state !== null)) { return; } -            let {state: {sentence, url, documentTitle}} = this._history; +            let {sentence, url, documentTitle} = state;              if (typeof url !== 'string') { url = window.location.href; }              if (typeof documentTitle !== 'string') { documentTitle = document.title; }              const optionsContext = this.getOptionsContext(); -            const query = e.currentTarget.textContent; +            const element = /** @type {Element} */ (e.currentTarget); +            let query = element.textContent; +            if (query === null) { query = ''; }              const dictionaryEntries = await yomitan.api.kanjiFind(query, optionsContext); +            /** @type {import('display').ContentDetails} */              const details = {                  focus: false,                  historyMode: 'new', @@ -755,10 +931,13 @@ export class Display extends EventDispatcher {              };              this.setContent(details);          } catch (error) { -            this.onError(error); +            this.onError(error instanceof Error ? error : new Error(`${error}`));          }      } +    /** +     * @param {WheelEvent} e +     */      _onWheel(e) {          if (e.altKey) {              if (e.deltaY !== 0) { @@ -770,6 +949,9 @@ export class Display extends EventDispatcher {          }      } +    /** +     * @param {WheelEvent} e +     */      _onHistoryWheel(e) {          if (e.altKey) { return; }          const delta = -e.deltaX || e.deltaY; @@ -784,12 +966,18 @@ export class Display extends EventDispatcher {          }      } +    /** +     * @param {MouseEvent} e +     */      _onDebugLogClick(e) { -        const link = e.currentTarget; +        const link = /** @type {HTMLElement} */ (e.currentTarget);          const index = this.getElementDictionaryEntryIndex(link);          this._logDictionaryEntryData(index);      } +    /** +     * @param {MouseEvent} e +     */      _onDocumentElementMouseUp(e) {          switch (e.button) {              case 3: // Back @@ -805,6 +993,9 @@ export class Display extends EventDispatcher {          }      } +    /** +     * @param {MouseEvent} e +     */      _onDocumentElementClick(e) {          switch (e.button) {              case 3: // Back @@ -822,27 +1013,43 @@ export class Display extends EventDispatcher {          }      } +    /** +     * @param {MouseEvent} e +     */      _onEntryClick(e) {          if (e.button !== 0) { return; } -        const node = e.currentTarget; -        const index = parseInt(node.dataset.index, 10); -        if (!Number.isFinite(index)) { return; } -        this._entrySetCurrent(index); +        const node = /** @type {HTMLElement} */ (e.currentTarget); +        const {index} = node.dataset; +        if (typeof index !== 'string') { return; } +        const indexNumber = parseInt(index, 10); +        if (!Number.isFinite(indexNumber)) { return; } +        this._entrySetCurrent(indexNumber);      } +    /** +     * @param {MouseEvent} e +     */      _onTagClick(e) { -        this._showTagNotification(e.currentTarget); +        const node = /** @type {HTMLElement} */ (e.currentTarget); +        this._showTagNotification(node);      } +    /** +     * @param {MouseEvent} e +     */      _onMenuButtonClick(e) { -        const node = e.currentTarget; +        const node = /** @type {HTMLElement} */ (e.currentTarget); -        const menuContainerNode = this._displayGenerator.instantiateTemplate('dictionary-entry-popup-menu'); -        const menuBodyNode = menuContainerNode.querySelector('.popup-menu-body'); +        const menuContainerNode = /** @type {HTMLElement} */ (this._displayGenerator.instantiateTemplate('dictionary-entry-popup-menu')); +        const menuBodyNode = /** @type {HTMLElement} */ (menuContainerNode.querySelector('.popup-menu-body')); +        /** +         * @param {string} menuAction +         * @param {string} label +         */          const addItem = (menuAction, label) => { -            const item = this._displayGenerator.instantiateTemplate('dictionary-entry-popup-menu-item'); -            item.querySelector('.popup-menu-item-label').textContent = label; +            const item = /** @type {HTMLElement} */ (this._displayGenerator.instantiateTemplate('dictionary-entry-popup-menu-item')); +            /** @type {HTMLElement} */ (item.querySelector('.popup-menu-item-label')).textContent = label;              item.dataset.menuAction = menuAction;              menuBodyNode.appendChild(item);          }; @@ -854,8 +1061,12 @@ export class Display extends EventDispatcher {          popupMenu.prepare();      } +    /** +     * @param {import('popup-menu').MenuCloseEvent} e +     */      _onMenuButtonMenuClose(e) { -        const {currentTarget: node, detail: {action}} = e; +        const node = /** @type {HTMLElement} */ (e.currentTarget); +        const {action} = e.detail;          switch (action) {              case 'log-debug-info':                  this._logDictionaryEntryData(this.getElementDictionaryEntryIndex(node)); @@ -863,27 +1074,36 @@ export class Display extends EventDispatcher {          }      } +    /** +     * @param {Element} tagNode +     */      _showTagNotification(tagNode) { -        tagNode = tagNode.parentNode; -        if (tagNode === null) { return; } +        const parent = tagNode.parentNode; +        if (parent === null || !(parent instanceof HTMLElement)) { return; }          if (this._tagNotification === null) {              this._tagNotification = this.createNotification(true);          } -        const index = this.getElementDictionaryEntryIndex(tagNode); +        const index = this.getElementDictionaryEntryIndex(parent);          const dictionaryEntry = (index >= 0 && index < this._dictionaryEntries.length ? this._dictionaryEntries[index] : null); -        const content = this._displayGenerator.createTagFooterNotificationDetails(tagNode, dictionaryEntry); +        const content = this._displayGenerator.createTagFooterNotificationDetails(parent, dictionaryEntry);          this._tagNotification.setContent(content);          this._tagNotification.open();      } +    /** +     * @param {boolean} animate +     */      _hideTagNotification(animate) {          if (this._tagNotification === null) { return; }          this._tagNotification.close(animate);      } +    /** +     * @param {import('settings').ProfileOptions} options +     */      _updateDocumentOptions(options) {          const data = document.documentElement.dataset;          data.ankiEnabled = `${options.anki.enable}`; @@ -903,6 +1123,9 @@ export class Display extends EventDispatcher {          data.popupActionBarLocation = `${options.general.popupActionBarLocation}`;      } +    /** +     * @param {import('settings').ProfileOptions} options +     */      _setTheme(options) {          const {general} = options;          const {popupTheme} = general; @@ -912,11 +1135,19 @@ export class Display extends EventDispatcher {          this.setCustomCss(general.customPopupCss);      } +    /** +     * @param {boolean} isKanji +     * @param {string} source +     * @param {boolean} wildcardsEnabled +     * @param {import('settings').OptionsContext} optionsContext +     * @returns {Promise<import('dictionary').DictionaryEntry[]>} +     */      async _findDictionaryEntries(isKanji, source, wildcardsEnabled, optionsContext) {          if (isKanji) {              const dictionaryEntries = await yomitan.api.kanjiFind(source, optionsContext);              return dictionaryEntries;          } else { +            /** @type {import('api').FindTermsDetails} */              const findDetails = {};              if (wildcardsEnabled) {                  const match = /^([*\uff0a]*)([\w\W]*?)([*\uff0a]*)$/.exec(source); @@ -937,6 +1168,11 @@ export class Display extends EventDispatcher {          }      } +    /** +     * @param {string} type +     * @param {URLSearchParams} urlSearchParams +     * @param {import('core').TokenObject} token +     */      async _setContentTermsOrKanji(type, urlSearchParams, token) {          const lookup = (urlSearchParams.get('lookup') !== 'false');          const wildcardsEnabled = (urlSearchParams.get('wildcards') !== 'off'); @@ -946,20 +1182,21 @@ export class Display extends EventDispatcher {          if (query === null) { query = ''; }          let queryFull = urlSearchParams.get('full');          queryFull = (queryFull !== null ? queryFull : query); -        let queryOffset = urlSearchParams.get('offset'); -        if (queryOffset !== null) { -            queryOffset = Number.parseInt(queryOffset, 10); -            queryOffset = Number.isFinite(queryOffset) ? Math.max(0, Math.min(queryFull.length - query.length, queryOffset)) : null; +        const queryOffsetString = urlSearchParams.get('offset'); +        let queryOffset = 0; +        if (queryOffsetString !== null) { +            queryOffset = Number.parseInt(queryOffsetString, 10); +            queryOffset = Number.isFinite(queryOffset) ? Math.max(0, Math.min(queryFull.length - query.length, queryOffset)) : 0;          }          this._setQuery(query, queryFull, queryOffset);          let {state, content} = this._history;          let changeHistory = false; -        if (!isObject(content)) { +        if (!(typeof content === 'object' && content !== null)) {              content = {};              changeHistory = true;          } -        if (!isObject(state)) { +        if (!(typeof state === 'object' && state !== null)) {              state = {};              changeHistory = true;          } @@ -1052,8 +1289,9 @@ export class Display extends EventDispatcher {          this._triggerContentUpdateComplete();      } +    /** */      _setContentExtensionUnloaded() { -        const errorExtensionUnloaded = document.querySelector('#error-extension-unloaded'); +        const errorExtensionUnloaded = /** @type {?HTMLElement} */ (document.querySelector('#error-extension-unloaded'));          if (this._container !== null) {              this._container.hidden = true; @@ -1071,6 +1309,7 @@ export class Display extends EventDispatcher {          this._triggerContentUpdateComplete();      } +    /** */      _clearContent() {          this._container.textContent = '';          this._updateNavigationAuto(); @@ -1080,14 +1319,22 @@ export class Display extends EventDispatcher {          this._triggerContentUpdateComplete();      } +    /** +     * @param {boolean} visible +     */      _setNoContentVisible(visible) { -        const noResults = document.querySelector('#no-results'); +        const noResults = /** @type {?HTMLElement} */ (document.querySelector('#no-results'));          if (noResults !== null) {              noResults.hidden = !visible;          }      } +    /** +     * @param {string} query +     * @param {string} fullQuery +     * @param {number} queryOffset +     */      _setQuery(query, fullQuery, queryOffset) {          this._query = query;          this._fullQuery = fullQuery; @@ -1096,6 +1343,7 @@ export class Display extends EventDispatcher {          this._setTitleText(query);      } +    /** */      _updateQueryParser() {          const text = this._fullQuery;          const visible = this._isQueryParserVisible(); @@ -1105,6 +1353,9 @@ export class Display extends EventDispatcher {          }      } +    /** +     * @param {string} text +     */      async _setQueryParserText(text) {          const overrideToken = this._progressIndicatorVisible.setOverride(true);          try { @@ -1114,6 +1365,9 @@ export class Display extends EventDispatcher {          }      } +    /** +     * @param {string} text +     */      _setTitleText(text) {          let title = this._defaultTitle;          if (text.length > 0) { @@ -1130,10 +1384,15 @@ export class Display extends EventDispatcher {          document.title = title;      } +    /** */      _updateNavigationAuto() {          this._updateNavigation(this._history.hasPrevious(), this._history.hasNext());      } +    /** +     * @param {boolean} previous +     * @param {boolean} next +     */      _updateNavigation(previous, next) {          const {documentElement} = document;          if (documentElement !== null) { @@ -1148,6 +1407,9 @@ export class Display extends EventDispatcher {          }      } +    /** +     * @param {number} index +     */      _entrySetCurrent(index) {          const entryPre = this._getEntry(this._index);          if (entryPre !== null) { @@ -1162,6 +1424,11 @@ export class Display extends EventDispatcher {          this._index = index;      } +    /** +     * @param {number} index +     * @param {number} definitionIndex +     * @param {boolean} smooth +     */      _focusEntry(index, definitionIndex, smooth) {          index = Math.max(Math.min(index, this._dictionaryEntries.length - 1), 0); @@ -1188,6 +1455,11 @@ export class Display extends EventDispatcher {          }      } +    /** +     * @param {number} offset +     * @param {boolean} smooth +     * @returns {boolean} +     */      _focusEntryWithDifferentDictionary(offset, smooth) {          const sign = Math.sign(offset);          if (sign === 0) { return false; } @@ -1200,18 +1472,22 @@ export class Display extends EventDispatcher {          const visibleDefinitionIndex = this._getDictionaryEntryVisibleDefinitionIndex(index, sign);          if (visibleDefinitionIndex === null) { return false; } -        const {dictionary} = dictionaryEntry.definitions[visibleDefinitionIndex];          let focusDefinitionIndex = null; -        for (let i = index; i >= 0 && i < count; i += sign) { -            const {definitions} = this._dictionaryEntries[i]; -            const jj = definitions.length; -            let j = (i === index ? visibleDefinitionIndex + sign : (sign > 0 ? 0 : jj - 1)); -            for (; j >= 0 && j < jj; j += sign) { -                if (definitions[j].dictionary !== dictionary) { -                    focusDefinitionIndex = j; -                    index = i; -                    i = -2; // Terminate outer loop -                    break; +        if (dictionaryEntry.type === 'term') { +            const {dictionary} = dictionaryEntry.definitions[visibleDefinitionIndex]; +            for (let i = index; i >= 0 && i < count; i += sign) { +                const otherDictionaryEntry = this._dictionaryEntries[i]; +                if (otherDictionaryEntry.type !== 'term') { continue; } +                const {definitions} = otherDictionaryEntry; +                const jj = definitions.length; +                let j = (i === index ? visibleDefinitionIndex + sign : (sign > 0 ? 0 : jj - 1)); +                for (; j >= 0 && j < jj; j += sign) { +                    if (definitions[j].dictionary !== dictionary) { +                        focusDefinitionIndex = j; +                        index = i; +                        i = -2; // Terminate outer loop +                        break; +                    }                  }              }          } @@ -1222,6 +1498,11 @@ export class Display extends EventDispatcher {          return true;      } +    /** +     * @param {number} index +     * @param {number} sign +     * @returns {?number} +     */      _getDictionaryEntryVisibleDefinitionIndex(index, sign) {          const {top: scrollTop, bottom: scrollBottom} = this._windowScroll.getRect(); @@ -1247,18 +1528,28 @@ export class Display extends EventDispatcher {          return visibleIndex !== null ? visibleIndex : (sign > 0 ? definitionCount - 1 : 0);      } +    /** +     * @param {number} index +     * @returns {NodeListOf<HTMLElement>} +     */      _getDictionaryEntryDefinitionNodes(index) {          return this._dictionaryEntryNodes[index].querySelectorAll('.definition-item');      } +    /** */      _sourceTermView() {          this._relativeTermView(false);      } +    /** */      _nextTermView() {          this._relativeTermView(true);      } +    /** +     * @param {boolean} next +     * @returns {boolean} +     */      _relativeTermView(next) {          if (next) {              return this._history.hasNext() && this._history.forward(); @@ -1267,24 +1558,29 @@ export class Display extends EventDispatcher {          }      } +    /** +     * @param {number} index +     * @returns {?HTMLElement} +     */      _getEntry(index) {          const entries = this._dictionaryEntryNodes;          return index >= 0 && index < entries.length ? entries[index] : null;      } +    /** +     * @param {Element} element +     * @returns {number} +     */      _getElementTop(element) {          const elementRect = element.getBoundingClientRect();          const documentRect = this._contentScrollBodyElement.getBoundingClientRect();          return elementRect.top - documentRect.top;      } -    _historyHasState() { -        return isObject(this._history.state); -    } - +    /** */      _updateHistoryState() {          const {state, content} = this._history; -        if (!isObject(state)) { return; } +        if (!(typeof state === 'object' && state !== null)) { return; }          state.focusEntry = this._index;          state.scrollX = this._windowScroll.x; @@ -1292,6 +1588,10 @@ export class Display extends EventDispatcher {          this._replaceHistoryStateNoNavigate(state, content);      } +    /** +     * @param {import('display-history').EntryState} state +     * @param {?import('display-history').EntryContent} content +     */      _replaceHistoryStateNoNavigate(state, content) {          const historyChangeIgnorePre = this._historyChangeIgnore;          try { @@ -1302,7 +1602,15 @@ export class Display extends EventDispatcher {          }      } +    /** +     * @param {import('display').PageType} type +     * @param {string} query +     * @param {boolean} wildcards +     * @param {?number} sentenceOffset +     * @returns {import('display').HistoryParams} +     */      _createSearchParams(type, query, wildcards, sentenceOffset) { +        /** @type {import('display').HistoryParams} */          const params = {};          const fullQuery = this._fullQuery;          const includeFull = (query.length < fullQuery.length); @@ -1325,6 +1633,9 @@ export class Display extends EventDispatcher {          return params;      } +    /** +     * @returns {boolean} +     */      _isQueryParserVisible() {          return (              this._queryParserVisibleOverride !== null ? @@ -1333,22 +1644,34 @@ export class Display extends EventDispatcher {          );      } +    /** */      _closePopups() {          yomitan.trigger('closePopups');      } +    /** +     * @param {import('settings').OptionsContext} optionsContext +     */      async _setOptionsContextIfDifferent(optionsContext) {          if (deepEqual(this._optionsContext, optionsContext)) { return; }          await this.setOptionsContext(optionsContext);      } +    /** +     * @param {number} scale +     */      _setContentScale(scale) {          const body = document.body;          if (body === null) { return; }          body.style.fontSize = `${scale}em`;      } +    /** +     * @param {import('settings').ProfileOptions} options +     */      async _updateNestedFrontend(options) { +        if (typeof this._frameId !== 'number') { return; } +          const isSearchPage = (this._pageType === 'search');          const isEnabled = (              this._childrenSupported && @@ -1376,15 +1699,18 @@ export class Display extends EventDispatcher {              }          } -        this._frontend.setDisabledOverride(!isEnabled); +        /** @type {Frontend} */ (this._frontend).setDisabledOverride(!isEnabled);      } +    /** */      async _setupNestedFrontend() { -        const setupNestedPopupsOptions = { -            useProxyPopup: this._parentFrameId !== null, -            parentPopupId: this._parentPopupId, -            parentFrameId: this._parentFrameId -        }; +        if (typeof this._frameId !== 'number') { +            throw new Error('No frameId assigned'); +        } + +        const useProxyPopup = this._parentFrameId !== null; +        const parentPopupId = this._parentPopupId; +        const parentFrameId = this._parentFrameId;          await dynamicLoader.loadScripts([              '/js/language/text-scanner.js', @@ -1401,7 +1727,11 @@ export class Display extends EventDispatcher {          const popupFactory = new PopupFactory(this._frameId);          popupFactory.prepare(); -        Object.assign(setupNestedPopupsOptions, { +        /** @type {import('frontend').ConstructorDetails} */ +        const setupNestedPopupsOptions = { +            useProxyPopup, +            parentPopupId, +            parentFrameId,              depth: this._depth + 1,              tabId: this._tabId,              frameId: this._frameId, @@ -1410,19 +1740,25 @@ export class Display extends EventDispatcher {              allowRootFramePopupProxy: true,              childrenSupported: this._childrenSupported,              hotkeyHandler: this._hotkeyHandler -        }); +        };          const frontend = new Frontend(setupNestedPopupsOptions);          this._frontend = frontend;          await frontend.prepare();      } +    /** +     * @returns {boolean} +     */      _copyHostSelection() { -        if (this._contentOriginFrameId === null || window.getSelection().toString()) { return false; } +        if (typeof this._contentOriginFrameId !== 'number') { return false; } +        const selection = window.getSelection(); +        if (selection !== null && selection.toString().length > 0) { return false; }          this._copyHostSelectionSafe();          return true;      } +    /** */      async _copyHostSelectionSafe() {          try {              await this._copyHostSelectionInner(); @@ -1431,11 +1767,13 @@ export class Display extends EventDispatcher {          }      } +    /** */      async _copyHostSelectionInner() {          switch (this._browser) {              case 'firefox':              case 'firefox-mobile':                  { +                    /** @type {string} */                      let text;                      try {                          text = await this.invokeContentOrigin('Frontend.getSelectionText'); @@ -1451,6 +1789,9 @@ export class Display extends EventDispatcher {          }      } +    /** +     * @param {string} text +     */      _copyText(text) {          const parent = document.body;          if (parent === null) { return; } @@ -1468,6 +1809,9 @@ export class Display extends EventDispatcher {          parent.removeChild(textarea);      } +    /** +     * @param {HTMLElement} entry +     */      _addEntryEventListeners(entry) {          const eventListeners = this._eventListeners;          eventListeners.addEventListener(entry, 'click', this._onEntryClickBind); @@ -1483,6 +1827,9 @@ export class Display extends EventDispatcher {          }      } +    /** +     * @param {import('settings').ProfileOptions} options +     */      _updateContentTextScanner(options) {          if (!options.scanning.enablePopupSearch) {              if (this._contentTextScanner !== null) { @@ -1544,10 +1891,14 @@ export class Display extends EventDispatcher {          this._contentTextScanner.setEnabled(true);      } +    /** */      _onContentTextScannerClear() { -        this._contentTextScanner.clearSelection(); +        /** @type {TextScanner} */ (this._contentTextScanner).clearSelection();      } +    /** +     * @param {import('text-scanner').SearchedEventDetails} details +     */      _onContentTextScannerSearched({type, dictionaryEntries, sentence, textSource, optionsContext, error}) {          if (error !== null && !yomitan.isExtensionUnloaded) {              log.error(error); @@ -1558,6 +1909,7 @@ export class Display extends EventDispatcher {          const query = textSource.text();          const url = window.location.href;          const documentTitle = document.title; +        /** @type {import('display').ContentDetails} */          const details = {              focus: false,              historyMode: 'new', @@ -1568,28 +1920,35 @@ export class Display extends EventDispatcher {              },              state: {                  focusEntry: 0, -                optionsContext, +                optionsContext: optionsContext !== null ? optionsContext : void 0,                  url, -                sentence, +                sentence: sentence !== null ? sentence : void 0,                  documentTitle              },              content: { -                dictionaryEntries, +                dictionaryEntries: dictionaryEntries !== null ? dictionaryEntries : void 0,                  contentOrigin: this.getContentOrigin()              }          }; -        this._contentTextScanner.clearSelection(); +        /** @type {TextScanner} */ (this._contentTextScanner).clearSelection();          this.setContent(details);      } +    /** +     * @type {import('display').GetSearchContextCallback} +     */      _getSearchContext() {          return {optionsContext: this.getOptionsContext()};      } +    /** +     * @param {import('settings').ProfileOptions} options +     */      _updateHotkeys(options) {          this._hotkeyHandler.setHotkeys(this._pageType, options.inputs.hotkeys);      } +    /** */      async _closeTab() {          const tab = await new Promise((resolve, reject) => {              chrome.tabs.getCurrent((result) => { @@ -1602,7 +1961,7 @@ export class Display extends EventDispatcher {              });          });          const tabId = tab.id; -        await new Promise((resolve, reject) => { +        await /** @type {Promise<void>} */ (new Promise((resolve, reject) => {              chrome.tabs.remove(tabId, () => {                  const e = chrome.runtime.lastError;                  if (e) { @@ -1611,27 +1970,36 @@ export class Display extends EventDispatcher {                      resolve();                  }              }); -        }); +        }));      } +    /** */      _onHotkeyClose() {          if (this._closeSinglePopupMenu()) { return; }          this.close();      } +    /** +     * @param {number} sign +     * @param {unknown} argument +     */      _onHotkeyActionMoveRelative(sign, argument) { -        let count = Number.parseInt(argument, 10); +        let count = typeof argument === 'number' ? argument : (typeof argument === 'string' ? Number.parseInt(argument, 10) : 0);          if (!Number.isFinite(count)) { count = 1; }          count = Math.max(0, Math.floor(count));          this._focusEntry(this._index + count * sign, 0, true);      } +    /** */      _closeAllPopupMenus() {          for (const popupMenu of PopupMenu.openMenus) {              popupMenu.close();          }      } +    /** +     * @returns {boolean} +     */      _closeSinglePopupMenu() {          for (const popupMenu of PopupMenu.openMenus) {              popupMenu.close(); @@ -1640,13 +2008,19 @@ export class Display extends EventDispatcher {          return false;      } +    /** +     * @param {number} index +     */      async _logDictionaryEntryData(index) {          if (index < 0 || index >= this._dictionaryEntries.length) { return; }          const dictionaryEntry = this._dictionaryEntries[index];          const result = {dictionaryEntry}; +        /** @type {Promise<unknown>[]} */          const promises = []; -        this.trigger('logDictionaryEntryData', {dictionaryEntry, promises}); +        /** @type {import('display').LogDictionaryEntryDataEvent} */ +        const event = {dictionaryEntry, promises}; +        this.trigger('logDictionaryEntryData', event);          if (promises.length > 0) {              for (const result2 of await Promise.all(promises)) {                  Object.assign(result, result2); @@ -1656,19 +2030,33 @@ export class Display extends EventDispatcher {          console.log(result);      } +    /** */      _triggerContentClear() {          this.trigger('contentClear', {});      } +    /** */      _triggerContentUpdateStart() { -        this.trigger('contentUpdateStart', {type: this._contentType, query: this._query}); +        /** @type {import('display').ContentUpdateStartEvent} */ +        const event = {type: this._contentType, query: this._query}; +        this.trigger('contentUpdateStart', event);      } +    /** +     * @param {import('dictionary').DictionaryEntry} dictionaryEntry +     * @param {Element} element +     * @param {number} index +     */      _triggerContentUpdateEntry(dictionaryEntry, element, index) { -        this.trigger('contentUpdateEntry', {dictionaryEntry, element, index}); +        /** @type {import('display').ContentUpdateEntryEvent} */ +        const event = {dictionaryEntry, element, index}; +        this.trigger('contentUpdateEntry', event);      } +    /** */      _triggerContentUpdateComplete() { -        this.trigger('contentUpdateComplete', {type: this._contentType}); +        /** @type {import('display').ContentUpdateCompleteEvent} */ +        const event = {type: this._contentType}; +        this.trigger('contentUpdateComplete', event);      }  } diff --git a/ext/js/display/element-overflow-controller.js b/ext/js/display/element-overflow-controller.js index 0a62906b..1d2c808f 100644 --- a/ext/js/display/element-overflow-controller.js +++ b/ext/js/display/element-overflow-controller.js @@ -20,16 +20,27 @@ import {EventListenerCollection} from '../core.js';  export class ElementOverflowController {      constructor() { +        /** @type {Element[]} */          this._elements = []; +        /** @type {?number} */          this._checkTimer = null; +        /** @type {EventListenerCollection} */          this._eventListeners = new EventListenerCollection(); +        /** @type {EventListenerCollection} */          this._windowEventListeners = new EventListenerCollection(); +        /** @type {Map<string, {collapsed: boolean, force: boolean}>} */          this._dictionaries = new Map(); +        /** @type {() => void} */          this._updateBind = this._update.bind(this); +        /** @type {() => void} */          this._onWindowResizeBind = this._onWindowResize.bind(this); +        /** @type {(event: MouseEvent) => void} */          this._onToggleButtonClickBind = this._onToggleButtonClick.bind(this);      } +    /** +     * @param {import('settings').ProfileOptions} options +     */      setOptions(options) {          this._dictionaries.clear();          for (const {name, definitionsCollapsible} of options.dictionaries) { @@ -59,12 +70,18 @@ export class ElementOverflowController {          }      } +    /** +     * @param {Element} entry +     */      addElements(entry) {          if (this._dictionaries.size === 0) { return; }          const elements = entry.querySelectorAll('.definition-item-inner');          for (const element of elements) { -            const {dictionary} = element.parentNode.dataset; +            const {parentNode} = element; +            if (parentNode === null) { continue; } +            const {dictionary} = /** @type {HTMLElement} */ (parentNode).dataset; +            if (typeof dictionary === 'undefined') { continue; }              const dictionaryInfo = this._dictionaries.get(dictionary);              if (typeof dictionaryInfo === 'undefined') { continue; } @@ -90,6 +107,7 @@ export class ElementOverflowController {          }      } +    /** */      clearElements() {          this._elements.length = 0;          this._windowEventListeners.removeAllEventListeners(); @@ -97,6 +115,7 @@ export class ElementOverflowController {      // Private +    /** */      _onWindowResize() {          if (this._checkTimer !== null) {              this._cancelIdleCallback(this._checkTimer); @@ -104,18 +123,26 @@ export class ElementOverflowController {          this._checkTimer = this._requestIdleCallback(this._updateBind, 100);      } +    /** +     * @param {MouseEvent} e +     */      _onToggleButtonClick(e) { -        const container = e.currentTarget.closest('.definition-item-inner'); +        const element = /** @type {Element} */ (e.currentTarget); +        const container = element.closest('.definition-item-inner');          if (container === null) { return; }          container.classList.toggle('collapsed');      } +    /** */      _update() {          for (const element of this._elements) {              this._updateElement(element);          }      } +    /** +     * @param {Element} element +     */      _updateElement(element) {          const {classList} = element;          classList.add('collapse-test'); @@ -124,6 +151,11 @@ export class ElementOverflowController {          classList.remove('collapse-test');      } +    /** +     * @param {() => void} callback +     * @param {number} timeout +     * @returns {number} +     */      _requestIdleCallback(callback, timeout) {          if (typeof requestIdleCallback === 'function') {              return requestIdleCallback(callback, {timeout}); @@ -132,6 +164,9 @@ export class ElementOverflowController {          }      } +    /** +     * @param {number} handle +     */      _cancelIdleCallback(handle) {          if (typeof cancelIdleCallback === 'function') {              cancelIdleCallback(handle); diff --git a/ext/js/display/option-toggle-hotkey-handler.js b/ext/js/display/option-toggle-hotkey-handler.js index 1f8de939..e73fcf04 100644 --- a/ext/js/display/option-toggle-hotkey-handler.js +++ b/ext/js/display/option-toggle-hotkey-handler.js @@ -16,17 +16,26 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -import {deserializeError} from '../core.js';  import {yomitan} from '../yomitan.js';  export class OptionToggleHotkeyHandler { +    /** +     * @param {Display} display +     */      constructor(display) { +        /** @type {Display} */          this._display = display; +        /** @type {?DisplayNotification} */          this._notification = null; +        /** @type {?number} */          this._notificationHideTimer = null; +        /** @type {number} */          this._notificationHideTimeout = 5000; +        /** @type {string} */ +        this._source = `option-toggle-hotkey-handler-${generateId(16)}`;      } +    /** @type {number} */      get notificationHideTimeout() {          return this._notificationHideTimeout;      } @@ -35,6 +44,7 @@ export class OptionToggleHotkeyHandler {          this._notificationHideTimeout = value;      } +    /** */      prepare() {          this._display.hotkeyHandler.registerActions([              ['toggleOption', this._onHotkeyActionToggleOption.bind(this)] @@ -43,10 +53,17 @@ export class OptionToggleHotkeyHandler {      // Private +    /** +     * @param {unknown} argument +     */      _onHotkeyActionToggleOption(argument) { +        if (typeof argument !== 'string') { return; }          this._toggleOption(argument);      } +    /** +     * @param {string} path +     */      async _toggleOption(path) {          let value;          try { @@ -59,7 +76,7 @@ export class OptionToggleHotkeyHandler {              }]))[0];              const {error} = result;              if (typeof error !== 'undefined') { -                throw deserializeError(error); +                throw ExtensionError.deserialize(error);              }              value = result.result; @@ -69,16 +86,18 @@ export class OptionToggleHotkeyHandler {              value = !value; -            const result2 = (await yomitan.api.modifySettings([{ +            /** @type {import('settings-modifications').ScopedModificationSet} */ +            const modification = {                  scope: 'profile',                  action: 'set',                  path,                  value,                  optionsContext -            }]))[0]; +            }; +            const result2 = (await yomitan.api.modifySettings([modification], this._source))[0];              const {error: error2} = result2;              if (typeof error2 !== 'undefined') { -                throw deserializeError(error2); +                throw ExtensionError.deserialize(error2);              }              this._showNotification(this._createSuccessMessage(path, value), true); @@ -87,12 +106,17 @@ export class OptionToggleHotkeyHandler {          }      } +    /** +     * @param {string} path +     * @param {unknown} value +     * @returns {DocumentFragment} +     */      _createSuccessMessage(path, value) {          const fragment = document.createDocumentFragment();          const n1 = document.createElement('em');          n1.textContent = path;          const n2 = document.createElement('strong'); -        n2.textContent = value; +        n2.textContent = `${value}`;          fragment.appendChild(document.createTextNode('Option '));          fragment.appendChild(n1);          fragment.appendChild(document.createTextNode(' changed to ')); @@ -100,17 +124,13 @@ export class OptionToggleHotkeyHandler {          return fragment;      } +    /** +     * @param {string} path +     * @param {unknown} error +     * @returns {DocumentFragment} +     */      _createErrorMessage(path, error) { -        let message; -        try { -            ({message} = error); -        } catch (e) { -            // NOP -        } -        if (typeof message !== 'string') { -            message = `${error}`; -        } - +        const message = error instanceof Error ? error.message : `${error}`;          const fragment = document.createDocumentFragment();          const n1 = document.createElement('em');          n1.textContent = path; @@ -124,6 +144,10 @@ export class OptionToggleHotkeyHandler {          return fragment;      } +    /** +     * @param {DocumentFragment} message +     * @param {boolean} autoClose +     */      _showNotification(message, autoClose) {          if (this._notification === null) {              this._notification = this._display.createNotification(false); @@ -139,12 +163,16 @@ export class OptionToggleHotkeyHandler {          }      } +    /** +     * @param {boolean} animate +     */      _hideNotification(animate) {          if (this._notification === null) { return; }          this._notification.close(animate);          this._stopHideNotificationTimer();      } +    /** */      _stopHideNotificationTimer() {          if (this._notificationHideTimer !== null) {              clearTimeout(this._notificationHideTimer); @@ -152,11 +180,13 @@ export class OptionToggleHotkeyHandler {          }      } +    /** */      _onNotificationHideTimeout() {          this._notificationHideTimer = null;          this._hideNotification(true);      } +    /** */      _onNotificationClick() {          this._stopHideNotificationTimer();      } diff --git a/ext/js/display/query-parser.js b/ext/js/display/query-parser.js index 85ec3031..fd173bde 100644 --- a/ext/js/display/query-parser.js +++ b/ext/js/display/query-parser.js @@ -20,22 +20,42 @@ import {EventDispatcher, log} from '../core.js';  import {TextScanner} from '../language/text-scanner.js';  import {yomitan} from '../yomitan.js'; +/** + * @augments EventDispatcher<import('display').QueryParserEventType> + */  export class QueryParser extends EventDispatcher { +    /** +     * @param {import('display').QueryParserConstructorDetails} details +     */      constructor({getSearchContext, japaneseUtil}) {          super(); +        /** @type {import('display').GetSearchContextCallback} */          this._getSearchContext = getSearchContext; +        /** @type {JapaneseUtil} */          this._japaneseUtil = japaneseUtil; +        /** @type {string} */          this._text = ''; +        /** @type {?import('core').TokenObject} */          this._setTextToken = null; +        /** @type {?string} */          this._selectedParser = null; +        /** @type {import('settings').ParsingReadingMode} */          this._readingMode = 'none'; +        /** @type {number} */          this._scanLength = 1; +        /** @type {boolean} */          this._useInternalParser = true; +        /** @type {boolean} */          this._useMecabParser = false; +        /** @type {import('api').ParseTextResult} */          this._parseResults = []; -        this._queryParser = document.querySelector('#query-parser-content'); -        this._queryParserModeContainer = document.querySelector('#query-parser-mode-container'); -        this._queryParserModeSelect = document.querySelector('#query-parser-mode-select'); +        /** @type {HTMLElement} */ +        this._queryParser = /** @type {HTMLElement} */ (document.querySelector('#query-parser-content')); +        /** @type {HTMLElement} */ +        this._queryParserModeContainer = /** @type {HTMLElement} */ (document.querySelector('#query-parser-mode-container')); +        /** @type {HTMLSelectElement} */ +        this._queryParserModeSelect = /** @type {HTMLSelectElement} */ (document.querySelector('#query-parser-mode-select')); +        /** @type {TextScanner} */          this._textScanner = new TextScanner({              node: this._queryParser,              getSearchContext, @@ -45,10 +65,12 @@ export class QueryParser extends EventDispatcher {          });      } +    /** @type {string} */      get text() {          return this._text;      } +    /** */      prepare() {          this._textScanner.prepare();          this._textScanner.on('clear', this._onTextScannerClear.bind(this)); @@ -56,6 +78,9 @@ export class QueryParser extends EventDispatcher {          this._queryParserModeSelect.addEventListener('change', this._onParserChange.bind(this), false);      } +    /** +     * @param {import('display').QueryParserOptions} display +     */      setOptions({selectedParser, termSpacing, readingMode, useInternalParser, useMecabParser, scanning}) {          let selectedParserChanged = false;          if (selectedParser === null || typeof selectedParser === 'string') { @@ -87,10 +112,14 @@ export class QueryParser extends EventDispatcher {          }      } +    /** +     * @param {string} text +     */      async setText(text) {          this._text = text;          this._setPreview(text); +        /** @type {?import('core').TokenObject} */          const token = {};          this._setTextToken = token;          this._parseResults = await yomitan.api.parseText(text, this._getOptionsContext(), this._scanLength, this._useInternalParser, this._useMecabParser); @@ -104,32 +133,63 @@ export class QueryParser extends EventDispatcher {      // Private +    /** */      _onTextScannerClear() {          this._textScanner.clearSelection();      } +    /** +     * @param {import('text-scanner').SearchedEventDetails} e +     */      _onSearched(e) {          const {error} = e;          if (error !== null) {              log.error(error);              return;          } -        if (e.type === null) { return; } - -        e.sentenceOffset = this._getSentenceOffset(e.textSource); -        this.trigger('searched', e); +        const { +            textScanner, +            type, +            dictionaryEntries, +            sentence, +            inputInfo, +            textSource, +            optionsContext +        } = e; +        if (type === null || dictionaryEntries === null || sentence === null || optionsContext === null) { return; } + +        /** @type {import('display').QueryParserSearchedEvent} */ +        const event2 = { +            textScanner, +            type, +            dictionaryEntries, +            sentence, +            inputInfo, +            textSource, +            optionsContext, +            sentenceOffset: this._getSentenceOffset(e.textSource) +        }; +        this.trigger('searched', event2);      } +    /** +     * @param {Event} e +     */      _onParserChange(e) { -        const value = e.currentTarget.value; +        const element = /** @type {HTMLInputElement} */ (e.currentTarget); +        const value = element.value;          this._setSelectedParser(value);      } +    /** +     * @returns {import('settings').OptionsContext} +     */      _getOptionsContext() {          return this._getSearchContext().optionsContext;      } +    /** */      _refreshSelectedParser() {          if (this._parseResults.length > 0 && !this._getParseResult()) {              const value = this._parseResults[0].id; @@ -137,22 +197,33 @@ export class QueryParser extends EventDispatcher {          }      } +    /** +     * @param {string} value +     */      _setSelectedParser(value) {          const optionsContext = this._getOptionsContext(); -        yomitan.api.modifySettings([{ +        /** @type {import('settings-modifications').ScopedModificationSet} */ +        const modification = {              action: 'set',              path: 'parsing.selectedParser',              value,              scope: 'profile',              optionsContext -        }], 'search'); +        }; +        yomitan.api.modifySettings([modification], 'search');      } +    /** +     * @returns {import('api').ParseTextResultItem|undefined} +     */      _getParseResult() {          const selectedParser = this._selectedParser;          return this._parseResults.find((r) => r.id === selectedParser);      } +    /** +     * @param {string} text +     */      _setPreview(text) {          const terms = [[{text, reading: ''}]];          this._queryParser.textContent = ''; @@ -160,6 +231,7 @@ export class QueryParser extends EventDispatcher {          this._queryParser.appendChild(this._createParseResult(terms));      } +    /** */      _renderParserSelect() {          const visible = (this._parseResults.length > 1);          if (visible) { @@ -168,6 +240,7 @@ export class QueryParser extends EventDispatcher {          this._queryParserModeContainer.hidden = !visible;      } +    /** */      _renderParseResult() {          const parseResult = this._getParseResult();          this._queryParser.textContent = ''; @@ -176,6 +249,11 @@ export class QueryParser extends EventDispatcher {          this._queryParser.appendChild(this._createParseResult(parseResult.content));      } +    /** +     * @param {HTMLSelectElement} select +     * @param {import('api').ParseTextResult} parseResults +     * @param {?string} selectedParser +     */      _updateParserModeSelect(select, parseResults, selectedParser) {          const fragment = document.createDocumentFragment(); @@ -208,6 +286,10 @@ export class QueryParser extends EventDispatcher {          select.selectedIndex = selectedIndex;      } +    /** +     * @param {import('api').ParseTextLine[]} data +     * @returns {DocumentFragment} +     */      _createParseResult(data) {          let offset = 0;          const fragment = document.createDocumentFragment(); @@ -229,6 +311,12 @@ export class QueryParser extends EventDispatcher {          return fragment;      } +    /** +     * @param {string} text +     * @param {string} reading +     * @param {number} offset +     * @returns {HTMLElement} +     */      _createSegment(text, reading, offset) {          const segmentNode = document.createElement('ruby');          segmentNode.className = 'query-parser-segment'; @@ -249,6 +337,11 @@ export class QueryParser extends EventDispatcher {          return segmentNode;      } +    /** +     * @param {string} term +     * @param {string} reading +     * @returns {string} +     */      _convertReading(term, reading) {          switch (this._readingMode) {              case 'hiragana': @@ -271,11 +364,15 @@ export class QueryParser extends EventDispatcher {          }      } +    /** +     * @param {import('text-source').TextSource} textSource +     * @returns {?number} +     */      _getSentenceOffset(textSource) {          if (textSource.type === 'range') {              const {range} = textSource;              const node = this._getParentElement(range.startContainer); -            if (node !== null) { +            if (node !== null && node instanceof HTMLElement) {                  const {offset} = node.dataset;                  if (typeof offset === 'string') {                      const value = Number.parseInt(offset, 10); @@ -288,12 +385,16 @@ export class QueryParser extends EventDispatcher {          return null;      } +    /** +     * @param {?Node} node +     * @returns {?Element} +     */      _getParentElement(node) {          const {ELEMENT_NODE} = Node;          while (true) { -            node = node.parentNode;              if (node === null) { return null; } -            if (node.nodeType === ELEMENT_NODE) { return node; } +            if (node.nodeType === ELEMENT_NODE) { return /** @type {Element} */ (node); } +            node = node.parentNode;          }      }  } diff --git a/ext/js/display/sandbox/pronunciation-generator.js b/ext/js/display/sandbox/pronunciation-generator.js index 76d5e2b1..eeedc574 100644 --- a/ext/js/display/sandbox/pronunciation-generator.js +++ b/ext/js/display/sandbox/pronunciation-generator.js @@ -17,10 +17,21 @@   */  export class PronunciationGenerator { +    /** +     * @param {JapaneseUtil} japaneseUtil +     */      constructor(japaneseUtil) { +        /** @type {JapaneseUtil} */          this._japaneseUtil = japaneseUtil;      } +    /** +     * @param {string[]} morae +     * @param {number} downstepPosition +     * @param {number[]} nasalPositions +     * @param {number[]} devoicePositions +     * @returns {HTMLSpanElement} +     */      createPronunciationText(morae, downstepPosition, nasalPositions, devoicePositions) {          const jp = this._japaneseUtil;          const nasalPositionsSet = nasalPositions.length > 0 ? new Set(nasalPositions) : null; @@ -63,7 +74,7 @@ export class PronunciationGenerator {                  group.className = 'pronunciation-character-group';                  const n2 = characterNodes[0]; -                const character = n2.textContent; +                const character = /** @type {string} */ (n2.textContent);                  const characterInfo = jp.getKanaDiacriticInfo(character);                  if (characterInfo !== null) { @@ -81,7 +92,7 @@ export class PronunciationGenerator {                  n3.className = 'pronunciation-nasal-indicator';                  group.appendChild(n3); -                n2.parentNode.replaceChild(group, n2); +                /** @type {ParentNode} */ (n2.parentNode).replaceChild(group, n2);                  group.insertBefore(n2, group.firstChild);              } @@ -94,6 +105,11 @@ export class PronunciationGenerator {          return container;      } +    /** +     * @param {string[]} morae +     * @param {number} downstepPosition +     * @returns {SVGSVGElement} +     */      createPronunciationGraph(morae, downstepPosition) {          const jp = this._japaneseUtil;          const ii = morae.length; @@ -145,12 +161,16 @@ export class PronunciationGenerator {          return svg;      } +    /** +     * @param {number} downstepPosition +     * @returns {HTMLSpanElement} +     */      createPronunciationDownstepPosition(downstepPosition) { -        downstepPosition = `${downstepPosition}`; +        const downstepPositionString = `${downstepPosition}`;          const n1 = document.createElement('span');          n1.className = 'pronunciation-downstep-notation'; -        n1.dataset.downstepPosition = downstepPosition; +        n1.dataset.downstepPosition = downstepPositionString;          let n2 = document.createElement('span');          n2.className = 'pronunciation-downstep-notation-prefix'; @@ -159,7 +179,7 @@ export class PronunciationGenerator {          n2 = document.createElement('span');          n2.className = 'pronunciation-downstep-notation-number'; -        n2.textContent = downstepPosition; +        n2.textContent = downstepPositionString;          n1.appendChild(n2);          n2 = document.createElement('span'); @@ -172,15 +192,33 @@ export class PronunciationGenerator {      // Private +    /** +     * @param {Element} container +     * @param {string} svgns +     * @param {number} x +     * @param {number} y +     */      _addGraphDot(container, svgns, x, y) {          container.appendChild(this._createGraphCircle(svgns, 'pronunciation-graph-dot', x, y, '15'));      } +    /** +     * @param {Element} container +     * @param {string} svgns +     * @param {number} x +     * @param {number} y +     */      _addGraphDotDownstep(container, svgns, x, y) {          container.appendChild(this._createGraphCircle(svgns, 'pronunciation-graph-dot-downstep1', x, y, '15'));          container.appendChild(this._createGraphCircle(svgns, 'pronunciation-graph-dot-downstep2', x, y, '5'));      } +    /** +     * @param {Element} container +     * @param {string} svgns +     * @param {number} x +     * @param {number} y +     */      _addGraphTriangle(container, svgns, x, y) {          const node = document.createElementNS(svgns, 'path');          node.setAttribute('class', 'pronunciation-graph-triangle'); @@ -189,6 +227,14 @@ export class PronunciationGenerator {          container.appendChild(node);      } +    /** +     * @param {string} svgns +     * @param {string} className +     * @param {number} x +     * @param {number} y +     * @param {string} radius +     * @returns {Element} +     */      _createGraphCircle(svgns, className, x, y, radius) {          const node = document.createElementNS(svgns, 'circle');          node.setAttribute('class', className); diff --git a/ext/js/display/sandbox/structured-content-generator.js b/ext/js/display/sandbox/structured-content-generator.js index 227892d6..af49b643 100644 --- a/ext/js/display/sandbox/structured-content-generator.js +++ b/ext/js/display/sandbox/structured-content-generator.js @@ -17,28 +17,51 @@   */  export class StructuredContentGenerator { +    /** +     * @param {DisplayContentManager|AnkiTemplateRendererContentManager} contentManager +     * @param {JapaneseUtil} japaneseUtil +     * @param {Document} document +     */      constructor(contentManager, japaneseUtil, document) { +        /** @type {DisplayContentManager|AnkiTemplateRendererContentManager} */          this._contentManager = contentManager; +        /** @type {JapaneseUtil} */          this._japaneseUtil = japaneseUtil; +        /** @type {Document} */          this._document = document;      } +    /** +     * @param {HTMLElement} node +     * @param {import('structured-content').Content} content +     * @param {string} dictionary +     */      appendStructuredContent(node, content, dictionary) {          node.classList.add('structured-content');          this._appendStructuredContent(node, content, dictionary, null);      } +    /** +     * @param {import('structured-content').Content} content +     * @param {string} dictionary +     * @returns {HTMLElement} +     */      createStructuredContent(content, dictionary) {          const node = this._createElement('span', 'structured-content');          this._appendStructuredContent(node, content, dictionary, null);          return node;      } +    /** +     * @param {import('structured-content').ImageElementBase} data +     * @param {string} dictionary +     * @returns {HTMLAnchorElement} +     */      createDefinitionImage(data, dictionary) {          const {              path, -            width, -            height, +            width = 100, +            height = 100,              preferredWidth,              preferredHeight,              title, @@ -65,7 +88,7 @@ export class StructuredContentGenerator {              (hasPreferredHeight ? preferredHeight / invAspectRatio : width)          ); -        const node = this._createElement('a', 'gloss-image-link'); +        const node = /** @type {HTMLAnchorElement} */ (this._createElement('a', 'gloss-image-link'));          node.target = '_blank';          node.rel = 'noreferrer noopener'; @@ -78,7 +101,7 @@ export class StructuredContentGenerator {          const imageBackground = this._createElement('span', 'gloss-image-background');          imageContainer.appendChild(imageBackground); -        const image = this._createElement('img', 'gloss-image'); +        const image = /** @type {HTMLImageElement} */ (this._createElement('img', 'gloss-image'));          image.alt = '';          imageContainer.appendChild(image); @@ -126,6 +149,12 @@ export class StructuredContentGenerator {      // Private +    /** +     * @param {HTMLElement} container +     * @param {import('structured-content').Content|undefined} content +     * @param {string} dictionary +     * @param {?string} language +     */      _appendStructuredContent(container, content, dictionary, language) {          if (typeof content === 'string') {              if (content.length > 0) { @@ -151,16 +180,29 @@ export class StructuredContentGenerator {          }      } +    /** +     * @param {string} tagName +     * @param {string} className +     * @returns {HTMLElement} +     */      _createElement(tagName, className) {          const node = this._document.createElement(tagName);          node.className = className;          return node;      } +    /** +     * @param {string} data +     * @returns {Text} +     */      _createTextNode(data) {          return this._document.createTextNode(data);      } +    /** +     * @param {HTMLElement} element +     * @param {import('structured-content').Data} data +     */      _setElementDataset(element, data) {          for (let [key, value] of Object.entries(data)) {              if (key.length > 0) { @@ -175,6 +217,13 @@ export class StructuredContentGenerator {          }      } +    /** +     * @param {HTMLAnchorElement} node +     * @param {HTMLImageElement} image +     * @param {HTMLElement} imageBackground +     * @param {?string} url +     * @param {boolean} unloaded +     */      _setImageData(node, image, imageBackground, url, unloaded) {          if (url !== null) {              image.src = url; @@ -189,6 +238,12 @@ export class StructuredContentGenerator {          }      } +    /** +     * @param {import('structured-content').Element} content +     * @param {string} dictionary +     * @param {?string} language +     * @returns {?HTMLElement} +     */      _createStructuredContentGenericElement(content, dictionary, language) {          const {tag} = content;          switch (tag) { @@ -222,6 +277,13 @@ export class StructuredContentGenerator {          return null;      } +    /** +     * @param {string} tag +     * @param {import('structured-content').UnstyledElement} content +     * @param {string} dictionary +     * @param {?string} language +     * @returns {HTMLElement} +     */      _createStructuredContentTableElement(tag, content, dictionary, language) {          const container = this._createElement('div', 'gloss-sc-table-container');          const table = this._createStructuredContentElement(tag, content, dictionary, language, 'table', true, false); @@ -229,6 +291,16 @@ export class StructuredContentGenerator {          return container;      } +    /** +     * @param {string} tag +     * @param {import('structured-content').StyledElement|import('structured-content').UnstyledElement|import('structured-content').TableElement|import('structured-content').LineBreak} content +     * @param {string} dictionary +     * @param {?string} language +     * @param {'simple'|'table'|'table-cell'} type +     * @param {boolean} hasChildren +     * @param {boolean} hasStyle +     * @returns {HTMLElement} +     */      _createStructuredContentElement(tag, content, dictionary, language, type, hasChildren, hasStyle) {          const node = this._createElement(tag, `gloss-sc-${tag}`);          const {data, lang} = content; @@ -240,14 +312,15 @@ export class StructuredContentGenerator {          switch (type) {              case 'table-cell':                  { -                    const {colSpan, rowSpan} = content; -                    if (typeof colSpan === 'number') { node.colSpan = colSpan; } -                    if (typeof rowSpan === 'number') { node.rowSpan = rowSpan; } +                    const cell = /** @type {HTMLTableCellElement} */ (node); +                    const {colSpan, rowSpan} = /** @type {import('structured-content').TableElement} */ (content); +                    if (typeof colSpan === 'number') { cell.colSpan = colSpan; } +                    if (typeof rowSpan === 'number') { cell.rowSpan = rowSpan; }                  }                  break;          }          if (hasStyle) { -            const {style} = content; +            const {style} = /** @type {import('structured-content').StyledElement} */ (content);              if (typeof style === 'object' && style !== null) {                  this._setStructuredContentElementStyle(node, style);              } @@ -258,6 +331,10 @@ export class StructuredContentGenerator {          return node;      } +    /** +     * @param {HTMLElement} node +     * @param {import('structured-content').StructuredContentStyle} contentStyle +     */      _setStructuredContentElementStyle(node, contentStyle) {          const {style} = node;          const { @@ -290,6 +367,12 @@ export class StructuredContentGenerator {          if (typeof listStyleType === 'string') { style.listStyleType = listStyleType; }      } +    /** +     * @param {import('structured-content').LinkElement} content +     * @param {string} dictionary +     * @param {?string} language +     * @returns {HTMLAnchorElement} +     */      _createLinkElement(content, dictionary, language) {          let {href} = content;          const internal = href.startsWith('?'); @@ -297,7 +380,7 @@ export class StructuredContentGenerator {              href = `${location.protocol}//${location.host}/search.html${href.length > 1 ? href : ''}`;          } -        const node = this._createElement('a', 'gloss-link'); +        const node = /** @type {HTMLAnchorElement} */ (this._createElement('a', 'gloss-link'));          node.dataset.external = `${!internal}`;          const text = this._createElement('span', 'gloss-link-text'); diff --git a/ext/js/display/search-action-popup-controller.js b/ext/js/display/search-action-popup-controller.js index e8fb9f1b..733fd70a 100644 --- a/ext/js/display/search-action-popup-controller.js +++ b/ext/js/display/search-action-popup-controller.js @@ -17,10 +17,15 @@   */  export class SearchActionPopupController { +    /** +     * @param {SearchPersistentStateController} searchPersistentStateController +     */      constructor(searchPersistentStateController) { +        /** @type {SearchPersistentStateController} */          this._searchPersistentStateController = searchPersistentStateController;      } +    /** */      prepare() {          const searchParams = new URLSearchParams(location.search);          if (searchParams.get('action-popup') !== 'true') { return; } diff --git a/ext/js/display/search-display-controller.js b/ext/js/display/search-display-controller.js index e31bd239..76e7bebe 100644 --- a/ext/js/display/search-display-controller.js +++ b/ext/js/display/search-display-controller.js @@ -22,34 +22,63 @@ import {EventListenerCollection, invokeMessageHandler} from '../core.js';  import {yomitan} from '../yomitan.js';  export class SearchDisplayController { +    /** +     * @param {number|undefined} tabId +     * @param {number|undefined} frameId +     * @param {Display} display +     * @param {DisplayAudio} displayAudio +     * @param {JapaneseUtil} japaneseUtil +     * @param {SearchPersistentStateController} searchPersistentStateController +     */      constructor(tabId, frameId, display, displayAudio, japaneseUtil, searchPersistentStateController) { +        /** @type {number|undefined} */          this._tabId = tabId; +        /** @type {number|undefined} */          this._frameId = frameId; +        /** @type {Display} */          this._display = display; +        /** @type {DisplayAudio} */          this._displayAudio = displayAudio; +        /** @type {SearchPersistentStateController} */          this._searchPersistentStateController = searchPersistentStateController; -        this._searchButton = document.querySelector('#search-button'); -        this._searchBackButton = document.querySelector('#search-back-button'); -        this._queryInput = document.querySelector('#search-textbox'); -        this._introElement = document.querySelector('#intro'); -        this._clipboardMonitorEnableCheckbox = document.querySelector('#clipboard-monitor-enable'); -        this._wanakanaEnableCheckbox = document.querySelector('#wanakana-enable'); +        /** @type {HTMLButtonElement} */ +        this._searchButton = /** @type {HTMLButtonElement} */ (document.querySelector('#search-button')); +        /** @type {HTMLButtonElement} */ +        this._searchBackButton = /** @type {HTMLButtonElement} */ (document.querySelector('#search-back-button')); +        /** @type {HTMLTextAreaElement} */ +        this._queryInput = /** @type {HTMLTextAreaElement} */ (document.querySelector('#search-textbox')); +        /** @type {HTMLElement} */ +        this._introElement = /** @type {HTMLElement} */ (document.querySelector('#intro')); +        /** @type {HTMLInputElement} */ +        this._clipboardMonitorEnableCheckbox = /** @type {HTMLInputElement} */ (document.querySelector('#clipboard-monitor-enable')); +        /** @type {HTMLInputElement} */ +        this._wanakanaEnableCheckbox = /** @type {HTMLInputElement} */ (document.querySelector('#wanakana-enable')); +        /** @type {EventListenerCollection} */          this._queryInputEvents = new EventListenerCollection(); +        /** @type {boolean} */          this._queryInputEventsSetup = false; +        /** @type {boolean} */          this._wanakanaEnabled = false; +        /** @type {boolean} */          this._wanakanaBound = false; +        /** @type {boolean} */          this._introVisible = true; +        /** @type {?number} */          this._introAnimationTimer = null; +        /** @type {boolean} */          this._clipboardMonitorEnabled = false; +        /** @type {ClipboardMonitor} */          this._clipboardMonitor = new ClipboardMonitor({              japaneseUtil,              clipboardReader: {                  getText: yomitan.api.clipboardGet.bind(yomitan.api)              }          }); +        /** @type {import('core').MessageHandlerMap} */          this._messageHandlers = new Map();      } +    /** */      async prepare() {          await this._display.updateOptions(); @@ -84,15 +113,22 @@ export class SearchDisplayController {          this._clipboardMonitorEnableCheckbox.addEventListener('change', this._onClipboardMonitorEnableChange.bind(this));          this._display.hotkeyHandler.on('keydownNonHotkey', this._onKeyDown.bind(this)); -        this._onDisplayOptionsUpdated({options: this._display.getOptions()}); +        const displayOptions = this._display.getOptions(); +        if (displayOptions !== null) { +            this._onDisplayOptionsUpdated({options: displayOptions}); +        }      } +    /** +     * @param {import('display').SearchMode} mode +     */      setMode(mode) { -        this._setMode(mode, true); +        this._searchPersistentStateController.mode = mode;      }      // Actions +    /** */      _onActionFocusSearchBox() {          if (this._queryInput === null) { return; }          this._queryInput.focus(); @@ -101,22 +137,37 @@ export class SearchDisplayController {      // Messages +    /** +     * @param {{mode: import('display').SearchMode}} details +     */      _onMessageSetMode({mode}) { -        this._searchPersistentStateController.mode = mode; +        this.setMode(mode);      } +    /** +     * @returns {import('display').SearchMode} +     */      _onMessageGetMode() {          return this._searchPersistentStateController.mode;      }      // Private +    /** +     * @param {{action: string, params?: import('core').SerializableObject}} message +     * @param {chrome.runtime.MessageSender} sender +     * @param {(response?: unknown) => void} callback +     * @returns {boolean} +     */      _onMessage({action, params}, sender, callback) {          const messageHandler = this._messageHandlers.get(action);          if (typeof messageHandler === 'undefined') { return false; }          return invokeMessageHandler(messageHandler, params, callback, sender);      } +    /** +     * @param {KeyboardEvent} e +     */      _onKeyDown(e) {          const {activeElement} = document;          if ( @@ -132,6 +183,7 @@ export class SearchDisplayController {          }      } +    /** */      async _onOptionsUpdated() {          await this._display.updateOptions();          const query = this._queryInput.value; @@ -140,15 +192,21 @@ export class SearchDisplayController {          }      } +    /** +     * @param {import('display').OptionsUpdatedEvent} details +     */      _onDisplayOptionsUpdated({options}) {          this._clipboardMonitorEnabled = options.clipboard.enableSearchPageMonitor;          this._updateClipboardMonitorEnabled(); -        const enableWanakana = !!this._display.getOptions().general.enableWanakana; +        const enableWanakana = !!options.general.enableWanakana;          this._wanakanaEnableCheckbox.checked = enableWanakana;          this._setWanakanaEnabled(enableWanakana);      } +    /** +     * @param {import('display').ContentUpdateStartEvent} details +     */      _onContentUpdateStart({type, query}) {          let animate = false;          let valid = false; @@ -182,38 +240,54 @@ export class SearchDisplayController {          this._setIntroVisible(!valid, animate);      } +    /** */      _onSearchInput() {          this._updateSearchHeight(false);      } +    /** +     * @param {KeyboardEvent} e +     */      _onSearchKeydown(e) {          if (e.isComposing) { return; }          const {code} = e;          if (!((code === 'Enter' || code === 'NumpadEnter') && !e.shiftKey)) { return; }          // Search +        const element = /** @type {HTMLElement} */ (e.currentTarget);          e.preventDefault();          e.stopImmediatePropagation(); -        this._display.blurElement(e.currentTarget); +        this._display.blurElement(element);          this._search(true, 'new', true, null);      } +    /** +     * @param {MouseEvent} e +     */      _onSearch(e) {          e.preventDefault();          this._search(true, 'new', true, null);      } +    /** */      _onSearchBackButtonClick() {          this._display.history.back();      } +    /** */      _onCopy() {          // ignore copy from search page -        this._clipboardMonitor.setPreviousText(window.getSelection().toString().trim()); +        const selection = window.getSelection(); +        this._clipboardMonitor.setPreviousText(selection !== null ? selection.toString().trim() : '');      } +    /** +     * @param {{text: string, animate?: boolean}} details +     */      _onExternalSearchUpdate({text, animate=true}) { -        const {clipboard: {autoSearchContent, maximumSearchLength}} = this._display.getOptions(); +        const options = this._display.getOptions(); +        if (options === null) { return; } +        const {clipboard: {autoSearchContent, maximumSearchLength}} = options;          if (text.length > maximumSearchLength) {              text = text.substring(0, maximumSearchLength);          } @@ -222,27 +296,41 @@ export class SearchDisplayController {          this._search(animate, 'clear', autoSearchContent, ['clipboard']);      } +    /** +     * @param {Event} e +     */      _onWanakanaEnableChange(e) { -        const value = e.target.checked; +        const element = /** @type {HTMLInputElement} */ (e.target); +        const value = element.checked;          this._setWanakanaEnabled(value); -        yomitan.api.modifySettings([{ +        /** @type {import('settings-modifications').ScopedModificationSet} */ +        const modification = {              action: 'set',              path: 'general.enableWanakana',              value,              scope: 'profile',              optionsContext: this._display.getOptionsContext() -        }], 'search'); +        }; +        yomitan.api.modifySettings([modification], 'search');      } +    /** +     * @param {Event} e +     */      _onClipboardMonitorEnableChange(e) { -        const enabled = e.target.checked; +        const element = /** @type {HTMLInputElement} */ (e.target); +        const enabled = element.checked;          this._setClipboardMonitorEnabled(enabled);      } +    /** */      _onModeChange() {          this._updateClipboardMonitorEnabled();      } +    /** +     * @param {boolean} enabled +     */      _setWanakanaEnabled(enabled) {          if (this._queryInputEventsSetup && this._wanakanaEnabled === enabled) { return; } @@ -267,6 +355,10 @@ export class SearchDisplayController {          this._queryInputEventsSetup = true;      } +    /** +     * @param {boolean} visible +     * @param {boolean} animate +     */      _setIntroVisible(visible, animate) {          if (this._introVisible === visible) {              return; @@ -290,6 +382,9 @@ export class SearchDisplayController {          }      } +    /** +     * @param {boolean} animate +     */      _showIntro(animate) {          if (animate) {              const duration = 0.4; @@ -310,6 +405,9 @@ export class SearchDisplayController {          }      } +    /** +     * @param {boolean} animate +     */      _hideIntro(animate) {          if (animate) {              const duration = 0.4; @@ -323,6 +421,9 @@ export class SearchDisplayController {          this._introElement.style.height = '0';      } +    /** +     * @param {boolean} value +     */      async _setClipboardMonitorEnabled(value) {          let modify = true;          if (value) { @@ -335,15 +436,18 @@ export class SearchDisplayController {          if (!modify) { return; } -        await yomitan.api.modifySettings([{ +        /** @type {import('settings-modifications').ScopedModificationSet} */ +        const modification = {              action: 'set',              path: 'clipboard.enableSearchPageMonitor',              value,              scope: 'profile',              optionsContext: this._display.getOptionsContext() -        }], 'search'); +        }; +        await yomitan.api.modifySettings([modification], 'search');      } +    /** */      _updateClipboardMonitorEnabled() {          const enabled = this._clipboardMonitorEnabled;          this._clipboardMonitorEnableCheckbox.checked = enabled; @@ -354,6 +458,9 @@ export class SearchDisplayController {          }      } +    /** +     * @returns {boolean} +     */      _canEnableClipboardMonitor() {          switch (this._searchPersistentStateController.mode) {              case 'popup': @@ -364,6 +471,10 @@ export class SearchDisplayController {          }      } +    /** +     * @param {string[]} permissions +     * @returns {Promise<boolean>} +     */      _requestPermissions(permissions) {          return new Promise((resolve) => {              chrome.permissions.request( @@ -376,15 +487,23 @@ export class SearchDisplayController {          });      } +    /** +     * @param {boolean} animate +     * @param {import('display').HistoryMode} historyMode +     * @param {boolean} lookup +     * @param {?import('settings').OptionsContextFlag[]} flags +     */      _search(animate, historyMode, lookup, flags) {          const query = this._queryInput.value;          const depth = this._display.depth;          const url = window.location.href;          const documentTitle = document.title; +        /** @type {import('settings').OptionsContext} */          const optionsContext = {depth, url};          if (flags !== null) {              optionsContext.flags = flags;          } +        /** @type {import('display').ContentDetails} */          const details = {              focus: false,              historyMode, @@ -399,7 +518,7 @@ export class SearchDisplayController {                  documentTitle              },              content: { -                dictionaryEntries: null, +                dictionaryEntries: void 0,                  animate,                  contentOrigin: {                      tabId: this._tabId, @@ -411,6 +530,9 @@ export class SearchDisplayController {          this._display.setContent(details);      } +    /** +     * @param {boolean} shrink +     */      _updateSearchHeight(shrink) {          const node = this._queryInput;          if (shrink) { @@ -423,12 +545,19 @@ export class SearchDisplayController {          }      } +    /** +     * @param {import('core').MessageHandlerArray} handlers +     */      _registerMessageHandlers(handlers) {          for (const [name, handlerInfo] of handlers) {              this._messageHandlers.set(name, handlerInfo);          }      } +    /** +     * @param {?Element} element +     * @returns {boolean} +     */      _isElementInput(element) {          if (element === null) { return false; }          switch (element.tagName.toLowerCase()) { @@ -438,7 +567,7 @@ export class SearchDisplayController {              case 'select':                  return true;          } -        if (element.isContentEditable) { return true; } +        if (element instanceof HTMLElement && element.isContentEditable) { return true; }          return false;      }  } diff --git a/ext/js/display/search-main.js b/ext/js/display/search-main.js index 5eee08d1..c20cc135 100644 --- a/ext/js/display/search-main.js +++ b/ext/js/display/search-main.js @@ -44,7 +44,9 @@ import {SearchPersistentStateController} from './search-persistent-state-control          const {tabId, frameId} = await yomitan.api.frameInformationGet(); -        const japaneseUtil = new JapaneseUtil(wanakana); +        /** @type {import('wanakana')} */ +        const wanakanaLib = wanakana; +        const japaneseUtil = new JapaneseUtil(wanakanaLib);          const hotkeyHandler = new HotkeyHandler();          hotkeyHandler.prepare(); diff --git a/ext/js/display/search-persistent-state-controller.js b/ext/js/display/search-persistent-state-controller.js index 60155143..d92ddf68 100644 --- a/ext/js/display/search-persistent-state-controller.js +++ b/ext/js/display/search-persistent-state-controller.js @@ -18,12 +18,17 @@  import {EventDispatcher} from '../core.js'; +/** + * @augments EventDispatcher<import('display').SearchPersistentStateControllerEventType> + */  export class SearchPersistentStateController extends EventDispatcher {      constructor() {          super(); +        /** @type {import('display').SearchMode} */          this._mode = null;      } +    /** @type {import('display').SearchMode} */      get mode() {          return this._mode;      } @@ -32,12 +37,14 @@ export class SearchPersistentStateController extends EventDispatcher {          this._setMode(value, true);      } +    /** */      prepare() {          this._updateMode();      }      // Private +    /** */      _updateMode() {          let mode = null;          try { @@ -45,9 +52,13 @@ export class SearchPersistentStateController extends EventDispatcher {          } catch (e) {              // Browsers can throw a SecurityError when cookie blocking is enabled.          } -        this._setMode(mode, false); +        this._setMode(this._normalizeMode(mode), false);      } +    /** +     * @param {import('display').SearchMode} mode +     * @param {boolean} save +     */      _setMode(mode, save) {          if (mode === this._mode) { return; }          if (save) { @@ -65,4 +76,18 @@ export class SearchPersistentStateController extends EventDispatcher {          document.documentElement.dataset.searchMode = (mode !== null ? mode : '');          this.trigger('modeChange', {mode});      } + +    /** +     * @param {?string} mode +     * @returns {import('display').SearchMode} +     */ +    _normalizeMode(mode) { +        switch (mode) { +            case 'popup': +            case 'action-popup': +                return mode; +            default: +                return null; +        } +    }  } |