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