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
This commit is contained in:
toasted-nutbread 2020-07-26 16:51:54 -04:00 committed by GitHub
parent e153971cd4
commit 208217198e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 475 additions and 222 deletions

View File

@ -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',
params: {
type,
source: textSource.text(),
definitions,
context: {
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];
}
const valid = (query.length > 0);
this._setIntroVisible(!valid, animate);
this._updateSearchButton();
if (valid) {
const {definitions} = await api.termsFind(query, details, this.getOptionsContext());
_onSearchQueryUpdated(query, animate) {
this.setContent({
focus: false,
history: false,
definitions,
source: query,
type: 'terms',
context: {
params: {
query
},
state: {
sentence: {text: query, offset: 0},
url: window.location.href
},
content: {
definitions: null,
animate
}
});
} 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);
}

View File

@ -83,9 +83,9 @@
<script src="/fg/js/dom-text-scanner.js"></script>
<script src="/fg/js/source.js"></script>
<script src="/mixed/js/audio-system.js"></script>
<script src="/mixed/js/display-context.js"></script>
<script src="/mixed/js/display.js"></script>
<script src="/mixed/js/display-generator.js"></script>
<script src="/mixed/js/display-history.js"></script>
<script src="/mixed/js/dynamic-loader.js"></script>
<script src="/mixed/js/media-loader.js"></script>
<script src="/mixed/js/scroll.js"></script>

View File

@ -50,9 +50,9 @@
<script src="/fg/js/dom-text-scanner.js"></script>
<script src="/fg/js/source.js"></script>
<script src="/mixed/js/audio-system.js"></script>
<script src="/mixed/js/display-context.js"></script>
<script src="/mixed/js/display.js"></script>
<script src="/mixed/js/display-generator.js"></script>
<script src="/mixed/js/display-history.js"></script>
<script src="/mixed/js/dynamic-loader.js"></script>
<script src="/mixed/js/frame-endpoint.js"></script>
<script src="/mixed/js/media-loader.js"></script>

View File

@ -50,6 +50,8 @@ class DisplayFloat extends Display {
]);
window.addEventListener('message', this._onWindowMessage.bind(this), false);
this.initializeState();
this._frameEndpoint.signal();
}

View File

@ -429,12 +429,17 @@ class Frontend {
{
focus,
history: false,
params: {
type,
source: textSource.text(),
definitions,
context: {
query: textSource.text(),
wildcards: 'off'
},
state: {
sentence,
url
},
content: {
definitions
}
}
);

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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) {
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,9 +345,96 @@ class Display {
// Private
_onExtensionUnloaded() {
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.setContent({
focus: false,
history: false,
params: {type: 'unloaded'},
state: {},
content: {}
});
}
_onSourceTermView(e) {
e.preventDefault();
@ -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,
params: {
type: 'kanji',
source,
definitions,
context
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,
params: {
type: 'terms',
source: textSource.text(),
definitions,
context
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;
}
}
}