aboutsummaryrefslogtreecommitdiff
path: root/ext/js/display
diff options
context:
space:
mode:
Diffstat (limited to 'ext/js/display')
-rw-r--r--ext/js/display/query-parser.js232
-rw-r--r--ext/js/display/search-display-controller.js422
-rw-r--r--ext/js/display/search-main.js57
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);
+ }
+})();