aboutsummaryrefslogtreecommitdiff
path: root/ext/mixed
diff options
context:
space:
mode:
authortoasted-nutbread <toasted-nutbread@users.noreply.github.com>2020-07-26 16:51:54 -0400
committerGitHub <noreply@github.com>2020-07-26 16:51:54 -0400
commit208217198e9228699e7299f06d3701899d44d8bb (patch)
treeaae11d6af70bac0c61774b8f611d9117101a288f /ext/mixed
parente153971cd4a5768a6c7dc9df36cf446cf298227d (diff)
Display history refactor (#691)
* Create DisplayHistory * Change arguments for _setContentTermsOrKanji * Set up history-driven content updates * Use new history only * Load definitions if missing * Refactor definitions getting * Add support for wildcards * Move definitions setup * Add events * Allow state change even if there is no history state * Update search page to use history * Fix history overwriting * Fix search page not seeing state chang events during prepare * Update state if necessary * Don't reassign query text if the same * Remove DisplayContext * Initialize with real history state * Track URL * Update DisplayHistory to support pseudo-history * Configure history settings on search page * Fix state * Use full URL * Change data format of setContent * Rename details to content * Update event arguments * Fix animation * Remove old state changes * Clear content properly * Remove set/clear content overrides * Fix setting up event listeners for content clear * Make clearContent private * Make focus opt-in * Validate source * Add unloaded type * Generalize content params * Update how extension unload content is assigned * Restore query blurring
Diffstat (limited to 'ext/mixed')
-rw-r--r--ext/mixed/js/display-context.js55
-rw-r--r--ext/mixed/js/display-history.js178
-rw-r--r--ext/mixed/js/display.js316
3 files changed, 404 insertions, 145 deletions
diff --git a/ext/mixed/js/display-context.js b/ext/mixed/js/display-context.js
deleted file mode 100644
index 2322974a..00000000
--- a/ext/mixed/js/display-context.js
+++ /dev/null
@@ -1,55 +0,0 @@
-/*
- * Copyright (C) 2019-2020 Yomichan Authors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-
-class DisplayContext {
- constructor(type, source, definitions, context) {
- this.type = type;
- this.source = source;
- this.definitions = definitions;
- this.context = context;
- }
-
- get(key) {
- return this.context[key];
- }
-
- set(key, value) {
- this.context[key] = value;
- }
-
- update(data) {
- Object.assign(this.context, data);
- }
-
- get previous() {
- return this.context.previous;
- }
-
- get next() {
- return this.context.next;
- }
-
- static push(self, type, source, definitions, context) {
- const newContext = new DisplayContext(type, source, definitions, context);
- if (self !== null) {
- newContext.update({previous: self});
- self.update({next: newContext});
- }
- return newContext;
- }
-}
diff --git a/ext/mixed/js/display-history.js b/ext/mixed/js/display-history.js
new file mode 100644
index 00000000..cf2db8d5
--- /dev/null
+++ b/ext/mixed/js/display-history.js
@@ -0,0 +1,178 @@
+/*
+ * Copyright (C) 2020 Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+class DisplayHistory extends EventDispatcher {
+ constructor({clearable=true, useBrowserHistory=false}) {
+ super();
+ this._clearable = clearable;
+ this._useBrowserHistory = useBrowserHistory;
+ this._historyMap = new Map();
+
+ const historyState = history.state;
+ const {id, state} = isObject(historyState) ? historyState : {id: null, state: null};
+ this._current = this._createHistoryEntry(id, location.href, state, null, null);
+ }
+
+ get state() {
+ return this._current.state;
+ }
+
+ get content() {
+ return this._current.content;
+ }
+
+ get useBrowserHistory() {
+ return this._useBrowserHistory;
+ }
+
+ set useBrowserHistory(value) {
+ this._useBrowserHistory = value;
+ }
+
+ prepare() {
+ window.addEventListener('popstate', this._onPopState.bind(this), false);
+ }
+
+ hasNext() {
+ return this._current.next !== null;
+ }
+
+ hasPrevious() {
+ return this._current.previous !== null;
+ }
+
+ clear() {
+ if (!this._clearable) { return; }
+ this._clear();
+ }
+
+ back() {
+ return this._go(false);
+ }
+
+ forward() {
+ return this._go(true);
+ }
+
+ pushState(state, content, url) {
+ if (typeof url === 'undefined') { url = location.href; }
+
+ const entry = this._createHistoryEntry(null, url, state, content, this._current);
+ this._current.next = entry;
+ this._current = entry;
+ this._updateHistoryFromCurrent(!this._useBrowserHistory);
+ }
+
+ replaceState(state, content, url) {
+ if (typeof url === 'undefined') { url = location.href; }
+
+ this._current.url = url;
+ this._current.state = state;
+ this._current.content = content;
+ this._updateHistoryFromCurrent(true);
+ }
+
+ _onPopState() {
+ this._updateStateFromHistory();
+ this._triggerStateChanged(false);
+ }
+
+ _go(forward) {
+ const target = forward ? this._current.next : this._current.previous;
+ if (target === null) {
+ return false;
+ }
+
+ if (this._useBrowserHistory) {
+ if (forward) {
+ history.forward();
+ } else {
+ history.back();
+ }
+ } else {
+ this._current = target;
+ this._updateHistoryFromCurrent(true);
+ }
+
+ return true;
+ }
+
+ _triggerStateChanged(synthetic) {
+ this.trigger('stateChanged', {history: this, synthetic});
+ }
+
+ _updateHistoryFromCurrent(replace) {
+ const {id, state, url} = this._current;
+ if (replace) {
+ history.replaceState({id, state}, '', url);
+ } else {
+ history.pushState({id, state}, '', url);
+ }
+ this._triggerStateChanged(true);
+ }
+
+ _updateStateFromHistory() {
+ let state = history.state;
+ let id = null;
+ if (isObject(state)) {
+ id = state.id;
+ if (typeof id === 'string') {
+ const entry = this._historyMap.get(id);
+ if (typeof entry !== 'undefined') {
+ // Valid
+ this._current = entry;
+ return;
+ }
+ }
+ // Partial state recovery
+ state = state.state;
+ } else {
+ state = null;
+ }
+
+ // Fallback
+ this._current.id = (typeof id === 'string' ? id : this._generateId());
+ this._current.state = state;
+ this._current.content = null;
+ this._clear();
+ }
+
+ _createHistoryEntry(id, url, state, content, previous) {
+ if (typeof id !== 'string') { id = this._generateId(); }
+ const entry = {
+ id,
+ url,
+ next: null,
+ previous,
+ state,
+ content
+ };
+ this._historyMap.set(id, entry);
+ return entry;
+ }
+
+ _generateId() {
+ return yomichan.generateId(16);
+ }
+
+ _clear() {
+ this._historyMap.clear();
+ this._historyMap.set(this._current.id, this._current);
+ this._current.next = null;
+ this._current.previous = null;
+ }
+}
diff --git a/ext/mixed/js/display.js b/ext/mixed/js/display.js
index b2d7d54d..e78b9765 100644
--- a/ext/mixed/js/display.js
+++ b/ext/mixed/js/display.js
@@ -18,8 +18,8 @@
/* global
* AudioSystem
* DOM
- * DisplayContext
* DisplayGenerator
+ * DisplayHistory
* Frontend
* MediaLoader
* PopupFactory
@@ -30,14 +30,14 @@
* dynamicLoader
*/
-class Display {
+class Display extends EventDispatcher {
constructor(spinner, container) {
+ super();
this._spinner = spinner;
this._container = container;
this._definitions = [];
this._optionsContext = {depth: 0, url: window.location.href};
this._options = null;
- this._context = null;
this._index = 0;
this._audioPlaying = null;
this._audioFallback = null;
@@ -64,6 +64,9 @@ class Display {
this._hotkeys = new Map();
this._actions = new Map();
this._messageHandlers = new Map();
+ this._history = new DisplayHistory({clearable: true, useBrowserHistory: false});
+ this._historyChangeIgnore = false;
+ this._historyHasChanged = false;
this.registerActions([
['close', () => { this.onEscape(); }],
@@ -116,12 +119,27 @@ class Display {
async prepare() {
this._setInteractive(true);
await this._displayGenerator.prepare();
+ this._history.prepare();
+ this._history.on('stateChanged', this._onStateChanged.bind(this));
yomichan.on('extensionUnloaded', this._onExtensionUnloaded.bind(this));
api.crossFrame.registerHandlers([
['popupMessage', {async: 'dynamic', handler: this._onMessage.bind(this)}]
]);
}
+ initializeState() {
+ this._onStateChanged();
+ }
+
+ setHistorySettings({clearable, useBrowserHistory}) {
+ if (typeof clearable !== 'undefined') {
+ this._history.clearable = clearable;
+ }
+ if (typeof useBrowserHistory !== 'undefined') {
+ this._history.useBrowserHistory = useBrowserHistory;
+ }
+ }
+
onError(error) {
if (yomichan.isExtensionUnloaded) { return; }
yomichan.logError(error);
@@ -202,46 +220,25 @@ class Display {
}
}
- async setContent(details) {
- const token = {}; // Unique identifier token
- this._setContentToken = token;
- try {
- this._mediaLoader.unloadAll();
-
- const {focus, history, type, source, definitions, context} = details;
-
- if (!history) {
- this._context = new DisplayContext(type, source, definitions, context);
- } else {
- this._context = DisplayContext.push(this._context, type, source, definitions, context);
- }
+ setContent(details) {
+ const {focus, history, params, state, content} = details;
- if (focus !== false) {
- window.focus();
- }
+ if (focus) {
+ window.focus();
+ }
- switch (type) {
- case 'terms':
- case 'kanji':
- {
- const {sentence, url, index=0, scroll=null} = context;
- await this._setContentTermsOrKanji((type === 'terms'), definitions, sentence, url, index, scroll, token);
- }
- break;
- }
- } catch (e) {
- this.onError(e);
- } finally {
- if (this._setContentToken === token) {
- this._setContentToken = null;
- }
+ const urlSearchParams = new URLSearchParams();
+ for (const [key, value] of Object.entries(params)) {
+ urlSearchParams.append(key, value);
}
- }
+ const url = `${location.protocol}//${location.host}${location.pathname}?${urlSearchParams.toString()}`;
- clearContent() {
- this._setEventListenersActive(false);
- this._container.textContent = '';
- this._setEventListenersActive(true);
+ if (history && this._historyHasChanged) {
+ this._history.pushState(state, content, url);
+ } else {
+ this._history.clear();
+ this._history.replaceState(state, content, url);
+ }
}
setCustomCss(css) {
@@ -348,8 +345,95 @@ class Display {
// Private
+ async _onStateChanged() {
+ if (this._historyChangeIgnore) { return; }
+
+ const token = {}; // Unique identifier token
+ this._setContentToken = token;
+ try {
+ const urlSearchParams = new URLSearchParams(location.search);
+ let type = urlSearchParams.get('type');
+ if (type === null) { type = 'terms'; }
+
+ let asigned = false;
+ const eventArgs = {type, urlSearchParams, token};
+ this._historyHasChanged = true;
+ this._mediaLoader.unloadAll();
+ switch (type) {
+ case 'terms':
+ case 'kanji':
+ {
+ const source = urlSearchParams.get('query');
+ if (!source) { break; }
+
+ const isTerms = (type === 'terms');
+ let {state, content} = this._history;
+ let changeHistory = false;
+ if (!isObject(content)) {
+ content = {};
+ changeHistory = true;
+ }
+ if (!isObject(state)) {
+ state = {};
+ changeHistory = true;
+ }
+
+ let {definitions} = content;
+ if (!Array.isArray(definitions)) {
+ definitions = await this._findDefinitions(isTerms, source, urlSearchParams);
+ if (this._setContentToken !== token) { return; }
+ content.definitions = definitions;
+ changeHistory = true;
+ }
+
+ if (changeHistory) {
+ this._historyStateUpdate(state, content);
+ }
+
+ asigned = true;
+ eventArgs.source = source;
+ eventArgs.content = content;
+ this.trigger('contentUpdating', eventArgs);
+ await this._setContentTermsOrKanji(token, isTerms, definitions, state);
+ }
+ break;
+ case 'unloaded':
+ {
+ const {content} = this._history;
+ eventArgs.content = content;
+ this.trigger('contentUpdating', eventArgs);
+ this._setContentExtensionUnloaded();
+ }
+ break;
+ }
+
+ if (!asigned) {
+ const {content} = this._history;
+ eventArgs.type = 'clear';
+ eventArgs.content = content;
+ this.trigger('contentUpdating', eventArgs);
+ this._clearContent();
+ }
+
+ eventArgs.stale = (this._setContentToken !== token);
+ this.trigger('contentUpdated', eventArgs);
+ } catch (e) {
+ this.onError(e);
+ } finally {
+ if (this._setContentToken === token) {
+ this._setContentToken = null;
+ }
+ }
+ }
+
_onExtensionUnloaded() {
- this._setContentExtensionUnloaded();
+ this.setContent({
+ focus: false,
+ history: false,
+ params: {type: 'unloaded'},
+ state: {},
+ content: {}
+ });
}
_onSourceTermView(e) {
@@ -365,27 +449,32 @@ class Display {
async _onKanjiLookup(e) {
try {
e.preventDefault();
- if (!this._context) { return; }
+ if (!this._historyHasState()) { return; }
const link = e.target;
- this._context.update({
- index: this._entryIndexFind(link),
- scroll: this._windowScroll.y
- });
- const context = {
- sentence: this._context.get('sentence'),
- url: this._context.get('url')
- };
+ const {state} = this._history;
+
+ state.index = this._entryIndexFind(link);
+ state.scroll = this._windowScroll.y;
+ this._historyStateUpdate(state);
- const source = link.textContent;
- const definitions = await api.kanjiFind(source, this.getOptionsContext());
+ const query = link.textContent;
+ const definitions = await api.kanjiFind(query, this.getOptionsContext());
this.setContent({
focus: false,
history: true,
- type: 'kanji',
- source,
- definitions,
- context
+ params: {
+ type: 'kanji',
+ query,
+ wildcards: 'off'
+ },
+ state: {
+ sentence: state.sentence,
+ url: state.url
+ },
+ content: {
+ definitions
+ }
});
} catch (error) {
this.onError(error);
@@ -410,10 +499,12 @@ class Display {
async _onTermLookup(e) {
try {
- if (!this._context) { return; }
+ if (!this._historyHasState()) { return; }
const termLookupResults = await this._termLookup(e);
- if (!termLookupResults) { return; }
+ if (!termLookupResults || !this._historyHasState()) { return; }
+
+ const {state} = this._history;
const {textSource, definitions} = termLookupResults;
const scannedElement = e.target;
@@ -421,22 +512,25 @@ class Display {
const layoutAwareScan = this._options.scanning.layoutAwareScan;
const sentence = docSentenceExtract(textSource, sentenceExtent, layoutAwareScan);
- this._context.update({
- index: this._entryIndexFind(scannedElement),
- scroll: this._windowScroll.y
- });
- const context = {
- sentence,
- url: this._context.get('url')
- };
+ state.index = this._entryIndexFind(scannedElement);
+ state.scroll = this._windowScroll.y;
+ this._historyStateUpdate(state);
this.setContent({
focus: false,
history: true,
- type: 'terms',
- source: textSource.text(),
- definitions,
- context
+ params: {
+ type: 'terms',
+ query: textSource.text(),
+ wildcards: 'off'
+ },
+ state: {
+ sentence,
+ url: state.url
+ },
+ content: {
+ definitions
+ }
});
} catch (error) {
this.onError(error);
@@ -583,7 +677,7 @@ class Display {
this.addMultipleEventListeners('.action-view-note', 'click', this._onNoteView.bind(this));
this.addMultipleEventListeners('.action-play-audio', 'click', this._onAudioPlay.bind(this));
this.addMultipleEventListeners('.kanji-link', 'click', this._onKanjiLookup.bind(this));
- if (this._options.scanning.enablePopupSearch) {
+ if (this._options !== null && this._options.scanning.enablePopupSearch) {
this.addMultipleEventListeners('.term-glossary-item, .tag', 'mouseup', this._onGlossaryMouseUp.bind(this));
this.addMultipleEventListeners('.term-glossary-item, .tag', 'mousedown', this._onGlossaryMouseDown.bind(this));
this.addMultipleEventListeners('.term-glossary-item, .tag', 'mousemove', this._onGlossaryMouseMove.bind(this));
@@ -593,7 +687,34 @@ class Display {
}
}
- async _setContentTermsOrKanji(isTerms, definitions, sentence, url, index, scroll, token) {
+ async _findDefinitions(isTerms, source, urlSearchParams) {
+ const optionsContext = this.getOptionsContext();
+ if (isTerms) {
+ const findDetails = {};
+ if (urlSearchParams.get('wildcards') !== 'off') {
+ const match = /^([*\uff0a]*)([\w\W]*?)([*\uff0a]*)$/.exec(source);
+ if (match !== null) {
+ if (match[1]) {
+ findDetails.wildcard = 'prefix';
+ } else if (match[3]) {
+ findDetails.wildcard = 'suffix';
+ }
+ source = match[2];
+ }
+ }
+
+ const {definitions} = await api.termsFind(source, findDetails, optionsContext);
+ return definitions;
+ } else {
+ const definitions = await api.kanjiFind(source, optionsContext);
+ return definitions;
+ }
+ }
+
+ async _setContentTermsOrKanji(token, isTerms, definitions, {sentence=null, url=null, index=0, scroll=null}) {
+ if (typeof url !== 'string') { url = window.location.href; }
+ sentence = this._getValidSentenceData(sentence);
+
this._setEventListenersActive(false);
this._definitions = definitions;
@@ -603,7 +724,7 @@ class Display {
definition.url = url;
}
- this._updateNavigation(this._context.previous, this._context.next);
+ this._updateNavigation(this._history.hasPrevious(), this._history.hasNext());
this._setNoContentVisible(definitions.length === 0);
const container = this._container;
@@ -657,6 +778,11 @@ class Display {
this._setNoContentVisible(false);
}
+ _clearContent() {
+ this._setEventListenersActive(false);
+ this._container.textContent = '';
+ }
+
_setNoContentVisible(visible) {
const noResults = document.querySelector('#no-results');
@@ -746,24 +872,11 @@ class Display {
}
_relativeTermView(next) {
- if (this._context === null) { return false; }
-
- const relative = next ? this._context.next : this._context.previous;
- if (!relative) { return false; }
-
- this._context.update({
- index: this._index,
- scroll: this._windowScroll.y
- });
- this.setContent({
- focus: false,
- history: false,
- type: relative.type,
- source: relative.source,
- definitions: relative.definitions,
- context: relative.context
- });
- return true;
+ if (next) {
+ return this._history.hasNext() && this._history.forward();
+ } else {
+ return this._history.hasPrevious() && this._history.back();
+ }
}
_noteTryAdd(mode) {
@@ -913,6 +1026,13 @@ class Display {
return index >= 0 && index < entries.length ? entries[index] : null;
}
+ _getValidSentenceData(sentence) {
+ let {text, offset} = (isObject(sentence) ? sentence : {});
+ if (typeof text !== 'string') { text = ''; }
+ if (typeof offset !== 'number') { offset = 0; }
+ return {text, offset};
+ }
+
_clozeBuild({text, offset}, source) {
return {
sentence: text.trim(),
@@ -1000,4 +1120,20 @@ class Display {
this._audioPlay(this._definitions[index], this._getFirstExpressionIndex(), index);
}
}
+
+ _historyHasState() {
+ return isObject(this._history.state);
+ }
+
+ _historyStateUpdate(state, content) {
+ const historyChangeIgnorePre = this._historyChangeIgnore;
+ try {
+ this._historyChangeIgnore = true;
+ if (typeof state === 'undefined') { state = this._history.state; }
+ if (typeof content === 'undefined') { content = this._history.content; }
+ this._history.replaceState(state, content);
+ } finally {
+ this._historyChangeIgnore = historyChangeIgnorePre;
+ }
+ }
}