あんちょこ

C++やRubyの話題 sampleはすべてpublic domainです。

4

使っているノートPCはかなり古く、windows11の動作要件を満たしていなかった。ハードウェア的にはまだ使えるものなのでなんとか延命したく、せっかくなのでLinuxに変えた。

メールやブラウザはほぼそのままデータを移行でき、それなりに快適に使えていたのだが1点だけ困った点があった。それが年賀状印刷ソフトがないということだった。

宛名面のレイアウト調整などいちいち差し込み印刷で作ってられない。何か良いソフトはないかと探していたところaikige氏がHTML+CSSでうまく作られているのを見つけた。なるほど、cssはミリメートル単位でも大きさを指定できるので、実寸が重要なレイアウトには都合がよさそうだ。そしてメディアクエリーで印刷時のcssを指定できるので、印刷のときだけ表示したり非表示にしたりも思いのままである。このままでも十分便利だがせっかくなのでsvelteを使って住所の編集画面を追加してみた。

ソースコードはこちら

ほとんどはスムーズに制作できたのだが、1点だけおや?と思うことがあった。それが編集画面の表のTABキーによるフォーカス移動である。普通のHTMLだと単純にtabindexを指定すればよいだけなのだが、svelteの画面更新のサイクルとうまくかみ合わない。TABキーを押してフォーカスを抜けると「入力の確定」と「次の要素へのフォーカス移動」が順番に行われるのだが、入力を確定した瞬間svelteの作用によってinput要素が書き換えられてしまい、次の要素を見失ってしまうことが分かった。

試しにAIに問題解決を提案させると長考のあげくとんでもない行数の大作を生成してくれた。いや、そうじゃなくて・・・と思い結局自分で解決してしまった。方法は単純で、svelteによる更新作用が終わった後に起こる$effect節に次のタブインデックスを持つ要素を検索してフォーカス移動せよ、と書き加えただけである。

どうやらAIは$effectの使用は控えめにせよという公式ドキュメントの警告に忠実に従っているようだ。私の書いたコードもフォーカスイベントでリアクティブな変数を更新するような副作用が定義されると崩壊してしまうので、コメントをしっかり書くなど開発の規模次第では対策した方が良いのだろう。AIの書いたコードと併せて考えることで学びがあったのは良かった。

5

目的

Svelte を使って Fabric.js 用のコントローラを作成する。 選択した図形が持つパラメータに応じて動的に変化し、現在値の確認と入力ができるようにする。

下準備

Fabric.js で canvas を操作できるようにしておく。今回は以前作ったコントローラーのデモを出発点とする。 また、プロジェクト全体は Vite でビルドできるようにしておく。 package.json の設定例は下記の通り。tsconfig.json と vite.config.js 等、その他の設定はボイラープレートのままで問題ない。例を見たい方は前回の Svelte 記事をご参照ください。

package.json

{
  "scripts": {
    "start": "vite"
    "build": "vite build",
  },
  "type": "module",
  "devDependencies": {
    "@sveltejs/vite-plugin-svelte": "^6.2.0",
    "svelte": "^5.38.10",
    "vite": "^7.1.5"
  },
  "dependencies": {
    "fabric": "^6.7.1"
  }
}

store と state の組み合わせ

先に断っておくと、Svelte コンポーネントの作成自体は手間がかかる。 Fabric.js のような外部ライブラリが絡むとさらに面倒である。 再利用性を考慮しないのであれば、双方向にマッピングするコードを直接書いた方が記述も実装も楽な場合がある。 大まかな手順としては、

  1. Fabric.js の activeObject を Svelte の store に保存する
  2. Svelte コンポーネントを作成し、操作したい activeObject を $state() で宣言する
  3. activeObject に属する表示・操作したいパラメータを $derived() で宣言する
  4. Svelte コンポーネント内で store を購読し、store に保存された object を先ほど $state() で宣言した activeObject に反映させる
  5. input 要素を記述し、bind:value で各パラメータを変更できるようにする
  6. $effect() を記述し、各パラメータの状態を activeObject に反映させる
となる。特に 2 と 6 が手間である。パラメーターの上下限や表示方法、適切な初期値など 個別に検討しなければならない事項が多く、処理の共通化が難しいのが主な理由だ。 決して楽な道のりではないことはご想像いただけると思う。

activeObject を store に登録する

まずは canvas で選択されている activeObject を Svelte の store に登録する。store は各コンポーネントから参照できる方が便利なので import して参照できるよう独立したファイルに記述する。 大事なのは図形の「非」選択状態、すなわち ActiveObject が undefined になる状態があり得ること。 store には何も入っていない状態を許容しなければならない。 以降のコンポーネント作成部でも常に中身の有無をチェックしつつ記述していくことになる。

store.ts

import { writable, type Writable } from 'svelte/store'
import { FabricObject } from 'fabric'

let store: Writable<FabricObject> | undefined = undefined

export const createStore = (object?: FabricObject) => {
    store = writable(object)
    return store
}

export const getStore = () => {
    return store
}

そして Fabric.js のセレクション系イベントでストアに保存されるようにする。 イベントリスナの引数の型が長くて煩わしい。もう少し使いやすいエイリアスがあるとよい。

fabricController.ts

import * as fabric from 'fabric'
import { getStore } from './store'

type FabricEvent = Partial<fabric.TEvent<fabric.TPointerEvent>> & { selected: fabric.FabricObject[]; deselected: fabric.FabricObject[]; }

export const setupFabricEvents = (canvas: fabric.Canvas) => {
    canvas.on('object:modified', onObjectModified)
    canvas.on('object:scaling', onObjectScaling)
    canvas.on('selection:created', onSelectionCreated)
    canvas.on('selection:updated', onSelectionUpdated)
    canvas.on('selection:cleared', onSelectionCleared)
}

function onObjectModified (options: fabric.ModifiedEvent<fabric.TPointerEvent>) {
    getStore()?.set(options.target)
}

function onObjectScaling (options: fabric.ModifiedEvent<fabric.TPointerEvent>) {
    getStore()?.update(() => options.target)
}

function onSelectionUpdated (options: FabricEvent) {
    options.selected.forEach(object => {
        getStore()?.set(object)
    })
}

function onSelectionCreated (options: FabricEvent) {
    options.selected.forEach(object => {
        getStore()?.set(object)
    })
}

function onSelectionCleared (options: FabricEvent) {
    getStore()?.set(undefined)
}

Svelte コンポーネントの作成

derived

次にコントローラーを定義する Svelte コンポーネントを作成する。 まずは変数宣言。選択した図形(ActiveObject)の状態をコントロールするため、これを $state で宣言する。 そしてそれに付随するプロパティを $derived で記述していく。 前述のとおり図形には非選択状態があり得るので、付随するパラメータもすべて undefined チェックを入れなければならない。 また、(width, height) と radius など図形の種類によってパラメータが異なる場合、片側のみ有効で片側が無効になる状態が起こり得る。

subscribe

そして store を subscribe して activeObject に反映させる。 たとえばスケールの比率とピクセル数など、パラメーター変換が必要な項目もここで処理する。 例では角まるめの比率を示す rx, ry やクリッピング領域の縦横サイズなどを割合でもたせ、ここで実寸に変換している。

effect

effect 節には subscribe とは逆の操作、つまりコントローラー側を操作したときに activeObject をどう変化させるかを記述する。 大抵はパラメータを素直に更新すればよいが、ここでも undefined になる可能性を考慮する必要がある。 また、先ほどと同様に比率と実寸の変換が必要ならここにも逆の変換を記述する。 最後に dirty フラグをセットして再描画し、その状態を store に放り込んでおく。 こうすることで他の Svelte コンポーネントにも状態変化が伝播する。 本来、Svelte 外のライブラリの副作用を記述する effect 節で store.update を行うべきかは議論の余地があるが、 今回は他に良い方法が思いつかなかったためこの方法を採用している。

HTML と style

Svelte ファイル内にレンダリングされる HTML とその style を記述していく。 図形の種類によって表示・非表示を切り替える必要のあるパラメーターは {#if} を使って分岐させている。 input からの入力受け取りは bind:value を活用している。 このあたりは onChange イベントを処理するより記述が楽だが、その分細かい調整はやりにくい。 パラメーター変換や入力値の検証は derived または subscribe で行う方がよい。

style 部分は Svelte の機能でコンポーネント内に閉じることができるため、他のコンポーネントに影響を与えないようにできる。 HTML のトップレベルで導入するグローバルなスタイルと、各 Svelte コンポーネントで定義するローカルなスタイルとを使い分けると良い。 カラースキーム、フォント、線のスタイルなどはグローバルにし、縦横サイズ、表示位置、アニメーション動作などはローカルに定義すると分かりやすい。

controller.svelte

<script lang="ts">
    import * as fabric from 'fabric'
    import { getStore } from '../store'
    import { type Writable } from 'svelte/store'

    let files = $state<FileList>()
    let activeObject = $state<fabric.Object>()
    let src = $derived(activeObject instanceof fabric.FabricImage ? activeObject.getSrc() : undefined)
    let filename = $derived(activeObject instanceof fabric.FabricImage ? 
        activeObject?.getSrc().replace(document.baseURI, '') : undefined)
    let width = $derived(activeObject?.width)
    let height = $derived(activeObject?.height)
    let scaleX = $derived(activeObject?.scaleX)
    let scaleY = $derived(activeObject?.scaleY)
    let fillColor = $derived(activeObject?.fill)
    let backgroundColor = $derived(activeObject?.backgroundColor)
    let strokeWidth = $derived(activeObject?.strokeWidth)
    let strokeUniform = $derived(activeObject?.strokeUniform)
    let strokeColor = $derived(activeObject?.stroke)
    let cornerRoundX = $derived(activeObject instanceof fabric.Rect? activeObject.rx : 0)
    let cornerRoundY = $derived(activeObject instanceof fabric.Rect? activeObject.ry : 0)
    let skewX = $derived(activeObject?.skewX)
    let skewY = $derived(activeObject?.skewY)

    let clipPath = $derived(activeObject?.clipPath)
    let clipPathWidthRate = $derived(activeObject?.clipPath.width / activeObject?.width)
    let clipPathHeightRate = $derived(activeObject?.clipPath.height / activeObject?.height)
    let clipPathRx = $derived(activeObject?.clipPath.rx)
    let clipPathRy = $derived(activeObject?.clipPath.ry)
    let clipPathRadius  = $derived(activeObject?.clipPath.radius)

    const objectStore: Writable<fabric.FabricObject> = getStore()
    if (objectStore) {
        objectStore.subscribe((object) => {
            const active = object
            if (active) {
                activeObject = active
                scaleX = activeObject.scaleX
                scaleY = activeObject.scaleY
                if (activeObject instanceof fabric.Rect) {
                    width = activeObject.width
                    height = activeObject.height
                    cornerRoundX = activeObject.rx / activeObject.width * 100
                    cornerRoundY = activeObject.ry / activeObject.height * 100
                }
                if (activeObject.clipPath instanceof fabric.Rect) {
                    clipPathWidthRate = activeObject.clipPath.width / activeObject.width
                    clipPathHeightRate = activeObject.clipPath.height / activeObject.height
                } else if (activeObject.clipPath instanceof fabric.Circle) {
                    activeObject.clipPath.radius = clipPathRadius
                }
            } else {
                activeObject = undefined
            }
        })
    }

    $effect(() => {
        if (activeObject) {
            if (files) {
                for (const file of files) {
                    filename = file.name
                    const r = new FileReader()
                    r.addEventListener('load', function (ev: ProgressEvent<FileReader>) {
                        if (activeObject instanceof fabric.FabricImage) {
                            src = this.result.toString()
                        }
                    })
                    r.readAsDataURL(file)
                }
            }
            if ((src !== undefined) && (activeObject instanceof fabric.FabricImage)) {
                activeObject.setSrc(src).then(() => {
                    activeObject.dirty = true
                    activeObject.canvas.requestRenderAll()
                })
            } 
            if (width !== undefined) activeObject.width = width
            if (height !== undefined) activeObject.height = height
            if (scaleX !== undefined) activeObject.scaleX = scaleX
            if (scaleY !== undefined) activeObject.scaleY = scaleY
            if (fillColor !== undefined) activeObject.fill = fillColor
            if (backgroundColor !== undefined) activeObject.backgroundColor = backgroundColor
            if (strokeWidth !== undefined) activeObject.strokeWidth = strokeWidth
            if (strokeUniform !== undefined) activeObject.strokeUniform = strokeUniform
            if (strokeColor !== undefined) activeObject.stroke = strokeColor
            if (activeObject instanceof fabric.Rect) {
                activeObject.rx = cornerRoundX * activeObject.width / 100
                activeObject.ry = cornerRoundY * activeObject.height / 100
            }
            activeObject.skewX = skewX
            activeObject.skewY = skewY
            if (activeObject.clipPath instanceof fabric.Rect) {
                activeObject.clipPath.width = clipPathWidthRate * activeObject.width
                activeObject.clipPath.height = clipPathHeightRate * activeObject.height
                activeObject.clipPath.rx = clipPathRx
                activeObject.clipPath.ry = clipPathRy
            } else if (activeObject.clipPath instanceof fabric.Circle) {
                activeObject.clipPath.radius = clipPathRadius
            }
            activeObject.dirty = true
            objectStore?.update(() => activeObject)
            activeObject.canvas?.requestRenderAll()
        }
    })
</script>

<form>
{#if src !== undefined}
    <h3>image file</h3>
    <label>
        {filename}
        <input type="file" title="image file" accept="image/jpg, image/png, image/gif, image/svg, image/webp" disabled={!activeObject} bind:files={files}>
    </label>
{/if}
    <h3>property</h3>
    <label>
        fill color
        <input type="color" title="fill color" disabled={!activeObject} bind:value={fillColor}>
    </label><br>
    <label>
        background color
        <input type="color" name="backgroundColor" title="background color" disabled={!activeObject} bind:value={backgroundColor}>
    </label><br>
    <label>
        border width
        <input type="number" name="strokeWidth" title="stroke width" min="0" disabled={!activeObject} bind:value={strokeWidth}>
    </label><br>
    <label>
        uniform border
        <input type="checkbox" name="strokeUniform" title="uniform border" disabled={!activeObject} bind:checked={strokeUniform}>
    </label><br>
    <label>
        border color
        <input type="color" name="strokeColor" title="stroke color" disabled={!activeObject} bind:value={strokeColor}>
    </label><br>
    <label>
        width
        <input type="number" step="1" disabled={!activeObject} bind:value={width}>
    </label>
    <label>
        height <input type="number" title="height" step="1" disabled={!activeObject} bind:value={height}>
    </label><br>
    <label>
        scale x
        <input type="number" title="scale x" disabled={!activeObject} bind:value={scaleX}>
        <input type="range" title="scale x" min="0" max="5" step="0.1" disabled={!activeObject} bind:value={scaleX}>
    </label><br>
    <label>
        scale y
        <input type="number" title="scale y" disabled={!activeObject} bind:value={scaleY}>
        <input type="range" title="scale y" min="0" max="5" step="0.1" disabled={!activeObject} bind:value={scaleY}>
    </label><br>
    <label>
        coenr round x
        <input type="range" title="corner round" min="0" max="100" step="1"
            disabled={!activeObject && !(activeObject instanceof fabric.Rect)} bind:value={cornerRoundX}>
    </label><br>
    <label>
        corner round y
        <input type="range" title="corner round" min="0" max="100" step="1"
            disabled={!activeObject && !(activeObject instanceof fabric.Rect)} bind:value={cornerRoundY}>
    </label><br>
    <label>
        skew x<br><input type="range" title="corner round" min="-60" max="60" step="5" disabled={!activeObject} bind:value={skewX}>
    </label><br>
    <label>
        skew y<br><input type="range" title="corner round" min="-60" max="60" step="5" disabled={!activeObject} bind:value={skewY}>
    </label>
    <hr>
{#if clipPath instanceof fabric.Rect}
    <h3>clip path</h3>
    <label>
        width
        <input type="range" title="clip path corner round" min="0" max="1" step="0.01" bind:value={clipPathWidthRate}>
    </label><br>
    <label>
        height
        <input type="range" title="clip path corner round" min="0" max="1" step="0.01" bind:value={clipPathHeightRate}>
    </label><br>
    <label>
        corner round x
        <input type="range" title="clip path corner round" min="0" max="100" bind:value={clipPathRx}>
    </label><br>
    <label>
        corner round y
        <input type="range" title="clip path corner round" min="0" max="100" bind:value={clipPathRy}>
    </label><br>
{/if}
{#if clipPath instanceof fabric.Circle}
    <h3>clip path</h3>
    <label>
        radius
        <input type="range" title="clip path radius" min="0" max="200" bind:value={clipPathRadius}>
    </label>
{/if}
</form>


<style>
    form {
        height: 100%;
        overflow-x: auto;
        overflow-y: auto;
        padding: 10px;
        background-color: #ffeeee;
        transition-property: width;
        transition-duration: 0.4s;
        transition-timing-function: ease-in-out;
        width: 10px;
        font-size: 12pt;
        line-height: 180%;
    }

    form:has(:enabled) {
        width: 250px;
    }

    form input {
        border-style: solid;
        border-color: #888888;
        border-width: 1px;
        border-radius: 4px;
        width: 70%;
    }

    form input[type="number"],
    form input[type="checkbox"],
    form input[type="color"] {
        min-width: 35px;
        max-width: 55px;
    }

    label input[type="file"] {
        display: none;
    }
</style>

結果と動作確認

非常に長くなってしまった。冒頭にも述べたが再利用性は向上するが記述は楽になるわけではない。 結局Fabric.jsとSvelteコンポーネントの双方向データバインドを全て書かなければならないからだ。 Svelteを導入するメリットがあるのはコンポーネントを他のプロジェクトでも再利用する計画がある場合に限られるだろう。 きれいにMVCパターンに分離できていて再利用する計画もないのであれば、Svelteを導入するメリットは薄いかもしれない。

動作確認はこちら

Chromeで表示するとinput関連のwarningが出る・・・が、どうやらSvelteの不具合らしい。

続きを読む

3

やりたいこと

2025年現在、web業界は何らかのフレームワークにのっとった開発が多いだろう。 そんな中でフレームワークなしのWebページに部分的にsvelteを適用する必要が出てきたので方法をご紹介する。 ページ全体をsveltekitで一から作る情報は公式ドキュメントも含め豊富にあるが、既に出来上がったページに部分的に適用する方法は意外と少ない。 例として、input要素を1つもち、そこへの入力内容に応じてspan要素が変化するページがあるとする。 このinput要素とspan要素をsvelteコンポーネントで置き換えることを考えてみよう。

ディレクトリ構成とソース

まずはsvelte適用前のプロジェクトを作る。viteでビルドを行うように下記のディレクトリ構成で開発をしたと仮定する。

top-|-index.html
    |-src-main.ts
    
indexとmainの内容は下記。

index.html

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>progressive svelte</title>
  </head>
  <body>
    <form name="titleInput">
      input title: <input id="titleInput" type="text" maxlength="20" /><br>
      title: <span id="title"></span>
    </form>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

main.ts

class App {
  title: string = ''
  input = document.getElementById('titleInput') as HTMLInputElement
  output = document.getElementById('title') as HTMLElement

  constructor () {
    this.input.addEventListener('input', this.onInput, {passive: true})
  }

  onInput = (event: InputEvent) => {
    this.title = this.input.value
    this.updateUI()
  }

  updateUI = () => {
    document.title = this.title
    this.output.innerText = this.title
  }
}

globalThis.app = new App()

ビルド

viteとtypescriptは以下の要領でインストールしておく。

        
    npm i -D vite typescript
        
    
また、packages.jsonのscriptsに
        
    "dev": "vite"
        
    
と記述して、npm run devでテストサーバーが起動できるようにしておく。

内容は単純で、input要素にイベントリスナを設定して入力に対して変化するようにしてある。 動作テストはこちら。 このサンプルの事例のように入力と出力が1:1だとさほど苦労はないが、入力に対して変化させなければならない表示が多数になるといかにも苦労しそうである。 これらの動作をsvelteを使って換装しようというわけだ。

svelteを適用する

ライブラリの追加

まずは必要なライブラリを追加する。 typescript, viteと併せて使うので下記のように追加。 vite以外でビルドしている場合はそれに応じたプラグインを入れる。


npm i -D sveltejs/vite-plugin-svelte tsconfig/svelte svelte svelte-check

各種設定ファイル

各種設定ファイルをトップディレクトリに配置する。このへんはほぼ定型文なのでsveltekitで適当に新規プロジェクトを作ってとってきても良いだろう。

vite.config.json

import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'

// https://vite.dev/config/
export default defineConfig({
  plugins: [svelte()],
})

svelte.config.js

import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'

/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
export default {
  // Consult https://svelte.dev/docs#compile-time-svelte-preprocess
  // for more information about preprocessors
  preprocess: vitePreprocess(),
}

index.html

inputとspan要素をsvelteコンポーネントで置換するために以下のようにidを割り振る

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>progressive svelte</title>
  </head>
  <body>
    <form name="titleInput">
      input title: <span id="titleInput"></span><br>
      title: <span id="title"></span>
    </form>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

コンポーネントの作成

top-src-libに以下のコンポーネントを置く。それぞれ入力のinput要素と出力のspan要素を生成するsvelteコンポーネントである。

TitleView.svelte

まずは簡単なspan要素から

<script lang="ts">
    let title = $state('untitled')
    const {store} = $props()
    store.subscribe((v: string) => title = v)
</script>

<span>{title}</span>

scriptの箇所を簡単に解説すると、titleという変数に対して画面更新がかかるように$stateを使って宣言し、 titleはpropsに保存されたstoreを購読する、となっている。 storeは後述するsvelteコンポーネントが反応するデータを格納する共通領域である。 今回はtitleの文字列1個のデータしか保存されていないのであまりありがたみはないが、複雑なオブジェクトを置くと管理の楽さがお分かりいただけると思う。 そこに保存されている値をもとにtitleを更新し、titleが更新されると画面が書き換わる仕組みである。

TitleInput.svelte

<script lang="ts">
    let title = $state('untitled')
    const {store} = $props()
    store.subscribe((v: string) => title = v)
</script>

<input bind:value={
    () => title,
    (v: string) => store.set(v)
} maxlength="10" />

こちらは出力だけでなく入力も扱うinput要素を生成する。 先ほどと同様にtitleを$stateを使って宣言し、storeを購読してtitleが更新されるようにしている。 ただし、inputでユーザーが入力したときのイベントを扱わなければならないので その処理をbind:value以下に記述してある。

bind:value=の一行目がtitleが更新されたらそのまま表示せよ、という意味で、 2行目以降は入力が変更されたらstoreの内容を更新せよ、という意味である。 そしてsotreの内容が更新されると自分自身も含め、それを購読しているすべてのコンポーネントが更新される。 先ほど定義したTitleView.svelteも更新がかかってくれるだろう。

main.ts

main.tsは先ほど定義したsvelteコンポーネントをimportし、moutメソッドでhtml要素と置換する。 このとき、propsにstoreを渡しておけば各コンポーネントが参照するようになる。 storeの定義はwritableで宣言するだけで良い。

import { mount} from 'svelte'
import { writable } from 'svelte/store'
import TitleInput from './lib/TitleInput.svelte'
import TitleView from './lib/TitleView.svelte'

export class App {
  title = writable('untitled')

  constructor () {
    const input = document.querySelector<HTMLInputElement>('#titleInput')
    if (input) {
      mount(TitleInput, {
        target: input,
        props: { store: this.title }
      })
    }  
    const output = document.querySelector<HTMLElement>('#title')
    if (output) {
      mount(TitleView, {
        target: output,
        props: { store: this.title }
      })
    }
  }
}

globalThis.app = new App()


子や孫要素が多くなってくるとpropsで引き渡して逆に辿っていくのが面倒になるかもしれない。 これはprop drillingと呼ばれるこの手のライブラリユーザーの間では有名な問題だ。 あまりにも階層が深い場合は直接storeを渡す方が良い。 その場合propsではなくstoreを定義したファイルをimportするか、またはcontextを使うことになる。 公式のサンプルはstoreを定義した別ファイルをimportする方法を使っているので、この方法が一番推奨なのかもしれない。

以上で完成。テストページはこちら

3

しくみ

前回実装した非同期generatorを2つ使い、色を変えつつ画面を書き換えるタスクを走らせる。

ソースコード

例によってgit bashやpython debuggerなどのANSIエスケープに対応した端末で実行すること。2つの表示が非同期に色が変わりつつ点滅するはずである。

from asyncio import sleep, run, create_task, gather

colors = ['\033[31mR\033[0m', '\033[31mR\033[0m']

async def signal(speed: float):
    if speed == 0:
        raise ZeroDivisionError("speed 0 is not allowed.")

    while True:
        yield '\033[31mR\033[0m'
        await sleep(3 / speed)
        yield '\033[32mG\033[0m'
        await sleep(3 / speed)
        for _ in range(0, 6):
            yield ''
            await sleep(0.2 / speed)
            yield '\033[32mG\033[0m'
            await sleep(0.2 / speed)

async def task1(speed: float):
    gen = signal(speed)
    t = 0
    while t < 60:
        colors[0] = await anext(gen)
        print_colors(colors)
        t += 1

async def task2(speed: float):
    gen = signal(speed)
    t = 0
    while t < 60:
        colors[1] = await anext(gen)
        print_colors(colors)
        t += 1

def print_colors(colors: list[str]):
    print("\033[2K\033[G", end='')
    print("\t".join(colors), end='')

async def main():
    t1 = create_task(task1(0.9))
    t2 = create_task(task2(1))
    await gather(t1, t2)
    print('\nfinished all tasks')

run(main())

考察

確かに非同期に実行されるが画面の書き換えまで不定期に発生するため、ちらつきが目立つ結果となった。比較のために画面書き換えのみを30fpsで動く別タスクに移すといくぶんかましになった。ゲームなど画面がちらつかない方が重要なのか、更新を取りこぼさない保証が重要かで最適な方法を選択する必要がある。

やりたいこと

赤、緑、緑点滅、赤・・・のパターンを繰り返す歩行者用信号機のようなものを実装する。

yieldを使う理由

別の言語であるが、以前の記事でgeneratorを使うと関数の途中でyieldで値を返し、次に呼ばれたときにyieldの次から再開できると述べた。自前で状態変数を使ってどこまで進んだか管理しなくても良いので、今回の信号機の例では点灯パターンを順番に並べるだけで簡単に書くことが期待できる。

asyncioを使う理由

信号機の状態遷移はgeneratorで単純化できるとして、待ち時間をどう実装するかが次の課題である。ここでasync/awaitを使う。python3.10から非同期generatorを待つためのanextという関数が用意されたので、これを用いるとgenerator内にawait sleepを使って待ち時間を直接記述できる。状態遷移と待ち時間を単純に並べるだけで記述できて楽である。

サンプルコード

注)windowsで動かすときはpowershellでANSIエスケープシーケンスを有効にするか、pythonデバッガーやgitコンソール等色を変えられる端末から起動すること。

from asyncio import sleep, run

async def signal():
    while True:
        yield '\033[2K\033[G\033[31mR\033[0m'
        await sleep(3)
        yield '\033[2K\033[G\033[32mG\033[0m'
        await sleep(3)
        for _ in range(0, 6):
            yield '\033[2K\033[G'
            await sleep(0.2)
            yield '\033[2K\033[G\033[32mG\033[0m'
            await sleep(0.2)

async def main():
    gen = signal()
    t = 0
    while t < 30:
        print(await anext(gen), end='')
        t += 1

run(main())

考察

今回の件ではすっきりした実装になったが、非同期generatorは多用するとどこで待ち時間を発生させているか分かりにくいとの批判もあるようだ。また、generatorが途中で失敗する可能性がある場合は処理方法についてもよく考えなければならない。

1

アロー関数で定義できない関数

筆者はjavascriptの関数を定義するときアロー関数を好んで使っている。 ところが、functionでしか定義できない特殊な関数も存在する。 それが今回話題とするgeneratorである。

generatorとは

generator関数を定義するにはfunction*を使う。 function*で定義された関数からはyieldで値を返すことができ、次回呼び出された時はyieldの次から再開する。 この性質を利用すれば呼ばれた回数に依存する処理を簡単に書くことができる。 最近は他の言語でもyieldが採用されている事例が多いので、それらの経験があるならほぼ同じと考えて良い。

今回は一例として呼び出すたびにtrue, false, true, false・・・と前回と逆の真偽値を返すtoggle関数を考えてみよう。 generatorを使う、使わないの差をご覧いただきたい。

generatorを使わない実装例

class Toggle {
    constructor () {
        this.value = false
    }
    doToggle = () => this.value = !this.value
}

const f = new Toggle()
for (let i = 0; i < 10; i++) {
    console.log(i, f.doToggle())
}

generatorを使った実装例

 const toggle = function* () {
    while (true) {
        yield true
        yield false
    }
}

const g = toggle()
for (let i = 0; i < 10; i++) {
    console.log(i, g.next().value)
}

結局どっちがいいの?

今回の例ではtrue, falseのみだったのでほとんど差は感じないと思う。 しかし扱う状態が3, 4, ...と増えたり、進行に伴い値を返すときの条件分岐が複雑になる可能性を考えてほしい。 classで実装した方はcall回数の進行度を変数で管理し、条件分岐するコードが爆発的に増えてしまう事が想像できると思う。 そのような時にgeneratorを活用して複雑度を下げると良い。

今回の事例のようなtoggle動作の他、筆者はanimationのキーフレームをスクリプトから生成するときによく使っている。 キーフレームも時間の進行に従い順番に値を返す必要があるので、generatorとは相性が良い。 また、通信でリクエストを順番に送りつつレスポンスをやり取りしたり、イベントをフィルタリングしつつ伝播させたりするような処理にも使える。

無理に使う必要はないが、覚えておくと時々役に立つ。なのでたまにはfunctionにも出番があるのである。

5
Document

freedrawのストロークをリアルタイムに取得する

背景目的

以前、ミニマップやサムネイルの作り方を解説したことがあった。 CanvasをtoBlobで縮小画像にすれば簡単に作れるが、freedrawを描いている途中の図形はこの方法では取得できない。 試しにisDrawingModeをtrueにして手書き編集モードにし、描いている途中で canvas.toBlobでイメージを取り出して中身を見てほしい。ストロークが抜け落ちた画像が得られるはずだ。 1ストローク書き終わってmouseUpイベントを発生させれば図形を取得できる。 しかし、描いている途中が取得できないといわゆるお絵描き掲示板のようなアプリを作ろうとしたときに不便である。 そこで今回はストローク途中の図形を画像として得る方法について紹介する。

freedraw途中の図形を取得できない理由

それはfabric.jsの描画サイクルが下図のようになっているからだ。 描いている途中のオブジェクトはupperCanvasElに描画され、 既存のオブジェクトはlowerCanvasElに描画される。 upperCanvasElは描いている途中経過をビットマップ画像として扱い、必要な領域しか再描画しない。 同時に、lowerCanvasELはまったく再描画しないことで描画にかかるコストを抑えている。 マウスをリリースして1ストロークが確定すると途中の中継点とともにベクター図形が生成され、 lowerCanvasElにオブジェクトに加えられる。そして次の編集が始まるまでupperCanvasElは空白となる。

g536-8

toDataURLやgetCanvasElementで得られるのはlowerCanvasElの方である。 よってマウスを離す前に取得しても描いている途中のストロークは得られない。

解決方法

upperCanvasElとlowerCanvasElを両方取得し、合成すればよい。新しいfabric.StaticCanvasを作って そこで合成しても良いが、再編集するものではないのでcontextを直接使えばよいだろう。 1つ注意点があるとすればupperCanvasElもlowerCanvasElもprivateメンバなので普通の方法ではアクセスできないこと。 typescriptの外でなんとかするか、[]演算子を使ってアクセスしよう。このへんはfabric.jsが6.0からtypescriptネイティブになったことで若干使いにくくなったと思う。

テストページ

動作確認はこちら。メインのCanvasに対して、toDataURLで得られる画像、upperCanvasElの画像、lowercanvasElの画像、upper + lowerを合成したものそれぞれが下にミニマップとして表示してある。 メインCanvasを編集したりfreedraw機能で描くと動作の様子がご理解いただけると思う。

source

index.html

<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="utf-8">
  <title>Upper and Lower Canvas Demo</title>
  <meta name="description" content="testing fabricjs">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="stylesheet" href="./styles.css">
</head>

<body class="Select">
  <h1>Upper and Lower Canvas Demo</h1>
  <menu>
    <form name="menu">
      <label>
        select
        <input type="radio" name="mode" value="Select" checked>
      </label>
      <label>
        +circle
        <input type="radio" name="mode" value="Circle">
      </label>
      <label>
        +square
        <input type="radio" name="mode" value="Square">
      </label>
      <label>
        free draw
        <input type="radio" name="mode" value="FreeDraw">
      </label>
      <button type="button" title="delete">delete</button>
    </form>
  </menu>
  <div>
    <canvas id="MainCanvas" class="BlueBorder" width="512" height="128">using HTML5 canvas</canvas>
  </div>
  <form name="freedrawController" class="FreeDrawController">
    color: <input type="color" title="freedraw color" name="color" value="#00ffff">
    width: <input type="range" title="freedraw width" name="width" min="1" max="30" value="5">
  </form>
  <div class="miniCanvasHolder">
    <div>toDataUrl</div><img id="MiniMap" class="BlueBorder" alt="mini map" width="256" height="64">
  </div>
  <div  class="miniCanvasHolder">
    <div>upperCanvasEl</div><canvas id="UpperCanvasCopy" class="BlueBorder" width="256" height="64">using HTML5 canvas</canvas>
  </div>
  <div  class="miniCanvasHolder">
    <div>lowercanvasEl</div><canvas id="LowerCanvasCopy" class="BlueBorder" width="256" height="64">using HTML5 canvas</canvas>
  </div>
  <div  class="miniCanvasHolder">
    <div>upper + lower</div><canvas id="CanvasCopy" class="BlueBorder" width="256" height="64">using HTML5 canvas</canvas>
  </div>
  <script src="./main.js"></script>
</body>

</html>

app.ts

import { fabric } from 'fabric'

const MAX_OBJECTS = 20

const Mode = {
  Select: "Select",
  Circle: "Circle",
  Square: "Square",
  FreeDraw: "FreeDraw"
}

export class App {
  menu: HTMLFormElement
  mode = Mode.Select
  canvas: fabric.Canvas
  ws: WebSocket
  ip = 'localhost'
  uctx: CanvasRenderingContext2D | null
  lctx: CanvasRenderingContext2D | null
  ctx: CanvasRenderingContext2D | null
  dt: number[] = []
  formerTime: DOMHighResTimeStamp = 0
  animationId: number

  constructor(canvasID: string) {
    console.log('app start')
    this.canvas = new fabric.Canvas(canvasID)
    this.menu = document.forms['menu']
  }

  setupCanvas = () => {
    const UpperCanvasCopy = document.getElementById('UpperCanvasCopy') as HTMLCanvasElement
    const LowerCanvasCopy = document.getElementById('LowerCanvasCopy') as HTMLCanvasElement
    const CanvasCopy = document.getElementById('CanvasCopy') as HTMLCanvasElement
    this.ctx = CanvasCopy.getContext('2d')
    this.lctx = LowerCanvasCopy.getContext('2d')
    this.uctx = UpperCanvasCopy.getContext('2d')

    this.canvas.add(new fabric.Rect({
      left: 32,
      top: 10,
      width: 20, 
      height: 20,
      fill: '#0fff0f'
    }))
    this.canvas.add(new fabric.Circle({
      left: 100,
      top: 40,
      radius: 60,
      fill: '#ffff00'
    }))
    this.canvas.renderAll()

    this.canvas.on('mouse:down', this.onMouseDown)
  }

  clearCtx = (ctx: CanvasRenderingContext2D | null) => {
    ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)
  }

  copyCanvas = (src: HTMLCanvasElement, dest: CanvasRenderingContext2D | null) => {
    if (dest) {
      dest.drawImage(src, 0, 0, src.width, src.height, 
        0, 0, dest.canvas.width, dest.canvas.height)
    }
  }

  sendCanvas = () => {
    const upper  = this.canvas['upperCanvasEl'] as HTMLCanvasElement
    const lower = this.canvas['lowerCanvasEl'] as HTMLCanvasElement
    this.clearCtx(this.lctx)
    this.clearCtx(this.uctx)
    this.clearCtx(this.ctx)

    this.copyCanvas(upper, this.uctx)
    this.copyCanvas(lower, this.lctx)
    this.copyCanvas(lower, this.ctx)
    this.copyCanvas(upper, this.ctx)

    const url = this.canvas.toDataURL({format: "png", multiplier: 0.5})
    const miniMap = document.getElementById("MiniMap") as HTMLImageElement
    miniMap.src = url
  }

  run = (time: DOMHighResTimeStamp = 0) => {
    if (this.formerTime !== 0) {
      this.dt.push(time - this.formerTime)
    }
    const SampleSize = 1000
    if (this.dt.length === SampleSize) {
      const sum = this.dt.reduce((prev, current) => {
        return prev + current
      }, 0)
      console.log(`frame per seconds = ${(SampleSize / (sum / 1000)).toFixed(1)}`)
      this.dt = []
    }
    if (this.dt.length % 2 === 0) {
      this.sendCanvas()
    }
    this.formerTime = time
    this.canvas.requestRenderAll()
    this.animationId = requestAnimationFrame(this.run)
  }

  addCircle = (event: fabric.IEvent<MouseEvent>) => {
    const [x, y] = [event.pointer?.x, event.pointer?.y]
    this.canvas.add(new fabric.Circle({
      radius: 20,
      left: x, top: y,
      fill: '#ffff00',
      originX: 'center',
      originY: 'center'
    }))
  }

  addSquare = (event: fabric.IEvent<MouseEvent>) => {
    const [x, y] = [event.pointer?.x, event.pointer?.y]
    this.canvas.add(new fabric.Rect({
      width: 34,
      height: 34,
      left: x, top: y,
      fill: '#00ff00',
      originX: 'center',
      originY: 'center'
    }))
  }

  changeMode = (mode: string) => {
    const nl = this.menu['mode'] as RadioNodeList
    nl.value = Mode.Select
    nl[0].dispatchEvent(new Event('change'))
  }

  onModeChange = (event: Event) => {
    const t = event.target as HTMLInputElement
    if (t) {
      this.mode = Mode[t.value] ? Mode[t.value] : Mode.Select
      this.canvas.isDrawingMode = false
      console.log(this.mode)
      this.canvas.discardActiveObject()
      this.canvas.requestRenderAll()
      this.canvas.forEachObject(obj => obj.selectable = this.mode === Mode.Select)
      this.canvas.defaultCursor = "default"

      switch (this.mode) {
        case Mode.Select:
          break
        case Mode.Circle:
          this.canvas.defaultCursor = "crosshair"
          break
        case Mode.Square:
          this.canvas.defaultCursor = "crosshair"
          break
        case Mode.FreeDraw:
          (document.forms['freedrawController'] as HTMLFormElement).dispatchEvent(new Event('input'))
          this.canvas.isDrawingMode = true
          break
        default:
          break
      }

      for (const e in Mode) {
        document.body.classList.remove(e)
      }
      document.body.classList.add(this.mode)
    }
  }

  onMouseDown = (event: fabric.IEvent<MouseEvent>) => {
    switch (this.mode) {
      case Mode.Select:
        return
      case Mode.Circle:
        this.addCircle(event)
        this.changeMode(Mode.Select)
        break
      case Mode.Square:
        this.addSquare(event)
        this.changeMode(Mode.Select)
        break
      default:
        // do not change from freedraw mode
        break
    }
  }

  onDelete = (event: Event) => {
    const selection = this.canvas.getActiveObjects()
    this.canvas.remove(...selection)
    this.canvas.discardActiveObject()
    this.canvas.requestRenderAll()
  }

  onFreedrawControllerChange = (event: Event) => {
    const t = event.currentTarget as HTMLFormElement
    if (t) {
      this.canvas.freeDrawingBrush.color = t['color']?.value
      this.canvas.freeDrawingBrush.width = parseFloat(t['width']?.value)
    }
  }

}


その他

その他のソースコードはあまり語ることはないので省略する。ぶっちゃけsendCanvas部分だけ見れば十分である。

3

背景目的

Webページからサーバーに入力情報を送るのにHTML Formは有効な手段だ。 このときFormのsubmit機能を直に使うことは稀で、submitボタンでscriptを起動してpostすることが多いと思う。 しかしサーバー側をFastAPIで書く場合、このためだけにjavascriptを書くのも億劫なもの。 そこでjavascriptを書かずにいい感じに処理することを考える。

方法

フロントエンド側でやること

javascriptを書かないのでFormに備わった機能を有効活用する。 Formの下にあるinputにtypeやmax_lengthなどを設定しておけばある程度は不正な入力を防げるだろう。 ただし、元々のFormの機能でsubmitするとページ遷移が発生してしまう。これを防ぐために responseを受け取るためのiframeを作り、Formのtargetをそこに指定する。 こうすればiframeの中身だけページ遷移が起こり、ページ全体は元の表示を維持できる。 iframeの枠表示を工夫すれば違和感なく元のページに溶け込めるだろう。

サーバー側でやること

pydanticを用いてForm入力を扱うクラスを作成し、FastAPIのpost処理と結びつける。 しかしこのままだと不正な入力があったときにクラス構造と入力ルールを詳細に表示してしまい、 普通のユーザーにはほぼ意味が分からないデータを晒すことになってしまう。

そこでRequestValidationErrorのハンドラを上書きし、フロントエンド側に返す前に適切に翻訳する。 今回はPlainTextResponseで簡素なメッセージを返すことを考えるが、こだわるならFileResponseで整えたHTMLファイルを返しても良い。

今回はpydanticのモデルとFormのmax_lengthにわざと不整合を設けてある。 これで不正な入力に対する挙動を簡易的にテストできるだろう。 もちろん綿密にテストケースを作っても良いし、FastAPIのdoc機能を使っても良い。

source code

frontend (index.html)

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="icon" href="./favicon.png">
    <title>Post Validation</title>
    <style>
        iframe[name="response"] {
            border: none;
        }
    </style>
</head>
<body>
    <h1>post validation test</h1>
    <form method="post" target="response" action="./post">
        nickname: <input value="valid nickname" name="nickname" maxlength="16"><br>
        channel: <input value="invalid channel name" name="channel_name" maxlength="17"><br>
        <button type="submit">post</button>
    </form>
    <iframe name="response" width="50%" height="100px"></iframe>
</body>
</html>

server (main.py)

from fastapi import FastAPI, Request, Form
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, RedirectResponse, PlainTextResponse
from fastapi.exceptions import RequestValidationError
from pydantic import BaseModel, Field
from typing import Annotated

class UserLogin(BaseModel):
    nickname: str = Field(max_length=16, min_length=1)
    channel_name: str = Field(max_length=16)
    model_config = {"extra": "forbid"}

app = FastAPI()

@app.get("/")
async def redirect():
    return RedirectResponse("./index.html")

@app.get("/index.hmtl")
async def index(req: Request):
    return FileResponse(path="../../client/dist/index.html")

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, ex: RequestValidationError):
    # log entire error
    return PlainTextResponse("不正な投稿です。", status_code=400)
 
@app.post("/post", )
async def post(userlogin: Annotated[UserLogin, Form()]):
    # do something
    return PlainTextResponse("%s の受付が完了しました。" % userlogin.nickname)

app.mount("/", StaticFiles(directory="../client/dist"), "static files")


結果

正常入力時

valid

不正な入力時

invalid

1

fastAPIとXMLHttpRequest

以前、単純な画像ファイルのアップローダーをtornadoで作ったことがあったのだが、今回はfastAPIで実装してみることにした。ところがtornadoと一緒には上手く動いていたクライアントがエラーを返す。ログを見てみるとResponseのstatuscodeにfastAPI特有の妙な挙動があるようだ。

クライアント側ではアップロードが完了するとXMLHttpRequestのonLoadendイベントが発火するので、その際に正常にアップロードできたかどうかstatus codeをチェックしている。しかしfastAPIのサーバーからはどのようなファイルをアップロードしても常に0しか返ってこず(というより何も返ってこない)、onLoadendが完了した後に200を返すという挙動になっていた。tornadoのときはonLoadendイベント内で捕まえられるstatus codeは既に200になっていた。

最初はfastAPIが速すぎるのでタイミングがずれたのかと思っていたがいろいろと速度にウェイトをかけてリトライしても同様だった。0でも200でもとりあえず成功と見なしてもう一度チェックしなおせば特段大きな課題はなさそうに見えるが、少々腑に落ちないものがある。フォーラムを検索しても話題になっていなかったのできっとXMLHttpRequestとあわせて使う人が少ないのだろう。あるいは、この方法でcancelable APIを作るのを推奨していないのかもしれない。このあたりは内部で使われているuvicornのソースを読んだ方が早そうだ。時間があればもう少し調査してみたいところ。検証に用いたソースコードは下記。

サーバー側

main.py

from fastapi import FastAPI, Request, Response, UploadFile
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, RedirectResponse

from PIL import Image
from io import BytesIO

app = FastAPI()

@app.get("/")
async def redirect():
    return RedirectResponse("./index.html")

@app.get("/index.hmtl")
async def index(req: Request):
    return FileResponse(path="../../client/dist/index.html")

@app.post("/upload")
async def handleFile(file: UploadFile):
    try:
        img = Image.open(BytesIO(await file.read()))
        img.save("../client/dist/img/" + file.filename)
    except Exception as e:
        return Response("could not accept file", 400)

app.mount("/", StaticFiles(directory="../client/dist"), "static files")

クライアント側

index.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Upload Test</title>
</head>
<body>
    <h2>image uploader test</h2>
    <form name="uploading" method="post">
        select image file<br>
        <input type="file" name="file" accept="image/*"><br>
    </form>
    <dialog id="DlgUpload">
        <h3>progress</h3>
        <p>uploading...</p><br>
        <form name="progress">
            <progress max="100" value="0"></progress><br>
            <button type="button" name="cancel">cancel</button>
        </form>
    </dialog>
    <script src="./main.js"></script>
</body>
</html>

index.ts


/*
 main program for test uploader
*/
export class App {
    version = '0.1.4'
    xhr: XMLHttpRequest
    uploadDlg: HTMLDialogElement
    progress: HTMLProgressElement

    constructor () {
        console.info(`app ver${this.version} start`)
        this.uploadDlg = document.getElementById("DlgUpload") as HTMLDialogElement
        this.progress = this.uploadDlg.querySelector("progress") as HTMLProgressElement
        this.xhr = new XMLHttpRequest()
        this.setupUploadEvents(this.xhr.upload)
    }

    setupEvents = () => {
        const uploadButton = document.forms["uploading"]["file"] as HTMLInputElement
        if (uploadButton) uploadButton.addEventListener("change", this.onFileChange, {passive: true}
)
        const cancelButton = document.forms["progress"]["cancel"] as HTMLInputElement
        if (cancelButton) cancelButton.addEventListener("click", this.onAbort, {passive: true})
    }

    setupUploadEvents = (uploader: XMLHttpRequestUpload) => {
        uploader.addEventListener("error", e => alert('error'))
        uploader.addEventListener("timeout", e => alert('タイムアウトしました'))
        uploader.addEventListener("abort", e => alert("中断しました"))
        uploader.addEventListener("progress", this.onProgress)
        uploader.addEventListener("loadstart", this.onLoadStart)
        uploader.addEventListener("loadend", this.onLoadEnd)
    }

    onFileChange = (event: Event) => {
        const form = document.forms['uploading']
        if (form) {
            const data = new FormData(form)
            this.xhr.open("post", "./upload", true)
            this.xhr.send(data)
        }
    }

    onProgress = (event: ProgressEvent) => {
        const value = (event.loaded / event.total) * 100
        console.log(`upload status= ${this.xhr.status}, progress = ${value}`)
        this.progress.value = value
    }

    onAbort = (event: Event) => {
        this.xhr.abort()
        this.uploadDlg.close()
    }

    onLoadStart = (event: Event) => {
        this.progress.value = 0
        this.uploadDlg.showModal()
    }

    onLoadEnd = async (event: Event) => {
        this.uploadDlg.close()
        const status = this.xhr.status
        document.forms['uploading']['file'].value = ""
        console.info(`status = ${status}`)
        // fastAPIは正常終了でもstatus code 0を返す
        if ((status == 200) || (status == 0)) {
            console.log("upload finished")
        } else {
            alert(`code ${this.xhr.status} アップロードできませんでした`)
        }
    }
}

const app = new App()
globalThis.app = app
app.setupEvents()


2
【fabric.js】controller

背景と目的

fabric.jsはHTML5Canvasの図形操作が簡単にできるようになるライブラリだが、導入しただけで図形エディットが簡単になるわけではない。 特にコントローラーの実装は煩雑になりがちである。 残念ながらこの手の図形操作に求められる機能は多様なため、既存のコンポーネントがうまく当てはまらないことが多い。 そのため、今回は1からコントローラーを作ることを考える。 後にreact等のコンポーネントを作るときも出発点になると思う。

実装例

まずは実装例を見ていただいた方が早いだろう。オブジェクトをクリックすると画面の右側に生えてくるパネルが今回実装したコントローラーである。 動作確認はこちら。

方法

まずコントローラーの基本形をHTML Formとinput要素で作る。 HTML form内にコントローラーとなるinput要素やselect要素を並べ、nameの属性を設定する。 この時点でcssなども整備し、外観を整えると良い。figmaを使える方は活用するとかなり楽ができるだろう。 既存のcssフレームワークがばっちり当てはまるなら採用を検討しても良いが、図形操作はなかなかレアなケース。 過度な期待はしない方がいい。

nameには操作したいfabric.Objectのプロパティを判別できるように設定する。たとえば、widthやheightだ。 Object.widthやObject.heightなどの場合は単純に"width", "height"と名付ければ良いが、 Object.Clippath.widthのように入れ子になったプロパティを設定したいこともある。 この場合はClipPath.radiusのように"."で子オブジェクトにアクセスできるようにするのが望ましい。

そして、Formのchangeとinputイベントを設定する。イベントリスナの中ではname属性を参照して ActiveObjectの値を変更する。このときname属性を.でsplitし、再帰的にたどって設定すれば さきほどのClipPathのように入れ子になった子Objectのプロパティにも対応可能である。

最後にFormの値を更新するメソッドを記述し、ObjectのModifiedEventに関連付ける。 これでFormからObject、ObjectからFormの双方向のマッピングが完了する。

ソースコード

HTML

        <!DOCTYPE html>
       <html lang="ja">
       
       <head>
         <meta charset="utf-8">
         <title>Fabric Controller Demo</title>
         <meta name="description" content="fabric controller demo">
         <meta name="viewport" content="width=device-width, initial-scale=1">
         <link rel="stylesheet" href="./styles.css">
       </head>
       
       <body>
         <h1>Fabric Controller Demo</h1>
         <div class="flexLR">
           <div id="MainCanvasHolder">
             <div class="Margin50">
               <canvas id="MainCanvas" width="400" height="300">using HTML5 canvas</canvas>
             </div>
           </div>
           <form name="controller">
             image file<br><input type="file" name="src" title="image file" accept="image/jpg, image/png, image/gif, image/svg, image/webp"><br>
             fill color<br><input type="color" name="fill" title="fill color"><br>
             background color<br><input type="color" name="backgroundColor" title="background color"><br>
             border width<br><input type="number" name="strokeWidth" title="stroke width" min="0"><br>
             uniform border<br><input type="checkbox" name="strokeUniform" title="uniform border"><br>
             border color<br><input type="color" name="stroke" title="border color"><br>
             width<br><input type="number" name="width" title="width" step="1"><br>
             height<br><input type="number" name="height" title="height" step="1"><br>
             scale x<br><input type="range" name="scaleX" title="scale x" min="0" max="5" step="0.1"><br>
             scale y<br><input type="range" name="scaleY" title="scale y" min="0" max="5" step="0.1"><br>
             coenr round x<br><input type="range" name="rx" title="corner round" min="0" max="100"><br>
             corner round y<br><input type="range" name="ry" title="corner round" min="0" max="100"><br>
             skew x<br><input type="range" name="skewX" title="corner round" min="-60" max="60" step="5"><br>
             skex y<br><input type="range" name="skewY" title="corner round" min="-60" max="60" step="5"><br>
             clip coenr round x<br><input type="range" name="clipPath.rx" title="clip path corner round" min="0" max="100"><br>
             clip corner round y<br><input type="range" name="clipPath.ry" title="clip path corner round" min="0" max="100"><br>
             clip radius<br><input type="range" name="clipPath.radius" title="clip path radius" min="0" max="200"><br>
           </form>
         </div>
         <script src="./main.js"></script>
       </body>
       
       </html>

CSS

        * {
           box-sizing: border-box;
       }
       
       body {
           width: 100dvw;
           height: 100dvh;
           margin: 0px;
           padding: 8px;
           overflow: hidden;
           --active-color: #ffff00;
           --border-color: #444444;
       }
       
       body>div {
           height: calc(100dvh - 110px);
       }
       
       label>input {
           display: none;
       }
       
       form[name="controller"] {
           height: 100%;
           overflow-x: auto;
           overflow-y: auto;
           padding: 10px;
           background-color: #ffeeee;
           transition-property: width;
           transition-duration: 0.4s;
           transition-timing-function: ease-in-out;
           width: 10px;
       }
       
       form[name="controller"]:has(:enabled) {
           width: 250px;
       }
       
       form[name="controller"] input {
           min-width: 100px;
           max-width: 70%;
       }
       
       label:has(input:checked) {
           background-color: var(--active-color);
       }
       
       #MainCanvas {
           border-width: 1px;
           border-style: solid;
           border-color: var(--border-color);
           padding: 0px;
           background-color: #ffffff;
           box-sizing: border-box;
       }
       
       #MainCanvasHolder {
           width: 100%;
           height: 100%;
           background-color: #eeeeff;
           padding: 0px;
           display: flex;
           overflow: scroll;
           flex-direction: row;
           flex-wrap: nowrap;
           justify-content: space-around;
           align-items: flex-start;
       }
       
       .Margin50 {
           margin: 50px;
       }
       
       .flexLR {
           display: flex;
           flex-direction: row;
           justify-content: stretch;
       }
       
       .canvasHolder {
           height: calc(100dvh - 150px);
           width: 95%;
           padding: 10px;
           overflow: auto;
           background-color: bisque;
       }
       
       .canvasMargin {
           border-style: dashed;
           width: 100%;
           height: 100%;
           overflow: auto;
       }
       

Typescript

ビルドにはesbuildを推奨。ビルドのスクリプト例は下記。

esbuild src/index.ts --bundle --sourcemap --outfile=dist/main.js

fabricController.ts

import * as fabric from 'fabric'
export class FabricController {
    canvas: fabric.Canvas
    forms: HTMLFormElement

    constructor (canvas: fabric.Canvas, forms: HTMLFormElement) {
        this.canvas = canvas
        this.forms = forms
        this.forms.addEventListener('input', this.onInput)
        this.forms.addEventListener('change', this.onChange)
        this.canvas.on('object:modified', this.onObjectModified)
        this.canvas.on('selection:created', this.onSelectionCreated)
        this.canvas.on('selection:updated', this.onSelectionUpdated)
        this.canvas.on('selection:cleared', this.onSelectionCleared)
        this.enableForms(false)
    }

    onChange = (event: Event) => {
        this.onInput(event)
        const object = this.canvas.getActiveObject()
        this.canvas.discardActiveObject()
        this.canvas.setActiveObject(object)
        this.canvas.requestRenderAll()
    }

    onInput = (event: Event) => {
        const element = event.target as HTMLInputElement
        const names = element.name.split('.')
        const object = this.canvas.getActiveObject()
        if (element && object) {
            this.setProperty(object, names, element)
            this.canvas.requestRenderAll()
        }
    }

    onSelectionUpdated = (options: Partial<fabric.TEvent<fabric.TPointerEvent>>) => {
        const object = this.canvas.getActiveObject()
        if (object) {
            this.updateForms(object)
            this.enableForms()
        }
    }

    onObjectModified = (options: fabric.ModifiedEvent) => {
        if (options.target) this.updateForms(options.target)
    }

    onSelectionCreated = (options) => {
        const object = this.canvas.getActiveObject()
        if (object) {
            this.updateForms(object)
            this.enableForms(true)
        }
    }

    onSelectionCleared = (options) => {
        this.enableForms(false)
    }

    enableForms = (enabled: boolean = true) => {
        const elements = this.forms.elements
        const object = this.canvas.getActiveObject()

        for (let i = 0; i < elements.length; i++) {
            const element = elements.item(i) as HTMLInputElement
            if (!object) {
                element.disabled = true
            } else {
                if (element && (typeof object[element.name] !== 'undefined')) {
                    element.disabled = !enabled
                }
            }
        }
    }

    // If names has multiple item, set object member's property recursively.
    getProperty = (object: fabric.FabricObject, names: string[], element: HTMLInputElement | HTMLSelectElement) => {
        if (names.length === 0) { return }
        const name = names.shift()
        if (names.length === 0) {
            if ((object instanceof fabric.FabricImage) && (element.type === 'file')) {
                element.disabled = false
            } else if (typeof object[name] !== 'undefined') {
                element.disabled = false
                element.value = object[name]
            } else {
                element.disabled = true
            }
        } else {
            this.getProperty(object[name], names, element)
        }
    }

    setImageFile = (object: fabric.FabricImage, element: HTMLInputElement) => {
        const file = element.files[0]
        const reader = new FileReader()
        reader.onload = (event: ProgressEvent<FileReader>) => {
            const data = event.target.result as string
            object.setSrc(data).then(() => {
                object.set('dirty', true)
                this.canvas.renderAll()
            })
        }
        reader.readAsDataURL(file)
    }

    setPropertyFromInput = (object: fabric.FabricObject, name: string, element: HTMLInputElement | HTMLSelectElement) => {
        switch (element.type) {
            case 'range':
                if (typeof object[name] !== 'undefined') {
                    object.set(name, parseFloat(element.value))
                }
                break
            case 'checkbox':
                if (typeof object[name] !== 'undefined') {
                    object.set(name, element.checked)
                }
                break
            case 'color':
                if (typeof object[name] !== 'undefined') {
                    object.set(name, element.value)
                }
                break
            case 'file':
                if (object instanceof fabric.FabricImage) {
                    this.setImageFile(object, element)
                }
                break
            default:
                const num = parseFloat(element.value)
                if (Number.isNaN(num)) {
                    if (element.value === 'true') {
                        object.set(name, true)
                    } else if (element.value === 'false') {
                        object.set(name, false)
                    } else {
                        object.set(name, element.value)
                    }
                } else {
                    object.set(name, num)
                }
        }
    }

    setProperty = (object: fabric.FabricObject, names: string[], element: HTMLInputElement | HTMLSelectElement) => {
        if (names.length === 0) { return }
        const name = names.shift()
        if (names.length === 0) {
            this.setPropertyFromInput(object, name, element)
        } else {
            this.setProperty(object[name], names, element)
        }
        object.set('dirty', true)
    }

    updateForms = (object: fabric.FabricObject) => {
        for (let i = 0; i < this.forms.length; i++) {
            const element = this.forms[i]
            if ((element instanceof HTMLInputElement) || (element instanceof HTMLSelectElement)) {
                const names = element.name.split('.')
                this.getProperty(object, names, element)
            }
        }
    }
}
    

app.ts

    import * as fabric from 'fabric'
globalThis.fabric = fabric
import { FabricController } from './fabricController'

export class App {
    canvas: fabric.Canvas
    controller: FabricController

    constructor(canvasID: string, form: HTMLFormElement) {
    this.canvas = new fabric.Canvas(canvasID)
    this.controller = new FabricController(this.canvas, form)
    }

    run = () => {
    const newRect = new fabric.Rect({
        left: 50,
        top: 50,
        width: 100,
        height: 50,
        fill: '#0000ff',
        clipPath: new fabric.Circle({
        top: 0,
        left: 0,
        originX: 'center',
        originY: 'center',
        radius: 40
        })
    })
    this.canvas.add(newRect)
    this.canvas.renderAll()

    fabric.util.loadImage('./path4.svg').then(imgel => {
        const fimg = new fabric.FabricImage(imgel, {
        originX: 'center',
        originY: 'center',
        backgroundColor: '#00ff33',
        top: 100,
        left: 300,
        scaleX: 0.5,
        scaleY: 0.5,
        clipPath: new fabric.Rect({
            width: imgel.width,
            height: imgel.height,
            originX: 'center',
            originY: 'center',
            rx: 50,
            ry: 50
        })
        })
        this.canvas.add(fimg)
        this.canvas.renderAll()
    })
    }
}
    

index.ts

import { App } from "./app"

const main = () => {
const app = new App('MainCanvas', document.forms['controller'])
globalThis.app = app
app.run()
}

main()

このページのトップヘ