diff options
Diffstat (limited to 'ext/mixed/js')
| -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; +        } +    }  } |