Closed18

deno_webview + Solidjs で デスクトップアプリもどき

nikogolinikogoli

モチベ

deno + webview で solidjs を使う。clippy も使う。

https://github.com/skanehira/deno-clippy

ということで、以下のような機能を持つ webアプリ?を作ってみる

  1. window 内をクリックすると
  2. クリップボードの中身を読み取って何らかの処理を行い
  3. 表示に反映させる
nikogolinikogoli

現状、deno の v1.25.0 における破壊的変更の影響で、deno-clippyのwrite_text()は動かないらしい。
read_text()は問題なく動いている

nikogolinikogoli

0:開始

とりあえず↓ の solidjs型をベースに、以下のような webview 用ファイルを作る。
https://zenn.dev/link/comments/12fd3dd278b104

main.ts
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()
nikogolinikogoli

1:.tsx + .ts に分離

このままでも良いけど、solidjs 部分が書きづらいので別ファイルに分離する。

↓の preat (renderToString)型を流用し、別ファイルから読み込んで <script>に流し込む[1]
https://zenn.dev/link/comments/84fa205246ca26

ついでに色々と見た目を調整する。

main.tsx
/** @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()
script.ts
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. これが適切な手法なのか自信はない ↩︎

nikogolinikogoli

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することで結果を返してもらうことにする。

nikogolinikogoli

2:serve in worker

公式の example を参考に、worker.ts を作る。
とりあえずawait clippy.read_text()するだけで、翻訳は行わない。

CORS制限に引っ掛かるので、Access-Control-Allow-Origin': 'null'を入れておく。

worker.ts
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
nikogolinikogoli

2.5:worker の呼び出し

webview 側 と soildjs 側で、worker へのアクセスを追加する

main.tsx
 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}でも機能する)
https://github.com/solidjs/solid/tree/main/packages/solid/html

script.ts
 //...
+    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>
 //...
nikogolinikogoli

3:fetch の結果の反映

solidjs には配列から DOM 要素を作るための For コンポーネントがあるが、htmlではそのままでは使えず、solid-js/web からインポートする必要がある。

ついでに signal の setter を調整して初期値を取り除く処理を追加する。

script.ts
 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>
   //...
nikogolinikogoli

4:翻訳の適用

DeepL 翻訳 API を使ってクリップボードから取得したテキストを翻訳する処理を worker に追加する。API Key は .env ファイルと deno の dotenv を利用して取得する。

色々整理するついでに、テキスト内の改行を取り除く処理も追加する。

worker.ts
 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 も少し変更する。

script.ts
 //...
        <${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>
 //...
nikogolinikogoli

4.5:複数の文章でも適用可能に

一度のリクエストで複数の文章に翻訳をかけつつ、1文ごとの表示を維持する。

worker.ts
 //...
   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}> )
         }
       }
     })
 //...
script.ts
 //...
       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("クリップボードが空です。") }
         } )
 //...
nikogolinikogoli

ボタンを押したら翻訳結果ごとクリップボードにコピーする機能を追加しようとしたが、clippy.write_text()がエラーを吐くので、とりあえずボタンだけ設置して修正待ち。

nikogolinikogoli

完成物

webview
main.tsx
/** @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
script.ts
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
worker.ts
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
このスクラップは2022/09/11にクローズされました