目的

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の不具合らしい。

ファイル一式

プロジェクトのファイル一式はこちら。 テストサーバーの起動方法は


npm i
npm run start