コンセプト
API の大部分は、少数の決定から導かれています。単体で見ると恣意的に見えるものも、たいていはこのどれかの帰結です。
2つのレイヤー
┌─────────────────────────────────────┐
│ DOM オーバーレイ — 選択枠・ハンドル │
├─────────────────────────────────────┤
│ Canvas — 図形・画像 │
└─────────────────────────────────────┘Canvas は図形だけを描きます。オーバーレイはユーザーが掴めるものすべてを担います。どちらも相手を直接呼びません。エディタの状態が変わり、両者が独立にそこから再描画します。
4つの不変条件
- Canvas に UI を描かない。 選択枠もハンドルも DOM 要素であり、CSS で差し替えられる。
- 非選択の図形にコントロール DOM を作らない。 複数選択でも枠は1個であり、図形ごとに1個ではない。オーバーレイのノード数は表示中のコントロールの関数であって、文書の大きさの関数ではない。支援技術が読む非表示リストはコントロール UI ではないため対象外だが、こちらも表示範囲に仮想化されており有界に保たれる。
- ビューポート変換は1要素に1回だけ適用する。 オーバーレイの子はワールド座標に置かれる。パンは、画面上にコントロールが何個あろうと transform の書き込み1回で済む。
- ハンドルの大きさは CSS で補正する。 ライブラリが書き込む
--hc-zoom変数を使う。要素ごとに JavaScript で補正すると、不変条件3で取り除いたはずの「要素数に比例する処理」が戻ってきてしまう。
自分でコントロールを作る場合、不変条件3と4を守るのはあなたの責任になります。実際の書き方はコントロールを自作するにあります。
座標系
3つの空間があります。
| 空間 | 原点 | 使う場所 |
|---|---|---|
| スクリーン | コンテナ左上、CSS ピクセル | ポインタイベント、hitTest、ヒット許容誤差 |
| ワールド | 文書自身の空間 | 図形の x/y、選択枠、スナップ、オーバーレイの子要素 |
| ローカル | 図形自身の左上(回転前) | ShapeUtil.render、ShapeUtil.hitTest |
Y 軸は下向き、回転はラジアン・時計回りです。ズーム 1.0 でカメラが原点にあるとき、ワールドとスクリーンは一致します。
const world = editor.viewport.screenToWorld({ x: 100, y: 40 })
const screen = editor.viewport.worldToScreen(world)ヒット許容誤差はスクリーンピクセルで指定し(既定 5)、問い合わせのたびにワールド単位へ変換されます。ワールド固定にすると、ズームに応じて見かけの許容誤差が伸縮してしまうためです。
変換モデル
図形は x / y / width / height / rotation を持ちます。スケール成分はありません。
これは見た目以上に重要です。Fabric.js は width と scaleX を併存させており、そこでリサイズすると線幅・角丸・文字が歪むのはそのためです。スケールは「拡大すべきでないもの」まで含めて掛かります。本ライブラリではリサイズが実際の width / height を書き換えるため、2px の線は 2px のまま、24px の文字は 24px のままです。
v1.0 には skew がないため変換は分解された形のまま保持され、行列は必要になった時点で導出されます。結果としてシリアライズされた文書は読める形になり、行列を成分へ逆分解する際の曖昧さも生じません。
const matrix = editor.getWorldTransform(id) // 親の連鎖を含む
const bounds = editor.getShapeBounds(id, 'world') // oriented: 回転を持つ状態・トランザクション・時間
状態は**イミュータブル(構造共有)**です。読み取るとスナップショットが得られ、それが背後で変化することはありません。
const snapshot = editor.getSnapshot()
snapshot.shapes // ReadonlyMap<ShapeId, AnyShape>
snapshot.paintOrder // 全図形を深さ優先の描画順で
snapshot.selectedIds変更の単位は1ユーザー操作 = 1トランザクションです。トランザクションのコミットは1回で、購読者への通知も1回、履歴も1件です。
editor.transact(() => {
editor.createShape({ type: 'rect', x: 0, y: 0, width: 10, height: 10 })
editor.createShape({ type: 'rect', x: 20, y: 0, width: 10, height: 10 })
}) // 通知1回、Undo 1ステップトランザクション内では、先に書いた値をその場で読めます。外からは getSnapshot() が中間状態を見せることはありません。購読者が操作の途中を観測することはできない、ということです。
editor.transact(() => {
const id = editor.createShape({ type: 'rect', x: 0, y: 0, width: 10, height: 10 })
editor.getShape(id) // トランザクション中でも取得できる
})一時状態(ephemeral state)
60fps のドラッグと、毎フレームのイミュータブル木の再構築は両立しません。そこでドラッグは確定状態にまったく触れません。ツールは文書の外に置かれる差分を書き、ポインタを離した時点でコミットします。
editor.setEphemeral(new Map([[id, { x: 120, y: 80 }]])) // 毎フレーム、安い
editor.commitEphemeral() // ポインタアップ時に1回
editor.clearEphemeral() // キャンセル時Undo の粒度が正しくなるのも同じ理由です。画面を横切るドラッグは履歴1件であって、400件ではありません。
一時状態は複数のサブシステムと関わります。ツールを書くなら知っておく価値があります。
| サブシステム | 規則 |
|---|---|
| 空間インデックス | 一時状態の図形は除外し線形に扱う(同時に動くのは高々100個程度) |
| カリング | 一時状態の図形は常に描画する。画面外からドラッグしてきた対象が消えないため |
| ヒットテスト | 一時状態の位置で判定する。見えている場所でドロップ先が決まるため |
| スナップ | 問い合わせ元は一時状態の位置、インデックス側は確定位置 |
| 境界取得 API | 一時状態を反映した値を返す |
editor.getResolvedShape(id) は一時状態を反映した図形を、editor.getShape(id) は確定レコードを返します。
識別子と順序
ID は衝突耐性のある不透明文字列です。 連番整数ではありません。連番はこのエディタインスタンス以外から変更が来た瞬間に衝突しますが、それはまさに外部パッチ経路が存在する理由そのものです。
z-order は fractional index です。常に「あいだ」に新しい値を生成できる文字列キーで、1つ並べ替えても書き換わるのは1フィールドだけです。そして、これを採用した意味が出る操作を API に露出しています。
editor.moveTo([id], { before: otherId })
editor.moveTo([id], { after: otherId })
editor.reorder([id], 'front')レイヤーパネルの行をドラッグして並べ替える操作は before / after アンカーの moveTo です。これを出さなければ、配列インデックスでなく fractional index を選んだ意味は半減します。
階層
図形は parentId で木を構成します。グループ化と解除だけが操作ではありません。実用的なエディタには、ワールド上の見た目を保ったままの再親子化が必要です。
editor.setParent([childId], groupId) // 見た目は動かない
editor.getChildren(parentId) // 描画順
editor.getAncestors(id) // 近い順グループ化も解除も子に変換を焼き込みません。回転したグループを解除しても、子の見た目はそのままです。
出入りするイベント
購読チャネルは意図的に3つに分かれています。
editor.subscribe(() => {}) // 確定状態が変わった
editor.subscribeEphemeral(() => {}) // 進行中の操作が変わった
editor.subscribeNotifications(n => {}) // 回復可能な問題が起きた3つ目があるのは、画像の読み込み失敗も、読み込んだファイル内の未登録シェイプ型も、書き出しの失敗も、呼び出し側のコードのバグではないからです。これらで throw すると、ごく普通の操作を try/catch で囲む羽目になります。登録していないツールへの切り替えなど、プログラミング上の誤りは今も throw します。
購読者がトランザクションの内側から呼ばれることはないため、リスナが書き込み途中の store に再入することはありません。
描画
描画は requestAnimationFrame に合体されます。状態を変えたものはフレームを1つ予約するだけで、同期的に描画されることはありません。エディタと歩調を合わせて自前のオーバーレイを描くには、既定 UI と同じフックを使います。
const stop = editor.onFrame(() => {
const box = editor.controls.getSelectionBox()
// 要素を配置する
})表示範囲外の図形は、ヒットテストと同じ R-Tree でカリングされます。追加コストはほぼゼロで、既定で有効です。editor.getRenderStats() が描画数とスキップ数を返します。