Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)

Improve popup performance (#1487)

* batch media lookups, eliminate base64ing

* properly abort if lookup is interrupted

* Begin to add performance marks

* batch media lookups, eliminate base64ing

* [WIP] attempt to use OffscreenCanvas

* Add worker to offload some stuff off the main thread (except SVG rasterization, because that needs to be on the main thread...)

* swap to rendering SVGs on worker thread using resvg-js/wasm

* further tweaking, add more debug logging

* remove some unnecessary asyncification done earlier in the PR, and add more logging

* remove some dead code and add more logging

* make SW instruct offscreen to create port if non-existent

* make SW instruct offscreen to create port when SW restarts

* Add typed interfaces for most of the new message passing. Remove a bit of dead code

* fix image rendering in Anki, more performance marks

* redo all message passing to make it work on both Chrome and FF

* begin cleanup

* cleanup

* make it work on FF133 which implements ImageDecoder but doesn't have cross-process transferrable VideoFrames

* remove console.logs

* remove unused alt constant

* fix no-undef errors

* fix ts errors

* clean up css, make tests pass

* fix benches

* fix remaining test by doing some upgrades

* cleanup, improve clarity in some places

* Replace performance with safePerformance

* Cleanup to resolve some PR comments

* fix issues discovered by Kuuuube

---------

Co-authored-by: kuuuube <hexagonisalie@gmail.com>

authored by

Darius Jahandarie
kuuuube
and committed by
GitHub
3142fa91 120f0af4

+1271 -459
+3
.eslintrc.json
··· 558 558 { 559 559 "files": [ 560 560 "ext/js/core/api-map.js", 561 + "ext/js/core/event-listener-collection.js", 561 562 "ext/js/core/extension-error.js", 562 563 "ext/js/core/json.js", 563 564 "ext/js/data/anki-note-data-creator.js", 564 565 "ext/js/dictionary/dictionary-data-util.js", 566 + "ext/js/display/display-content-manager.js", 565 567 "ext/js/display/pronunciation-generator.js", 566 568 "ext/js/display/structured-content-generator.js", 567 569 "ext/js/dom/css-style-applier.js", ··· 582 584 }, 583 585 { 584 586 "files": [ 587 + "ext/js/core/api-map.js", 585 588 "ext/js/core/event-dispatcher.js", 586 589 "ext/js/core/extension-error.js", 587 590 "ext/js/core/json.js",
+7
benches/jsconfig.json
··· 23 23 }, 24 24 "types": [ 25 25 "chrome", 26 + "dom-webcodecs", 26 27 "firefox-webext-browser", 27 28 "handlebars", 28 29 "jszip", 29 30 "parse5", 30 31 "wanakana" 32 + ], 33 + "lib": [ 34 + "ES2022", 35 + "DOM", 36 + "DOM.Iterable", 37 + "WebWorker" 31 38 ] 32 39 }, 33 40 "include": [
+3
benches/translator.bench.js
··· 21 21 import {bench, describe} from 'vitest'; 22 22 import {parseJson} from '../dev/json.js'; 23 23 import {createTranslatorContext} from '../test/fixtures/translator-test.js'; 24 + import {setupStubs} from '../test/utilities/database.js'; 24 25 import {createFindKanjiOptions, createFindTermsOptions} from '../test/utilities/translator.js'; 26 + 27 + setupStubs(); 25 28 26 29 const dirname = path.dirname(fileURLToPath(import.meta.url)); 27 30 const dictionaryName = 'Test Dictionary 2';
+16
dev/build-libs.js
··· 20 20 import standaloneCode from 'ajv/dist/standalone/index.js'; 21 21 import esbuild from 'esbuild'; 22 22 import fs from 'fs'; 23 + import {createRequire} from 'module'; 23 24 import path from 'path'; 24 25 import {fileURLToPath} from 'url'; 25 26 import {parseJson} from './json.js'; 26 27 28 + const require = createRequire(import.meta.url); 29 + 27 30 const dirname = path.dirname(fileURLToPath(import.meta.url)); 28 31 const extDir = path.join(dirname, '..', 'ext'); 32 + 33 + /** 34 + * @param {string} out 35 + */ 36 + async function copyWasm(out) { 37 + // copy from node modules '@resvg/resvg-wasm/index_bg.wasm' to out 38 + const resvgWasmPath = path.dirname(require.resolve('@resvg/resvg-wasm')); 39 + const wasmPath = path.join(resvgWasmPath, 'index_bg.wasm'); 40 + fs.copyFileSync(wasmPath, path.join(out, 'resvg.wasm')); 41 + } 42 + 29 43 30 44 /** 31 45 * @param {string} scriptPath ··· 79 93 const patchedModuleCode = "// @ts-nocheck\nimport {ucs2length} from './ucs2length.js';" + moduleCode.replaceAll('require("ajv/dist/runtime/ucs2length").default', 'ucs2length'); 80 94 81 95 fs.writeFileSync(path.join(extDir, 'lib/validate-schemas.js'), patchedModuleCode); 96 + 97 + await copyWasm(path.join(extDir, 'lib')); 82 98 }
+4 -3
dev/data/manifest-variants.json
··· 102 102 "resources": [ 103 103 "popup.html", 104 104 "template-renderer.html", 105 - "js/*" 105 + "js/*", 106 + "lib/resvg.wasm" 106 107 ], 107 108 "matches": [ 108 109 "<all_urls>" ··· 110 111 } 111 112 ], 112 113 "content_security_policy": { 113 - "extension_pages": "default-src 'self'; img-src blob: 'self'; style-src 'self' 'unsafe-inline'; media-src *; connect-src *", 114 + "extension_pages": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; img-src blob: 'self'; style-src 'self' 'unsafe-inline'; media-src *; connect-src *", 114 115 "sandbox": "sandbox allow-scripts; default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'unsafe-inline'" 115 116 } 116 117 }, ··· 238 239 "content_security_policy", 239 240 "extension_pages" 240 241 ], 241 - "value": "default-src 'self'; script-src 'self'; img-src blob: 'self'; style-src 'self' 'unsafe-inline'; media-src *; connect-src *" 242 + "value": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; img-src blob: 'self'; style-src 'self' 'unsafe-inline'; media-src *; connect-src *" 242 243 }, 243 244 { 244 245 "action": "set",
+1 -14
dev/data/structured-content-overrides.css
··· 28 28 .gloss-image-link:hover { 29 29 /* remove-rule */ 30 30 } 31 - .gloss-image-container-overlay { 32 - font-size: initial; 33 - line-height: initial; 34 - color: initial; 35 - } 36 - .gloss-image-background { 37 - background-color: currentColor; 38 - } 39 31 :root[data-browser=firefox] .gloss-image-link[data-image-rendering=crisp-edges] .gloss-image, 40 - :root[data-browser=firefox] .gloss-image-link[data-image-rendering=crisp-edges] .gloss-image-background, 41 - :root[data-browser=firefox-mobile] .gloss-image-link[data-image-rendering=crisp-edges] .gloss-image, 42 - :root[data-browser=firefox-mobile] .gloss-image-link[data-image-rendering=crisp-edges] .gloss-image-background { 32 + :root[data-browser=firefox-mobile] .gloss-image-link[data-image-rendering=crisp-edges] .gloss-image { 43 33 /* remove-rule */ 44 - } 45 - .gloss-image-link-text { 46 - line-height: initial; 47 34 } 48 35 .gloss-sc-thead, 49 36 .gloss-sc-tfoot,
+8 -1
dev/jsconfig.json
··· 53 53 "assert", 54 54 "css", 55 55 "chrome", 56 - "ajv" 56 + "ajv", 57 + "dom-webcodecs" 58 + ], 59 + "lib": [ 60 + "ES2022", 61 + "DOM", 62 + "DOM.Iterable", 63 + "WebWorker" 57 64 ] 58 65 }, 59 66 "include": [
+18
dev/lib/resvg-wasm.js
··· 1 + /* 2 + * Copyright (C) 2023-2024 Yomitan Authors 3 + * 4 + * This program is free software: you can redistribute it and/or modify 5 + * it under the terms of the GNU General Public License as published by 6 + * the Free Software Foundation, either version 3 of the License, or 7 + * (at your option) any later version. 8 + * 9 + * This program is distributed in the hope that it will be useful, 10 + * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 + * GNU General Public License for more details. 13 + * 14 + * You should have received a copy of the GNU General Public License 15 + * along with this program. If not, see <https://www.gnu.org/licenses/>. 16 + */ 17 + 18 + export * from '@resvg/resvg-wasm';
+2
ext/css/display.css
··· 832 832 .entry { 833 833 padding: var(--entry-vertical-padding) var(--entry-horizontal-padding); 834 834 position: relative; 835 + content-visibility: auto; 836 + contain-intrinsic-height: auto 500px; 835 837 } 836 838 .entry+.entry { 837 839 border-top: var(--thin-border-size) solid var(--light-border-color);
+4 -98
ext/css/structured-content.css
··· 19 19 /* Glossary images */ 20 20 .gloss-image-container { 21 21 display: inline-block; 22 - white-space: nowrap; 23 - max-width: 100%; 24 - max-height: 100vh; 25 - position: relative; 26 - vertical-align: top; 27 - line-height: 0; 28 - font-size: calc(1em / var(--font-size-no-units)); 29 - overflow: hidden; 30 22 } 31 23 .gloss-image-link[data-background=true]>.gloss-image-container { 32 24 background-color: var(--gloss-image-background-color); ··· 45 37 .gloss-image-link[href]:hover { 46 38 cursor: pointer; 47 39 } 48 - .gloss-image-container-overlay { 49 - position: absolute; 50 - left: 0; 51 - top: 0; 52 - width: 100%; 53 - height: 100%; 54 - font-size: calc(1em * var(--font-size-no-units)); 55 - line-height: var(--line-height); 56 - display: table; 57 - table-layout: fixed; 58 - white-space: normal; 59 - color: var(--text-color-light3); 60 - } 61 - .gloss-image-link[data-has-image=true][data-image-load-state=load-error] .gloss-image-container-overlay::after { 62 - content: 'Image failed to load'; 63 - display: table-cell; 64 - width: 100%; 65 - height: 100%; 66 - vertical-align: middle; 67 - text-align: center; 68 - padding: 0.25em; 69 - } 70 - .gloss-image-background { 71 - --image: none; 72 - 73 - position: absolute; 74 - left: 0; 75 - top: 0; 76 - width: 100%; 77 - height: 100%; 78 - background-color: var(--text-color); 79 - -webkit-mask-repeat: no-repeat; 80 - -webkit-mask-position: center center; 81 - -webkit-mask-mode: alpha; 82 - -webkit-mask-size: contain; 83 - -webkit-mask-image: var(--image); 84 - mask-repeat: no-repeat; 85 - mask-position: center center; 86 - mask-mode: alpha; 87 - mask-size: contain; 88 - mask-image: var(--image); 89 - } 90 40 .gloss-image { 91 41 display: inline-block; 92 42 vertical-align: top; 93 43 object-fit: contain; 94 44 border: none; 95 45 outline: none; 96 - } 97 - .gloss-image-link[data-has-aspect-ratio=true] .gloss-image { 98 - position: absolute; 99 - left: 0; 100 - top: 0; 101 46 width: 100%; 102 - height: 100%; 103 47 } 104 - .gloss-image:not([src]) { 105 - display: none; 106 - } 107 - .gloss-image-link[data-image-rendering=pixelated] .gloss-image, 108 - .gloss-image-link[data-image-rendering=pixelated] .gloss-image-background { 48 + .gloss-image-link[data-image-rendering=pixelated] .gloss-image { 109 49 image-rendering: auto; 110 50 image-rendering: -moz-crisp-edges; 111 51 image-rendering: -webkit-optimize-contrast; 112 52 image-rendering: pixelated; 113 53 image-rendering: crisp-edges; 114 54 } 115 - .gloss-image-link[data-image-rendering=crisp-edges] .gloss-image, 116 - .gloss-image-link[data-image-rendering=crisp-edges] .gloss-image-background { 55 + .gloss-image-link[data-image-rendering=crisp-edges] .gloss-image { 117 56 image-rendering: auto; 118 57 image-rendering: -moz-crisp-edges; 119 58 image-rendering: -webkit-optimize-contrast; 120 59 image-rendering: crisp-edges; 121 60 } 122 61 :root[data-browser=firefox] .gloss-image-link[data-image-rendering=crisp-edges] .gloss-image, 123 - :root[data-browser=firefox] .gloss-image-link[data-image-rendering=crisp-edges] .gloss-image-background, 124 - :root[data-browser=firefox-mobile] .gloss-image-link[data-image-rendering=crisp-edges] .gloss-image, 125 - :root[data-browser=firefox-mobile] .gloss-image-link[data-image-rendering=crisp-edges] .gloss-image-background { 62 + :root[data-browser=firefox-mobile] .gloss-image-link[data-image-rendering=crisp-edges] .gloss-image { 126 63 image-rendering: auto; 127 64 } 128 - .gloss-image-link[data-has-aspect-ratio=true] .gloss-image-sizer { 129 - display: inline-block; 130 - width: 0; 131 - vertical-align: top; 132 - font-size: 0; 133 - } 134 - .gloss-image-link-text { 135 - display: none; 136 - line-height: var(--line-height); 137 - } 138 - .gloss-image-link-text::before { 139 - content: '['; 140 - } 141 - .gloss-image-link-text::after { 142 - content: ']'; 143 - } 144 - .gloss-image-description { 145 - display: block; 146 - white-space: pre-line; 147 - } 148 65 149 66 .gloss-image-link[data-appearance=monochrome] .gloss-image { 150 - opacity: 0; 151 - } 152 - .gloss-image-link:not([data-appearance=monochrome]) .gloss-image-background { 153 - display: none; 67 + filter: grayscale(1); 154 68 } 155 69 156 70 .gloss-image-link[data-size-units=em] .gloss-image-container { ··· 188 102 :root[data-glossary-layout-mode=compact] .gloss-image-link[data-collapsible=true]:hover .gloss-image-container, 189 103 :root[data-glossary-layout-mode=compact] .gloss-image-link[data-collapsible=true]:focus .gloss-image-container { 190 104 display: block; 191 - } 192 - .gloss-image-link[data-collapsed=true] .gloss-image-link-text, 193 - :root[data-glossary-layout-mode=compact] .gloss-image-link[data-collapsible=true] .gloss-image-link-text { 194 - display: inline; 195 - } 196 - .gloss-image-link[data-collapsed=true]~.gloss-image-description, 197 - :root[data-glossary-layout-mode=compact] .gloss-image-description { 198 - display: inline; 199 105 } 200 106 201 107
+5 -142
ext/data/structured-content-style.json
··· 3 3 "selectors": [".gloss-image-container"], 4 4 "styles": [ 5 5 ["display", "inline-block"], 6 - ["white-space", "nowrap"], 7 - ["max-width", "100%"], 8 - ["max-height", "100vh"], 9 - ["position", "relative"], 10 - ["vertical-align", "top"], 11 - ["line-height", "0"], 12 - ["overflow", "hidden"], 13 6 ["font-size", "1px"] 14 7 ] 15 8 }, ··· 31 24 ] 32 25 }, 33 26 { 34 - "selectors": [".gloss-image-container-overlay"], 35 - "styles": [ 36 - ["position", "absolute"], 37 - ["left", "0"], 38 - ["top", "0"], 39 - ["width", "100%"], 40 - ["height", "100%"], 41 - ["display", "table"], 42 - ["table-layout", "fixed"], 43 - ["white-space", "normal"], 44 - ["font-size", "initial"], 45 - ["line-height", "initial"], 46 - ["color", "initial"] 47 - ] 48 - }, 49 - { 50 - "selectors": [".gloss-image-link[data-has-image=true][data-image-load-state=load-error] .gloss-image-container-overlay::after"], 51 - "styles": [ 52 - ["content", "'Image failed to load'"], 53 - ["display", "table-cell"], 54 - ["width", "100%"], 55 - ["height", "100%"], 56 - ["vertical-align", "middle"], 57 - ["text-align", "center"], 58 - ["padding", "0.25em"] 59 - ] 60 - }, 61 - { 62 - "selectors": [".gloss-image-background"], 63 - "styles": [ 64 - ["--image", "none"], 65 - ["position", "absolute"], 66 - ["left", "0"], 67 - ["top", "0"], 68 - ["width", "100%"], 69 - ["height", "100%"], 70 - ["-webkit-mask-repeat", "no-repeat"], 71 - ["-webkit-mask-position", "center center"], 72 - ["-webkit-mask-mode", "alpha"], 73 - ["-webkit-mask-size", "contain"], 74 - ["-webkit-mask-image", "var(--image)"], 75 - ["mask-repeat", "no-repeat"], 76 - ["mask-position", "center center"], 77 - ["mask-mode", "alpha"], 78 - ["mask-size", "contain"], 79 - ["mask-image", "var(--image)"], 80 - ["background-color", "currentColor"] 81 - ] 82 - }, 83 - { 84 27 "selectors": [".gloss-image"], 85 28 "styles": [ 86 29 ["display", "inline-block"], 87 30 ["vertical-align", "top"], 88 31 ["object-fit", "contain"], 89 32 ["border", "none"], 90 - ["outline", "none"] 91 - ] 92 - }, 93 - { 94 - "selectors": [".gloss-image-link[data-has-aspect-ratio=true] .gloss-image"], 95 - "styles": [ 96 - ["position", "absolute"], 97 - ["left", "0"], 98 - ["top", "0"], 99 - ["width", "100%"], 100 - ["height", "100%"] 33 + ["outline", "none"], 34 + ["width", "100%"] 101 35 ] 102 36 }, 103 37 { 104 - "selectors": [".gloss-image:not([src])"], 105 - "styles": [ 106 - ["display", "none"] 107 - ] 108 - }, 109 - { 110 - "selectors": [ 111 - ".gloss-image-link[data-image-rendering=pixelated] .gloss-image", 112 - ".gloss-image-link[data-image-rendering=pixelated] .gloss-image-background" 113 - ], 38 + "selectors": [".gloss-image-link[data-image-rendering=pixelated] .gloss-image"], 114 39 "styles": [ 115 40 ["image-rendering", "auto"], 116 41 ["image-rendering", "-moz-crisp-edges"], ··· 120 45 ] 121 46 }, 122 47 { 123 - "selectors": [ 124 - ".gloss-image-link[data-image-rendering=crisp-edges] .gloss-image", 125 - ".gloss-image-link[data-image-rendering=crisp-edges] .gloss-image-background" 126 - ], 48 + "selectors": [".gloss-image-link[data-image-rendering=crisp-edges] .gloss-image"], 127 49 "styles": [ 128 50 ["image-rendering", "auto"], 129 51 ["image-rendering", "-moz-crisp-edges"], ··· 132 54 ] 133 55 }, 134 56 { 135 - "selectors": [".gloss-image-link[data-has-aspect-ratio=true] .gloss-image-sizer"], 136 - "styles": [ 137 - ["display", "inline-block"], 138 - ["width", "0"], 139 - ["vertical-align", "top"], 140 - ["font-size", "0"] 141 - ] 142 - }, 143 - { 144 - "selectors": [".gloss-image-link-text"], 145 - "styles": [ 146 - ["display", "none"], 147 - ["line-height", "initial"] 148 - ] 149 - }, 150 - { 151 - "selectors": [".gloss-image-link-text::before"], 152 - "styles": [ 153 - ["content", "'['"] 154 - ] 155 - }, 156 - { 157 - "selectors": [".gloss-image-link-text::after"], 158 - "styles": [ 159 - ["content", "']'"] 160 - ] 161 - }, 162 - { 163 - "selectors": [".gloss-image-description"], 164 - "styles": [ 165 - ["display", "block"], 166 - ["white-space", "pre-line"] 167 - ] 168 - }, 169 - { 170 57 "selectors": [".gloss-image-link[data-appearance=monochrome] .gloss-image"], 171 58 "styles": [ 172 - ["opacity", "0"] 173 - ] 174 - }, 175 - { 176 - "selectors": [".gloss-image-link:not([data-appearance=monochrome]) .gloss-image-background"], 177 - "styles": [ 178 - ["display", "none"] 59 + ["filter", "grayscale(1)"] 179 60 ] 180 61 }, 181 62 { ··· 273 154 ], 274 155 "styles": [ 275 156 ["display", "block"] 276 - ] 277 - }, 278 - { 279 - "selectors": [ 280 - ".gloss-image-link[data-collapsed=true] .gloss-image-link-text", 281 - ":root[data-glossary-layout-mode=compact] .gloss-image-link[data-collapsible=true] .gloss-image-link-text" 282 - ], 283 - "styles": [ 284 - ["display", "inline"] 285 - ] 286 - }, 287 - { 288 - "selectors": [ 289 - ".gloss-image-link[data-collapsed=true]~.gloss-image-description", 290 - ":root[data-glossary-layout-mode=compact] .gloss-image-description" 291 - ], 292 - "styles": [ 293 - ["display", "inline"] 294 157 ] 295 158 }, 296 159 {
ext/fonts/NotoSansJP-Regular.ttf

This is a binary file and will not be displayed.

+28 -1
ext/js/application.js
··· 190 190 * @param {(application: Application) => (Promise<void>)} mainFunction 191 191 */ 192 192 static async main(waitForDom, mainFunction) { 193 + const supportsServiceWorker = 'serviceWorker' in navigator; // Basically, all browsers except Firefox. But it's possible Firefox will support it in the future, so we check in this fashion to be future-proof. 194 + const inExtensionContext = window.location.protocol === new URL(import.meta.url).protocol; // This code runs both in content script as well as in the iframe, so we need to differentiate the situation 195 + /** @type {MessagePort | null} */ 196 + // If this is Firefox, we don't have a service worker and can't postMessage, 197 + // so we temporarily create a SharedWorker in order to establish a MessageChannel 198 + // which we can use to postMessage with the backend. 199 + // This can only be done in the extension context (aka iframe within popup), 200 + // not in the content script context. 201 + const backendPort = !supportsServiceWorker && inExtensionContext ? 202 + (() => { 203 + const sharedWorkerBridge = new SharedWorker(new URL('comm/shared-worker-bridge.js', import.meta.url), {type: 'module'}); 204 + const backendChannel = new MessageChannel(); 205 + sharedWorkerBridge.port.postMessage({action: 'connectToBackend1'}, [backendChannel.port1]); 206 + sharedWorkerBridge.port.close(); 207 + return backendChannel.port2; 208 + })() : 209 + null; 210 + 193 211 const webExtension = new WebExtension(); 194 212 log.configure(webExtension.extensionName); 195 - const api = new API(webExtension); 213 + 214 + const mediaDrawingWorkerToBackendChannel = new MessageChannel(); 215 + const mediaDrawingWorker = inExtensionContext ? new Worker(new URL('display/media-drawing-worker.js', import.meta.url), {type: 'module'}) : null; 216 + mediaDrawingWorker?.postMessage({action: 'connectToDatabaseWorker'}, [mediaDrawingWorkerToBackendChannel.port2]); 217 + 218 + const api = new API(webExtension, mediaDrawingWorker, backendPort); 196 219 await waitForBackendReady(webExtension); 220 + if (mediaDrawingWorker !== null) { 221 + api.connectToDatabaseWorker(mediaDrawingWorkerToBackendChannel.port1); 222 + } 223 + 197 224 const {tabId, frameId} = await api.frameInformationGet(); 198 225 const crossFrameApi = new CrossFrameAPI(api, tabId, frameId); 199 226 crossFrameApi.prepare();
+42
ext/js/background/backend.js
··· 186 186 ['openCrossFramePort', this._onApiOpenCrossFramePort.bind(this)], 187 187 ['getLanguageSummaries', this._onApiGetLanguageSummaries.bind(this)], 188 188 ]); 189 + 190 + /** @type {import('api').PmApiMap} */ 191 + this._pmApiMap = createApiMap([ 192 + ['connectToDatabaseWorker', this._onPmConnectToDatabaseWorker.bind(this)], 193 + ['registerOffscreenPort', this._onPmApiRegisterOffscreenPort.bind(this)], 194 + ]); 189 195 /* eslint-enable @stylistic/no-multi-spaces */ 190 196 191 197 /** @type {Map<string, (params?: import('core').SerializableObject) => void>} */ ··· 240 246 const onMessage = this._onMessageWrapper.bind(this); 241 247 chrome.runtime.onMessage.addListener(onMessage); 242 248 249 + // On Chrome, this is for receiving messages sent with navigator.serviceWorker, which has the benefit of being able to transfer objects, but doesn't accept callbacks 250 + (/** @type {ServiceWorkerGlobalScope & typeof globalThis} */ (globalThis)).addEventListener('message', this._onPmMessage.bind(this)); 251 + 243 252 if (this._canObservePermissionsChanges()) { 244 253 const onPermissionsChanged = this._onWebExtensionEventWrapper(this._onPermissionsChanged.bind(this)); 245 254 chrome.permissions.onAdded.addListener(onPermissionsChanged); ··· 249 258 chrome.runtime.onInstalled.addListener(this._onInstalled.bind(this)); 250 259 } 251 260 261 + /** @type {import('api').PmApiHandler<'connectToDatabaseWorker'>} */ 262 + async _onPmConnectToDatabaseWorker(_params, ports) { 263 + if (ports !== null && ports.length > 0) { 264 + await this._dictionaryDatabase.connectToDatabaseWorker(ports[0]); 265 + } 266 + } 267 + 268 + /** @type {import('api').PmApiHandler<'registerOffscreenPort'>} */ 269 + async _onPmApiRegisterOffscreenPort(_params, ports) { 270 + if (ports !== null && ports.length > 0) { 271 + await this._offscreen?.registerOffscreenPort(ports[0]); 272 + } 273 + } 274 + 252 275 /** 253 276 * @returns {Promise<void>} 254 277 */ ··· 273 296 } 274 297 this._clipboardReader.browser = this._environment.getInfo().browser; 275 298 299 + // if this is Firefox and therefore not running in Service Worker, we need to use a SharedWorker to setup a MessageChannel to postMessage with the popup 300 + if (self.constructor.name === 'Window') { 301 + const sharedWorkerBridge = new SharedWorker(new URL('../comm/shared-worker-bridge.js', import.meta.url), {type: 'module'}); 302 + sharedWorkerBridge.port.postMessage({action: 'registerBackendPort'}); 303 + sharedWorkerBridge.port.addEventListener('message', (/** @type {MessageEvent} */ e) => { 304 + // connectToBackend2 305 + e.ports[0].onmessage = this._onPmMessage.bind(this); 306 + }); 307 + sharedWorkerBridge.port.start(); 308 + } 276 309 try { 277 310 await this._dictionaryDatabase.prepare(); 278 311 } catch (e) { ··· 406 439 */ 407 440 _onMessage({action, params}, sender, callback) { 408 441 return invokeApiMapHandler(this._apiMap, action, params, [sender], callback); 442 + } 443 + 444 + /** 445 + * @param {MessageEvent<import('api').PmApiMessageAny>} event 446 + * @returns {boolean} 447 + */ 448 + _onPmMessage(event) { 449 + const {action, params} = event.data; 450 + return invokeApiMapHandler(this._pmApiMap, action, params, [event.ports], () => {}); 409 451 } 410 452 411 453 /**
+41 -1
ext/js/background/offscreen-proxy.js
··· 22 22 23 23 /** 24 24 * This class is responsible for creating and communicating with an offscreen document. 25 - * This offscreen document is used to solve two issues: 25 + * This offscreen document is used to solve three issues: 26 26 * 27 27 * - Provide clipboard access for the `ClipboardReader` class in the context of a MV3 extension. 28 28 * The background service workers doesn't have access a webpage to read the clipboard from, 29 29 * so it must be done in the offscreen page. 30 + * 31 + * - Create a worker for image rendering, which both selects the images from the database, 32 + * decodes/rasterizes them, and then sends (= postMessage transfers) them back to a worker 33 + * in the popup to be rendered onto OffscreenCanvas. 30 34 * 31 35 * - Provide a longer lifetime for the dictionary database. The background service worker can be 32 36 * terminated by the web browser, which means that when it restarts, it has to go through its ··· 55 59 this._webExtension = webExtension; 56 60 /** @type {?Promise<void>} */ 57 61 this._creatingOffscreen = null; 62 + 63 + /** @type {?MessagePort} */ 64 + this._currentOffscreenPort = null; 58 65 } 59 66 60 67 /** ··· 62 69 */ 63 70 async prepare() { 64 71 if (await this._hasOffscreenDocument()) { 72 + void this.sendMessagePromise({action: 'createAndRegisterPortOffscreen'}); 65 73 return; 66 74 } 67 75 if (this._creatingOffscreen) { ··· 133 141 } 134 142 return response.result; 135 143 } 144 + 145 + /** 146 + * @param {MessagePort} port 147 + */ 148 + async registerOffscreenPort(port) { 149 + if (this._currentOffscreenPort) { 150 + this._currentOffscreenPort.close(); 151 + } 152 + this._currentOffscreenPort = port; 153 + } 154 + 155 + /** 156 + * When you need to transfer Transferable objects, you can use this method which uses postMessage over the MessageChannel port established with the offscreen document. 157 + * @template {import('offscreen').McApiNames} TMessageType 158 + * @param {import('offscreen').McApiMessage<TMessageType>} message 159 + * @param {Transferable[]} transfers 160 + */ 161 + sendMessageViaPort(message, transfers) { 162 + if (this._currentOffscreenPort !== null) { 163 + this._currentOffscreenPort.postMessage(message, transfers); 164 + } else { 165 + void this.sendMessagePromise({action: 'createAndRegisterPortOffscreen'}); 166 + } 167 + } 136 168 } 137 169 138 170 export class DictionaryDatabaseProxy { ··· 172 204 async getMedia(targets) { 173 205 const serializedMedia = /** @type {import('dictionary-database').Media<string>[]} */ (await this._offscreen.sendMessagePromise({action: 'databaseGetMediaOffscreen', params: {targets}})); 174 206 return serializedMedia.map((m) => ({...m, content: base64ToArrayBuffer(m.content)})); 207 + } 208 + 209 + /** 210 + * @param {MessagePort} port 211 + * @returns {Promise<void>} 212 + */ 213 + async connectToDatabaseWorker(port) { 214 + this._offscreen.sendMessageViaPort({action: 'connectToDatabaseWorker'}, [port]); 175 215 } 176 216 } 177 217
+51 -15
ext/js/background/offscreen.js
··· 16 16 * along with this program. If not, see <https://www.gnu.org/licenses/>. 17 17 */ 18 18 19 + import {API} from '../comm/api.js'; 19 20 import {ClipboardReader} from '../comm/clipboard-reader.js'; 20 21 import {createApiMap, invokeApiMapHandler} from '../core/api-map.js'; 21 22 import {arrayBufferToBase64} from '../data/array-buffer-util.js'; 22 23 import {DictionaryDatabase} from '../dictionary/dictionary-database.js'; 24 + import {WebExtension} from '../extension/web-extension.js'; 23 25 import {Translator} from '../language/translator.js'; 24 26 25 27 /** ··· 42 44 '#clipboard-rich-content-paste-target', 43 45 ); 44 46 45 - 46 47 /* eslint-disable @stylistic/no-multi-spaces */ 47 48 /** @type {import('offscreen').ApiMap} */ 48 49 this._apiMap = createApiMap([ 49 - ['clipboardGetTextOffscreen', this._getTextHandler.bind(this)], 50 - ['clipboardGetImageOffscreen', this._getImageHandler.bind(this)], 51 - ['clipboardSetBrowserOffscreen', this._setClipboardBrowser.bind(this)], 52 - ['databasePrepareOffscreen', this._prepareDatabaseHandler.bind(this)], 53 - ['getDictionaryInfoOffscreen', this._getDictionaryInfoHandler.bind(this)], 54 - ['databasePurgeOffscreen', this._purgeDatabaseHandler.bind(this)], 55 - ['databaseGetMediaOffscreen', this._getMediaHandler.bind(this)], 56 - ['translatorPrepareOffscreen', this._prepareTranslatorHandler.bind(this)], 57 - ['findKanjiOffscreen', this._findKanjiHandler.bind(this)], 58 - ['findTermsOffscreen', this._findTermsHandler.bind(this)], 59 - ['getTermFrequenciesOffscreen', this._getTermFrequenciesHandler.bind(this)], 60 - ['clearDatabaseCachesOffscreen', this._clearDatabaseCachesHandler.bind(this)], 50 + ['clipboardGetTextOffscreen', this._getTextHandler.bind(this)], 51 + ['clipboardGetImageOffscreen', this._getImageHandler.bind(this)], 52 + ['clipboardSetBrowserOffscreen', this._setClipboardBrowser.bind(this)], 53 + ['databasePrepareOffscreen', this._prepareDatabaseHandler.bind(this)], 54 + ['getDictionaryInfoOffscreen', this._getDictionaryInfoHandler.bind(this)], 55 + ['databasePurgeOffscreen', this._purgeDatabaseHandler.bind(this)], 56 + ['databaseGetMediaOffscreen', this._getMediaHandler.bind(this)], 57 + ['translatorPrepareOffscreen', this._prepareTranslatorHandler.bind(this)], 58 + ['findKanjiOffscreen', this._findKanjiHandler.bind(this)], 59 + ['findTermsOffscreen', this._findTermsHandler.bind(this)], 60 + ['getTermFrequenciesOffscreen', this._getTermFrequenciesHandler.bind(this)], 61 + ['clearDatabaseCachesOffscreen', this._clearDatabaseCachesHandler.bind(this)], 62 + ['createAndRegisterPortOffscreen', this._createAndRegisterPort.bind(this)], 61 63 ]); 62 64 /* eslint-enable @stylistic/no-multi-spaces */ 65 + 66 + /** @type {import('offscreen').McApiMap} */ 67 + this._mcApiMap = createApiMap([ 68 + ['connectToDatabaseWorker', this._connectToDatabaseWorkerHandler.bind(this)], 69 + ]); 63 70 64 71 /** @type {?Promise<void>} */ 65 72 this._prepareDatabasePromise = null; 73 + 74 + /** 75 + * @type {API} 76 + */ 77 + this._api = new API(new WebExtension()); 66 78 } 67 79 68 80 /** */ 69 81 prepare() { 70 82 chrome.runtime.onMessage.addListener(this._onMessage.bind(this)); 83 + navigator.serviceWorker.addEventListener('controllerchange', this._createAndRegisterPort.bind(this)); 84 + this._createAndRegisterPort(); 71 85 } 72 86 73 87 /** @type {import('offscreen').ApiHandler<'clipboardGetTextOffscreen'>} */ ··· 130 144 const enabledDictionaryMap = new Map(options.enabledDictionaryMap); 131 145 const excludeDictionaryDefinitions = ( 132 146 options.excludeDictionaryDefinitions !== null ? 133 - new Set(options.excludeDictionaryDefinitions) : 134 - null 147 + new Set(options.excludeDictionaryDefinitions) : 148 + null 135 149 ); 136 150 const textReplacements = options.textReplacements.map((group) => { 137 151 if (group === null) { return null; } ··· 165 179 /** @type {import('extension').ChromeRuntimeOnMessageCallback<import('offscreen').ApiMessageAny>} */ 166 180 _onMessage({action, params}, _sender, callback) { 167 181 return invokeApiMapHandler(this._apiMap, action, params, [], callback); 182 + } 183 + 184 + /** 185 + * 186 + */ 187 + _createAndRegisterPort() { 188 + const mc = new MessageChannel(); 189 + mc.port1.onmessage = this._onMcMessage.bind(this); 190 + this._api.registerOffscreenPort([mc.port2]); 191 + } 192 + 193 + /** @type {import('offscreen').McApiHandler<'connectToDatabaseWorker'>} */ 194 + async _connectToDatabaseWorkerHandler(_params, ports) { 195 + await this._dictionaryDatabase.connectToDatabaseWorker(ports[0]); 196 + } 197 + 198 + /** 199 + * @param {MessageEvent<import('offscreen').McApiMessageAny>} event 200 + */ 201 + _onMcMessage(event) { 202 + const {action, params} = event.data; 203 + invokeApiMapHandler(this._mcApiMap, action, params, [event.ports], () => {}); 168 204 } 169 205 }
+59 -1
ext/js/comm/api.js
··· 17 17 */ 18 18 19 19 import {ExtensionError} from '../core/extension-error.js'; 20 + import {log} from '../core/log.js'; 20 21 21 22 export class API { 22 23 /** 23 24 * @param {import('../extension/web-extension.js').WebExtension} webExtension 25 + * @param {Worker?} mediaDrawingWorker 26 + * @param {MessagePort?} backendPort 24 27 */ 25 - constructor(webExtension) { 28 + constructor(webExtension, mediaDrawingWorker = null, backendPort = null) { 26 29 /** @type {import('../extension/web-extension.js').WebExtension} */ 27 30 this._webExtension = webExtension; 31 + 32 + /** @type {Worker?} */ 33 + this._mediaDrawingWorker = mediaDrawingWorker; 34 + 35 + /** @type {MessagePort?} */ 36 + this._backendPort = backendPort; 28 37 } 29 38 30 39 /** ··· 255 264 } 256 265 257 266 /** 267 + * @param {import('api').PmApiParam<'drawMedia', 'requests'>} requests 268 + * @param {Transferable[]} transferables 269 + */ 270 + drawMedia(requests, transferables) { 271 + this._mediaDrawingWorker?.postMessage({action: 'drawMedia', params: {requests}}, transferables); 272 + } 273 + 274 + /** 258 275 * @param {import('api').ApiParam<'logGenericErrorBackend', 'error'>} error 259 276 * @param {import('api').ApiParam<'logGenericErrorBackend', 'level'>} level 260 277 * @param {import('api').ApiParam<'logGenericErrorBackend', 'context'>} context ··· 365 382 } 366 383 367 384 /** 385 + * @param {Transferable[]} transferables 386 + */ 387 + registerOffscreenPort(transferables) { 388 + this._pmInvoke('registerOffscreenPort', void 0, transferables); 389 + } 390 + 391 + /** 392 + * @param {MessagePort} port 393 + */ 394 + connectToDatabaseWorker(port) { 395 + this._pmInvoke('connectToDatabaseWorker', void 0, [port]); 396 + } 397 + 398 + /** 368 399 * @returns {Promise<import('api').ApiReturn<'getLanguageSummaries'>>} 369 400 */ 370 401 getLanguageSummaries() { ··· 404 435 reject(e); 405 436 } 406 437 }); 438 + } 439 + 440 + /** 441 + * @template {import('api').PmApiNames} TAction 442 + * @template {import('api').PmApiParams<TAction>} TParams 443 + * @param {TAction} action 444 + * @param {TParams} params 445 + * @param {Transferable[]} transferables 446 + */ 447 + _pmInvoke(action, params, transferables) { 448 + // on firefox, there is no service worker, so we instead use a MessageChannel which is established 449 + // via a handshake via a SharedWorker 450 + if (!('serviceWorker' in navigator)) { 451 + if (this._backendPort === null) { 452 + log.error('no backend port available'); 453 + return; 454 + } 455 + this._backendPort.postMessage({action, params}, transferables); 456 + } else { 457 + void navigator.serviceWorker.ready.then((serviceWorkerRegistration) => { 458 + if (serviceWorkerRegistration.active !== null) { 459 + serviceWorkerRegistration.active.postMessage({action, params}, transferables); 460 + } else { 461 + log.error(`[${self.constructor.name}] no active service worker`); 462 + } 463 + }); 464 + } 407 465 } 408 466 }
+87
ext/js/comm/shared-worker-bridge.js
··· 1 + /* 2 + * Copyright (C) 2024 Yomitan Authors 3 + * 4 + * This program is free software: you can redistribute it and/or modify 5 + * it under the terms of the GNU General Public License as published by 6 + * the Free Software Foundation, either version 3 of the License, or 7 + * (at your option) any later version. 8 + * 9 + * This program is distributed in the hope that it will be useful, 10 + * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 + * GNU General Public License for more details. 13 + * 14 + * You should have received a copy of the GNU General Public License 15 + * along with this program. If not, see <https://www.gnu.org/licenses/>. 16 + */ 17 + 18 + import {createApiMap, invokeApiMapHandler} from '../core/api-map.js'; 19 + import {log} from '../core/log.js'; 20 + 21 + /** 22 + * This serves as a bridge between the application and the backend on Firefox 23 + * where we don't have service workers. 24 + * 25 + * It is designed to have extremely short lifetime on the application side, 26 + * as otherwise it will stay alive across extension updates (which only restart 27 + * the backend) which can lead to extremely difficult to debug situations where 28 + * the bridge is running an old version of the code. 29 + * 30 + * All it does is broker a handshake between the application and the backend, 31 + * where they establish a connection between each other with a MessageChannel. 32 + * 33 + * # On backend startup 34 + * backend 35 + * ↓↓<"registerBackendPort" via SharedWorker.port.postMessage>↓↓ 36 + * bridge: store the port in state 37 + * 38 + * # On application startup 39 + * application: create a new MessageChannel, bind event listeners to one of the ports, and send the other port to the bridge 40 + * ↓↓<"connectToBackend1" via SharedWorker.port.postMessage>↓↓ 41 + * bridge 42 + * ↓↓<"connectToBackend2" via MessageChannel.port.postMessage which is stored in state from backend startup phase>↓↓ 43 + * backend: bind event listeners to the other port 44 + */ 45 + export class SharedWorkerBridge { 46 + constructor() { 47 + /** @type {MessagePort?} */ 48 + this._backendPort = null; 49 + 50 + /** @type {import('shared-worker').ApiMap} */ 51 + this._apiMap = createApiMap([ 52 + ['registerBackendPort', this._onRegisterBackendPort.bind(this)], 53 + ['connectToBackend1', this._onConnectToBackend1.bind(this)], 54 + ]); 55 + } 56 + 57 + /** 58 + * 59 + */ 60 + prepare() { 61 + addEventListener('connect', (connectEvent) => { 62 + const interlocutorPort = (/** @type {MessageEvent} */ (connectEvent)).ports[0]; 63 + interlocutorPort.addEventListener('message', (/** @type {MessageEvent<import('shared-worker').ApiMessageAny>} */ event) => { 64 + const {action, params} = event.data; 65 + return invokeApiMapHandler(this._apiMap, action, params, [interlocutorPort, event.ports], () => {}); 66 + }); 67 + interlocutorPort.start(); 68 + }); 69 + } 70 + 71 + /** @type {import('shared-worker').ApiHandler<'registerBackendPort'>} */ 72 + _onRegisterBackendPort(_params, interlocutorPort, _ports) { 73 + this._backendPort = interlocutorPort; 74 + } 75 + 76 + /** @type {import('shared-worker').ApiHandler<'connectToBackend1'>} */ 77 + _onConnectToBackend1(_params, _interlocutorPort, ports) { 78 + if (this._backendPort !== null) { 79 + this._backendPort.postMessage(void 0, [ports[0]]); // connectToBackend2 80 + } else { 81 + log.error('SharedWorkerBridge: backend port is not registered'); 82 + } 83 + } 84 + } 85 + 86 + const bridge = new SharedWorkerBridge(); 87 + bridge.prepare();
+4 -2
ext/js/data/database.js
··· 30 30 /** 31 31 * @param {string} databaseName 32 32 * @param {number} version 33 - * @param {import('database').StructureDefinition<TObjectStoreName>[]} structure 33 + * @param {import('database').StructureDefinition<TObjectStoreName>[]?} structure 34 34 */ 35 35 async open(databaseName, version, structure) { 36 36 if (this._db !== null) { ··· 43 43 try { 44 44 this._isOpening = true; 45 45 this._db = await this._open(databaseName, version, (db, transaction, oldVersion) => { 46 - this._upgrade(db, transaction, oldVersion, structure); 46 + if (structure !== null) { 47 + this._upgrade(db, transaction, oldVersion, structure); 48 + } 47 49 }); 48 50 } finally { 49 51 this._isOpening = false;
+54
ext/js/dictionary/dictionary-database-worker-handler.js
··· 1 + /* 2 + * Copyright (C) 2024 Yomitan Authors 3 + * 4 + * This program is free software: you can redistribute it and/or modify 5 + * it under the terms of the GNU General Public License as published by 6 + * the Free Software Foundation, either version 3 of the License, or 7 + * (at your option) any later version. 8 + * 9 + * This program is distributed in the hope that it will be useful, 10 + * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 + * GNU General Public License for more details. 13 + * 14 + * You should have received a copy of the GNU General Public License 15 + * along with this program. If not, see <https://www.gnu.org/licenses/>. 16 + */ 17 + 18 + import {log} from '../core/log.js'; 19 + import {DictionaryDatabase} from './dictionary-database.js'; 20 + 21 + export class DictionaryDatabaseWorkerHandler { 22 + constructor() { 23 + /** @type {DictionaryDatabase?} */ 24 + this._dictionaryDatabase = null; 25 + } 26 + 27 + /** 28 + * 29 + */ 30 + async prepare() { 31 + this._dictionaryDatabase = new DictionaryDatabase(); 32 + try { 33 + await this._dictionaryDatabase.prepare(); 34 + } catch (e) { 35 + log.error(e); 36 + } 37 + self.addEventListener('message', this._onMessage.bind(this), false); 38 + } 39 + // Private 40 + 41 + /** 42 + * @param {MessageEvent<import('dictionary-database-worker-handler').MessageToWorker>} event 43 + */ 44 + _onMessage(event) { 45 + const {action} = event.data; 46 + switch (action) { 47 + case 'connectToDatabaseWorker': 48 + void this._dictionaryDatabase?.connectToDatabaseWorker(event.ports[0]); 49 + break; 50 + default: 51 + log.error(`Unknown action: ${action}`); 52 + } 53 + } 54 + }
+31
ext/js/dictionary/dictionary-database-worker-main.js
··· 1 + /* 2 + * Copyright (C) 2024 Yomitan Authors 3 + * 4 + * This program is free software: you can redistribute it and/or modify 5 + * it under the terms of the GNU General Public License as published by 6 + * the Free Software Foundation, either version 3 of the License, or 7 + * (at your option) any later version. 8 + * 9 + * This program is distributed in the hope that it will be useful, 10 + * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 + * GNU General Public License for more details. 13 + * 14 + * You should have received a copy of the GNU General Public License 15 + * along with this program. If not, see <https://www.gnu.org/licenses/>. 16 + */ 17 + 18 + import {log} from '../core/log.js'; 19 + import {DictionaryDatabaseWorkerHandler} from './dictionary-database-worker-handler.js'; 20 + 21 + /** Entry point. */ 22 + function main() { 23 + try { 24 + const dictionaryDatabaseWorkerHandler = new DictionaryDatabaseWorkerHandler(); 25 + void dictionaryDatabaseWorkerHandler.prepare(); 26 + } catch (e) { 27 + log.error(e); 28 + } 29 + } 30 + 31 + main();
+192 -10
ext/js/dictionary/dictionary-database.js
··· 16 16 * along with this program. If not, see <https://www.gnu.org/licenses/>. 17 17 */ 18 18 19 + import {initWasm, Resvg} from '../../lib/resvg-wasm.js'; 20 + import {createApiMap, invokeApiMapHandler} from '../core/api-map.js'; 19 21 import {log} from '../core/log.js'; 20 22 import {safePerformance} from '../core/safe-performance.js'; 21 23 import {stringReverse} from '../core/utilities.js'; ··· 35 37 this._createOnlyQuery3 = (item) => IDBKeyRange.only(item.term); 36 38 /** @type {import('dictionary-database').CreateQuery<import('dictionary-database').MediaRequest>} */ 37 39 this._createOnlyQuery4 = (item) => IDBKeyRange.only(item.path); 40 + /** @type {import('dictionary-database').CreateQuery<import('dictionary-database').DrawMediaGroupedRequest>} */ 41 + this._createOnlyQuery5 = (item) => IDBKeyRange.only(item.path); 38 42 /** @type {import('dictionary-database').CreateQuery<string>} */ 39 43 this._createBoundQuery1 = (item) => IDBKeyRange.bound(item, `${item}\uffff`, false, false); 40 44 /** @type {import('dictionary-database').CreateQuery<string>} */ ··· 54 58 this._createKanjiMetaBind = this._createKanjiMeta.bind(this); 55 59 /** @type {import('dictionary-database').CreateResult<import('dictionary-database').MediaRequest, import('dictionary-database').MediaDataArrayBufferContent, import('dictionary-database').Media>} */ 56 60 this._createMediaBind = this._createMedia.bind(this); 61 + /** @type {import('dictionary-database').CreateResult<import('dictionary-database').DrawMediaGroupedRequest, import('dictionary-database').MediaDataArrayBufferContent, import('dictionary-database').DrawMedia>} */ 62 + this._createDrawMediaBind = this._createDrawMedia.bind(this); 63 + 64 + /** 65 + * @type {Worker?} 66 + */ 67 + this._worker = null; 68 + 69 + /** 70 + * @type {Uint8Array?} 71 + */ 72 + this._resvgFontBuffer = null; 73 + 74 + /** @type {import('dictionary-database').ApiMap} */ 75 + this._apiMap = createApiMap([ 76 + ['drawMedia', this._onDrawMedia.bind(this)], 77 + ]); 57 78 } 58 79 59 - /** */ 80 + /** 81 + * do upgrades for the IndexedDB schema (basically limited to adding new stores when needed) 82 + */ 60 83 async prepare() { 61 - await this._db.open( 62 - this._dbName, 63 - 60, 64 - /** @type {import('database').StructureDefinition<import('dictionary-database').ObjectStoreName>[]} */ 84 + // do not do upgrades in web workers as they are considered to be children of the main thread and are not responsible for database upgrades 85 + const isWorker = self.constructor.name !== 'Window'; 86 + const upgrade = 87 + /** @type {import('database').StructureDefinition<import('dictionary-database').ObjectStoreName>[]?} */ 65 88 ([ 66 89 /** @type {import('database').StructureDefinition<import('dictionary-database').ObjectStoreName>} */ 67 90 ({ ··· 129 152 }, 130 153 }, 131 154 }, 132 - ]), 155 + ]); 156 + await this._db.open( 157 + this._dbName, 158 + 60, 159 + isWorker ? null : upgrade, 133 160 ); 161 + 162 + // when we are not a worker ourselves, create a worker which is basically just a wrapper around this class, which we can use to offload some functions to 163 + if (!isWorker) { 164 + this._worker = new Worker('/js/dictionary/dictionary-database-worker-main.js', {type: 'module'}); 165 + this._worker.addEventListener('error', (event) => { 166 + log.log('Worker terminated with error:', event); 167 + }); 168 + this._worker.addEventListener('unhandledrejection', (event) => { 169 + log.log('Unhandled promise rejection in worker:', event); 170 + }); 171 + } else { 172 + // when we are the worker, prepare to need to do some SVG work and load appropriate wasm & fonts 173 + await initWasm(fetch('/lib/resvg.wasm')); 174 + 175 + const font = await fetch('/fonts/NotoSansJP-Regular.ttf'); 176 + const fontData = await font.arrayBuffer(); 177 + this._resvgFontBuffer = new Uint8Array(fontData); 178 + } 134 179 } 135 180 136 181 /** */ ··· 348 393 } 349 394 350 395 /** 396 + * @param {import('dictionary-database').DrawMediaRequest[]} items 397 + * @param {MessagePort} source 398 + */ 399 + async drawMedia(items, source) { 400 + if (this._worker !== null) { // if a worker is available, offload the work to it 401 + this._worker.postMessage({action: 'drawMedia', params: {items}}, [source]); 402 + return; 403 + } 404 + // otherwise, you are the worker, so do the work 405 + safePerformance.mark('drawMedia:start'); 406 + 407 + // merge items with the same path to reduce the number of database queries. collects the canvases into a single array for each path. 408 + /** @type {Map<string, import('dictionary-database').DrawMediaGroupedRequest>} */ 409 + const groupedItems = new Map(); 410 + for (const item of items) { 411 + const {path, dictionary, canvasIndex, canvasWidth, canvasHeight, generation} = item; 412 + const key = `${path}:::${dictionary}`; 413 + if (!groupedItems.has(key)) { 414 + groupedItems.set(key, {path, dictionary, canvasIndexes: [], canvasWidth, canvasHeight, generation}); 415 + } 416 + groupedItems.get(key)?.canvasIndexes.push(canvasIndex); 417 + } 418 + const groupedItemsArray = [...groupedItems.values()]; 419 + 420 + /** @type {import('dictionary-database').FindPredicate<import('dictionary-database').MediaRequest, import('dictionary-database').MediaDataArrayBufferContent>} */ 421 + const predicate = (row, item) => (row.dictionary === item.dictionary); 422 + const results = await this._findMultiBulk('media', ['path'], groupedItemsArray, this._createOnlyQuery5, predicate, this._createDrawMediaBind); 423 + 424 + // move all svgs to front to have a hotter loop 425 + results.sort((a, _b) => (a.mediaType === 'image/svg+xml' ? -1 : 1)); 426 + 427 + safePerformance.mark('drawMedia:draw:start'); 428 + for (const m of results) { 429 + if (m.mediaType === 'image/svg+xml') { 430 + safePerformance.mark('drawMedia:draw:svg:start'); 431 + /** @type {import('@resvg/resvg-wasm').ResvgRenderOptions} */ 432 + const opts = { 433 + fitTo: { 434 + mode: 'width', 435 + value: m.canvasWidth, 436 + }, 437 + font: { 438 + fontBuffers: this._resvgFontBuffer !== null ? [this._resvgFontBuffer] : [], 439 + }, 440 + }; 441 + const resvgJS = new Resvg(new Uint8Array(m.content), opts); 442 + const render = resvgJS.render(); 443 + source.postMessage({action: 'drawBufferToCanvases', params: {buffer: render.pixels.buffer, width: render.width, height: render.height, canvasIndexes: m.canvasIndexes, generation: m.generation}}, [render.pixels.buffer]); 444 + safePerformance.mark('drawMedia:draw:svg:end'); 445 + safePerformance.measure('drawMedia:draw:svg', 'drawMedia:draw:svg:start', 'drawMedia:draw:svg:end'); 446 + } else { 447 + safePerformance.mark('drawMedia:draw:raster:start'); 448 + 449 + // ImageDecoder is slightly faster than Blob/createImageBitmap, but 450 + // 1) it is not available in Firefox <133 451 + // 2) it is available in Firefox >=133, but it's not possible to transfer VideoFrames cross-process 452 + // 453 + // So the second branch is a fallback for all versions of Firefox and doesn't use ImageDecoder at all 454 + // The second branch can eventually be changed to use ImageDecoder when we are okay with dropping support for Firefox <133 455 + // The branches can be unified entirely when Firefox implements support for transferring VideoFrames cross-process in postMessage 456 + if ('serviceWorker' in navigator) { // this is just a check for chrome, we don't actually use service worker functionality here 457 + // eslint-disable-next-line no-undef 458 + const imageDecoder = new ImageDecoder({type: m.mediaType, data: m.content}); 459 + await imageDecoder.decode().then((decodedImageResult) => { 460 + source.postMessage({action: 'drawDecodedImageToCanvases', params: {decodedImage: decodedImageResult.image, canvasIndexes: m.canvasIndexes, generation: m.generation}}, [decodedImageResult.image]); 461 + }); 462 + } else { 463 + const image = new Blob([m.content], {type: m.mediaType}); 464 + // eslint-disable-next-line no-undef 465 + await createImageBitmap(image).then((decodedImage) => { 466 + // we need to do a dumb hack where we convert this ImageBitmap to an ImageData by drawing it to a temporary canvas, because Firefox doesn't support transferring ImageBitmaps cross-process 467 + const canvas = new OffscreenCanvas(decodedImage.width, decodedImage.height); 468 + const ctx = canvas.getContext('2d'); 469 + if (ctx !== null) { 470 + ctx.drawImage(decodedImage, 0, 0); 471 + const imageData = ctx.getImageData(0, 0, decodedImage.width, decodedImage.height); 472 + source.postMessage({action: 'drawBufferToCanvases', params: {buffer: imageData.data.buffer, width: decodedImage.width, height: decodedImage.height, canvasIndexes: m.canvasIndexes, generation: m.generation}}, [imageData.data.buffer]); 473 + } 474 + }); 475 + } 476 + safePerformance.mark('drawMedia:draw:raster:end'); 477 + safePerformance.measure('drawMedia:draw:raster', 'drawMedia:draw:raster:start', 'drawMedia:draw:raster:end'); 478 + } 479 + } 480 + safePerformance.mark('drawMedia:draw:end'); 481 + safePerformance.measure('drawMedia:draw', 'drawMedia:draw:start', 'drawMedia:draw:end'); 482 + 483 + safePerformance.mark('drawMedia:end'); 484 + safePerformance.measure('drawMedia', 'drawMedia:start', 'drawMedia:end'); 485 + } 486 + 487 + /** 351 488 * @returns {Promise<import('dictionary-importer').Summary[]>} 352 489 */ 353 490 getDictionaryInfo() { ··· 478 615 let completeCount = 0; 479 616 const requiredCompleteCount = itemCount * indexCount; 480 617 /** 481 - * @param {TRow[]} rows 482 - * @param {import('dictionary-database').FindMultiBulkData<TItem>} data 618 + * @param {TItem} item 619 + * @returns {(rows: TRow[], data: import('dictionary-database').FindMultiBulkData<TItem>) => void} 483 620 */ 484 - const onGetAll = (rows, data) => { 621 + const onGetAll = (item) => (rows, data) => { 622 + if (typeof item === 'object' && item !== null && 'path' in item) { 623 + safePerformance.mark(`findMultiBulk:onGetAll:${item.path}:end`); 624 + safePerformance.measure(`findMultiBulk:onGetAll:${item.path}`, `findMultiBulk:onGetAll:${item.path}:start`, `findMultiBulk:onGetAll:${item.path}:end`); 625 + } 485 626 for (const row of rows) { 486 627 if (predicate(row, data.item)) { 487 628 results.push(createResult(row, data)); ··· 489 630 } 490 631 if (++completeCount >= requiredCompleteCount) { 491 632 resolve(results); 633 + safePerformance.mark('findMultiBulk:end'); 634 + safePerformance.measure('findMultiBulk', 'findMultiBulk:start', 'findMultiBulk:end'); 492 635 } 493 636 }; 637 + safePerformance.mark('findMultiBulk:getAll:start'); 494 638 for (let i = 0; i < itemCount; ++i) { 495 639 const item = items[i]; 496 640 const query = createQuery(item); 497 641 for (let j = 0; j < indexCount; ++j) { 498 642 /** @type {import('dictionary-database').FindMultiBulkData<TItem>} */ 499 643 const data = {item, itemIndex: i, indexIndex: j}; 500 - this._db.getAll(indexList[j], query, onGetAll, reject, data); 644 + if (typeof item === 'object' && item !== null && 'path' in item) { 645 + safePerformance.mark(`findMultiBulk:onGetAll:${item.path}:start`); 646 + } 647 + this._db.getAll(indexList[j], query, onGetAll(item), reject, data); 501 648 } 502 649 } 650 + safePerformance.mark('findMultiBulk:getAll:end'); 651 + safePerformance.measure('findMultiBulk:getAll', 'findMultiBulk:getAll:start', 'findMultiBulk:getAll:end'); 503 652 }); 504 653 } 505 654 ··· 662 811 } 663 812 664 813 /** 814 + * @param {import('dictionary-database').MediaDataArrayBufferContent} row 815 + * @param {import('dictionary-database').FindMultiBulkData<import('dictionary-database').DrawMediaGroupedRequest>} data 816 + * @returns {import('dictionary-database').DrawMedia} 817 + */ 818 + _createDrawMedia(row, {itemIndex: index, item: {canvasIndexes, canvasWidth, generation}}) { 819 + const {dictionary, path, mediaType, width, height, content} = row; 820 + return {index, dictionary, path, mediaType, width, height, content, canvasIndexes, canvasWidth, generation}; 821 + } 822 + 823 + /** 665 824 * @param {unknown} field 666 825 * @returns {string[]} 667 826 */ 668 827 _splitField(field) { 669 828 return typeof field === 'string' && field.length > 0 ? field.split(' ') : []; 829 + } 830 + 831 + // Parent-Worker API 832 + 833 + /** 834 + * @param {MessagePort} port 835 + */ 836 + async connectToDatabaseWorker(port) { 837 + if (this._worker !== null) { 838 + // executes outside of worker 839 + this._worker.postMessage({action: 'connectToDatabaseWorker'}, [port]); 840 + return; 841 + } 842 + // executes inside worker 843 + port.onmessage = (/** @type {MessageEvent<import('dictionary-database').ApiMessageAny>} */event) => { 844 + const {action, params} = event.data; 845 + return invokeApiMapHandler(this._apiMap, action, params, [port], () => {}); 846 + }; 847 + } 848 + 849 + /** @type {import('dictionary-database').ApiHandler<'drawMedia'>} */ 850 + _onDrawMedia(params, port) { 851 + void this.drawMedia(params.requests, port); 670 852 } 671 853 }
+19 -92
ext/js/display/display-content-manager.js
··· 17 17 */ 18 18 19 19 import {EventListenerCollection} from '../core/event-listener-collection.js'; 20 - import {base64ToArrayBuffer} from '../data/array-buffer-util.js'; 21 20 22 21 /** 23 22 * The content manager which is used when generating HTML display content. ··· 32 31 this._display = display; 33 32 /** @type {import('core').TokenObject} */ 34 33 this._token = {}; 35 - /** @type {Map<string, Map<string, Promise<?import('display-content-manager').CachedMediaDataLoaded>>>} */ 36 - this._mediaCache = new Map(); 37 - /** @type {import('display-content-manager').LoadMediaDataInfo[]} */ 38 - this._loadMediaData = []; 39 34 /** @type {EventListenerCollection} */ 40 35 this._eventListeners = new EventListenerCollection(); 36 + /** @type {import('display-content-manager').LoadMediaRequest[]} */ 37 + this._loadMediaRequests = []; 38 + } 39 + 40 + /** @type {import('display-content-manager').LoadMediaRequest[]} */ 41 + get loadMediaRequests() { 42 + return this._loadMediaRequests; 41 43 } 42 44 43 45 /** 44 - * Attempts to load the media file from a given dictionary. 45 - * @param {string} path The path to the media file in the dictionary. 46 - * @param {string} dictionary The name of the dictionary. 47 - * @param {import('display-content-manager').OnLoadCallback} onLoad The callback that is executed if the media was loaded successfully. 48 - * No assumptions should be made about the synchronicity of this callback. 49 - * @param {import('display-content-manager').OnUnloadCallback} onUnload The callback that is executed when the media should be unloaded. 46 + * Queues loading media file from a given dictionary. 47 + * @param {string} path 48 + * @param {string} dictionary 49 + * @param {OffscreenCanvas} canvas 50 50 */ 51 - loadMedia(path, dictionary, onLoad, onUnload) { 52 - void this._loadMedia(path, dictionary, onLoad, onUnload); 51 + loadMedia(path, dictionary, canvas) { 52 + this._loadMediaRequests.push({path, dictionary, canvas}); 53 53 } 54 54 55 55 /** 56 56 * Unloads all media that has been loaded. 57 57 */ 58 58 unloadAll() { 59 - for (const {onUnload, loaded} of this._loadMediaData) { 60 - if (typeof onUnload === 'function') { 61 - onUnload(loaded); 62 - } 63 - } 64 - this._loadMediaData = []; 65 - 66 - for (const map of this._mediaCache.values()) { 67 - for (const result of map.values()) { 68 - void this._revokeUrl(result); 69 - } 70 - } 71 - this._mediaCache.clear(); 72 - 73 59 this._token = {}; 74 60 75 61 this._eventListeners.removeAllEventListeners(); 62 + 63 + this._loadMediaRequests = []; 76 64 } 77 65 78 66 /** ··· 91 79 } 92 80 93 81 /** 94 - * @param {string} path 95 - * @param {string} dictionary 96 - * @param {import('display-content-manager').OnLoadCallback} onLoad 97 - * @param {import('display-content-manager').OnUnloadCallback} onUnload 82 + * Execute media requests 98 83 */ 99 - async _loadMedia(path, dictionary, onLoad, onUnload) { 100 - const token = this._token; 101 - const media = await this._getMedia(path, dictionary); 102 - if (token !== this._token || media === null) { return; } 103 - 104 - /** @type {import('display-content-manager').LoadMediaDataInfo} */ 105 - const data = {onUnload, loaded: false}; 106 - this._loadMediaData.push(data); 107 - onLoad(media.url); 108 - data.loaded = true; 109 - } 110 - 111 - /** 112 - * @param {string} path 113 - * @param {string} dictionary 114 - * @returns {Promise<?import('display-content-manager').CachedMediaDataLoaded>} 115 - */ 116 - _getMedia(path, dictionary) { 117 - /** @type {Promise<?import('display-content-manager').CachedMediaDataLoaded>|undefined} */ 118 - let promise; 119 - let dictionaryCache = this._mediaCache.get(dictionary); 120 - if (typeof dictionaryCache !== 'undefined') { 121 - promise = dictionaryCache.get(path); 122 - } else { 123 - dictionaryCache = new Map(); 124 - this._mediaCache.set(dictionary, dictionaryCache); 125 - } 126 - 127 - if (typeof promise === 'undefined') { 128 - promise = this._getMediaData(path, dictionary); 129 - dictionaryCache.set(path, promise); 130 - } 131 - 132 - return promise; 133 - } 134 - 135 - /** 136 - * @param {string} path 137 - * @param {string} dictionary 138 - * @returns {Promise<?import('display-content-manager').CachedMediaDataLoaded>} 139 - */ 140 - async _getMediaData(path, dictionary) { 141 - const token = this._token; 142 - const datas = await this._display.application.api.getMedia([{path, dictionary}]); 143 - if (token === this._token && datas.length > 0) { 144 - const data = datas[0]; 145 - const buffer = base64ToArrayBuffer(data.content); 146 - const blob = new Blob([buffer], {type: data.mediaType}); 147 - const url = URL.createObjectURL(blob); 148 - return {data, url}; 149 - } 150 - return null; 84 + async executeMediaRequests() { 85 + this._display.application.api.drawMedia(this._loadMediaRequests, this._loadMediaRequests.map(({canvas}) => canvas)); 86 + this._loadMediaRequests = []; 151 87 } 152 88 153 89 /** ··· 176 112 state: null, 177 113 content: null, 178 114 }); 179 - } 180 - 181 - /** 182 - * @param {Promise<?import('display-content-manager').CachedMediaDataLoaded>} data 183 - */ 184 - async _revokeUrl(data) { 185 - const result = await data; 186 - if (result === null) { return; } 187 - URL.revokeObjectURL(result.url); 188 115 } 189 116 }
+7
ext/js/display/display-generator.js
··· 43 43 this._language = 'ja'; 44 44 } 45 45 46 + /** @type {import('./display-content-manager.js').DisplayContentManager} */ 47 + get contentManager() { return this._contentManager; } 48 + 49 + set contentManager(contentManager) { 50 + this._contentManager = contentManager; 51 + } 52 + 46 53 /** */ 47 54 async prepare() { 48 55 await this._templates.loadFromFiles(['/templates-display.html']);
+19 -5
ext/js/display/display.js
··· 805 805 this._setContentToken = token; 806 806 try { 807 807 // Clear 808 - safePerformance.mark('display:clear:start'); 808 + safePerformance.mark('display:_onStateChanged:clear:start'); 809 809 this._closePopups(); 810 810 this._closeAllPopupMenus(); 811 811 this._eventListeners.removeAllEventListeners(); ··· 816 816 this._dictionaryEntries = []; 817 817 this._dictionaryEntryNodes = []; 818 818 this._elementOverflowController.clearElements(); 819 - safePerformance.mark('display:clear:end'); 820 - safePerformance.measure('display:clear', 'display:clear:start', 'display:clear:end'); 819 + safePerformance.mark('display:_onStateChanged:clear:end'); 820 + safePerformance.measure('display:_onStateChanged:clear', 'display:_onStateChanged:clear:start', 'display:_onStateChanged:clear:end'); 821 821 822 822 // Prepare 823 823 safePerformance.mark('display:_onStateChanged:prepare:start'); ··· 1427 1427 safePerformance.mark('display:contentUpdate:start'); 1428 1428 this._triggerContentUpdateStart(); 1429 1429 1430 - for (let i = 0, ii = dictionaryEntries.length; i < ii; ++i) { 1430 + let i = 0; 1431 + for (const dictionaryEntry of dictionaryEntries) { 1431 1432 safePerformance.mark('display:createEntry:start'); 1432 1433 1433 1434 if (i > 0) { ··· 1435 1436 if (this._setContentToken !== token) { return; } 1436 1437 } 1437 1438 1438 - const dictionaryEntry = dictionaryEntries[i]; 1439 + safePerformance.mark('display:createEntryReal:start'); 1440 + 1439 1441 const entry = ( 1440 1442 dictionaryEntry.type === 'term' ? 1441 1443 this._displayGenerator.createTermEntry(dictionaryEntry, this._dictionaryInfo) : ··· 1445 1447 this._dictionaryEntryNodes.push(entry); 1446 1448 this._addEntryEventListeners(entry); 1447 1449 this._triggerContentUpdateEntry(dictionaryEntry, entry, i); 1450 + if (this._setContentToken !== token) { return; } 1448 1451 container.appendChild(entry); 1452 + 1449 1453 if (focusEntry === i) { 1450 1454 this._focusEntry(i, 0, false); 1451 1455 } 1452 1456 1453 1457 this._elementOverflowController.addElements(entry); 1454 1458 1459 + safePerformance.mark('display:createEntryReal:end'); 1460 + safePerformance.measure('display:createEntryReal', 'display:createEntryReal:start', 'display:createEntryReal:end'); 1461 + 1455 1462 safePerformance.mark('display:createEntry:end'); 1456 1463 safePerformance.measure('display:createEntry', 'display:createEntry:start', 'display:createEntry:end'); 1464 + 1465 + if (i === 0) { 1466 + void this._contentManager.executeMediaRequests(); // prioritize loading media for first entry since it is visible 1467 + } 1468 + ++i; 1457 1469 } 1470 + if (this._setContentToken !== token) { return; } 1471 + void this._contentManager.executeMediaRequests(); 1458 1472 1459 1473 if (typeof scrollX === 'number' || typeof scrollY === 'number') { 1460 1474 let {x, y} = this._windowScroll;
+138
ext/js/display/media-drawing-worker.js
··· 1 + /* 2 + * Copyright (C) 2024 Yomitan Authors 3 + * 4 + * This program is free software: you can redistribute it and/or modify 5 + * it under the terms of the GNU General Public License as published by 6 + * the Free Software Foundation, either version 3 of the License, or 7 + * (at your option) any later version. 8 + * 9 + * This program is distributed in the hope that it will be useful, 10 + * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 + * GNU General Public License for more details. 13 + * 14 + * You should have received a copy of the GNU General Public License 15 + * along with this program. If not, see <https://www.gnu.org/licenses/>. 16 + */ 17 + 18 + import {API} from '../comm/api.js'; 19 + import {createApiMap, invokeApiMapHandler} from '../core/api-map.js'; 20 + import {log} from '../core/log.js'; 21 + import {WebExtension} from '../extension/web-extension.js'; 22 + 23 + export class MediaDrawingWorker { 24 + constructor() { 25 + /** @type {number} */ 26 + this._generation = 0; 27 + 28 + /** @type {MessagePort?} */ 29 + this._dbPort = null; 30 + 31 + /** @type {import('api').PmApiMap} */ 32 + this._fromApplicationApiMap = createApiMap([ 33 + ['drawMedia', this._onDrawMedia.bind(this)], 34 + ['connectToDatabaseWorker', this._onConnectToDatabaseWorker.bind(this)], 35 + ]); 36 + 37 + /** @type {import('api').PmApiMap} */ 38 + this._fromDatabaseApiMap = createApiMap([ 39 + ['drawBufferToCanvases', this._onDrawBufferToCanvases.bind(this)], 40 + ['drawDecodedImageToCanvases', this._onDrawDecodedImageToCanvases.bind(this)], 41 + ]); 42 + 43 + /** @type {Map<number, OffscreenCanvas[]>} */ 44 + this._canvasesByGeneration = new Map(); 45 + 46 + /** 47 + * @type {API} 48 + */ 49 + this._api = new API(new WebExtension()); 50 + } 51 + 52 + /** 53 + * 54 + */ 55 + async prepare() { 56 + addEventListener('message', (event) => { 57 + /** @type {import('api').PmApiMessageAny} */ 58 + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 59 + const message = event.data; 60 + return invokeApiMapHandler(this._fromApplicationApiMap, message.action, message.params, [event.ports], () => {}); 61 + }); 62 + } 63 + 64 + /** @type {import('api').PmApiHandler<'drawMedia'>} */ 65 + async _onDrawMedia({requests}) { 66 + this._generation++; 67 + this._canvasesByGeneration.set(this._generation, requests.map((request) => request.canvas)); 68 + this._cleanOldGenerations(); 69 + const newRequests = requests.map((request, index) => ({...request, canvas: null, generation: this._generation, canvasIndex: index, canvasWidth: request.canvas.width, canvasHeight: request.canvas.height})); 70 + if (this._dbPort !== null) { 71 + this._dbPort.postMessage({action: 'drawMedia', params: {requests: newRequests}}); 72 + } else { 73 + log.error('no database port available'); 74 + } 75 + } 76 + 77 + /** @type {import('api').PmApiHandler<'drawBufferToCanvases'>} */ 78 + async _onDrawBufferToCanvases({buffer, width, height, canvasIndexes, generation}) { 79 + try { 80 + const canvases = this._canvasesByGeneration.get(generation); 81 + if (typeof canvases === 'undefined') { 82 + return; 83 + } 84 + const imageData = new ImageData(new Uint8ClampedArray(buffer), width, height); 85 + for (const ci of canvasIndexes) { 86 + const c = canvases[ci]; 87 + c.getContext('2d')?.putImageData(imageData, 0, 0); 88 + } 89 + } catch (e) { 90 + log.error(e); 91 + } 92 + } 93 + 94 + /** @type {import('api').PmApiHandler<'drawDecodedImageToCanvases'>} */ 95 + async _onDrawDecodedImageToCanvases({decodedImage, canvasIndexes, generation}) { 96 + try { 97 + const canvases = this._canvasesByGeneration.get(generation); 98 + if (typeof canvases === 'undefined') { 99 + return; 100 + } 101 + for (const ci of canvasIndexes) { 102 + const c = canvases[ci]; 103 + c.getContext('2d')?.drawImage(decodedImage, 0, 0, c.width, c.height); 104 + } 105 + } catch (e) { 106 + log.error(e); 107 + } 108 + } 109 + 110 + /** @type {import('api').PmApiHandler<'connectToDatabaseWorker'>} */ 111 + async _onConnectToDatabaseWorker(_params, ports) { 112 + if (ports === null) { 113 + return; 114 + } 115 + const dbPort = ports[0]; 116 + this._dbPort = dbPort; 117 + dbPort.addEventListener('message', (/** @type {MessageEvent<import('api').PmApiMessageAny>} */ event) => { 118 + const message = event.data; 119 + return invokeApiMapHandler(this._fromDatabaseApiMap, message.action, message.params, [event.ports], () => {}); 120 + }); 121 + dbPort.start(); 122 + } 123 + 124 + /** 125 + * @param {number} keepNGenerations Number of generations to keep, defaults to 2 (the current generation and the one before it). 126 + */ 127 + _cleanOldGenerations(keepNGenerations = 2) { 128 + const generations = [...this._canvasesByGeneration.keys()]; 129 + for (const g of generations) { 130 + if (g <= this._generation - keepNGenerations) { 131 + this._canvasesByGeneration.delete(g); 132 + } 133 + } 134 + } 135 + } 136 + 137 + const mediaDrawingWorker = new MediaDrawingWorker(); 138 + await mediaDrawingWorker.prepare();
+36 -48
ext/js/display/structured-content-generator.js
··· 16 16 * along with this program. If not, see <https://www.gnu.org/licenses/>. 17 17 */ 18 18 19 + import {DisplayContentManager} from '../display/display-content-manager.js'; 19 20 import {getLanguageFromText} from '../language/text-utilities.js'; 21 + import {AnkiTemplateRendererContentManager} from '../templates/anki-template-renderer-content-manager.js'; 20 22 21 23 export class StructuredContentGenerator { 22 24 /** ··· 64 66 preferredWidth, 65 67 preferredHeight, 66 68 title, 67 - alt, 68 69 pixelated, 69 70 imageRendering, 70 71 appearance, ··· 97 98 const imageContainer = this._createElement('span', 'gloss-image-container'); 98 99 node.appendChild(imageContainer); 99 100 100 - const aspectRatioSizer = this._createElement('span', 'gloss-image-sizer'); 101 - imageContainer.appendChild(aspectRatioSizer); 102 - 103 - const imageBackground = this._createElement('span', 'gloss-image-background'); 104 - imageContainer.appendChild(imageBackground); 105 - 106 - const image = /** @type {HTMLImageElement} */ (this._createElement('img', 'gloss-image')); 107 - image.alt = typeof alt === 'string' ? alt : ''; 108 - imageContainer.appendChild(image); 109 - 110 - const overlay = this._createElement('span', 'gloss-image-container-overlay'); 111 - imageContainer.appendChild(overlay); 112 - 113 - const linkText = this._createElement('span', 'gloss-image-link-text'); 114 - linkText.textContent = 'Image'; 115 - node.appendChild(linkText); 116 - 117 101 node.dataset.path = path; 118 102 node.dataset.dictionary = dictionary; 119 103 node.dataset.imageLoadState = 'not-loaded'; ··· 130 114 node.dataset.sizeUnits = sizeUnits; 131 115 } 132 116 133 - imageContainer.style.width = `${usedWidth}em`; 134 117 if (typeof border === 'string') { imageContainer.style.border = border; } 135 118 if (typeof borderRadius === 'string') { imageContainer.style.borderRadius = borderRadius; } 136 119 if (typeof title === 'string') { 137 120 imageContainer.title = title; 138 121 } 139 122 140 - aspectRatioSizer.style.paddingTop = `${invAspectRatio * 100}%`; 141 - 142 123 if (this._contentManager !== null) { 143 - this._contentManager.loadMedia( 144 - path, 145 - dictionary, 146 - (url) => this._setImageData(node, image, imageBackground, url, false), 147 - () => this._setImageData(node, image, imageBackground, null, true), 148 - ); 124 + const image = this._contentManager instanceof DisplayContentManager ? 125 + /** @type {HTMLCanvasElement} */ (this._createElement('canvas', 'gloss-image')) : 126 + /** @type {HTMLImageElement} */ (this._createElement('img', 'gloss-image')); 127 + if (sizeUnits === 'em' && (hasPreferredWidth || hasPreferredHeight)) { 128 + const emSize = 14; // We could Number.parseFloat(getComputedStyle(document.documentElement).fontSize); here for more accuracy but it would cause a layout and be extremely slow; possible improvement would be to calculate and cache the value 129 + const scaleFactor = 2 * window.devicePixelRatio; 130 + image.style.width = `${usedWidth}em`; 131 + image.style.height = `${usedWidth * invAspectRatio}em`; 132 + image.width = usedWidth * emSize * scaleFactor; 133 + } else { 134 + image.width = usedWidth; 135 + } 136 + image.height = image.width * invAspectRatio; 137 + 138 + imageContainer.appendChild(image); 139 + 140 + if (this._contentManager instanceof DisplayContentManager) { 141 + this._contentManager.loadMedia( 142 + path, 143 + dictionary, 144 + (/** @type {HTMLCanvasElement} */(image)).transferControlToOffscreen(), 145 + ); 146 + } else if (this._contentManager instanceof AnkiTemplateRendererContentManager) { 147 + this._contentManager.loadMedia( 148 + path, 149 + dictionary, 150 + (url) => { 151 + (/** @type {HTMLImageElement} */(image)).src = url; 152 + }, 153 + () => { 154 + (/** @type {HTMLImageElement} */(image)).removeAttribute('src'); 155 + }, 156 + ); 157 + } 149 158 } 150 159 151 160 return node; ··· 221 230 } catch (e) { 222 231 // DOMException if key is malformed 223 232 } 224 - } 225 - } 226 - 227 - /** 228 - * @param {HTMLAnchorElement} node 229 - * @param {HTMLImageElement} image 230 - * @param {HTMLElement} imageBackground 231 - * @param {?string} url 232 - * @param {boolean} unloaded 233 - */ 234 - _setImageData(node, image, imageBackground, url, unloaded) { 235 - if (url !== null) { 236 - image.src = url; 237 - node.href = url; 238 - node.dataset.imageLoadState = 'loaded'; 239 - imageBackground.style.setProperty('--image', `url("${url}")`); 240 - } else { 241 - image.removeAttribute('src'); 242 - node.removeAttribute('href'); 243 - node.dataset.imageLoadState = unloaded ? 'unloaded' : 'load-error'; 244 - imageBackground.style.removeProperty('--image'); 245 233 } 246 234 } 247 235
+7
jsconfig.json
··· 17 17 }, 18 18 "types": [ 19 19 "chrome", 20 + "dom-webcodecs", 20 21 "firefox-webext-browser", 21 22 "handlebars", 22 23 "jszip", ··· 25 26 "zip.js", 26 27 "dexie", 27 28 "ajv" 29 + ], 30 + "lib": [ 31 + "ES2022", 32 + "DOM", 33 + "DOM.Iterable", 34 + "WebWorker" 28 35 ] 29 36 }, 30 37 "include": [
+27
package-lock.json
··· 9 9 "version": "0.0.0", 10 10 "license": "GPL-3.0-or-later", 11 11 "dependencies": { 12 + "@resvg/resvg-wasm": "^2.6.2", 12 13 "@zip.js/zip.js": "^2.7.45", 13 14 "dexie": "^3.2.5", 14 15 "dexie-export-import": "^4.1.2", ··· 26 27 "@types/browserify": "^12.0.40", 27 28 "@types/chrome": "^0.0.268", 28 29 "@types/css": "^0.0.37", 30 + "@types/dom-webcodecs": "^0.1.13", 29 31 "@types/events": "^3.0.3", 30 32 "@types/firefox-webext-browser": "^120.0.3", 31 33 "@types/jsdom": "^21.1.6", ··· 1231 1233 "node": ">=16" 1232 1234 } 1233 1235 }, 1236 + "node_modules/@resvg/resvg-wasm": { 1237 + "version": "2.6.2", 1238 + "resolved": "https://registry.npmjs.org/@resvg/resvg-wasm/-/resvg-wasm-2.6.2.tgz", 1239 + "integrity": "sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw==", 1240 + "engines": { 1241 + "node": ">= 10" 1242 + } 1243 + }, 1234 1244 "node_modules/@rollup/rollup-android-arm-eabi": { 1235 1245 "version": "4.24.0", 1236 1246 "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.0.tgz", ··· 1656 1666 "version": "0.0.37", 1657 1667 "resolved": "https://registry.npmjs.org/@types/css/-/css-0.0.37.tgz", 1658 1668 "integrity": "sha512-IVhWCNH1mw3VRjkOMHsxVAcnANhee9w//TX1fqmALP628Dzf6VMG1LRnOngpptnrilcWCkmcY1tj6QkKGUy0CA==", 1669 + "dev": true 1670 + }, 1671 + "node_modules/@types/dom-webcodecs": { 1672 + "version": "0.1.13", 1673 + "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.13.tgz", 1674 + "integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==", 1659 1675 "dev": true 1660 1676 }, 1661 1677 "node_modules/@types/eslint": { ··· 10937 10953 "playwright": "1.44.1" 10938 10954 } 10939 10955 }, 10956 + "@resvg/resvg-wasm": { 10957 + "version": "2.6.2", 10958 + "resolved": "https://registry.npmjs.org/@resvg/resvg-wasm/-/resvg-wasm-2.6.2.tgz", 10959 + "integrity": "sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw==" 10960 + }, 10940 10961 "@rollup/rollup-android-arm-eabi": { 10941 10962 "version": "4.24.0", 10942 10963 "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.0.tgz", ··· 11214 11235 "version": "0.0.37", 11215 11236 "resolved": "https://registry.npmjs.org/@types/css/-/css-0.0.37.tgz", 11216 11237 "integrity": "sha512-IVhWCNH1mw3VRjkOMHsxVAcnANhee9w//TX1fqmALP628Dzf6VMG1LRnOngpptnrilcWCkmcY1tj6QkKGUy0CA==", 11238 + "dev": true 11239 + }, 11240 + "@types/dom-webcodecs": { 11241 + "version": "0.1.13", 11242 + "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.13.tgz", 11243 + "integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==", 11217 11244 "dev": true 11218 11245 }, 11219 11246 "@types/eslint": {
+2
package.json
··· 66 66 "@types/browserify": "^12.0.40", 67 67 "@types/chrome": "^0.0.268", 68 68 "@types/css": "^0.0.37", 69 + "@types/dom-webcodecs": "^0.1.13", 69 70 "@types/events": "^3.0.3", 70 71 "@types/firefox-webext-browser": "^120.0.3", 71 72 "@types/jsdom": "^21.1.6", ··· 106 107 "vitest": "^1.2.2" 107 108 }, 108 109 "dependencies": { 110 + "@resvg/resvg-wasm": "^2.6.2", 109 111 "@zip.js/zip.js": "^2.7.45", 110 112 "dexie": "^3.2.5", 111 113 "dexie-export-import": "^4.1.2",
+12 -12
test/data/anki-note-builder-test-results.json
··· 863 863 "frequency-average-occurrence": "0", 864 864 "furigana": "<ruby>画像<rt>がぞう</rt></ruby>", 865 865 "furigana-plain": "画像[がぞう]", 866 - "glossary": "<div style=\"text-align: left;\" class=\"yomitan-glossary\"><i>(n, termsDictAlias)</i> <ul><li>gazou definition 1</li><li><a target=\"_blank\" rel=\"noreferrer noopener\" style=\"cursor:inherit;display:inline-block;position:relative;line-height:1;max-width:100%;color:inherit;\"><span style=\"display:inline-block;white-space:nowrap;max-width:100%;max-height:100vh;position:relative;vertical-align:top;line-height:0;overflow:hidden;font-size:1px;width: 350em;\"><span style=\"display:inline-block;width:0;vertical-align:top;font-size:0;padding-top: 100%;\"></span><span style=\"--image:none;position:absolute;left:0;top:0;width:100%;height:100%;-webkit-mask-repeat:no-repeat;-webkit-mask-position:center center;-webkit-mask-mode:alpha;-webkit-mask-size:contain;-webkit-mask-image:var(--image);mask-repeat:no-repeat;mask-position:center center;mask-mode:alpha;mask-size:contain;mask-image:var(--image);background-color:currentColor;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;display:none;\"></span><img alt=\"\" style=\"display:inline-block;vertical-align:top;object-fit:contain;border:none;outline:none;position:absolute;left:0;top:0;width:100%;height:100%;display:none;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;\"><span style=\"position:absolute;left:0;top:0;width:100%;height:100%;display:table;table-layout:fixed;white-space:normal;font-size:initial;line-height:initial;color:initial;\"></span></span><span style=\"display:none;line-height:initial;\">Image</span></a></li></ul><style>.yomitan-glossary ul[data-sc-content='glossary'] {\n color: #ffff00;\n}</style></div>", 867 - "glossary-brief": "<div style=\"text-align: left;\" class=\"yomitan-glossary\"><ul><li>gazou definition 1</li><li><a target=\"_blank\" rel=\"noreferrer noopener\" style=\"cursor:inherit;display:inline-block;position:relative;line-height:1;max-width:100%;color:inherit;\"><span style=\"display:inline-block;white-space:nowrap;max-width:100%;max-height:100vh;position:relative;vertical-align:top;line-height:0;overflow:hidden;font-size:1px;width: 350em;\"><span style=\"display:inline-block;width:0;vertical-align:top;font-size:0;padding-top: 100%;\"></span><span style=\"--image:none;position:absolute;left:0;top:0;width:100%;height:100%;-webkit-mask-repeat:no-repeat;-webkit-mask-position:center center;-webkit-mask-mode:alpha;-webkit-mask-size:contain;-webkit-mask-image:var(--image);mask-repeat:no-repeat;mask-position:center center;mask-mode:alpha;mask-size:contain;mask-image:var(--image);background-color:currentColor;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;display:none;\"></span><img alt=\"\" style=\"display:inline-block;vertical-align:top;object-fit:contain;border:none;outline:none;position:absolute;left:0;top:0;width:100%;height:100%;display:none;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;\"><span style=\"position:absolute;left:0;top:0;width:100%;height:100%;display:table;table-layout:fixed;white-space:normal;font-size:initial;line-height:initial;color:initial;\"></span></span><span style=\"display:none;line-height:initial;\">Image</span></a></li></ul><style>.yomitan-glossary ul[data-sc-content='glossary'] {\n color: #ffff00;\n}</style></div>", 868 - "glossary-no-dictionary": "<div style=\"text-align: left;\" class=\"yomitan-glossary\"><i>(n)</i> <ul><li>gazou definition 1</li><li><a target=\"_blank\" rel=\"noreferrer noopener\" style=\"cursor:inherit;display:inline-block;position:relative;line-height:1;max-width:100%;color:inherit;\"><span style=\"display:inline-block;white-space:nowrap;max-width:100%;max-height:100vh;position:relative;vertical-align:top;line-height:0;overflow:hidden;font-size:1px;width: 350em;\"><span style=\"display:inline-block;width:0;vertical-align:top;font-size:0;padding-top: 100%;\"></span><span style=\"--image:none;position:absolute;left:0;top:0;width:100%;height:100%;-webkit-mask-repeat:no-repeat;-webkit-mask-position:center center;-webkit-mask-mode:alpha;-webkit-mask-size:contain;-webkit-mask-image:var(--image);mask-repeat:no-repeat;mask-position:center center;mask-mode:alpha;mask-size:contain;mask-image:var(--image);background-color:currentColor;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;display:none;\"></span><img alt=\"\" style=\"display:inline-block;vertical-align:top;object-fit:contain;border:none;outline:none;position:absolute;left:0;top:0;width:100%;height:100%;display:none;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;\"><span style=\"position:absolute;left:0;top:0;width:100%;height:100%;display:table;table-layout:fixed;white-space:normal;font-size:initial;line-height:initial;color:initial;\"></span></span><span style=\"display:none;line-height:initial;\">Image</span></a></li></ul><style>.yomitan-glossary ul[data-sc-content='glossary'] {\n color: #ffff00;\n}</style></div>", 869 - "glossary-first": "<div style=\"text-align: left;\" class=\"yomitan-glossary\"><i>(n, termsDictAlias)</i> <ul><li>gazou definition 1</li><li><a target=\"_blank\" rel=\"noreferrer noopener\" style=\"cursor:inherit;display:inline-block;position:relative;line-height:1;max-width:100%;color:inherit;\"><span style=\"display:inline-block;white-space:nowrap;max-width:100%;max-height:100vh;position:relative;vertical-align:top;line-height:0;overflow:hidden;font-size:1px;width: 350em;\"><span style=\"display:inline-block;width:0;vertical-align:top;font-size:0;padding-top: 100%;\"></span><span style=\"--image:none;position:absolute;left:0;top:0;width:100%;height:100%;-webkit-mask-repeat:no-repeat;-webkit-mask-position:center center;-webkit-mask-mode:alpha;-webkit-mask-size:contain;-webkit-mask-image:var(--image);mask-repeat:no-repeat;mask-position:center center;mask-mode:alpha;mask-size:contain;mask-image:var(--image);background-color:currentColor;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;display:none;\"></span><img alt=\"\" style=\"display:inline-block;vertical-align:top;object-fit:contain;border:none;outline:none;position:absolute;left:0;top:0;width:100%;height:100%;display:none;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;\"><span style=\"position:absolute;left:0;top:0;width:100%;height:100%;display:table;table-layout:fixed;white-space:normal;font-size:initial;line-height:initial;color:initial;\"></span></span><span style=\"display:none;line-height:initial;\">Image</span></a></li></ul><style>.yomitan-glossary ul[data-sc-content='glossary'] {\n color: #ffff00;\n}</style></div>", 870 - "glossary-first-brief": "<div style=\"text-align: left;\" class=\"yomitan-glossary\"><ul><li>gazou definition 1</li><li><a target=\"_blank\" rel=\"noreferrer noopener\" style=\"cursor:inherit;display:inline-block;position:relative;line-height:1;max-width:100%;color:inherit;\"><span style=\"display:inline-block;white-space:nowrap;max-width:100%;max-height:100vh;position:relative;vertical-align:top;line-height:0;overflow:hidden;font-size:1px;width: 350em;\"><span style=\"display:inline-block;width:0;vertical-align:top;font-size:0;padding-top: 100%;\"></span><span style=\"--image:none;position:absolute;left:0;top:0;width:100%;height:100%;-webkit-mask-repeat:no-repeat;-webkit-mask-position:center center;-webkit-mask-mode:alpha;-webkit-mask-size:contain;-webkit-mask-image:var(--image);mask-repeat:no-repeat;mask-position:center center;mask-mode:alpha;mask-size:contain;mask-image:var(--image);background-color:currentColor;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;display:none;\"></span><img alt=\"\" style=\"display:inline-block;vertical-align:top;object-fit:contain;border:none;outline:none;position:absolute;left:0;top:0;width:100%;height:100%;display:none;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;\"><span style=\"position:absolute;left:0;top:0;width:100%;height:100%;display:table;table-layout:fixed;white-space:normal;font-size:initial;line-height:initial;color:initial;\"></span></span><span style=\"display:none;line-height:initial;\">Image</span></a></li></ul><style>.yomitan-glossary ul[data-sc-content='glossary'] {\n color: #ffff00;\n}</style></div>", 871 - "glossary-first-no-dictionary": "<div style=\"text-align: left;\" class=\"yomitan-glossary\"><i>(n)</i> <ul><li>gazou definition 1</li><li><a target=\"_blank\" rel=\"noreferrer noopener\" style=\"cursor:inherit;display:inline-block;position:relative;line-height:1;max-width:100%;color:inherit;\"><span style=\"display:inline-block;white-space:nowrap;max-width:100%;max-height:100vh;position:relative;vertical-align:top;line-height:0;overflow:hidden;font-size:1px;width: 350em;\"><span style=\"display:inline-block;width:0;vertical-align:top;font-size:0;padding-top: 100%;\"></span><span style=\"--image:none;position:absolute;left:0;top:0;width:100%;height:100%;-webkit-mask-repeat:no-repeat;-webkit-mask-position:center center;-webkit-mask-mode:alpha;-webkit-mask-size:contain;-webkit-mask-image:var(--image);mask-repeat:no-repeat;mask-position:center center;mask-mode:alpha;mask-size:contain;mask-image:var(--image);background-color:currentColor;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;display:none;\"></span><img alt=\"\" style=\"display:inline-block;vertical-align:top;object-fit:contain;border:none;outline:none;position:absolute;left:0;top:0;width:100%;height:100%;display:none;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;\"><span style=\"position:absolute;left:0;top:0;width:100%;height:100%;display:table;table-layout:fixed;white-space:normal;font-size:initial;line-height:initial;color:initial;\"></span></span><span style=\"display:none;line-height:initial;\">Image</span></a></li></ul><style>.yomitan-glossary ul[data-sc-content='glossary'] {\n color: #ffff00;\n}</style></div>", 866 + "glossary": "<div style=\"text-align: left;\" class=\"yomitan-glossary\"><i>(n, termsDictAlias)</i> <ul><li>gazou definition 1</li><li><a target=\"_blank\" rel=\"noreferrer noopener\" style=\"cursor:inherit;display:inline-block;position:relative;line-height:1;max-width:100%;color:inherit;\"><span style=\"display:inline-block;font-size:1px;\"><img width=\"350\" height=\"350\" style=\"display:inline-block;vertical-align:top;object-fit:contain;border:none;outline:none;width:100%;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;\"></span></a></li></ul><style>.yomitan-glossary ul[data-sc-content='glossary'] {\n color: #ffff00;\n}</style></div>", 867 + "glossary-brief": "<div style=\"text-align: left;\" class=\"yomitan-glossary\"><ul><li>gazou definition 1</li><li><a target=\"_blank\" rel=\"noreferrer noopener\" style=\"cursor:inherit;display:inline-block;position:relative;line-height:1;max-width:100%;color:inherit;\"><span style=\"display:inline-block;font-size:1px;\"><img width=\"350\" height=\"350\" style=\"display:inline-block;vertical-align:top;object-fit:contain;border:none;outline:none;width:100%;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;\"></span></a></li></ul><style>.yomitan-glossary ul[data-sc-content='glossary'] {\n color: #ffff00;\n}</style></div>", 868 + "glossary-no-dictionary": "<div style=\"text-align: left;\" class=\"yomitan-glossary\"><i>(n)</i> <ul><li>gazou definition 1</li><li><a target=\"_blank\" rel=\"noreferrer noopener\" style=\"cursor:inherit;display:inline-block;position:relative;line-height:1;max-width:100%;color:inherit;\"><span style=\"display:inline-block;font-size:1px;\"><img width=\"350\" height=\"350\" style=\"display:inline-block;vertical-align:top;object-fit:contain;border:none;outline:none;width:100%;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;\"></span></a></li></ul><style>.yomitan-glossary ul[data-sc-content='glossary'] {\n color: #ffff00;\n}</style></div>", 869 + "glossary-first": "<div style=\"text-align: left;\" class=\"yomitan-glossary\"><i>(n, termsDictAlias)</i> <ul><li>gazou definition 1</li><li><a target=\"_blank\" rel=\"noreferrer noopener\" style=\"cursor:inherit;display:inline-block;position:relative;line-height:1;max-width:100%;color:inherit;\"><span style=\"display:inline-block;font-size:1px;\"><img width=\"350\" height=\"350\" style=\"display:inline-block;vertical-align:top;object-fit:contain;border:none;outline:none;width:100%;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;\"></span></a></li></ul><style>.yomitan-glossary ul[data-sc-content='glossary'] {\n color: #ffff00;\n}</style></div>", 870 + "glossary-first-brief": "<div style=\"text-align: left;\" class=\"yomitan-glossary\"><ul><li>gazou definition 1</li><li><a target=\"_blank\" rel=\"noreferrer noopener\" style=\"cursor:inherit;display:inline-block;position:relative;line-height:1;max-width:100%;color:inherit;\"><span style=\"display:inline-block;font-size:1px;\"><img width=\"350\" height=\"350\" style=\"display:inline-block;vertical-align:top;object-fit:contain;border:none;outline:none;width:100%;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;\"></span></a></li></ul><style>.yomitan-glossary ul[data-sc-content='glossary'] {\n color: #ffff00;\n}</style></div>", 871 + "glossary-first-no-dictionary": "<div style=\"text-align: left;\" class=\"yomitan-glossary\"><i>(n)</i> <ul><li>gazou definition 1</li><li><a target=\"_blank\" rel=\"noreferrer noopener\" style=\"cursor:inherit;display:inline-block;position:relative;line-height:1;max-width:100%;color:inherit;\"><span style=\"display:inline-block;font-size:1px;\"><img width=\"350\" height=\"350\" style=\"display:inline-block;vertical-align:top;object-fit:contain;border:none;outline:none;width:100%;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;\"></span></a></li></ul><style>.yomitan-glossary ul[data-sc-content='glossary'] {\n color: #ffff00;\n}</style></div>", 872 872 "part-of-speech": "Noun", 873 873 "pitch-accents": "", 874 874 "pitch-accent-graphs": "", ··· 1570 1570 "frequency-average-occurrence": "0", 1571 1571 "furigana": "<ruby>画像<rt>がぞう</rt></ruby>", 1572 1572 "furigana-plain": "画像[がぞう]", 1573 - "glossary": "<div style=\"text-align: left;\" class=\"yomitan-glossary\"><i>(n, termsDictAlias)</i> <ul><li>gazou definition 1</li><li><a target=\"_blank\" rel=\"noreferrer noopener\" style=\"cursor:inherit;display:inline-block;position:relative;line-height:1;max-width:100%;color:inherit;\"><span style=\"display:inline-block;white-space:nowrap;max-width:100%;max-height:100vh;position:relative;vertical-align:top;line-height:0;overflow:hidden;font-size:1px;width: 350em;\"><span style=\"display:inline-block;width:0;vertical-align:top;font-size:0;padding-top: 100%;\"></span><span style=\"--image:none;position:absolute;left:0;top:0;width:100%;height:100%;-webkit-mask-repeat:no-repeat;-webkit-mask-position:center center;-webkit-mask-mode:alpha;-webkit-mask-size:contain;-webkit-mask-image:var(--image);mask-repeat:no-repeat;mask-position:center center;mask-mode:alpha;mask-size:contain;mask-image:var(--image);background-color:currentColor;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;display:none;\"></span><img alt=\"\" style=\"display:inline-block;vertical-align:top;object-fit:contain;border:none;outline:none;position:absolute;left:0;top:0;width:100%;height:100%;display:none;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;\"><span style=\"position:absolute;left:0;top:0;width:100%;height:100%;display:table;table-layout:fixed;white-space:normal;font-size:initial;line-height:initial;color:initial;\"></span></span><span style=\"display:none;line-height:initial;\">Image</span></a></li></ul><style>.yomitan-glossary ul[data-sc-content='glossary'] {\n color: #ffff00;\n}</style></div>", 1574 - "glossary-brief": "<div style=\"text-align: left;\" class=\"yomitan-glossary\"><ul><li>gazou definition 1</li><li><a target=\"_blank\" rel=\"noreferrer noopener\" style=\"cursor:inherit;display:inline-block;position:relative;line-height:1;max-width:100%;color:inherit;\"><span style=\"display:inline-block;white-space:nowrap;max-width:100%;max-height:100vh;position:relative;vertical-align:top;line-height:0;overflow:hidden;font-size:1px;width: 350em;\"><span style=\"display:inline-block;width:0;vertical-align:top;font-size:0;padding-top: 100%;\"></span><span style=\"--image:none;position:absolute;left:0;top:0;width:100%;height:100%;-webkit-mask-repeat:no-repeat;-webkit-mask-position:center center;-webkit-mask-mode:alpha;-webkit-mask-size:contain;-webkit-mask-image:var(--image);mask-repeat:no-repeat;mask-position:center center;mask-mode:alpha;mask-size:contain;mask-image:var(--image);background-color:currentColor;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;display:none;\"></span><img alt=\"\" style=\"display:inline-block;vertical-align:top;object-fit:contain;border:none;outline:none;position:absolute;left:0;top:0;width:100%;height:100%;display:none;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;\"><span style=\"position:absolute;left:0;top:0;width:100%;height:100%;display:table;table-layout:fixed;white-space:normal;font-size:initial;line-height:initial;color:initial;\"></span></span><span style=\"display:none;line-height:initial;\">Image</span></a></li></ul><style>.yomitan-glossary ul[data-sc-content='glossary'] {\n color: #ffff00;\n}</style></div>", 1575 - "glossary-no-dictionary": "<div style=\"text-align: left;\" class=\"yomitan-glossary\"><i>(n)</i> <ul><li>gazou definition 1</li><li><a target=\"_blank\" rel=\"noreferrer noopener\" style=\"cursor:inherit;display:inline-block;position:relative;line-height:1;max-width:100%;color:inherit;\"><span style=\"display:inline-block;white-space:nowrap;max-width:100%;max-height:100vh;position:relative;vertical-align:top;line-height:0;overflow:hidden;font-size:1px;width: 350em;\"><span style=\"display:inline-block;width:0;vertical-align:top;font-size:0;padding-top: 100%;\"></span><span style=\"--image:none;position:absolute;left:0;top:0;width:100%;height:100%;-webkit-mask-repeat:no-repeat;-webkit-mask-position:center center;-webkit-mask-mode:alpha;-webkit-mask-size:contain;-webkit-mask-image:var(--image);mask-repeat:no-repeat;mask-position:center center;mask-mode:alpha;mask-size:contain;mask-image:var(--image);background-color:currentColor;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;display:none;\"></span><img alt=\"\" style=\"display:inline-block;vertical-align:top;object-fit:contain;border:none;outline:none;position:absolute;left:0;top:0;width:100%;height:100%;display:none;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;\"><span style=\"position:absolute;left:0;top:0;width:100%;height:100%;display:table;table-layout:fixed;white-space:normal;font-size:initial;line-height:initial;color:initial;\"></span></span><span style=\"display:none;line-height:initial;\">Image</span></a></li></ul><style>.yomitan-glossary ul[data-sc-content='glossary'] {\n color: #ffff00;\n}</style></div>", 1576 - "glossary-first": "<div style=\"text-align: left;\" class=\"yomitan-glossary\"><i>(n, termsDictAlias)</i> <ul><li>gazou definition 1</li><li><a target=\"_blank\" rel=\"noreferrer noopener\" style=\"cursor:inherit;display:inline-block;position:relative;line-height:1;max-width:100%;color:inherit;\"><span style=\"display:inline-block;white-space:nowrap;max-width:100%;max-height:100vh;position:relative;vertical-align:top;line-height:0;overflow:hidden;font-size:1px;width: 350em;\"><span style=\"display:inline-block;width:0;vertical-align:top;font-size:0;padding-top: 100%;\"></span><span style=\"--image:none;position:absolute;left:0;top:0;width:100%;height:100%;-webkit-mask-repeat:no-repeat;-webkit-mask-position:center center;-webkit-mask-mode:alpha;-webkit-mask-size:contain;-webkit-mask-image:var(--image);mask-repeat:no-repeat;mask-position:center center;mask-mode:alpha;mask-size:contain;mask-image:var(--image);background-color:currentColor;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;display:none;\"></span><img alt=\"\" style=\"display:inline-block;vertical-align:top;object-fit:contain;border:none;outline:none;position:absolute;left:0;top:0;width:100%;height:100%;display:none;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;\"><span style=\"position:absolute;left:0;top:0;width:100%;height:100%;display:table;table-layout:fixed;white-space:normal;font-size:initial;line-height:initial;color:initial;\"></span></span><span style=\"display:none;line-height:initial;\">Image</span></a></li></ul><style>.yomitan-glossary ul[data-sc-content='glossary'] {\n color: #ffff00;\n}</style></div>", 1577 - "glossary-first-brief": "<div style=\"text-align: left;\" class=\"yomitan-glossary\"><ul><li>gazou definition 1</li><li><a target=\"_blank\" rel=\"noreferrer noopener\" style=\"cursor:inherit;display:inline-block;position:relative;line-height:1;max-width:100%;color:inherit;\"><span style=\"display:inline-block;white-space:nowrap;max-width:100%;max-height:100vh;position:relative;vertical-align:top;line-height:0;overflow:hidden;font-size:1px;width: 350em;\"><span style=\"display:inline-block;width:0;vertical-align:top;font-size:0;padding-top: 100%;\"></span><span style=\"--image:none;position:absolute;left:0;top:0;width:100%;height:100%;-webkit-mask-repeat:no-repeat;-webkit-mask-position:center center;-webkit-mask-mode:alpha;-webkit-mask-size:contain;-webkit-mask-image:var(--image);mask-repeat:no-repeat;mask-position:center center;mask-mode:alpha;mask-size:contain;mask-image:var(--image);background-color:currentColor;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;display:none;\"></span><img alt=\"\" style=\"display:inline-block;vertical-align:top;object-fit:contain;border:none;outline:none;position:absolute;left:0;top:0;width:100%;height:100%;display:none;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;\"><span style=\"position:absolute;left:0;top:0;width:100%;height:100%;display:table;table-layout:fixed;white-space:normal;font-size:initial;line-height:initial;color:initial;\"></span></span><span style=\"display:none;line-height:initial;\">Image</span></a></li></ul><style>.yomitan-glossary ul[data-sc-content='glossary'] {\n color: #ffff00;\n}</style></div>", 1578 - "glossary-first-no-dictionary": "<div style=\"text-align: left;\" class=\"yomitan-glossary\"><i>(n)</i> <ul><li>gazou definition 1</li><li><a target=\"_blank\" rel=\"noreferrer noopener\" style=\"cursor:inherit;display:inline-block;position:relative;line-height:1;max-width:100%;color:inherit;\"><span style=\"display:inline-block;white-space:nowrap;max-width:100%;max-height:100vh;position:relative;vertical-align:top;line-height:0;overflow:hidden;font-size:1px;width: 350em;\"><span style=\"display:inline-block;width:0;vertical-align:top;font-size:0;padding-top: 100%;\"></span><span style=\"--image:none;position:absolute;left:0;top:0;width:100%;height:100%;-webkit-mask-repeat:no-repeat;-webkit-mask-position:center center;-webkit-mask-mode:alpha;-webkit-mask-size:contain;-webkit-mask-image:var(--image);mask-repeat:no-repeat;mask-position:center center;mask-mode:alpha;mask-size:contain;mask-image:var(--image);background-color:currentColor;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;display:none;\"></span><img alt=\"\" style=\"display:inline-block;vertical-align:top;object-fit:contain;border:none;outline:none;position:absolute;left:0;top:0;width:100%;height:100%;display:none;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;\"><span style=\"position:absolute;left:0;top:0;width:100%;height:100%;display:table;table-layout:fixed;white-space:normal;font-size:initial;line-height:initial;color:initial;\"></span></span><span style=\"display:none;line-height:initial;\">Image</span></a></li></ul><style>.yomitan-glossary ul[data-sc-content='glossary'] {\n color: #ffff00;\n}</style></div>", 1573 + "glossary": "<div style=\"text-align: left;\" class=\"yomitan-glossary\"><i>(n, termsDictAlias)</i> <ul><li>gazou definition 1</li><li><a target=\"_blank\" rel=\"noreferrer noopener\" style=\"cursor:inherit;display:inline-block;position:relative;line-height:1;max-width:100%;color:inherit;\"><span style=\"display:inline-block;font-size:1px;\"><img width=\"350\" height=\"350\" style=\"display:inline-block;vertical-align:top;object-fit:contain;border:none;outline:none;width:100%;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;\"></span></a></li></ul><style>.yomitan-glossary ul[data-sc-content='glossary'] {\n color: #ffff00;\n}</style></div>", 1574 + "glossary-brief": "<div style=\"text-align: left;\" class=\"yomitan-glossary\"><ul><li>gazou definition 1</li><li><a target=\"_blank\" rel=\"noreferrer noopener\" style=\"cursor:inherit;display:inline-block;position:relative;line-height:1;max-width:100%;color:inherit;\"><span style=\"display:inline-block;font-size:1px;\"><img width=\"350\" height=\"350\" style=\"display:inline-block;vertical-align:top;object-fit:contain;border:none;outline:none;width:100%;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;\"></span></a></li></ul><style>.yomitan-glossary ul[data-sc-content='glossary'] {\n color: #ffff00;\n}</style></div>", 1575 + "glossary-no-dictionary": "<div style=\"text-align: left;\" class=\"yomitan-glossary\"><i>(n)</i> <ul><li>gazou definition 1</li><li><a target=\"_blank\" rel=\"noreferrer noopener\" style=\"cursor:inherit;display:inline-block;position:relative;line-height:1;max-width:100%;color:inherit;\"><span style=\"display:inline-block;font-size:1px;\"><img width=\"350\" height=\"350\" style=\"display:inline-block;vertical-align:top;object-fit:contain;border:none;outline:none;width:100%;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;\"></span></a></li></ul><style>.yomitan-glossary ul[data-sc-content='glossary'] {\n color: #ffff00;\n}</style></div>", 1576 + "glossary-first": "<div style=\"text-align: left;\" class=\"yomitan-glossary\"><i>(n, termsDictAlias)</i> <ul><li>gazou definition 1</li><li><a target=\"_blank\" rel=\"noreferrer noopener\" style=\"cursor:inherit;display:inline-block;position:relative;line-height:1;max-width:100%;color:inherit;\"><span style=\"display:inline-block;font-size:1px;\"><img width=\"350\" height=\"350\" style=\"display:inline-block;vertical-align:top;object-fit:contain;border:none;outline:none;width:100%;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;\"></span></a></li></ul><style>.yomitan-glossary ul[data-sc-content='glossary'] {\n color: #ffff00;\n}</style></div>", 1577 + "glossary-first-brief": "<div style=\"text-align: left;\" class=\"yomitan-glossary\"><ul><li>gazou definition 1</li><li><a target=\"_blank\" rel=\"noreferrer noopener\" style=\"cursor:inherit;display:inline-block;position:relative;line-height:1;max-width:100%;color:inherit;\"><span style=\"display:inline-block;font-size:1px;\"><img width=\"350\" height=\"350\" style=\"display:inline-block;vertical-align:top;object-fit:contain;border:none;outline:none;width:100%;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;\"></span></a></li></ul><style>.yomitan-glossary ul[data-sc-content='glossary'] {\n color: #ffff00;\n}</style></div>", 1578 + "glossary-first-no-dictionary": "<div style=\"text-align: left;\" class=\"yomitan-glossary\"><i>(n)</i> <ul><li>gazou definition 1</li><li><a target=\"_blank\" rel=\"noreferrer noopener\" style=\"cursor:inherit;display:inline-block;position:relative;line-height:1;max-width:100%;color:inherit;\"><span style=\"display:inline-block;font-size:1px;\"><img width=\"350\" height=\"350\" style=\"display:inline-block;vertical-align:top;object-fit:contain;border:none;outline:none;width:100%;image-rendering:auto;image-rendering:-moz-crisp-edges;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;image-rendering:crisp-edges;\"></span></a></li></ul><style>.yomitan-glossary ul[data-sc-content='glossary'] {\n color: #ffff00;\n}</style></div>", 1579 1579 "part-of-speech": "Noun", 1580 1580 "pitch-accents": "", 1581 1581 "pitch-accent-graphs": "",
+2 -4
test/data/translator-test-results.json
··· 10869 10869 "dictionary": "Test Dictionary 2", 10870 10870 "dictionaryIndex": 0, 10871 10871 "dictionaryAlias": "termsDictAlias", 10872 - 10873 10872 "hasReading": true, 10874 10873 "frequency": 10, 10875 10874 "displayValue": null, ··· 19650 19649 ] 19651 19650 }, 19652 19651 { 19653 - "name": "Find term using primary reading 1", 19652 + "name": "Find terms using primary reading 1", 19654 19653 "originalTextLength": 2, 19655 19654 "dictionaryEntries": [ 19656 19655 { ··· 19814 19813 ] 19815 19814 }, 19816 19815 { 19817 - "name": "Find term using primary reading 2", 19816 + "name": "Find terms using primary reading 2", 19818 19817 "originalTextLength": 2, 19819 19818 "dictionaryEntries": [ 19820 - 19821 19819 { 19822 19820 "type": "term", 19823 19821 "isPrimary": true,
+2
test/database.test.js
··· 26 26 import {DictionaryDatabase} from '../ext/js/dictionary/dictionary-database.js'; 27 27 import {DictionaryImporter} from '../ext/js/dictionary/dictionary-importer.js'; 28 28 import {DictionaryImporterMediaLoader} from './mocks/dictionary-importer-media-loader.js'; 29 + import {setupStubs} from './utilities/database.js'; 29 30 30 31 const dirname = pathDirname(fileURLToPath(import.meta.url)); 31 32 33 + setupStubs(); 32 34 vi.stubGlobal('IDBKeyRange', IDBKeyRange); 33 35 34 36 /**
+4
test/dictionary-data.test.js
··· 22 22 import {parseJson} from '../dev/json.js'; 23 23 import {createTranslatorTest} from './fixtures/translator-test.js'; 24 24 import {createTestAnkiNoteData, getTemplateRenderResults} from './utilities/anki.js'; 25 + import {setupStubs} from './utilities/database.js'; 25 26 import {createFindKanjiOptions, createFindTermsOptions} from './utilities/translator.js'; 27 + 28 + setupStubs(); 26 29 27 30 const dirname = path.dirname(fileURLToPath(import.meta.url)); 28 31 const dictionaryName = 'Test Dictionary 2'; 29 32 const test = await createTranslatorTest(void 0, path.join(dirname, 'data/dictionaries/valid-dictionary1'), dictionaryName); 30 33 31 34 describe('Dictionary data', () => { 35 + console.log('test'); 32 36 const testInputsFilePath = path.join(dirname, 'data/translator-test-inputs.json'); 33 37 /** @type {import('test/translator').TranslatorTestInputs} */ 34 38 const {optionsPresets, tests} = parseJson(readFileSync(testInputsFilePath, {encoding: 'utf8'}));
+3
test/dictionary-data.write.js
··· 21 21 import {parseJson} from '../dev/json.js'; 22 22 import {createTranslatorTest} from './fixtures/translator-test.js'; 23 23 import {createTestAnkiNoteData, getTemplateRenderResults} from './utilities/anki.js'; 24 + import {setupStubs} from './utilities/database.js'; 24 25 import {createFindKanjiOptions, createFindTermsOptions} from './utilities/translator.js'; 26 + 27 + setupStubs(); 25 28 26 29 /** 27 30 * @param {string} fileName
+7
test/jsconfig.json
··· 22 22 }, 23 23 "types": [ 24 24 "chrome", 25 + "dom-webcodecs", 25 26 "firefox-webext-browser", 26 27 "handlebars", 27 28 "jszip", 28 29 "parse5", 29 30 "wanakana" 31 + ], 32 + "lib": [ 33 + "ES2022", 34 + "DOM", 35 + "DOM.Iterable", 36 + "WebWorker" 30 37 ] 31 38 }, 32 39 "include": [
+36
test/utilities/database.js
··· 1 + /* 2 + * Copyright (C) 2023-2024 Yomitan Authors 3 + * 4 + * This program is free software: you can redistribute it and/or modify 5 + * it under the terms of the GNU General Public License as published by 6 + * the Free Software Foundation, either version 3 of the License, or 7 + * (at your option) any later version. 8 + * 9 + * This program is distributed in the hope that it will be useful, 10 + * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 + * GNU General Public License for more details. 13 + * 14 + * You should have received a copy of the GNU General Public License 15 + * along with this program. If not, see <https://www.gnu.org/licenses/>. 16 + */ 17 + import {vi} from 'vitest'; 18 + 19 + /** 20 + * 21 + */ 22 + export function setupStubs() { 23 + vi.stubGlobal('self', { 24 + constructor: { 25 + name: 'Window', 26 + }, 27 + }); 28 + 29 + // eslint-disable-next-line jsdoc/require-jsdoc 30 + function Worker() { 31 + return { 32 + addEventListener: () => {}, 33 + }; 34 + } 35 + vi.stubGlobal('Worker', Worker); 36 + }
+74
types/ext/api.d.ts
··· 418 418 action: TName; 419 419 params: ApiParams<TName>; 420 420 }; 421 + 422 + // postMessage API (i.e., API endpoints called via postMessage, either through ServiceWorker on Chrome or a MessageChannel port on Firefox) 423 + 424 + type PmApiSurface = { 425 + drawMedia: { 426 + params: { 427 + requests: DrawMediaRequest[]; 428 + }; 429 + return: void; 430 + }; 431 + connectToDatabaseWorker: { 432 + params: void; 433 + return: void; 434 + }; 435 + drawBufferToCanvases: { 436 + params: { 437 + buffer: ArrayBuffer; 438 + width: number; 439 + height: number; 440 + canvasIndexes: number[]; 441 + generation: number; 442 + }; 443 + return: void; 444 + }; 445 + drawDecodedImageToCanvases: { 446 + params: { 447 + decodedImage: VideoFrame | ImageBitmap; 448 + canvasIndexes: number[]; 449 + generation: number; 450 + }; 451 + return: void; 452 + }; 453 + registerOffscreenPort: { 454 + params: void; 455 + return: void; 456 + }; 457 + registerDatabasePort: { 458 + params: void; 459 + return: void; 460 + }; 461 + }; 462 + 463 + type DrawMediaRequest = { 464 + path: string; 465 + dictionary: string; 466 + canvas: OffscreenCanvas; 467 + }; 468 + 469 + type PmApiExtraArgs = [ports: readonly MessagePort[] | null]; 470 + 471 + export type PmApiNames = BaseApiNames<PmApiSurface>; 472 + 473 + export type PmApiMap = BaseApiMap<PmApiSurface, PmApiExtraArgs>; 474 + 475 + export type PmApiMapInit = BaseApiMapInit<PmApiSurface, PmApiExtraArgs>; 476 + 477 + export type PmApiHandler<TName extends PmApiNames> = BaseApiHandler<PmApiSurface[TName], PmApiExtraArgs>; 478 + 479 + export type PmApiHandlerNoExtraArgs<TName extends PmApiNames> = BaseApiHandler<PmApiSurface[TName], []>; 480 + 481 + export type PmApiParams<TName extends PmApiNames> = BaseApiParams<PmApiSurface[TName]>; 482 + 483 + export type PmApiParam<TName extends PmApiNames, TParamName extends BaseApiParamNames<PmApiSurface[TName]>> = BaseApiParam<PmApiSurface[TName], TParamName>; 484 + 485 + export type PmApiReturn<TName extends PmApiNames> = BaseApiReturn<PmApiSurface[TName]>; 486 + 487 + export type PmApiParamsAny = BaseApiParamsAny<PmApiSurface>; 488 + 489 + export type PmApiMessageAny = {[name in PmApiNames]: PmApiMessage<name>}[PmApiNames]; 490 + 491 + type PmApiMessage<TName extends PmApiNames> = { 492 + action: TName; 493 + params: PmApiParams<TName>; 494 + };
+25
types/ext/dictionary-database-worker-handler.d.ts
··· 1 + /* 2 + * Copyright (C) 2023-2024 Yomitan Authors 3 + * 4 + * This program is free software: you can redistribute it and/or modify 5 + * it under the terms of the GNU General Public License as published by 6 + * the Free Software Foundation, either version 3 of the License, or 7 + * (at your option) any later version. 8 + * 9 + * This program is distributed in the hope that it will be useful, 10 + * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 + * GNU General Public License for more details. 13 + * 14 + * You should have received a copy of the GNU General Public License 15 + * along with this program. If not, see <https://www.gnu.org/licenses/>. 16 + */ 17 + 18 + export type MessageToWorker = ( 19 + ConnectToDatabaseWorker 20 + ); 21 + 22 + export type ConnectToDatabaseWorker = { 23 + action: 'connectToDatabaseWorker'; 24 + params: null; 25 + };
+75 -1
types/ext/dictionary-database.d.ts
··· 36 36 37 37 export type MediaDataStringContent = MediaDataBase<string>; 38 38 39 - type MediaType = ArrayBuffer | string; 39 + type MediaType = ArrayBuffer | string | null; 40 40 41 41 export type Media<T extends MediaType = ArrayBuffer> = {index: number} & MediaDataBase<T>; 42 + 43 + export type DrawMedia<T extends MediaType = ArrayBuffer> = {index: number} & MediaDataBase<T> & {canvasWidth: number, canvasIndexes: number[], generation: number}; 42 44 43 45 export type DatabaseTermEntry = { 44 46 expression: string; ··· 239 241 dictionary: string; 240 242 }; 241 243 244 + export type DrawMediaRequest = { 245 + path: string; 246 + dictionary: string; 247 + canvasIndex: number; 248 + canvasWidth: number; 249 + canvasHeight: number; 250 + generation: number; 251 + }; 252 + 253 + export type DrawMediaGroupedRequest = { 254 + path: string; 255 + dictionary: string; 256 + canvasIndexes: number[]; 257 + canvasWidth: number; 258 + canvasHeight: number; 259 + generation: number; 260 + }; 261 + 242 262 export type FindMultiBulkData<TItem = unknown> = { 243 263 item: TItem; 244 264 itemIndex: number; ··· 254 274 export type DictionarySet = { 255 275 has(value: string): boolean; 256 276 }; 277 + 278 + /** API for communicating with its own worker */ 279 + 280 + import type { 281 + ApiMap as BaseApiMap, 282 + ApiMapInit as BaseApiMapInit, 283 + ApiHandler as BaseApiHandler, 284 + ApiParams as BaseApiParams, 285 + ApiReturn as BaseApiReturn, 286 + ApiNames as BaseApiNames, 287 + ApiParam as BaseApiParam, 288 + ApiParamNames as BaseApiParamNames, 289 + ApiParamsAny as BaseApiParamsAny, 290 + } from './api-map'; 291 + 292 + type ApiSurface = { 293 + drawMedia: { 294 + params: { 295 + requests: DrawMediaRequest[]; 296 + }; 297 + return: void; 298 + }; 299 + dummy: { 300 + params: void; 301 + return: void; 302 + }; 303 + }; 304 + 305 + type ApiExtraArgs = [port: MessagePort]; 306 + 307 + export type ApiNames = BaseApiNames<ApiSurface>; 308 + 309 + export type ApiMap = BaseApiMap<ApiSurface, ApiExtraArgs>; 310 + 311 + export type ApiMapInit = BaseApiMapInit<ApiSurface, ApiExtraArgs>; 312 + 313 + export type ApiHandler<TName extends ApiNames> = BaseApiHandler<ApiSurface[TName], ApiExtraArgs>; 314 + 315 + export type ApiHandlerNoExtraArgs<TName extends ApiNames> = BaseApiHandler<ApiSurface[TName], []>; 316 + 317 + export type ApiParams<TName extends ApiNames> = BaseApiParams<ApiSurface[TName]>; 318 + 319 + export type ApiParam<TName extends ApiNames, TParamName extends BaseApiParamNames<ApiSurface[TName]>> = BaseApiParam<ApiSurface[TName], TParamName>; 320 + 321 + export type ApiReturn<TName extends ApiNames> = BaseApiReturn<ApiSurface[TName]>; 322 + 323 + export type ApiParamsAny = BaseApiParamsAny<ApiSurface>; 324 + 325 + export type ApiMessageAny = {[name in ApiNames]: ApiMessage<name>}[ApiNames]; 326 + 327 + type ApiMessage<TName extends ApiNames> = { 328 + action: TName; 329 + params: ApiParams<TName>; 330 + };
+11 -9
types/ext/display-content-manager.d.ts
··· 15 15 * along with this program. If not, see <https://www.gnu.org/licenses/>. 16 16 */ 17 17 18 - import type * as DictionaryDatabase from './dictionary-database'; 19 - 20 18 /** A callback used when a media file has been loaded. */ 21 19 export type OnLoadCallback = ( 22 20 /** The URL of the media that was loaded. */ 23 21 url: string, 24 - ) => void; 22 + ) => Promise<void>; 25 23 26 24 /** A callback used when a media file should be unloaded. */ 27 25 export type OnUnloadCallback = ( 28 26 /** Whether or not the media was fully loaded. */ 29 27 fullyLoaded: boolean, 30 - ) => void; 31 - 32 - export type CachedMediaDataLoaded = { 33 - data: DictionaryDatabase.MediaDataStringContent; 34 - url: string; 35 - }; 28 + ) => Promise<void>; 36 29 37 30 export type LoadMediaDataInfo = { 38 31 onUnload: OnUnloadCallback; 39 32 loaded: boolean; 40 33 }; 34 + 35 + export type LoadMediaRequest = { 36 + /** The path to the media file in the dictionary. */ 37 + path: string; 38 + /** The name of the dictionary. */ 39 + dictionary: string; 40 + /** The canvas to draw the image onto. */ 41 + canvas: OffscreenCanvas; 42 + };
+39
types/ext/offscreen.d.ts
··· 95 95 params: void; 96 96 return: string | null; 97 97 }; 98 + createAndRegisterPortOffscreen: { 99 + params: void; 100 + return: void; 101 + }; 98 102 }; 99 103 100 104 export type ApiMessage<TName extends ApiNames> = ( ··· 136 140 export type ApiReturn<TName extends ApiNames> = BaseApiReturn<ApiSurface[TName]>; 137 141 138 142 export type ApiMessageAny = {[name in ApiNames]: ApiMessage<name>}[ApiNames]; 143 + 144 + // MessageChannel API 145 + 146 + type McApiSurface = { 147 + connectToDatabaseWorker: { 148 + params: void; 149 + return: void; 150 + }; 151 + dummy: { 152 + params: void; 153 + return: void; 154 + }; 155 + }; 156 + 157 + type McApiExtraArgs = [ports: readonly MessagePort[]]; 158 + 159 + export type McApiMessage<TName extends McApiNames> = ( 160 + McApiParams<TName> extends void ? 161 + {action: TName, params?: never} : 162 + {action: TName, params: McApiParams<TName>} 163 + ); 164 + 165 + export type McApiNames = BaseApiNames<McApiSurface>; 166 + 167 + export type McApiMap = BaseApiMap<McApiSurface, McApiExtraArgs>; 168 + 169 + export type McApiMapInit = BaseApiMapInit<McApiSurface, McApiExtraArgs>; 170 + 171 + export type McApiHandler<TName extends McApiNames> = BaseApiHandler<McApiSurface[TName], McApiExtraArgs>; 172 + 173 + export type McApiParams<TName extends McApiNames> = BaseApiParams<McApiSurface[TName]>; 174 + 175 + export type McApiReturn<TName extends McApiNames> = BaseApiReturn<McApiSurface[TName]>; 176 + 177 + export type McApiMessageAny = {[name in McApiNames]: McApiMessage<name>}[McApiNames];
+66
types/ext/shared-worker.d.ts
··· 1 + /* 2 + * Copyright (C) 2023-2024 Yomitan Authors 3 + * 4 + * This program is free software: you can redistribute it and/or modify 5 + * it under the terms of the GNU General Public License as published by 6 + * the Free Software Foundation, either version 3 of the License, or 7 + * (at your option) any later version. 8 + * 9 + * This program is distributed in the hope that it will be useful, 10 + * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 + * GNU General Public License for more details. 13 + * 14 + * You should have received a copy of the GNU General Public License 15 + * along with this program. If not, see <https://www.gnu.org/licenses/>. 16 + */ 17 + 18 + import type { 19 + ApiMap as BaseApiMap, 20 + ApiMapInit as BaseApiMapInit, 21 + ApiHandler as BaseApiHandler, 22 + ApiParams as BaseApiParams, 23 + ApiReturn as BaseApiReturn, 24 + ApiNames as BaseApiNames, 25 + ApiParam as BaseApiParam, 26 + ApiParamNames as BaseApiParamNames, 27 + ApiParamsAny as BaseApiParamsAny, 28 + } from './api-map'; 29 + 30 + type ApiSurface = { 31 + registerBackendPort: { 32 + params: void; 33 + return: void; 34 + }; 35 + connectToBackend1: { 36 + params: void; 37 + return: void; 38 + }; 39 + }; 40 + 41 + type ApiExtraArgs = [interlocutorPort: MessagePort, ports: readonly MessagePort[]]; 42 + 43 + export type ApiNames = BaseApiNames<ApiSurface>; 44 + 45 + export type ApiMap = BaseApiMap<ApiSurface, ApiExtraArgs>; 46 + 47 + export type ApiMapInit = BaseApiMapInit<ApiSurface, ApiExtraArgs>; 48 + 49 + export type ApiHandler<TName extends ApiNames> = BaseApiHandler<ApiSurface[TName], ApiExtraArgs>; 50 + 51 + export type ApiHandlerNoExtraArgs<TName extends ApiNames> = BaseApiHandler<ApiSurface[TName], []>; 52 + 53 + export type ApiParams<TName extends ApiNames> = BaseApiParams<ApiSurface[TName]>; 54 + 55 + export type ApiParam<TName extends ApiNames, TParamName extends BaseApiParamNames<ApiSurface[TName]>> = BaseApiParam<ApiSurface[TName], TParamName>; 56 + 57 + export type ApiReturn<TName extends ApiNames> = BaseApiReturn<ApiSurface[TName]>; 58 + 59 + export type ApiParamsAny = BaseApiParamsAny<ApiSurface>; 60 + 61 + export type ApiMessageAny = {[name in ApiNames]: ApiMessage<name>}[ApiNames]; 62 + 63 + type ApiMessage<TName extends ApiNames> = { 64 + action: TName; 65 + params: ApiParams<TName>; 66 + };