diff options
Diffstat (limited to 'ext/js/display')
-rw-r--r-- | ext/js/display/query-parser.js | 232 | ||||
-rw-r--r-- | ext/js/display/search-display-controller.js | 422 | ||||
-rw-r--r-- | ext/js/display/search-main.js | 57 |
3 files changed, 711 insertions, 0 deletions
diff --git a/ext/js/display/query-parser.js b/ext/js/display/query-parser.js new file mode 100644 index 00000000..05ebfa27 --- /dev/null +++ b/ext/js/display/query-parser.js @@ -0,0 +1,232 @@ +/* + * Copyright (C) 2019-2021 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/>. + */ + +/* global + * TextScanner + * api + */ + +class QueryParser extends EventDispatcher { + constructor({getSearchContext, documentUtil}) { + super(); + this._getSearchContext = getSearchContext; + this._documentUtil = documentUtil; + this._text = ''; + this._setTextToken = null; + this._selectedParser = null; + this._parseResults = []; + this._queryParser = document.querySelector('#query-parser-content'); + this._queryParserModeContainer = document.querySelector('#query-parser-mode-container'); + this._queryParserModeSelect = document.querySelector('#query-parser-mode-select'); + this._textScanner = new TextScanner({ + node: this._queryParser, + getSearchContext, + documentUtil, + searchTerms: true, + searchKanji: false, + searchOnClick: true + }); + } + + get text() { + return this._text; + } + + prepare() { + this._textScanner.prepare(); + this._textScanner.on('searched', this._onSearched.bind(this)); + this._queryParserModeSelect.addEventListener('change', this._onParserChange.bind(this), false); + } + + setOptions({selectedParser, termSpacing, scanning}) { + let selectedParserChanged = false; + if (selectedParser === null || typeof selectedParser === 'string') { + selectedParserChanged = (this._selectedParser !== selectedParser); + this._selectedParser = selectedParser; + } + if (typeof termSpacing === 'boolean') { + this._queryParser.dataset.termSpacing = `${termSpacing}`; + } + if (scanning !== null && typeof scanning === 'object') { + this._textScanner.setOptions(scanning); + } + this._textScanner.setEnabled(true); + if (selectedParserChanged && this._parseResults.length > 0) { + this._renderParseResult(); + } + } + + async setText(text) { + this._text = text; + this._setPreview(text); + + const token = {}; + this._setTextToken = token; + this._parseResults = await api.textParse(text, this._getOptionsContext()); + if (this._setTextToken !== token) { return; } + + this._refreshSelectedParser(); + + this._renderParserSelect(); + this._renderParseResult(); + } + + // Private + + _onSearched(e) { + const {error} = e; + if (error !== null) { + yomichan.logError(error); + return; + } + if (e.type === null) { return; } + + this.trigger('searched', e); + } + + _onParserChange(e) { + const value = e.currentTarget.value; + this._setSelectedParser(value); + } + + _getOptionsContext() { + return this._getSearchContext().optionsContext; + } + + _refreshSelectedParser() { + if (this._parseResults.length > 0 && !this._getParseResult()) { + const value = this._parseResults[0].id; + this._setSelectedParser(value); + } + } + + _setSelectedParser(value) { + const optionsContext = this._getOptionsContext(); + api.modifySettings([{ + action: 'set', + path: 'parsing.selectedParser', + value, + scope: 'profile', + optionsContext + }], 'search'); + } + + _getParseResult() { + const selectedParser = this._selectedParser; + return this._parseResults.find((r) => r.id === selectedParser); + } + + _setPreview(text) { + const terms = [[{text, reading: ''}]]; + this._queryParser.textContent = ''; + this._queryParser.appendChild(this._createParseResult(terms, true)); + } + + _renderParserSelect() { + const visible = (this._parseResults.length > 1); + if (visible) { + this._updateParserModeSelect(this._queryParserModeSelect, this._parseResults, this._selectedParser); + } + this._queryParserModeContainer.hidden = !visible; + } + + _renderParseResult() { + const parseResult = this._getParseResult(); + this._queryParser.textContent = ''; + if (!parseResult) { return; } + this._queryParser.appendChild(this._createParseResult(parseResult.content, false)); + } + + _updateParserModeSelect(select, parseResults, selectedParser) { + const fragment = document.createDocumentFragment(); + + let index = 0; + let selectedIndex = -1; + for (const parseResult of parseResults) { + const option = document.createElement('option'); + option.value = parseResult.id; + switch (parseResult.source) { + case 'scanning-parser': + option.textContent = 'Scanning parser'; + break; + case 'mecab': + option.textContent = `MeCab: ${parseResult.dictionary}`; + break; + default: + option.textContent = `Unknown source: ${parseResult.source}`; + break; + } + fragment.appendChild(option); + + if (selectedParser === parseResult.id) { + selectedIndex = index; + } + ++index; + } + + select.textContent = ''; + select.appendChild(fragment); + select.selectedIndex = selectedIndex; + } + + _createParseResult(terms, preview) { + const type = preview ? 'preview' : 'normal'; + const fragment = document.createDocumentFragment(); + for (const term of terms) { + const termNode = document.createElement('span'); + termNode.className = 'query-parser-term'; + termNode.dataset.type = type; + for (const segment of term) { + if (segment.reading.trim().length === 0) { + this._addSegmentText(segment.text, termNode); + } else { + termNode.appendChild(this._createSegment(segment)); + } + } + fragment.appendChild(termNode); + } + return fragment; + } + + _createSegment(segment) { + const segmentNode = document.createElement('ruby'); + segmentNode.className = 'query-parser-segment'; + + const textNode = document.createElement('span'); + textNode.className = 'query-parser-segment-text'; + + const readingNode = document.createElement('rt'); + readingNode.className = 'query-parser-segment-reading'; + + segmentNode.appendChild(textNode); + segmentNode.appendChild(readingNode); + + this._addSegmentText(segment.text, textNode); + readingNode.textContent = segment.reading; + + return segmentNode; + } + + _addSegmentText(text, container) { + for (const character of text) { + const node = document.createElement('span'); + node.className = 'query-parser-char'; + node.textContent = character; + container.appendChild(node); + } + } +} diff --git a/ext/js/display/search-display-controller.js b/ext/js/display/search-display-controller.js new file mode 100644 index 00000000..a295346d --- /dev/null +++ b/ext/js/display/search-display-controller.js @@ -0,0 +1,422 @@ +/* + * Copyright (C) 2016-2021 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/>. + */ + +/* global + * ClipboardMonitor + * api + * wanakana + */ + +class SearchDisplayController { + constructor(tabId, frameId, display, japaneseUtil) { + this._tabId = tabId; + this._frameId = frameId; + this._display = display; + this._searchButton = document.querySelector('#search-button'); + this._queryInput = document.querySelector('#search-textbox'); + this._introElement = document.querySelector('#intro'); + this._clipboardMonitorEnableCheckbox = document.querySelector('#clipboard-monitor-enable'); + this._wanakanaEnableCheckbox = document.querySelector('#wanakana-enable'); + this._queryInputEvents = new EventListenerCollection(); + this._queryInputEventsSetup = false; + this._wanakanaEnabled = false; + this._introVisible = true; + this._introAnimationTimer = null; + this._clipboardMonitorEnabled = false; + this._clipboardMonitor = new ClipboardMonitor({ + japaneseUtil, + clipboardReader: { + getText: async () => (await api.clipboardGet()) + } + }); + this._messageHandlers = new Map(); + this._mode = null; + } + + async prepare() { + this._updateMode(); + + await this._display.updateOptions(); + + chrome.runtime.onMessage.addListener(this._onMessage.bind(this)); + yomichan.on('optionsUpdated', this._onOptionsUpdated.bind(this)); + + this._display.on('optionsUpdated', this._onDisplayOptionsUpdated.bind(this)); + this._display.on('contentUpdating', this._onContentUpdating.bind(this)); + + this._display.hotkeyHandler.registerActions([ + ['focusSearchBox', this._onActionFocusSearchBox.bind(this)] + ]); + this._registerMessageHandlers([ + ['getMode', {async: false, handler: this._onMessageGetMode.bind(this)}], + ['setMode', {async: false, handler: this._onMessageSetMode.bind(this)}], + ['updateSearchQuery', {async: false, handler: this._onExternalSearchUpdate.bind(this)}] + ]); + + this._display.autoPlayAudioDelay = 0; + this._display.queryParserVisible = true; + this._display.setHistorySettings({useBrowserHistory: true}); + this._display.setQueryPostProcessor(this._postProcessQuery.bind(this)); + + this._searchButton.addEventListener('click', this._onSearch.bind(this), false); + this._wanakanaEnableCheckbox.addEventListener('change', this._onWanakanaEnableChange.bind(this)); + window.addEventListener('copy', this._onCopy.bind(this)); + this._clipboardMonitor.on('change', this._onExternalSearchUpdate.bind(this)); + this._clipboardMonitorEnableCheckbox.addEventListener('change', this._onClipboardMonitorEnableChange.bind(this)); + this._display.hotkeyHandler.on('keydownNonHotkey', this._onKeyDown.bind(this)); + + this._onDisplayOptionsUpdated({options: this._display.getOptions()}); + } + + // Actions + + _onActionFocusSearchBox() { + if (this._queryInput === null) { return; } + this._queryInput.focus(); + this._queryInput.select(); + } + + // Messages + + _onMessageSetMode({mode}) { + this._setMode(mode, true); + } + + _onMessageGetMode() { + return this._mode; + } + + // Private + + _onMessage({action, params}, sender, callback) { + const messageHandler = this._messageHandlers.get(action); + if (typeof messageHandler === 'undefined') { return false; } + return yomichan.invokeMessageHandler(messageHandler, params, callback, sender); + } + + _onKeyDown(e) { + if ( + document.activeElement !== this._queryInput && + !e.ctrlKey && + !e.metaKey && + !e.altKey && + e.key.length === 1 + ) { + this._queryInput.focus({preventScroll: true}); + } + } + + async _onOptionsUpdated() { + await this._display.updateOptions(); + const query = this._queryInput.value; + if (query) { + this._display.searchLast(); + } + } + + _onDisplayOptionsUpdated({options}) { + this._clipboardMonitorEnabled = options.clipboard.enableSearchPageMonitor; + this._updateClipboardMonitorEnabled(); + + const enableWanakana = !!this._display.getOptions().general.enableWanakana; + this._wanakanaEnableCheckbox.checked = enableWanakana; + this._setWanakanaEnabled(enableWanakana); + } + + _onContentUpdating({type, content, source}) { + let animate = false; + let valid = false; + switch (type) { + case 'terms': + case 'kanji': + animate = !!content.animate; + valid = (typeof source === 'string' && source.length > 0); + this._display.blurElement(this._queryInput); + break; + case 'clear': + valid = false; + animate = true; + source = ''; + break; + } + + if (typeof source !== 'string') { source = ''; } + + if (this._queryInput.value !== source) { + this._queryInput.value = source; + this._updateSearchHeight(true); + } + this._setIntroVisible(!valid, animate); + } + + _onSearchInput() { + this._updateSearchHeight(false); + } + + _onSearchKeydown(e) { + const {code} = e; + if (!((code === 'Enter' || code === 'NumpadEnter') && !e.shiftKey)) { return; } + + // Search + e.preventDefault(); + e.stopImmediatePropagation(); + this._display.blurElement(e.currentTarget); + this._search(true, true, true); + } + + _onSearch(e) { + e.preventDefault(); + this._search(true, true, true); + } + + _onCopy() { + // ignore copy from search page + this._clipboardMonitor.setPreviousText(window.getSelection().toString().trim()); + } + + _onExternalSearchUpdate({text, animate=true}) { + const {clipboard: {autoSearchContent, maximumSearchLength}} = this._display.getOptions(); + if (text.length > maximumSearchLength) { + text = text.substring(0, maximumSearchLength); + } + this._queryInput.value = text; + this._updateSearchHeight(true); + this._search(animate, false, autoSearchContent); + } + + _onWanakanaEnableChange(e) { + const value = e.target.checked; + this._setWanakanaEnabled(value); + api.modifySettings([{ + action: 'set', + path: 'general.enableWanakana', + value, + scope: 'profile', + optionsContext: this._display.getOptionsContext() + }], 'search'); + } + + _onClipboardMonitorEnableChange(e) { + const enabled = e.target.checked; + this._setClipboardMonitorEnabled(enabled); + } + + _setWanakanaEnabled(enabled) { + if (this._queryInputEventsSetup && this._wanakanaEnabled === enabled) { return; } + + const input = this._queryInput; + this._queryInputEvents.removeAllEventListeners(); + this._queryInputEvents.addEventListener(input, 'keydown', this._onSearchKeydown.bind(this), false); + + this._wanakanaEnabled = enabled; + if (enabled) { + wanakana.bind(input); + } else { + wanakana.unbind(input); + } + + this._queryInputEvents.addEventListener(input, 'input', this._onSearchInput.bind(this), false); + this._queryInputEventsSetup = true; + } + + _setIntroVisible(visible, animate) { + if (this._introVisible === visible) { + return; + } + + this._introVisible = visible; + + if (this._introElement === null) { + return; + } + + if (this._introAnimationTimer !== null) { + clearTimeout(this._introAnimationTimer); + this._introAnimationTimer = null; + } + + if (visible) { + this._showIntro(animate); + } else { + this._hideIntro(animate); + } + } + + _showIntro(animate) { + if (animate) { + const duration = 0.4; + this._introElement.style.transition = ''; + this._introElement.style.height = ''; + const size = this._introElement.getBoundingClientRect(); + this._introElement.style.height = '0px'; + this._introElement.style.transition = `height ${duration}s ease-in-out 0s`; + window.getComputedStyle(this._introElement).getPropertyValue('height'); // Commits height so next line can start animation + this._introElement.style.height = `${size.height}px`; + this._introAnimationTimer = setTimeout(() => { + this._introElement.style.height = ''; + this._introAnimationTimer = null; + }, duration * 1000); + } else { + this._introElement.style.transition = ''; + this._introElement.style.height = ''; + } + } + + _hideIntro(animate) { + if (animate) { + const duration = 0.4; + const size = this._introElement.getBoundingClientRect(); + this._introElement.style.height = `${size.height}px`; + this._introElement.style.transition = `height ${duration}s ease-in-out 0s`; + window.getComputedStyle(this._introElement).getPropertyValue('height'); // Commits height so next line can start animation + } else { + this._introElement.style.transition = ''; + } + this._introElement.style.height = '0'; + } + + async _setClipboardMonitorEnabled(value) { + let modify = true; + if (value) { + value = await this._requestPermissions(['clipboardRead']); + modify = value; + } + + this._clipboardMonitorEnabled = value; + this._updateClipboardMonitorEnabled(); + + if (!modify) { return; } + + await api.modifySettings([{ + action: 'set', + path: 'clipboard.enableSearchPageMonitor', + value, + scope: 'profile', + optionsContext: this._display.getOptionsContext() + }], 'search'); + } + + _updateClipboardMonitorEnabled() { + const enabled = this._clipboardMonitorEnabled; + this._clipboardMonitorEnableCheckbox.checked = enabled; + if (enabled && this._mode !== 'popup') { + this._clipboardMonitor.start(); + } else { + this._clipboardMonitor.stop(); + } + } + + _requestPermissions(permissions) { + return new Promise((resolve) => { + chrome.permissions.request( + {permissions}, + (granted) => { + const e = chrome.runtime.lastError; + resolve(!e && granted); + } + ); + }); + } + + _search(animate, history, lookup) { + const query = this._queryInput.value; + const depth = this._display.depth; + const url = window.location.href; + const documentTitle = document.title; + const details = { + focus: false, + history, + params: { + query + }, + state: { + focusEntry: 0, + optionsContext: {depth, url}, + url, + sentence: {text: query, offset: 0}, + documentTitle + }, + content: { + definitions: null, + animate, + contentOrigin: { + tabId: this.tabId, + frameId: this.frameId + } + } + }; + if (!lookup) { details.params.lookup = 'false'; } + this._display.setContent(details); + } + + _updateSearchHeight(shrink) { + const node = this._queryInput; + if (shrink) { + node.style.height = '0'; + } + const {scrollHeight} = node; + const currentHeight = node.getBoundingClientRect().height; + if (shrink || scrollHeight >= currentHeight - 1) { + node.style.height = `${scrollHeight}px`; + } + } + + _postProcessQuery(query) { + if (this._wanakanaEnabled) { + try { + query = this._japaneseUtil.convertToKana(query); + } catch (e) { + // NOP + } + } + return query; + } + + _registerMessageHandlers(handlers) { + for (const [name, handlerInfo] of handlers) { + this._messageHandlers.set(name, handlerInfo); + } + } + + _updateMode() { + let mode = null; + try { + mode = sessionStorage.getItem('mode'); + } catch (e) { + // Browsers can throw a SecurityError when cookie blocking is enabled. + } + this._setMode(mode, false); + } + + _setMode(mode, save) { + if (mode === this._mode) { return; } + if (save) { + try { + if (mode === null) { + sessionStorage.removeItem('mode'); + } else { + sessionStorage.setItem('mode', mode); + } + } catch (e) { + // Browsers can throw a SecurityError when cookie blocking is enabled. + } + } + this._mode = mode; + document.documentElement.dataset.searchMode = (mode !== null ? mode : ''); + this._updateClipboardMonitorEnabled(); + } +} diff --git a/ext/js/display/search-main.js b/ext/js/display/search-main.js new file mode 100644 index 00000000..c7ec595a --- /dev/null +++ b/ext/js/display/search-main.js @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2019-2021 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/>. + */ + +/* global + * Display + * DocumentFocusController + * HotkeyHandler + * JapaneseUtil + * SearchDisplayController + * api + * wanakana + */ + +(async () => { + try { + const documentFocusController = new DocumentFocusController(); + documentFocusController.prepare(); + + api.forwardLogsToBackend(); + await yomichan.backendReady(); + + const {tabId, frameId} = await api.frameInformationGet(); + + const japaneseUtil = new JapaneseUtil(wanakana); + + const hotkeyHandler = new HotkeyHandler(); + hotkeyHandler.prepare(); + + const display = new Display(tabId, frameId, 'search', japaneseUtil, documentFocusController, hotkeyHandler); + await display.prepare(); + + const searchDisplayController = new SearchDisplayController(tabId, frameId, display, japaneseUtil); + await searchDisplayController.prepare(); + + display.initializeState(); + + document.documentElement.dataset.loaded = 'true'; + + yomichan.ready(); + } catch (e) { + yomichan.logError(e); + } +})(); |