deno_webview + Solidjs で デスクトップアプリもどき
モチベ
deno + webview で solidjs を使う。clippy も使う。
ということで、以下のような機能を持つ webアプリ?を作ってみる
- window 内をクリックすると
- クリップボードの中身を読み取って何らかの処理を行い
- 表示に反映させる
0:開始
とりあえず↓ の solidjs型をベースに、以下のような webview 用ファイルを作る。
import { Webview, SizeHint } from "https://deno.land/x/webview/mod.ts";
const html = `
</html>
<head>
<meta charset="utf-8"/>
<script type="module" src="https://cdn.skypack.dev/twind/shim"></script>
<script type="module">
import {
createSignal,
} from "https://cdn.skypack.dev/solid-js"
import { render } from "https://cdn.skypack.dev/solid-js/web"
import html from "https://cdn.skypack.dev/solid-js/html"
const App = () => {
const [textdata, setTextData] = createSignal([
{ en: 'Please Click Here !', jp: '' }
])
return html\`
<div class='m-4'>
<p> Not click me... </p>
</div>
\`
}
render(App, document.body)
</script>
</head>
<body></body>
</html>`;
const webview = new Webview(true, {
width: 600,
height: 400,
hint: SizeHint.FIXED
})
webview.navigate(`data: text/html; charset=utf-8, ${encodeURIComponent(html)}`)
webview.title = "Translator app"
webview.run()
1:.tsx + .ts に分離
このままでも良いけど、solidjs 部分が書きづらいので別ファイルに分離する。
↓の preat (renderToString)型を流用し、別ファイルから読み込んで <script>に流し込む[1]。
ついでに色々と見た目を調整する。
/** @jsx h */
import { Webview, SizeHint } from "https://deno.land/x/webview/mod.ts";
import { h, type VNode } from "https://esm.sh/preact@10.10.6"
import {
renderToString
} from "https://esm.sh/preact-render-to-string@5.2.2"
const script = await Deno.readTextFile("script.ts")
function View(){
return (
<html>
<head>
<meta charSet="utf-8"/>
<link href="https://fonts.googleapis.com/css2?family=Overpass+Mono&family=Baloo+2&display=swap" rel="stylesheet"></link>
<script type="module" src="https://cdn.skypack.dev/twind/shim"></script>
<script type="module" dangerouslySetInnerHTML={{__html: script}}>
</script>
</head>
<body>
<style> {'body { font-family: \'Baloo 2\'; }'} </style>
</body>
</html>
)
}
const webview = new Webview(true, {
width: 600,
height: 400,
hint: SizeHint.FIXED
})
webview.navigate(`data: text/html; charset=utf-8, ${encodeURIComponent(renderToString(View()))}`)
webview.title = "Translator app"
webview.run()
import {
createSignal,
} from "https://cdn.skypack.dev/solid-js"
import { render } from "https://cdn.skypack.dev/solid-js/web"
import html from "https://cdn.skypack.dev/solid-js/html"
const App = () => {
const [textdata, setTextData] = createSignal([
{ en: 'Please Click Here !', jp: '' }
])
return html`
<div class='h-screen bg-[#14532d] text-white'>
<div class='p-6 bg-[#14532d]'}>
<h1 class='my-4 text-2xl'> Translator </h1>
<div class='m-2 p-2 bg-[#065f46] border-4 border-double border-[#fef08a]' >
<p class='m-1'> Not click me... </p>
</div>
</div>
</div>
`
}
render(App, document.body)
-
これが適切な手法なのか自信はない ↩︎
見た目
1.5:clippy をどうやって使う?
webview 側でも、bind
を使ってクリップボードにアクセスすることができる。
が、 bind されたasync function
は webview 起動中には resolve されない仕様なので意味がない。
対処法として、Web Worker を呼び出し、worker 内で await clippy.read_text()
を行って結果を返してもらう。
が、webview → worker の情報伝達はできるが、webview ← worker の伝達は deno_webview には今のところできない?らしい
最終的な対処法として、worker 内でサーバを建てて、webview からfetch
することで結果を返してもらうことにする。
2:serve in worker
公式の example を参考に、worker.ts を作る。
とりあえずawait clippy.read_text()
するだけで、翻訳は行わない。
CORS制限に引っ掛かるので、Access-Control-Allow-Origin': 'null'
を入れておく。
import { serve } from "https://deno.land/std/http/server.ts";
import * as clippy from "https://deno.land/x/clippy/mod.ts"
type CallResult = {
valid: false,
result: null
} | {
valid: true,
result:{en: string, jp: string},
}
const HEADER = new Headers({
'Access-Control-Allow-Method': 'OPTIONS, GET',
'Access-Control-Allow-Headers': 'Content-Type, Origin',
'Access-Control-Allow-Origin': 'null'
})
const server = serve( async (_req) => {
const text = await clippy.read_text()
const result: CallResult = (text.length > 0)
? {valid: true, result: {en: text, jp: "とりあえず"}}
: {valid: false, result: null}
return new Response(JSON.stringify(result), {headers: HEADER, status: 200})
}, { port: 8000 });
await server
2.5:worker の呼び出し
webview 側 と soildjs 側で、worker へのアクセスを追加する
import { Webview, SizeHint } from "https://deno.land/x/webview/mod.ts";
+ import { dirname, join } from "https://deno.land/std@0.153.0/path/mod.ts";
import { h, type VNode } from "https://esm.sh/preact@10.10.6"
// ...
const script = await Deno.readTextFile("script.ts")
+ const worker = new Worker(
+ join(dirname(import.meta.url), "worker.ts"),
+ { type: "module" },
+ )
//...
solidjs の html
、つまり Solid Tagged Template Literals は変数・関数の呼び出しに少し癖があるので注意。↓ に公式の説明がある。(ちなみにonClick=${call}
でも機能する)
//...
+ async function call(){
+ const resp = await fetch("http://localhost:8000/")
+ if (resp.ok){
+ await resp.json().then( jdata => {
+ console.log(jdata)
+ if (jdata.valid){ setTextData(prev => [...prev, jdata.result]) }
+ else { window.alert("クリップボードが空です。") }
+ } )
+ } else {
+ console.log(resp)
+ window.alert("Error")
+ }
+ }
return html`
//...
+ <div onClick=${() => call()} class='m-2 p-2 bg-[#065f46] border-4 border-double border-[#fef08a]'>
<p class='m-1'> Not click me... </p>
//...
3:fetch の結果の反映
solidjs には配列から DOM 要素を作るための For コンポーネントがあるが、html
ではそのままでは使えず、solid-js/web からインポートする必要がある。
ついでに signal の setter を調整して初期値を取り除く処理を追加する。
import {
createSignal,
} from "https://cdn.skypack.dev/solid-js"
+ import { render, For } from "https://cdn.skypack.dev/solid-js/web"
import html from "https://cdn.skypack.dev/solid-js/html"
//...
if (jdata.valid){ setTextData(
+ prev => (prev[0].jp.length > 0) ? [...prev, jdata.result] : [jdata.result]
) }
//...
return html`
//...
<div onClick=${() => call()} class='m-2 p-2 bg-[#065f46] border-4 border-double border-[#fef08a]'>
+ <${For} each=${() => textdata()}>
+ ${(dat, i) => html`<li id=${() => i()}>${dat.en}</li>`}
+ </For>
</div>
//...
見た目
4:翻訳の適用
DeepL 翻訳 API を使ってクリップボードから取得したテキストを翻訳する処理を worker に追加する。API Key は .env ファイルと deno の dotenv を利用して取得する。
色々整理するついでに、テキスト内の改行を取り除く処理も追加する。
import { serve } from "https://deno.land/std/http/server.ts";
import * as clippy from "https://deno.land/x/clippy/mod.ts"
+ import { config } from "https://deno.land/x/dotenv/mod.ts"
//...
+ const BaseUrl = "https://api-free.deepl.com/v2/translate?"
const server = serve( async (_req) => {
+ const text = await clippy.read_text().then( t => t.trim().replaceAll("\r\n", "").replaceAll("\n", ""))
+ let result: CallResult = {valid: false, result: null}
+ if (text.length > 0){
+ const URL = BaseUrl + new URLSearchParams({
+ "text": text,
+ "source_lang": "EN",
+ "target_lang": "JA",
+ })
+ await fetch(URL, {
+ headers: { Authorization: "DeepL-Auth-Key " + config()["ApiKey"] }
+ })
+ .then(resp => resp.json())
+ .then( jdata => {
+ if ("translations" in jdata){
+ result = {valid: true, result: {en: text, jp: jdata["translations"].map(x => x["text"]).join("")}}
+ }
+ })
+ }
return new Response(JSON.stringify(result), {headers: HEADER, status: 200})
// ...
style も少し変更する。
//...
<${For} each=${() => textdata()}>
${(dat, i) => html`
<li class='m-3' id=${() => i()}>
+ <p class=${(dat.jp.length > 0) ? 'text-[#fed7aa]' : 'text-white'}>${dat.en}</p><p>${dat.jp}</p>
</li>`}
</For>
//...
見た目
4.5:複数の文章でも適用可能に
一度のリクエストで複数の文章に翻訳をかけつつ、1文ごとの表示を維持する。
//...
if (text.length > 0){
+ const text_param = text.split(".").map(x => `text=${encodeURI(x)}&`).join("")
+ const URL = BaseUrl + text_param + new URLSearchParams({
"source_lang": "EN",
"target_lang": "JA",
})
//...
.then( jdata => {
if ("translations" in jdata){
result = {
valid: true,
+ result: text.split(".").reduce((pre, tx, idx) => {
+ if (tx.length > 0){ pre.push({ en: tx+".", jp: jdata["translations"].at(idx)["text"] })}
+ return pre
+ },[] as Array<{en: string, jp: string}> )
}
}
})
//...
//...
if (resp.ok){
await resp.json().then( jdata => {
console.log(jdata)
if (jdata.valid){ setTextData(
+ prev => (prev[0].jp.length > 0) ? [...prev, ...jdata.result] : [...jdata.result]
) }
else { window.alert("クリップボードが空です。") }
} )
//...
ボタンを押したら翻訳結果ごとクリップボードにコピーする機能を追加しようとしたが、clippy.write_text()
がエラーを吐くので、とりあえずボタンだけ設置して修正待ち。
5:エラー処理とか
気が向いたら
6:ビルド
うまくいかなかったので、とりあえず .bat で起動する。
完成物
webview
/** @jsx h */
import { Webview, SizeHint } from "https://deno.land/x/webview/mod.ts";
import { dirname, join } from "https://deno.land/std@0.153.0/path/mod.ts";
import { h, type VNode } from "https://esm.sh/preact@10.10.6"
import {
renderToString
} from "https://esm.sh/preact-render-to-string@5.2.2"
const script = await Deno.readTextFile("script.ts")
const worker = new Worker(
join(dirname(import.meta.url), "worker.ts"),
{ type: "module" },
)
function View(){
return (
<html>
<head>
<meta charSet="utf-8"/>
<link href="https://fonts.googleapis.com/css2?family=Overpass+Mono&family=Zen+Kaku+Gothic+New&display=swap" rel="stylesheet"></link>
<script type="module" src="https://cdn.skypack.dev/twind/shim"></script>
<script type="module" dangerouslySetInnerHTML={{__html: script}}>
</script>
</head>
<body>
<style> {'body { font-family: \'Zen Kaku Gothic New\'; }'} </style>
</body>
</html>
)
}
const webview = new Webview(true, {
width: 700,
height: 600,
hint: SizeHint.FIXED
})
webview.navigate(`data: text/html; charset=utf-8, ${encodeURIComponent(renderToString(View()))}`)
webview.title = "Translator app"
webview.run()
worker.terminate()
solidjs
import {
createSignal,
} from "https://cdn.skypack.dev/solid-js"
import { render, For } from "https://cdn.skypack.dev/solid-js/web"
import html from "https://cdn.skypack.dev/solid-js/html"
const App = () => {
const [textdata, setTextData] = createSignal([
{ en: 'Please Click Here !', jp: '' }
])
async function call(){
const resp = await fetch("http://localhost:8000/translate", {method:"GET"})
if (resp.ok){
await resp.json().then( jdata => {
console.log(jdata)
if (jdata.valid){ setTextData(
prev => (prev[0].jp.length > 0) ? [...prev, ...jdata.result] : [...jdata.result]
) }
else { window.alert("クリップボードが空です。") }
} )
} else {
console.log(resp)
window.alert("Error")
}
}
async function copy() {
await fetch("http://localhost:8000/copy", {
method: "POST",
headers: { 'Content-Type': 'text/plain' },
body: textdata().map(x => `- ${x.en}\n\t- ${x.jp}`).join("\n")
})
.then(res => {
if (res.ok){ window.alert("コピーしました") }
else { window.alert("Error") }
})
.catch(er => window.alert(er))
}
return html`
<div class='h-screen bg-[#14532d] text-white'>
<div class='p-6 bg-[#14532d]'}>
<div class='my-4 text-2xl'>
<span> Translator </span>
<button onClick=${() => copy()}> 📝 </button>
</div>
<div onClick=${() => call()} class='m-2 p-2 bg-[#065f46] border-4 border-double border-[#fef08a]'>
<ul>
<${For} each=${() => textdata()}>
${(dat, i) => html`
<li class='m-3' id=${() => i()}>
<p class=${(dat.jp.length > 0) ? 'text-[#fed7aa]' : 'text-white'}>${dat.en}</p><p>${dat.jp}</p>
</li>`}
</For>
</ul>
</div>
</div>
</div>
`
}
render(App, document.body)
worker
import { serve } from "https://deno.land/std/http/server.ts";
import * as clippy from "https://deno.land/x/clippy/mod.ts"
import { config } from "https://deno.land/x/dotenv/mod.ts"
type CallResult = {
valid: false,
result: null
} | {
valid: true,
result: Array<{en: string, jp: string}>,
}
const HEADER = new Headers({
'Access-Control-Allow-Method': 'OPTIONS, GET, POST',
'Access-Control-Allow-Headers': 'Content-Type, Origin',
'Access-Control-Allow-Origin': 'null'
})
const BaseUrl = "https://api-free.deepl.com/v2/translate?"
const server = serve( async (req) => {
const url = new URL(req.url)
let result: CallResult = {valid: false, result: null}
if (url.pathname == "/translate"){
const text = await clippy.read_text().then( t => t.trim().replaceAll("\r\n", "").replaceAll("\n", ""))
if (text.length > 0){
const text_param = text.split(".").map(x => `text=${encodeURI(x)}&`).join("")
const URL = BaseUrl + text_param + new URLSearchParams({
"source_lang": "EN",
"target_lang": "JA",
})
await fetch(URL, {
headers: { Authorization: "DeepL-Auth-Key " + config()["ApiKey"] }
})
.then(resp => resp.json())
.then( jdata => {
if ("translations" in jdata){
result = {
valid: true,
result: text.split(".").reduce((pre, tx, idx) => {
if (tx.length > 0){ pre.push({ en: tx+".", jp: jdata["translations"].at(idx)["text"] })}
return pre
},[] as Array<{en: string, jp: string}> )
}
}
})
}
}
else if (url.pathname == "/copy"){
const text = await req.blob().then(x => x.text())
await clippy.write_text(text)
result = {valid: true, result: [{en:"", jp: ""}]}
}
return new Response(JSON.stringify(result), {headers: HEADER, status: 200})
}, { port: 8000 });
await server