diff options
Diffstat (limited to 'ext/mixed')
35 files changed, 1320 insertions, 392 deletions
diff --git a/ext/mixed/css/display.css b/ext/mixed/css/display.css index 8b567173..703cef1c 100644 --- a/ext/mixed/css/display.css +++ b/ext/mixed/css/display.css @@ -27,13 +27,12 @@ --default-text-color: #333333; --light-text-color: #777777; --very-light-text-color: #999999; + --popuplar-kanji-text-color: #0275d8; --light-border-color: #eeeeee; --medium-border-color: #dddddd; --dark-border-color: #777777; - --popuplar-kanji-text-color: #0275d8; - --pitch-accent-annotation-color: #000000; --tag-text-color: #ffffff; @@ -58,13 +57,12 @@ --default-text-color: #d4d4d4; --light-text-color: #888888; --very-light-text-color: #666666; + --popuplar-kanji-text-color: #0275d8; --light-border-color: #2f2f2f; --medium-border-color: #3f3f3f; --dark-border-color: #888888; - --popuplar-kanji-text-color: #0275d8; - --pitch-accent-annotation-color: #ffffff; --tag-text-color: #e1e1e1; @@ -173,14 +171,14 @@ h2 { display: flex; } -.navigation-header:not([data-has-previous=true]) .navigation-header-actions .action-previous>img, -.navigation-header:not([data-has-next=true]) .navigation-header-actions .action-next>img { +.navigation-header:not([data-has-previous=true]) .navigation-header-actions .action-button.action-previous:before, +.navigation-header:not([data-has-next=true]) .navigation-header-actions .action-button.action-next:before { opacity: 0.25; -webkit-filter: grayscale(100%); filter: grayscale(100%); } -.action-next>img { +.action-button.action-next:before { transform: scaleX(-1); } @@ -234,18 +232,18 @@ h2 { padding-right: 0.72em; } -.actions .disabled { +.action-button.disabled { pointer-events: none; cursor: default; } -.actions .disabled img { +.action-button.disabled:before { -webkit-filter: grayscale(100%); filter: grayscale(100%); opacity: 0.25; } -.actions .pending { +.action-button.pending { visibility: hidden; } @@ -274,10 +272,33 @@ button.action-button { cursor: pointer; } -.icon-image { +.action-button[data-icon]:before { + content: ""; width: 1.14285714em; /* 14px => 16px */ height: 1.14285714em; /* 14px => 16px */ display: block; + background-color: transparent; + background-repeat: no-repeat; + background-size: contain; +} + +.action-button[data-icon=entry-current]:before { + background-image: url("/mixed/img/entry-current.svg"); +} +.action-button[data-icon=view-note]:before { + background-image: url("/mixed/img/view-note.svg"); +} +.action-button[data-icon=add-term-kanji]:before { + background-image: url("/mixed/img/add-term-kanji.svg"); +} +.action-button[data-icon=add-term-kana]:before { + background-image: url("/mixed/img/add-term-kana.svg"); +} +.action-button[data-icon=play-audio]:before { + background-image: url("/mixed/img/play-audio.svg"); +} +.action-button[data-icon=source-term]:before { + background-image: url("/mixed/img/source-term.svg"); } .term-expression .kanji-link { @@ -296,7 +317,7 @@ button.action-button { color: var(--very-light-text-color); } -.entry:not(.entry-current) .current { +.entry:not(.entry-current) .action-current-indicator { display: none; } @@ -358,7 +379,7 @@ button.action-button { display: block; } -.tag-list>.tag:not(:last-child) { +.tag-list>.tag { margin-right: 0.375em; } @@ -505,7 +526,7 @@ button.action-button { } .term-definition-disambiguation-list:after { - content: " only)"; + content: " only) "; } .term-definition-disambiguation+.term-definition-disambiguation:before { diff --git a/ext/mixed/display-templates.html b/ext/mixed/display-templates.html index fc0558a9..fed252a1 100644 --- a/ext/mixed/display-templates.html +++ b/ext/mixed/display-templates.html @@ -5,11 +5,11 @@ <div class="entry-header2"> <div class="entry-header3"> <div class="actions"> - <button class="action-button action-view-note pending disabled"><img src="/mixed/img/view-note.svg" class="icon-image" title="View added note (Alt + V)" alt></button> - <button class="action-button action-add-note pending disabled" data-mode="term-kanji"><img src="/mixed/img/add-term-kanji.svg" class="icon-image" title="Add expression (Alt + E)" alt></button> - <button class="action-button action-add-note pending disabled" data-mode="term-kana"><img src="/mixed/img/add-term-kana.svg" class="icon-image" title="Add reading (Alt + R)" alt></button> - <button class="action-button action-play-audio"><img src="/mixed/img/play-audio.svg" class="icon-image" title="Play audio (Alt + P)" alt></button> - <span class="action-button action-current-indicator"><img src="/mixed/img/entry-current.svg" class="icon-image current" title="Current entry (Alt + Up/Down/Home/End/PgUp/PgDn)" alt></span> + <button class="action-button action-view-note pending disabled" data-icon="view-note" title="View added note (Alt + V)"></button> + <button class="action-button action-add-note pending disabled" data-icon="add-term-kanji" data-mode="term-kanji" title="Add expression (Alt + E)"></button> + <button class="action-button action-add-note pending disabled" data-icon="add-term-kana" data-mode="term-kana" title="Add reading (Alt + R)"></button> + <button class="action-button action-play-audio" data-icon="play-audio" title="Play audio (Alt + P)"></button> + <span class="action-button action-current-indicator" data-icon="entry-current" title="Current entry (Alt + Up/Down/Home/End/PgUp/PgDn)"></span> </div> <div class="term-expression-list"></div> </div> @@ -24,15 +24,11 @@ <pre class="debug-info"></pre> </div></template> <template id="term-expression-template"><div class="term-expression"><span class="term-expression-text source-text"></span><div class="term-expression-details"> - <button class="action-button action-play-audio"><img src="/mixed/img/play-audio.svg" class="icon-image" title="Play audio" alt></button> + <button class="action-button action-play-audio" data-icon="play-audio" title="Play audio"></button> <div class="tags tag-list"></div> <div class="frequencies tag-list"></div> </div></div></template> -<template id="term-definition-item-template"><li class="term-definition-item"> - <div class="term-definition-tag-list tag-list"></div> - <div class="term-definition-disambiguation-list"></div> - <ul class="term-glossary-list"></ul> -</li></template> +<template id="term-definition-item-template"><li class="term-definition-item"><div class="term-definition-tag-list tag-list"></div><div class="term-definition-disambiguation-list"></div><ul class="term-glossary-list"></ul></li></template> <template id="term-definition-disambiguation-template"><span class="term-definition-disambiguation"></span></template> <template id="term-glossary-item-template"><li class="term-glossary-item"><span class="term-glossary-separator"> </span><span class="term-glossary"></span></li></template> <template id="term-glossary-item-image-template"><li class="term-glossary-item" data-has-image="true"><span class="term-glossary-separator"> </span><span class="term-glossary"><a class="term-glossary-image-link" target="_blank" rel="noreferrer noopener"><span class="term-glossary-image-container"><span class="term-glossary-image-aspect-ratio-sizer"></span><img class="term-glossary-image" alt="" /><span class="term-glossary-image-container-overlay"></span></span><span class="term-glossary-image-link-text">Image</span></a> <span class="term-glossary-image-description"></span></span></li></template> @@ -55,9 +51,9 @@ <div class="entry-header2"> <div class="entry-header3"> <div class="actions"> - <button class="action-button action-view-note pending disabled"><img src="/mixed/img/view-note.svg" class="icon-image" title="View added note (Alt + V)" alt></button> - <button class="action-button action-add-note pending disabled" data-mode="kanji"><img src="/mixed/img/add-term-kanji.svg" class="icon-image" title="Add Kanji (Alt + K)" alt></button> - <span class="action-button action-current-indicator"><img src="/mixed/img/entry-current.svg" class="icon-image current" title="Current entry (Alt + Up/Down/Home/End/PgUp/PgDn)" alt></span> + <button class="action-button action-view-note pending disabled" data-icon="view-note" title="View added note (Alt + V)"></button> + <button class="action-button action-add-note pending disabled" data-icon="add-term-kanji" data-mode="kanji" title="Add Kanji (Alt + K)"></button> + <span class="action-button action-current-indicator" data-icon="entry-current" title="Current entry (Alt + Up/Down/Home/End/PgUp/PgDn)"></span> </div> <div class="kanji-glyph source-text"></div> </div> @@ -92,7 +88,7 @@ <template id="kanji-reading-template"><dd class="kanji-reading"></dd></template> <template id="tag-template"><span class="tag"><span class="tag-inner"></span></span></template> -<template id="tag-frequency-template"><span class="tag" data-category="frequency"><span class="tag-inner"><span class="term-frequency-dictionary-name"></span><span class="term-frequency-separator"></span><span class="term-frequency-value"></span></span></template> +<template id="tag-frequency-template"><span class="tag" data-category="frequency"><span class="tag-inner"><span class="term-frequency-dictionary-name"></span><span class="term-frequency-separator"></span><span class="term-frequency-value"></span></span></span></template> <template id="tag-search-template"><span class="tag" data-category="search"></span></template> </body></html> diff --git a/ext/mixed/img/backup.svg b/ext/mixed/img/backup.svg new file mode 100644 index 00000000..081560c2 --- /dev/null +++ b/ext/mixed/img/backup.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M6.5 1.25c-.13807.00001-.24999.11193-.25.25v6H4c-.22428-.00167-.33715.26997-.17773.42773l4 4c.09785.0989.25761.0989.35546 0l4-4c.15942-.15776.04655-.4294-.17773-.42773H9.75v-6c-.00001-.13807-.11193-.24999-.25-.25zM2 11v3.5h12V11h-1v1.5H3V11z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/book.svg b/ext/mixed/img/book.svg new file mode 100644 index 00000000..1b785296 --- /dev/null +++ b/ext/mixed/img/book.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M2 2.25L3.75.5H14v13l-1 1v-13H4.75l-1.04592 1H12v13H2z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/cog.svg b/ext/mixed/img/cog.svg new file mode 100644 index 00000000..7232d25d --- /dev/null +++ b/ext/mixed/img/cog.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M7 .5c-.13807 0-.26165.11066-.2754.24805l-.15624 1.55468c-.02198.21982-.2122.44631-.41992.52149a5.5035 5.5035 0 00-.4961.20703c-.19937.0951-.49501.0715-.66601-.0684l-1.2129-.99219c-.10685-.0874-.2715-.0781-.36913.0195L1.99023 3.40423c-.09763.0976-.10696.26228-.01953.36914l.99024 1.21094c.13984.17101.16424.46606.07031.66601a5.4994 5.4994 0 00-.20313.49805c-.07396.20816-.2997.398-.51953.41992l-1.56054.15625C.61066 6.73835.5 6.86193.5 7v2c0 .13807.11066.26165.24805.27539l1.55468.15625c.21982.022.4463.21219.52149.41992a5.49997 5.49997 0 00.20703.4961c.09514.19937.0715.49501-.06836.66601l-.99219 1.21289c-.08743.10686-.0781.27151.01953.36914l1.41407 1.41407c.09763.0976.26228.10696.36914.0195l1.21093-.99024c.17102-.13984.46607-.16425.66602-.0703.1626.0758.32882.14358.49805.20313.20816.074.398.2997.41992.51953l.15625 1.56055c.01374.13737.13732.24804.27539.24804h2c.13807 0 .26165-.11066.2754-.24805l.15624-1.55468c.02198-.21982.2122-.44631.41992-.52149a5.50632 5.50632 0 00.4961-.20703c.19937-.0952.49502-.0715.66601.0684l1.2129.99219c.10685.0874.2715.0781.36913-.0195l1.41407-1.41406c.09763-.0976.10696-.26229.01953-.36915l-.99024-1.21093c-.13984-.17102-.16424-.46607-.0703-.66602a5.49962 5.49962 0 00.20312-.49805c.07396-.20816.2997-.398.51953-.41992l1.56054-.15625C15.38935 9.26165 15.5 9.13807 15.5 9V7c0-.13807-.11066-.26165-.24805-.27539l-1.55468-.15625c-.21982-.022-.44631-.21219-.52149-.41992a5.50036 5.50036 0 00-.20703-.4961c-.09515-.19937-.07151-.49501.06836-.66601l.99219-1.21289c.08743-.10686.0781-.27151-.01953-.36914L12.5957 1.99023c-.09763-.0976-.26227-.10696-.36914-.0195l-1.21093.99024c-.17102.13984-.46607.16424-.66602.0703a5.4957 5.4957 0 00-.49805-.20312c-.20816-.074-.398-.29971-.41992-.51954L9.2754.74807C9.26165.61066 9.13807.5 9 .5zm1 4.75A2.75 2.75 0 0110.75 8 2.75 2.75 0 018 10.75 2.75 2.75 0 015.25 8 2.75 2.75 0 018 5.25z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/hiragana-a.svg b/ext/mixed/img/hiragana-a.svg new file mode 100644 index 00000000..1a7d6a7f --- /dev/null +++ b/ext/mixed/img/hiragana-a.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M2.42064 2.88601h9.52827M8.92775 14.04106c4.64794-.2324 5.11273-2.78876 5.12992-4.21415.01933-1.60201-1.62623-3.97684-5.22508-4.05209-4.78517-.10004-8.34375 4.35907-6.4074 6.96794 1.72067 2.31827 6.03777-.32848 7.43215-6.3708M5.67419 1.02685c-.2324 6.7395.06942 8.31184.4648 11.61984" fill="none" stroke="#333" stroke-width="1.85917" stroke-linejoin="round"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/keyboard.svg b/ext/mixed/img/keyboard.svg new file mode 100644 index 00000000..b94afde5 --- /dev/null +++ b/ext/mixed/img/keyboard.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M7.99268 1.43018a.4688.4688 0 00-.46143.47607v3.75H1.4375c-.51938 0-.9375.41812-.9375.9375v6.09375c0 .51938.41813.9375.9375.9375h13.125c.51938 0 .9375-.41813.9375-.9375V6.59375c0-.51938-.41813-.9375-.9375-.9375H8.46875v-3.75a.4688.4688 0 00-.47607-.47607zM1.78906 7.0625h1.17188a.11693.11693 0 01.11719.11719v1.17187a.11693.11693 0 01-.1172.11719H1.78907a.11693.11693 0 01-.11719-.11719V7.1797a.11693.11693 0 01.1172-.11719zm1.875 0h1.17188a.11693.11693 0 01.11719.11719v1.17187a.11693.11693 0 01-.1172.11719H3.66407a.11693.11693 0 01-.11719-.11719V7.1797a.11693.11693 0 01.1172-.11719zm1.875 0h1.17188a.11693.11693 0 01.11719.11719v1.17187a.11693.11693 0 01-.1172.11719H5.53907a.11693.11693 0 01-.11719-.11719V7.1797a.11693.11693 0 01.1172-.11719zm1.875 0h1.17188a.11693.11693 0 01.11718.11719v1.17187a.11693.11693 0 01-.11718.11719H7.41406a.11693.11693 0 01-.11719-.11719V7.1797a.11693.11693 0 01.1172-.11719zm1.875 0h1.17188a.11693.11693 0 01.11718.11719v1.17187a.11693.11693 0 01-.11718.11719H9.28906a.11693.11693 0 01-.11718-.11719V7.1797a.11693.11693 0 01.11718-.11719zm1.875 0h1.17188a.11693.11693 0 01.11718.11719v1.17187a.11693.11693 0 01-.11718.11719h-1.17188a.11693.11693 0 01-.11718-.11719V7.1797a.11693.11693 0 01.11718-.11719zm1.875 0h1.17188a.11693.11693 0 01.11718.11719v1.17187a.11693.11693 0 01-.11718.11719h-1.17188a.11693.11693 0 01-.11718-.11719V7.1797a.11693.11693 0 01.11718-.11719zm-11.25 1.875h2.10938a.11693.11693 0 01.11719.11719v1.17187a.11693.11693 0 01-.1172.11719H1.78907a.11693.11693 0 01-.11719-.11719V9.0547a.11693.11693 0 01.1172-.11719zm2.8125 0h1.17188a.11693.11693 0 01.11719.11719v1.17187a.11693.11693 0 01-.1172.11719H4.60157a.11693.11693 0 01-.11719-.11719V9.0547a.11693.11693 0 01.1172-.11719zm1.875 0h1.17188a.11693.11693 0 01.11719.11719v1.17187a.11693.11693 0 01-.1172.11719H6.47657a.11693.11693 0 01-.11719-.11719V9.0547a.11693.11693 0 01.1172-.11719zm1.875 0h1.17188a.11693.11693 0 01.11718.11719v1.17187a.11693.11693 0 01-.11718.11719H8.35156a.11693.11693 0 01-.11718-.11719V9.0547a.11693.11693 0 01.11718-.11719zm1.875 0h1.17188a.11693.11693 0 01.11718.11719v1.17187a.11694.11694 0 01-.11718.11719h-1.17188a.11694.11694 0 01-.11718-.11719V9.0547a.11693.11693 0 01.11718-.11719zm1.875 0h2.10938a.11693.11693 0 01.11718.11719v1.17187a.11694.11694 0 01-.11718.11719h-2.10938a.11694.11694 0 01-.11718-.11719V9.0547a.11693.11693 0 01.11718-.11719zm-10.3125 1.875h1.17188a.11693.11693 0 01.11719.11719v1.17187a.11693.11693 0 01-.1172.11719H1.78907a.11693.11693 0 01-.11719-.11719V10.9297a.11693.11693 0 01.1172-.11719zm1.875 0h1.17188a.11693.11693 0 01.11719.11719v1.17187a.11693.11693 0 01-.1172.11719H3.66407a.11693.11693 0 01-.11719-.11719V10.9297a.11693.11693 0 01.1172-.11719zm1.875 0h4.92188a.11693.11693 0 01.11718.11719v1.17187a.11694.11694 0 01-.11718.11719H5.53906a.11693.11693 0 01-.11719-.11719V10.9297a.11693.11693 0 01.1172-.11719zm5.625 0h1.17188a.11693.11693 0 01.11718.11719v1.17187a.11694.11694 0 01-.11718.11719h-1.17188a.11694.11694 0 01-.11719-.11719V10.9297a.11693.11693 0 01.1172-.11719zm1.875 0h1.17188a.11693.11693 0 01.11718.11719v1.17187a.11694.11694 0 01-.11718.11719h-1.17188a.11694.11694 0 01-.11719-.11719V10.9297a.11693.11693 0 01.1172-.11719z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/left-chevron.svg b/ext/mixed/img/left-chevron.svg new file mode 100644 index 00000000..9dd012dc --- /dev/null +++ b/ext/mixed/img/left-chevron.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M9.93945 15.06054l2.1211-2.12109L7.12109 8l4.93946-4.93946L9.93945.93945 2.87891 8z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/lock.svg b/ext/mixed/img/lock.svg new file mode 100644 index 00000000..7707ba98 --- /dev/null +++ b/ext/mixed/img/lock.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M8 1.5c-1.786 0-3.25 1.464-3.25 3.25V7.5H4c-.55226.00006-.99994.44774-1 1v5c.00006.55226.44774.99995 1 1h8c.55226-.00005.99994-.44774 1-1v-5c-.00006-.55226-.44774-.99994-1-1h-.75V4.75C11.25 2.964 9.786 1.5 8 1.5zM8 3c.97538 0 1.75.77462 1.75 1.75V7.5h-3.5V4.75C6.25 3.77462 7.02462 3 8 3z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/magnifying-glass.svg b/ext/mixed/img/magnifying-glass.svg new file mode 100644 index 00000000..a8367d8d --- /dev/null +++ b/ext/mixed/img/magnifying-glass.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M10.781 9.7188l-1.0312.03125-.03125 1.0312 4.5 4.5c.70833.7085 1.7708-.354 1.0625-1.0625zM5.7498.5C2.8592.5.4998 2.8594.4998 5.75S2.8592 11 5.7498 11s5.25-2.3594 5.25-5.25S8.6404.5 5.7498.5zm0 1.5c2.08 0 3.75 1.67 3.75 3.75s-1.67 3.75-3.75 3.75-3.75-1.67-3.75-3.75S3.6698 2 5.7498 2z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/note-card.svg b/ext/mixed/img/note-card.svg new file mode 100644 index 00000000..fb00b074 --- /dev/null +++ b/ext/mixed/img/note-card.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M4 .5c-.82235 0-1.5.67765-1.5 1.5v12c0 .82235.67765 1.5 1.5 1.5h8c.82235 0 1.5-.67765 1.5-1.5V2c0-.82235-.67765-1.5-1.5-1.5zm6.75977 1.875c.19735.005.35705.16213.36523.35937l.04883 1.125.81055.78125c.19826.1921.12805.52533-.13086.6211l-1.0547.39258-.49218 1.01367c-.12112.24791-.45945.28457-.63086.0684l-.69922-.88086-1.11523-.15625c-.27337-.0377-.41375-.34782-.26172-.57813l.62305-.93945-.19727-1.10742c-.04931-.27269.20396-.50285.4707-.42774l1.08399.30274.99414-.5293a.37513.37513 0 01.18555-.0449zM4 9h8v1H4zm0 2h8v1H4zm0 2h6v1H4z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/palette.svg b/ext/mixed/img/palette.svg new file mode 100644 index 00000000..4a615ef2 --- /dev/null +++ b/ext/mixed/img/palette.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M8 .5C3.85786.5.5 3.85786.5 8c0 4.14214 3.35786 7.5 7.5 7.5 2.5209 0 1.25017-2.49957.47852-3.63867-.80904-1.19429.27195-1.86152 1.3828-1.36524C13.04713 11.91938 15.5 10.75 15.5 8 15.5 3.85786 12.14214.5 8 .5zm-2 2c.69036 0 1.25.55964 1.25 1.25S6.69036 5 6 5s-1.25-.55964-1.25-1.25S5.30964 2.5 6 2.5zm4 0c.69036 0 1.25.55964 1.25 1.25S10.69036 5 10 5s-1.25-.55964-1.25-1.25S9.30964 2.5 10 2.5zm-6.75 3c.69036 0 1.25.55964 1.25 1.25S3.94036 8 3.25 8 2 7.44036 2 6.75 2.55964 5.5 3.25 5.5zm9.5 0c.69036 0 1.25.55964 1.25 1.25S13.44036 8 12.75 8s-1.25-.55964-1.25-1.25.55964-1.25 1.25-1.25z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/popup.svg b/ext/mixed/img/popup.svg new file mode 100644 index 00000000..ef528cfb --- /dev/null +++ b/ext/mixed/img/popup.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M1.25 2c-.4155 0-.75.3345-.75.75v8.5c0 .4155.3345.75.75.75H2.5v2.75L4.75 12h9.75c.4155 0 .75-.3345.75-.75v-8.5c0-.4155-.3345-.75-.75-.75zM2 3.5h3.5v1.75H2zM2 7h12v1H2zm0 2h10v1H2z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/profile.svg b/ext/mixed/img/profile.svg new file mode 100644 index 00000000..52a1363d --- /dev/null +++ b/ext/mixed/img/profile.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M5.5039 9.3262c-.85148.2284-1.632.5678-2.3008 1.0137C1.922 11.19398 1 12.4902 1 14.0001c.00006.55226.44774.99994 1 1h12c.55226-.00006.99994-.44774 1-1 0-1.5099-.922-2.8061-2.2031-3.6602-.66975-.4465-1.4518-.78528-2.3047-1.0137A5 5 0 018 10.00003a5 5 0 01-2.4961-.67383zM12 5a4 4 0 01-4 4 4 4 0 01-4-4 4 4 0 014-4 4 4 0 014 4z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/question-mark-circle.svg b/ext/mixed/img/question-mark-circle.svg new file mode 100644 index 00000000..0076f7cd --- /dev/null +++ b/ext/mixed/img/question-mark-circle.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M8 1a7 7 0 00-7 7 7 7 0 007 7 7 7 0 007-7 7 7 0 00-7-7zm0 1.54687c2.26411 0 4.28516 1.56084 4.28516 3.70118 0 1.49831-1.02674 2.73602-2.375 3.31445-.2834.12158-.5242.29226-.68946.4707-.16525.17845-.2461.35932-.2461.49805 0 .10756-.08775.1953-.1953.19531H7.2207c-.10755-.00001-.1953-.0878-.1953-.19531 0-.70384.2822-1.29397.68554-1.75195.40334-.45799.9275-.79069 1.4336-1.00782.77637-.33308 1.19335-.91838 1.19335-1.52343 0-.85756-.93956-1.75391-2.33789-1.75391-1.39833 0-2.3379.89635-2.3379 1.75391 0 .10755-.0858.19335-.19335.19336h-1.5586c-.10755-.00001-.1953-.0858-.1953-.19336 0-2.14034 2.02104-3.70118 4.28515-3.70118zm-.7793 8.95899h1.5586c.10755.00001.1953.0878.1953.19531v1.55664c0 .10756-.08775.1953-.1953.19531H7.2207c-.10755-.00001-.1953-.0878-.1953-.19531v-1.55664c0-.10756.08775-.1953.1953-.19531z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/question-mark-thick.svg b/ext/mixed/img/question-mark-thick.svg new file mode 100644 index 00000000..7f2214a6 --- /dev/null +++ b/ext/mixed/img/question-mark-thick.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M8 1C5.09367 1 2.5 3.00255 2.5 5.75c.00001.13807.11193.24999.25.25h2c.13807-.00001.24999-.11193.25-.25C5 4.6492 6.20504 3.5 8 3.5s3 1.1492 3 2.25c0 .77668-.53465 1.52947-1.53125 1.95703C8.8191 7.98574 8.14666 8.41211 7.62891 9c-.51776.58789-.87891 1.34652-.87891 2.25.00001.13806.11193.24999.25.25h2c.13807-.00001.24999-.11194.25-.25 0-.17808.10428-.41156.3164-.64063.21213-.22906.52294-.44744.88672-.60351C12.18383 9.26336 13.5 7.67331 13.5 5.75 13.5 3.00255 10.90633 1 8 1zM7 12.5c-.13807.00001-.24999.11194-.25.25v2c.00001.13806.11193.24999.25.25h2c.13807-.00001.24999-.11194.25-.25v-2c-.00001-.13806-.11193-.24999-.25-.25z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/question-mark.svg b/ext/mixed/img/question-mark.svg new file mode 100644 index 00000000..bc3b9a1c --- /dev/null +++ b/ext/mixed/img/question-mark.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M8 1.5c-2.28226 0-4.25 1.62278-4.25 3.75.00001.13807.11193.24999.25.25h1c.13807-.00001.24999-.11193.25-.25C5.25 4.06973 6.4121 3 8 3s2.75 1.06973 2.75 2.25c0 .7462-.43551 1.42101-1.1836 1.84766-.52876.30156-1.0814.69072-1.52538 1.20117C7.59525 8.81133 7.25 9.47585 7.25 10.25v1c.00001.13806.11193.24999.25.25h1c.13807-.00001.24999-.11194.25-.25v-1c0-.33249.14065-.63898.42578-.9668.2828-.32513.70214-.6383 1.13086-.8828C11.44681 7.75013 12.25 6.59134 12.25 5.25c0-2.12722-1.96775-3.75-4.25-3.75zM7.5 13c-.13807.00001-.24999.11194-.25.25v1c.00001.13806.11193.24999.25.25h1c.13807-.00001.24999-.11194.25-.25v-1c-.00001-.13806-.11193-.24999-.25-.25z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/right-chevron.svg b/ext/mixed/img/right-chevron.svg new file mode 100644 index 00000000..e210057b --- /dev/null +++ b/ext/mixed/img/right-chevron.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M6.06055.93945l-2.1211 2.12109L8.87891 8l-4.93946 4.93945 2.1211 2.12109L13.12109 8z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/scanning.svg b/ext/mixed/img/scanning.svg new file mode 100644 index 00000000..9ac16c83 --- /dev/null +++ b/ext/mixed/img/scanning.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M.5 1.5V3h.75c.42847 0 .75.32153.75.75v8.5c0 .42847-.32153.75-.75.75H.5v1.5h.75c.57623 0 1.1-.2277 1.5-.58984.4.36214.92377.58984 1.5.58984H5V13h-.75c-.42847 0-.75-.32153-.75-.75v-8.5c0-.42847.32153-.75.75-.75H5V1.5h-.75c-.57623 0-1.1.2277-1.5.58984C2.35 1.7277 1.82623 1.5 1.25 1.5zm11.2461 2.75c-.13654.00215-.2461.11345-.2461.25v2H8v3h3.5v2c-.0017.22428.26997.33715.42773.17773l3.5-3.5c.0989-.09785.0989-.25761 0-.35546l-3.5-3.5A.25002.25002 0 0011.7461 4.25zM5 6.5h1.5v3H5z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/speaker.svg b/ext/mixed/img/speaker.svg new file mode 100644 index 00000000..4c9b8eba --- /dev/null +++ b/ext/mixed/img/speaker.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M12.25 2.75c1.5 3 1.5 7.5 0 10.5 4.25-1.75 4.25-8.75 0-10.5m-2.5 2c.75 1.5.75 5 0 6.5 3-1.25 3-5.25 0-6.5m-1.75781-2.5a.25001.25001 0 00-.1543.06055L4.4082 5.25H.75c-.13807.00001-.24999.11193-.25.25V8a.25002.25002 0 000 .0254V10.5c.00001.13806.11193.24999.25.25h3.6582l3.4297 2.93945c.16201.13795.41132.02334.4121-.18945v-11c.00005-.14117-.1167-.2544-.25781-.25z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/text-parsing.svg b/ext/mixed/img/text-parsing.svg new file mode 100644 index 00000000..dfa88af8 --- /dev/null +++ b/ext/mixed/img/text-parsing.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M.5 2v2.5h.25c0-.277.223-.5.5-.5H3.5c.277 0 .5.223.5.5v8.75c0 .277-.223.5-.5.5V14h3v-.25c-.277 0-.5-.223-.5-.5V4.5c0-.277.223-.5.5-.5h2.25c.277 0 .5.223.5.5h.25V2zm7 3.5v1h8v-1zm0 2v1h5v-1zm6 0v1h2v-1zm-6 2v1h2v-1zm3 0v1h3v-1zm4 0v1h1v-1zm-7 2v1h1v-1zm2 0v1H12v-1z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/img/translation.svg b/ext/mixed/img/translation.svg new file mode 100644 index 00000000..fdb98b1d --- /dev/null +++ b/ext/mixed/img/translation.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M1.35873 1.65912H6.75M5.04057 7.97085c2.6299-.13149 2.89288-1.57793 2.9026-2.38444.01094-.90645-.92014-2.25017-2.95644-2.29275C2.2792 3.23706.2657 5.7601 1.36131 7.23625c.97358 1.31172 3.41628-.18586 4.20524-3.60471M3.19965.60717c-.13149 3.81333.03928 4.70299.263 6.57471" fill="none" stroke="#333" stroke-width="1.25" stroke-linejoin="round"/><path d="M9.5 5.5l-4 10h2l1.0996-2.75h3.8008L13.5 15.5h2l-4-10zm1 2.5l1.0996 2.75H9.4004z" fill="#333"/></svg>
\ No newline at end of file diff --git a/ext/mixed/js/api.js b/ext/mixed/js/api.js index 0bc91759..5c17d50e 100644 --- a/ext/mixed/js/api.js +++ b/ext/mixed/js/api.js @@ -15,307 +15,341 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/* global + * CrossFrameAPI + */ + +const api = (() => { + class API { + constructor() { + this._forwardLogsToBackendEnabled = false; + this._crossFrame = new CrossFrameAPI(); + } + + get crossFrame() { + return this._crossFrame; + } + + prepare() { + this._crossFrame.prepare(); + } + + forwardLogsToBackend() { + if (this._forwardLogsToBackendEnabled) { return; } + this._forwardLogsToBackendEnabled = true; + + yomichan.on('log', async ({error, level, context}) => { + try { + await this.log(errorToJson(error), level, context); + } catch (e) { + // NOP + } + }); + } + + // Invoke functions + + optionsSchemaGet() { + return this._invoke('optionsSchemaGet'); + } + + optionsGet(optionsContext) { + return this._invoke('optionsGet', {optionsContext}); + } + + optionsGetFull() { + return this._invoke('optionsGetFull'); + } + + optionsSave(source) { + return this._invoke('optionsSave', {source}); + } + + termsFind(text, details, optionsContext) { + return this._invoke('termsFind', {text, details, optionsContext}); + } + + textParse(text, optionsContext) { + return this._invoke('textParse', {text, optionsContext}); + } + + kanjiFind(text, optionsContext) { + return this._invoke('kanjiFind', {text, optionsContext}); + } + + definitionAdd(definition, mode, context, details, optionsContext) { + return this._invoke('definitionAdd', {definition, mode, context, details, optionsContext}); + } + + definitionsAddable(definitions, modes, context, optionsContext) { + return this._invoke('definitionsAddable', {definitions, modes, context, optionsContext}); + } + + noteView(noteId) { + return this._invoke('noteView', {noteId}); + } + + templateRender(template, data) { + return this._invoke('templateRender', {data, template}); + } + + audioGetUri(definition, source, details) { + return this._invoke('audioGetUri', {definition, source, details}); + } + + commandExec(command, params) { + return this._invoke('commandExec', {command, params}); + } + + screenshotGet(options) { + return this._invoke('screenshotGet', {options}); + } + + sendMessageToFrame(frameId, action, params) { + return this._invoke('sendMessageToFrame', {frameId, action, params}); + } + + broadcastTab(action, params) { + return this._invoke('broadcastTab', {action, params}); + } + + frameInformationGet() { + return this._invoke('frameInformationGet'); + } + + injectStylesheet(type, value) { + return this._invoke('injectStylesheet', {type, value}); + } + + getStylesheetContent(url) { + return this._invoke('getStylesheetContent', {url}); + } + + getEnvironmentInfo() { + return this._invoke('getEnvironmentInfo'); + } + + clipboardGet() { + return this._invoke('clipboardGet'); + } -function apiOptionsSchemaGet() { - return _apiInvoke('optionsSchemaGet'); -} + getDisplayTemplatesHtml() { + return this._invoke('getDisplayTemplatesHtml'); + } + + getQueryParserTemplatesHtml() { + return this._invoke('getQueryParserTemplatesHtml'); + } + + getZoom() { + return this._invoke('getZoom'); + } + + getDefaultAnkiFieldTemplates() { + return this._invoke('getDefaultAnkiFieldTemplates'); + } + + getAnkiDeckNames() { + return this._invoke('getAnkiDeckNames'); + } + + getAnkiModelNames() { + return this._invoke('getAnkiModelNames'); + } + + getAnkiModelFieldNames(modelName) { + return this._invoke('getAnkiModelFieldNames', {modelName}); + } + + getDictionaryInfo() { + return this._invoke('getDictionaryInfo'); + } + + getDictionaryCounts(dictionaryNames, getTotal) { + return this._invoke('getDictionaryCounts', {dictionaryNames, getTotal}); + } -function apiOptionsGet(optionsContext) { - return _apiInvoke('optionsGet', {optionsContext}); -} + purgeDatabase() { + return this._invoke('purgeDatabase'); + } -function apiOptionsGetFull() { - return _apiInvoke('optionsGetFull'); -} + getMedia(targets) { + return this._invoke('getMedia', {targets}); + } + + log(error, level, context) { + return this._invoke('log', {error, level, context}); + } -function apiOptionsSave(source) { - return _apiInvoke('optionsSave', {source}); -} + logIndicatorClear() { + return this._invoke('logIndicatorClear'); + } -function apiTermsFind(text, details, optionsContext) { - return _apiInvoke('termsFind', {text, details, optionsContext}); -} + modifySettings(targets, source) { + return this._invoke('modifySettings', {targets, source}); + } -function apiTextParse(text, optionsContext) { - return _apiInvoke('textParse', {text, optionsContext}); -} + getSettings(targets) { + return this._invoke('getSettings', {targets}); + } -function apiKanjiFind(text, optionsContext) { - return _apiInvoke('kanjiFind', {text, optionsContext}); -} + setAllSettings(value, source) { + return this._invoke('setAllSettings', {value, source}); + } -function apiDefinitionAdd(definition, mode, context, details, optionsContext) { - return _apiInvoke('definitionAdd', {definition, mode, context, details, optionsContext}); -} + // Invoke functions with progress -function apiDefinitionsAddable(definitions, modes, context, optionsContext) { - return _apiInvoke('definitionsAddable', {definitions, modes, context, optionsContext}); -} + importDictionaryArchive(archiveContent, details, onProgress) { + return this._invokeWithProgress('importDictionaryArchive', {archiveContent, details}, onProgress); + } -function apiNoteView(noteId) { - return _apiInvoke('noteView', {noteId}); -} + deleteDictionary(dictionaryName, onProgress) { + return this._invokeWithProgress('deleteDictionary', {dictionaryName}, onProgress); + } -function apiTemplateRender(template, data) { - return _apiInvoke('templateRender', {data, template}); -} + // Utilities -function apiAudioGetUri(definition, source, details) { - return _apiInvoke('audioGetUri', {definition, source, details}); -} + _createActionPort(timeout=5000) { + return new Promise((resolve, reject) => { + let timer = null; + let portNameResolve; + let portNameReject; + const portNamePromise = new Promise((resolve2, reject2) => { + portNameResolve = resolve2; + portNameReject = reject2; + }); -function apiCommandExec(command, params) { - return _apiInvoke('commandExec', {command, params}); -} - -function apiScreenshotGet(options) { - return _apiInvoke('screenshotGet', {options}); -} - -function apiSendMessageToFrame(frameId, action, params) { - return _apiInvoke('sendMessageToFrame', {frameId, action, params}); -} - -function apiBroadcastTab(action, params) { - return _apiInvoke('broadcastTab', {action, params}); -} - -function apiFrameInformationGet() { - return _apiInvoke('frameInformationGet'); -} - -function apiInjectStylesheet(type, value) { - return _apiInvoke('injectStylesheet', {type, value}); -} - -function apiGetEnvironmentInfo() { - return _apiInvoke('getEnvironmentInfo'); -} - -function apiClipboardGet() { - return _apiInvoke('clipboardGet'); -} - -function apiGetDisplayTemplatesHtml() { - return _apiInvoke('getDisplayTemplatesHtml'); -} - -function apiGetQueryParserTemplatesHtml() { - return _apiInvoke('getQueryParserTemplatesHtml'); -} - -function apiGetZoom() { - return _apiInvoke('getZoom'); -} - -function apiGetDefaultAnkiFieldTemplates() { - return _apiInvoke('getDefaultAnkiFieldTemplates'); -} - -function apiGetAnkiDeckNames() { - return _apiInvoke('getAnkiDeckNames'); -} - -function apiGetAnkiModelNames() { - return _apiInvoke('getAnkiModelNames'); -} - -function apiGetAnkiModelFieldNames(modelName) { - return _apiInvoke('getAnkiModelFieldNames', {modelName}); -} - -function apiGetDictionaryInfo() { - return _apiInvoke('getDictionaryInfo'); -} - -function apiGetDictionaryCounts(dictionaryNames, getTotal) { - return _apiInvoke('getDictionaryCounts', {dictionaryNames, getTotal}); -} - -function apiPurgeDatabase() { - return _apiInvoke('purgeDatabase'); -} - -function apiGetMedia(targets) { - return _apiInvoke('getMedia', {targets}); -} - -function apiLog(error, level, context) { - return _apiInvoke('log', {error, level, context}); -} - -function apiLogIndicatorClear() { - return _apiInvoke('logIndicatorClear'); -} - -function apiImportDictionaryArchive(archiveContent, details, onProgress) { - return _apiInvokeWithProgress('importDictionaryArchive', {archiveContent, details}, onProgress); -} - -function apiDeleteDictionary(dictionaryName, onProgress) { - return _apiInvokeWithProgress('deleteDictionary', {dictionaryName}, onProgress); -} - -function apiModifySettings(targets, source) { - return _apiInvoke('modifySettings', {targets, source}); -} - -function _apiCreateActionPort(timeout=5000) { - return new Promise((resolve, reject) => { - let timer = null; - let portNameResolve; - let portNameReject; - const portNamePromise = new Promise((resolve2, reject2) => { - portNameResolve = resolve2; - portNameReject = reject2; - }); - - const onConnect = async (port) => { - try { - const portName = await portNamePromise; - if (port.name !== portName || timer === null) { return; } - } catch (e) { - return; - } - - clearTimeout(timer); - timer = null; - - chrome.runtime.onConnect.removeListener(onConnect); - resolve(port); - }; - - const onError = (e) => { - if (timer !== null) { - clearTimeout(timer); - timer = null; - } - chrome.runtime.onConnect.removeListener(onConnect); - portNameReject(e); - reject(e); - }; - - timer = setTimeout(() => onError(new Error('Timeout')), timeout); - - chrome.runtime.onConnect.addListener(onConnect); - _apiInvoke('createActionPort').then(portNameResolve, onError); - }); -} - -function _apiInvokeWithProgress(action, params, onProgress, timeout=5000) { - return new Promise((resolve, reject) => { - let timer = null; - let port = null; - - if (typeof onProgress !== 'function') { - onProgress = () => {}; - } - - const onMessage = (message) => { - switch (message.type) { - case 'ack': + const onConnect = async (port) => { + try { + const portName = await portNamePromise; + if (port.name !== portName || timer === null) { return; } + } catch (e) { + return; + } + + clearTimeout(timer); + timer = null; + + chrome.runtime.onConnect.removeListener(onConnect); + resolve(port); + }; + + const onError = (e) => { if (timer !== null) { clearTimeout(timer); timer = null; } - break; - case 'progress': - try { - onProgress(...message.data); - } catch (e) { - // NOP + chrome.runtime.onConnect.removeListener(onConnect); + portNameReject(e); + reject(e); + }; + + timer = setTimeout(() => onError(new Error('Timeout')), timeout); + + chrome.runtime.onConnect.addListener(onConnect); + this._invoke('createActionPort').then(portNameResolve, onError); + }); + } + + _invokeWithProgress(action, params, onProgress, timeout=5000) { + return new Promise((resolve, reject) => { + let port = null; + + if (typeof onProgress !== 'function') { + onProgress = () => {}; + } + + const onMessage = (message) => { + switch (message.type) { + case 'progress': + try { + onProgress(...message.data); + } catch (e) { + // NOP + } + break; + case 'complete': + cleanup(); + resolve(message.data); + break; + case 'error': + cleanup(); + reject(jsonToError(message.data)); + break; } - break; - case 'complete': - cleanup(); - resolve(message.data); - break; - case 'error': + }; + + const onDisconnect = () => { cleanup(); - reject(jsonToError(message.data)); - break; - } - }; - - const onDisconnect = () => { - cleanup(); - reject(new Error('Disconnected')); - }; - - const cleanup = () => { - if (timer !== null) { - clearTimeout(timer); - timer = null; - } - if (port !== null) { - port.onMessage.removeListener(onMessage); - port.onDisconnect.removeListener(onDisconnect); - port.disconnect(); - port = null; - } - onProgress = null; - }; - - timer = setTimeout(() => { - cleanup(); - reject(new Error('Timeout')); - }, timeout); - - (async () => { - try { - port = await _apiCreateActionPort(timeout); - port.onMessage.addListener(onMessage); - port.onDisconnect.addListener(onDisconnect); - port.postMessage({action, params}); - } catch (e) { - cleanup(); - reject(e); - } finally { - action = null; - params = null; - } - })(); - }); -} - -function _apiInvoke(action, params={}) { - const data = {action, params}; - return new Promise((resolve, reject) => { - try { - chrome.runtime.sendMessage(data, (response) => { - _apiCheckLastError(chrome.runtime.lastError); - if (response !== null && typeof response === 'object') { - if (typeof response.error !== 'undefined') { - reject(jsonToError(response.error)); - } else { - resolve(response.result); + reject(new Error('Disconnected')); + }; + + const cleanup = () => { + if (port !== null) { + port.onMessage.removeListener(onMessage); + port.onDisconnect.removeListener(onDisconnect); + port.disconnect(); + port = null; + } + onProgress = null; + }; + + (async () => { + try { + port = await this._createActionPort(timeout); + port.onMessage.addListener(onMessage); + port.onDisconnect.addListener(onDisconnect); + + // Chrome has a maximum message size that can be sent, so longer messages must be fragmented. + const messageString = JSON.stringify({action, params}); + const fragmentSize = 1e7; // 10 MB + for (let i = 0, ii = messageString.length; i < ii; i += fragmentSize) { + const data = messageString.substring(i, i + fragmentSize); + port.postMessage({action: 'fragment', data}); + } + port.postMessage({action: 'invoke'}); + } catch (e) { + cleanup(); + reject(e); + } finally { + action = null; + params = null; } - } else { - const message = response === null ? 'Unexpected null response' : `Unexpected response of type ${typeof response}`; - reject(new Error(`${message} (${JSON.stringify(data)})`)); + })(); + }); + } + + _invoke(action, params={}) { + const data = {action, params}; + return new Promise((resolve, reject) => { + try { + chrome.runtime.sendMessage(data, (response) => { + this._checkLastError(chrome.runtime.lastError); + if (response !== null && typeof response === 'object') { + if (typeof response.error !== 'undefined') { + reject(jsonToError(response.error)); + } else { + resolve(response.result); + } + } else { + const message = response === null ? 'Unexpected null response' : `Unexpected response of type ${typeof response}`; + reject(new Error(`${message} (${JSON.stringify(data)})`)); + } + }); + } catch (e) { + reject(e); + yomichan.triggerOrphaned(e); } }); - } catch (e) { - reject(e); - yomichan.triggerOrphaned(e); - } - }); -} - -function _apiCheckLastError() { - // NOP -} - -let _apiForwardLogsToBackendEnabled = false; -function apiForwardLogsToBackend() { - if (_apiForwardLogsToBackendEnabled) { return; } - _apiForwardLogsToBackendEnabled = true; - - yomichan.on('log', async ({error, level, context}) => { - try { - await apiLog(errorToJson(error), level, context); - } catch (e) { + } + + _checkLastError() { // NOP } - }); -} + } + + // eslint-disable-next-line no-shadow + const api = new API(); + api.prepare(); + return api; +})(); diff --git a/ext/mixed/js/audio-system.js b/ext/mixed/js/audio-system.js index fdfb0b10..c590b909 100644 --- a/ext/mixed/js/audio-system.js +++ b/ext/mixed/js/audio-system.js @@ -169,22 +169,22 @@ class AudioSystem { }); } - _createAudioBinaryFromUrl(url) { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.responseType = 'arraybuffer'; - xhr.addEventListener('load', async () => { - const arrayBuffer = xhr.response; - if (!await this._isAudioBinaryValid(arrayBuffer)) { - reject(new Error('Could not retrieve audio')); - } else { - resolve(arrayBuffer); - } - }); - xhr.addEventListener('error', () => reject(new Error('Failed to connect'))); - xhr.open('GET', url); - xhr.send(); + async _createAudioBinaryFromUrl(url) { + const response = await fetch(url, { + method: 'GET', + mode: 'no-cors', + cache: 'default', + credentials: 'omit', + redirect: 'follow', + referrerPolicy: 'no-referrer' }); + const arrayBuffer = await response.arrayBuffer(); + + if (!await this._isAudioBinaryValid(arrayBuffer)) { + throw new Error('Could not retrieve audio'); + } + + return arrayBuffer; } _isAudioValid(audio) { diff --git a/ext/mixed/js/comm.js b/ext/mixed/js/comm.js new file mode 100644 index 00000000..7787616e --- /dev/null +++ b/ext/mixed/js/comm.js @@ -0,0 +1,282 @@ +/* + * Copyright (C) 2020 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +class CrossFrameAPIPort extends EventDispatcher { + constructor(otherFrameId, port, messageHandlers) { + super(); + this._otherFrameId = otherFrameId; + this._port = port; + this._messageHandlers = messageHandlers; + this._activeInvocations = new Map(); + this._invocationId = 0; + this._eventListeners = new EventListenerCollection(); + } + + get otherFrameId() { + return this._otherFrameId; + } + + prepare() { + this._eventListeners.addListener(this._port.onDisconnect, this._onDisconnect.bind(this)); + this._eventListeners.addListener(this._port.onMessage, this._onMessage.bind(this)); + } + + invoke(action, params, ackTimeout, responseTimeout) { + return new Promise((resolve, reject) => { + if (this._port === null) { + reject(new Error('Port is disconnected')); + return; + } + + const id = this._invocationId++; + const invocation = {id, resolve, reject, responseTimeout, ack: false, timer: null}; + this._activeInvocations.set(id, invocation); + + if (ackTimeout !== null) { + try { + invocation.timer = setTimeout(() => this._onError(id, new Error('Timeout (ack)')), ackTimeout); + } catch (e) { + this._onError(id, new Error('Failed to set timeout')); + return; + } + } + + try { + this._port.postMessage({type: 'invoke', id, data: {action, params}}); + } catch (e) { + this._onError(id, e); + } + }); + } + + disconnect() { + this._onDisconnect(); + } + + // Private + + _onDisconnect() { + if (this._port === null) { return; } + this._eventListeners.removeAllEventListeners(); + this._port = null; + for (const id of this._activeInvocations.keys()) { + this._onError(id, new Error('Disconnected')); + } + this.trigger('disconnect', this); + } + + _onMessage({type, id, data}) { + switch (type) { + case 'invoke': + this._onInvoke(id, data); + break; + case 'ack': + this._onAck(id); + break; + case 'result': + this._onResult(id, data); + break; + } + } + + // Response handlers + + _onAck(id) { + const invocation = this._activeInvocations.get(id); + if (typeof invocation === 'undefined') { + yomichan.logWarning(new Error(`Request ${id} not found for ack`)); + return; + } + + if (invocation.ack) { + this._onError(id, new Error(`Request ${id} already ack'd`)); + return; + } + + invocation.ack = true; + + if (invocation.timer !== null) { + clearTimeout(invocation.timer); + invocation.timer = null; + } + + const responseTimeout = invocation.responseTimeout; + if (responseTimeout !== null) { + try { + invocation.timer = setTimeout(() => this._onError(id, new Error('Timeout (response)')), responseTimeout); + } catch (e) { + this._onError(id, new Error('Failed to set timeout')); + } + } + } + + _onResult(id, data) { + const invocation = this._activeInvocations.get(id); + if (typeof invocation === 'undefined') { + yomichan.logWarning(new Error(`Request ${id} not found`)); + return; + } + + if (!invocation.ack) { + this._onError(id, new Error(`Request ${id} not ack'd`)); + return; + } + + this._activeInvocations.delete(id); + + if (invocation.timer !== null) { + clearTimeout(invocation.timer); + invocation.timer = null; + } + + const error = data.error; + if (typeof error !== 'undefined') { + invocation.reject(jsonToError(error)); + } else { + invocation.resolve(data.result); + } + } + + _onError(id, error) { + const invocation = this._activeInvocations.get(id); + if (typeof invocation === 'undefined') { return; } + + this._activeInvocations.delete(id); + if (invocation.timer !== null) { + clearTimeout(invocation.timer); + invocation.timer = null; + } + invocation.reject(error); + } + + // Invocation + + _onInvoke(id, {action, params}) { + const messageHandler = this._messageHandlers.get(action); + if (typeof messageHandler === 'undefined') { + this._sendError(id, new Error(`Unknown action: ${action}`)); + return; + } + + const {handler, async} = messageHandler; + + this._sendAck(id); + if (async) { + this._invokeHandlerAsync(id, handler, params); + } else { + this._invokeHandler(id, handler, params); + } + } + + _invokeHandler(id, handler, params) { + try { + const result = handler(params); + this._sendResult(id, result); + } catch (error) { + this._sendError(id, error); + } + } + + async _invokeHandlerAsync(id, handler, params) { + try { + const result = await handler(params); + this._sendResult(id, result); + } catch (error) { + this._sendError(id, error); + } + } + + _sendResponse(data) { + if (this._port === null) { return; } + try { + this._port.postMessage(data); + } catch (e) { + // NOP + } + } + + _sendAck(id) { + this._sendResponse({type: 'ack', id}); + } + + _sendResult(id, result) { + this._sendResponse({type: 'result', id, data: {result}}); + } + + _sendError(id, error) { + this._sendResponse({type: 'result', id, data: {error: errorToJson(error)}}); + } +} + +class CrossFrameAPI { + constructor() { + this._ackTimeout = 3000; // 3 seconds + this._responseTimeout = 10000; // 10 seconds + this._commPorts = new Map(); + this._messageHandlers = new Map(); + this._onDisconnectBind = this._onDisconnect.bind(this); + } + + prepare() { + chrome.runtime.onConnect.addListener(this._onConnect.bind(this)); + } + + async invoke(targetFrameId, action, params={}) { + const commPort = this._getOrCreateCommPort(targetFrameId); + return await commPort.invoke(action, params, this._ackTimeout, this._responseTimeout); + } + + registerHandlers(messageHandlers) { + for (const [key, value] of messageHandlers) { + if (this._messageHandlers.has(key)) { + throw new Error(`Handler ${key} is already registered`); + } + this._messageHandlers.set(key, value); + } + } + + _onConnect(port) { + const match = /^cross-frame-communication-port-(\d+)$/.exec(`${port.name}`); + if (match === null) { return; } + + const otherFrameId = parseInt(match[1], 10); + this._setupCommPort(otherFrameId, port); + } + + _onDisconnect(commPort) { + commPort.off('disconnect', this._onDisconnectBind); + this._commPorts.delete(commPort.otherFrameId); + } + + _getOrCreateCommPort(otherFrameId) { + const commPort = this._commPorts.get(otherFrameId); + return (typeof commPort !== 'undefined' ? commPort : this._createCommPort(otherFrameId)); + } + + _createCommPort(otherFrameId) { + const port = chrome.runtime.connect(null, {name: `background-cross-frame-communication-port-${otherFrameId}`}); + return this._setupCommPort(otherFrameId, port); + } + + _setupCommPort(otherFrameId, port) { + const commPort = new CrossFrameAPIPort(otherFrameId, port, this._messageHandlers); + this._commPorts.set(otherFrameId, commPort); + commPort.prepare(); + commPort.on('disconnect', this._onDisconnectBind); + return commPort; + } +} diff --git a/ext/mixed/js/core.js b/ext/mixed/js/core.js index 589425f2..bf877e72 100644 --- a/ext/mixed/js/core.js +++ b/ext/mixed/js/core.js @@ -164,7 +164,10 @@ function getSetDifference(set1, set2) { function promiseTimeout(delay, resolveValue) { if (delay <= 0) { - return Promise.resolve(resolveValue); + const promise = Promise.resolve(resolveValue); + promise.resolve = () => {}; // NOP + promise.reject = () => {}; // NOP + return promise; } let timer = null; @@ -174,7 +177,7 @@ function promiseTimeout(delay, resolveValue) { const complete = (callback, value) => { if (callback === null) { return; } if (timer !== null) { - window.clearTimeout(timer); + clearTimeout(timer); timer = null; } promiseResolve = null; @@ -189,7 +192,7 @@ function promiseTimeout(delay, resolveValue) { promiseResolve = resolve2; promiseReject = reject2; }); - timer = window.setTimeout(() => { + timer = setTimeout(() => { timer = null; resolve(resolveValue); }, delay); @@ -255,15 +258,37 @@ class EventListenerCollection { return this._eventListeners.length; } - addEventListener(node, type, listener, options) { - node.addEventListener(type, listener, options); - this._eventListeners.push([node, type, listener, options]); + addEventListener(object, ...args) { + object.addEventListener(...args); + this._eventListeners.push(['removeEventListener', object, ...args]); + } + + addListener(object, ...args) { + object.addListener(...args); + this._eventListeners.push(['removeListener', object, ...args]); + } + + on(object, ...args) { + object.on(...args); + this._eventListeners.push(['off', object, ...args]); } removeAllEventListeners() { if (this._eventListeners.length === 0) { return; } - for (const [node, type, listener, options] of this._eventListeners) { - node.removeEventListener(type, listener, options); + for (const [removeFunctionName, object, ...args] of this._eventListeners) { + switch (removeFunctionName) { + case 'removeEventListener': + object.removeEventListener(...args); + break; + case 'removeListener': + object.removeListener(...args); + break; + case 'off': + object.off(...args); + break; + default: + throw new Error(`Unknown remove function: ${removeFunctionName}`); + } } this._eventListeners = []; } @@ -306,7 +331,7 @@ const yomichan = (() => { generateId(length) { const array = new Uint8Array(length); - window.crypto.getRandomValues(array); + crypto.getRandomValues(array); let id = ''; for (const value of array) { id += value.toString(16).padStart(2, '0'); @@ -339,7 +364,7 @@ const yomichan = (() => { const runtimeMessageCallback = ({action, params}, sender, sendResponse) => { let timeoutId = null; if (timeout !== null) { - timeoutId = window.setTimeout(() => { + timeoutId = setTimeout(() => { timeoutId = null; eventHandler.removeListener(runtimeMessageCallback); reject(new Error(`Listener timed out in ${timeout} ms`)); @@ -348,7 +373,7 @@ const yomichan = (() => { const cleanupResolve = (value) => { if (timeoutId !== null) { - window.clearTimeout(timeoutId); + clearTimeout(timeoutId); timeoutId = null; } eventHandler.removeListener(runtimeMessageCallback); @@ -428,10 +453,12 @@ const yomichan = (() => { // Private + _getUrl() { + return (typeof window === 'object' && window !== null ? window.location.href : ''); + } + _getLogContext() { - return { - url: window.location.href - }; + return {url: this._getUrl()}; } _onMessage({action, params}, sender, callback) { @@ -444,7 +471,7 @@ const yomichan = (() => { } _onMessageGetUrl() { - return {url: window.location.href}; + return {url: this._getUrl()}; } _onMessageOptionsUpdated({source}) { diff --git a/ext/mixed/js/display-generator.js b/ext/mixed/js/display-generator.js index a2b2b139..3f3a155e 100644 --- a/ext/mixed/js/display-generator.js +++ b/ext/mixed/js/display-generator.js @@ -17,7 +17,7 @@ /* global * TemplateHandler - * apiGetDisplayTemplatesHtml + * api * jp */ @@ -29,7 +29,7 @@ class DisplayGenerator { } async prepare() { - const html = await apiGetDisplayTemplatesHtml(); + const html = await api.getDisplayTemplatesHtml(); this._templateHandler = new TemplateHandler(html); } diff --git a/ext/mixed/js/display.js b/ext/mixed/js/display.js index 2e59b4ff..1d699706 100644 --- a/ext/mixed/js/display.js +++ b/ext/mixed/js/display.js @@ -22,15 +22,7 @@ * DisplayGenerator * MediaLoader * WindowScroll - * apiAudioGetUri - * apiBroadcastTab - * apiDefinitionAdd - * apiDefinitionsAddable - * apiKanjiFind - * apiNoteView - * apiOptionsGet - * apiScreenshotGet - * apiTermsFind + * api * docRangeFromPoint * docSentenceExtract */ @@ -49,7 +41,7 @@ class Display { this.audioSystem = new AudioSystem({ audioUriBuilder: { getUri: async (definition, source, details) => { - return await apiAudioGetUri(definition, source, details); + return await api.audioGetUri(definition, source, details); } }, useCache: true @@ -212,7 +204,7 @@ class Display { url: this.context.get('url') }; - const definitions = await apiKanjiFind(link.textContent, this.getOptionsContext()); + const definitions = await api.kanjiFind(link.textContent, this.getOptionsContext()); this.setContent('kanji', {definitions, context}); } catch (error) { this.onError(error); @@ -244,7 +236,9 @@ class Display { const {textSource, definitions} = termLookupResults; const scannedElement = e.target; - const sentence = docSentenceExtract(textSource, this.options.anki.sentenceExt); + const sentenceExtent = this.options.anki.sentenceExt; + const layoutAwareScan = this.options.scanning.layoutAwareScan; + const sentence = docSentenceExtract(textSource, sentenceExtent, layoutAwareScan); this.context.update({ index: this.entryIndexFind(scannedElement), @@ -281,21 +275,22 @@ class Display { try { e.preventDefault(); - const textSource = docRangeFromPoint(e.clientX, e.clientY, this.options.scanning.deepDomScan); + const {length: scanLength, deepDomScan: deepScan, layoutAwareScan} = this.options.scanning; + const textSource = docRangeFromPoint(e.clientX, e.clientY, deepScan); if (textSource === null) { return false; } let definitions, length; try { - textSource.setEndOffset(this.options.scanning.length); + textSource.setEndOffset(scanLength, layoutAwareScan); - ({definitions, length} = await apiTermsFind(textSource.text(), {}, this.getOptionsContext())); + ({definitions, length} = await api.termsFind(textSource.text(), {}, this.getOptionsContext())); if (definitions.length === 0) { return false; } - textSource.setEndOffset(length); + textSource.setEndOffset(length, layoutAwareScan); } finally { textSource.cleanup(); } @@ -334,7 +329,7 @@ class Display { onNoteView(e) { e.preventDefault(); const link = e.currentTarget; - apiNoteView(link.dataset.noteId); + api.noteView(link.dataset.noteId); } onKeyDown(e) { @@ -379,7 +374,7 @@ class Display { } async updateOptions() { - this.options = await apiOptionsGet(this.getOptionsContext()); + this.options = await api.optionsGet(this.getOptionsContext()); this.updateDocumentOptions(this.options); this.updateTheme(this.options.general.popupTheme); this.setCustomCss(this.options.general.customPopupCss); @@ -746,7 +741,7 @@ class Display { noteTryView() { const button = this.viewerButtonFind(this.index); if (button !== null && !button.classList.contains('disabled')) { - apiNoteView(button.dataset.noteId); + api.noteView(button.dataset.noteId); } } @@ -763,7 +758,7 @@ class Display { } const context = await this._getNoteContext(); - const noteId = await apiDefinitionAdd(definition, mode, context, details, this.getOptionsContext()); + const noteId = await api.definitionAdd(definition, mode, context, details, this.getOptionsContext()); if (noteId) { const index = this.definitions.indexOf(definition); const adderButton = this.adderButtonFind(index, mode); @@ -815,9 +810,10 @@ class Display { this._stopPlayingAudio(); + const volume = Math.max(0.0, Math.min(1.0, this.options.audio.volume / 100.0)); this.audioPlaying = audio; audio.currentTime = 0; - audio.volume = this.options.audio.volume / 100.0; + audio.volume = Number.isFinite(volume) ? volume : 1.0; const playPromise = audio.play(); if (typeof playPromise !== 'undefined') { try { @@ -857,7 +853,7 @@ class Display { await promiseTimeout(1); // Wait for popup to be hidden. const {format, quality} = this.options.anki.screenshot; - const dataUrl = await apiScreenshotGet({format, quality}); + const dataUrl = await api.screenshotGet({format, quality}); if (!dataUrl || dataUrl.error) { return; } return {dataUrl, format}; @@ -871,7 +867,7 @@ class Display { } setPopupVisibleOverride(visible) { - return apiBroadcastTab('popupSetVisibleOverride', {visible}); + return api.broadcastTab('popupSetVisibleOverride', {visible}); } setSpinnerVisible(visible) { @@ -933,7 +929,7 @@ class Display { async getDefinitionsAddable(definitions, modes) { try { const context = await this._getNoteContext(); - return await apiDefinitionsAddable(definitions, modes, context, this.getOptionsContext()); + return await api.definitionsAddable(definitions, modes, context, this.getOptionsContext()); } catch (e) { return []; } diff --git a/ext/mixed/js/dom-data-binder.js b/ext/mixed/js/dom-data-binder.js new file mode 100644 index 00000000..d46e8087 --- /dev/null +++ b/ext/mixed/js/dom-data-binder.js @@ -0,0 +1,349 @@ +/* + * Copyright (C) 2020 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * TaskAccumulator + */ + +class DOMDataBinder { + constructor({selector, ignoreSelectors=[], createElementMetadata, compareElementMetadata, getValues, setValues, onError=null}) { + this._selector = selector; + this._ignoreSelectors = ignoreSelectors; + this._createElementMetadata = createElementMetadata; + this._compareElementMetadata = compareElementMetadata; + this._getValues = getValues; + this._setValues = setValues; + this._onError = onError; + this._updateTasks = new TaskAccumulator(this._onBulkUpdate.bind(this)); + this._assignTasks = new TaskAccumulator(this._onBulkAssign.bind(this)); + this._mutationObserver = new MutationObserver(this._onMutation.bind(this)); + this._observingElement = null; + this._elementMap = new Map(); // Map([element => observer]...) + this._elementAncestorMap = new Map(); // Map([element => Set([observer]...)) + } + + observe(element) { + if (this._isObserving) { return; } + + this._observingElement = element; + this._mutationObserver.observe(element, { + attributes: true, + attributeOldValue: true, + childList: true, + subtree: true + }); + this._onMutation([{ + type: 'childList', + target: element.parentNode, + addedNodes: [element], + removedNodes: [] + }]); + } + + disconnect() { + if (!this._isObserving) { return; } + + this._mutationObserver.disconnect(); + this._observingElement = null; + + for (const observer of this._elementMap.values()) { + this._removeObserver(observer); + } + } + + async refresh() { + await this._updateTasks.enqueue(null, {all: true}); + } + + // Private + + _onMutation(mutationList) { + for (const mutation of mutationList) { + switch (mutation.type) { + case 'childList': + this._onChildListMutation(mutation); + break; + case 'attributes': + this._onAttributeMutation(mutation); + break; + } + } + } + + _onChildListMutation({addedNodes, removedNodes, target}) { + const selector = this._selector; + const ELEMENT_NODE = Node.ELEMENT_NODE; + + for (const node of removedNodes) { + const observers = this._elementAncestorMap.get(node); + if (typeof observers === 'undefined') { continue; } + for (const observer of observers) { + this._removeObserver(observer); + } + } + + for (const node of addedNodes) { + if (node.nodeType !== ELEMENT_NODE) { continue; } + if (node.matches(selector)) { + this._createObserver(node); + } + for (const childNode of node.querySelectorAll(selector)) { + this._createObserver(childNode); + } + } + + if (addedNodes.length !== 0 || addedNodes.length !== 0) { + const observer = this._elementMap.get(target); + if (typeof observer !== 'undefined' && observer.hasValue) { + this._setElementValue(observer.element, observer.value); + } + } + } + + _onAttributeMutation({target}) { + const selector = this._selector; + const observers = this._elementAncestorMap.get(target); + if (typeof observers !== 'undefined') { + for (const observer of observers) { + const element = observer.element; + if ( + !element.matches(selector) || + this._shouldIgnoreElement(element) || + this._isObserverStale(observer) + ) { + this._removeObserver(observer); + } + } + } + + if (target.matches(selector)) { + this._createObserver(target); + } + } + + async _onBulkUpdate(tasks) { + let all = false; + const targets = []; + for (const [observer, task] of tasks) { + if (observer === null) { + if (task.data.all) { + all = true; + break; + } + } else { + targets.push([observer, task]); + } + } + if (all) { + targets.length = 0; + for (const observer of this._elementMap.values()) { + targets.push([observer, null]); + } + } + + const args = targets.map(([observer]) => ({ + element: observer.element, + metadata: observer.metadata + })); + const responses = await this._getValues(args); + this._applyValues(targets, responses, true); + } + + async _onBulkAssign(tasks) { + const targets = tasks; + const args = targets.map(([observer, task]) => ({ + element: observer.element, + metadata: observer.metadata, + value: task.data.value + })); + const responses = await this._setValues(args); + this._applyValues(targets, responses, false); + } + + _onElementChange(observer) { + const value = this._getElementValue(observer.element); + observer.value = value; + observer.hasValue = true; + this._assignTasks.enqueue(observer, {value}); + } + + _applyValues(targets, response, ignoreStale) { + if (!Array.isArray(response)) { return; } + + for (let i = 0, ii = targets.length; i < ii; ++i) { + const [observer, task] = targets[i]; + const {error, result} = response[i]; + const stale = (task !== null && task.stale); + + if (error) { + if (typeof this._onError === 'function') { + this._onError(error, stale, observer.element, observer.metadata); + } + continue; + } + + if (stale && !ignoreStale) { continue; } + + observer.value = result; + observer.hasValue = true; + this._setElementValue(observer.element, result); + } + } + + _createObserver(element) { + if (this._elementMap.has(element) || this._shouldIgnoreElement(element)) { return; } + + const metadata = this._createElementMetadata(element); + const nodeName = element.nodeName.toUpperCase(); + const ancestors = this._getAncestors(element); + const observer = { + element, + ancestors, + type: (nodeName === 'INPUT' ? element.type : null), + value: null, + hasValue: false, + onChange: null, + metadata + }; + observer.onChange = this._onElementChange.bind(this, observer); + this._elementMap.set(element, observer); + + element.addEventListener('change', observer.onChange, false); + + for (const ancestor of ancestors) { + let observers = this._elementAncestorMap.get(ancestor); + if (typeof observers === 'undefined') { + observers = new Set(); + this._elementAncestorMap.set(ancestor, observers); + } + observers.add(observer); + } + + this._updateTasks.enqueue(observer); + } + + _removeObserver(observer) { + const {element, ancestors} = observer; + + element.removeEventListener('change', observer.onChange, false); + observer.onChange = null; + + this._elementMap.delete(element); + + for (const ancestor of ancestors) { + const observers = this._elementAncestorMap.get(ancestor); + if (typeof observers === 'undefined') { continue; } + + observers.delete(observer); + if (observers.size === 0) { + this._elementAncestorMap.delete(ancestor); + } + } + } + + _isObserverStale(observer) { + const {element, type, metadata} = observer; + const nodeName = element.nodeName.toUpperCase(); + return !( + type === (nodeName === 'INPUT' ? element.type : null) && + this._compareElementMetadata(metadata, this._createElementMetadata(element)) + ); + } + + _shouldIgnoreElement(element) { + for (const selector of this._ignoreSelectors) { + if (element.matches(selector)) { + return true; + } + } + return false; + } + + _getAncestors(node) { + const root = this._observingElement; + const results = []; + while (true) { + results.push(node); + if (node === root) { break; } + node = node.parentNode; + if (node === null) { break; } + } + return results; + } + + _setElementValue(element, value) { + switch (element.nodeName.toUpperCase()) { + case 'INPUT': + switch (element.type) { + case 'checkbox': + element.checked = value; + break; + case 'text': + case 'number': + element.value = value; + break; + } + break; + case 'TEXTAREA': + case 'SELECT': + element.value = value; + break; + } + } + + _getElementValue(element) { + switch (element.nodeName.toUpperCase()) { + case 'INPUT': + switch (element.type) { + case 'checkbox': + return !!element.checked; + case 'text': + return `${element.value}`; + case 'number': + return this._getInputNumberValue(element); + } + break; + case 'TEXTAREA': + case 'SELECT': + return element.value; + } + return null; + } + + _getInputNumberValue(element) { + let value = parseFloat(element.value); + if (!Number.isFinite(value)) { return 0; } + + let {min, max, step} = element; + min = this._stringValueToNumberOrNull(min); + max = this._stringValueToNumberOrNull(max); + step = this._stringValueToNumberOrNull(step); + if (typeof min === 'number') { value = Math.max(value, min); } + if (typeof max === 'number') { value = Math.min(value, max); } + if (typeof step === 'number' && step !== 0) { value = Math.round(value / step) * step; } + return value; + } + + _stringValueToNumberOrNull(value) { + if (typeof value !== 'string' || value.length === 0) { + return null; + } + + const number = parseFloat(value); + return !Number.isNaN(number) ? number : null; + } +} diff --git a/ext/mixed/js/dom.js b/ext/mixed/js/dom.js index 0e8f4462..59fea9f6 100644 --- a/ext/mixed/js/dom.js +++ b/ext/mixed/js/dom.js @@ -77,6 +77,24 @@ class DOM { return (typeof key === 'string' ? (key.length === 1 ? key.toUpperCase() : key) : ''); } + static addFullscreenChangeEventListener(onFullscreenChanged, eventListenerCollection=null) { + const target = document; + const options = false; + const fullscreenEventNames = [ + 'fullscreenchange', + 'MSFullscreenChange', + 'mozfullscreenchange', + 'webkitfullscreenchange' + ]; + for (const eventName of fullscreenEventNames) { + if (eventListenerCollection === null) { + target.addEventListener(eventName, onFullscreenChanged, options); + } else { + eventListenerCollection.addEventListener(target, eventName, onFullscreenChanged, options); + } + } + } + static getFullscreenElement() { return ( document.fullscreenElement || @@ -86,4 +104,42 @@ class DOM { null ); } + + static getNodesInRange(range) { + const end = range.endContainer; + const nodes = []; + for (let node = range.startContainer; node !== null; node = DOM.getNextNode(node)) { + nodes.push(node); + if (node === end) { break; } + } + return nodes; + } + + static getNextNode(node) { + let next = node.firstChild; + if (next === null) { + while (true) { + next = node.nextSibling; + if (next !== null) { break; } + + next = node.parentNode; + if (next === null) { break; } + + node = next; + } + } + return next; + } + + static anyNodeMatchesSelector(nodes, selector) { + const ELEMENT_NODE = Node.ELEMENT_NODE; + for (let node of nodes) { + for (; node !== null; node = node.parentNode) { + if (node.nodeType !== ELEMENT_NODE) { continue; } + if (node.matches(selector)) { return true; } + break; + } + } + return false; + } } diff --git a/ext/mixed/js/dynamic-loader.js b/ext/mixed/js/dynamic-loader.js index ce946109..981d1ee5 100644 --- a/ext/mixed/js/dynamic-loader.js +++ b/ext/mixed/js/dynamic-loader.js @@ -16,19 +16,41 @@ */ /* global - * apiInjectStylesheet + * api */ const dynamicLoader = (() => { const injectedStylesheets = new Map(); + const injectedStylesheetsWithParent = new WeakMap(); - async function loadStyle(id, type, value, useWebExtensionApi=false) { + function getInjectedStylesheet(id, parentNode) { + if (parentNode === null) { + return injectedStylesheets.get(id); + } + const map = injectedStylesheetsWithParent.get(parentNode); + return typeof map !== 'undefined' ? map.get(id) : void 0; + } + + function setInjectedStylesheet(id, parentNode, value) { + if (parentNode === null) { + injectedStylesheets.set(id, value); + return; + } + let map = injectedStylesheetsWithParent.get(parentNode); + if (typeof map === 'undefined') { + map = new Map(); + injectedStylesheetsWithParent.set(parentNode, map); + } + map.set(id, value); + } + + async function loadStyle(id, type, value, useWebExtensionApi=false, parentNode=null) { if (useWebExtensionApi && yomichan.isExtensionUrl(window.location.href)) { // Permissions error will occur if trying to use the WebExtension API to inject into an extension page useWebExtensionApi = false; } - let styleNode = injectedStylesheets.get(id); + let styleNode = getInjectedStylesheet(id, parentNode); if (typeof styleNode !== 'undefined') { if (styleNode === null) { // Previously injected via WebExtension API @@ -38,21 +60,30 @@ const dynamicLoader = (() => { styleNode = null; } + if (type === 'file-content') { + value = await api.getStylesheetContent(value); + type = 'code'; + useWebExtensionApi = false; + } + if (useWebExtensionApi) { // Inject via WebExtension API if (styleNode !== null && styleNode.parentNode !== null) { styleNode.parentNode.removeChild(styleNode); } - injectedStylesheets.set(id, null); - await apiInjectStylesheet(type, value); + setInjectedStylesheet(id, parentNode, null); + await api.injectStylesheet(type, value); return null; } // Create node in document - const parentNode = document.head; - if (parentNode === null) { - throw new Error('No parent node'); + let parentNode2 = parentNode; + if (parentNode2 === null) { + parentNode2 = document.head; + if (parentNode2 === null) { + throw new Error('No parent node'); + } } // Create or reuse node @@ -74,12 +105,12 @@ const dynamicLoader = (() => { } // Update parent - if (styleNode.parentNode !== parentNode) { - parentNode.appendChild(styleNode); + if (styleNode.parentNode !== parentNode2) { + parentNode2.appendChild(styleNode); } // Add to map - injectedStylesheets.set(id, styleNode); + setInjectedStylesheet(id, parentNode, styleNode); return styleNode; } diff --git a/ext/mixed/js/environment.js b/ext/mixed/js/environment.js index e5bc20a7..5bd84010 100644 --- a/ext/mixed/js/environment.js +++ b/ext/mixed/js/environment.js @@ -32,17 +32,40 @@ class Environment { async _loadEnvironmentInfo() { const browser = await this._getBrowser(); - const platform = await new Promise((resolve) => chrome.runtime.getPlatformInfo(resolve)); - const modifierInfo = this._getModifierInfo(browser, platform.os); + const os = await this._getOperatingSystem(); + const modifierInfo = this._getModifierInfo(browser, os); return { browser, - platform: { - os: platform.os - }, + platform: {os}, modifiers: modifierInfo }; } + async _getOperatingSystem() { + try { + const {os} = await this._getPlatformInfo(); + if (typeof os === 'string') { + return os; + } + } catch (e) { + // NOP + } + return 'unknown'; + } + + _getPlatformInfo() { + return new Promise((resolve, reject) => { + chrome.runtime.getPlatformInfo((result) => { + const error = chrome.runtime.lastError; + if (error) { + reject(error); + } else { + resolve(result); + } + }); + }); + } + async _getBrowser() { if (EXTENSION_IS_BROWSER_EDGE) { return 'edge'; @@ -96,8 +119,15 @@ class Environment { ['meta', 'Super'] ]; break; - default: - throw new Error(`Invalid OS: ${os}`); + default: // 'unknown', etc + separator = ' + '; + osKeys = [ + ['alt', 'Alt'], + ['ctrl', 'Ctrl'], + ['shift', 'Shift'], + ['meta', 'Meta'] + ]; + break; } const isFirefox = (browser === 'firefox' || browser === 'firefox-mobile'); diff --git a/ext/mixed/js/media-loader.js b/ext/mixed/js/media-loader.js index 64ccd715..fc6e93d1 100644 --- a/ext/mixed/js/media-loader.js +++ b/ext/mixed/js/media-loader.js @@ -16,7 +16,7 @@ */ /* global - * apiGetMedia + * api */ class MediaLoader { @@ -84,7 +84,7 @@ class MediaLoader { async _getMediaData(path, dictionaryName, cachedData) { const token = this._token; - const data = (await apiGetMedia([{path, dictionaryName}]))[0]; + const data = (await api.getMedia([{path, dictionaryName}]))[0]; if (token === this._token && data !== null) { const contentArrayBuffer = this._base64ToArrayBuffer(data.content); const blob = new Blob([contentArrayBuffer], {type: data.mediaType}); diff --git a/ext/mixed/js/task-accumulator.js b/ext/mixed/js/task-accumulator.js new file mode 100644 index 00000000..5c6fe312 --- /dev/null +++ b/ext/mixed/js/task-accumulator.js @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2020 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +class TaskAccumulator { + constructor(runTasks) { + this._deferPromise = null; + this._activePromise = null; + this._tasks = []; + this._tasksActive = []; + this._uniqueTasks = new Map(); + this._uniqueTasksActive = new Map(); + this._runTasksBind = this._runTasks.bind(this); + this._tasksCompleteBind = this._tasksComplete.bind(this); + this._runTasks = runTasks; + } + + enqueue(key, data) { + if (this._deferPromise === null) { + const promise = this._activePromise !== null ? this._activePromise : Promise.resolve(); + this._deferPromise = promise.then(this._runTasksBind); + } + + const task = {data, stale: false}; + if (key !== null) { + const activeTaskInfo = this._uniqueTasksActive.get(key); + if (typeof activeTaskInfo !== 'undefined') { + activeTaskInfo.stale = true; + } + + this._uniqueTasks.set(key, task); + } else { + this._tasks.push(task); + } + + return this._deferPromise; + } + + _runTasks() { + this._deferPromise = null; + + // Swap + [this._tasks, this._tasksActive] = [this._tasksActive, this._tasks]; + [this._uniqueTasks, this._uniqueTasksActive] = [this._uniqueTasksActive, this._uniqueTasks]; + + const promise = this._runTasksAsync(); + this._activePromise = promise.then(this._tasksCompleteBind); + return this._activePromise; + } + + async _runTasksAsync() { + try { + const allTasks = [ + ...this._tasksActive.map((taskInfo) => [null, taskInfo]), + ...this._uniqueTasksActive.entries() + ]; + await this._runTasks(allTasks); + } catch (e) { + yomichan.logError(e); + } + } + + _tasksComplete() { + this._tasksActive.length = 0; + this._uniqueTasksActive.clear(); + this._activePromise = null; + } +} diff --git a/ext/mixed/js/text-scanner.js b/ext/mixed/js/text-scanner.js index b8688b08..7c705fc8 100644 --- a/ext/mixed/js/text-scanner.js +++ b/ext/mixed/js/text-scanner.js @@ -17,7 +17,6 @@ /* global * DOM - * TextSourceRange * docRangeFromPoint */ @@ -29,6 +28,7 @@ class TextScanner extends EventDispatcher { this._ignorePoint = ignorePoint; this._search = search; + this._isPrepared = false; this._ignoreNodes = null; this._causeCurrent = null; @@ -70,10 +70,15 @@ class TextScanner extends EventDispatcher { return this._causeCurrent; } + prepare() { + this._isPrepared = true; + this.setEnabled(this._enabled); + } + setEnabled(enabled) { this._eventListeners.removeAllEventListeners(); this._enabled = enabled; - if (this._enabled) { + if (this._enabled && this._isPrepared) { this._hookEvents(); } else { this.clearSelection(true); @@ -119,20 +124,20 @@ class TextScanner extends EventDispatcher { } } - getTextSourceContent(textSource, length) { + getTextSourceContent(textSource, length, layoutAwareScan) { const clonedTextSource = textSource.clone(); - clonedTextSource.setEndOffset(length); + clonedTextSource.setEndOffset(length, layoutAwareScan); if (this._ignoreNodes !== null && clonedTextSource.range) { length = clonedTextSource.text().length; while (clonedTextSource.range && length > 0) { - const nodes = TextSourceRange.getNodesInRange(clonedTextSource.range); - if (!TextSourceRange.anyNodeMatchesSelector(nodes, this._ignoreNodes)) { + const nodes = DOM.getNodesInRange(clonedTextSource.range); + if (!DOM.anyNodeMatchesSelector(nodes, this._ignoreNodes)) { break; } --length; - clonedTextSource.setEndOffset(length); + clonedTextSource.setEndOffset(length, layoutAwareScan); } } |