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 | 103 | ||||
-rw-r--r-- | ext/js/display/display-generator.js | 430 | ||||
-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 | 41 | ||||
-rw-r--r-- | ext/js/display/option-toggle-hotkey-handler.js | 64 | ||||
-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-persistent-state-controller.js | 27 |
17 files changed, 2192 insertions, 472 deletions
diff --git a/ext/js/display/display-anki.js b/ext/js/display/display-anki.js index 2f94e414..d0da568c 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 {import('./display.js').Display} display + * @param {import('./display-audio.js').DisplayAudio} displayAudio + * @param {import('../language/sandbox/japanese-util.js').JapaneseUtil} japaneseUtil + */ constructor(display, displayAudio, japaneseUtil) { + /** @type {import('./display.js').Display} */ this._display = display; + /** @type {import('./display-audio.js').DisplayAudio} */ this._displayAudio = displayAudio; + /** @type {?string} */ this._ankiFieldTemplates = null; + /** @type {?string} */ this._ankiFieldTemplatesDefault = null; + /** @type {AnkiNoteBuilder} */ this._ankiNoteBuilder = new AnkiNoteBuilder({japaneseUtil}); + /** @type {?import('./display-notification.js').DisplayNotification} */ this._errorNotification = null; + /** @type {?EventListenerCollection} */ this._errorNotificationEventListeners = null; + /** @type {?import('./display-notification.js').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..3576decb 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 {import('./display.js').Display} display + */ constructor(display) { + /** @type {import('./display.js').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 {?import('core').Timeout} */ 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..c78ef7e8 100644 --- a/ext/js/display/display-content-manager.js +++ b/ext/js/display/display-content-manager.js @@ -21,30 +21,23 @@ 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 { /** * Creates a new instance of the class. - * @param {Display} display The display instance that owns this object. + * @param {import('./display.js').Display} display The display instance that owns this object. */ constructor(display) { + /** @type {import('./display.js').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..eb464001 100644 --- a/ext/js/display/display-generator.js +++ b/ext/js/display/display-generator.js @@ -17,6 +17,7 @@ */ import {isObject} from '../core.js'; +import {ExtensionError} from '../core/extension-error.js'; import {HtmlTemplateCollection} from '../dom/html-template-collection.js'; import {DictionaryDataUtil} from '../language/sandbox/dictionary-data-util.js'; import {yomitan} from '../yomitan.js'; @@ -24,21 +25,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 {import('../language/sandbox/japanese-util.js').JapaneseUtil} */ this._japaneseUtil = japaneseUtil; + /** @type {import('./display-content-manager.js').DisplayContentManager} */ this._contentManager = contentManager; + /** @type {?import('../input/hotkey-help-controller.js').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 +59,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 +79,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 +126,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 +148,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 +185,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 +222,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 +243,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 +262,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 +284,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 +329,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 +348,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 +395,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 +415,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 +450,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 +484,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 +522,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 +564,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 +579,11 @@ export class DisplayGenerator { return node; } + /** + * @param {string} name + * @param {string} category + * @returns {import('dictionary').Tag} + */ _createTagData(name, category) { return { name, @@ -470,20 +596,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 +628,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 +696,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 +730,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 +760,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 +781,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 +819,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 +844,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 +873,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 +896,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 +919,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 +947,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 +970,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..d1cfa537 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 {?import('core').Timeout} */ 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..c5cb7d06 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 {import('./display.js').Display} display + */ constructor(display) { + /** @type {import('./display.js').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..8ce7e91a 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 {import('./display.js').Display} display + */ constructor(display) { + /** @type {import('./display.js').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..6e1450c3 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 {import('../language/sandbox/japanese-util.js').JapaneseUtil} japaneseUtil + * @param {import('../dom/document-focus-controller.js').DocumentFocusController} documentFocusController + * @param {import('../input/hotkey-handler.js').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 {import('../language/sandbox/japanese-util.js').JapaneseUtil} */ this._japaneseUtil = japaneseUtil; + /** @type {import('../dom/document-focus-controller.js').DocumentFocusController} */ this._documentFocusController = documentFocusController; + /** @type {import('../input/hotkey-handler.js').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 {?import('core').Timeout} */ 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 {?import('./display-notification.js').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 {import('../language/sandbox/japanese-util.js').JapaneseUtil} */ get japaneseUtil() { return this._japaneseUtil; } + /** @type {number} */ get depth() { return this._depth; } + /** @type {import('../input/hotkey-handler.js').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..35277fa5 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|import('core').Timeout)} */ 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|import('core').Timeout} + */ _requestIdleCallback(callback, timeout) { if (typeof requestIdleCallback === 'function') { return requestIdleCallback(callback, {timeout}); @@ -132,9 +164,12 @@ export class ElementOverflowController { } } + /** + * @param {number|import('core').Timeout} handle + */ _cancelIdleCallback(handle) { if (typeof cancelIdleCallback === 'function') { - cancelIdleCallback(handle); + cancelIdleCallback(/** @type {number} */ (handle)); } else { clearTimeout(handle); } diff --git a/ext/js/display/option-toggle-hotkey-handler.js b/ext/js/display/option-toggle-hotkey-handler.js index 1f8de939..edd7de5b 100644 --- a/ext/js/display/option-toggle-hotkey-handler.js +++ b/ext/js/display/option-toggle-hotkey-handler.js @@ -16,17 +16,28 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import {deserializeError} from '../core.js'; +import {generateId} from '../core.js'; +import {ExtensionError} from '../core/extension-error.js'; import {yomitan} from '../yomitan.js'; export class OptionToggleHotkeyHandler { + /** + * @param {import('./display.js').Display} display + */ constructor(display) { + /** @type {import('./display.js').Display} */ this._display = display; + /** @type {?import('./display-notification.js').DisplayNotification} */ this._notification = null; + /** @type {?import('core').Timeout} */ 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 +46,7 @@ export class OptionToggleHotkeyHandler { this._notificationHideTimeout = value; } + /** */ prepare() { this._display.hotkeyHandler.registerActions([ ['toggleOption', this._onHotkeyActionToggleOption.bind(this)] @@ -43,10 +55,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 +78,7 @@ export class OptionToggleHotkeyHandler { }]))[0]; const {error} = result; if (typeof error !== 'undefined') { - throw deserializeError(error); + throw ExtensionError.deserialize(error); } value = result.result; @@ -69,16 +88,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 +108,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 +126,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 +146,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 +165,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 +182,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..03b54fd5 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 {import('../language/sandbox/japanese-util.js').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..d113485f 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 {import('../../language/sandbox/japanese-util.js').JapaneseUtil} japaneseUtil + */ constructor(japaneseUtil) { + /** @type {import('../../language/sandbox/japanese-util.js').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..5a91b01c 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 {import('../../display/display-content-manager.js').DisplayContentManager|import('../../templates/sandbox/anki-template-renderer-content-manager.js').AnkiTemplateRendererContentManager} contentManager + * @param {import('../../language/sandbox/japanese-util.js').JapaneseUtil} japaneseUtil + * @param {Document} document + */ constructor(contentManager, japaneseUtil, document) { + /** @type {import('../../display/display-content-manager.js').DisplayContentManager|import('../../templates/sandbox/anki-template-renderer-content-manager.js').AnkiTemplateRendererContentManager} */ this._contentManager = contentManager; + /** @type {import('../../language/sandbox/japanese-util.js').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..3a2057a1 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 {import('./search-persistent-state-controller.js').SearchPersistentStateController} searchPersistentStateController + */ constructor(searchPersistentStateController) { + /** @type {import('./search-persistent-state-controller.js').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..2778c4cd 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 {import('./display.js').Display} display + * @param {import('./display-audio.js').DisplayAudio} displayAudio + * @param {import('../language/sandbox/japanese-util.js').JapaneseUtil} japaneseUtil + * @param {import('./search-persistent-state-controller.js').SearchPersistentStateController} searchPersistentStateController + */ constructor(tabId, frameId, display, displayAudio, japaneseUtil, searchPersistentStateController) { + /** @type {number|undefined} */ this._tabId = tabId; + /** @type {number|undefined} */ this._frameId = frameId; + /** @type {import('./display.js').Display} */ this._display = display; + /** @type {import('./display-audio.js').DisplayAudio} */ this._displayAudio = displayAudio; + /** @type {import('./search-persistent-state-controller.js').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 {?import('core').Timeout} */ 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-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; + } + } } |