✏️

Canvasでズーム&描画!インタラクティブなホワイトボードを構築 (1)

2025/01/24に公開

はじめに

こんにちは!PortalKeyの渋谷です。

今回はプロジェクトでホワイトボードが必要になったため、見様見真似で作った結果結構苦しんだ過程と共に、実装方法を紹介していきます。
(Canvasをズームした時にガビガビになってる人の助けにもなるかもしれません。)

今回は第一弾!
必要最低限の操作と、マーカーで書いて遊べるようにします。
完成イメージはこちらです。

開発環境

  • TypeScript v5.5.4
  • react v18.3.1
  • tailwindcss v3.4.10

※ HTML5の<canvas>が使えれば、同じ環境でなくても問題ありません!

実装要件

  • マーカー
    自由に描画ができるペンツールのような機能を実現したい。
  • ズームイン・ズームアウト
    拡大縮小機能は、キャンバス操作には必須。スムーズに操作できるように実現する。
  • キャンバス上での移動
    拡大・縮小した状態での全体移動も対応。マウスやタッチ操作に対応する必要がある。
今回の記事で詳細に触れない追加機能について
  • 通信機能
    誰かと描画内容をシェア!一緒に同じホワイトボードを書きたい。
  • 付箋機能
    キーボードで入力したテキストを付箋としてキャンバス上に配置できるようにしたい。
  • 保存形式
    シンプルな形式でデータを保存したい。
    画像として保存するのではなく、以下のようなデータ構造を利用する:
    • マーカー: Point[](例: { x: number, y: number } の配列)
    • 付箋: Location(例: { x: number, y: number, text: string }
  • 選択
    生成済みのマーカーや付箋を選択後以下の操作をしたい。
    • 削除
    • 編集
    • 移動
    • 拡大縮小
    • 回転
  • 矩形選択
    まとめて選択したい。
  • アンドゥ・リドゥ
    操作の巻き戻し、先送りがしたい。

失敗案:Canvasを移動/拡縮

忙しい人向けにリンクを貼っておきます。詳細な解決方法はこちらからどうぞ!
(改善案:Canvas1つで全てを描画するアプローチ)

とりあえずキャンバスにマーカーを書けるようにして、キャンバス自体を移動/拡縮できるようにします。
Canvasの書き込み処理は色々用意されているので意外と簡単です!

export const Whiteboard = () => {
  const divRef = useRef<HTMLDivElement>(null)
  const canvasRef = useRef<HTMLCanvasElement>(null)

  const scaleRef = useRef(1.0)

  useLayoutEffect(() => {
    const div = divRef.current
    if (div == null) {
      return
    }

    const canvas = canvasRef.current
    if (canvas == null) {
      return
    }

    const ctx = canvas.getContext("2d")
    if (ctx == null) {
      return
    }

    // ホワイトボードの真ん中をデフォルト値に設定
    div.scrollLeft = (div.scrollWidth - div.clientWidth) / 2
    div.scrollTop = (div.scrollHeight - div.clientHeight) / 2

    canvas.style.scale = scaleRef.current.toString()

    // キャンバスを真っ白に塗りつぶす
    ctx.fillStyle = "white"
    ctx.fillRect(0, 0, 1920, 1080)
  }, [])

  useEffect(() => {
    const div = divRef.current
    if (div == null) {
      return
    }

    const canvas = canvasRef.current
    if (canvas == null) {
      return
    }

    const ctx = canvas.getContext("2d")
    if (ctx == null) {
      return
    }

    let prevLocation: { x: number; y: number } | null = null
    const mouseDown = (e: MouseEvent) => {
      const canvasRect = canvas.getBoundingClientRect()
      const x = (e.clientX - canvasRect.left) / scaleRef.current
      const y = (e.clientY - canvasRect.top) / scaleRef.current
      prevLocation = { x, y }
    }

    const mouseMove = (e: MouseEvent) => {
      if (prevLocation == null) {
        return
      }

      const canvasRect = canvas.getBoundingClientRect()
      const x = (e.clientX - canvasRect.left) / scaleRef.current
      const y = (e.clientY - canvasRect.top) / scaleRef.current

      // キャンバスに線を引く
      ctx.beginPath()
      ctx.moveTo(prevLocation.x, prevLocation.y)
      ctx.lineTo(x, y)
      ctx.stroke()

      prevLocation = { x, y }
    }

    const mouseUp = () => {
      prevLocation = null
    }

    const mouseWheel = (e: WheelEvent) => {
      // ホイールでスクロール操作が起きないようにする
      e.preventDefault()

      // キャンバスの拡大縮小
      scaleRef.current = Math.min(2.0, Math.max(0.1, scaleRef.current + e.deltaY * -0.001))
      canvas.style.scale = scaleRef.current.toString()
    }

    div.addEventListener("mousedown", mouseDown)
    div.addEventListener("mousemove", mouseMove)
    div.addEventListener("mouseup", mouseUp)
    div.addEventListener("wheel", mouseWheel)

    return () => {
      div.removeEventListener("mousedown", mouseDown)
      div.removeEventListener("mousemove", mouseMove)
      div.removeEventListener("mouseup", mouseUp)
      div.removeEventListener("wheel", mouseWheel)
    }
  }, [])

  return (
    <div className="w-[960px] h-[540px] overflow-x-scroll overflow-y-scroll bg-gray-800" ref={divRef}>
      <canvas width={960} height={540} ref={canvasRef} />
    </div>
  )
}


こんな感じになりました。あれ…?意外とよさそう?

ん゛…拡縮すると…

キャンバスの解像度が固定なので拡縮に耐えられません。
これは、canvasのサイズそのものを変えるのではなく、スタイルで拡縮しているため、描画解像度が落ちてしまうからです。
ガビガビです…こんなホワイトボードじゃダメですよね…。

改善案:Canvas1つで全てを行う

先程はCanvasにホワイトボード全体のみを描画していましたが、今回はその外側も描画します。
この方法では、仮想ホワイトボードの領域を内部的に管理し、実際のCanvasに必要な部分だけ描画することで、解像度の問題を解消できます。

このアプローチのメリット:

  • 解像度の問題を解消できる
  • 大きなホワイトボードを扱ってもパフォーマンスが安定
  • 将来的な拡張性が高い
export const Whiteboard = () => {
  const width = 960
  const height = 540

  const canvasRef = useRef<HTMLCanvasElement>(null)
  const locationRef = useRef({ x: 0.5, y: 0.5 }) // 中央位置を基準
  const scaleRef = useRef(0.5) // 初期拡縮率

  useEffect(() => {
    const canvas = canvasRef.current
    if (canvas == null) {
      return
    }

    const ctx = canvas.getContext("2d")
    if (ctx == null) {
      return
    }

    const getWhiteboardLeftTop = (currentWidth: number, currentHeight: number) => {
      // 画面の移動量
      const vec = { x: (locationRef.current.x - 0.5) * width, y: (locationRef.current.y - 0.5) * height }

      return { left: -currentWidth / 2 + width / 2 + vec.x, top: -currentHeight / 2 + height / 2 + vec.y }
    }

    const pathsList: { x: number; y: number }[][] = []
    const draw = () => {
      // ホワイトボードの座標を求める
      const currentWidth = width * scaleRef.current
      const currentHeight = height * scaleRef.current
      const whiteboardLeftTop = getWhiteboardLeftTop(currentWidth, currentHeight)

      // 一旦全部黒く塗りつぶす
      ctx.clearRect(0, 0, canvas.width, canvas.height)
      ctx.fillStyle = "black"
      ctx.fillRect(0, 0, width, height)

      // ホワイトボードを描画
      ctx.fillStyle = "white"
      ctx.fillRect(whiteboardLeftTop.left, whiteboardLeftTop.top, currentWidth, currentHeight)

      // 保存されたマーカーを描画
      ctx.lineCap = "round"
      ctx.strokeStyle = "black"
      ctx.lineWidth = 2 * scaleRef.current
      for (const paths of pathsList) {
        ctx.beginPath()
        for (let index = 1; index < paths.length; index++) {
          // ホワイトボード上の座標なのでホワイトボード座標に足す
          const prev = paths[index - 1]!
          const current = paths[index]!
          ctx.moveTo(whiteboardLeftTop.left + prev.x * scaleRef.current, whiteboardLeftTop.top + prev.y * scaleRef.current)
          ctx.lineTo(whiteboardLeftTop.left + current.x * scaleRef.current, whiteboardLeftTop.top + current.y * scaleRef.current)
        }
        ctx.stroke()
      }
    }

    draw()

    const addPath = (index: number, path: { x: number; y: number }) => {
      if (pathsList[index] == null) {
        pathsList[index] = []
      }

      const currentWidth = width * scaleRef.current
      const currentHeight = height * scaleRef.current
      const whiteboardLeftTop = getWhiteboardLeftTop(currentWidth, currentHeight)

      // ホワイトボード上の座標に変換
      const localPath = { x: (path.x - whiteboardLeftTop.left) / scaleRef.current, y: (path.y - whiteboardLeftTop.top) / scaleRef.current }
      pathsList[index].push(localPath)

      draw()
    }

    let index = 0
    let prevLocationLeft: { x: number; y: number } | null = null
    let prevLocationRight: { x: number; y: number } | null = null
    const mouseDown = (e: MouseEvent) => {
      const canvasRect = canvas.getBoundingClientRect()
      const x = e.clientX - canvasRect.left
      const y = e.clientY - canvasRect.top

      if (e.button === 0) {
        // 左クリックではマーカー座標を求める
        prevLocationLeft = { x, y }
        addPath(index, { x, y })
      } else if (e.button === 2) {
        // 右クリックではホワイトボードの移動を開始
        prevLocationRight = { x, y }
      }
    }

    const mouseMove = (e: MouseEvent) => {
      const canvasRect = canvas.getBoundingClientRect()
      const x = e.clientX - canvasRect.left
      const y = e.clientY - canvasRect.top

      if (prevLocationLeft != null) {
        // 左クリック中ならマーカーの座標を求める
        prevLocationLeft = { x, y }
        addPath(index, { x, y })
      }

      if (prevLocationRight != null) {
        // 右クリック中ならホワイトボードの移動
        const dx = x - prevLocationRight.x
        const dy = y - prevLocationRight.y
        locationRef.current = {
          x: Math.min(1, Math.max(0, locationRef.current.x + dx / width)),
          y: Math.min(1, Math.max(0, locationRef.current.y + dy / height))
        }
        prevLocationRight = { x, y }
        draw()
      }
    }

    const mouseUp = (e: MouseEvent) => {
      if (e.button === 0) {
        // 左クリックを離したらマーカーの描画を終了
        prevLocationLeft = null
        index++
      } else if (e.button === 2) {
        // 右クリックを離したらホワイトボードの移動を終了
        prevLocationRight = null
      }
    }

    const mouseWheel = (e: WheelEvent) => {
      // ホイールでスクロール操作が起きないようにする
      e.preventDefault()

      // キャンバスの拡大縮小
      scaleRef.current = Math.min(5.0, Math.max(0.1, scaleRef.current + e.deltaY * -0.001))
      draw()
    }

    canvas.addEventListener("mousedown", mouseDown)
    canvas.addEventListener("mousemove", mouseMove)
    canvas.addEventListener("mouseup", mouseUp)
    canvas.addEventListener("wheel", mouseWheel)

    return () => {
      canvas.removeEventListener("mousedown", mouseDown)
      canvas.removeEventListener("mousemove", mouseMove)
      canvas.removeEventListener("mouseup", mouseUp)
      canvas.removeEventListener("wheel", mouseWheel)
    }
  }, [])

  return <canvas className={`w-[${width}px] h-[${height}px]`} width={width} height={height} ref={canvasRef} />
}


できました!



拡縮にも耐えられます。
pathslistの値をうまいこと用いれば保存や通信も行えそうです。
canvasのサイズもelementのサイズに依存するので、4Kのホワイトボードを用意してもメモリを食らう心配もありません…!

最後に

ホワイトボード制作の基礎部分の解説だけになってしまいましたが、いかがでしたでしょうか?
今回の改善案をベースにすれば、さまざまな機能を拡張していけると思います。

この仕組みを基に、付箋なども実装可能です。
次回の記事では、付箋実装時にテキストエリアによる入力をどのように行ったかの解説を予定しています。

ぜひ今回の内容を参考に、あなたもインタラクティブなホワイトボードライフを送ってみてください!ではまた!

PortalKey Tech Blog

Discussion