Reactで、付せんをドラッグして自由に動かす方法

​​React​Playwright,Pointer Events,TypeScript

付せんの位置(x・y)と重なり順(z)を state に持ち、つまみで受け取る Pointer Events をもとにその値を書き換えれば、ライブラリを使わずに付せんを好きな位置へ動かせます。押したときに setPointerCapture() を呼ぶと、ポインターがつまみから外れても、離すまで付せんが付いてきます。

いちばん上に重ねるには、z-index に使う z の値を大きくします。配列の最後へ移して DOM の並びで重ねる方法もありますが、検証ではドラッグが途中で止まりました。その結果と、考えられる理由もあわせて書きます。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、Playwright 1.63.0 で、Playwright でマウスとキーボードを操作して確かめました。タッチ操作、Firefox と Safari、スマートフォンのブラウザ、スクリーンリーダーでの読み上げは試していません。ボードの大きさが途中で変わる場合や、付せんがとても多い場合の動きも、この記事では扱いません。

付せん1のつまみをドラッグすると上に重なって右下へ動き、付せん2はボードの右端で止まり、付せん3は左下へ動いて上に重なる。下の一覧では、そのとき変わった付せんの位置と z がピンクになる
3回のドラッグで、付せんの位置と重なり順が変わる様子(GIF は幅の狭いボードで撮影。カーソルと操作の表示は GIF 用に重ねたもの)

付せんの位置と重なり順を持つコンポーネント

付せん1枚は { id, x, y, z, text, color } で表します。x と y はボードの左上から付せんの左上までの px、z は重なり順です。NoteBoard は付せんの一覧を親から受け取り、動かすたびに onChange で新しい一覧を渡します。ドラッグを終えて離したときと、矢印キーで動かしたときには、onDrop も呼びます。

tsx
import { useEffect, useRef } from 'react';
import type { KeyboardEvent, PointerEvent } from 'react';

export type Note = { id: number; x: number; y: number; z: number; text: string; color: string };

type Drag = { id: number; offsetX: number; offsetY: number };

type Props = {
  notes: Note[];
  onChange: (notes: Note[]) => void;
  // ドラッグを終えて離したときと、矢印キーで動かしたときに呼ぶ(ドラッグ中は呼ばない)
  onDrop?: (notes: Note[]) => void;
};

const clamp = (n: number, min: number, max: number) => Math.min(Math.max(n, min), max);

export function NoteBoard({ notes, onChange, onDrop }: Props) {
  const boardRef = useRef<HTMLDivElement>(null);
  const drag = useRef<Drag | null>(null);
  // いちばん新しい付せんの一覧。props の notes はレンダリングした時点の値のままなので、
  // 同じハンドラーの中で変えたばかりの値を onDrop に渡せるように、ref にも持っておく
  const latest = useRef(notes);
  useEffect(() => { latest.current = notes; }, [notes]);

  // 付せんの左上を、ボードの中に収まる位置に直す
  function fit(x: number, y: number, el: HTMLElement) {
    const board = boardRef.current!;
    return {
      x: Math.round(clamp(x, 0, board.clientWidth - el.offsetWidth)),
      y: Math.round(clamp(y, 0, board.clientHeight - el.offsetHeight)),
    };
  }

  function update(id: number, patch: Partial<Note>) {
    const next = latest.current.map((n) => (n.id === id ? { ...n, ...patch } : n));
    latest.current = next;
    onChange(next);
  }

  // いちばん上に重ねる(z をいまの最大より 1 大きくする)
  function bringToFront(id: number) {
    const top = Math.max(...latest.current.map((n) => n.z));
    const note = latest.current.find((n) => n.id === id)!;
    if (note.z !== top) update(id, { z: top + 1 });
  }

  function handlePointerDown(e: PointerEvent<HTMLDivElement>, note: Note) {
    if (e.button !== 0) return;
    const board = boardRef.current!.getBoundingClientRect();
    // つかんだ位置と付せんの左上のずれ。これを保って動かすと、付せんが指の位置へ飛ばない
    drag.current = {
      id: note.id,
      offsetX: e.clientX - board.left - note.x,
      offsetY: e.clientY - board.top - note.y,
    };
    bringToFront(note.id);
    // ポインターがつまみの外へ出ても、離すまでこの要素にイベントが届くようにする
    e.currentTarget.setPointerCapture(e.pointerId);
  }

  function handlePointerMove(e: PointerEvent<HTMLDivElement>) {
    const d = drag.current;
    if (!d) return;
    const board = boardRef.current!.getBoundingClientRect();
    const el = e.currentTarget.parentElement!; // 付せん全体
    update(d.id, fit(e.clientX - board.left - d.offsetX, e.clientY - board.top - d.offsetY, el));
  }

  function handlePointerUp() {
    if (!drag.current) return;
    drag.current = null;
    onDrop?.(latest.current);
  }

  // 矢印キーで 10px ずつ動かす
  function handleKeyDown(e: KeyboardEvent<HTMLDivElement>, note: Note) {
    const step = { ArrowLeft: [-10, 0], ArrowRight: [10, 0], ArrowUp: [0, -10], ArrowDown: [0, 10] }[e.key];
    if (!step) return;
    e.preventDefault();
    update(note.id, fit(note.x + step[0], note.y + step[1], e.currentTarget.parentElement!));
    onDrop?.(latest.current);
  }

  return (
    <div className="board" ref={boardRef}>
      {notes.map((note) => (
        <div
          key={note.id}
          className="note"
          data-id={note.id}
          style={{ transform: `translate(${note.x}px, ${note.y}px)`, zIndex: note.z, background: note.color }}
        >
          <div
            className="note-handle"
            tabIndex={0}
            role="button"
            aria-label={`付せん${note.id}を動かす(矢印キーでも動きます)`}
            onPointerDown={(e) => handlePointerDown(e, note)}
            onPointerMove={handlePointerMove}
            onPointerUp={handlePointerUp}
            onPointerCancel={handlePointerUp}
            onFocus={() => bringToFront(note.id)}
            onKeyDown={(e) => handleKeyDown(e, note)}
          />
          <textarea
            aria-label={`付せん${note.id}の内容`}
            value={note.text}
            onChange={(e) => update(note.id, { text: e.target.value })}
            onFocus={() => bringToFront(note.id)}
          />
        </div>
      ))}
    </div>
  );
}

ドラッグ中の情報(どの付せんか、つかんだ位置と付せんの左上とのずれ)は drag という ref に入れています。ref を書き換えても再レンダリングは起きないので、画面に出さない値を置くのに向いています。

付せんの一覧は、latest という ref にも持たせています。props の notes は、そのレンダリングの時点の値のままです。矢印キーのハンドラーのように、位置を変えてすぐ onDrop に渡す場面では、notes ではなく latest.current を渡さないと、変える前の位置が保存されてしまいます。

付せんを動かせるのは、上の帯(つまみ)だけです。本文の textarea を押したときは、ふつうに文字を選んだり入力したりできます。付せんの表示位置は transform: translate() で指定し、fit でボードの外へはみ出さない位置に直しています。

見た目は CSS で付けています。付せんは .board の中で position: absolute にし、.note-handle には touch-action: none を付けています。

css
.board {
  position: relative;
  height: 360px;
  background: #eefafd;
  border: 1px solid #9aa5ab;
  border-radius: 8px;
  overflow: hidden;
}

.note {
  position: absolute;
  left: 0;
  top: 0;
  width: 160px;
  height: 120px;
  display: flex;
  flex-direction: column;
  box-shadow: 0 2px 6px rgb(0 0 0 / 0.2);
}

.note-handle {
  height: 22px;
  flex: none;
  background: rgb(0 0 0 / 0.08);
  cursor: grab;
  /* タッチでドラッグしたときに、ページがスクロールしないようにする */
  touch-action: none;
}
.note-handle:active { cursor: grabbing; }
.note-handle:focus-visible { outline: 3px solid #00c8e8; }

.note textarea {
  flex: 1;
  border: 0;
  padding: 6px 8px;
  background: transparent;
  font: inherit;
  resize: none;
}

タッチでは、ブラウザがスクロールや拡大を始めると pointercancel が送られ、ドラッグが続かなくなります(MDN の touch-action の説明)。touch-action: none は、つまみの上でブラウザにスクロールや拡大をさせないための指定です。ただし、筆者はタッチ操作では試していません。

位置を保存する App

使う側の App は、付せんの一覧を state に持ち、onDrop で localStorage に保存します。ページを開いたときは、保存してある一覧があればそれを使います。

tsx
import { useState } from 'react';
import { NoteBoard } from './NoteBoard';
import type { Note } from './NoteBoard';

const STORAGE_KEY = 'notes';

const initialNotes: Note[] = [
  { id: 1, x: 20, y: 20, z: 1, text: '牛乳を買う', color: '#fff3a8' },
  { id: 2, x: 120, y: 70, z: 2, text: '記事の図を描く', color: '#c9f1fa' },
  { id: 3, x: 220, y: 120, z: 3, text: '本を返す', color: '#ffd6e0' },
];

// 保存してある位置があれば、それを使う
function load(): Note[] {
  try {
    const saved = localStorage.getItem(STORAGE_KEY);
    return saved ? (JSON.parse(saved) as Note[]) : initialNotes;
  } catch {
    return initialNotes;
  }
}

export function App() {
  const [notes, setNotes] = useState<Note[]>(load);

  function save(next: Note[]) {
    try {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
    } catch {
      // 保存できなくても、画面の操作は続けられるようにする
    }
  }

  return (
    <main>
      <NoteBoard notes={notes} onChange={setNotes} onDrop={save} />
      <ul className="info">
        {notes.map((n) => (
          <li key={n.id}>付せん{n.id}:({n.x}, {n.y}) z {n.z}</li>
        ))}
      </ul>
    </main>
  );
}

ドラッグ中は保存せず、離したときに保存します(矢印キーで動かしたときは、押すたびに保存します)。付せん1を 20回の pointermove でドラッグしたとき、localStorage.setItem が呼ばれたのは離したときの1回だけでした。ページを読み込み直すと、付せんは離した位置に出ました。

text
付せん1を 20回の pointermove でドラッグ: 付せん1:(140, 210) z 7 / 付せん2:(0, 0) z 5 / 付せん3:(220, 120) z 6
localStorage.setItem の回数: ドラッグ中 0回、離したあと 1回
ページを読み込み直したあと: 付せん1:(140, 210) z 7 / 付せん2:(0, 0) z 5 / 付せん3:(220, 120) z 6

localStorage に入れた値は、ブラウザを閉じても残ります。ただし、ブラウザの設定によっては保存できないことがあるので、setItem は try で囲んでいます。なお、このコードでは本文の欄で書き換えた文字は onDrop を通らないので、次に付せんを動かすまで保存されません。文字も保存したいときは、入力のたびに保存する処理を別に足します。

ドラッグしたときの動き

ボードの内側が 598×360px(ブラウザの表示領域の幅が 640px のとき)、付せんが 160×120px の状態で、Playwright でつまみをドラッグしました。付せんの位置は、ボードの左上からの px です。

text
付せん1:(20, 20) z 1 / 付せん2:(120, 70) z 2 / 付せん3:(220, 120) z 3
付せん1のつまみの左端から30px・上端から10pxをつかみ、右へ200px・下へ130pxドラッグ: 付せん1:(220, 150) z 4 / 付せん2:(120, 70) z 2 / 付せん3:(220, 120) z 3
離したときのポインターと、付せん1のつまみの左上とのずれ: (30, 10)
ボードの (230, 160) でいちばん上の付せん: 1
付せん2を、ボードの右下の外までドラッグ: 付せん1:(220, 150) z 4 / 付せん2:(438, 240) z 5 / 付せん3:(220, 120) z 3
付せん2を、ボードの左上の外までドラッグ: 付せん1:(220, 150) z 4 / 付せん2:(0, 0) z 5 / 付せん3:(220, 120) z 3

付せん1は、ドラッグした分だけ動いて (220, 150) になりました。離したときも、ポインターはつまみの左端から 30px・上端から 10px の所にあります。押したときのずれ(offsetX・offsetY)を保って動かしているので、つかんだ所が付せんの左上へ飛ぶことはありません。

付せん1の z は 4 になり、付せん3と重なった所では付せん1が上に見えるようになりました。CSS の z-index は、値の大きい要素が小さい要素の上に重なります。

付せん2をボードの外までドラッグしても、右下では (438, 240) で止まりました。ボードの内側の幅 598 から付せんの幅 160 を引くと 438、高さ 360 から 120 を引くと 240 です。左上の外まで引いたときは (0, 0) で止まりました。

付せん3のつまみをクリックしただけのときは、位置は変わらず、いちばん上に重なるだけでした(付せん3の z が 6 になり、付せん1との重なりで上に見えるようになりました)。本文の上でドラッグしたときは付せんは動かず、文字が選択されました。続けて入力した文字も、そのまま入りました。

text
付せん3の本文の上で、右へ110pxドラッグ: 付せん1:(220, 150) z 4 / 付せん2:(0, 0) z 5 / 付せん3:(220, 120) z 6 / 選ばれた文字: textarea の 0〜4 文字目
続けて入力: 付せん3の内容「本を返す(金曜まで)」

つまみにはキーボードでもフォーカスでき、矢印キーで 10px ずつ動きます。付せん1のつまみで → を2回と ↓ を1回押すと、(220, 150) から (240, 160) へ動きました。

setPointerCapture を呼ばないと、ポインターがつまみから外れたところで止まる

ポインターのイベントは、ふつうはポインターの下にある要素に届きます。setPointerCapture() を呼ぶと、そのポインターのイベントは、離すまで呼んだ要素に届くようになります(MDN の Pointer Events の説明)。

setPointerCapture を何もしない関数に置き換えたページと比べました。付せん1を、10回の pointermove で右へ 300px・下へ 150px ドラッグしています。つまみの上端から 10px の所をつかんでいて、1回で 15px 下がるので、付せんが付いてこなければ、ポインターは1回目で高さ 22px のつまみから外れます。

text
あり: 付せん1を右へ300px・下へ150pxドラッグして離す: 付せん1:(320, 170) z 4 / 押さずに、ポインターを元の位置へ戻す: 付せん1:(320, 170) z 4
  ドラッグ中にイベントが届いた要素: 付せん1のつまみ 付せん1のつまみ 付せん1のつまみ 付せん1のつまみ 付せん1のつまみ 付せん1のつまみ 付せん1のつまみ 付せん1のつまみ 付せん1のつまみ 付せん1のつまみ 離す:付せん1のつまみ
なし: 付せん1を右へ300px・下へ150pxドラッグして離す: 付せん1:(200, 110) z 4 / 押さずに、ポインターを元の位置へ戻す: 付せん1:(140, 80) z 4
  ドラッグ中にイベントが届いた要素: 付せん1の本文 付せん1の本文 付せん1の本文 付せん1の本文 付せん2の本文 付せん3のつまみ 付せん1の本文 付せん1の本文 付せん1の本文 付せん1の本文 離す:付せん1の本文

呼んだ場合は、10回の pointermove と離したときの pointerup が、すべて付せん1のつまみに届きました。呼ばない場合は、イベントはポインターの下にある本文の欄などに届きました。付せん1が動いたのは、途中で付せん3のつまみの上を通ったときの1回だけで、(200, 110) で止まっています。

さらに、離したときの pointerup も本文の欄に届いたので、ドラッグが終わったことが伝わりませんでした。そのあとボタンを押さずにポインターを元の位置へ戻しただけで、付せん1が (140, 80) へ動きました。

重なり順を配列の並べ替えで変えると、ドラッグが止まる

z-index を指定しなくても、position: absolute などを指定した要素は、HTML の中で後ろにあるものほど上に描かれます。そこで、押した付せんを配列の最後へ移して上に重ねる版も作り、比べました。bringToFront を次のように変え、zIndex の指定を外しています。

tsx

// 比べる用:z は変えず、配列の最後へ移して(DOM の最後に描いて)上に重ねる
  function bringToFront(id: number) {
    const list = latest.current;
    if (list[list.length - 1].id === id) return;
    const next = [...list.filter((n) => n.id !== id), list.find((n) => n.id === id)!];
    latest.current = next;
    onChange(next);
  }

いちばん下にある付せん1を、上と同じように右へ 300px・下へ 150px ドラッグしました。

text
z を変える版: 押した直後の hasPointerCapture: true
z を変える版: 付せん1を右へ300px・下へ150pxドラッグ: 付せん1:(320, 170) z 4 / gotpointercapture 1回 / 付せんの DOM の並び: 1,2,3
配列の最後へ移す版: 押した直後の hasPointerCapture: false
配列の最後へ移す版: 付せん1を右へ300px・下へ150pxドラッグ: 付せん1:(200, 110) z 1 / gotpointercapture 0回 / 付せんの DOM の並び: 2,3,1

配列の最後へ移す版では、押した直後の hasPointerCapture() が false で、gotpointercapture も起きませんでした。付せん1は (200, 110) で止まり、setPointerCapture を呼ばなかった場合と同じ動きになりました。

setPointerCapture() は呼んでいますが、そのあと React が配列の並びに合わせて付せん1の要素を DOM の最後へ移したため、キャプチャが効かなくなったと考えられます。MDN の Pointer Events の説明にも、要素を DOM の中で移すときは、移したあとで setPointerCapture() を呼ぶようにと書かれています(キャプチャの節の注意書き)。

z を変える版では、DOM の並びは 1,2,3 のままで、付せん1は最後まで付いてきました。

ポイント

ドラッグする要素の重なり順は、配列の並びではなく z-index で変えます。DOM の中で要素が動かないので、キャプチャが外れません。

​Posted by 86