🤖

react-konvaでドラッグ時にシェイプの位置をスナップする方法

2023/03/21に公開

はじめに

まずは、見てもらったほうがはやいと思うので、こちらを御覧ください。

このように、オブジェクトとオブジェクトをきれいに整列させたいという時に使います。
パワポなどではおなじみの機能ですね!

前提

  • React.js
  • TypeScript
  • react-konva
  • Konva

スナップとは?

スナップとは、オブジェクトを定められた位置に正確に移動させることができる機能のことです。例えば、線やグリッド上にオブジェクトを移動させる場合、スナップを使用することで正確な位置にオブジェクトを配置することができます。

この機能は、オブジェクトを正確に位置決めする必要がある場合に特に有用です。Konvaにおいて、スナップを実現するためには、ドラッグ操作中にマウスカーソルの位置を定期的に計算し、指定したグリッドや線に対して最も近い位置にオブジェクトを移動させるようにプログラムする必要があります。

ソースコード

import Konva from 'konva';
import React from 'react';
import { Stage, Layer } from 'react-konva';

const GUIDELINE_OFFSET = 5;

type Snap = 'start' | 'center' | 'end';
type SnappingEdges = {
  vertical: Array<{
    guide: number;
    offset: number;
    snap: Snap;
  }>;
  horizontal: Array<{
    guide: number;
    offset: number;
    snap: Snap;
  }>;
};

export const Demo: React.FC = () => {
  const stageRef = React.useRef<Konva.Stage>(null);
  const layerRef = React.useRef<Konva.Layer>(null);

  const getLineGuideStops = (skipShape: Konva.Shape) => {
    const stage = skipShape.getStage();
    if (!stage) return { vertical: [], horizontal: [] };

    // Stageの境界や中央にスナップできます
    const vertical = [0, stage.width() / 2, stage.width()];
    const horizontal = [0, stage.height() / 2, stage.height()];

    // Canvas上の各オブジェクトのエッジや中央にスナップできます
    stage.find('.object').forEach((guideItem) => {
      if (guideItem === skipShape) {
        return;
      }
      const box = guideItem.getClientRect();
      // 図形のすべてのエッジにスナップできます
      vertical.push(box.x, box.x + box.width, box.x + box.width / 2);
      horizontal.push(box.y, box.y + box.height, box.y + box.height / 2);
    });
    return {
      vertical,
      horizontal,
    };
  }

  const getObjectSnappingEdges = React.useCallback((node: Konva.Shape): SnappingEdges => {
    const box = node.getClientRect();
    const absPos = node.absolutePosition();

    return {
      vertical: [
        {
          guide: Math.round(box.x),
          offset: Math.round(absPos.x - box.x),
          snap: 'start',
        },
        {
          guide: Math.round(box.x + box.width / 2),
          offset: Math.round(absPos.x - box.x - box.width / 2),
          snap: 'center',
        },
        {
          guide: Math.round(box.x + box.width),
          offset: Math.round(absPos.x - box.x - box.width),
          snap: 'end',
        },
      ],
      horizontal: [
        {
          guide: Math.round(box.y),
          offset: Math.round(absPos.y - box.y),
          snap: 'start',
        },
        {
          guide: Math.round(box.y + box.height / 2),
          offset: Math.round(absPos.y - box.y - box.height / 2),
          snap: 'center',
        },
        {
          guide: Math.round(box.y + box.height),
          offset: Math.round(absPos.y - box.y - box.height),
          snap: 'end',
        },
      ],
    };
  }, []);

  const getGuides = React.useCallback(
    (lineGuideStops: ReturnType<typeof getLineGuideStops>, itemBounds: ReturnType<typeof getObjectSnappingEdges>) => {
      const resultV: Array<{
        lineGuide: number;
        diff: number;
        snap: Snap;
        offset: number;
      }> = [];

      const resultH: Array<{
        lineGuide: number;
        diff: number;
        snap: Snap;
        offset: number;
      }> = [];

      lineGuideStops.vertical.forEach((lineGuide) => {
        itemBounds.vertical.forEach((itemBound) => {
          const diff = Math.abs(lineGuide - itemBound.guide);
          if (diff < GUIDELINE_OFFSET) {
            resultV.push({
              lineGuide: lineGuide,
              diff: diff,
              snap: itemBound.snap,
              offset: itemBound.offset,
            });
          }
        });
      });

      lineGuideStops.horizontal.forEach((lineGuide) => {
        itemBounds.horizontal.forEach((itemBound) => {
          const diff = Math.abs(lineGuide - itemBound.guide);
          if (diff < GUIDELINE_OFFSET) {
            resultH.push({
              lineGuide: lineGuide,
              diff: diff,
              snap: itemBound.snap,
              offset: itemBound.offset,
            });
          }
        });
      });

      const guides: Array<{
        lineGuide: number;
        offset: number;
        orientation: 'V' | 'H';
        snap: 'start' | 'center' | 'end';
      }> = [];

      const minV = resultV.sort((a, b) => a.diff - b.diff)[0];
      const minH = resultH.sort((a, b) => a.diff - b.diff)[0];

      if (minV) {
        guides.push({
          lineGuide: minV.lineGuide,
          offset: minV.offset,
          orientation: 'V',
          snap: minV.snap,
        });
      }

      if (minH) {
        guides.push({
          lineGuide: minH.lineGuide,
          offset: minH.offset,
          orientation: 'H',
          snap: minH.snap,
        });
      }

      return guides;
    },
    []
  );

  const drawGuides = React.useCallback((guides: ReturnType<typeof getGuides>, layer: Konva.Layer) => {
    guides.forEach((lg) => {
      if (lg.orientation === 'H') {
        const line = new Konva.Line({
          points: [-6000, 0, 6000, 0],
          stroke: 'rgb(0, 161, 255)',
          strokeWidth: 1,
          name: 'guid-line',
          dash: [4, 6],
        });
        layer.add(line);
        line.absolutePosition({
          x: 0,
          y: lg.lineGuide,
        });
      } else if (lg.orientation === 'V') {
        const line = new Konva.Line({
          points: [0, -6000, 0, 6000],
          stroke: 'rgb(0, 161, 255)',
          strokeWidth: 1,
          name: 'guid-line',
          dash: [4, 6],
        });
        layer.add(line);
        line.absolutePosition({
          x: lg.lineGuide,
          y: 0,
        });
      }
    });
  }, []);

  const onDragMove = React.useCallback(
    (e: Konva.KonvaEventObject<DragEvent>) => {
      const layer = e.target.getLayer();

      // 画面上のすべての線を削除する
      layer.find('.guid-line').forEach((l: Konva.Shape) => l.destroy());

      // スナップ可能なラインを探す
      const lineGuideStops = getLineGuideStops(e.target as Konva.Shape);
      // 現在のオブジェクトのスナップポイントを探す
      const itemBounds = getObjectSnappingEdges(e.target as Konva.Shape);

      // 現在のオブジェクトをスナップできる場所を探す
      const guides = getGuides(lineGuideStops, itemBounds);

      // スナップできなかったら、何もしない
      if (!guides.length) {
        return;
      }

      drawGuides(guides, layer);

      const absPos = e.target.absolutePosition();
      // オブジェクトの位置を強制する
      guides.forEach((lg) => {
        switch (lg.snap) {
          case 'start': {
            switch (lg.orientation) {
              case 'V': {
                absPos.x = lg.lineGuide + lg.offset;
                break;
              }
              case 'H': {
                absPos.y = lg.lineGuide + lg.offset;
                break;
              }
            }
            break;
          }
          case 'center': {
            switch (lg.orientation) {
              case 'V': {
                absPos.x = lg.lineGuide + lg.offset;
                break;
              }
              case 'H': {
                absPos.y = lg.lineGuide + lg.offset;
                break;
              }
            }
            break;
          }
          case 'end': {
            switch (lg.orientation) {
              case 'V': {
                absPos.x = lg.lineGuide + lg.offset;
                break;
              }
              case 'H': {
                absPos.y = lg.lineGuide + lg.offset;
                break;
              }
            }
            break;
          }
        }
      });
      e.target.absolutePosition(absPos);
    },
    [drawGuides, getGuides, getObjectSnappingEdges]
  );

  const onDragEnd = (e: Konva.KonvaEventObject<DragEvent>) => {
    const layer = e.target.getLayer();
    // 画面上のすべての線を削除する
    layer.find('.guid-line').forEach((l: Konva.Shape) => l.destroy());
  }

  React.useEffect(() => {
    const stage = stageRef.current;
    const layer = layerRef.current;

    if (!stage || !layer) return;

    // 最初にランダムな長方形を生成します
    for (let i = 0; i < 5; i++) {
      const rect = new Konva.Rect({
        x: Math.random() * stage.width(),
        y: Math.random() * stage.height(),
        width: 50 + Math.random() * 50,
        height: 50 + Math.random() * 50,
        fill: Konva.Util.getRandomColor(),
        draggable: true,
        name: 'object',
      });

      rect.on('dragmove', (e: Konva.KonvaEventObject<DragEvent>) => onDragMove(e));
      rect.on('dragend', (e: Konva.KonvaEventObject<DragEvent>) => onDragEnd(e));

      layer.add(rect);
    }

    layer.draw();
  }, [onDragMove]);

  return (
    <div style={{ backgroundColor: '#f0f0f0', width: '100vw', height: '100vh' }}>
      <Stage ref={stageRef} width={window.innerWidth} height={window.innerHeight}>
        <Layer ref={layerRef} />
      </Stage>
    </div>
  );
};


解説

  1. スナップガイド機能:
    スナップガイド機能は、ドラッグ中のオブジェクトが、キャンバス上の他のオブジェクトやキャンバスの境界線に自動的に吸着する機能です。このデモでは、オブジェクトが垂直または水平方向にスナップされるように設定されています。

  2. オブジェクトの生成とイベントハンドリング:
    このデモでは、5つのランダムな四角形がキャンバス上に生成されます。それぞれの四角形には、dragmove イベントと dragend イベントが設定されており、これらのイベントは、オブジェクトのドラッグ操作に応じてスナップガイド機能を実行します。

  3. スナップガイドの描画:
    スナップガイドは、オブジェクトのドラッグ中に表示される破線で、オブジェクトがどの位置にスナップされるかを示します。drawGuides 関数は、スナップガイドの線を描画し、オブジェクトがスナップされる位置に合わせて更新します。ドラッグ操作が終了すると、ガイド線は削除されます。

Next.jsで動かすには

Next.js は、サーバーサイドレンダリング (SSR) と静的サイト生成 (SSG) をサポートする React フレームワークです。これにより、高速なページロードと SEO パフォーマンスの向上が期待できます。しかし、Konva は Canvas を操作するためにブラウザの window や document などのグローバルオブジェクトを利用しますが、これらはサーバーサイドでは利用できません。そのため、Next.js で Konva を使用する際には、Dynamic import を使ってクライアントサイドでのみ実行する必要があります。

Dynamic import を使用することで、モジュールを遅延ロードし、クライアントサイドでのみ実行されるようにすることができます。これにより、サーバーサイドでのエラーを回避し、ブラウザの環境で正常に動作するようになります。

https://nextjs.org/docs/advanced-features/dynamic-import

例えば、先ほどのデモコンポーネントを Next.js で動かす場合、以下のように Demo コンポーネントを Dynamic import で読み込むことができます。

import dynamic from 'next/dynamic';

// Demo コンポーネントをクライアントサイドでのみ実行するように設定
const DynamicDemoComponent = dynamic(() => import('./path/to/DemoComponent'), {
  ssr: false,
});

export default function MyApp() {
  return (
    <div>
      <DynamicDemoComponent />
    </div>
  );
}

ここで、dynamic 関数を使用して、Demo コンポーネントをインポートし、ssr: false のオプションを指定してサーバーサイドレンダリングを無効にしています。これにより、Demo コンポーネントがクライアントサイドでのみ実行され、Next.js で正常に動作するようになります。

参考

https://konvajs.org/docs/sandbox/Objects_Snapping.html

GitHubで編集を提案

Discussion