aboutsummaryrefslogtreecommitdiff
path: root/ext
diff options
context:
space:
mode:
Diffstat (limited to 'ext')
-rw-r--r--ext/bg/background.html4
-rw-r--r--ext/bg/css/settings.css14
-rw-r--r--ext/bg/data/options-schema.json5
-rw-r--r--ext/bg/js/api.js8
-rw-r--r--ext/bg/js/backend.js127
-rw-r--r--ext/bg/js/clipboard-monitor.js80
-rw-r--r--ext/bg/js/context.js4
-rw-r--r--ext/bg/js/japanese.js (renamed from ext/mixed/js/japanese.js)2
-rw-r--r--ext/bg/js/options.js1
-rw-r--r--ext/bg/js/search-query-parser-generator.js77
-rw-r--r--ext/bg/js/search-query-parser.js64
-rw-r--r--ext/bg/js/search.js178
-rw-r--r--ext/bg/js/settings/main.js17
-rw-r--r--ext/bg/js/templates.js55
-rw-r--r--ext/bg/query-parser-templates.html11
-rw-r--r--ext/bg/search.html44
-rw-r--r--ext/bg/settings.html7
-rw-r--r--ext/mixed/css/display.css19
-rw-r--r--ext/mixed/display-templates.html2
-rw-r--r--ext/mixed/js/api.js4
-rw-r--r--ext/mixed/js/core.js8
-rw-r--r--ext/mixed/js/display-generator.js1
-rw-r--r--ext/mixed/js/template-handler.js47
23 files changed, 505 insertions, 274 deletions
diff --git a/ext/bg/background.html b/ext/bg/background.html
index af87eddb..7fd1c477 100644
--- a/ext/bg/background.html
+++ b/ext/bg/background.html
@@ -26,20 +26,20 @@
<script src="/bg/js/mecab.js"></script>
<script src="/bg/js/audio.js"></script>
<script src="/bg/js/backend-api-forwarder.js"></script>
+ <script src="/bg/js/clipboard-monitor.js"></script>
<script src="/bg/js/conditions.js"></script>
<script src="/bg/js/database.js"></script>
<script src="/bg/js/deinflector.js"></script>
<script src="/bg/js/dictionary.js"></script>
<script src="/bg/js/handlebars.js"></script>
+ <script src="/bg/js/japanese.js"></script>
<script src="/bg/js/json-schema.js"></script>
<script src="/bg/js/options.js"></script>
<script src="/bg/js/profile-conditions.js"></script>
<script src="/bg/js/request.js"></script>
- <script src="/bg/js/templates.js"></script>
<script src="/bg/js/translator.js"></script>
<script src="/bg/js/util.js"></script>
<script src="/mixed/js/audio.js"></script>
- <script src="/mixed/js/japanese.js"></script>
<script src="/bg/js/backend.js"></script>
</body>
diff --git a/ext/bg/css/settings.css b/ext/bg/css/settings.css
index 815a88fa..d686e8f8 100644
--- a/ext/bg/css/settings.css
+++ b/ext/bg/css/settings.css
@@ -222,6 +222,20 @@ html:root[data-operating-system=openbsd] [data-show-for-operating-system~=openbs
display: initial;
}
+html:root[data-browser=edge] [data-hide-for-browser~=edge],
+html:root[data-browser=chrome] [data-hide-for-browser~=chrome],
+html:root[data-browser=firefox] [data-hide-for-browser~=firefox],
+html:root[data-browser=firefox-mobile] [data-hide-for-browser~=firefox-mobile],
+html:root[data-operating-system=mac] [data-hide-for-operating-system~=mac],
+html:root[data-operating-system=win] [data-hide-for-operating-system~=win],
+html:root[data-operating-system=android] [data-hide-for-operating-system~=android],
+html:root[data-operating-system=cros] [data-hide-for-operating-system~=cros],
+html:root[data-operating-system=linux] [data-hide-for-operating-system~=linux],
+html:root[data-operating-system=openbsd] [data-hide-for-operating-system~=openbsd] {
+ display: none;
+}
+
+
@media screen and (max-width: 740px) {
.col-xs-6 {
float: none;
diff --git a/ext/bg/data/options-schema.json b/ext/bg/data/options-schema.json
index 7e12481d..d6207952 100644
--- a/ext/bg/data/options-schema.json
+++ b/ext/bg/data/options-schema.json
@@ -79,6 +79,7 @@
"type": "object",
"required": [
"enable",
+ "enableClipboardPopups",
"resultOutputMode",
"debugInfo",
"maxResults",
@@ -111,6 +112,10 @@
"type": "boolean",
"default": true
},
+ "enableClipboardPopups": {
+ "type": "boolean",
+ "default": false
+ },
"resultOutputMode": {
"type": "string",
"enum": ["group", "merge", "split"],
diff --git a/ext/bg/js/api.js b/ext/bg/js/api.js
index 285b8016..cd6a9d18 100644
--- a/ext/bg/js/api.js
+++ b/ext/bg/js/api.js
@@ -29,6 +29,14 @@ function apiGetDisplayTemplatesHtml() {
return _apiInvoke('getDisplayTemplatesHtml');
}
+function apiClipboardGet() {
+ return _apiInvoke('clipboardGet');
+}
+
+function apiGetQueryParserTemplatesHtml() {
+ return _apiInvoke('getQueryParserTemplatesHtml');
+}
+
function _apiInvoke(action, params={}) {
const data = {action, params};
return new Promise((resolve, reject) => {
diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js
index eeab68a5..529055d2 100644
--- a/ext/bg/js/backend.js
+++ b/ext/bg/js/backend.js
@@ -22,6 +22,7 @@ class Backend {
this.translator = new Translator();
this.anki = new AnkiNull();
this.mecab = new Mecab();
+ this.clipboardMonitor = new ClipboardMonitor();
this.options = null;
this.optionsSchema = null;
this.optionsContext = {
@@ -34,6 +35,8 @@ class Backend {
this.clipboardPasteTarget = document.querySelector('#clipboard-paste-target');
+ this.popupWindow = null;
+
this.apiForwarder = new BackendApiForwarder();
}
@@ -67,6 +70,8 @@ class Backend {
this.isPreparedResolve();
this.isPreparedResolve = null;
this.isPreparedPromise = null;
+
+ this.clipboardMonitor.onClipboardText = (text) => this._onClipboardText(text);
}
onOptionsUpdated(source) {
@@ -97,6 +102,10 @@ class Backend {
}
}
+ _onClipboardText(text) {
+ this._onCommandSearch({mode: 'popup', query: text});
+ }
+
_onZoomChange({tabId, oldZoomFactor, newZoomFactor}) {
const callback = () => this.checkLastError(chrome.runtime.lastError);
chrome.tabs.sendMessage(tabId, {action: 'zoomChanged', params: {oldZoomFactor, newZoomFactor}}, callback);
@@ -121,6 +130,12 @@ class Backend {
} else {
this.mecab.stopListener();
}
+
+ if (options.general.enableClipboardPopups) {
+ this.clipboardMonitor.start();
+ } else {
+ this.clipboardMonitor.stop();
+ }
}
async getOptionsSchema() {
@@ -521,13 +536,30 @@ class Backend {
}
async _onApiClipboardGet() {
- const clipboardPasteTarget = this.clipboardPasteTarget;
- clipboardPasteTarget.value = '';
- clipboardPasteTarget.focus();
- document.execCommand('paste');
- const result = clipboardPasteTarget.value;
- clipboardPasteTarget.value = '';
- return result;
+ /*
+ Notes:
+ document.execCommand('paste') doesn't work on Firefox.
+ This may be a bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1603985
+ Therefore, navigator.clipboard.readText() is used on Firefox.
+
+ navigator.clipboard.readText() can't be used in Chrome for two reasons:
+ * Requires page to be focused, else it rejects with an exception.
+ * When the page is focused, Chrome will request clipboard permission, despite already
+ being an extension with clipboard permissions. It effectively asks for the
+ non-extension permission for clipboard access.
+ */
+ const browser = await Backend._getBrowser();
+ if (browser === 'firefox' || browser === 'firefox-mobile') {
+ return await navigator.clipboard.readText();
+ } else {
+ const clipboardPasteTarget = this.clipboardPasteTarget;
+ clipboardPasteTarget.value = '';
+ clipboardPasteTarget.focus();
+ document.execCommand('paste');
+ const result = clipboardPasteTarget.value;
+ clipboardPasteTarget.value = '';
+ return result;
+ }
}
async _onApiGetDisplayTemplatesHtml() {
@@ -535,6 +567,11 @@ class Backend {
return await requestText(url, 'GET');
}
+ async _onApiGetQueryParserTemplatesHtml() {
+ const url = chrome.runtime.getURL('/bg/query-parser-templates.html');
+ return await requestText(url, 'GET');
+ }
+
_onApiGetZoom(params, sender) {
if (!sender || !sender.tab) {
return Promise.reject(new Error('Invalid tab'));
@@ -565,23 +602,68 @@ class Backend {
// Command handlers
async _onCommandSearch(params) {
- const url = chrome.runtime.getURL('/bg/search.html');
- if (!(params && params.newTab)) {
- try {
- const tab = await Backend._findTab(1000, (url2) => (
- url2 !== null &&
- url2.startsWith(url) &&
- (url2.length === url.length || url2[url.length] === '?' || url2[url.length] === '#')
- ));
- if (tab !== null) {
- await Backend._focusTab(tab);
- return;
+ const {mode='existingOrNewTab', query} = params || {};
+
+ const options = await this.getOptions(this.optionsContext);
+ const {popupWidth, popupHeight} = options.general;
+
+ const baseUrl = chrome.runtime.getURL('/bg/search.html');
+ const queryParams = {mode};
+ if (query && query.length > 0) { queryParams.query = query; }
+ const queryString = new URLSearchParams(queryParams).toString();
+ const url = `${baseUrl}?${queryString}`;
+
+ const isTabMatch = (url2) => {
+ if (url2 === null || !url2.startsWith(baseUrl)) { return false; }
+ const {baseUrl: baseUrl2, queryParams: queryParams2} = parseUrl(url2);
+ return baseUrl2 === baseUrl && (queryParams2.mode === mode || (!queryParams2.mode && mode === 'existingOrNewTab'));
+ };
+
+ const openInTab = async () => {
+ const tab = await Backend._findTab(1000, isTabMatch);
+ if (tab !== null) {
+ await Backend._focusTab(tab);
+ if (queryParams.query) {
+ await new Promise((resolve) => chrome.tabs.sendMessage(
+ tab.id, {action: 'searchQueryUpdate', params: {query: queryParams.query}}, resolve
+ ));
}
- } catch (e) {
- // NOP
+ return true;
}
+ };
+
+ switch (mode) {
+ case 'existingOrNewTab':
+ try {
+ if (await openInTab()) { return; }
+ } catch (e) {
+ // NOP
+ }
+ chrome.tabs.create({url});
+ return;
+ case 'newTab':
+ chrome.tabs.create({url});
+ return;
+ case 'popup':
+ try {
+ // chrome.windows not supported (e.g. on Firefox mobile)
+ if (!isObject(chrome.windows)) { return; }
+ if (await openInTab()) { return; }
+ // if the previous popup is open in an invalid state, close it
+ if (this.popupWindow !== null) {
+ const callback = () => this.checkLastError(chrome.runtime.lastError);
+ chrome.windows.remove(this.popupWindow.id, callback);
+ }
+ // open new popup
+ this.popupWindow = await new Promise((resolve) => chrome.windows.create(
+ {url, width: popupWidth, height: popupHeight, type: 'popup'},
+ resolve
+ ));
+ } catch (e) {
+ // NOP
+ }
+ return;
}
- chrome.tabs.create({url});
}
_onCommandHelp() {
@@ -708,7 +790,7 @@ class Backend {
}
try {
- const tabWindow = await new Promise((resolve) => {
+ const tabWindow = await new Promise((resolve, reject) => {
chrome.windows.get(tab.windowId, {}, (tabWindow) => {
const e = chrome.runtime.lastError;
if (e) { reject(e); }
@@ -777,6 +859,7 @@ Backend._messageHandlers = new Map([
['getEnvironmentInfo', (self, ...args) => self._onApiGetEnvironmentInfo(...args)],
['clipboardGet', (self, ...args) => self._onApiClipboardGet(...args)],
['getDisplayTemplatesHtml', (self, ...args) => self._onApiGetDisplayTemplatesHtml(...args)],
+ ['getQueryParserTemplatesHtml', (self, ...args) => self._onApiGetQueryParserTemplatesHtml(...args)],
['getZoom', (self, ...args) => self._onApiGetZoom(...args)]
]);
diff --git a/ext/bg/js/clipboard-monitor.js b/ext/bg/js/clipboard-monitor.js
new file mode 100644
index 00000000..b4a27fa2
--- /dev/null
+++ b/ext/bg/js/clipboard-monitor.js
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2020 Alex Yatskov <alex@foosoft.net>
+ * Author: Alex Yatskov <alex@foosoft.net>
+ *
+ * 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 ClipboardMonitor {
+ constructor() {
+ this.timerId = null;
+ this.timerToken = null;
+ this.interval = 250;
+ this.previousText = null;
+ }
+
+ onClipboardText(_text) {
+ throw new Error('Override me');
+ }
+
+ start() {
+ this.stop();
+
+ // The token below is used as a unique identifier to ensure that a new clipboard monitor
+ // hasn't been started during the await call. The check below the await apiClipboardGet()
+ // call will exit early if the reference has changed.
+ const token = {};
+ const intervalCallback = async () => {
+ this.timerId = null;
+
+ let text = null;
+ try {
+ text = await apiClipboardGet();
+ } catch (e) {
+ // NOP
+ }
+ if (this.timerToken !== token) { return; }
+
+ if (
+ typeof text === 'string' &&
+ (text = text.trim()).length > 0 &&
+ text !== this.previousText
+ ) {
+ this.previousText = text;
+ if (jpIsStringPartiallyJapanese(text)) {
+ this.onClipboardText(text);
+ }
+ }
+
+ this.timerId = setTimeout(intervalCallback, this.interval);
+ };
+
+ this.timerToken = token;
+
+ intervalCallback();
+ }
+
+ stop() {
+ this.timerToken = null;
+ if (this.timerId !== null) {
+ clearTimeout(this.timerId);
+ this.timerId = null;
+ }
+ }
+
+ setPreviousText(text) {
+ this.previousText = text;
+ }
+}
diff --git a/ext/bg/js/context.js b/ext/bg/js/context.js
index 834174bf..37adb6b7 100644
--- a/ext/bg/js/context.js
+++ b/ext/bg/js/context.js
@@ -30,12 +30,12 @@ function setupButtonEvents(selector, command, url) {
for (const node of nodes) {
node.addEventListener('click', (e) => {
if (e.button !== 0) { return; }
- apiCommandExec(command, {newTab: e.ctrlKey});
+ apiCommandExec(command, {mode: e.ctrlKey ? 'newTab' : 'existingOrNewTab'});
e.preventDefault();
}, false);
node.addEventListener('auxclick', (e) => {
if (e.button !== 1) { return; }
- apiCommandExec(command, {newTab: true});
+ apiCommandExec(command, {mode: 'newTab'});
e.preventDefault();
}, false);
diff --git a/ext/mixed/js/japanese.js b/ext/bg/js/japanese.js
index 0da822d7..c45c0958 100644
--- a/ext/mixed/js/japanese.js
+++ b/ext/bg/js/japanese.js
@@ -108,7 +108,7 @@ const JP_JAPANESE_RANGES = [
[0xff1a, 0xff1f], // Fullwidth punctuation 2
[0xff3b, 0xff3f], // Fullwidth punctuation 3
[0xff5b, 0xff60], // Fullwidth punctuation 4
- [0xffe0, 0xffee], // Currency markers
+ [0xffe0, 0xffee] // Currency markers
];
diff --git a/ext/bg/js/options.js b/ext/bg/js/options.js
index 78508059..97032660 100644
--- a/ext/bg/js/options.js
+++ b/ext/bg/js/options.js
@@ -266,6 +266,7 @@ function profileOptionsCreateDefaults() {
return {
general: {
enable: true,
+ enableClipboardPopups: false,
resultOutputMode: 'group',
debugInfo: false,
maxResults: 32,
diff --git a/ext/bg/js/search-query-parser-generator.js b/ext/bg/js/search-query-parser-generator.js
new file mode 100644
index 00000000..8d71890b
--- /dev/null
+++ b/ext/bg/js/search-query-parser-generator.js
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2020 Alex Yatskov <alex@foosoft.net>
+ * Author: Alex Yatskov <alex@foosoft.net>
+ *
+ * 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 QueryParserGenerator {
+ constructor() {
+ this._templateHandler = null;
+ }
+
+ async prepare() {
+ const html = await apiGetQueryParserTemplatesHtml();
+ this._templateHandler = new TemplateHandler(html);
+ }
+
+ createParseResult(terms, preview=false) {
+ const fragment = document.createDocumentFragment();
+ for (const term of terms) {
+ const termContainer = this._templateHandler.instantiate(preview ? 'term-preview' : 'term');
+ for (const segment of term) {
+ if (!segment.text.trim()) { continue; }
+ if (!segment.reading || !segment.reading.trim()) {
+ termContainer.appendChild(this.createSegmentText(segment.text));
+ } else {
+ termContainer.appendChild(this.createSegment(segment));
+ }
+ }
+ fragment.appendChild(termContainer);
+ }
+ return fragment;
+ }
+
+ createSegment(segment) {
+ const segmentContainer = this._templateHandler.instantiate('segment');
+ const segmentTextContainer = segmentContainer.querySelector('.query-parser-segment-text');
+ const segmentReadingContainer = segmentContainer.querySelector('.query-parser-segment-reading');
+ segmentTextContainer.appendChild(this.createSegmentText(segment.text));
+ segmentReadingContainer.innerText = segment.reading;
+ return segmentContainer;
+ }
+
+ createSegmentText(text) {
+ const fragment = document.createDocumentFragment();
+ for (const chr of text) {
+ const charContainer = this._templateHandler.instantiate('char');
+ charContainer.innerText = chr;
+ fragment.appendChild(charContainer);
+ }
+ return fragment;
+ }
+
+ createParserSelect(parseResults, selectedParser) {
+ const selectContainer = this._templateHandler.instantiate('select');
+ for (const parseResult of parseResults) {
+ const optionContainer = this._templateHandler.instantiate('select-option');
+ optionContainer.value = parseResult.id;
+ optionContainer.innerText = parseResult.name;
+ optionContainer.defaultSelected = selectedParser === parseResult.id;
+ selectContainer.appendChild(optionContainer);
+ }
+ return selectContainer;
+ }
+}
diff --git a/ext/bg/js/search-query-parser.js b/ext/bg/js/search-query-parser.js
index e8e6d11f..f648fdd4 100644
--- a/ext/bg/js/search-query-parser.js
+++ b/ext/bg/js/search-query-parser.js
@@ -19,14 +19,20 @@
class QueryParser extends TextScanner {
constructor(search) {
- super(document.querySelector('#query-parser'), [], [], []);
+ super(document.querySelector('#query-parser-content'), [], [], []);
this.search = search;
this.parseResults = [];
this.selectedParser = null;
- this.queryParser = document.querySelector('#query-parser');
- this.queryParserSelect = document.querySelector('#query-parser-select');
+ this.queryParser = document.querySelector('#query-parser-content');
+ this.queryParserSelect = document.querySelector('#query-parser-select-container');
+
+ this.queryParserGenerator = new QueryParserGenerator();
+ }
+
+ async prepare() {
+ await this.queryParserGenerator.prepare();
}
onError(error) {
@@ -64,7 +70,7 @@ class QueryParser extends TextScanner {
const selectedParser = e.target.value;
this.selectedParser = selectedParser;
apiOptionsSet({parsing: {selectedParser}}, this.search.getOptionsContext());
- this.renderParseResult(this.getParseResult());
+ this.renderParseResult();
}
getMouseEventListeners() {
@@ -113,13 +119,13 @@ class QueryParser extends TextScanner {
async setText(text) {
this.search.setSpinnerVisible(true);
- await this.setPreview(text);
+ this.setPreview(text);
this.parseResults = await this.parseText(text);
this.refreshSelectedParser();
this.renderParserSelect();
- await this.renderParseResult();
+ this.renderParseResult();
this.search.setSpinnerVisible(false);
}
@@ -146,57 +152,29 @@ class QueryParser extends TextScanner {
return results;
}
- async setPreview(text) {
+ setPreview(text) {
const previewTerms = [];
for (let i = 0, ii = text.length; i < ii; i += 2) {
const tempText = text.substring(i, i + 2);
- previewTerms.push([{text: tempText.split('')}]);
+ previewTerms.push([{text: tempText}]);
}
- this.queryParser.innerHTML = await apiTemplateRender('query-parser.html', {
- terms: previewTerms,
- preview: true
- });
+ this.queryParser.textContent = '';
+ this.queryParser.appendChild(this.queryParserGenerator.createParseResult(previewTerms, true));
}
renderParserSelect() {
this.queryParserSelect.innerHTML = '';
if (this.parseResults.length > 1) {
- const select = document.createElement('select');
- select.classList.add('form-control');
- for (const parseResult of this.parseResults) {
- const option = document.createElement('option');
- option.value = parseResult.id;
- option.innerText = parseResult.name;
- option.defaultSelected = this.selectedParser === parseResult.id;
- select.appendChild(option);
- }
+ const select = this.queryParserGenerator.createParserSelect(this.parseResults, this.selectedParser);
select.addEventListener('change', this.onParserChange.bind(this));
this.queryParserSelect.appendChild(select);
}
}
- async renderParseResult() {
+ renderParseResult() {
const parseResult = this.getParseResult();
- if (!parseResult) {
- this.queryParser.innerHTML = '';
- return;
- }
-
- this.queryParser.innerHTML = await apiTemplateRender(
- 'query-parser.html',
- {terms: QueryParser.processParseResultForDisplay(parseResult.parsedText)}
- );
- }
-
- static processParseResultForDisplay(result) {
- return result.map((term) => {
- return term.filter((part) => part.text.trim()).map((part) => {
- return {
- text: part.text.split(''),
- reading: part.reading,
- raw: !part.reading || !part.reading.trim()
- };
- });
- });
+ this.queryParser.textContent = '';
+ if (!parseResult) { return; }
+ this.queryParser.appendChild(this.queryParserGenerator.createParseResult(parseResult.parsedText));
}
}
diff --git a/ext/bg/js/search.js b/ext/bg/js/search.js
index f5c641a8..6641255f 100644
--- a/ext/bg/js/search.js
+++ b/ext/bg/js/search.js
@@ -36,12 +36,7 @@ class DisplaySearch extends Display {
this.introVisible = true;
this.introAnimationTimer = null;
- this.isFirefox = false;
-
- this.clipboardMonitorTimerId = null;
- this.clipboardMonitorTimerToken = null;
- this.clipboardInterval = 250;
- this.clipboardPreviousText = null;
+ this.clipboardMonitor = new ClipboardMonitor();
}
static create() {
@@ -53,12 +48,16 @@ class DisplaySearch extends Display {
async prepare() {
try {
await this.initialize();
- this.isFirefox = await DisplaySearch._isFirefox();
+
+ await this.queryParser.prepare();
+
+ const {queryParams: {query='', mode=''}} = parseUrl(window.location.href);
if (this.search !== null) {
this.search.addEventListener('click', (e) => this.onSearch(e), false);
}
if (this.query !== null) {
+ document.documentElement.dataset.searchMode = mode;
this.query.addEventListener('input', () => this.onSearchInput(), false);
if (this.wanakanaEnable !== null) {
@@ -69,34 +68,26 @@ class DisplaySearch extends Display {
this.wanakanaEnable.checked = false;
}
this.wanakanaEnable.addEventListener('change', (e) => {
- const query = DisplaySearch.getSearchQueryFromLocation(window.location.href) || '';
+ const {queryParams: {query=''}} = parseUrl(window.location.href);
if (e.target.checked) {
window.wanakana.bind(this.query);
- this.setQuery(window.wanakana.toKana(query));
apiOptionsSet({general: {enableWanakana: true}}, this.getOptionsContext());
} else {
window.wanakana.unbind(this.query);
- this.setQuery(query);
apiOptionsSet({general: {enableWanakana: false}}, this.getOptionsContext());
}
+ this.setQuery(query);
this.onSearchQueryUpdated(this.query.value, false);
});
}
- const query = DisplaySearch.getSearchQueryFromLocation(window.location.href);
- if (query !== null) {
- if (this.isWanakanaEnabled()) {
- this.setQuery(window.wanakana.toKana(query));
- } else {
- this.setQuery(query);
- }
- this.onSearchQueryUpdated(this.query.value, false);
- }
+ this.setQuery(query);
+ this.onSearchQueryUpdated(this.query.value, false);
}
- if (this.clipboardMonitorEnable !== null) {
+ if (this.clipboardMonitorEnable !== null && mode !== 'popup') {
if (this.options.general.enableClipboardMonitor === true) {
this.clipboardMonitorEnable.checked = true;
- this.startClipboardMonitor();
+ this.clipboardMonitor.start();
} else {
this.clipboardMonitorEnable.checked = false;
}
@@ -106,7 +97,7 @@ class DisplaySearch extends Display {
{permissions: ['clipboardRead']},
(granted) => {
if (granted) {
- this.startClipboardMonitor();
+ this.clipboardMonitor.start();
apiOptionsSet({general: {enableClipboardMonitor: true}}, this.getOptionsContext());
} else {
e.target.checked = false;
@@ -114,16 +105,20 @@ class DisplaySearch extends Display {
}
);
} else {
- this.stopClipboardMonitor();
+ this.clipboardMonitor.stop();
apiOptionsSet({general: {enableClipboardMonitor: false}}, this.getOptionsContext());
}
});
}
+ chrome.runtime.onMessage.addListener(this.onRuntimeMessage.bind(this));
+
window.addEventListener('popstate', (e) => this.onPopState(e));
+ window.addEventListener('copy', (e) => this.onCopy(e));
+
+ this.clipboardMonitor.onClipboardText = (text) => this.onExternalSearchUpdate(text);
this.updateSearchButton();
- this.initClipboardMonitor();
} catch (e) {
this.onError(e);
}
@@ -159,25 +154,32 @@ class DisplaySearch extends Display {
e.preventDefault();
const query = this.query.value;
+
this.queryParser.setText(query);
- const queryString = query.length > 0 ? `?query=${encodeURIComponent(query)}` : '';
- window.history.pushState(null, '', `${window.location.pathname}${queryString}`);
+
+ const url = new URL(window.location.href);
+ url.searchParams.set('query', query);
+ window.history.pushState(null, '', url.toString());
+
this.onSearchQueryUpdated(query, true);
}
onPopState() {
- const query = DisplaySearch.getSearchQueryFromLocation(window.location.href) || '';
- if (this.query !== null) {
- if (this.isWanakanaEnabled()) {
- this.setQuery(window.wanakana.toKana(query));
- } else {
- this.setQuery(query);
- }
- }
-
+ const {queryParams: {query='', mode=''}} = parseUrl(window.location.href);
+ document.documentElement.dataset.searchMode = mode;
+ this.setQuery(query);
this.onSearchQueryUpdated(this.query.value, false);
}
+ onRuntimeMessage({action, params}, sender, callback) {
+ const handler = DisplaySearch._runtimeMessageHandlers.get(action);
+ if (typeof handler !== 'function') { return false; }
+
+ const result = handler(this, params, sender);
+ callback(result);
+ return false;
+ }
+
onKeyDown(e) {
const key = Display.getKeyFromEvent(e);
const ignoreKeys = DisplaySearch.onKeyDownIgnoreKeys;
@@ -202,6 +204,19 @@ class DisplaySearch extends Display {
}
}
+ onCopy() {
+ // ignore copy from search page
+ this.clipboardMonitor.setPreviousText(document.getSelection().toString().trim());
+ }
+
+ onExternalSearchUpdate(text) {
+ 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, true);
+ }
+
async onSearchQueryUpdated(query, animate) {
try {
const details = {};
@@ -241,74 +256,6 @@ class DisplaySearch extends Display {
this.queryParser.setOptions(this.options);
}
- initClipboardMonitor() {
- // ignore copy from search page
- window.addEventListener('copy', () => {
- this.clipboardPreviousText = document.getSelection().toString().trim();
- });
- }
-
- startClipboardMonitor() {
- // The token below is used as a unique identifier to ensure that a new clipboard monitor
- // hasn't been started during the await call. The check below the await this.getClipboardText()
- // call will exit early if the reference has changed.
- const token = {};
- const intervalCallback = async () => {
- this.clipboardMonitorTimerId = null;
-
- let text = await this.getClipboardText();
- if (this.clipboardMonitorTimerToken !== token) { return; }
-
- if (
- typeof text === 'string' &&
- (text = text.trim()).length > 0 &&
- text !== this.clipboardPreviousText
- ) {
- this.clipboardPreviousText = text;
- if (jpIsStringPartiallyJapanese(text)) {
- this.setQuery(this.isWanakanaEnabled() ? window.wanakana.toKana(text) : text);
- window.history.pushState(null, '', `${window.location.pathname}?query=${encodeURIComponent(text)}`);
- this.onSearchQueryUpdated(this.query.value, true);
- }
- }
-
- this.clipboardMonitorTimerId = setTimeout(intervalCallback, this.clipboardInterval);
- };
-
- this.clipboardMonitorTimerToken = token;
-
- intervalCallback();
- }
-
- stopClipboardMonitor() {
- this.clipboardMonitorTimerToken = null;
- if (this.clipboardMonitorTimerId !== null) {
- clearTimeout(this.clipboardMonitorTimerId);
- this.clipboardMonitorTimerId = null;
- }
- }
-
- async getClipboardText() {
- /*
- Notes:
- apiClipboardGet doesn't work on Firefox because document.execCommand('paste')
- results in an empty string on the web extension background page.
- This may be a bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1603985
- Therefore, navigator.clipboard.readText() is used on Firefox.
-
- navigator.clipboard.readText() can't be used in Chrome for two reasons:
- * Requires page to be focused, else it rejects with an exception.
- * When the page is focused, Chrome will request clipboard permission, despite already
- being an extension with clipboard permissions. It effectively asks for the
- non-extension permission for clipboard access.
- */
- try {
- return this.isFirefox ? await navigator.clipboard.readText() : await apiClipboardGet();
- } catch (e) {
- return null;
- }
- }
-
isWanakanaEnabled() {
return this.wanakanaEnable !== null && this.wanakanaEnable.checked;
}
@@ -318,8 +265,9 @@ class DisplaySearch extends Display {
}
setQuery(query) {
- this.query.value = query;
- this.queryParser.setText(query);
+ const interpretedQuery = this.isWanakanaEnabled() ? window.wanakana.toKana(query) : query;
+ this.query.value = interpretedQuery;
+ this.queryParser.setText(interpretedQuery);
}
setIntroVisible(visible, animate) {
@@ -394,22 +342,6 @@ class DisplaySearch extends Display {
document.title = `${text} - Yomichan Search`;
}
}
-
- static getSearchQueryFromLocation(url) {
- const match = /^[^?#]*\?(?:[^&#]*&)?query=([^&#]*)/.exec(url);
- return match !== null ? decodeURIComponent(match[1]) : null;
- }
-
- static async _isFirefox() {
- const {browser} = await apiGetEnvironmentInfo();
- switch (browser) {
- case 'firefox':
- case 'firefox-mobile':
- return true;
- default:
- return false;
- }
- }
}
DisplaySearch.onKeyDownIgnoreKeys = {
@@ -427,4 +359,8 @@ DisplaySearch.onKeyDownIgnoreKeys = {
'Shift': []
};
+DisplaySearch._runtimeMessageHandlers = new Map([
+ ['searchQueryUpdate', (self, {query}) => { self.onExternalSearchUpdate(query); }]
+]);
+
DisplaySearch.instance = DisplaySearch.create();
diff --git a/ext/bg/js/settings/main.js b/ext/bg/js/settings/main.js
index 4492cd42..cf0f08db 100644
--- a/ext/bg/js/settings/main.js
+++ b/ext/bg/js/settings/main.js
@@ -28,6 +28,22 @@ function getOptionsFullMutable() {
async function formRead(options) {
options.general.enable = $('#enable').prop('checked');
+ const enableClipboardPopups = $('#enable-clipboard-popups').prop('checked');
+ if (enableClipboardPopups) {
+ options.general.enableClipboardPopups = await new Promise((resolve, _reject) => {
+ chrome.permissions.request(
+ {permissions: ['clipboardRead']},
+ (granted) => {
+ if (!granted) {
+ $('#enable-clipboard-popups').prop('checked', false);
+ }
+ resolve(granted);
+ }
+ );
+ });
+ } else {
+ options.general.enableClipboardPopups = false;
+ }
options.general.showGuide = $('#show-usage-guide').prop('checked');
options.general.compactTags = $('#compact-tags').prop('checked');
options.general.compactGlossaries = $('#compact-glossaries').prop('checked');
@@ -104,6 +120,7 @@ async function formRead(options) {
async function formWrite(options) {
$('#enable').prop('checked', options.general.enable);
+ $('#enable-clipboard-popups').prop('checked', options.general.enableClipboardPopups);
$('#show-usage-guide').prop('checked', options.general.showGuide);
$('#compact-tags').prop('checked', options.general.compactTags);
$('#compact-glossaries').prop('checked', options.general.compactGlossaries);
diff --git a/ext/bg/js/templates.js b/ext/bg/js/templates.js
deleted file mode 100644
index 2f65be31..00000000
--- a/ext/bg/js/templates.js
+++ /dev/null
@@ -1,55 +0,0 @@
-(function() {
- var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {};
-templates['query-parser.html'] = template({"1":function(container,depth0,helpers,partials,data) {
- var stack1, alias1=depth0 != null ? depth0 : (container.nullContext || {});
-
- return ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.preview : depth0),{"name":"if","hash":{},"fn":container.program(2, data, 0),"inverse":container.program(4, data, 0),"data":data})) != null ? stack1 : "")
- + ((stack1 = helpers.each.call(alias1,depth0,{"name":"each","hash":{},"fn":container.program(6, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "")
- + "</span>";
-},"2":function(container,depth0,helpers,partials,data) {
- return "<span class=\"query-parser-term-preview\">";
-},"4":function(container,depth0,helpers,partials,data) {
- return "<span class=\"query-parser-term\">";
-},"6":function(container,depth0,helpers,partials,data) {
- var stack1;
-
- return ((stack1 = container.invokePartial(partials.part,depth0,{"name":"part","data":data,"helpers":helpers,"partials":partials,"decorators":container.decorators})) != null ? stack1 : "");
-},"8":function(container,depth0,helpers,partials,data) {
- var stack1;
-
- return ((stack1 = helpers["if"].call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.raw : depth0),{"name":"if","hash":{},"fn":container.program(9, data, 0),"inverse":container.program(12, data, 0),"data":data})) != null ? stack1 : "");
-},"9":function(container,depth0,helpers,partials,data) {
- var stack1;
-
- return ((stack1 = helpers.each.call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.text : depth0),{"name":"each","hash":{},"fn":container.program(10, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "");
-},"10":function(container,depth0,helpers,partials,data) {
- return "<span class=\"query-parser-char\">"
- + container.escapeExpression(container.lambda(depth0, depth0))
- + "</span>";
-},"12":function(container,depth0,helpers,partials,data) {
- var stack1, helper, alias1=depth0 != null ? depth0 : (container.nullContext || {});
-
- return "<ruby>"
- + ((stack1 = helpers.each.call(alias1,(depth0 != null ? depth0.text : depth0),{"name":"each","hash":{},"fn":container.program(10, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "")
- + "<rt>"
- + container.escapeExpression(((helper = (helper = helpers.reading || (depth0 != null ? depth0.reading : depth0)) != null ? helper : helpers.helperMissing),(typeof helper === "function" ? helper.call(alias1,{"name":"reading","hash":{},"data":data}) : helper)))
- + "</rt></ruby>";
-},"14":function(container,depth0,helpers,partials,data,blockParams,depths) {
- var stack1;
-
- return ((stack1 = container.invokePartial(partials.term,depth0,{"name":"term","hash":{"preview":(depths[1] != null ? depths[1].preview : depths[1])},"data":data,"helpers":helpers,"partials":partials,"decorators":container.decorators})) != null ? stack1 : "");
-},"compiler":[7,">= 4.0.0"],"main":function(container,depth0,helpers,partials,data,blockParams,depths) {
- var stack1;
-
- return ((stack1 = helpers.each.call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.terms : depth0),{"name":"each","hash":{},"fn":container.program(14, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : "");
-},"main_d": function(fn, props, container, depth0, data, blockParams, depths) {
-
- var decorators = container.decorators;
-
- fn = decorators.inline(fn,props,container,{"name":"inline","hash":{},"fn":container.program(1, data, 0, blockParams, depths),"inverse":container.noop,"args":["term"],"data":data}) || fn;
- fn = decorators.inline(fn,props,container,{"name":"inline","hash":{},"fn":container.program(8, data, 0, blockParams, depths),"inverse":container.noop,"args":["part"],"data":data}) || fn;
- return fn;
- }
-
-,"useDecorators":true,"usePartial":true,"useData":true,"useDepths":true});
-})(); \ No newline at end of file
diff --git a/ext/bg/query-parser-templates.html b/ext/bg/query-parser-templates.html
new file mode 100644
index 00000000..7cab16a9
--- /dev/null
+++ b/ext/bg/query-parser-templates.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html><html><head></head><body>
+
+<template id="term-template"><span class="query-parser-term" data-type="normal"></span></template>
+<template id="term-preview-template"><span class="query-parser-term" data-type="preview"></span></template>
+<template id="segment-template"><ruby class="query-parser-segment"><span class="query-parser-segment-text"></span><rt class="query-parser-segment-reading"></rt></ruby></template>
+<template id="char-template"><span class="query-parser-char"></span></template>
+
+<template id="select-template"><select class="query-parser-select form-control"></select></template>
+<template id="select-option-template"><option class="query-parser-select-option"></option></template>
+
+</body></html>
diff --git a/ext/bg/search.html b/ext/bg/search.html
index 74afbb68..d6336826 100644
--- a/ext/bg/search.html
+++ b/ext/bg/search.html
@@ -25,29 +25,31 @@
<p style="margin-bottom: 0;">Search your installed dictionaries by entering a Japanese expression into the field below.</p>
</div>
- <div class="input-group" style="padding-top: 20px;">
- <span title="Enable kana input method" class="input-group-text">
- <input type="checkbox" id="wanakana-enable" class="icon-checkbox" />
- <label for="wanakana-enable" class="scan-disable">あ</label>
- </span>
- <span title="Enable clipboard monitor" class="input-group-text">
- <input type="checkbox" id="clipboard-monitor-enable" class="icon-checkbox" />
- <label for="clipboard-monitor-enable"><span class="glyphicon glyphicon-paste"></span></label>
- </span>
- </div>
+ <div class="search-input">
+ <div class="input-group" style="padding-top: 20px;">
+ <span title="Enable kana input method" class="input-group-text">
+ <input type="checkbox" id="wanakana-enable" class="icon-checkbox" />
+ <label for="wanakana-enable" class="scan-disable">あ</label>
+ </span>
+ <span title="Enable clipboard monitor" class="input-group-text">
+ <input type="checkbox" id="clipboard-monitor-enable" class="icon-checkbox" />
+ <label for="clipboard-monitor-enable"><span class="glyphicon glyphicon-paste"></span></label>
+ </span>
+ </div>
- <form class="input-group">
- <input type="text" class="form-control" placeholder="Search for..." id="query" autofocus>
- <span class="input-group-btn">
- <input type="submit" class="btn btn-default form-control" id="search" value="Search">
- </span>
- </form>
+ <form class="input-group">
+ <input type="text" class="form-control" placeholder="Search for..." id="query" autofocus>
+ <span class="input-group-btn">
+ <input type="submit" class="btn btn-default form-control" id="search" value="Search">
+ </span>
+ </form>
+ </div>
<div id="spinner" hidden><img src="/mixed/img/spinner.gif"></div>
<div class="scan-disable">
- <div id="query-parser-select" class="input-group"></div>
- <div id="query-parser"></div>
+ <div id="query-parser-select-container" class="input-group"></div>
+ <div id="query-parser-content"></div>
</div>
<hr>
@@ -75,18 +77,20 @@
<script src="/bg/js/dictionary.js"></script>
<script src="/bg/js/handlebars.js"></script>
- <script src="/bg/js/templates.js"></script>
+ <script src="/bg/js/japanese.js"></script>
<script src="/fg/js/document.js"></script>
<script src="/fg/js/source.js"></script>
<script src="/mixed/js/audio.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/japanese.js"></script>
<script src="/mixed/js/scroll.js"></script>
<script src="/mixed/js/text-scanner.js"></script>
+ <script src="/mixed/js/template-handler.js"></script>
+ <script src="/bg/js/search-query-parser-generator.js"></script>
<script src="/bg/js/search-query-parser.js"></script>
+ <script src="/bg/js/clipboard-monitor.js"></script>
<script src="/bg/js/search.js"></script>
<script src="/bg/js/search-frontend.js"></script>
</body>
diff --git a/ext/bg/settings.html b/ext/bg/settings.html
index 77bcc359..b048a36c 100644
--- a/ext/bg/settings.html
+++ b/ext/bg/settings.html
@@ -134,6 +134,10 @@
<label><input type="checkbox" id="enable"> Enable content scanning</label>
</div>
+ <div class="checkbox" data-hide-for-browser="firefox-mobile">
+ <label><input type="checkbox" id="enable-clipboard-popups"> Enable native popups when copying Japanese text</label>
+ </div>
+
<div class="checkbox">
<label><input type="checkbox" id="show-usage-guide"> Show usage guide on startup</label>
</div>
@@ -1084,16 +1088,15 @@
<script src="/mixed/js/core.js"></script>
<script src="/mixed/js/dom.js"></script>
<script src="/mixed/js/api.js"></script>
- <script src="/mixed/js/japanese.js"></script>
<script src="/bg/js/anki.js"></script>
<script src="/bg/js/conditions.js"></script>
<script src="/bg/js/dictionary.js"></script>
<script src="/bg/js/handlebars.js"></script>
+ <script src="/bg/js/japanese.js"></script>
<script src="/bg/js/options.js"></script>
<script src="/bg/js/page-exit-prevention.js"></script>
<script src="/bg/js/profile-conditions.js"></script>
- <script src="/bg/js/templates.js"></script>
<script src="/bg/js/util.js"></script>
<script src="/mixed/js/audio.js"></script>
diff --git a/ext/mixed/css/display.css b/ext/mixed/css/display.css
index 3a66cec3..6a5383bc 100644
--- a/ext/mixed/css/display.css
+++ b/ext/mixed/css/display.css
@@ -127,15 +127,19 @@ html:root[data-yomichan-page=float] .navigation-header:not([hidden])~.navigation
user-select: none;
}
-#query-parser {
+#query-parser-content {
margin-top: 0.5em;
font-size: 2em;
}
-#query-parser[data-term-spacing=true] .query-parser-term {
+#query-parser-content[data-term-spacing=true] .query-parser-term {
margin-right: 0.2em;
}
+html:root[data-yomichan-page=search][data-search-mode=popup] .search-input {
+ display: none;
+}
+
/*
* Entries
@@ -223,8 +227,8 @@ button.action-button {
display: block;
}
-.tag-list>.tag+.tag {
- margin-left: 0.375em;
+.tag-list>.tag:not(:last-child) {
+ margin-right: 0.375em;
}
html:root:not([data-enable-search-tags=true]) .tag[data-category=search] {
@@ -394,7 +398,7 @@ html:root:not([data-enable-search-tags=true]) .tag[data-category=search] {
:root[data-compact-glossaries=true] .term-definition-tag-list,
:root[data-compact-glossaries=true] .term-definition-only-list:not([data-count="0"]) {
- display: inline-block;
+ display: inline;
}
:root[data-compact-glossaries=true] .term-glossary-list {
@@ -421,6 +425,11 @@ html:root:not([data-enable-search-tags=true]) .tag[data-category=search] {
white-space: pre-wrap;
}
+.term-special-tags>.frequencies {
+ display: inline;
+}
+
+
/*
* Kanji
*/
diff --git a/ext/mixed/display-templates.html b/ext/mixed/display-templates.html
index 6fcf4c74..7ae51a62 100644
--- a/ext/mixed/display-templates.html
+++ b/ext/mixed/display-templates.html
@@ -15,7 +15,7 @@
</div>
<div class="term-reasons"></div>
</div>
- <div class="frequencies"></div>
+ <div class="term-special-tags"><div class="frequencies tag-list"></div></div>
</div>
<div class="term-definition-container"><ol class="term-definition-list"></ol></div>
<pre class="debug-info"></pre>
diff --git a/ext/mixed/js/api.js b/ext/mixed/js/api.js
index 5ec93b01..0b1e7e4f 100644
--- a/ext/mixed/js/api.js
+++ b/ext/mixed/js/api.js
@@ -105,6 +105,10 @@ function apiGetDisplayTemplatesHtml() {
return _apiInvoke('getDisplayTemplatesHtml');
}
+function apiGetQueryParserTemplatesHtml() {
+ return _apiInvoke('getQueryParserTemplatesHtml');
+}
+
function apiGetZoom() {
return _apiInvoke('getZoom');
}
diff --git a/ext/mixed/js/core.js b/ext/mixed/js/core.js
index 0142d594..ca9e98e5 100644
--- a/ext/mixed/js/core.js
+++ b/ext/mixed/js/core.js
@@ -128,6 +128,14 @@ function stringReverse(string) {
return string.split('').reverse().join('').replace(/([\uDC00-\uDFFF])([\uD800-\uDBFF])/g, '$2$1');
}
+function parseUrl(url) {
+ const parsedUrl = new URL(url);
+ const baseUrl = `${parsedUrl.origin}${parsedUrl.pathname}`;
+ const queryParams = Array.from(parsedUrl.searchParams.entries())
+ .reduce((a, [k, v]) => Object.assign({}, a, {[k]: v}), {});
+ return {baseUrl, queryParams};
+}
+
/*
* Async utilities
diff --git a/ext/mixed/js/display-generator.js b/ext/mixed/js/display-generator.js
index c90e693a..d5ab9dbc 100644
--- a/ext/mixed/js/display-generator.js
+++ b/ext/mixed/js/display-generator.js
@@ -307,6 +307,7 @@ class DisplayGenerator {
const html = await apiGetDisplayTemplatesHtml();
const doc = new DOMParser().parseFromString(html, 'text/html');
this._setTemplates(doc);
+ this._isInitialized = true;
}
_setTemplates(doc) {
diff --git a/ext/mixed/js/template-handler.js b/ext/mixed/js/template-handler.js
new file mode 100644
index 00000000..a5a62937
--- /dev/null
+++ b/ext/mixed/js/template-handler.js
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2020 Alex Yatskov <alex@foosoft.net>
+ * Author: Alex Yatskov <alex@foosoft.net>
+ *
+ * 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 <http://www.gnu.org/licenses/>.
+ */
+
+
+class TemplateHandler {
+ constructor(html) {
+ this._templates = new Map();
+
+ const doc = new DOMParser().parseFromString(html, 'text/html');
+ for (const template of doc.querySelectorAll('template')) {
+ this._setTemplate(template);
+ }
+ }
+
+ _setTemplate(template) {
+ const idMatch = template.id.match(/^([a-z-]+)-template$/);
+ if (!idMatch) {
+ throw new Error(`Invalid template ID: ${template.id}`);
+ }
+ this._templates.set(idMatch[1], template);
+ }
+
+ instantiate(name) {
+ const template = this._templates.get(name);
+ return document.importNode(template.content.firstChild, true);
+ }
+
+ instantiateFragment(name) {
+ const template = this._templates.get(name);
+ return document.importNode(template.content, true);
+ }
+}