Canvasでズーム&描画!インタラクティブなホワイトボードを構築 (2)
はじめに
こんにちは!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倍に固定
- 付箋の枠をcanvas上にレンダリング
- その上に重ねる形でtextareaを生成
- 入力をtextareaで行う
- 入力が完了したらtextareaからtextを取得しtextareaを削除
- canvas上の付箋の上にcanvasのfillTextを用いてtextをレンダリング
という流れです。
コード解説
viewManager拡張
まずviewManagerに指定した位置まで移動させてズームを指定できる関数を用意します。
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のコンストラクタに渡して完了となります。
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弾を書きたいと思います!ではまた!
Discussion