diff --git a/ext/bg/js/search.js b/ext/bg/js/search.js index c29abbaa..c5051fa9 100644 --- a/ext/bg/js/search.js +++ b/ext/bg/js/search.js @@ -33,6 +33,7 @@ class DisplaySearch extends Display { this._intro = document.querySelector('#intro'); this._clipboardMonitorEnable = document.querySelector('#clipboard-monitor-enable'); this._wanakanaEnable = document.querySelector('#wanakana-enable'); + this._queryText = ''; this._introVisible = true; this._introAnimationTimer = null; this._clipboardMonitor = new ClipboardMonitor({ @@ -68,6 +69,9 @@ class DisplaySearch extends Display { await this._queryParser.prepare(); this._queryParser.on('searched', this._onQueryParserSearch.bind(this)); + this.on('contentUpdating', this._onContentUpdating.bind(this)); + + this.setHistorySettings({useBrowserHistory: true}); const options = this.getOptions(); @@ -83,7 +87,6 @@ class DisplaySearch extends Display { } this._setQuery(query); - this._onSearchQueryUpdated(this._query.value, false); if (mode !== 'popup') { if (options.general.enableClipboardMonitor === true) { @@ -100,7 +103,6 @@ class DisplaySearch extends Display { this._search.addEventListener('click', this._onSearch.bind(this), false); this._query.addEventListener('input', this._onSearchInput.bind(this), false); this._wanakanaEnable.addEventListener('change', this._onWanakanaEnableChange.bind(this)); - window.addEventListener('popstate', this._onPopState.bind(this)); window.addEventListener('copy', this._onCopy.bind(this)); this._clipboardMonitor.on('change', this._onExternalSearchUpdate.bind(this)); @@ -108,6 +110,8 @@ class DisplaySearch extends Display { await this._prepareNestedPopups(); + this.initializeState(); + this._isPrepared = true; } @@ -158,29 +162,47 @@ class DisplaySearch extends Display { } } - async setContent(...args) { - this._query.blur(); - this._closePopups(); - return await super.setContent(...args); - } - - clearContent() { - this._closePopups(); - return super.clearContent(); - } - // Private + _onContentUpdating({type, source, content}) { + let animate = false; + let valid = false; + switch (type) { + case 'terms': + case 'kanji': + animate = content.animate; + valid = content.definitions.length > 0; + this._query.blur(); + break; + case 'clear': + valid = false; + animate = true; + source = ''; + break; + } + if (typeof source !== 'string') { source = ''; } + this._closePopups(); + this._setQuery(source); + this._setIntroVisible(!valid, animate); + this._setTitleText(source); + this._updateSearchButton(); + } + _onQueryParserSearch({type, definitions, sentence, cause, textSource}) { this.setContent({ focus: false, history: cause !== 'mouse', - type, - source: textSource.text(), - definitions, - context: { + params: { + type, + query: textSource.text(), + wildcards: 'off' + }, + state: { sentence, url: window.location.href + }, + content: { + definitions } }); } @@ -202,22 +224,9 @@ class DisplaySearch extends Display { e.preventDefault(); const query = this._query.value; - - this._queryParser.setText(query); - - const url = new URL(window.location.href); - url.searchParams.set('query', query); - window.history.pushState(null, '', url.toString()); - this._onSearchQueryUpdated(query, true); } - _onPopState() { - const {queryParams: {query=''}} = parseUrl(window.location.href); - this._setQuery(query); - this._onSearchQueryUpdated(this._query.value, false); - } - _onRuntimeMessage({action, params}, sender, callback) { const messageHandler = this._runtimeMessageHandlers.get(action); if (typeof messageHandler === 'undefined') { return false; } @@ -230,49 +239,25 @@ class DisplaySearch extends Display { } _onExternalSearchUpdate({text, animate=true}) { - this._setQuery(text); - const url = new URL(window.location.href); - url.searchParams.set('query', text); - window.history.pushState(null, '', url.toString()); - this._onSearchQueryUpdated(this._query.value, animate); + this._onSearchQueryUpdated(text, animate); } - async _onSearchQueryUpdated(query, animate) { - try { - const details = {}; - const match = /^([*\uff0a]*)([\w\W]*?)([*\uff0a]*)$/.exec(query); - if (match !== null) { - if (match[1]) { - details.wildcard = 'prefix'; - } else if (match[3]) { - details.wildcard = 'suffix'; - } - query = match[2]; + _onSearchQueryUpdated(query, animate) { + this.setContent({ + focus: false, + history: false, + params: { + query + }, + state: { + sentence: {text: query, offset: 0}, + url: window.location.href + }, + content: { + definitions: null, + animate } - - const valid = (query.length > 0); - this._setIntroVisible(!valid, animate); - this._updateSearchButton(); - if (valid) { - const {definitions} = await api.termsFind(query, details, this.getOptionsContext()); - this.setContent({ - focus: false, - history: false, - definitions, - source: query, - type: 'terms', - context: { - sentence: {text: query, offset: 0}, - url: window.location.href - } - }); - } else { - this.clearContent(); - } - this._setTitleText(query); - } catch (e) { - this.onError(e); - } + }); } _onWanakanaEnableChange(e) { @@ -335,6 +320,8 @@ class DisplaySearch extends Display { // NOP } } + if (this._queryText === interpretedQuery) { return; } + this._queryText = interpretedQuery; this._query.value = interpretedQuery; this._queryParser.setText(interpretedQuery); } diff --git a/ext/bg/search.html b/ext/bg/search.html index 8f7c1d4c..dd44b376 100644 --- a/ext/bg/search.html +++ b/ext/bg/search.html @@ -83,9 +83,9 @@ - + diff --git a/ext/fg/float.html b/ext/fg/float.html index 9e0e9ff4..f5a85f8e 100644 --- a/ext/fg/float.html +++ b/ext/fg/float.html @@ -50,9 +50,9 @@ - + diff --git a/ext/fg/js/float.js b/ext/fg/js/float.js index f23a9b93..513ee178 100644 --- a/ext/fg/js/float.js +++ b/ext/fg/js/float.js @@ -50,6 +50,8 @@ class DisplayFloat extends Display { ]); window.addEventListener('message', this._onWindowMessage.bind(this), false); + this.initializeState(); + this._frameEndpoint.signal(); } diff --git a/ext/fg/js/frontend.js b/ext/fg/js/frontend.js index 73cea841..09928cd4 100644 --- a/ext/fg/js/frontend.js +++ b/ext/fg/js/frontend.js @@ -429,12 +429,17 @@ class Frontend { { focus, history: false, - type, - source: textSource.text(), - definitions, - context: { + params: { + type, + query: textSource.text(), + wildcards: 'off' + }, + state: { sentence, url + }, + content: { + definitions } } ); 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 . - */ - - -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 . + */ + +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(); + setContent(details) { + const {focus, history, params, state, content} = details; - 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); - } - - if (focus !== false) { - 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; - } + if (focus) { + window.focus(); } - } - clearContent() { - this._setEventListenersActive(false); - this._container.textContent = ''; - this._setEventListenersActive(true); + 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()}`; + + 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; - const source = link.textContent; - const definitions = await api.kanjiFind(source, this.getOptionsContext()); + state.index = this._entryIndexFind(link); + state.scroll = this._windowScroll.y; + this._historyStateUpdate(state); + + 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; + } + } }