diff options
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"}} |