diff options
author | Alex Yatskov <alex@foosoft.net> | 2019-10-20 11:23:20 -0700 |
---|---|---|
committer | Alex Yatskov <alex@foosoft.net> | 2019-10-20 11:23:20 -0700 |
commit | 438498435227cfa59cf9ed3430045b288cd2a7c0 (patch) | |
tree | 6a05520e5d6fa8d26d372673a9ed3e5d2da7e3fd | |
parent | 06d7713189be9eb51669d3842b78278371e6cfa4 (diff) | |
parent | d32fd1381b6cd5141a21c22f9ef639b2fe9774fb (diff) |
Merge branch 'master' into testing
41 files changed, 2159 insertions, 709 deletions
@@ -162,10 +162,10 @@ Flashcard fields can be configured with the following steps: `{furigana-plain}` | Term expressed as Kanji with Furigana displayed next to it in brackets (e.g. 日本語[にほんご]). `{glossary}` | List of definitions for the term (output format depends on whether running in *grouped* mode). `{reading}` | Kana reading for the term (empty for terms where the expression is the reading). + `{screenshot}` | Screenshot of the web page taken at the time the term was added. `{sentence}` | Sentence, quote, or phrase in which the term appears in the source content. `{tags}` | Grammar and usage tags providing information about the term (unavailable in *grouped* mode). `{url}` | Address of the web page in which the term appeared in. - `{screenshot}` | Screenshot of the web page taken at the time the term was added. #### Markers for Kanji Cards #### @@ -179,9 +179,9 @@ Flashcard fields can be configured with the following steps: `{glossary}` | List of definitions for the Kanji. `{kunyomi}` | Kunyomi (Japanese reading) for the Kanji expressed as Katakana. `{onyomi}` | Onyomi (Chinese reading) for the Kanji expressed as Hiragana. + `{screenshot}` | Screenshot of the web page taken at the time the Kanji was added. `{sentence}` | Sentence, quote, or phrase in which the character appears in the source content. `{url}` | Address of the web page in which the Kanji appeared in. - `{screenshot}` | Screenshot of the web page taken at the time the Kanji was added. When creating your model for Yomichan, *please make sure that you pick a unique field to be first*; fields that will contain `{expression}` or `{character}` are ideal candidates for this. Anki does not require duplicate flashcards to be diff --git a/ext/bg/context.html b/ext/bg/context.html index 51346838..48fa463f 100644 --- a/ext/bg/context.html +++ b/ext/bg/context.html @@ -9,24 +9,107 @@ <style type="text/css"> body { padding: 10px; - text-align: center; } + h3 { + margin-top: 10px; + } + label { + font-weight: normal; + } + + #mini { + text-align: center; + } + #full { + display: none; + } .btn-group { display: flex; + justify-content: center; margin-top: 10px; white-space: nowrap; } + + html:root[data-mode=full] #mini { + display: none; + } + html:root[data-mode=full] #full { + display: initial; + } + + .link-group { + display: block; + line-height: 1.5em; + margin: 0 -10px; + padding: 0.5em 10px; + cursor: pointer; + color: #333; + text-decoration: none; + background-color: transparent; + transition: background-color 0.125s linear 0s; + max-width: none; + } + .link-group:hover, + .link-group:active { + color: #333; + text-decoration: none; + } + .link-group:hover>.link-group-label, + .link-group:active>.link-group-label { + text-decoration: underline; + } + .link-group:hover { + background-color: rgba(0, 0, 0, 0.05); + } + .link-group:active { + background-color: rgba(0, 0, 0, 0.1); + } + .link-group-icon { + width: 16px; + height: 16px; + text-align: center; + vertical-align: middle; + display: inline-block; + margin-right: 0.25em; + } + .link-group-icon>input { + margin: 0; + padding: 0; + } + .link-group-icon>.glyphicon { + top: 0; + } + .link-group-label { + vertical-align: middle; + } </style> </head> <body> - <div> - <input type="checkbox" id="enable-search"> + <div id="mini"> + <div> + <input type="checkbox" id="enable-search"> + </div> + <div class="btn-group"> + <a title="Search (Alt + Insert) (Middle click to open in new tab)" class="btn btn-default btn-xs action-open-search"><span class="glyphicon glyphicon-search"></span></a> + <a title="Options (Middle click to open in new tab)" class="btn btn-default btn-xs action-open-options"><span class="glyphicon glyphicon-wrench"></span></a> + <a title="Help" class="btn btn-default btn-xs action-open-help"><span class="glyphicon glyphicon-question-sign"></span></a> + </div> </div> - <div class="btn-group"> - <button type="button" id="open-search" title="Search (Alt + Insert)" class="btn btn-default btn-xs"><span class="glyphicon glyphicon-search"></span></button> - <button type="button" id="open-options" title="Options" class="btn btn-default btn-xs"><span class="glyphicon glyphicon-wrench"></span></button> - <button type="button" id="open-help" title="Help" class="btn btn-default btn-xs"><span class="glyphicon glyphicon-question-sign"></span></button> + <div id="full"> + <h3 id="extension-info">Yomichan</h3> + <label class="link-group"> + <span class="link-group-icon"><input type="checkbox" id="enable-search2" /></span><span class="link-group-label">Enable content scanning</span> + </label> + <a class="link-group action-open-options"> + <span class="link-group-icon"><span class="glyphicon glyphicon-chevron-right"></span></span><span class="link-group-label">Options</span> + </a> + <a class="link-group action-open-search"> + <span class="link-group-icon"><span class="glyphicon glyphicon-chevron-right"></span></span><span class="link-group-label">Search</span> + </a> + <a class="link-group action-open-help"> + <span class="link-group-icon"><span class="glyphicon glyphicon-chevron-right"></span></span><span class="link-group-label">Help</span> + </a> </div> <script src="/mixed/lib/jquery.min.js"></script> diff --git a/ext/bg/css/settings.css b/ext/bg/css/settings.css index 6284058a..b3d5b884 100644 --- a/ext/bg/css/settings.css +++ b/ext/bg/css/settings.css @@ -128,7 +128,8 @@ content: counter(audio-source-id); } -#custom-popup-css { +#custom-popup-css, +#custom-popup-outer-css { width: 100%; min-height: 34px; height: 96px; @@ -137,14 +138,41 @@ white-space: pre; } -[data-show-for-browser] { +.btn-inner-middle { + vertical-align: middle; +} +.storage-button-inner { + pointer-events: none; +} +input[type=checkbox].storage-button-checkbox { + margin: 0 0.375em 0 0; + padding: 0; +} + +#settings-popup-preview-frame { + background-color: transparent; + border: none; + margin: 0; + padding: 0; + width: 100%; + height: 320px; +} + +[data-show-for-browser], +[data-show-for-operating-system] { display: none; } -[data-browser=edge] [data-show-for-browser~=edge], -[data-browser=chrome] [data-show-for-browser~=chrome], -[data-browser=firefox] [data-show-for-browser~=firefox], -[data-browser=firefox-mobile] [data-show-for-browser~=firefox-mobile] { +html:root[data-browser=edge] [data-show-for-browser~=edge], +html:root[data-browser=chrome] [data-show-for-browser~=chrome], +html:root[data-browser=firefox] [data-show-for-browser~=firefox], +html:root[data-browser=firefox-mobile] [data-show-for-browser~=firefox-mobile], +html:root[data-operating-system=mac] [data-show-for-operating-system~=mac], +html:root[data-operating-system=win] [data-show-for-operating-system~=win], +html:root[data-operating-system=android] [data-show-for-operating-system~=android], +html:root[data-operating-system=cros] [data-show-for-operating-system~=cros], +html:root[data-operating-system=linux] [data-show-for-operating-system~=linux], +html:root[data-operating-system=openbsd] [data-show-for-operating-system~=openbsd] { display: initial; } diff --git a/ext/bg/js/api.js b/ext/bg/js/api.js index f768e6f9..93d9c155 100644 --- a/ext/bg/js/api.js +++ b/ext/bg/js/api.js @@ -144,24 +144,46 @@ async function apiTemplateRender(template, data, dynamic) { } } -async function apiCommandExec(command) { +async function apiCommandExec(command, params) { const handlers = apiCommandExec.handlers; if (handlers.hasOwnProperty(command)) { const handler = handlers[command]; - handler(); + handler(params); } } apiCommandExec.handlers = { - search: () => { - chrome.tabs.create({url: chrome.extension.getURL('/bg/search.html')}); + search: async (params) => { + const url = chrome.extension.getURL('/bg/search.html'); + if (!(params && params.newTab)) { + try { + const tab = await apiFindTab(1000, (url2) => ( + url2 !== null && + url2.startsWith(url) && + (url2.length === url.length || url2[url.length] === '?' || url2[url.length] === '#') + )); + if (tab !== null) { + await apiFocusTab(tab); + return; + } + } catch (e) { + // NOP + } + } + chrome.tabs.create({url}); }, help: () => { chrome.tabs.create({url: 'https://foosoft.net/projects/yomichan/'}); }, - options: () => { - chrome.runtime.openOptionsPage(); + options: (params) => { + if (!(params && params.newTab)) { + chrome.runtime.openOptionsPage(); + } else { + const manifest = chrome.runtime.getManifest(); + const url = chrome.extension.getURL(manifest.options_ui.page); + chrome.tabs.create({url}); + } }, toggle: async () => { @@ -176,7 +198,7 @@ apiCommandExec.handlers = { }; async function apiAudioGetUrl(definition, source, optionsContext) { - return audioBuildUrl(definition, source, optionsContext); + return audioGetUrl(definition, source, optionsContext); } async function apiInjectScreenshot(definition, fields, screenshot) { @@ -241,3 +263,141 @@ function apiFrameInformationGet(sender) { const frameId = sender.frameId; return Promise.resolve({frameId}); } + +function apiInjectStylesheet(css, sender) { + if (!sender.tab) { + return Promise.reject(new Error('Invalid tab')); + } + + const tabId = sender.tab.id; + const frameId = sender.frameId; + const details = { + code: css, + runAt: 'document_start', + cssOrigin: 'user', + allFrames: false + }; + if (typeof frameId === 'number') { + details.frameId = frameId; + } + + return new Promise((resolve, reject) => { + chrome.tabs.insertCSS(tabId, details, () => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(); + } + }); + }); +} + +async function apiGetEnvironmentInfo() { + const browser = await apiGetBrowser(); + const platform = await new Promise((resolve) => chrome.runtime.getPlatformInfo(resolve)); + return { + browser, + platform: { + os: platform.os + } + }; +} + +async function apiGetBrowser() { + if (EXTENSION_IS_BROWSER_EDGE) { + return 'edge'; + } + if (typeof browser !== 'undefined') { + try { + const info = await browser.runtime.getBrowserInfo(); + if (info.name === 'Fennec') { + return 'firefox-mobile'; + } + } catch (e) { } + return 'firefox'; + } else { + return 'chrome'; + } +} + +function apiGetTabUrl(tab) { + return new Promise((resolve) => { + chrome.tabs.sendMessage(tab.id, {action: 'getUrl'}, {frameId: 0}, (response) => { + let url = null; + if (!chrome.runtime.lastError) { + url = (response !== null && typeof response === 'object' && !Array.isArray(response) ? response.url : null); + if (url !== null && typeof url !== 'string') { + url = null; + } + } + resolve({tab, url}); + }); + }); +} + +async function apiFindTab(timeout, checkUrl) { + // This function works around the need to have the "tabs" permission to access tab.url. + const tabs = await new Promise((resolve) => chrome.tabs.query({}, resolve)); + let matchPromiseResolve = null; + const matchPromise = new Promise((resolve) => { matchPromiseResolve = resolve; }); + + const checkTabUrl = ({tab, url}) => { + if (checkUrl(url, tab)) { + matchPromiseResolve(tab); + } + }; + + const promises = []; + for (const tab of tabs) { + const promise = apiGetTabUrl(tab); + promise.then(checkTabUrl); + promises.push(promise); + } + + const racePromises = [ + matchPromise, + Promise.all(promises).then(() => null) + ]; + if (typeof timeout === 'number') { + racePromises.push(new Promise((resolve) => setTimeout(() => resolve(null), timeout))); + } + + return await Promise.race(racePromises); +} + +async function apiFocusTab(tab) { + await new Promise((resolve, reject) => { + chrome.tabs.update(tab.id, {active: true}, () => { + const e = chrome.runtime.lastError; + if (e) { reject(e); } + else { resolve(); } + }); + }); + + if (!(typeof chrome.windows === 'object' && chrome.windows !== null)) { + // Windows not supported (e.g. on Firefox mobile) + return; + } + + try { + const tabWindow = await new Promise((resolve) => { + chrome.windows.get(tab.windowId, {}, (tabWindow) => { + const e = chrome.runtime.lastError; + if (e) { reject(e); } + else { resolve(tabWindow); } + }); + }); + if (!tabWindow.focused) { + await new Promise((resolve, reject) => { + chrome.windows.update(tab.windowId, {focused: true}, () => { + const e = chrome.runtime.lastError; + if (e) { reject(e); } + else { resolve(); } + }); + }); + } + } catch (e) { + // Edge throws exception for no reason here. + } +} diff --git a/ext/bg/js/audio.js b/ext/bg/js/audio.js index 9e0ae67c..3efcce46 100644 --- a/ext/bg/js/audio.js +++ b/ext/bg/js/audio.js @@ -86,6 +86,24 @@ const audioUrlBuilders = { throw new Error('Failed to find audio URL'); }, + 'text-to-speech': async (definition, optionsContext) => { + const options = await apiOptionsGet(optionsContext); + const voiceURI = options.audio.textToSpeechVoice; + if (!voiceURI) { + throw new Error('No voice'); + } + + return `tts:?text=${encodeURIComponent(definition.expression)}&voice=${encodeURIComponent(voiceURI)}`; + }, + 'text-to-speech-reading': async (definition, optionsContext) => { + const options = await apiOptionsGet(optionsContext); + const voiceURI = options.audio.textToSpeechVoice; + if (!voiceURI) { + throw new Error('No voice'); + } + + return `tts:?text=${encodeURIComponent(definition.reading || definition.expression)}&voice=${encodeURIComponent(voiceURI)}`; + }, 'custom': async (definition, optionsContext) => { const options = await apiOptionsGet(optionsContext); const customSourceUrl = options.audio.customSourceUrl; @@ -93,20 +111,14 @@ const audioUrlBuilders = { } }; -async function audioBuildUrl(definition, mode, optionsContext, cache={}) { - const cacheKey = `${mode}:${definition.expression}`; - if (cache.hasOwnProperty(cacheKey)) { - return Promise.resolve(cache[cacheKey]); - } - +async function audioGetUrl(definition, mode, optionsContext, download) { if (audioUrlBuilders.hasOwnProperty(mode)) { const handler = audioUrlBuilders[mode]; - return handler(definition, optionsContext).then( - (url) => { - cache[cacheKey] = url; - return url; - }, - () => null); + try { + return await handler(definition, optionsContext, download); + } catch (e) { + // NOP + } } return null; } @@ -163,7 +175,7 @@ async function audioInject(definition, fields, sources, optionsContext) { audioSourceDefinition = definition.expressions[0]; } - const {url} = await audioGetFromSources(audioSourceDefinition, sources, optionsContext, false); + const {url} = await audioGetFromSources(audioSourceDefinition, sources, optionsContext, true); if (url !== null) { const filename = audioBuildFilename(audioSourceDefinition); if (filename !== null) { diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js index 453f4282..f29230a2 100644 --- a/ext/bg/js/backend.js +++ b/ext/bg/js/backend.js @@ -73,9 +73,10 @@ class Backend { if (handlers.hasOwnProperty(action)) { const handler = handlers[action]; const promise = handler(params, sender); - promise - .then(result => callback({result})) - .catch(error => callback(errorToJson(error))); + promise.then( + result => callback({result}), + error => callback({error: errorToJson(error)}) + ); } return true; @@ -180,11 +181,13 @@ Backend.messageHandlers = { definitionsAddable: ({definitions, modes, optionsContext}) => apiDefinitionsAddable(definitions, modes, optionsContext), noteView: ({noteId}) => apiNoteView(noteId), templateRender: ({template, data, dynamic}) => apiTemplateRender(template, data, dynamic), - commandExec: ({command}) => apiCommandExec(command), + commandExec: ({command, params}) => apiCommandExec(command, params), audioGetUrl: ({definition, source, optionsContext}) => apiAudioGetUrl(definition, source, optionsContext), screenshotGet: ({options}, sender) => apiScreenshotGet(options, sender), forward: ({action, params}, sender) => apiForward(action, params, sender), frameInformationGet: (params, sender) => apiFrameInformationGet(sender), + injectStylesheet: ({css}, sender) => apiInjectStylesheet(css, sender), + getEnvironmentInfo: () => apiGetEnvironmentInfo() }; window.yomichan_backend = new Backend(); diff --git a/ext/bg/js/context.js b/ext/bg/js/context.js index 0f88e9c0..8e1dbce6 100644 --- a/ext/bg/js/context.js +++ b/ext/bg/js/context.js @@ -17,10 +17,47 @@ */ +function showExtensionInfo() { + const node = document.getElementById('extension-info'); + if (node === null) { return; } + + const manifest = chrome.runtime.getManifest(); + node.textContent = `${manifest.name} v${manifest.version}`; +} + +function setupButtonEvents(selector, command, url) { + const node = $(selector); + node.on('click', (e) => { + if (e.button !== 0) { return; } + apiCommandExec(command, {newTab: e.ctrlKey}); + e.preventDefault(); + }) + .on('auxclick', (e) => { + if (e.button !== 1) { return; } + apiCommandExec(command, {newTab: true}); + e.preventDefault(); + }); + + if (typeof url === 'string') { + node.attr('href', url); + node.attr('target', '_blank'); + node.attr('rel', 'noopener'); + } +} + $(document).ready(utilAsync(() => { - $('#open-search').click(() => apiCommandExec('search')); - $('#open-options').click(() => apiCommandExec('options')); - $('#open-help').click(() => apiCommandExec('help')); + showExtensionInfo(); + + apiGetEnvironmentInfo().then(({browser}) => { + // Firefox mobile opens this page as a full webpage. + document.documentElement.dataset.mode = (browser === 'firefox-mobile' ? 'full' : 'mini'); + }); + + const manifest = chrome.runtime.getManifest(); + + setupButtonEvents('.action-open-search', 'search', chrome.extension.getURL('/bg/search.html')); + setupButtonEvents('.action-open-options', 'options', chrome.extension.getURL(manifest.options_ui.page)); + setupButtonEvents('.action-open-help', 'help'); const optionsContext = { depth: 0, @@ -31,5 +68,9 @@ $(document).ready(utilAsync(() => { toggle.prop('checked', options.general.enable).change(); toggle.bootstrapToggle(); toggle.change(() => apiCommandExec('toggle')); + + const toggle2 = $('#enable-search2'); + toggle2.prop('checked', options.general.enable).change(); + toggle2.change(() => apiCommandExec('toggle')); }); })); diff --git a/ext/bg/js/database.js b/ext/bg/js/database.js index 771a71c9..9f477b24 100644 --- a/ext/bg/js/database.js +++ b/ext/bg/js/database.js @@ -20,7 +20,6 @@ class Database { constructor() { this.db = null; - this.tagCache = {}; } async prepare() { @@ -53,33 +52,20 @@ class Database { this.db.close(); await this.db.delete(); this.db = null; - this.tagCache = {}; await this.prepare(); } - async findTerms(term, titles) { + async findTermsBulk(termList, titles) { this.validate(); - const results = []; - await this.db.terms.where('expression').equals(term).or('reading').equals(term).each(row => { - if (titles.includes(row.dictionary)) { - results.push(Database.createTerm(row)); - } - }); - - return results; - } - - async findTermsBulk(terms, titles) { const promises = []; const visited = {}; const results = []; - const createResult = Database.createTerm; const processRow = (row, index) => { if (titles.includes(row.dictionary) && !visited.hasOwnProperty(row.id)) { visited[row.id] = true; - results.push(createResult(row, index)); + results.push(Database.createTerm(row, index)); } }; @@ -89,8 +75,8 @@ class Database { const dbIndex1 = dbTerms.index('expression'); const dbIndex2 = dbTerms.index('reading'); - for (let i = 0; i < terms.length; ++i) { - const only = IDBKeyRange.only(terms[i]); + for (let i = 0; i < termList.length; ++i) { + const only = IDBKeyRange.only(termList[i]); promises.push( Database.getAll(dbIndex1, only, i, processRow), Database.getAll(dbIndex2, only, i, processRow) @@ -102,66 +88,50 @@ class Database { return results; } - async findTermsExact(term, reading, titles) { + async findTermsExactBulk(termList, readingList, titles) { this.validate(); + const promises = []; const results = []; - await this.db.terms.where('expression').equals(term).each(row => { - if (row.reading === reading && titles.includes(row.dictionary)) { - results.push(Database.createTerm(row)); + const processRow = (row, index) => { + if (row.reading === readingList[index] && titles.includes(row.dictionary)) { + results.push(Database.createTerm(row, index)); } - }); + }; - return results; - } + const db = this.db.backendDB(); + const dbTransaction = db.transaction(['terms'], 'readonly'); + const dbTerms = dbTransaction.objectStore('terms'); + const dbIndex = dbTerms.index('expression'); - async findTermsBySequence(sequence, mainDictionary) { - this.validate(); + for (let i = 0; i < termList.length; ++i) { + const only = IDBKeyRange.only(termList[i]); + promises.push(Database.getAll(dbIndex, only, i, processRow)); + } - const results = []; - await this.db.terms.where('sequence').equals(sequence).each(row => { - if (row.dictionary === mainDictionary) { - results.push(Database.createTerm(row)); - } - }); + await Promise.all(promises); return results; } - async findTermMeta(term, titles) { + async findTermsBySequenceBulk(sequenceList, mainDictionary) { this.validate(); - const results = []; - await this.db.termMeta.where('expression').equals(term).each(row => { - if (titles.includes(row.dictionary)) { - results.push({ - mode: row.mode, - data: row.data, - dictionary: row.dictionary - }); - } - }); - - return results; - } - - async findTermMetaBulk(terms, titles) { const promises = []; const results = []; - const createResult = Database.createTermMeta; const processRow = (row, index) => { - if (titles.includes(row.dictionary)) { - results.push(createResult(row, index)); + if (row.dictionary === mainDictionary) { + results.push(Database.createTerm(row, index)); } }; const db = this.db.backendDB(); - const dbTransaction = db.transaction(['termMeta'], 'readonly'); - const dbTerms = dbTransaction.objectStore('termMeta'); - const dbIndex = dbTerms.index('expression'); + const dbTransaction = db.transaction(['terms'], 'readonly'); + const dbTerms = dbTransaction.objectStore('terms'); + const dbIndex = dbTerms.index('sequence'); - for (let i = 0; i < terms.length; ++i) { - const only = IDBKeyRange.only(terms[i]); + for (let i = 0; i < sequenceList.length; ++i) { + const only = IDBKeyRange.only(sequenceList[i]); promises.push(Database.getAll(dbIndex, only, i, processRow)); } @@ -170,67 +140,59 @@ class Database { return results; } - async findKanji(kanji, titles) { - this.validate(); + async findTermMetaBulk(termList, titles) { + return this.findGenericBulk('termMeta', 'expression', termList, titles, Database.createMeta); + } - const results = []; - await this.db.kanji.where('character').equals(kanji).each(row => { - if (titles.includes(row.dictionary)) { - results.push({ - character: row.character, - onyomi: dictFieldSplit(row.onyomi), - kunyomi: dictFieldSplit(row.kunyomi), - tags: dictFieldSplit(row.tags), - glossary: row.meanings, - stats: row.stats, - dictionary: row.dictionary - }); - } - }); + async findKanjiBulk(kanjiList, titles) { + return this.findGenericBulk('kanji', 'character', kanjiList, titles, Database.createKanji); + } - return results; + async findKanjiMetaBulk(kanjiList, titles) { + return this.findGenericBulk('kanjiMeta', 'character', kanjiList, titles, Database.createMeta); } - async findKanjiMeta(kanji, titles) { + async findGenericBulk(tableName, indexName, indexValueList, titles, createResult) { this.validate(); + const promises = []; const results = []; - await this.db.kanjiMeta.where('character').equals(kanji).each(row => { + const processRow = (row, index) => { if (titles.includes(row.dictionary)) { - results.push({ - mode: row.mode, - data: row.data, - dictionary: row.dictionary - }); + results.push(createResult(row, index)); } - }); + }; - return results; - } + const db = this.db.backendDB(); + const dbTransaction = db.transaction([tableName], 'readonly'); + const dbTerms = dbTransaction.objectStore(tableName); + const dbIndex = dbTerms.index(indexName); - findTagForTitleCached(name, title) { - if (this.tagCache.hasOwnProperty(title)) { - const cache = this.tagCache[title]; - if (cache.hasOwnProperty(name)) { - return cache[name]; - } + for (let i = 0; i < indexValueList.length; ++i) { + const only = IDBKeyRange.only(indexValueList[i]); + promises.push(Database.getAll(dbIndex, only, i, processRow)); } + + await Promise.all(promises); + + return results; } async findTagForTitle(name, title) { this.validate(); - const cache = (this.tagCache.hasOwnProperty(title) ? this.tagCache[title] : (this.tagCache[title] = {})); - let result = null; - await this.db.tagMeta.where('name').equals(name).each(row => { + const db = this.db.backendDB(); + const dbTransaction = db.transaction(['tagMeta'], 'readonly'); + const dbTerms = dbTransaction.objectStore('tagMeta'); + const dbIndex = dbTerms.index('name'); + const only = IDBKeyRange.only(name); + await Database.getAll(dbIndex, only, null, row => { if (title === row.dictionary) { result = row; } }); - cache[name] = result; - return result; } @@ -522,7 +484,20 @@ class Database { }; } - static createTermMeta(row, index) { + static createKanji(row, index) { + return { + index, + character: row.character, + onyomi: dictFieldSplit(row.onyomi), + kunyomi: dictFieldSplit(row.kunyomi), + tags: dictFieldSplit(row.tags), + glossary: row.meanings, + stats: row.stats, + dictionary: row.dictionary + }; + } + + static createMeta(row, index) { return { index, mode: row.mode, diff --git a/ext/bg/js/dictionary.js b/ext/bg/js/dictionary.js index 498eafcd..191058c1 100644 --- a/ext/bg/js/dictionary.js +++ b/ext/bg/js/dictionary.js @@ -342,10 +342,10 @@ async function dictFieldFormat(field, definition, mode, options) { 'kunyomi', 'onyomi', 'reading', + 'screenshot', 'sentence', 'tags', - 'url', - 'screenshot' + 'url' ]; for (const marker of markers) { diff --git a/ext/bg/js/handlebars.js b/ext/bg/js/handlebars.js index 66d5fa2b..92764a20 100644 --- a/ext/bg/js/handlebars.js +++ b/ext/bg/js/handlebars.js @@ -75,6 +75,10 @@ function handlebarsMultiLine(options) { return options.fn(this).split('\n').join('<br>'); } +function handlebarsSanitizeCssClass(options) { + return options.fn(this).replace(/[^_a-z0-9\u00a0-\uffff]/ig, '_'); +} + function handlebarsRegisterHelpers() { if (Handlebars.partials !== Handlebars.templates) { Handlebars.partials = Handlebars.templates; @@ -83,6 +87,7 @@ function handlebarsRegisterHelpers() { Handlebars.registerHelper('furiganaPlain', handlebarsFuriganaPlain); Handlebars.registerHelper('kanjiLinks', handlebarsKanjiLinks); Handlebars.registerHelper('multiLine', handlebarsMultiLine); + Handlebars.registerHelper('sanitizeCssClass', handlebarsSanitizeCssClass); } } diff --git a/ext/bg/js/options.js b/ext/bg/js/options.js index d0aa6fd3..4854cd65 100644 --- a/ext/bg/js/options.js +++ b/ext/bg/js/options.js @@ -276,15 +276,19 @@ function profileOptionsCreateDefaults() { compactTags: false, compactGlossaries: false, mainDictionary: '', - customPopupCss: '' + popupTheme: 'default', + popupOuterTheme: 'default', + customPopupCss: '', + customPopupOuterCss: '' }, audio: { enabled: true, - sources: ['jpod101', 'jpod101-alternate', 'jisho', 'custom'], + sources: ['jpod101'], volume: 100, autoPlay: false, - customSourceUrl: '' + customSourceUrl: '', + textToSpeechVoice: '' }, scanning: { diff --git a/ext/bg/js/search-frontend.js b/ext/bg/js/search-frontend.js index 0c1a61ea..b21dac17 100644 --- a/ext/bg/js/search-frontend.js +++ b/ext/bg/js/search-frontend.js @@ -25,11 +25,14 @@ async function searchFrontendSetup() { const options = await apiOptionsGet(optionsContext); if (!options.scanning.enableOnSearchPage) { return; } + window.frontendInitializationData = {depth: 1, proxy: false}; + const scriptSrcs = [ '/fg/js/frontend-api-receiver.js', '/fg/js/popup.js', '/fg/js/popup-proxy-host.js', - '/fg/js/frontend.js' + '/fg/js/frontend.js', + '/fg/js/frontend-initialize.js' ]; for (const src of scriptSrcs) { const script = document.createElement('script'); diff --git a/ext/bg/js/search.js b/ext/bg/js/search.js index ead9ba6f..431478c9 100644 --- a/ext/bg/js/search.js +++ b/ext/bg/js/search.js @@ -31,25 +31,37 @@ class DisplaySearch extends Display { this.intro = document.querySelector('#intro'); this.introVisible = true; this.introAnimationTimer = null; + } - this.dependencies = Object.assign({}, this.dependencies, {docRangeFromPoint, docSentenceExtract}); + static create() { + const instance = new DisplaySearch(); + instance.prepare(); + return instance; + } - if (this.search !== null) { - this.search.addEventListener('click', (e) => this.onSearch(e), false); - } - if (this.query !== null) { - this.query.addEventListener('input', () => this.onSearchInput(), false); + async prepare() { + try { + await this.initialize(); - const query = DisplaySearch.getSearchQueryFromLocation(window.location.href); - if (query !== null) { - this.query.value = window.wanakana.toKana(query); - this.onSearchQueryUpdated(query, false); + if (this.search !== null) { + this.search.addEventListener('click', (e) => this.onSearch(e), false); } + if (this.query !== null) { + this.query.addEventListener('input', () => this.onSearchInput(), false); - window.wanakana.bind(this.query); - } + const query = DisplaySearch.getSearchQueryFromLocation(window.location.href); + if (query !== null) { + this.query.value = window.wanakana.toKana(query); + this.onSearchQueryUpdated(query, false); + } - this.updateSearchButton(); + window.wanakana.bind(this.query); + } + + this.updateSearchButton(); + } catch (e) { + this.onError(e); + } } onError(error) { @@ -89,7 +101,11 @@ class DisplaySearch extends Display { this.updateSearchButton(); if (valid) { const {definitions} = await apiTermsFind(query, this.optionsContext); - this.termsShow(definitions, await apiOptionsGet(this.optionsContext)); + this.setContentTerms(definitions, { + focus: false, + sentence: null, + url: window.location.href + }); } else { this.container.textContent = ''; } @@ -98,6 +114,25 @@ class DisplaySearch extends Display { } } + onRuntimeMessage({action, params}, sender, callback) { + const handlers = DisplaySearch.runtimeMessageHandlers; + if (handlers.hasOwnProperty(action)) { + const handler = handlers[action]; + const result = handler(this, params); + callback(result); + } else { + return super.onRuntimeMessage({action, params}, sender, callback); + } + } + + getOptionsContext() { + return this.optionsContext; + } + + setCustomCss() { + // No custom CSS + } + setIntroVisible(visible, animate) { if (this.introVisible === visible) { return; @@ -164,4 +199,10 @@ class DisplaySearch extends Display { } } -window.yomichan_search = new DisplaySearch(); +DisplaySearch.runtimeMessageHandlers = { + getUrl: () => { + return {url: window.location.href}; + } +}; + +window.yomichan_search = DisplaySearch.create(); diff --git a/ext/bg/js/settings-popup-preview.js b/ext/bg/js/settings-popup-preview.js new file mode 100644 index 00000000..b12fb726 --- /dev/null +++ b/ext/bg/js/settings-popup-preview.js @@ -0,0 +1,183 @@ +/* + * Copyright (C) 2019 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 SettingsPopupPreview { + constructor() { + this.frontend = null; + this.apiOptionsGetOld = apiOptionsGet; + this.popupInjectOuterStylesheetOld = Popup.injectOuterStylesheet; + this.popupShown = false; + this.themeChangeTimeout = null; + } + + static create() { + const instance = new SettingsPopupPreview(); + instance.prepare(); + return instance; + } + + async prepare() { + // Setup events + window.addEventListener('resize', (e) => this.onWindowResize(e), false); + window.addEventListener('message', (e) => this.onMessage(e), false); + + const themeDarkCheckbox = document.querySelector('#theme-dark-checkbox'); + if (themeDarkCheckbox !== null) { + themeDarkCheckbox.addEventListener('change', () => this.onThemeDarkCheckboxChanged(themeDarkCheckbox), false); + } + + // Overwrite API functions + window.apiOptionsGet = (...args) => this.apiOptionsGet(...args); + + // Overwrite frontend + this.frontend = Frontend.create(); + window.yomichan_frontend = this.frontend; + + this.frontend.setEnabled = function () {}; + this.frontend.searchClear = function () {}; + + this.frontend.popup.childrenSupported = false; + this.frontend.popup.interactive = false; + + await this.frontend.isPrepared(); + + // Overwrite popup + Popup.injectOuterStylesheet = (...args) => this.popupInjectOuterStylesheet(...args); + + // Update search + this.updateSearch(); + } + + async apiOptionsGet(...args) { + const options = await this.apiOptionsGetOld(...args); + options.general.enable = true; + options.general.debugInfo = false; + options.general.popupWidth = 400; + options.general.popupHeight = 250; + options.general.popupHorizontalOffset = 0; + options.general.popupVerticalOffset = 10; + options.general.popupHorizontalOffset2 = 10; + options.general.popupVerticalOffset2 = 0; + options.general.popupHorizontalTextPosition = 'below'; + options.general.popupVerticalTextPosition = 'before'; + options.scanning.selectText = false; + return options; + } + + popupInjectOuterStylesheet(...args) { + // This simulates the stylesheet priorities when injecting using the web extension API. + const result = this.popupInjectOuterStylesheetOld(...args); + + const outerStylesheet = Popup.outerStylesheet; + const node = document.querySelector('#client-css'); + if (node !== null && outerStylesheet !== null) { + node.parentNode.insertBefore(outerStylesheet, node); + } + + return result; + } + + onWindowResize() { + if (this.frontend === null) { return; } + const textSource = this.frontend.textSourceLast; + if (textSource === null) { return; } + + const elementRect = textSource.getRect(); + const writingMode = textSource.getWritingMode(); + this.frontend.popup.showContent(elementRect, writingMode); + } + + onMessage(e) { + const {action, params} = e.data; + const handlers = SettingsPopupPreview.messageHandlers; + if (handlers.hasOwnProperty(action)) { + const handler = handlers[action]; + handler(this, params); + } + } + + onThemeDarkCheckboxChanged(node) { + document.documentElement.classList.toggle('dark', node.checked); + if (this.themeChangeTimeout !== null) { + clearTimeout(this.themeChangeTimeout); + } + this.themeChangeTimeout = setTimeout(() => { + this.themeChangeTimeout = null; + this.frontend.popup.updateTheme(); + }, 300); + } + + setText(text) { + const exampleText = document.querySelector('#example-text'); + if (exampleText === null) { return; } + + exampleText.textContent = text; + this.updateSearch(); + } + + setInfoVisible(visible) { + const node = document.querySelector('.placeholder-info'); + if (node === null) { return; } + + node.classList.toggle('placeholder-info-visible', visible); + } + + setCustomCss(css) { + if (this.frontend === null) { return; } + this.frontend.popup.setCustomCss(css); + } + + setCustomOuterCss(css) { + if (this.frontend === null) { return; } + this.frontend.popup.setCustomOuterCss(css, true); + } + + async updateSearch() { + const exampleText = document.querySelector('#example-text'); + if (exampleText === null) { return; } + + const textNode = exampleText.firstChild; + if (textNode === null) { return; } + + const range = document.createRange(); + range.selectNode(textNode); + const source = new TextSourceRange(range, range.toString(), null); + + this.frontend.textSourceLast = null; + await this.frontend.searchSource(source, 'script'); + await this.frontend.lastShowPromise; + + if (this.frontend.popup.isVisible()) { + this.popupShown = true; + } + + this.setInfoVisible(!this.popupShown); + } +} + +SettingsPopupPreview.messageHandlers = { + setText: (self, {text}) => self.setText(text), + setCustomCss: (self, {css}) => self.setCustomCss(css), + setCustomOuterCss: (self, {css}) => self.setCustomOuterCss(css) +}; + +SettingsPopupPreview.instance = SettingsPopupPreview.create(); + + + diff --git a/ext/bg/js/settings.js b/ext/bg/js/settings.js index f3b5ff16..05a0604a 100644 --- a/ext/bg/js/settings.js +++ b/ext/bg/js/settings.js @@ -39,12 +39,16 @@ async function formRead(options) { options.general.popupVerticalOffset = parseInt($('#popup-vertical-offset').val(), 10); options.general.popupHorizontalOffset2 = parseInt($('#popup-horizontal-offset2').val(), 0); options.general.popupVerticalOffset2 = parseInt($('#popup-vertical-offset2').val(), 10); + options.general.popupTheme = $('#popup-theme').val(); + options.general.popupOuterTheme = $('#popup-outer-theme').val(); options.general.customPopupCss = $('#custom-popup-css').val(); + options.general.customPopupOuterCss = $('#custom-popup-outer-css').val(); options.audio.enabled = $('#audio-playback-enabled').prop('checked'); options.audio.autoPlay = $('#auto-play-audio').prop('checked'); options.audio.volume = parseFloat($('#audio-playback-volume').val()); options.audio.customSourceUrl = $('#audio-custom-source').val(); + options.audio.textToSpeechVoice = $('#text-to-speech-voice').val(); options.scanning.middleMouse = $('#middle-mouse-button-scan').prop('checked'); options.scanning.touchInputEnabled = $('#touch-input-enabled').prop('checked'); @@ -107,12 +111,16 @@ async function formWrite(options) { $('#popup-vertical-offset').val(options.general.popupVerticalOffset); $('#popup-horizontal-offset2').val(options.general.popupHorizontalOffset2); $('#popup-vertical-offset2').val(options.general.popupVerticalOffset2); + $('#popup-theme').val(options.general.popupTheme); + $('#popup-outer-theme').val(options.general.popupOuterTheme); $('#custom-popup-css').val(options.general.customPopupCss); + $('#custom-popup-outer-css').val(options.general.customPopupOuterCss); $('#audio-playback-enabled').prop('checked', options.audio.enabled); $('#auto-play-audio').prop('checked', options.audio.autoPlay); $('#audio-playback-volume').val(options.audio.volume); $('#audio-custom-source').val(options.audio.customSourceUrl); + $('#text-to-speech-voice').val(options.audio.textToSpeechVoice).attr('data-value', options.audio.textToSpeechVoice); $('#middle-mouse-button-scan').prop('checked', options.scanning.middleMouse); $('#touch-input-enabled').prop('checked', options.scanning.touchInputEnabled); @@ -248,6 +256,7 @@ async function onReady() { showExtensionInformation(); formSetupEventListeners(); + appearanceInitialize(); await audioSettingsInitialize(); await profileOptionsSetup(); @@ -260,6 +269,55 @@ $(document).ready(utilAsync(onReady)); /* + * Appearance + */ + +function appearanceInitialize() { + let previewVisible = false; + $('#settings-popup-preview-button').on('click', () => { + if (previewVisible) { return; } + showAppearancePreview(); + previewVisible = true; + }); +} + +function showAppearancePreview() { + const container = $('#settings-popup-preview-container'); + const buttonContainer = $('#settings-popup-preview-button-container'); + const settings = $('#settings-popup-preview-settings'); + const text = $('#settings-popup-preview-text'); + const customCss = $('#custom-popup-css'); + const customOuterCss = $('#custom-popup-outer-css'); + + const frame = document.createElement('iframe'); + frame.src = '/bg/settings-popup-preview.html'; + frame.id = 'settings-popup-preview-frame'; + + window.wanakana.bind(text[0]); + + text.on('input', () => { + const action = 'setText'; + const params = {text: text.val()}; + frame.contentWindow.postMessage({action, params}, '*'); + }); + customCss.on('input', () => { + const action = 'setCustomCss'; + const params = {css: customCss.val()}; + frame.contentWindow.postMessage({action, params}, '*'); + }); + customOuterCss.on('input', () => { + const action = 'setCustomOuterCss'; + const params = {css: customOuterCss.val()}; + frame.contentWindow.postMessage({action, params}, '*'); + }); + + container.append(frame); + buttonContainer.remove(); + settings.css('display', ''); +} + + +/* * Audio */ @@ -270,6 +328,81 @@ async function audioSettingsInitialize() { const options = await apiOptionsGet(optionsContext); audioSourceUI = new AudioSourceUI.Container(options.audio.sources, $('.audio-source-list'), $('.audio-source-add')); audioSourceUI.save = () => apiOptionsSave(); + + textToSpeechInitialize(); +} + +function textToSpeechInitialize() { + if (typeof speechSynthesis === 'undefined') { return; } + + speechSynthesis.addEventListener('voiceschanged', () => updateTextToSpeechVoices(), false); + updateTextToSpeechVoices(); + + $('#text-to-speech-voice-test').on('click', () => textToSpeechTest()); +} + +function updateTextToSpeechVoices() { + const voices = Array.prototype.map.call(speechSynthesis.getVoices(), (voice, index) => ({voice, index})); + voices.sort(textToSpeechVoiceCompare); + if (voices.length > 0) { + $('#text-to-speech-voice-container').css('display', ''); + } + + const select = $('#text-to-speech-voice'); + select.empty(); + select.append($('<option>').val('').text('None')); + for (const {voice} of voices) { + select.append($('<option>').val(voice.voiceURI).text(`${voice.name} (${voice.lang})`)); + } + + select.val(select.attr('data-value')); +} + +function languageTagIsJapanese(languageTag) { + return ( + languageTag.startsWith('ja-') || + languageTag.startsWith('jpn-') + ); +} + +function textToSpeechVoiceCompare(a, b) { + const aIsJapanese = languageTagIsJapanese(a.voice.lang); + const bIsJapanese = languageTagIsJapanese(b.voice.lang); + if (aIsJapanese) { + if (!bIsJapanese) { return -1; } + } else { + if (bIsJapanese) { return 1; } + } + + const aIsDefault = a.voice.default; + const bIsDefault = b.voice.default; + if (aIsDefault) { + if (!bIsDefault) { return -1; } + } else { + if (bIsDefault) { return 1; } + } + + if (a.index < b.index) { return -1; } + if (a.index > b.index) { return 1; } + return 0; +} + +function textToSpeechTest() { + try { + const text = $('#text-to-speech-voice-test').attr('data-speech-text') || ''; + const voiceURI = $('#text-to-speech-voice').val(); + const voice = audioGetTextToSpeechVoice(voiceURI); + if (voice === null) { return; } + + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = 'ja-JP'; + utterance.voice = voice; + utterance.volume = 1.0; + + speechSynthesis.speak(utterance); + } catch (e) { + // NOP + } } @@ -297,9 +430,14 @@ async function onOptionsUpdate({source}) { await formWrite(options); } -function onMessage({action, params}) { - if (action === 'optionsUpdate') { - onOptionsUpdate(params); +function onMessage({action, params}, sender, callback) { + switch (action) { + case 'optionsUpdate': + onOptionsUpdate(params); + break; + case 'getUrl': + callback({url: window.location.href}); + break; } } @@ -607,10 +745,10 @@ async function ankiFieldsPopulate(element, options) { 'glossary', 'glossary-brief', 'reading', + 'screenshot', 'sentence', 'tags', - 'url', - 'screenshot' + 'url' ], 'kanji': [ 'character', @@ -618,6 +756,7 @@ async function ankiFieldsPopulate(element, options) { 'glossary', 'kunyomi', 'onyomi', + 'screenshot', 'sentence', 'tags', 'url' @@ -685,32 +824,15 @@ async function onAnkiFieldTemplatesReset(e) { * Storage */ -async function getBrowser() { - if (EXTENSION_IS_BROWSER_EDGE) { - return 'edge'; - } - if (typeof browser !== 'undefined') { - try { - const info = await browser.runtime.getBrowserInfo(); - if (info.name === 'Fennec') { - return 'firefox-mobile'; - } - } catch (e) { } - return 'firefox'; - } else { - return 'chrome'; - } -} - function storageBytesToLabeledString(size) { const base = 1000; - const labels = ['bytes', 'KB', 'MB', 'GB']; + const labels = [' bytes', 'KB', 'MB', 'GB']; let labelIndex = 0; while (size >= base) { size /= base; ++labelIndex; } - const label = size.toFixed(1); + const label = labelIndex === 0 ? `${size}` : size.toFixed(1); return `${label}${labels[labelIndex]}`; } @@ -722,15 +844,21 @@ async function storageEstimate() { } storageEstimate.mostRecent = null; +async function isStoragePeristent() { + try { + return await navigator.storage.persisted(); + } catch (e) { } + return false; +} + async function storageInfoInitialize() { - const browser = await getBrowser(); - const container = document.querySelector('#storage-info'); - container.setAttribute('data-browser', browser); + storagePersistInitialize(); + const {browser, platform} = await apiGetEnvironmentInfo(); + document.documentElement.dataset.browser = browser; + document.documentElement.dataset.operatingSystem = platform.os; await storageShowInfo(); - container.classList.remove('storage-hidden'); - document.querySelector('#storage-refresh').addEventListener('click', () => storageShowInfo(), false); } @@ -741,8 +869,14 @@ async function storageUpdateStats() { const valid = (estimate !== null); if (valid) { - document.querySelector('#storage-usage').textContent = storageBytesToLabeledString(estimate.usage); - document.querySelector('#storage-quota').textContent = storageBytesToLabeledString(estimate.quota); + // Firefox reports usage as 0 when persistent storage is enabled. + const finite = (estimate.usage > 0 || !(await isStoragePeristent())); + if (finite) { + document.querySelector('#storage-usage').textContent = storageBytesToLabeledString(estimate.usage); + document.querySelector('#storage-quota').textContent = storageBytesToLabeledString(estimate.quota); + } + document.querySelector('#storage-use-finite').classList.toggle('storage-hidden', !finite); + document.querySelector('#storage-use-infinite').classList.toggle('storage-hidden', finite); } storageUpdateStats.isUpdating = false; @@ -769,6 +903,43 @@ function storageSpinnerShow(show) { } } +async function storagePersistInitialize() { + if (!(navigator.storage && navigator.storage.persist)) { + // Not supported + return; + } + + const info = document.querySelector('#storage-persist-info'); + const button = document.querySelector('#storage-persist-button'); + const checkbox = document.querySelector('#storage-persist-button-checkbox'); + + info.classList.remove('storage-hidden'); + button.classList.remove('storage-hidden'); + + let persisted = await isStoragePeristent(); + checkbox.checked = persisted; + + button.addEventListener('click', async () => { + if (persisted) { + return; + } + let result = false; + try { + result = await navigator.storage.persist(); + } catch (e) { + // NOP + } + + if (result) { + persisted = true; + checkbox.checked = true; + storageShowInfo(); + } else { + $('.storage-persist-fail-warning').removeClass('storage-hidden'); + } + }, false); +} + /* * Information diff --git a/ext/bg/js/templates.js b/ext/bg/js/templates.js index c61f5d7f..59516d97 100644 --- a/ext/bg/js/templates.js +++ b/ext/bg/js/templates.js @@ -208,148 +208,157 @@ templates['model.html'] = template({"1":function(container,depth0,helpers,partia + " </ul>\n </div>\n </div>\n </td>\n</tr>\n"; },"useData":true}); templates['terms.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.definitionTags : depth0),{"name":"if","hash":{},"fn":container.program(2, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") - + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.only : depth0),{"name":"if","hash":{},"fn":container.program(7, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") - + ((stack1 = helpers["if"].call(alias1,((stack1 = (depth0 != null ? depth0.glossary : depth0)) != null ? stack1["1"] : stack1),{"name":"if","hash":{},"fn":container.program(11, data, 0),"inverse":container.program(17, data, 0),"data":data})) != null ? stack1 : ""); + var stack1, helper, options, alias1=depth0 != null ? depth0 : (container.nullContext || {}), buffer = + "<div class=\"dict-"; + stack1 = ((helper = (helper = helpers.sanitizeCssClass || (depth0 != null ? depth0.sanitizeCssClass : depth0)) != null ? helper : helpers.helperMissing),(options={"name":"sanitizeCssClass","hash":{},"fn":container.program(2, data, 0),"inverse":container.noop,"data":data}),(typeof helper === "function" ? helper.call(alias1,options) : helper)); + if (!helpers.sanitizeCssClass) { stack1 = helpers.blockHelperMissing.call(depth0,stack1,options)} + if (stack1 != null) { buffer += stack1; } + return buffer + "\">\n" + + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.definitionTags : depth0),{"name":"if","hash":{},"fn":container.program(4, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") + + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.only : depth0),{"name":"if","hash":{},"fn":container.program(9, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") + + ((stack1 = helpers["if"].call(alias1,((stack1 = (depth0 != null ? depth0.glossary : depth0)) != null ? stack1["1"] : stack1),{"name":"if","hash":{},"fn":container.program(13, data, 0),"inverse":container.program(19, data, 0),"data":data})) != null ? stack1 : "") + + "</div>\n"; },"2":function(container,depth0,helpers,partials,data) { + var helper; + + return container.escapeExpression(((helper = (helper = helpers.dictionary || (depth0 != null ? depth0.dictionary : depth0)) != null ? helper : helpers.helperMissing),(typeof helper === "function" ? helper.call(depth0 != null ? depth0 : (container.nullContext || {}),{"name":"dictionary","hash":{},"data":data}) : helper))); +},"4":function(container,depth0,helpers,partials,data) { var stack1, alias1=depth0 != null ? depth0 : (container.nullContext || {}); - return "<div " - + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.compactGlossaries : depth0),{"name":"if","hash":{},"fn":container.program(3, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") + return " <div " + + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.compactGlossaries : depth0),{"name":"if","hash":{},"fn":container.program(5, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") + ">\n" - + ((stack1 = helpers.each.call(alias1,(depth0 != null ? depth0.definitionTags : depth0),{"name":"each","hash":{},"fn":container.program(5, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") - + "</div>\n"; -},"3":function(container,depth0,helpers,partials,data) { - return "class=\"compact-info\""; + + ((stack1 = helpers.each.call(alias1,(depth0 != null ? depth0.definitionTags : depth0),{"name":"each","hash":{},"fn":container.program(7, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") + + " </div>\n"; },"5":function(container,depth0,helpers,partials,data) { + return "class=\"compact-info\""; +},"7":function(container,depth0,helpers,partials,data) { var helper, alias1=depth0 != null ? depth0 : (container.nullContext || {}), alias2=helpers.helperMissing, alias3="function", alias4=container.escapeExpression; - return " <span class=\"label label-default tag-" + return " <span class=\"label label-default tag-" + alias4(((helper = (helper = helpers.category || (depth0 != null ? depth0.category : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"category","hash":{},"data":data}) : helper))) + "\" title=\"" + alias4(((helper = (helper = helpers.notes || (depth0 != null ? depth0.notes : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"notes","hash":{},"data":data}) : helper))) + "\">" + alias4(((helper = (helper = helpers.name || (depth0 != null ? depth0.name : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"name","hash":{},"data":data}) : helper))) + "</span>\n"; -},"7":function(container,depth0,helpers,partials,data) { +},"9":function(container,depth0,helpers,partials,data) { var stack1, alias1=depth0 != null ? depth0 : (container.nullContext || {}); - return "<div " - + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.compactGlossaries : depth0),{"name":"if","hash":{},"fn":container.program(3, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") - + ">\n (" - + ((stack1 = helpers.each.call(alias1,(depth0 != null ? depth0.only : depth0),{"name":"each","hash":{},"fn":container.program(8, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") - + " only)\n</div>\n"; -},"8":function(container,depth0,helpers,partials,data) { + return " <div " + + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.compactGlossaries : depth0),{"name":"if","hash":{},"fn":container.program(5, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") + + ">\n (" + + ((stack1 = helpers.each.call(alias1,(depth0 != null ? depth0.only : depth0),{"name":"each","hash":{},"fn":container.program(10, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") + + " only)\n </div>\n"; +},"10":function(container,depth0,helpers,partials,data) { var stack1; return ((stack1 = container.lambda(depth0, depth0)) != null ? stack1 : "") - + ((stack1 = helpers.unless.call(depth0 != null ? depth0 : (container.nullContext || {}),(data && data.last),{"name":"unless","hash":{},"fn":container.program(9, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") + + ((stack1 = helpers.unless.call(depth0 != null ? depth0 : (container.nullContext || {}),(data && data.last),{"name":"unless","hash":{},"fn":container.program(11, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") + "\n"; -},"9":function(container,depth0,helpers,partials,data) { - return ", "; },"11":function(container,depth0,helpers,partials,data) { + return ", "; +},"13":function(container,depth0,helpers,partials,data) { var stack1, alias1=depth0 != null ? depth0 : (container.nullContext || {}); - return "<ul " - + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.compactGlossaries : depth0),{"name":"if","hash":{},"fn":container.program(12, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") + return " <ul " + + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.compactGlossaries : depth0),{"name":"if","hash":{},"fn":container.program(14, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") + ">\n" - + ((stack1 = helpers.each.call(alias1,(depth0 != null ? depth0.glossary : depth0),{"name":"each","hash":{},"fn":container.program(14, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") - + "</ul>\n"; -},"12":function(container,depth0,helpers,partials,data) { - return "class=\"compact-glossary\""; + + ((stack1 = helpers.each.call(alias1,(depth0 != null ? depth0.glossary : depth0),{"name":"each","hash":{},"fn":container.program(16, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") + + " </ul>\n"; },"14":function(container,depth0,helpers,partials,data) { + return "class=\"compact-glossary\""; +},"16":function(container,depth0,helpers,partials,data) { var stack1, helper, options, buffer = - " <li><span class=\"glossary-item\">"; - stack1 = ((helper = (helper = helpers.multiLine || (depth0 != null ? depth0.multiLine : depth0)) != null ? helper : helpers.helperMissing),(options={"name":"multiLine","hash":{},"fn":container.program(15, data, 0),"inverse":container.noop,"data":data}),(typeof helper === "function" ? helper.call(depth0 != null ? depth0 : (container.nullContext || {}),options) : helper)); + " <li><span class=\"glossary-item\">"; + stack1 = ((helper = (helper = helpers.multiLine || (depth0 != null ? depth0.multiLine : depth0)) != null ? helper : helpers.helperMissing),(options={"name":"multiLine","hash":{},"fn":container.program(17, data, 0),"inverse":container.noop,"data":data}),(typeof helper === "function" ? helper.call(depth0 != null ? depth0 : (container.nullContext || {}),options) : helper)); if (!helpers.multiLine) { stack1 = helpers.blockHelperMissing.call(depth0,stack1,options)} if (stack1 != null) { buffer += stack1; } return buffer + "</span></li>\n"; -},"15":function(container,depth0,helpers,partials,data) { - return container.escapeExpression(container.lambda(depth0, depth0)); },"17":function(container,depth0,helpers,partials,data) { + return container.escapeExpression(container.lambda(depth0, depth0)); +},"19":function(container,depth0,helpers,partials,data) { var stack1, helper, options, alias1=depth0 != null ? depth0 : (container.nullContext || {}), buffer = - "<div class=\"glossary-item " - + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.compactGlossaries : depth0),{"name":"if","hash":{},"fn":container.program(18, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") + " <div class=\"glossary-item " + + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.compactGlossaries : depth0),{"name":"if","hash":{},"fn":container.program(20, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") + "\">"; - stack1 = ((helper = (helper = helpers.multiLine || (depth0 != null ? depth0.multiLine : depth0)) != null ? helper : helpers.helperMissing),(options={"name":"multiLine","hash":{},"fn":container.program(20, data, 0),"inverse":container.noop,"data":data}),(typeof helper === "function" ? helper.call(alias1,options) : helper)); + stack1 = ((helper = (helper = helpers.multiLine || (depth0 != null ? depth0.multiLine : depth0)) != null ? helper : helpers.helperMissing),(options={"name":"multiLine","hash":{},"fn":container.program(22, data, 0),"inverse":container.noop,"data":data}),(typeof helper === "function" ? helper.call(alias1,options) : helper)); if (!helpers.multiLine) { stack1 = helpers.blockHelperMissing.call(depth0,stack1,options)} if (stack1 != null) { buffer += stack1; } return buffer + "</div>\n"; -},"18":function(container,depth0,helpers,partials,data) { - return "compact-glossary"; },"20":function(container,depth0,helpers,partials,data) { + return "compact-glossary"; +},"22":function(container,depth0,helpers,partials,data) { var stack1; return container.escapeExpression(container.lambda(((stack1 = (depth0 != null ? depth0.glossary : depth0)) != null ? stack1["0"] : stack1), depth0)); -},"22":function(container,depth0,helpers,partials,data,blockParams,depths) { +},"24":function(container,depth0,helpers,partials,data,blockParams,depths) { var stack1, alias1=depth0 != null ? depth0 : (container.nullContext || {}); return "<div class=\"entry\" data-type=\"term\">\n <div class=\"actions\">\n" - + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.addable : depth0),{"name":"if","hash":{},"fn":container.program(23, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : "") - + ((stack1 = helpers.unless.call(alias1,(depth0 != null ? depth0.merged : depth0),{"name":"unless","hash":{},"fn":container.program(25, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : "") - + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.source : depth0),{"name":"if","hash":{},"fn":container.program(28, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : "") + + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.addable : depth0),{"name":"if","hash":{},"fn":container.program(25, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : "") + + ((stack1 = helpers.unless.call(alias1,(depth0 != null ? depth0.merged : depth0),{"name":"unless","hash":{},"fn":container.program(27, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : "") + + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.source : depth0),{"name":"if","hash":{},"fn":container.program(30, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : "") + " <img src=\"/mixed/img/entry-current.svg\" class=\"current\" title=\"Current entry (Alt + Up/Down/Home/End/PgUp/PgDn)\" alt>\n </div>\n\n" - + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.merged : depth0),{"name":"if","hash":{},"fn":container.program(30, data, 0, blockParams, depths),"inverse":container.program(45, data, 0, blockParams, depths),"data":data})) != null ? stack1 : "") + + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.merged : depth0),{"name":"if","hash":{},"fn":container.program(32, data, 0, blockParams, depths),"inverse":container.program(47, data, 0, blockParams, depths),"data":data})) != null ? stack1 : "") + "\n" - + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.reasons : depth0),{"name":"if","hash":{},"fn":container.program(49, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : "") + + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.reasons : depth0),{"name":"if","hash":{},"fn":container.program(50, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : "") + "\n" - + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.frequencies : depth0),{"name":"if","hash":{},"fn":container.program(53, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : "") + + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.frequencies : depth0),{"name":"if","hash":{},"fn":container.program(54, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : "") + "\n <div class=\"glossary\">\n" - + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.grouped : depth0),{"name":"if","hash":{},"fn":container.program(56, data, 0, blockParams, depths),"inverse":container.program(62, data, 0, blockParams, depths),"data":data})) != null ? stack1 : "") + + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.grouped : depth0),{"name":"if","hash":{},"fn":container.program(57, data, 0, blockParams, depths),"inverse":container.program(63, data, 0, blockParams, depths),"data":data})) != null ? stack1 : "") + " </div>\n\n" - + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.debug : depth0),{"name":"if","hash":{},"fn":container.program(65, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : "") + + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.debug : depth0),{"name":"if","hash":{},"fn":container.program(66, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : "") + "</div>\n"; -},"23":function(container,depth0,helpers,partials,data) { - return " <a href=\"#\" class=\"action-view-note pending disabled\"><img src=\"/mixed/img/view-note.svg\" title=\"View added note (Alt + V)\" alt></a>\n <a href=\"#\" class=\"action-add-note pending disabled\" data-mode=\"term-kanji\"><img src=\"/mixed/img/add-term-kanji.svg\" title=\"Add expression (Alt + E)\" alt></a>\n <a href=\"#\" class=\"action-add-note pending disabled\" data-mode=\"term-kana\"><img src=\"/mixed/img/add-term-kana.svg\" title=\"Add reading (Alt + R)\" alt></a>\n"; },"25":function(container,depth0,helpers,partials,data) { + return " <a href=\"#\" class=\"action-view-note pending disabled\"><img src=\"/mixed/img/view-note.svg\" title=\"View added note (Alt + V)\" alt></a>\n <a href=\"#\" class=\"action-add-note pending disabled\" data-mode=\"term-kanji\"><img src=\"/mixed/img/add-term-kanji.svg\" title=\"Add expression (Alt + E)\" alt></a>\n <a href=\"#\" class=\"action-add-note pending disabled\" data-mode=\"term-kana\"><img src=\"/mixed/img/add-term-kana.svg\" title=\"Add reading (Alt + R)\" alt></a>\n"; +},"27":function(container,depth0,helpers,partials,data) { var stack1; - return ((stack1 = helpers["if"].call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.playback : depth0),{"name":"if","hash":{},"fn":container.program(26, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : ""); -},"26":function(container,depth0,helpers,partials,data) { - return " <a href=\"#\" class=\"action-play-audio\"><img src=\"/mixed/img/play-audio.svg\" title=\"Play audio (Alt + P)\" alt></a>\n"; + return ((stack1 = helpers["if"].call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.playback : depth0),{"name":"if","hash":{},"fn":container.program(28, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : ""); },"28":function(container,depth0,helpers,partials,data) { + return " <a href=\"#\" class=\"action-play-audio\"><img src=\"/mixed/img/play-audio.svg\" title=\"Play audio (Alt + P)\" alt></a>\n"; +},"30":function(container,depth0,helpers,partials,data) { return " <a href=\"#\" class=\"source-term\"><img src=\"/mixed/img/source-term.svg\" title=\"Source term (Alt + B)\" alt></a>\n"; -},"30":function(container,depth0,helpers,partials,data,blockParams,depths) { +},"32":function(container,depth0,helpers,partials,data,blockParams,depths) { var stack1; - return ((stack1 = helpers.each.call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.expressions : depth0),{"name":"each","hash":{},"fn":container.program(31, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : ""); -},"31":function(container,depth0,helpers,partials,data,blockParams,depths) { + return ((stack1 = helpers.each.call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.expressions : depth0),{"name":"each","hash":{},"fn":container.program(33, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : ""); +},"33":function(container,depth0,helpers,partials,data,blockParams,depths) { var stack1, helper, options, alias1=depth0 != null ? depth0 : (container.nullContext || {}), alias2=helpers.helperMissing, alias3="function", buffer = "<div class=\"expression\"><span class=\"expression-" + container.escapeExpression(((helper = (helper = helpers.termFrequency || (depth0 != null ? depth0.termFrequency : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"termFrequency","hash":{},"data":data}) : helper))) + "\">"; - stack1 = ((helper = (helper = helpers.kanjiLinks || (depth0 != null ? depth0.kanjiLinks : depth0)) != null ? helper : alias2),(options={"name":"kanjiLinks","hash":{},"fn":container.program(32, data, 0, blockParams, depths),"inverse":container.noop,"data":data}),(typeof helper === alias3 ? helper.call(alias1,options) : helper)); + stack1 = ((helper = (helper = helpers.kanjiLinks || (depth0 != null ? depth0.kanjiLinks : depth0)) != null ? helper : alias2),(options={"name":"kanjiLinks","hash":{},"fn":container.program(34, data, 0, blockParams, depths),"inverse":container.noop,"data":data}),(typeof helper === alias3 ? helper.call(alias1,options) : helper)); if (!helpers.kanjiLinks) { stack1 = helpers.blockHelperMissing.call(depth0,stack1,options)} if (stack1 != null) { buffer += stack1; } return buffer + "</span><div class=\"peek-wrapper\">" - + ((stack1 = helpers["if"].call(alias1,(depths[1] != null ? depths[1].playback : depths[1]),{"name":"if","hash":{},"fn":container.program(35, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : "") - + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.termTags : depth0),{"name":"if","hash":{},"fn":container.program(37, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : "") - + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.frequencies : depth0),{"name":"if","hash":{},"fn":container.program(40, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : "") + + ((stack1 = helpers["if"].call(alias1,(depths[1] != null ? depths[1].playback : depths[1]),{"name":"if","hash":{},"fn":container.program(37, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : "") + + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.termTags : depth0),{"name":"if","hash":{},"fn":container.program(39, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : "") + + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.frequencies : depth0),{"name":"if","hash":{},"fn":container.program(42, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : "") + "</div><span class=\"" - + ((stack1 = helpers["if"].call(alias1,(data && data.last),{"name":"if","hash":{},"fn":container.program(43, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : "") + + ((stack1 = helpers["if"].call(alias1,(data && data.last),{"name":"if","hash":{},"fn":container.program(45, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : "") + "\">、</span></div>"; -},"32":function(container,depth0,helpers,partials,data) { +},"34":function(container,depth0,helpers,partials,data) { var stack1, helper, options; - stack1 = ((helper = (helper = helpers.furigana || (depth0 != null ? depth0.furigana : depth0)) != null ? helper : helpers.helperMissing),(options={"name":"furigana","hash":{},"fn":container.program(33, data, 0),"inverse":container.noop,"data":data}),(typeof helper === "function" ? helper.call(depth0 != null ? depth0 : (container.nullContext || {}),options) : helper)); + stack1 = ((helper = (helper = helpers.furigana || (depth0 != null ? depth0.furigana : depth0)) != null ? helper : helpers.helperMissing),(options={"name":"furigana","hash":{},"fn":container.program(35, data, 0),"inverse":container.noop,"data":data}),(typeof helper === "function" ? helper.call(depth0 != null ? depth0 : (container.nullContext || {}),options) : helper)); if (!helpers.furigana) { stack1 = helpers.blockHelperMissing.call(depth0,stack1,options)} if (stack1 != null) { return stack1; } else { return ''; } -},"33":function(container,depth0,helpers,partials,data) { +},"35":function(container,depth0,helpers,partials,data) { var stack1; return ((stack1 = container.lambda(depth0, depth0)) != null ? stack1 : ""); -},"35":function(container,depth0,helpers,partials,data) { - return "<a href=\"#\" class=\"action-play-audio\"><img src=\"/mixed/img/play-audio.svg\" title=\"Play audio\" alt></a>"; },"37":function(container,depth0,helpers,partials,data) { + return "<a href=\"#\" class=\"action-play-audio\"><img src=\"/mixed/img/play-audio.svg\" title=\"Play audio\" alt></a>"; +},"39":function(container,depth0,helpers,partials,data) { var stack1; return "<div class=\"tags\">" - + ((stack1 = helpers.each.call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.termTags : depth0),{"name":"each","hash":{},"fn":container.program(38, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") + + ((stack1 = helpers.each.call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.termTags : depth0),{"name":"each","hash":{},"fn":container.program(40, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") + "</div>"; -},"38":function(container,depth0,helpers,partials,data) { +},"40":function(container,depth0,helpers,partials,data) { var helper, alias1=depth0 != null ? depth0 : (container.nullContext || {}), alias2=helpers.helperMissing, alias3="function", alias4=container.escapeExpression; return " <span class=\"label label-default tag-" @@ -359,13 +368,13 @@ templates['terms.html'] = template({"1":function(container,depth0,helpers,partia + "\">" + alias4(((helper = (helper = helpers.name || (depth0 != null ? depth0.name : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"name","hash":{},"data":data}) : helper))) + "</span>\n"; -},"40":function(container,depth0,helpers,partials,data) { +},"42":function(container,depth0,helpers,partials,data) { var stack1; return "<div class=\"frequencies\">" - + ((stack1 = helpers.each.call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.frequencies : depth0),{"name":"each","hash":{},"fn":container.program(41, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") + + ((stack1 = helpers.each.call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.frequencies : depth0),{"name":"each","hash":{},"fn":container.program(43, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") + "</div>"; -},"41":function(container,depth0,helpers,partials,data) { +},"43":function(container,depth0,helpers,partials,data) { var helper, alias1=depth0 != null ? depth0 : (container.nullContext || {}), alias2=helpers.helperMissing, alias3="function", alias4=container.escapeExpression; return " <span class=\"label label-default tag-frequency\">" @@ -373,55 +382,45 @@ templates['terms.html'] = template({"1":function(container,depth0,helpers,partia + ":" + alias4(((helper = (helper = helpers.frequency || (depth0 != null ? depth0.frequency : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"frequency","hash":{},"data":data}) : helper))) + "</span>\n"; -},"43":function(container,depth0,helpers,partials,data) { - return "invisible"; },"45":function(container,depth0,helpers,partials,data) { + return "invisible"; +},"47":function(container,depth0,helpers,partials,data) { var stack1, helper, options, alias1=depth0 != null ? depth0 : (container.nullContext || {}), buffer = " <div class=\"expression\">"; - stack1 = ((helper = (helper = helpers.kanjiLinks || (depth0 != null ? depth0.kanjiLinks : depth0)) != null ? helper : helpers.helperMissing),(options={"name":"kanjiLinks","hash":{},"fn":container.program(32, data, 0),"inverse":container.noop,"data":data}),(typeof helper === "function" ? helper.call(alias1,options) : helper)); + stack1 = ((helper = (helper = helpers.kanjiLinks || (depth0 != null ? depth0.kanjiLinks : depth0)) != null ? helper : helpers.helperMissing),(options={"name":"kanjiLinks","hash":{},"fn":container.program(34, data, 0),"inverse":container.noop,"data":data}),(typeof helper === "function" ? helper.call(alias1,options) : helper)); if (!helpers.kanjiLinks) { stack1 = helpers.blockHelperMissing.call(depth0,stack1,options)} if (stack1 != null) { buffer += stack1; } return buffer + "</div>\n" - + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.termTags : depth0),{"name":"if","hash":{},"fn":container.program(46, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : ""); -},"46":function(container,depth0,helpers,partials,data) { + + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.termTags : depth0),{"name":"if","hash":{},"fn":container.program(48, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : ""); +},"48":function(container,depth0,helpers,partials,data) { var stack1; return " <div style=\"display: inline-block;\">\n" - + ((stack1 = helpers.each.call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.termTags : depth0),{"name":"each","hash":{},"fn":container.program(47, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") + + ((stack1 = helpers.each.call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.termTags : depth0),{"name":"each","hash":{},"fn":container.program(7, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") + " </div>\n"; -},"47":function(container,depth0,helpers,partials,data) { - var helper, alias1=depth0 != null ? depth0 : (container.nullContext || {}), alias2=helpers.helperMissing, alias3="function", alias4=container.escapeExpression; - - return " <span class=\"label label-default tag-" - + alias4(((helper = (helper = helpers.category || (depth0 != null ? depth0.category : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"category","hash":{},"data":data}) : helper))) - + "\" title=\"" - + alias4(((helper = (helper = helpers.notes || (depth0 != null ? depth0.notes : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"notes","hash":{},"data":data}) : helper))) - + "\">" - + alias4(((helper = (helper = helpers.name || (depth0 != null ? depth0.name : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"name","hash":{},"data":data}) : helper))) - + "</span>\n"; -},"49":function(container,depth0,helpers,partials,data) { +},"50":function(container,depth0,helpers,partials,data) { var stack1; return " <div class=\"reasons\">\n" - + ((stack1 = helpers.each.call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.reasons : depth0),{"name":"each","hash":{},"fn":container.program(50, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") + + ((stack1 = helpers.each.call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.reasons : depth0),{"name":"each","hash":{},"fn":container.program(51, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") + " </div>\n"; -},"50":function(container,depth0,helpers,partials,data) { +},"51":function(container,depth0,helpers,partials,data) { var stack1; return " <span class=\"reasons\">" + container.escapeExpression(container.lambda(depth0, depth0)) + "</span> " - + ((stack1 = helpers.unless.call(depth0 != null ? depth0 : (container.nullContext || {}),(data && data.last),{"name":"unless","hash":{},"fn":container.program(51, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") + + ((stack1 = helpers.unless.call(depth0 != null ? depth0 : (container.nullContext || {}),(data && data.last),{"name":"unless","hash":{},"fn":container.program(52, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") + "\n"; -},"51":function(container,depth0,helpers,partials,data) { +},"52":function(container,depth0,helpers,partials,data) { return "«"; -},"53":function(container,depth0,helpers,partials,data) { +},"54":function(container,depth0,helpers,partials,data) { var stack1; return " <div>\n" - + ((stack1 = helpers.each.call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.frequencies : depth0),{"name":"each","hash":{},"fn":container.program(54, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") + + ((stack1 = helpers.each.call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.frequencies : depth0),{"name":"each","hash":{},"fn":container.program(55, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") + " </div>\n"; -},"54":function(container,depth0,helpers,partials,data) { +},"55":function(container,depth0,helpers,partials,data) { var helper, alias1=depth0 != null ? depth0 : (container.nullContext || {}), alias2=helpers.helperMissing, alias3="function", alias4=container.escapeExpression; return " <span class=\"label label-default tag-frequency\">" @@ -429,67 +428,67 @@ templates['terms.html'] = template({"1":function(container,depth0,helpers,partia + ":" + alias4(((helper = (helper = helpers.frequency || (depth0 != null ? depth0.frequency : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"frequency","hash":{},"data":data}) : helper))) + "</span>\n"; -},"56":function(container,depth0,helpers,partials,data,blockParams,depths) { +},"57":function(container,depth0,helpers,partials,data,blockParams,depths) { var stack1; - return ((stack1 = helpers["if"].call(depth0 != null ? depth0 : (container.nullContext || {}),((stack1 = (depth0 != null ? depth0.definitions : depth0)) != null ? stack1["1"] : stack1),{"name":"if","hash":{},"fn":container.program(57, data, 0, blockParams, depths),"inverse":container.program(60, data, 0, blockParams, depths),"data":data})) != null ? stack1 : ""); -},"57":function(container,depth0,helpers,partials,data,blockParams,depths) { + return ((stack1 = helpers["if"].call(depth0 != null ? depth0 : (container.nullContext || {}),((stack1 = (depth0 != null ? depth0.definitions : depth0)) != null ? stack1["1"] : stack1),{"name":"if","hash":{},"fn":container.program(58, data, 0, blockParams, depths),"inverse":container.program(61, data, 0, blockParams, depths),"data":data})) != null ? stack1 : ""); +},"58":function(container,depth0,helpers,partials,data,blockParams,depths) { var stack1; return " <ol>\n" - + ((stack1 = helpers.each.call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.definitions : depth0),{"name":"each","hash":{},"fn":container.program(58, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : "") + + ((stack1 = helpers.each.call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.definitions : depth0),{"name":"each","hash":{},"fn":container.program(59, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : "") + " </ol>\n"; -},"58":function(container,depth0,helpers,partials,data,blockParams,depths) { +},"59":function(container,depth0,helpers,partials,data,blockParams,depths) { var stack1; return " <li>" + ((stack1 = container.invokePartial(partials.definition,depth0,{"name":"definition","hash":{"compactGlossaries":(depths[1] != null ? depths[1].compactGlossaries : depths[1])},"data":data,"helpers":helpers,"partials":partials,"decorators":container.decorators})) != null ? stack1 : "") + "</li>\n"; -},"60":function(container,depth0,helpers,partials,data) { +},"61":function(container,depth0,helpers,partials,data) { var stack1; return ((stack1 = container.invokePartial(partials.definition,((stack1 = (depth0 != null ? depth0.definitions : depth0)) != null ? stack1["0"] : stack1),{"name":"definition","hash":{"compactGlossaries":(depth0 != null ? depth0.compactGlossaries : depth0)},"data":data,"indent":" ","helpers":helpers,"partials":partials,"decorators":container.decorators})) != null ? stack1 : ""); -},"62":function(container,depth0,helpers,partials,data,blockParams,depths) { +},"63":function(container,depth0,helpers,partials,data,blockParams,depths) { var stack1; - return ((stack1 = helpers["if"].call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.merged : depth0),{"name":"if","hash":{},"fn":container.program(56, data, 0, blockParams, depths),"inverse":container.program(63, data, 0, blockParams, depths),"data":data})) != null ? stack1 : ""); -},"63":function(container,depth0,helpers,partials,data) { + return ((stack1 = helpers["if"].call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.merged : depth0),{"name":"if","hash":{},"fn":container.program(57, data, 0, blockParams, depths),"inverse":container.program(64, data, 0, blockParams, depths),"data":data})) != null ? stack1 : ""); +},"64":function(container,depth0,helpers,partials,data) { var stack1; return ((stack1 = container.invokePartial(partials.definition,depth0,{"name":"definition","hash":{"compactGlossaries":(depth0 != null ? depth0.compactGlossaries : depth0)},"data":data,"indent":" ","helpers":helpers,"partials":partials,"decorators":container.decorators})) != null ? stack1 : "") + " "; -},"65":function(container,depth0,helpers,partials,data) { +},"66":function(container,depth0,helpers,partials,data) { var stack1, helper, options, buffer = " <pre>"; - stack1 = ((helper = (helper = helpers.dumpObject || (depth0 != null ? depth0.dumpObject : depth0)) != null ? helper : helpers.helperMissing),(options={"name":"dumpObject","hash":{},"fn":container.program(33, data, 0),"inverse":container.noop,"data":data}),(typeof helper === "function" ? helper.call(depth0 != null ? depth0 : (container.nullContext || {}),options) : helper)); + stack1 = ((helper = (helper = helpers.dumpObject || (depth0 != null ? depth0.dumpObject : depth0)) != null ? helper : helpers.helperMissing),(options={"name":"dumpObject","hash":{},"fn":container.program(35, data, 0),"inverse":container.noop,"data":data}),(typeof helper === "function" ? helper.call(depth0 != null ? depth0 : (container.nullContext || {}),options) : helper)); if (!helpers.dumpObject) { stack1 = helpers.blockHelperMissing.call(depth0,stack1,options)} if (stack1 != null) { buffer += stack1; } return buffer + "</pre>\n"; -},"67":function(container,depth0,helpers,partials,data,blockParams,depths) { +},"68":function(container,depth0,helpers,partials,data,blockParams,depths) { var stack1; - return ((stack1 = helpers.each.call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.definitions : depth0),{"name":"each","hash":{},"fn":container.program(68, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : ""); -},"68":function(container,depth0,helpers,partials,data,blockParams,depths) { + return ((stack1 = helpers.each.call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.definitions : depth0),{"name":"each","hash":{},"fn":container.program(69, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : ""); +},"69":function(container,depth0,helpers,partials,data,blockParams,depths) { var stack1; - return ((stack1 = helpers.unless.call(depth0 != null ? depth0 : (container.nullContext || {}),(data && data.first),{"name":"unless","hash":{},"fn":container.program(69, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : "") + return ((stack1 = helpers.unless.call(depth0 != null ? depth0 : (container.nullContext || {}),(data && data.first),{"name":"unless","hash":{},"fn":container.program(70, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : "") + "\n" + ((stack1 = container.invokePartial(partials.term,depth0,{"name":"term","hash":{"source":(depths[1] != null ? depths[1].source : depths[1]),"compactGlossaries":(depths[1] != null ? depths[1].compactGlossaries : depths[1]),"playback":(depths[1] != null ? depths[1].playback : depths[1]),"addable":(depths[1] != null ? depths[1].addable : depths[1]),"merged":(depths[1] != null ? depths[1].merged : depths[1]),"grouped":(depths[1] != null ? depths[1].grouped : depths[1]),"debug":(depths[1] != null ? depths[1].debug : depths[1])},"data":data,"helpers":helpers,"partials":partials,"decorators":container.decorators})) != null ? stack1 : ""); -},"69":function(container,depth0,helpers,partials,data) { +},"70":function(container,depth0,helpers,partials,data) { return "<hr>"; -},"71":function(container,depth0,helpers,partials,data) { +},"72":function(container,depth0,helpers,partials,data) { return "<p class=\"note\">No results found.</p>\n"; },"compiler":[7,">= 4.0.0"],"main":function(container,depth0,helpers,partials,data,blockParams,depths) { var stack1; return "\n\n" - + ((stack1 = helpers["if"].call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.definitions : depth0),{"name":"if","hash":{},"fn":container.program(67, data, 0, blockParams, depths),"inverse":container.program(71, data, 0, blockParams, depths),"data":data})) != null ? stack1 : ""); + + ((stack1 = helpers["if"].call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.definitions : depth0),{"name":"if","hash":{},"fn":container.program(68, data, 0, blockParams, depths),"inverse":container.program(72, data, 0, blockParams, depths),"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":["definition"],"data":data}) || fn; - fn = decorators.inline(fn,props,container,{"name":"inline","hash":{},"fn":container.program(22, data, 0, blockParams, depths),"inverse":container.noop,"args":["term"],"data":data}) || fn; + fn = decorators.inline(fn,props,container,{"name":"inline","hash":{},"fn":container.program(24, data, 0, blockParams, depths),"inverse":container.noop,"args":["term"],"data":data}) || fn; return fn; } diff --git a/ext/bg/js/translator.js b/ext/bg/js/translator.js index 601ee30c..ee012d96 100644 --- a/ext/bg/js/translator.js +++ b/ext/bg/js/translator.js @@ -21,6 +21,7 @@ class Translator { constructor() { this.database = null; this.deinflector = null; + this.tagCache = {}; } async prepare() { @@ -36,6 +37,11 @@ class Translator { } } + async purgeDatabase() { + this.tagCache = {}; + await this.database.purge(); + } + async findTermsGrouped(text, dictionaries, alphanumeric, options) { const titles = Object.keys(dictionaries); const {length, definitions} = await this.findTerms(text, dictionaries, alphanumeric); @@ -52,94 +58,121 @@ class Translator { return {length, definitions: definitionsGrouped}; } - async findTermsMerged(text, dictionaries, alphanumeric, options) { - const secondarySearchTitles = Object.keys(options.dictionaries).filter(dict => options.dictionaries[dict].allowSecondarySearches); - const titles = Object.keys(dictionaries); - const {length, definitions} = await this.findTerms(text, dictionaries, alphanumeric); + async getSequencedDefinitions(definitions, mainDictionary) { + const definitionsBySequence = dictTermsMergeBySequence(definitions, mainDictionary); + const defaultDefinitions = definitionsBySequence['-1']; - const definitionsBySequence = dictTermsMergeBySequence(definitions, options.general.mainDictionary); + const sequenceList = Object.keys(definitionsBySequence).map(v => Number(v)).filter(v => v >= 0); + const sequencedDefinitions = sequenceList.map((key) => ({ + definitions: definitionsBySequence[key], + rawDefinitions: [] + })); - const definitionsMerged = []; - const mergedByTermIndices = new Set(); - for (const sequence in definitionsBySequence) { - if (sequence < 0) { - continue; - } + for (const definition of await this.database.findTermsBySequenceBulk(sequenceList, mainDictionary)) { + sequencedDefinitions[definition.index].rawDefinitions.push(definition); + } - const result = definitionsBySequence[sequence]; + return {sequencedDefinitions, defaultDefinitions}; + } - const rawDefinitionsBySequence = await this.database.findTermsBySequence(Number(sequence), options.general.mainDictionary); + async getMergedSecondarySearchResults(text, expressionsMap, secondarySearchTitles) { + if (secondarySearchTitles.length === 0) { + return []; + } - for (const definition of rawDefinitionsBySequence) { - const definitionTags = await this.expandTags(definition.definitionTags, definition.dictionary); - definitionTags.push(dictTagBuildSource(definition.dictionary)); - definition.definitionTags = definitionTags; - const termTags = await this.expandTags(definition.termTags, definition.dictionary); - definition.termTags = termTags; + const expressionList = []; + const readingList = []; + for (const expression of expressionsMap.keys()) { + if (expression === text) { continue; } + for (const reading of expressionsMap.get(expression).keys()) { + expressionList.push(expression); + readingList.push(reading); } + } - const definitionsByGloss = dictTermsMergeByGloss(result, rawDefinitionsBySequence); - - const secondarySearchResults = []; - if (secondarySearchTitles.length > 0) { - for (const expression of result.expressions.keys()) { - if (expression === text) { - continue; - } - - for (const reading of result.expressions.get(expression).keys()) { - for (const definition of await this.database.findTermsExact(expression, reading, secondarySearchTitles)) { - const definitionTags = await this.expandTags(definition.definitionTags, definition.dictionary); - definitionTags.push(dictTagBuildSource(definition.dictionary)); - definition.definitionTags = definitionTags; - const termTags = await this.expandTags(definition.termTags, definition.dictionary); - definition.termTags = termTags; - secondarySearchResults.push(definition); - } - } - } - } + const definitions = await this.database.findTermsExactBulk(expressionList, readingList, secondarySearchTitles); + for (const definition of definitions) { + const definitionTags = await this.expandTags(definition.definitionTags, definition.dictionary); + definitionTags.push(dictTagBuildSource(definition.dictionary)); + definition.definitionTags = definitionTags; + const termTags = await this.expandTags(definition.termTags, definition.dictionary); + definition.termTags = termTags; + } - dictTermsMergeByGloss(result, definitionsBySequence['-1'].concat(secondarySearchResults), definitionsByGloss, mergedByTermIndices); + if (definitions.length > 1) { + definitions.sort((a, b) => a.index - b.index); + } - for (const gloss in definitionsByGloss) { - const definition = definitionsByGloss[gloss]; - dictTagsSort(definition.definitionTags); - result.definitions.push(definition); - } + return definitions; + } - dictTermsSort(result.definitions, dictionaries); - - const expressions = []; - for (const expression of result.expressions.keys()) { - for (const reading of result.expressions.get(expression).keys()) { - const termTags = result.expressions.get(expression).get(reading); - expressions.push({ - expression: expression, - reading: reading, - termTags: dictTagsSort(termTags), - termFrequency: (score => { - if (score > 0) { - return 'popular'; - } else if (score < 0) { - return 'rare'; - } else { - return 'normal'; - } - })(termTags.map(tag => tag.score).reduce((p, v) => p + v, 0)) - }); - } + async getMergedDefinition(text, dictionaries, sequencedDefinition, defaultDefinitions, secondarySearchTitles, mergedByTermIndices) { + const result = sequencedDefinition.definitions; + const rawDefinitionsBySequence = sequencedDefinition.rawDefinitions; + + for (const definition of rawDefinitionsBySequence) { + const definitionTags = await this.expandTags(definition.definitionTags, definition.dictionary); + definitionTags.push(dictTagBuildSource(definition.dictionary)); + definition.definitionTags = definitionTags; + const termTags = await this.expandTags(definition.termTags, definition.dictionary); + definition.termTags = termTags; + } + + const definitionsByGloss = dictTermsMergeByGloss(result, rawDefinitionsBySequence); + const secondarySearchResults = await this.getMergedSecondarySearchResults(text, result.expressions, secondarySearchTitles); + + dictTermsMergeByGloss(result, defaultDefinitions.concat(secondarySearchResults), definitionsByGloss, mergedByTermIndices); + + for (const gloss in definitionsByGloss) { + const definition = definitionsByGloss[gloss]; + dictTagsSort(definition.definitionTags); + result.definitions.push(definition); + } + + dictTermsSort(result.definitions, dictionaries); + + const expressions = []; + for (const expression of result.expressions.keys()) { + for (const reading of result.expressions.get(expression).keys()) { + const termTags = result.expressions.get(expression).get(reading); + const score = termTags.map(tag => tag.score).reduce((p, v) => p + v, 0); + expressions.push({ + expression: expression, + reading: reading, + termTags: dictTagsSort(termTags), + termFrequency: Translator.scoreToTermFrequency(score) + }); } + } - result.expressions = expressions; + result.expressions = expressions; + result.expression = Array.from(result.expression); + result.reading = Array.from(result.reading); - result.expression = Array.from(result.expression); - result.reading = Array.from(result.reading); + return result; + } + + async findTermsMerged(text, dictionaries, alphanumeric, options) { + const secondarySearchTitles = Object.keys(options.dictionaries).filter(dict => options.dictionaries[dict].allowSecondarySearches); + const titles = Object.keys(dictionaries); + const {length, definitions} = await this.findTerms(text, dictionaries, alphanumeric); + const {sequencedDefinitions, defaultDefinitions} = await this.getSequencedDefinitions(definitions, options.general.mainDictionary); + const definitionsMerged = []; + const mergedByTermIndices = new Set(); + for (const sequencedDefinition of sequencedDefinitions) { + const result = await this.getMergedDefinition( + text, + dictionaries, + sequencedDefinition, + defaultDefinitions, + secondarySearchTitles, + mergedByTermIndices + ); definitionsMerged.push(result); } - const strayDefinitions = definitionsBySequence['-1'].filter((definition, index) => !mergedByTermIndices.has(index)); + const strayDefinitions = defaultDefinitions.filter((definition, index) => !mergedByTermIndices.has(index)); for (const groupedDefinition of dictTermsGroup(strayDefinitions, dictionaries)) { groupedDefinition.expressions = [{expression: groupedDefinition.expression, reading: groupedDefinition.reading}]; definitionsMerged.push(groupedDefinition); @@ -277,33 +310,44 @@ class Translator { } async findKanji(text, dictionaries) { - let definitions = []; - const processed = {}; const titles = Object.keys(dictionaries); + const kanjiUnique = {}; + const kanjiList = []; for (const c of text) { - if (!processed[c]) { - definitions.push(...await this.database.findKanji(c, titles)); - processed[c] = true; + if (!kanjiUnique.hasOwnProperty(c)) { + kanjiList.push(c); + kanjiUnique[c] = true; } } + const definitions = await this.database.findKanjiBulk(kanjiList, titles); + if (definitions.length === 0) { + return definitions; + } + + if (definitions.length > 1) { + definitions.sort((a, b) => a.index - b.index); + } + + const kanjiList2 = []; for (const definition of definitions) { + kanjiList2.push(definition.character); + const tags = await this.expandTags(definition.tags, definition.dictionary); tags.push(dictTagBuildSource(definition.dictionary)); definition.tags = dictTagsSort(tags); definition.stats = await this.expandStats(definition.stats, definition.dictionary); - definition.frequencies = []; - for (const meta of await this.database.findKanjiMeta(definition.character, titles)) { - if (meta.mode === 'freq') { - definition.frequencies.push({ - character: meta.character, - frequency: meta.data, - dictionary: meta.dictionary - }); - } - } + } + + for (const meta of await this.database.findKanjiMetaBulk(kanjiList2, titles)) { + if (meta.mode !== 'freq') { continue; } + definitions[meta.index].frequencies.push({ + character: meta.character, + frequency: meta.data, + dictionary: meta.dictionary + }); } return definitions; @@ -359,56 +403,76 @@ class Translator { } async expandTags(names, title) { - const tags = []; - for (const name of names) { - const base = Translator.getNameBase(name); - let meta = this.database.findTagForTitleCached(base, title); - if (typeof meta === 'undefined') { - meta = await this.database.findTagForTitle(base, title); - } - - const tag = Object.assign({}, meta !== null ? meta : {}, {name}); - - tags.push(dictTagSanitize(tag)); - } - - return tags; + const tagMetaList = await this.getTagMetaList(names, title); + return tagMetaList.map((meta, index) => { + const name = names[index]; + const tag = dictTagSanitize(Object.assign({}, meta !== null ? meta : {}, {name})); + return dictTagSanitize(tag); + }); } async expandStats(items, title) { - const stats = {}; - for (const name in items) { - const base = Translator.getNameBase(name); - let meta = this.database.findTagForTitleCached(base, title); - if (typeof meta === 'undefined') { - meta = await this.database.findTagForTitle(base, title); - if (meta === null) { - continue; - } - } + const names = Object.keys(items); + const tagMetaList = await this.getTagMetaList(names, title); - const group = stats[meta.category] = stats[meta.category] || []; + const stats = {}; + for (let i = 0; i < names.length; ++i) { + const name = names[i]; + const meta = tagMetaList[i]; + if (meta === null) { continue; } + + const category = meta.category; + const group = ( + stats.hasOwnProperty(category) ? + stats[category] : + (stats[category] = []) + ); const stat = Object.assign({}, meta, {name, value: items[name]}); - group.push(dictTagSanitize(stat)); } + const sortCompare = (a, b) => a.notes - b.notes; for (const category in stats) { - stats[category].sort((a, b) => { - if (a.notes < b.notes) { - return -1; - } else if (a.notes > b.notes) { - return 1; - } else { - return 0; - } - }); + stats[category].sort(sortCompare); } return stats; } + async getTagMetaList(names, title) { + const tagMetaList = []; + const cache = ( + this.tagCache.hasOwnProperty(title) ? + this.tagCache[title] : + (this.tagCache[title] = {}) + ); + + for (const name of names) { + const base = Translator.getNameBase(name); + + if (cache.hasOwnProperty(base)) { + tagMetaList.push(cache[base]); + } else { + const tagMeta = await this.database.findTagForTitle(base, title); + cache[base] = tagMeta; + tagMetaList.push(tagMeta); + } + } + + return tagMetaList; + } + + static scoreToTermFrequency(score) { + if (score > 0) { + return 'popular'; + } else if (score < 0) { + return 'rare'; + } else { + return 'normal'; + } + } + static getNameBase(name) { const pos = name.indexOf(':'); return (pos >= 0 ? name.substr(0, pos) : name); diff --git a/ext/bg/js/util.js b/ext/bg/js/util.js index 73a8396f..1ca0833b 100644 --- a/ext/bg/js/util.js +++ b/ext/bg/js/util.js @@ -89,7 +89,7 @@ function utilAnkiGetModelFieldNames(modelName) { } function utilDatabasePurge() { - return utilBackend().translator.database.purge(); + return utilBackend().translator.purgeDatabase(); } async function utilDatabaseImport(data, progress, exceptions) { diff --git a/ext/bg/lang/deinflect.json b/ext/bg/lang/deinflect.json index 682093e1..a0b6baa1 100644 --- a/ext/bg/lang/deinflect.json +++ b/ext/bg/lang/deinflect.json @@ -3671,7 +3671,7 @@ "kanaIn": "ておる", "kanaOut": "て", "rulesIn": [ - "v1" + "v5" ], "rulesOut": [ "iru" @@ -3701,7 +3701,7 @@ "kanaIn": "でおる", "kanaOut": "で", "rulesIn": [ - "v1" + "v5" ], "rulesOut": [ "iru" @@ -3711,7 +3711,7 @@ "kanaIn": "とる", "kanaOut": "て", "rulesIn": [ - "v1" + "v5" ], "rulesOut": [ "iru" diff --git a/ext/bg/search.html b/ext/bg/search.html index 3284ed43..9d28b358 100644 --- a/ext/bg/search.html +++ b/ext/bg/search.html @@ -1,5 +1,5 @@ <!DOCTYPE html> -<html lang="en"> +<html lang="en" data-yomichan-page="search"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width,initial-scale=1" /> @@ -7,6 +7,8 @@ <link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap.min.css"> <link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap-theme.min.css"> <link rel="stylesheet" type="text/css" href="/mixed/css/display.css"> + <link rel="stylesheet" type="text/css" href="/mixed/css/display-default.css" data-yomichan-theme-name="default"> + <link rel="stylesheet alternate" type="text/css" href="/mixed/css/display-dark.css" data-yomichan-theme-name="dark"> </head> <body> <div class="container"> diff --git a/ext/bg/settings-popup-preview.html b/ext/bg/settings-popup-preview.html new file mode 100644 index 00000000..07caa271 --- /dev/null +++ b/ext/bg/settings-popup-preview.html @@ -0,0 +1,125 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width,initial-scale=1" /> + <title>Yomichan Popup Preview</title> + <link rel="stylesheet" type="text/css" href="/fg/css/client.css" id="client-css"> + <style> + html { + transition: background-color 0.25s linear 0s, color 0.25s linear 0s; + color: #333333; + } + html.dark { + background-color: #1e1e1e; + color: #d4d4d4; + } + html, body { + margin: 0; + padding: 0; + border: 0; + overflow: hidden; + width: 100%; + height: 100%; + font-family: "Helvetica Neue", Helvetica, Arial ,sans-serif; + font-size: 14px; + } + iframe.yomichan-float { + resize: none; + } + .vertical-align-outer { + width: 100%; + height: 100%; + white-space: nowrap; + } + .vertical-align-outer::before { + content: ""; + display: inline-block; + vertical-align: middle; + width: 0; + height: 100%; + } + .vertical-align-inner { + display: inline-block; + vertical-align: middle; + white-space: normal; + width: 100%; + } + .horizontal-size { + max-width: 400px; + padding: 15px; + margin: 0 auto; + } + .example-text-container { + font-size: 24px; + line-height: 1.25em; + height: 1.25em; + } + .popup-placeholder { + height: 250px; + padding-top: 10px; + border: 1px solid rgba(0, 0, 0, 0); + } + .placeholder-info { + visibility: hidden; + opacity: 0; + transition: opacity 0.5s linear 0s, visibility 0s linear 0.5s; + } + .placeholder-info.placeholder-info-visible { + visibility: visible; + opacity: 1; + transition: opacity 0.5s linear 0s, visibility 0s linear 0s; + } + + .options { + float: right; + font-size: 14px; + line-height: 30px; + } + .theme-button { + display: inline-block; + margin-left: 0.5em; + text-decoration: none; + cursor: pointer; + white-space: nowrap; + line-height: 0; + } + .theme-button>input { + vertical-align: middle; + margin: 0 0.25em 0 0; + padding: 0; + } + .theme-button>span { + vertical-align: middle; + } + .theme-button:hover>span { + text-decoration: underline; + } + </style> + </head> + <body> + <div class="vertical-align-outer"><div class="vertical-align-inner"><div class="horizontal-size"> + <div class="example-text-container"> + <div class="options"><label class="theme-button"><input type="checkbox" id="theme-dark-checkbox" /><span>dark</span></label></div> + <span id="example-text">読め</span> + </div> + <div class="popup-placeholder"> + <div class="vertical-align-outer"><div class="vertical-align-inner placeholder-info"> + This page uses the dictionaries you have installed in order to show a preview. + If you see this message, make sure you have a dictionary installed. + </div></div> + </div> + </div></div></div> + + <script src="/mixed/js/extension.js"></script> + <script src="/fg/js/api.js"></script> + <script src="/fg/js/document.js"></script> + <script src="/fg/js/frontend-api-receiver.js"></script> + <script src="/fg/js/popup.js"></script> + <script src="/fg/js/source.js"></script> + <script src="/fg/js/util.js"></script> + <script src="/fg/js/popup-proxy-host.js"></script> + <script src="/fg/js/frontend.js"></script> + <script src="/bg/js/settings-popup-preview.js"></script> + </body> +</html> diff --git a/ext/bg/settings.html b/ext/bg/settings.html index e4710283..9b1c4366 100644 --- a/ext/bg/settings.html +++ b/ext/bg/settings.html @@ -231,9 +231,49 @@ </div> </div> + <div class="form-group"> + <div class="row"> + <div class="col-xs-6"> + <label for="popup-theme">Popup theme</label> + <select class="form-control" id="popup-theme"> + <option value="default">Light</option> + <option value="dark">Dark</option> + </select> + </div> + <div class="col-xs-6"> + <label for="popup-outer-theme">Popup shadow theme</label> + <select class="form-control" id="popup-outer-theme"> + <option value="auto">Auto-detect</option> + <option value="default">Light</option> + <option value="dark">Dark</option> + </select> + </div> + </div> + </div> + <div class="form-group options-advanced"> - <label for="custom-popup-css">Custom popup CSS</label> - <div><textarea autocomplete="off" spellcheck="false" wrap="soft" id="custom-popup-css" class="form-control"></textarea></div> + <div class="row"> + <div class="col-xs-6"> + <label for="custom-popup-css">Custom popup CSS</label> + <div><textarea autocomplete="off" spellcheck="false" wrap="soft" id="custom-popup-css" class="form-control"></textarea></div> + </div> + <div class="col-xs-6"> + <label for="custom-popup-outer-css">Custom popup outer CSS</label> + <div><textarea autocomplete="off" spellcheck="false" wrap="soft" id="custom-popup-outer-css" class="form-control" placeholder="iframe.yomichan-float { /*styles*/ }"></textarea></div> + </div> + </div> + </div> + + <div class="form-group ignore-form-changes" style="display: none;" id="settings-popup-preview-settings"> + <label for="settings-popup-preview-text">Popup preview text</label> + <input type="text" id="settings-popup-preview-text" class="form-control" value="読め"> + </div> + + <div class="form-group ignore-form-changes"> + <div id="settings-popup-preview-button-container"> + <button class="btn btn-default" id="settings-popup-preview-button">Show popup preview</button> + </div> + <div id="settings-popup-preview-container"></div> </div> </div> @@ -253,6 +293,16 @@ <input type="number" min="0" max="100" id="audio-playback-volume" class="form-control"> </div> + <div class="form-group" style="display: none;" id="text-to-speech-voice-container"> + <label for="text-to-speech-voice">Text-to-speech voice</label> + <div class="input-group"> + <select class="form-control" id="text-to-speech-voice"></select> + <div class="input-group-btn"> + <button class="btn btn-default" id="text-to-speech-voice-test" title="Test voice" data-speech-text="よみちゃん"><span class="glyphicon glyphicon-volume-up"></span></button> + </div> + </div> + </div> + <div class="form-group options-advanced"> <label for="audio-custom-source">Custom audio source <span class="label-light">(URL)</span></label> <input type="text" id="audio-custom-source" class="form-control" placeholder="Example: http://localhost/audio.mp3?expression={expression}&reading={reading}"> @@ -269,8 +319,10 @@ <div class="input-group-addon audio-source-prefix"></div> <select class="form-control audio-source-select"> <option value="jpod101">JapanesePod101</option> - <option value="jpod101-alternate">JapanesePod101 (alternate)</option> + <option value="jpod101-alternate">JapanesePod101 (Alternate)</option> <option value="jisho">Jisho.org</option> + <option value="text-to-speech">Text-to-speech</option> + <option value="text-to-speech-reading">Text-to-speech (Kana reading)</option> <option value="custom">Custom</option> </select> <div class="input-group-btn"><button class="btn btn-danger audio-source-remove" title="Remove"><span class="glyphicon glyphicon-remove"></span></button></div> @@ -395,16 +447,27 @@ </div> </div> - <div id="storage-info" class="storage-hidden"> + <div id="storage-info"> <div> <img src="/mixed/img/spinner.gif" class="pull-right" id="storage-spinner" /> <h3>Storage</h3> </div> - <div id="storage-use" class="storage-hidden"> + <div id="storage-persist-info" class="storage-hidden"> <p class="help-block"> + Web browsers may sometimes clear stored data if the device is running low on storage space. + This can result in the stored dictionary data being deleted unexpectedly, causing Yomichan to stop working for no apparent reason. + In order to prevent this, persistent storage must be enable by clicking the "Persistent Storage" button below. + </p> + </div> + + <div id="storage-use" class="storage-hidden"> + <p class="help-block storage-hidden" id="storage-use-finite"> Yomichan is using approximately <strong id="storage-usage"></strong> of <strong id="storage-quota"></strong>. </p> + <p class="help-block storage-hidden" id="storage-use-infinite"> + Yomichan is permitted <strong>unlimited storage</strong>. + </p> </div> <div id="storage-error" class="storage-hidden"> @@ -425,8 +488,23 @@ </div></div> <div> - <button class="btn btn-default" id="storage-refresh">Refresh</button> + <button class="btn btn-default" id="storage-refresh"><span class="btn-inner-middle">Refresh</span></button> + <button class="btn btn-default storage-hidden ignore-form-changes" id="storage-persist-button"><span class="storage-button-inner"><input type="checkbox" class="btn-inner-middle storage-button-checkbox" id="storage-persist-button-checkbox" readonly /><span class="btn-inner-middle">Persistent Storage</span></span></button> </div> + + <p></p> + + <div data-show-for-browser="firefox-mobile"><div class="alert alert-warning storage-persist-fail-warning storage-hidden"> + <p>It may not be possible to enable Persistent Storage on Firefox for Android.</p> + </div></div> + + <div data-show-for-browser="chrome"><div class="alert alert-warning storage-persist-fail-warning storage-hidden"> + <p> + It may not be possible to enable Persistent Storage on Chrome-based browsers. + However, the Yomichan extension has permission for unlimited storage which should + prevent Chrome from deleting data.<sup><a href="https://bugs.chromium.org/p/chromium/issues/detail?id=680392#c15" target="_blank" rel="noopener">[1]</a></sup> + </p> + </div></div> </div> <div> @@ -441,13 +519,21 @@ <a href="https://foosoft.net/projects/anki-connect/" target="_blank" rel="noopener">AnkiConnect</a> plugin. </p> - <div class="alert alert-danger" id="anki-error"></div> - <div class="checkbox"> <label><input type="checkbox" id="anki-enable"> Enable Anki integration</label> </div> <div id="anki-general"> + <div data-show-for-operating-system="mac"> + <div class="alert alert-warning" id="anki-mac-warning"> + Notice for Mac OS X users: + If Yomichan has issues connecting to AnkiConnect, you may have to tweak some system settings. + See <a href="https://foosoft.net/projects/anki-connect/#notes-for-mac-os-x-users" target="_blank" rel="noopener">this link</a> for more details. + </div> + </div> + + <div class="alert alert-danger" id="anki-error"></div> + <div class="form-group"> <label for="card-tags">Card tags <span class="label-light">(comma or space separated)</span></label> <input type="text" id="card-tags" class="form-control"> @@ -591,6 +677,7 @@ <script src="/mixed/lib/jquery.min.js"></script> <script src="/mixed/lib/bootstrap/js/bootstrap.min.js"></script> <script src="/mixed/lib/handlebars.min.js"></script> + <script src="/mixed/lib/wanakana.min.js"></script> <script src="/mixed/js/extension.js"></script> @@ -605,6 +692,7 @@ <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> <script src="/bg/js/settings-profiles.js"></script> <script src="/bg/js/settings.js"></script> diff --git a/ext/fg/css/client.css b/ext/fg/css/client.css index a2b06d0f..633c88ef 100644 --- a/ext/fg/css/client.css +++ b/ext/fg/css/client.css @@ -17,11 +17,11 @@ */ -iframe#yomichan-float { +iframe.yomichan-float { all: initial; background-color: #fff; border: 1px solid #999; - box-shadow: 0 0 10px rgba(0, 0, 0, .5); + box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); position: fixed; resize: both; visibility: hidden; @@ -29,7 +29,14 @@ iframe#yomichan-float { box-sizing: border-box; } -iframe#yomichan-float.yomichan-float-full-width { +iframe.yomichan-float[data-yomichan-theme=dark], +iframe.yomichan-float[data-yomichan-theme=auto][data-yomichan-site-color=dark] { + background-color: #1e1e1e; + border: 1px solid #666; + box-shadow: 0 0 10px rgba(255, 255, 255, 0.5); +} + +iframe.yomichan-float.yomichan-float-full-width { border-left: none; border-right: none; left: 0 !important; @@ -39,13 +46,13 @@ iframe#yomichan-float.yomichan-float-full-width { resize: none; } -iframe#yomichan-float.yomichan-float-full-width:not(.yomichan-float-above) { +iframe.yomichan-float.yomichan-float-full-width:not(.yomichan-float-above) { border-bottom: none; top: auto !important; bottom: 0 !important; } -iframe#yomichan-float.yomichan-float-full-width.yomichan-float-above { +iframe.yomichan-float.yomichan-float-full-width.yomichan-float-above { border-top: none; top: 0 !important; bottom: auto !important; diff --git a/ext/fg/float.html b/ext/fg/float.html index fe1aee8f..580a7963 100644 --- a/ext/fg/float.html +++ b/ext/fg/float.html @@ -1,18 +1,12 @@ <!DOCTYPE html> -<html lang="en"> +<html lang="en" data-yomichan-page="float"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width,initial-scale=1" /> <title></title> - <link rel="stylesheet" href="/mixed/lib/bootstrap/css/bootstrap.min.css"> - <link rel="stylesheet" href="/mixed/lib/bootstrap/css/bootstrap-theme.min.css"> <link rel="stylesheet" href="/mixed/css/display.css"> - <style type="text/css"> - .entry, .note { - padding-left: 10px; - padding-right: 10px; - } - </style> + <link rel="stylesheet" type="text/css" href="/mixed/css/display-default.css" data-yomichan-theme-name="default"> + <link rel="stylesheet alternate" type="text/css" href="/mixed/css/display-dark.css" data-yomichan-theme-name="dark"> </head> <body> <div id="spinner"> diff --git a/ext/fg/js/api.js b/ext/fg/js/api.js index a553e514..b0746b85 100644 --- a/ext/fg/js/api.js +++ b/ext/fg/js/api.js @@ -49,8 +49,8 @@ function apiAudioGetUrl(definition, source, optionsContext) { return utilInvoke('audioGetUrl', {definition, source, optionsContext}); } -function apiCommandExec(command) { - return utilInvoke('commandExec', {command}); +function apiCommandExec(command, params) { + return utilInvoke('commandExec', {command, params}); } function apiScreenshotGet(options) { @@ -64,3 +64,11 @@ function apiForward(action, params) { function apiFrameInformationGet() { return utilInvoke('frameInformationGet'); } + +function apiInjectStylesheet(css) { + return utilInvoke('injectStylesheet', {css}); +} + +function apiGetEnvironmentInfo() { + return utilInvoke('getEnvironmentInfo'); +} diff --git a/ext/fg/js/document.js b/ext/fg/js/document.js index 94a68e6c..a168705e 100644 --- a/ext/fg/js/document.js +++ b/ext/fg/js/document.js @@ -27,8 +27,8 @@ function docImposterCreate(element, isTextarea) { const elementStyle = window.getComputedStyle(element); const elementRect = element.getBoundingClientRect(); const documentRect = document.documentElement.getBoundingClientRect(); - const left = elementRect.left - documentRect.left; - const top = elementRect.top - documentRect.top; + let left = elementRect.left - documentRect.left; + let top = elementRect.top - documentRect.top; // Container const container = document.createElement('div'); @@ -82,6 +82,12 @@ function docImposterCreate(element, isTextarea) { docSetImposterStyle(imposterStyle, 'width', `${width}px`); docSetImposterStyle(imposterStyle, 'height', `${height}px`); } + if (imposterRect.x !== elementRect.x || imposterRect.y !== elementRect.y) { + left += (elementRect.left - imposterRect.left); + top += (elementRect.top - imposterRect.top); + docSetImposterStyle(imposterStyle, 'left', `${left}px`); + docSetImposterStyle(imposterStyle, 'top', `${top}px`); + } imposter.scrollTop = element.scrollTop; imposter.scrollLeft = element.scrollLeft; diff --git a/ext/fg/js/float.js b/ext/fg/js/float.js index 8fdb6925..089c9422 100644 --- a/ext/fg/js/float.js +++ b/ext/fg/js/float.js @@ -21,39 +21,23 @@ class DisplayFloat extends Display { constructor() { super(document.querySelector('#spinner'), document.querySelector('#definitions')); this.autoPlayAudioTimer = null; - this.styleNode = null; this.optionsContext = { depth: 0, url: window.location.href }; - this.dependencies = Object.assign({}, this.dependencies, {docRangeFromPoint, docSentenceExtract}); - window.addEventListener('message', (e) => this.onMessage(e), false); } onError(error) { if (window.yomichan_orphaned) { - this.onOrphaned(); + this.setContentOrphaned(); } else { logError(error, true); } } - onOrphaned() { - const definitions = document.querySelector('#definitions'); - const errorOrphaned = document.querySelector('#error-orphaned'); - - if (definitions !== null) { - definitions.style.setProperty('display', 'none', 'important'); - } - - if (errorOrphaned !== null) { - errorOrphaned.style.setProperty('display', 'block', 'important'); - } - } - onSearchClear() { window.parent.postMessage('popupClose', '*'); } @@ -84,6 +68,10 @@ class DisplayFloat extends Display { super.onKeyDown(e); } + getOptionsContext() { + return this.optionsContext; + } + autoPlayAudio() { this.clearAutoPlayTimer(); this.autoPlayAudioTimer = window.setTimeout(() => super.autoPlayAudio(), 400); @@ -96,29 +84,15 @@ class DisplayFloat extends Display { } } - initialize(options, popupInfo, url) { - const css = options.general.customPopupCss; - if (css) { - this.setStyle(css); - } + async initialize(options, popupInfo, url, childrenSupported) { + await super.initialize(options); const {id, depth, parentFrameId} = popupInfo; this.optionsContext.depth = depth; this.optionsContext.url = url; - popupNestedInitialize(id, depth, parentFrameId, url); - } - - setStyle(css) { - const parent = document.head; - - if (this.styleNode === null) { - this.styleNode = document.createElement('style'); - } - - this.styleNode.textContent = css; - if (this.styleNode.parentNode !== parent) { - parent.appendChild(this.styleNode); + if (childrenSupported) { + popupNestedInitialize(id, depth, parentFrameId, url); } } } @@ -134,11 +108,10 @@ DisplayFloat.onKeyDownHandlers = { }; DisplayFloat.messageHandlers = { - termsShow: (self, {definitions, options, context}) => self.termsShow(definitions, options, context), - kanjiShow: (self, {definitions, options, context}) => self.kanjiShow(definitions, options, context), + setContent: (self, {type, details}) => self.setContent(type, details), clearAutoPlayTimer: (self) => self.clearAutoPlayTimer(), - orphaned: (self) => self.onOrphaned(), - initialize: (self, {options, popupInfo, url}) => self.initialize(options, popupInfo, url) + setCustomCss: (self, {css}) => self.setCustomCss(css), + initialize: (self, {options, popupInfo, url, childrenSupported}) => self.initialize(options, popupInfo, url, childrenSupported) }; window.yomichan_display = new DisplayFloat(); diff --git a/ext/fg/js/frontend-initialize.js b/ext/fg/js/frontend-initialize.js new file mode 100644 index 00000000..37a82faa --- /dev/null +++ b/ext/fg/js/frontend-initialize.js @@ -0,0 +1,20 @@ +/* + * Copyright (C) 2019 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/>. + */ + + +window.yomichan_frontend = Frontend.create(); diff --git a/ext/fg/js/frontend.js b/ext/fg/js/frontend.js index 88cb93a9..e854f74e 100644 --- a/ext/fg/js/frontend.js +++ b/ext/fg/js/frontend.js @@ -41,14 +41,18 @@ class Frontend { this.enabled = false; this.eventListeners = []; + + this.isPreparedPromiseResolve = null; + this.isPreparedPromise = new Promise((resolve) => { this.isPreparedPromiseResolve = resolve; }); + + this.lastShowPromise = Promise.resolve(); } static create() { - const initializationData = window.frontendInitializationData; - const isNested = (initializationData !== null && typeof initializationData === 'object'); - const {id, depth, parentFrameId, ignoreNodes, url} = isNested ? initializationData : {}; + const data = window.frontendInitializationData || {}; + const {id, depth=0, parentFrameId, ignoreNodes, url, proxy=false} = data; - const popup = isNested ? new PopupProxy(depth + 1, id, parentFrameId, url) : PopupProxyHost.instance.createPopup(null); + const popup = proxy ? new PopupProxy(depth + 1, id, parentFrameId, url) : PopupProxyHost.instance.createPopup(null, depth); const frontend = new Frontend(popup, ignoreNodes); frontend.prepare(); return frontend; @@ -59,11 +63,16 @@ class Frontend { await this.updateOptions(); chrome.runtime.onMessage.addListener(this.onRuntimeMessage.bind(this)); + this.isPreparedPromiseResolve(); } catch (e) { this.onError(e); } } + isPrepared() { + return this.isPreparedPromise; + } + onMouseOver(e) { if (e.target === this.popup.container && this.popupTimer !== null) { this.popupTimerClear(); @@ -130,8 +139,14 @@ class Frontend { } } - onResize() { - this.searchClear(false); + async onResize() { + if (this.textSourceLast !== null && await this.popup.isVisibleAsync()) { + const textSource = this.textSourceLast; + this.lastShowPromise = this.popup.showContent( + textSource.getRect(), + textSource.getWritingMode() + ); + } } onClick(e) { @@ -222,8 +237,8 @@ class Frontend { const handlers = Frontend.runtimeMessageHandlers; if (handlers.hasOwnProperty(action)) { const handler = handlers[action]; - handler(this, params); - callback(); + const result = handler(this, params); + callback(result); } } @@ -279,6 +294,7 @@ class Frontend { async updateOptions() { this.options = await apiOptionsGet(this.getOptionsContext()); this.setEnabled(this.options.general.enable); + await this.popup.setOptions(this.options); } popupTimerSet(callback) { @@ -303,6 +319,10 @@ class Frontend { } const textSource = docRangeFromPoint(x, y, this.options); + return await this.searchSource(textSource, cause); + } + + async searchSource(textSource, cause) { let hideResults = textSource === null; let searched = false; let success = false; @@ -318,10 +338,10 @@ class Frontend { } catch (e) { if (window.yomichan_orphaned) { if (textSource && this.options.scanning.modifier !== 'none') { - this.popup.showOrphaned( + this.lastShowPromise = this.popup.showContent( textSource.getRect(), textSource.getWritingMode(), - this.options + 'orphaned' ); } } else { @@ -357,12 +377,11 @@ class Frontend { const sentence = docSentenceExtract(textSource, this.options.anki.sentenceExt); const url = window.location.href; - this.popup.termsShow( + this.lastShowPromise = this.popup.showContent( textSource.getRect(), textSource.getWritingMode(), - definitions, - this.options, - {sentence, url, focus} + 'terms', + {definitions, context: {sentence, url, focus}} ); this.textSourceLast = textSource; @@ -388,12 +407,11 @@ class Frontend { const sentence = docSentenceExtract(textSource, this.options.anki.sentenceExt); const url = window.location.href; - this.popup.kanjiShow( + this.lastShowPromise = this.popup.showContent( textSource.getRect(), textSource.getWritingMode(), - definitions, - this.options, - {sentence, url, focus} + 'kanji', + {definitions, context: {sentence, url, focus}} ); this.textSourceLast = textSource; @@ -558,8 +576,9 @@ Frontend.runtimeMessageHandlers = { popupSetVisibleOverride: (self, {visible}) => { self.popup.setVisibleOverride(visible); + }, + + getUrl: () => { + return {url: window.location.href}; } }; - - -window.yomichan_frontend = Frontend.create(); diff --git a/ext/fg/js/popup-nested.js b/ext/fg/js/popup-nested.js index b36de2ec..cec95aea 100644 --- a/ext/fg/js/popup-nested.js +++ b/ext/fg/js/popup-nested.js @@ -35,13 +35,14 @@ async function popupNestedInitialize(id, depth, parentFrameId, url) { const ignoreNodes = options.scanning.enableOnPopupExpressions ? [] : [ '.expression', '.expression *' ]; - window.frontendInitializationData = {id, depth, parentFrameId, ignoreNodes, url}; + window.frontendInitializationData = {id, depth, parentFrameId, ignoreNodes, url, proxy: true}; const scriptSrcs = [ '/fg/js/frontend-api-sender.js', '/fg/js/popup.js', '/fg/js/popup-proxy.js', - '/fg/js/frontend.js' + '/fg/js/frontend.js', + '/fg/js/frontend-initialize.js' ]; for (const src of scriptSrcs) { const script = document.createElement('script'); diff --git a/ext/fg/js/popup-proxy-host.js b/ext/fg/js/popup-proxy-host.js index f933639c..d8dec4df 100644 --- a/ext/fg/js/popup-proxy-host.js +++ b/ext/fg/js/popup-proxy-host.js @@ -38,21 +38,23 @@ class PopupProxyHost { this.apiReceiver = new FrontendApiReceiver(`popup-proxy-host#${frameId}`, { createNestedPopup: ({parentId}) => this.createNestedPopup(parentId), - show: ({id, elementRect, options}) => this.show(id, elementRect, options), - showOrphaned: ({id, elementRect, options}) => this.show(id, elementRect, options), + setOptions: ({id, options}) => this.setOptions(id, options), hide: ({id, changeFocus}) => this.hide(id, changeFocus), + isVisibleAsync: ({id}) => this.isVisibleAsync(id), setVisibleOverride: ({id, visible}) => this.setVisibleOverride(id, visible), containsPoint: ({id, x, y}) => this.containsPoint(id, x, y), - termsShow: ({id, elementRect, writingMode, definitions, options, context}) => this.termsShow(id, elementRect, writingMode, definitions, options, context), - kanjiShow: ({id, elementRect, writingMode, definitions, options, context}) => this.kanjiShow(id, elementRect, writingMode, definitions, options, context), + showContent: ({id, elementRect, writingMode, type, details}) => this.showContent(id, elementRect, writingMode, type, details), + setCustomCss: ({id, css}) => this.setCustomCss(id, css), clearAutoPlayTimer: ({id}) => this.clearAutoPlayTimer(id) }); } - createPopup(parentId) { + createPopup(parentId, depth) { const parent = (typeof parentId === 'string' && this.popups.hasOwnProperty(parentId) ? this.popups[parentId] : null); - const depth = (parent !== null ? parent.depth + 1 : 0); const id = `${this.nextId}`; + if (parent !== null) { + depth = parent.depth + 1; + } ++this.nextId; const popup = new Popup(id, depth, this.frameIdPromise); if (parent !== null) { @@ -64,7 +66,7 @@ class PopupProxyHost { } async createNestedPopup(parentId) { - return this.createPopup(parentId).id; + return this.createPopup(parentId, 0).id; } getPopup(id) { @@ -86,26 +88,24 @@ class PopupProxyHost { return new DOMRect(x, y, jsonRect.width, jsonRect.height); } - async show(id, elementRect, options) { + async setOptions(id, options) { const popup = this.getPopup(id); - elementRect = this.jsonRectToDOMRect(popup, elementRect); - return await popup.show(elementRect, options); + return await popup.setOptions(options); } - async showOrphaned(id, elementRect, options) { + async hide(id, changeFocus) { const popup = this.getPopup(id); - elementRect = this.jsonRectToDOMRect(popup, elementRect); - return await popup.showOrphaned(elementRect, options); + return popup.hide(changeFocus); } - async hide(id, changeFocus) { + async isVisibleAsync(id) { const popup = this.getPopup(id); - return popup.hide(changeFocus); + return await popup.isVisibleAsync(); } async setVisibleOverride(id, visible) { const popup = this.getPopup(id); - return popup.setVisibleOverride(visible); + return await popup.setVisibleOverride(visible); } async containsPoint(id, x, y) { @@ -113,18 +113,16 @@ class PopupProxyHost { return await popup.containsPoint(x, y); } - async termsShow(id, elementRect, writingMode, definitions, options, context) { + async showContent(id, elementRect, writingMode, type, details) { const popup = this.getPopup(id); elementRect = this.jsonRectToDOMRect(popup, elementRect); - if (!PopupProxyHost.popupCanShow(popup)) { return false; } - return await popup.termsShow(elementRect, writingMode, definitions, options, context); + if (!PopupProxyHost.popupCanShow(popup)) { return Promise.resolve(false); } + return await popup.showContent(elementRect, writingMode, type, details); } - async kanjiShow(id, elementRect, writingMode, definitions, options, context) { + async setCustomCss(id, css) { const popup = this.getPopup(id); - elementRect = this.jsonRectToDOMRect(popup, elementRect); - if (!PopupProxyHost.popupCanShow(popup)) { return false; } - return await popup.kanjiShow(elementRect, writingMode, definitions, options, context); + return popup.setCustomCss(css); } async clearAutoPlayTimer(id) { diff --git a/ext/fg/js/popup-proxy.js b/ext/fg/js/popup-proxy.js index efbd28b2..e62a4868 100644 --- a/ext/fg/js/popup-proxy.js +++ b/ext/fg/js/popup-proxy.js @@ -46,16 +46,9 @@ class PopupProxy { return id; } - async show(elementRect, options) { + async setOptions(options) { const id = await this.getPopupId(); - elementRect = PopupProxy.DOMRectToJson(elementRect); - return await this.invokeHostApi('show', {id, elementRect, options}); - } - - async showOrphaned(elementRect, options) { - const id = await this.getPopupId(); - elementRect = PopupProxy.DOMRectToJson(elementRect); - return await this.invokeHostApi('showOrphaned', {id, elementRect, options}); + return await this.invokeHostApi('setOptions', {id, options}); } async hide(changeFocus) { @@ -65,6 +58,11 @@ class PopupProxy { return await this.invokeHostApi('hide', {id: this.id, changeFocus}); } + async isVisibleAsync() { + const id = await this.getPopupId(); + return await this.invokeHostApi('isVisibleAsync', {id}); + } + async setVisibleOverride(visible) { const id = await this.getPopupId(); return await this.invokeHostApi('setVisibleOverride', {id, visible}); @@ -77,16 +75,15 @@ class PopupProxy { return await this.invokeHostApi('containsPoint', {id: this.id, x, y}); } - async termsShow(elementRect, writingMode, definitions, options, context) { + async showContent(elementRect, writingMode, type=null, details=null) { const id = await this.getPopupId(); elementRect = PopupProxy.DOMRectToJson(elementRect); - return await this.invokeHostApi('termsShow', {id, elementRect, writingMode, definitions, options, context}); + return await this.invokeHostApi('showContent', {id, elementRect, writingMode, type, details}); } - async kanjiShow(elementRect, writingMode, definitions, options, context) { + async setCustomCss(css) { const id = await this.getPopupId(); - elementRect = PopupProxy.DOMRectToJson(elementRect); - return await this.invokeHostApi('kanjiShow', {id, elementRect, writingMode, definitions, options, context}); + return await this.invokeHostApi('setCustomCss', {id, css}); } async clearAutoPlayTimer() { diff --git a/ext/fg/js/popup.js b/ext/fg/js/popup.js index 9ca91afa..b5eb9fe2 100644 --- a/ext/fg/js/popup.js +++ b/ext/fg/js/popup.js @@ -25,8 +25,9 @@ class Popup { this.frameId = null; this.parent = null; this.child = null; + this.childrenSupported = true; this.container = document.createElement('iframe'); - this.container.id = 'yomichan-float'; + this.container.className = 'yomichan-float'; this.container.addEventListener('mousedown', e => e.stopPropagation()); this.container.addEventListener('scroll', e => e.stopPropagation()); this.container.setAttribute('src', chrome.extension.getURL('/fg/float.html')); @@ -36,17 +37,19 @@ class Popup { this.isInjected = false; this.visible = false; this.visibleOverride = null; + this.options = null; + this.stylesheetInjectedViaApi = false; this.updateVisibility(); } - inject(options) { + inject() { if (this.injectPromise === null) { - this.injectPromise = this.createInjectPromise(options); + this.injectPromise = this.createInjectPromise(); } return this.injectPromise; } - async createInjectPromise(options) { + async createInjectPromise() { try { const {frameId} = await this.frameIdPromise; if (typeof frameId === 'number') { @@ -60,30 +63,44 @@ class Popup { const parentFrameId = (typeof this.frameId === 'number' ? this.frameId : null); this.container.addEventListener('load', () => { this.invokeApi('initialize', { - options: { - general: { - customPopupCss: options.general.customPopupCss - } - }, + options: this.options, popupInfo: { id: this.id, depth: this.depth, parentFrameId }, - url: this.url + url: this.url, + childrenSupported: this.childrenSupported }); resolve(); }); this.observeFullscreen(); this.onFullscreenChanged(); + this.setCustomOuterCss(this.options.general.customPopupOuterCss, false); this.isInjected = true; }); } - async show(elementRect, writingMode, options) { - await this.inject(options); + isInitialized() { + return this.options !== null; + } + + async setOptions(options) { + this.options = options; + this.updateTheme(); + } + + async showContent(elementRect, writingMode, type=null, details=null) { + if (!this.isInitialized()) { return; } + await this.show(elementRect, writingMode); + if (type === null) { return; } + this.invokeApi('setContent', {type, details}); + } - const optionsGeneral = options.general; + async show(elementRect, writingMode) { + await this.inject(); + + const optionsGeneral = this.options.general; const container = this.container; const containerRect = container.getBoundingClientRect(); const getPosition = ( @@ -208,11 +225,6 @@ class Popup { return [position, size, after]; } - async showOrphaned(elementRect, writingMode, options) { - await this.show(elementRect, writingMode, options); - this.invokeApi('orphaned'); - } - hide(changeFocus) { if (!this.isVisible()) { return; @@ -227,6 +239,10 @@ class Popup { } } + async isVisibleAsync() { + return this.isVisible(); + } + isVisible() { return this.isInjected && (this.visibleOverride !== null ? this.visibleOverride : this.visible); } @@ -261,6 +277,44 @@ class Popup { } } + updateTheme() { + this.container.dataset.yomichanTheme = this.options.general.popupOuterTheme; + this.container.dataset.yomichanSiteColor = this.getSiteColor(); + } + + getSiteColor() { + const color = [255, 255, 255]; + Popup.addColor(color, Popup.getColorInfo(window.getComputedStyle(document.documentElement).backgroundColor)); + Popup.addColor(color, Popup.getColorInfo(window.getComputedStyle(document.body).backgroundColor)); + const dark = (color[0] < 128 && color[1] < 128 && color[2] < 128); + return dark ? 'dark' : 'light'; + } + + static addColor(target, color) { + if (color === null) { return; } + + const a = color[3]; + if (a <= 0.0) { return; } + + const aInv = 1.0 - a; + for (let i = 0; i < 3; ++i) { + target[i] = target[i] * aInv + color[i] * a; + } + } + + static getColorInfo(cssColor) { + const m = /^\s*rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d\.]+)\s*)?\)\s*$/.exec(cssColor); + if (m === null) { return null; } + + const m4 = m[4]; + return [ + Number.parseInt(m[1], 10), + Number.parseInt(m[2], 10), + Number.parseInt(m[3], 10), + m4 ? Math.max(0.0, Math.min(1.0, Number.parseFloat(m4))) : 1.0 + ]; + } + async containsPoint(x, y) { for (let popup = this; popup !== null && popup.isVisible(); popup = popup.child) { const rect = popup.container.getBoundingClientRect(); @@ -271,14 +325,25 @@ class Popup { return false; } - async termsShow(elementRect, writingMode, definitions, options, context) { - await this.show(elementRect, writingMode, options); - this.invokeApi('termsShow', {definitions, options, context}); + async setCustomCss(css) { + this.invokeApi('setCustomCss', {css}); } - async kanjiShow(elementRect, writingMode, definitions, options, context) { - await this.show(elementRect, writingMode, options); - this.invokeApi('kanjiShow', {definitions, options, context}); + async setCustomOuterCss(css, injectDirectly) { + // Cannot repeatedly inject stylesheets using web extension APIs since there is no way to remove them. + if (this.stylesheetInjectedViaApi) { return; } + + if (injectDirectly || Popup.isOnExtensionPage()) { + Popup.injectOuterStylesheet(css); + } else { + if (!css) { return; } + try { + await apiInjectStylesheet(css); + this.stylesheetInjectedViaApi = true; + } catch (e) { + // NOP + } + } } clearAutoPlayTimer() { @@ -322,4 +387,35 @@ class Popup { get url() { return window.location.href; } + + static isOnExtensionPage() { + try { + const url = chrome.runtime.getURL('/'); + return window.location.href.substr(0, url.length) === url; + } catch (e) { + // NOP + } + } + + static injectOuterStylesheet(css) { + if (Popup.outerStylesheet === null) { + if (!css) { return; } + Popup.outerStylesheet = document.createElement('style'); + Popup.outerStylesheet.id = "yomichan-popup-outer-stylesheet"; + } + + const outerStylesheet = Popup.outerStylesheet; + if (css) { + outerStylesheet.textContent = css; + + const par = document.head; + if (par && outerStylesheet.parentNode !== par) { + par.appendChild(outerStylesheet); + } + } else { + outerStylesheet.textContent = ''; + } + } } + +Popup.outerStylesheet = null; diff --git a/ext/fg/js/source.js b/ext/fg/js/source.js index ee4f58e2..c3da9f46 100644 --- a/ext/fg/js/source.js +++ b/ext/fg/js/source.js @@ -229,13 +229,29 @@ class TextSourceRange { } static getElementWritingMode(element) { - if (element === null) { - return 'horizontal-tb'; + if (element !== null) { + const style = window.getComputedStyle(element); + const writingMode = style.writingMode; + if (typeof writingMode === 'string') { + return TextSourceRange.normalizeWritingMode(writingMode); + } } + return 'horizontal-tb'; + } - const style = window.getComputedStyle(element); - const writingMode = style.writingMode; - return typeof writingMode === 'string' ? writingMode : 'horizontal-tb'; + static normalizeWritingMode(writingMode) { + switch (writingMode) { + case 'lr': + case 'lr-tb': + case 'rl': + return 'horizontal-tb'; + case 'tb': + return 'vertical-lr'; + case 'tb-rl': + return 'vertical-rl'; + default: + return writingMode; + } } static getNodesInRange(range) { diff --git a/ext/manifest.json b/ext/manifest.json index c69b556f..d4a72c8a 100644 --- a/ext/manifest.json +++ b/ext/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "Yomichan (testing)", - "version": "1.8.8", + "version": "1.8.9", "description": "Japanese dictionary with Anki integration (testing)", "icons": {"16": "mixed/img/icon16.png", "48": "mixed/img/icon48.png", "128": "mixed/img/icon128.png"}, @@ -26,7 +26,8 @@ "fg/js/source.js", "fg/js/util.js", "fg/js/popup-proxy-host.js", - "fg/js/frontend.js" + "fg/js/frontend.js", + "fg/js/frontend-initialize.js" ], "css": ["fg/css/client.css"], "all_frames": true @@ -40,7 +41,8 @@ "permissions": [ "<all_urls>", "storage", - "clipboardWrite" + "clipboardWrite", + "unlimitedStorage" ], "commands": { "toggle": { diff --git a/ext/mixed/css/display-dark.css b/ext/mixed/css/display-dark.css new file mode 100644 index 00000000..34a0ccd1 --- /dev/null +++ b/ext/mixed/css/display-dark.css @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2019 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 entrys 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/>. + */ + + +body { background-color: #1e1e1e; color: #d4d4d4; } + +hr { border-top-color: #2f2f2f; } + +.tag-default { background-color: #69696e; } +.tag-name { background-color: #489148; } +.tag-expression { background-color: #b07f39; } +.tag-popular { background-color: #025caa; } +.tag-frequent { background-color: #4490a7; } +.tag-archaism { background-color: #b04340; } +.tag-dictionary { background-color: #9057ad; } +.tag-frequency { background-color: #489148; } +.tag-partOfSpeech { background-color: #565656; } + +.reasons { color: #888888; } +.glossary li { color: #888888; } +.glossary-item { color: #d4d4d4; } +.label { color: #e1e1e1; } + +.expression .kanji-link { + border-bottom-color: #888888; + color: #CCCCCC; +} + +.expression-popular, .expression-popular .kanji-link { + color: #0275d8; +} + +.expression-rare, .expression-rare .kanji-link { + color: #666666; +} diff --git a/ext/mixed/css/display-default.css b/ext/mixed/css/display-default.css new file mode 100644 index 00000000..176c5387 --- /dev/null +++ b/ext/mixed/css/display-default.css @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2019 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 entrys 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/>. + */ + + +body { background-color: #ffffff; color: #333333; } + +hr { border-top-color: #eeeeee; } + +.tag-default { background-color: #8a8a91; } +.tag-name { background-color: #5cb85c; } +.tag-expression { background-color: #f0ad4e; } +.tag-popular { background-color: #0275d8; } +.tag-frequent { background-color: #5bc0de; } +.tag-archaism { background-color: #d9534f; } +.tag-dictionary { background-color: #aa66cc; } +.tag-frequency { background-color: #5cb85c; } +.tag-partOfSpeech { background-color: #565656; } + +.reasons { color: #777777; } +.glossary li { color: #777777; } +.glossary-item { color: #000000; } +.label { color: #ffffff; } + +.expression .kanji-link { + border-bottom-color: #777777; + color: #333333; +} + +.expression-popular, .expression-popular .kanji-link { + color: #0275d8; +} + +.expression-rare, .expression-rare .kanji-link { + color: #999999; +} diff --git a/ext/mixed/css/display.css b/ext/mixed/css/display.css index 8a4cf4a7..7793ddeb 100644 --- a/ext/mixed/css/display.css +++ b/ext/mixed/css/display.css @@ -30,9 +30,31 @@ * General */ +html:root[data-yomichan-page=float]:not([data-yomichan-theme]), +html:root[data-yomichan-page=float]:not([data-yomichan-theme]) body { + background-color: transparent; +} + +body { + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 14px; + line-height: 1.42857143; + margin: 0; + border: 0; + padding: 0; +} + hr { padding: 0px; margin: 0px; + border: 0; + border-top-width: 1px; + border-top-style: solid; +} + +ol, ul { + margin-top: 0; + margin-bottom: 10px; } #spinner { @@ -60,40 +82,10 @@ hr { padding-bottom: 10px; } -.tag-default { - background-color: #8a8a91; -} - -.tag-name { - background-color: #5cb85c; -} - -.tag-expression { - background-color: #f0ad4e; -} - -.tag-popular { - background-color: #0275d8; -} - -.tag-frequent { - background-color: #5bc0de; -} - -.tag-archaism { - background-color: #d9534f; -} - -.tag-dictionary { - background-color: #aa66cc; -} - -.tag-frequency { - background-color: #5cb85c; -} - -.tag-partOfSpeech { - background-color: #565656; +html:root[data-yomichan-page=float] .entry, +html:root[data-yomichan-page=float] .note { + padding-left: 10px; + padding-right: 10px; } .actions .disabled { @@ -103,6 +95,7 @@ hr { .actions .disabled img { -webkit-filter: grayscale(100%); + filter: grayscale(100%); opacity: 0.25; } @@ -111,7 +104,7 @@ hr { } .actions { - display: inline-block; + display: block; float: right; } @@ -127,19 +120,11 @@ hr { } .expression .kanji-link { - border-bottom: 1px #777 dashed; - color: #333; + border-bottom-width: 1px; + border-bottom-style: dashed; text-decoration: none; } -.expression-popular, .expression-popular .kanji-link { - color: #0275d8; -} - -.expression-rare, .expression-rare .kanji-link { - color: #999; -} - .expression .peek-wrapper { font-size: 14px; white-space: nowrap; @@ -173,7 +158,6 @@ hr { } .reasons { - color: #777; display: inline-block; } @@ -199,14 +183,6 @@ hr { content: " | "; } -.glossary li { - color: #777; -} - -.glossary-item { - color: #000; -} - div.glossary-item.compact-glossary { display: inline; } @@ -234,3 +210,15 @@ div.glossary-item.compact-glossary { .entry:not(.entry-current) .current { display: none; } + +.label { + display: inline; + padding: 0.2em 0.6em 0.3em; + font-size: 75%; + font-weight: 700; + line-height: 1; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + border-radius: 0.25em; +} diff --git a/ext/mixed/js/audio.js b/ext/mixed/js/audio.js index b905140c..cf8b8d24 100644 --- a/ext/mixed/js/audio.js +++ b/ext/mixed/js/audio.js @@ -17,7 +17,90 @@ */ -function audioGetFromUrl(url) { +class TextToSpeechAudio { + constructor(text, voice) { + this.text = text; + this.voice = voice; + this._utterance = null; + this._volume = 1; + } + + get currentTime() { + return 0; + } + set currentTime(value) { + // NOP + } + + get volume() { + return this._volume; + } + set volume(value) { + this._volume = value; + if (this._utterance !== null) { + this._utterance.volume = value; + } + } + + play() { + try { + if (this._utterance === null) { + this._utterance = new SpeechSynthesisUtterance(this.text || ''); + this._utterance.lang = 'ja-JP'; + this._utterance.volume = this._volume; + this._utterance.voice = this.voice; + } + + speechSynthesis.cancel(); + speechSynthesis.speak(this._utterance); + + } catch (e) { + // NOP + } + } + + pause() { + try { + speechSynthesis.cancel(); + } catch (e) { + // NOP + } + } + + static createFromUri(ttsUri) { + const m = /^tts:[^#\?]*\?([^#]*)/.exec(ttsUri); + if (m === null) { return null; } + + const searchParameters = {}; + for (const group of m[1].split('&')) { + const sep = group.indexOf('='); + if (sep < 0) { continue; } + searchParameters[decodeURIComponent(group.substr(0, sep))] = decodeURIComponent(group.substr(sep + 1)); + } + + if (!searchParameters.text) { return null; } + + const voice = audioGetTextToSpeechVoice(searchParameters.voice); + if (voice === null) { return null; } + + return new TextToSpeechAudio(searchParameters.text, voice); + } + +} + +function audioGetFromUrl(url, download) { + const tts = TextToSpeechAudio.createFromUri(url); + if (tts !== null) { + if (download) { + throw new Error('Download not supported for text-to-speech'); + } + return Promise.resolve(tts); + } + + if (download) { + return Promise.resolve(null); + } + return new Promise((resolve, reject) => { const audio = new Audio(url); audio.addEventListener('loadeddata', () => { @@ -32,7 +115,7 @@ function audioGetFromUrl(url) { }); } -async function audioGetFromSources(expression, sources, optionsContext, createAudioObject, cache=null) { +async function audioGetFromSources(expression, sources, optionsContext, download, cache=null) { const key = `${expression.expression}:${expression.reading}`; if (cache !== null && cache.hasOwnProperty(expression)) { return cache[key]; @@ -46,7 +129,7 @@ async function audioGetFromSources(expression, sources, optionsContext, createAu } try { - const audio = createAudioObject ? await audioGetFromUrl(url) : null; + const audio = await audioGetFromUrl(url, download); const result = {audio, url, source}; if (cache !== null) { cache[key] = result; @@ -56,5 +139,42 @@ async function audioGetFromSources(expression, sources, optionsContext, createAu // NOP } } - return {audio: null, source: null}; + return {audio: null, url: null, source: null}; +} + +function audioGetTextToSpeechVoice(voiceURI) { + try { + for (const voice of speechSynthesis.getVoices()) { + if (voice.voiceURI === voiceURI) { + return voice; + } + } + } catch (e) { + // NOP + } + return null; +} + +function audioPrepareTextToSpeech(options) { + if ( + audioPrepareTextToSpeech.state || + !options.audio.textToSpeechVoice || + !( + options.audio.sources.includes('text-to-speech') || + options.audio.sources.includes('text-to-speech-reading') + ) + ) { + // Text-to-speech not in use. + return; + } + + // Chrome needs this value called once before it will become populated. + // The first call will return an empty list. + audioPrepareTextToSpeech.state = true; + try { + speechSynthesis.getVoices(); + } catch (e) { + // NOP + } } +audioPrepareTextToSpeech.state = false; diff --git a/ext/mixed/js/display.js b/ext/mixed/js/display.js index 22181301..b40228b0 100644 --- a/ext/mixed/js/display.js +++ b/ext/mixed/js/display.js @@ -29,15 +29,16 @@ class Display { this.audioPlaying = null; this.audioFallback = null; this.audioCache = {}; - this.optionsContext = {}; - this.eventListeners = []; + this.styleNode = null; - this.dependencies = {}; + this.eventListeners = []; + this.persistentEventListeners = []; + this.interactive = false; + this.eventListenersActive = false; this.windowScroll = new WindowScroll(); - document.addEventListener('keydown', this.onKeyDown.bind(this)); - document.addEventListener('wheel', this.onWheel.bind(this), {passive: false}); + this.setInteractive(true); } onError(error) { @@ -73,8 +74,8 @@ class Display { context.source.source = this.context.source; } - const kanjiDefs = await apiKanjiFind(link.textContent, this.optionsContext); - this.kanjiShow(kanjiDefs, this.options, context); + const definitions = await apiKanjiFind(link.textContent, this.getOptionsContext()); + this.setContentKanji(definitions, context); } catch (e) { this.onError(e); } @@ -84,8 +85,6 @@ class Display { try { e.preventDefault(); - const {docRangeFromPoint, docSentenceExtract} = this.dependencies; - const clickedElement = e.target; const textSource = docRangeFromPoint(e.clientX, e.clientY, this.options); if (textSource === null) { @@ -96,7 +95,7 @@ class Display { try { textSource.setEndOffset(this.options.scanning.length); - ({definitions, length} = await apiTermsFind(textSource.text(), this.optionsContext)); + ({definitions, length} = await apiTermsFind(textSource.text(), this.getOptionsContext())); if (definitions.length === 0) { return false; } @@ -123,7 +122,7 @@ class Display { context.source.source = this.context.source; } - this.termsShow(definitions, this.options, context); + this.setContentTerms(definitions, context); } catch (e) { this.onError(e); } @@ -172,16 +171,124 @@ class Display { } } - async termsShow(definitions, options, context) { + onRuntimeMessage({action, params}, sender, callback) { + const handlers = Display.runtimeMessageHandlers; + if (handlers.hasOwnProperty(action)) { + const handler = handlers[action]; + const result = handler(this, params); + callback(result); + } + } + + getOptionsContext() { + throw new Error('Override me'); + } + + isInitialized() { + return this.options !== null; + } + + async initialize(options=null) { + await this.updateOptions(options); + chrome.runtime.onMessage.addListener(this.onRuntimeMessage.bind(this)); + } + + async updateOptions(options) { + this.options = options ? options : await apiOptionsGet(this.getOptionsContext()); + this.updateTheme(this.options.general.popupTheme); + this.setCustomCss(this.options.general.customPopupCss); + audioPrepareTextToSpeech(this.options); + } + + updateTheme(themeName) { + document.documentElement.dataset.yomichanTheme = themeName; + + const stylesheets = document.querySelectorAll('link[data-yomichan-theme-name]'); + for (const stylesheet of stylesheets) { + const match = (stylesheet.dataset.yomichanThemeName === themeName); + stylesheet.rel = (match ? 'stylesheet' : 'stylesheet alternate'); + } + } + + setCustomCss(css) { + if (this.styleNode === null) { + if (css.length === 0) { return; } + this.styleNode = document.createElement('style'); + } + + this.styleNode.textContent = css; + + const parent = document.head; + if (this.styleNode.parentNode !== parent) { + parent.appendChild(this.styleNode); + } + } + + setInteractive(interactive) { + interactive = !!interactive; + if (this.interactive === interactive) { return; } + this.interactive = interactive; + + if (interactive) { + Display.addEventListener(this.persistentEventListeners, document, 'keydown', this.onKeyDown.bind(this), false); + Display.addEventListener(this.persistentEventListeners, document, 'wheel', this.onWheel.bind(this), {passive: false}); + } else { + Display.clearEventListeners(this.persistentEventListeners); + } + this.setEventListenersActive(this.eventListenersActive); + } + + setEventListenersActive(active) { + active = !!active && this.interactive; + if (this.eventListenersActive === active) { return; } + this.eventListenersActive = active; + + if (active) { + this.addEventListeners('.action-add-note', 'click', this.onNoteAdd.bind(this)); + this.addEventListeners('.action-view-note', 'click', this.onNoteView.bind(this)); + this.addEventListeners('.action-play-audio', 'click', this.onAudioPlay.bind(this)); + this.addEventListeners('.kanji-link', 'click', this.onKanjiLookup.bind(this)); + this.addEventListeners('.source-term', 'click', this.onSourceTermView.bind(this)); + if (this.options.scanning.enablePopupSearch) { + this.addEventListeners('.glossary-item', 'click', this.onTermLookup.bind(this)); + } + } else { + Display.clearEventListeners(this.eventListeners); + } + } + + addEventListeners(selector, type, listener, options) { + this.container.querySelectorAll(selector).forEach((node) => { + Display.addEventListener(this.eventListeners, node, type, listener, options); + }); + } + + setContent(type, details) { + switch (type) { + case 'terms': + return this.setContentTerms(details.definitions, details.context); + case 'kanji': + return this.setContentKanji(details.definitions, details.context); + case 'orphaned': + return this.setContentOrphaned(); + default: + return Promise.resolve(); + } + } + + async setContentTerms(definitions, context) { + if (!this.isInitialized()) { return; } + try { - this.clearEventListeners(); + const options = this.options; + + this.setEventListenersActive(false); if (!context || context.focus !== false) { window.focus(); } this.definitions = definitions; - this.options = options; this.context = context; const sequence = ++this.sequence; @@ -211,18 +318,11 @@ class Display { const {index, scroll} = context || {}; this.entryScrollIntoView(index || 0, scroll); - if (this.options.audio.enabled && this.options.audio.autoPlay) { + if (options.audio.enabled && options.audio.autoPlay) { this.autoPlayAudio(); } - this.addEventListeners('.action-add-note', 'click', this.onNoteAdd.bind(this)); - this.addEventListeners('.action-view-note', 'click', this.onNoteView.bind(this)); - this.addEventListeners('.action-play-audio', 'click', this.onAudioPlay.bind(this)); - this.addEventListeners('.kanji-link', 'click', this.onKanjiLookup.bind(this)); - this.addEventListeners('.source-term', 'click', this.onSourceTermView.bind(this)); - if (this.options.scanning.enablePopupSearch) { - this.addEventListeners('.glossary-item', 'click', this.onTermLookup.bind(this)); - } + this.setEventListenersActive(true); await this.adderButtonUpdate(['term-kanji', 'term-kana'], sequence); } catch (e) { @@ -230,16 +330,19 @@ class Display { } } - async kanjiShow(definitions, options, context) { + async setContentKanji(definitions, context) { + if (!this.isInitialized()) { return; } + try { - this.clearEventListeners(); + const options = this.options; + + this.setEventListenersActive(false); if (!context || context.focus !== false) { window.focus(); } this.definitions = definitions; - this.options = options; this.context = context; const sequence = ++this.sequence; @@ -265,9 +368,7 @@ class Display { const {index, scroll} = context || {}; this.entryScrollIntoView(index || 0, scroll); - this.addEventListeners('.action-add-note', 'click', this.onNoteAdd.bind(this)); - this.addEventListeners('.action-view-note', 'click', this.onNoteView.bind(this)); - this.addEventListeners('.source-term', 'click', this.onSourceTermView.bind(this)); + this.setEventListenersActive(true); await this.adderButtonUpdate(['kanji'], sequence); } catch (e) { @@ -275,13 +376,26 @@ class Display { } } + async setContentOrphaned() { + const definitions = document.querySelector('#definitions'); + const errorOrphaned = document.querySelector('#error-orphaned'); + + if (definitions !== null) { + definitions.style.setProperty('display', 'none', 'important'); + } + + if (errorOrphaned !== null) { + errorOrphaned.style.setProperty('display', 'block', 'important'); + } + } + autoPlayAudio() { this.audioPlay(this.definitions[0], this.firstExpressionIndex, 0); } async adderButtonUpdate(modes, sequence) { try { - const states = await apiDefinitionsAddable(this.definitions, modes, this.optionsContext); + const states = await apiDefinitionsAddable(this.definitions, modes, this.getOptionsContext()); if (!states || sequence !== this.sequence) { return; } @@ -353,7 +467,7 @@ class Display { source: this.context.source.source }; - this.termsShow(this.context.source.definitions, this.options, context); + this.setContentTerms(this.context.source.definitions, context); } } @@ -383,7 +497,7 @@ class Display { } } - const noteId = await apiDefinitionAdd(definition, mode, context, this.optionsContext); + const noteId = await apiDefinitionAdd(definition, mode, context, this.getOptionsContext()); if (noteId) { const index = this.definitions.indexOf(definition); const adderButton = this.adderButtonFind(index, mode); @@ -413,7 +527,7 @@ class Display { } const sources = this.options.audio.sources; - let {audio, source} = await audioGetFromSources(expression, sources, this.optionsContext, true, this.audioCache); + let {audio, source} = await audioGetFromSources(expression, sources, this.getOptionsContext(), false, this.audioCache); let info; if (audio === null) { if (this.audioFallback === null) { @@ -544,18 +658,16 @@ class Display { return -1; } - addEventListeners(selector, type, listener, options) { - this.container.querySelectorAll(selector).forEach((node) => { - node.addEventListener(type, listener, options); - this.eventListeners.push([node, type, listener, options]); - }); + static addEventListener(eventListeners, object, type, listener, options) { + object.addEventListener(type, listener, options); + eventListeners.push([object, type, listener, options]); } - clearEventListeners() { - for (const [node, type, listener, options] of this.eventListeners) { - node.removeEventListener(type, listener, options); + static clearEventListeners(eventListeners) { + for (const [object, type, listener, options] of eventListeners) { + object.removeEventListener(type, listener, options); } - this.eventListeners = []; + eventListeners.length = 0; } static getElementTop(element) { @@ -675,3 +787,7 @@ Display.onKeyDownHandlers = { return false; } }; + +Display.runtimeMessageHandlers = { + optionsUpdate: (self) => self.updateOptions(null) +}; diff --git a/tmpl/terms.html b/tmpl/terms.html index 0b967bb5..3dee43d7 100644 --- a/tmpl/terms.html +++ b/tmpl/terms.html @@ -1,29 +1,31 @@ {{#*inline "definition"}} -{{#if definitionTags}} -<div {{#if compactGlossaries}}class="compact-info"{{/if}}> - {{#each definitionTags}} - <span class="label label-default tag-{{category}}" title="{{notes}}">{{name}}</span> - {{/each}} -</div> -{{/if}} -{{#if only}} -<div {{#if compactGlossaries}}class="compact-info"{{/if}}> - ( - {{~#each only~}} - {{{.}}}{{#unless @last}}, {{/unless}} - {{/each}} - only) +<div class="dict-{{#sanitizeCssClass}}{{dictionary}}{{/sanitizeCssClass}}"> + {{#if definitionTags}} + <div {{#if compactGlossaries}}class="compact-info"{{/if}}> + {{#each definitionTags}} + <span class="label label-default tag-{{category}}" title="{{notes}}">{{name}}</span> + {{/each}} + </div> + {{/if}} + {{#if only}} + <div {{#if compactGlossaries}}class="compact-info"{{/if}}> + ( + {{~#each only~}} + {{{.}}}{{#unless @last}}, {{/unless}} + {{/each}} + only) + </div> + {{/if}} + {{#if glossary.[1]}} + <ul {{#if compactGlossaries}}class="compact-glossary"{{/if}}> + {{#each glossary}} + <li><span class="glossary-item">{{#multiLine}}{{.}}{{/multiLine}}</span></li> + {{/each}} + </ul> + {{else}} + <div class="glossary-item {{#if compactGlossaries}}compact-glossary{{/if}}">{{#multiLine}}{{glossary.[0]}}{{/multiLine}}</div> + {{/if}} </div> -{{/if}} -{{#if glossary.[1]}} -<ul {{#if compactGlossaries}}class="compact-glossary"{{/if}}> - {{#each glossary}} - <li><span class="glossary-item">{{#multiLine}}{{.}}{{/multiLine}}</span></li> - {{/each}} -</ul> -{{else}} -<div class="glossary-item {{#if compactGlossaries}}compact-glossary{{/if}}">{{#multiLine}}{{glossary.[0]}}{{/multiLine}}</div> -{{/if}} {{/inline}} {{#*inline "term"}} |