🎨

【React-konva】Rectをホバーした時にFigmaっぽくラベルを表示する

に公開

こんにちは。株式会社 Sally エンジニアの @piesukeです。
私たちは、マーダーミステリーを遊べることが出来るアプリ「ウズ」と、マーダーミステリーを制作してウズ上で遊べることが出来るアプリ「ウズスタジオ」を開発しています。
最近良かったマーダーミステリーは「新世界のユキサキ」です。

今回はかなりニッチですが、React-Konvaを使ってShapeをホバーした時にDevモードのFigmaっぽく表示する実装の解説を行いたいと思います。

前提

  • React-Konvaの基本的な知識

React-Konvaの軽い説明

https://konvajs.org/docs/react/index.html

KonvaというJavascriptでCanvasを描画できるライブラリをReactで使えるようにしたものです。

DevモードのFigmaっぽくとは

こんな感じ。

ポイント

  • ホバーした際にオブジェクトの上部にラベルが表示される
  • ズームイン・ズームアウトによってラベルのサイズが調整される

実装

import { Group, Layer, Rect, Stage, Tag, Text } from "react-konva";
import { useState, useEffect, useMemo, useRef } from "react";
import Konva from "konva";

export default function App() {
  const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
  // 現在のScaleを取得するためにStageのrefを使用
  const stageRef = useRef<Konva.Stage>(null);

  useEffect(() => {
    setDimensions({
      width: window.innerWidth,
      height: window.innerHeight,
    });
  }, []);

  const TEXT = "サンプルRect";

  // 描画に必要な値を定数で指定
  const IMAGE_SIZE = 300;
  const IMAGE_X = dimensions.width / 2 - IMAGE_SIZE / 2;
  const IMAGE_Y = dimensions.height / 2;
  const IMAGE_PADDING = 10;
  const RECT_PADDING = 4;
  const FONT_SIZE = 12;
  const LINE_HEIGHT = 1.2;
  const TEXT_MAX_WIDTH = 100;
  const TAG_Y_PADDING = 10;
  const TAG_HEIGHT = 24;

  // widthを取得するために一時的にKonva.Textを使用
  const textWidth = useMemo(() => {
    const tempText = new Konva.Text({
      text: TEXT,
      fontSize: FONT_SIZE,
      padding: IMAGE_PADDING,
      lineHeight: LINE_HEIGHT,
      x: IMAGE_X + IMAGE_SIZE,
      letterSpacing: 1.2,
      y: 0,
      ellipsis: true,
    });
    const width = tempText.width();
    tempText.destroy();
    return Math.min(width, TEXT_MAX_WIDTH);
  }, []);

  // onWheel時にマウスポインターの位置を中心にScaleの値を変更する
  const onHandleScale = (e: Konva.KonvaEventObject<WheelEvent>) => {
    const stage = e.target.getStage();
    if (!stage) return;
    
    const oldScale = stage.scaleX();
    const pointer = stage.getPointerPosition();
    if (!pointer) return;

    const mousePointTo = {
      x: (pointer.x - stage.x()) / oldScale,
      y: (pointer.y - stage.y()) / oldScale,
    };

    const direction = e.evt.deltaY > 0 ? -1 : 1;
    const scaleBy = 1.05;
    const newScale = direction > 0 ? oldScale * scaleBy : oldScale / scaleBy;

    stage.scale({ x: newScale, y: newScale });

    const newPos = {
      x: pointer.x - mousePointTo.x * newScale,
      y: pointer.y - mousePointTo.y * newScale,
    };
    stage.position(newPos);
  }

  const tagWidth = useMemo(() => textWidth + RECT_PADDING, [textWidth]);

  const [isHover, setIsHover] = useState(false);


  return (
    <Stage
      width={dimensions.width}
      height={dimensions.height}
      ref={stageRef}
      onWheel={(e) => {
        e.evt.preventDefault();
        // ホイール中はラベルを非表示にするというFigmaの仕様を再現
        setIsHover(false);
        
        onHandleScale(e);
      }}
    >
      <Layer>
        <Rect
          x={IMAGE_X}
          y={IMAGE_Y}
          width={IMAGE_SIZE}
          height={IMAGE_SIZE}
          fill="red"
          shadowBlur={10}
          draggable
          onMouseEnter={() => setIsHover(true)}
          onMouseMove={() => {
            setIsHover(true);
          }}
          onMouseLeave={() => setIsHover(false)}
        />
        {isHover && stageRef.current && (
          <Group
            x={IMAGE_X}
            y={IMAGE_Y -TAG_HEIGHT / stageRef.current.scaleY() - TAG_Y_PADDING / stageRef.current.scaleY()}
            // スケールサイズごとにラベルのサイズを調整
            scaleX={1 / stageRef.current.scaleX()}
            scaleY={1 / stageRef.current.scaleY()}
          >
            <Tag
              width={tagWidth}
              height={TAG_HEIGHT}
              fill="#4A46D4"
              cornerRadius={99}
            />
            <Text
              text={TEXT}
              fill={"white"}
              fontSize={FONT_SIZE}
              x={RECT_PADDING / 2}
              y={TAG_HEIGHT / 2}
              offsetY={6}
              align="center"
              width={tagWidth - RECT_PADDING}
            />
          </Group>
        )}
      </Layer>
    </Stage>
  );
}

実際の表示

ホバー時にラベルが表示され、かつ拡大・縮小時にラベルのサイズが動的に変わっているのがわかると思います。

解説

ホバー時のラベル表示機能

ホバーした際にオブジェクトの上部にラベルが表示される機能は、以下の実装で実現されています:

const [isHover, setIsHover] = useState(false);

 // widthを取得するために一時的にKonva.Textを使用
  const textWidth = useMemo(() => {
    const tempText = new Konva.Text({
      text: TEXT,
      fontSize: FONT_SIZE,
      padding: IMAGE_PADDING,
      lineHeight: LINE_HEIGHT,
      x: IMAGE_X + IMAGE_SIZE,
      letterSpacing: 1.2,
      y: 0,
      ellipsis: true,
    });
    const width = tempText.width();
    tempText.destroy();
    return Math.min(width, TEXT_MAX_WIDTH);
  }, []);

// 本体のRect
<Rect
  // ... 他のprops
  onMouseEnter={() => setIsHover(true)}
  onMouseMove={() => {
    setIsHover(true);
  }}
  onMouseLeave={() => setIsHover(false)}
/>
// 描画は主にこの部分
{isHover && stageRef.current && (
  <Group
    x={IMAGE_X}
    y={IMAGE_Y - (TAG_HEIGHT / scale.y) - (TAG_Y_PADDING / scale.y)}
    // ...
  >
    <Tag
      width={tagWidth}
      height={TAG_HEIGHT}
      fill="#4A46D4"
      cornerRadius={99}
    />
    <Text
      text={TEXT}
      fill={"white"}
      fontSize={FONT_SIZE}
      // ...
    />
  </Group>
)}

実装のポイント:

  • isHoverステートでホバー状態を管理
  • onMouseEnteronMouseMoveonMouseLeaveでホバー状態を切り替え
  • 条件付きレンダリング{isHover && ...}でラベルの表示を制御
  • ラベルはGroup内のTag(背景)とText(テキスト)で構成
  • ラベルの位置はy={IMAGE_Y -TAG_HEIGHT / stageRef.current.scaleY() - TAG_Y_PADDING / stageRef.current.scaleY()}でオブジェクトの上部に配置
    • scaleを考慮しないと拡大・縮小した際に位置がズレる。実装時にはここでつまづいた。
  • ラベルの幅を確定するため、一度Konva.Textを作りTextのサイズを計算し、適切な値をTagのwidthに設定している

ズーム対応のラベルサイズ調整機能

ズームイン・ズームアウトによってラベルのサイズが調整される機能は、以下の実装で実現されています:

const [scale, setScale] = useState({ x: 1, y: 1 });

// ズーム時のスケール更新
const onHandleScale = (e: Konva.KonvaEventObject<WheelEvent>) => {
  // ... ズーム処理
  const newScale = direction > 0 ? oldScale * scaleBy : oldScale / scaleBy;
  stage.scale({ x: newScale, y: newScale });
  setScale({ x: newScale, y: newScale });
}

<Group
  x={IMAGE_X}
  y={IMAGE_Y - (TAG_HEIGHT / scale.y) - (TAG_Y_PADDING / scale.y)}
  // スケールサイズごとにラベルのサイズを調整
  scaleX={1 / scale.x}
  scaleY={1 / scale.y}
>

実装のポイント:

  • scaleステートで現在のズーム倍率を管理
  • onHandleScale関数でホイールイベントからズーム処理を実行し、scaleを更新
    • scaleの更新は stage.scale({ x: newScale, y: newScale });で行っている。こちらを呼び出さないと stageRef.current.scaleX の値が更新されない。
  • ラベルのGroupscaleX={1 / stageRef.current.scaleX()}scaleY={1 / stageRef.current.scaleY()}を設定
    • これにより、キャンバスがズームしてもラベルは常に一定サイズを保持
  • ラベルのY座標も Groupy={IMAGE_Y - (TAG_HEIGHT / scale.y) - (TAG_Y_PADDING / scale.y)}でスケールに応じて調整され、正確な位置に表示

また、Groupのy座標もScaleが考慮されてない場合、以下のように拡大・縮小したときにラベルがずれてしまいます。

まとめ

React-KonvaはCanvasの実装を宣言的に書けるので重宝しています。
使いこなすことによってはFigmaのような難易度が高そうなプロダクトも(見た目上は)作れるかもしれないのでもっと学んでいきたいです。

GitHubで編集を提案
UZU テックブログ

Discussion