Zenn
✏️

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

2025/02/17に公開

はじめに

こんにちは!PortalKeyの渋谷です。
今回は前回作成したホワイトボードに付箋の実装をしていきます。
完成イメージはこちらです!

付箋実装を行う前に…

前回実装した物をそのまま拡張すると1ファイルのコード量がとんでもないことになるので一旦各機能毎にクラス分けしようと思います。

whiteboard.ts
import { useCallback, useEffect, useRef } from "react"
import { ViewManager } from "./viewManager"
import { MarkerManager } from "./markerManager"

export const Whiteboard = () => {
  const width = 960
  const height = 540

  const draw = useCallback(() => {
    viewManagerRef.current.draw()
    markerManagerRef.current.draw()
    stickyNoteManagerRef.current.draw()
  }, [])

  const canvasRef = useRef<HTMLCanvasElement>(null)
  const textAreaRootRef = useRef<HTMLDivElement>(null)
  const viewManagerRef = useRef(new ViewManager(canvasRef))
  const markerManagerRef = useRef(new MarkerManager(viewManagerRef.current))

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

    const mouseDown = (e: MouseEvent) => {
      e.preventDefault()
      viewManagerRef.current.onMouseDown(e)
      markerManagerRef.current.onMouseDown(e)
      draw()
    }

    const mouseMove = (e: MouseEvent) => {
      e.preventDefault()
      viewManagerRef.current.onMouseMove(e)
      markerManagerRef.current.onMouseMove(e)
      draw()
    }

    const mouseUp = (e: MouseEvent) => {
      e.preventDefault()
      viewManagerRef.current.onMouseUp(e)
      markerManagerRef.current.onMouseUp(e)
      draw()
    }

    const mouseWheel = (e: WheelEvent) => {
      e.preventDefault()
      viewManagerRef.current.onMouseWheel(e)
      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)
    }
  }, [draw])

  return (
    <div className={`relative w-[${width}px] h-[${height}px]`}>
      <canvas width={width} height={height} ref={canvasRef} />
      <div className="absolute top-0 left-0 w-full h-full pointer-events-none" ref={textAreaRootRef} />
    </div>
  )
}
viewManager.ts
export class ViewManager {
  private canvasRef: React.RefObject<HTMLCanvasElement>
  private prevLocation: { x: number; y: number } | null = null
  private location: { x: number; y: number } = { x: 0.5, y: 0.5 }
  #scale: number = 0.5

  constructor(canvasRef: React.RefObject<HTMLCanvasElement>) {
    this.canvasRef = canvasRef
  }

  onMouseDown(e: MouseEvent) {
    if (e.button !== 2) {
      return
    }

    if (this.canvas == null) {
      return
    }

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

    this.prevLocation = { x, y }
  }

  onMouseUp(e: MouseEvent) {
    if (e.button !== 2) {
      return
    }

    this.prevLocation = null
  }

  onMouseMove(e: MouseEvent) {
    if (this.prevLocation == null) {
      return
    }

    if (this.canvas == null) {
      return
    }

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

    const dx = x - this.prevLocation.x
    const dy = y - this.prevLocation.y

    this.prevLocation = { x, y }
    this.location = {
      x: Math.min(1, Math.max(0, this.location.x + dx / this.width)),
      y: Math.min(1, Math.max(0, this.location.y + dy / this.height))
    }
  }

  onMouseWheel(e: WheelEvent) {
    this.#scale = Math.min(5.0, Math.max(0.1, this.#scale + e.deltaY * -0.001))
  }

  draw() {
    if (this.canvas == null) {
      return
    }

    const ctx = this.ctx
    if (ctx == null) {
      return
    }

    ctx.clearRect(0, 0, this.width, this.height)
    ctx.fillStyle = "black"
    ctx.fillRect(0, 0, this.width, this.height)

    ctx.fillStyle = "white"
    ctx.fillRect(this.leftTop.left, this.leftTop.top, this.currentWidth, this.currentHeight)
  }

  get leftTop() {
    if (this.canvas == null) {
      return { left: 0, top: 0 }
    }

    const vec = { x: (this.location.x - 0.5) * this.canvas.width, y: (this.location.y - 0.5) * this.canvas.height }
    return { left: -this.currentWidth / 2 + this.canvas.width / 2 + vec.x, top: -this.currentHeight / 2 + this.canvas.height / 2 + vec.y }
  }

  get canvas() {
    return this.canvasRef.current
  }

  get ctx() {
    return this.canvas?.getContext("2d") ?? null
  }

  get scale() {
    return this.#scale
  }

  get width() {
    return this.canvas?.width ?? 960
  }

  get height() {
    return this.canvas?.height ?? 540
  }

  get currentWidth() {
    return this.width * this.scale
  }

  get currentHeight() {
    return this.height * this.scale
  }
}
markerManager.ts
import { ViewManager } from "./viewManager"

export class MarkerManager {
  private viewManager: ViewManager
  private isWriting = false
  private index = 0
  private pathsList: { x: number; y: number }[][] = []

  constructor(viewManager: ViewManager) {
    this.viewManager = viewManager
  }

  onMouseDown(e: MouseEvent) {
    if (e.button !== 0) {
      return
    }

    if (this.canvas == null) {
      return
    }

    this.isWriting = true

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

    this.addPath({ x, y })
  }

  onMouseUp(e: MouseEvent) {
    if (e.button !== 0) {
      return
    }

    this.isWriting = false
    this.index++
  }

  onMouseMove(e: MouseEvent) {
    if (!this.isWriting) {
      return
    }

    if (this.canvas == null) {
      return
    }

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

    this.addPath({ x, y })
  }

  draw() {
    if (this.ctx == null) {
      return
    }

    const leftTop = this.viewManager.leftTop
    const scale = this.viewManager.scale

    this.ctx.lineCap = "round"
    this.ctx.strokeStyle = "black"
    this.ctx.lineWidth = 2 * scale
    for (const paths of this.pathsList) {
      this.ctx.beginPath()
      for (let index = 1; index < paths.length; index++) {
        const prev = paths[index - 1]!
        const current = paths[index]!
        this.ctx.moveTo(leftTop.left + prev.x * scale, leftTop.top + prev.y * scale)
        this.ctx.lineTo(leftTop.left + current.x * scale, leftTop.top + current.y * scale)
      }
      this.ctx.stroke()
    }
  }

  private addPath(path: { x: number; y: number }) {
    if (this.pathsList[this.index] == null) {
      this.pathsList[this.index] = []
    }

    const whiteboardLeftTop = this.viewManager.leftTop
    const localPath = { x: (path.x - whiteboardLeftTop.left) / this.viewManager.scale, y: (path.y - whiteboardLeftTop.top) / this.viewManager.scale }
    this.pathsList[this.index]!.push(localPath)
  }

  private get canvas() {
    return this.viewManager.canvas
  }

  private get ctx() {
    return this.viewManager.ctx
  }
}

付箋の実装方法解説

コードを書く前にどのようにテキスト入力を実装するかを解説します。
canvasの機能としてテキストエリアが用意されていないため、textarea要素を用いて実装を行います。

今回の実装では以下の手順で行っています。

  1. 付箋を作成する操作をユーザーが行う
  2. 付箋を画面中央になる場所にホワイトボード上で移動し固定
  3. ズーム状態をリセット、スケール1倍に固定
  4. 付箋の枠をcanvas上にレンダリング
  5. その上に重ねる形でtextareaを生成
  6. 入力をtextareaで行う
  7. 入力が完了したらtextareaからtextを取得しtextareaを削除
  8. canvas上の付箋の上にcanvasのfillTextを用いてtextをレンダリング

という流れです。

コード解説

viewManager拡張

まずviewManagerに指定した位置まで移動させてズームを指定できる関数を用意します。

viewManager.ts
  attention({ location, scale }: { location: { x: number; y: number }; scale: number }) {
    this.#scale = scale

    this.location = {
      x: 1 - (location.x + this.currentWidth / 2) / (this.currentWidth * 2),
      y: 1 - (location.y + this.currentHeight / 2) / (this.currentHeight * 2)
    }
  }

createTextArea(textArea要素を作成する関数)を追加

次にtextAreaを作成し、入力後のテキストを返す部分を見ていきます。
textareaの自動改行を手動で検知してテキストを返すようにしています。

createTextArea.tsx
export interface WhiteboardTextareaProps {
  location: { x: number; y: number }
  size: { width: number; height: number }
  text: string
  font: {
    color: string
    size: number
    family: string
    lineHeight: number
  }
  onChange?: (text: string) => void
  onFinish?: (text: string) => void
  rootElement: HTMLDivElement
}

export const createTextArea = ({ location, size, text, font, onChange, onFinish, rootElement }: WhiteboardTextareaProps) => {
  // RootElementのpointerEventsを有効にし、Canvasのイベントを実質無効化
  rootElement.style.pointerEvents = "auto"

  const textArea = document.createElement("textarea")
  // スペルチェックは改行チェックの邪魔になってしまうので無効化
  textArea.spellcheck = false
  textArea.style.position = "absolute"
  textArea.style.left = `${location.x}px`
  textArea.style.top = `${location.y}px`
  textArea.style.width = `${size.width}px`
  textArea.style.height = `${size.height}px`
  textArea.value = text
  textArea.style.display = "block"
  // 背景は透明に
  textArea.style.backgroundColor = "transparent"
  textArea.style.border = "none"
  textArea.style.resize = "none"
  textArea.style.outline = "none"
  textArea.style.overflow = "hidden"
  textArea.style.pointerEvents = "auto"
  textArea.style.padding = "0"
  textArea.style.font = `${font.size}px ${font.family}`
  textArea.style.lineHeight = `${font.lineHeight}px`
  rootElement.appendChild(textArea)
  textArea.focus()

  // 追加前と追加後の高さを比較して、はみ出すかどうかを判定
  const calcTextArea = textArea.cloneNode() as HTMLTextAreaElement
  calcTextArea.hidden = true
  calcTextArea.style.height = "1px"
  rootElement.appendChild(calcTextArea)
  const isOverWidth = (prevText: string, newText: string) => {
    calcTextArea.value = prevText
    const prevHeight = calcTextArea.scrollHeight
    calcTextArea.value = newText
    return calcTextArea.scrollHeight > prevHeight
  }

  const convertText = (text: string) => {
    const maxLineCount = Math.floor(size.height / font.lineHeight)
    const newLines: string[] = []

    // 行を分割
    const lines = text.replace(/\r\n|\r/g, "\n").split("\n")
    for (const line of lines) {
      let currentLine = ""

      // スペースで分割、全角文字は1文字ずつ分割
      const words = line.split(/(\s+|[^ -~-])/)
      for (const word of words) {
        // 自動折り返ししなければ追加して次へ
        if (!isOverWidth(currentLine, `${currentLine}${word}`)) {
          currentLine += word
          continue
        }

        // 自動折り返し後この単語が入り切るなら改行して追加して次へ
        if (!isOverWidth("", word) && newLines.length < maxLineCount - 1) {
          newLines.push(currentLine)
          currentLine = word
          continue
        }

        // 自動折り返しでは入り切らない場合、1文字ずつ追加していく
        const chars = word.split("")
        for (const char of chars) {
          if (!isOverWidth(currentLine, `${currentLine}${char}`)) {
            currentLine += char
            continue
          }

          newLines.push(currentLine)
          currentLine = char
        }
      }

      newLines.push(currentLine)
    }

    return { text: newLines.slice(0, maxLineCount).join("\n"), isOver: newLines.length > maxLineCount }
  }

  const input = () => {
    // はみ出したらテキストを修正
    const { text, isOver } = convertText(textArea.value)
    if (isOver) {
      textArea.value = text
    }

    if (onChange != null) {
      onChange(textArea.value)
    }
  }

  // Escapeキーが押されたら終了
  const keyDown = (e: KeyboardEvent) => {
    if (e.key === "Escape") {
      finish()
    }
  }

  // フォーカスが外れたら終了
  const blur = () => {
    finish()
  }

  // 入力前に入力内容をチェック
  const beforeInput = (e: InputEvent) => {
    let newText = e.data

    // 改行はe.dataに入ってこないので追加
    if (e.inputType === "insertLineBreak") {
      newText = "\n"
    }
    if (newText == null) {
      return
    }

    // 既存のテキストと新しいテキストを結合して、はみ出すならinputを呼ばせない
    if (convertText(textArea.value + newText).isOver) {
      e.preventDefault()
    }
  }

  let isDestroyed = false
  const destroy = () => {
    if (isDestroyed) {
      return
    }
    isDestroyed = true

    textArea.removeEventListener("input", input)
    textArea.removeEventListener("keydown", keyDown)
    textArea.removeEventListener("blur", blur)
    textArea.removeEventListener("beforeinput", beforeInput)

    textArea.remove()
    calcTextArea.remove()

    rootElement.style.pointerEvents = "none"
  }

  const finish = () => {
    if (isDestroyed) {
      return
    }

    if (onFinish != null) {
      onFinish(convertText(textArea.value).text)
    }

    destroy()
  }

  textArea.addEventListener("input", input)
  textArea.addEventListener("keydown", keyDown)
  textArea.addEventListener("blur", blur)
  textArea.addEventListener("beforeinput", beforeInput)

  // 戻り値で破棄を渡しているので、外部から破棄をしたくなった時も安心
  return { destroy }
}

StickyNoteManager追加

今回のメインである付箋のマネージャーを作ります。
fillTextを行う際フォントサイズをいじることでサイズを変更しようとしたところ、1px以下の値を設定することになるためうまく動きませんでした…
そのため、scaleを行い、fillTextが終わった後再度scaleで戻すという対応を入れています。
これによりどんなスケーリングにも耐えられるようになります。

stickyNoteManager.ts
import { ViewManager } from "./viewManager"
import { createTextArea } from "./createTextArea"

interface StickyNote {
  left: number
  top: number
  width: number
  height: number
  text: string
}

const STICKY_NOTE_WIDTH = 200
const STICKY_NOTE_HEIGHT = 200
const STICKY_NOTE_PADDING = 4
const STICKY_NOTE_FONT_COLOR = "black"
const STICKY_NOTE_FONT_SIZE = 16
const STICKY_NOTE_FONT_FAMILY = "Arial"
const STICKY_NOTE_FONT_LINE_HEIGHT = 24

export class StickyNoteManager {
  private viewManager: ViewManager
  private textAreaRootRef: React.RefObject<HTMLDivElement>
  private drawingStickyNote: StickyNote | null = null
  private stickyNotes: StickyNote[] = []
  private onCreated: () => void

  constructor(viewManager: ViewManager, textAreaRootRef: React.RefObject<HTMLDivElement>, onCreated: () => void) {
    this.viewManager = viewManager
    this.textAreaRootRef = textAreaRootRef
    this.onCreated = onCreated
  }

  onMouseDown(e: MouseEvent) {
    // 条件は中クリック
    if (e.button !== 1) {
      return
    }

    if (this.canvas == null) {
      return
    }

    if (this.textAreaRootRef.current == null) {
      return
    }

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

    const dx = (x - this.viewManager.leftTop.left) / this.viewManager.scale
    const dy = (y - this.viewManager.leftTop.top) / this.viewManager.scale

    // 描画中の付箋として保持
    this.drawingStickyNote = { left: dx, top: dy, width: STICKY_NOTE_WIDTH, height: STICKY_NOTE_HEIGHT, text: "" }
    // viewManagerの位置とスケールを固定
    this.viewManager.attention({ location: { x: dx + STICKY_NOTE_WIDTH / 2, y: dy + STICKY_NOTE_HEIGHT / 2 }, scale: 1 })

    // テキストエリアを作成
    createTextArea({
      location: {
        x: this.viewManager.leftTop.left + dx + STICKY_NOTE_PADDING,
        y: this.viewManager.leftTop.top + dy + STICKY_NOTE_PADDING
      },
      size: { width: STICKY_NOTE_WIDTH - STICKY_NOTE_PADDING * 2, height: STICKY_NOTE_HEIGHT - STICKY_NOTE_PADDING * 2 },
      text: "",
      font: { color: STICKY_NOTE_FONT_COLOR, size: STICKY_NOTE_FONT_SIZE, family: STICKY_NOTE_FONT_FAMILY, lineHeight: STICKY_NOTE_FONT_LINE_HEIGHT },
      rootElement: this.textAreaRootRef.current,
      onFinish: (text) => {
        // 入力が完了した時の処理
        if (this.drawingStickyNote != null) {
          this.stickyNotes.push({ ...this.drawingStickyNote, text })
          this.drawingStickyNote = null
          this.onCreated()
        }
      }
    })
  }

  draw() {
    if (this.ctx == null) {
      return
    }

    for (const stickyNote of this.stickyNotes) {
      this.drawStickyNote(stickyNote)
    }

    if (this.drawingStickyNote != null) {
      this.drawStickyNote(this.drawingStickyNote)
    }
  }

  private drawStickyNote(stickyNote: StickyNote) {
    if (this.ctx == null) {
      return
    }

    const leftTop = this.viewManager.leftTop
    const scale = this.viewManager.scale

    const localX = leftTop.left + stickyNote.left * scale
    const localY = leftTop.top + stickyNote.top * scale

    this.ctx.fillStyle = "lightgreen"
    this.ctx.fillRect(localX, localY, stickyNote.width * scale, stickyNote.height * scale)

    const fontPadding = (STICKY_NOTE_FONT_LINE_HEIGHT - STICKY_NOTE_FONT_SIZE) * scale

    this.ctx.textBaseline = "top"
    this.ctx.fillStyle = STICKY_NOTE_FONT_COLOR
    this.ctx.font = `${STICKY_NOTE_FONT_SIZE}px ${STICKY_NOTE_FONT_FAMILY}`

    // フォントサイズでスケーリングできないのでscaleを用いる
    this.ctx.scale(scale, scale)
    const padding = STICKY_NOTE_PADDING * scale
    const textX = (localX + padding) / scale
    const lines = stickyNote.text.split("\n")
    for (let i = 0; i < lines.length; i++) {
      const line = lines[i]!
      const textY = (localY + padding + fontPadding + STICKY_NOTE_FONT_LINE_HEIGHT * i * scale) / scale
      this.ctx.fillText(line, textX, textY)
    }
    // scaleを用いてもとに戻す
    this.ctx.scale(1 / scale, 1 / scale)
  }

  private get canvas() {
    return this.viewManager.canvas
  }

  private get ctx() {
    return this.viewManager.ctx
  }
}

whiteboard.tsxの拡張

最後にwhiteboard.tsxでcanvasの上に重なる形でtextAreaのRootとなるdivを配置します。
このtextAreaRootRefをstickyNoteManagerのコンストラクタに渡して完了となります。

whiteboard.tsx
  return (
    <div className={`relative w-[${width}px] h-[${height}px]`}>
      <canvas width={width} height={height} ref={canvasRef} />
      <div className="absolute top-0 left-0 w-full h-full pointer-events-none" ref={textAreaRootRef} />
    </div>
  )

最後に

いかがでしたでしょうか?
これでcanvasでテキストの入力ができるようになったはずです。
入力後のテキストの変換方法はもっといい方法があると思うので現在模索中です…
textareaの自動改行を用いずに実装側でカバーできるようにすればもっと楽に実装ができるかもしれません。

ホワイトボードのようなツールは拘れば拘るほどより良い物になっていくのですが、時間は有限なのでどこまで実装できるか判断できるかが試されるため面白いです。
他の機能や、実装の改善等が行えた際、第3弾を書きたいと思います!ではまた!

PortalKey Tech Blog

Discussion

ログインするとコメントできます