diff options
author | toasted-nutbread <toasted-nutbread@users.noreply.github.com> | 2020-07-26 16:51:54 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-07-26 16:51:54 -0400 |
commit | 208217198e9228699e7299f06d3701899d44d8bb (patch) | |
tree | aae11d6af70bac0c61774b8f611d9117101a288f /ext/mixed | |
parent | e153971cd4a5768a6c7dc9df36cf446cf298227d (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.js | 55 | ||||
-rw-r--r-- | ext/mixed/js/display-history.js | 178 | ||||
-rw-r--r-- | ext/mixed/js/display.js | 316 |
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; + } + } } |