mcpField
D
draggable-dom-with-lit

draggable-dom-with-lit

github.com/rodydavis開発6046 回インストール2 週間前に更新
リポジトリ

draggable-dom-with-lit について

Lit と TypeScript で、ドラッグ可能な DOM キャンバスを Web コンポーネントとして作る方法を解説するスキルです。ポインター操作でキャンバスとスロット内の HTML・SVG 要素を移動できます。

主な機能

  1. 01Lit Web コンポーネントの設定
  2. 02HTML・SVG 要素のドラッグ
  3. 03ポインターイベントによるキャンバス移動
  4. 04複数ポインターの追跡
  5. 05CSS 変換による要素移動

活用シーン

  1. 01ドラッグ可能な DOM キャンバスの構築
  2. 02個別の HTML・SVG 要素の移動
  3. 03要素位置を保ったキャンバスの移動

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/rodydavis/skills --skill draggable-dom-with-lit

インストール後、Claude Code で/draggable-dom-with-litを入力して読み込まれたか確認します。

詳細

Lit でドラッグ可能な DOM を作成する

この記事では、Lit の Web コンポーネントを設定し、CSS トランスフォームとスロットを使ってインタラクティブな DOM を作成する方法を説明します。

要約 最終的なソースはこちら、オンラインのデモはこちらです。

前提条件

  • Vscode
  • Node >= 16
  • Typescript

はじめに

まず、ターミナルでプロジェクトの場所に移動し、次のコマンドを実行します。

npm init @vitejs/app --template lit-ts

次に、プロジェクト名として lit-draggable-dom を入力します。続いて、vscode でプロジェクトを開き、依存関係をインストールします。

cd lit-draggable-dom
npm i lit
npm i -D @types/node
code .

vite.config.ts を次のように更新します。

import { defineConfig } from "vite";
import { resolve } from "path";

export default defineConfig({
  base: "/lit-draggable-dom/",
  build: {
    rollupOptions: {
      input: {
        main: resolve(__dirname, "index.html"),
      },
    },
  },
});

テンプレート

index.html を開き、次のように更新します。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Lit Draggable DOM</title>
    <style>
      body {
        margin: 0;
        padding: 0;
        width: 100%;
        height: 100vh;
      }
    </style>
    <script type="module" src="/src/draggable-dom.ts"></script>
  </head>
  <body>
    <draggable-dom>
      <img
        src="https://lit.dev/images/logo.svg"
        alt="Lit Logo"
        width="500"
        height="333"
        style="--dx: 59.4909px; --dy: 32.8429px"
      />
      <svg width="400" height="110" style="--dx: 230.057px; --dy: 33.6257px">
        <rect
          width="400"
          height="100"
          style="fill: rgb(0, 0, 255); stroke-width: 3; stroke: rgb(0, 0, 0)"
        />
      </svg>
      <svg height="100" width="100">
        <circle
          cx="50"
          cy="50"
          r="40"
          stroke="black"
          stroke-width="3"
          fill="red"
        />
      </svg>
    </draggable-dom>
  </body>
</html>

ここでは、lit-element にいくつかのスロットを設定しています。スロットには、有効な HTML または SVG 要素を指定できます。

CSS カスタムプロパティ の --dx と --dy の設定は任意です。これはキャンバス上の初期位置を指定するだけです。

Web コンポーネント

コンポーネントを更新する前に、my-element.ts を draggable-dom.ts に名前変更する必要があります。

draggable-dom.ts を開き、次のように更新します。

import { html, css, LitElement } from "lit";
import { customElement, query } from "lit/decorators.js";

type DragType = "none" | "canvas" | "element";
type SupportedNode = HTMLElement | SVGElement;

@customElement("draggable-dom")
export class CSSCanvas extends LitElement {
  @query("main") root!: HTMLElement;
  @query("#children") container!: HTMLElement;
  @query("canvas") canvas!: HTMLCanvasElement;
  dragType: DragType = "none";
  offset: Offset = { x: 0, y: 0 };
  pointerMap: Map<number, PointerData> = new Map();

  static styles = css`
    :host {
      --offset-x: 0;
      --offset-y: 0;
      --grid-background-color: white;
      --grid-color: black;
      --grid-size: 40px;
      --grid-dot-size: 1px;
    }
    main {
      overflow: hidden;
    }
    canvas {
      background-size: var(--grid-size) var(--grid-size);
      background-image: radial-gradient(
        circle,
        var(--grid-color) var(--grid-dot-size),
        var(--grid-background-color) var(--grid-dot-size)
      );
      background-position: var(--offset-x) var(--offset-y);
      z-index: 0;
    }
    .full-size {
      width: 100%;
      height: 100%;
      position: fixed;
    }
    .child {
      --dx: 0px;
      --dy: 0px;
      position: fixed;
      flex-shrink: 1;
      z-index: var(--layer, 0);
      transform: translate(var(--dx), var(--dy));
    }
    @media (prefers-color-scheme: dark) {
      main {
        --grid-background-color: black;
        --grid-color: grey;
      }
    }
  `;

  render() {
    return html`
      <main class="full-size">
        <canvas class="full-size"></canvas>
        <div id="children" class="full-size"></div>
      </main>
    `;
  }
}

interface Offset {
  x: number;
  y: number;
}

interface PointerData {
  id: number;
  startPos: Offset;
  currentPos: Offset;
}

ここでは、背景として canvas 要素を含む main 要素と、canvas 要素を格納する div 要素を描画するためのボイラープレートを設定しています。

また、表示領域をクリップし、見えている部分だけを描画するようにしています。

Offset インターフェースと PointerData インターフェースは、画面上で操作する各ポインターの位置を保存するために使います。

システムでダークモードが有効になっている場合は、キャンバスグリッドの色が変わります。

次に、クラスに以下を追加して、キャンバスにスロットの子要素を追加します。

async firstUpdated() {
    const items = Array.from(this.childNodes);
    let i = 0;
    for (const node of items) {
        if (node instanceof SVGElement || node instanceof HTMLElement) {
            const child = node as SupportedNode;
            child.classList.add("child");
            child.style.setProperty("--layer", `${i}`);
            this.container.append(child);
            child.addEventListener("pointerdown", (e: any) => {
                // Pointer Down for Child
            });
            child.addEventListener("pointermove", (e: any) => {
                // Pointer Move for Child
            });
            i++;
        }
    }
    this.requestUpdate();
    this.root.addEventListener("pointerdown", (e: any) => {
        // Pointer Down for Canvas
    });
    this.root.addEventListener("pointermove", (e: any) => {
        // Pointer Move for Canvas
    });
    this.root.addEventListener("pointerup", (e: any) => {
        // Pointer Up for Canvas
    });
}

スロットの順序によって、どの要素がほかの要素の上に描画されるかが決まります。各スロット項目には、現在のインデックスを --layer と z-index に設定します。

現時点では要素を操作しても何も起こりませんが、描画はされるはずです。

README の画像

次に、クラスに以下を追加して、ポインターイベントのイベントハンドラーを追加します。

handleDown(event: PointerEvent, type: DragType) {
    if (this.dragType === "none") {
        event.preventDefault();
        this.dragType = type;
        (event.target as Element).setPointerCapture(event.pointerId);
        this.pointerMap.set(event.pointerId, {
            id: event.pointerId,
            startPos: { x: event.clientX, y: event.clientY },
            currentPos: { x: event.clientX, y: event.clientY },
        });
    }
}

handleMove(
    event: PointerEvent,
    type: DragType,
    onMove: (delta: Offset) => void
) {
    if (this.dragType === type) {
        event.preventDefault();
        const saved = this.pointerMap.get(event.pointerId)!;
        const current = { ...saved.currentPos };
        saved.currentPos = { x: event.clientX, y: event.clientY };
        const delta = {
            x: saved.currentPos.x - current.x,
            y: saved.currentPos.y - current.y,
        };
        onMove(delta);
    }
}

handleUp(event: PointerEvent) {
    this.dragType = "none";
    (event.target as Element).releasePointerCapture(event.pointerId);
}

各イベントでは、現在のイベントが canvas と element のどちらに対するものかを確認します。これにより、要素の移動を開始したときにキャンバスが動いたり、その逆が起きたりするのを防ぎます。

ポインターが画面に触れたら、ポインターマップに追加します(マルチタッチにも対応できるためです)。そして、オフセットの追跡を開始します。

差分を計算して要素を移動させますが、キャンバスの背景にはグローバルオフセットを使用します。

ポインターキャプチャイベントを設定することで、マウスが対象項目から少し外れても追跡が途切れないようにします。

次に、クラスに以下を追加して、キャンバスと要素を移動するメソッドを追加します。

moveCanvas(delta: Offset) {
    this.offset.x += delta.x;
    this.offset.y += delta.y;
    this.root.style.setProperty("--offset-x", `${this.offset.x}px`);
    this.root.style.setProperty("--offset-y", `${this.offset.y}px`);
}

moveElement(child: SupportedNode, delta: Offset) {
    const getNumber = (key: "--dx" | "--dy", fallback: number) => {
      const saved = child.style.getPropertyValue(key);
      if (saved.length > 0) {
        return parseFloat(saved.replace("px", ""));
      }
      return fallback;
    };
    const dx = getNumber("--dx", 0) + delta.x;
    const dy = getNumber("--dy", 0) + delta.y;
    child.style.transform = `translate(${dx}px, ${dy}px)`;
    child.style.setProperty("--dx", `${dx}px`);
    child.style.setProperty("--dy", `${dy}px`);
}

キャンバスでは、CSS の background-position にグローバルオフセットを設定し、保存済みのオフセットを更新します。

要素では、ハードウェアアクセラレーションによる滑らかなアニメーションのため、差分を使ってトランスフォームします。トランスフォーム後、オフセットを CSS カスタムプロパティとして保存します。

次に、以下を調整して、キャンバスと要素にイベントハンドラーを追加します。

async firstUpdated() {
    const items = Array.from(this.childNodes);
    let i = 0;
    for (const node of items) {
      if (node instanceof SVGElement || node instanceof HTMLElement) {
        const child = node as SupportedNode;
        child.classList.add("child");
        child.style.setProperty("--layer", `${i}`);
        this.container.append(child);
        child.addEventListener("pointerdown", (e: any) => {
          this.handleDown(e, "element");
        });
        child.addEventListener("pointermove", (e: any) => {
          this.handleMove(e, "element", (delta) => {
            this.moveElement(child, delta);
          });
        });
        i++;
      }
    }
    this.requestUpdate();
    this.root.addEventListener("pointerdown", (e: any) => {
      this.handleDown(e, "canvas");
    });
    this.root.addEventListener("pointermove", (e: any) => {
      this.handleMove(e, "canvas", (delta) => {
        this.moveCanvas(delta);
        for (const node of Array.from(this.container.children)) {
          if (node instanceof SVGElement || node instanceof HTMLElement) {
            this.moveElement(node, delta);
          }
        }
      });
    });
    this.root.addEventListener("pointerup", (e: any) => {
      this.handleUp(e);
    });
}

これですべて期待どおりに動作するはずです。最終的なコードは次のようになります。

import { html, css, LitElement } from "lit";
import { customElement, query } from "lit/decorators.js";

type DragType = "none" | "canvas" | "element";
type SupportedNode = HTMLElement | SVGElement;

@customElement("draggable-dom")
export class DraggableDOM extends LitElement {
  @query("main") root!: HTMLElement;
  @query("#children") container!: HTMLElement;
  @query("canvas") canvas!: HTMLCanvasElement;
  dragType: DragType = "none";
  offset: Offset = { x: 0, y: 0 };
  pointerMap: Map<number, PointerData> = new Map();

  static styles = css`
    :host {
      --offset-x: 0;
      --offset-y: 0;
      --grid-background-color: white;
      --grid-color: black;
      --grid-size: 40px;
      --grid-dot-size: 1px;
    }
    main {
      overflow: hidden;
    }
    canvas {
      background-size: var(--grid-size) var(--grid-size);
      background-image: radial-gradient(
        circle,
        var(--grid-color) var(--grid-dot-size),
        var(--grid-background-color) var(--grid-dot-size)
      );
      background-position: var(--offset-x) var(--offset-y);
      z-index: 0;
    }
    .full-size {
      width: 100%;
      height: 100%;
      position: fixed;
    }
    .child {
      --dx: 0px;
      --dy: 0px;
      position: fixed;
      flex-shrink: 1;
      z-index: var(--layer, 0);
      transform: translate(var(--dx), var(--dy));
    }
    @media (prefers-color-scheme: dark) {
      main {
        --grid-background-color: black;
        --grid-color: grey;
      }
    }
  `;

  render() {
    console.log("render");
    return html`
      <main class="full-size">
        <canvas class="full-size"></canvas>
        <div id="children" class="full-size"></div>
      </main>
    `;
  }

  handleDown(event: PointerEvent, type: DragType) {
    if (this.dragType === "none") {
      event.preventDefault();
      this.dragType = type;
      (event.target as Element).setPointerCapture(event.pointerId);
      this.pointerMap.set(event.pointerId, {
        id: event.pointerId,
        startPos: { x: event.clientX, y: event.clientY },
        currentPos: { x: event.clientX, y: event.clientY },
      });
    }
  }

  handleMove(
    event: PointerEvent,
    type: DragType,
    onMove: (delta: Offset) => void
  ) {
    if (this.dragType === type) {
      event.preventDefault();
      const saved = this.pointerMap.get(event.pointerId)!;
      const current = { ...saved.currentPos };
      saved.currentPos = { x: event.clientX, y: event.clientY };
      const delta = {
        x: saved.currentPos.x - current.x,
        y: saved.currentPos.y - current.y,
      };
      onMove(delta);
    }
  }

  handleUp(event: PointerEvent) {
    this.dragType = "none";
    (event.target as Element).releasePointerCapture(event.pointerId);
  }

  moveCanvas(delta: Offset) {
    this.offset.x += delta.x;
    this.offset.y += delta.y;
    this.root.style.setProperty("--offset-x", `${this.offset.x}px`);
    this.root.style.setProperty("--offset-y", `${this.offset.y}px`);
  }

  moveElement(child: SupportedNode, delta: Offset) {
    const getNumber = (key: "--dx" | "--dy", fallback: number) => {
      const saved = child.style.getPropertyValue(key);
      if (saved.length > 0) {
        return parseFloat(saved.replace("px", ""));
      }
      return fallback;
    };
    const dx = getNumber("--dx", 0) + delta.x;
    const dy = getNumber("--dy", 0) + delta.y;
    child.style.transform = `translate(${dx}px, ${dy}px)`;
    child.style.setProperty("--dx", `${dx}px`);
    child.style.setProperty("--dy", `${dy}px`);
  }

  async firstUpdated() {
    const items = Array.from(this.childNodes);
    let i = 0;
    for (const node of items) {
      if (node instanceof SVGElement || node instanceof HTMLElement) {
        const child = node as SupportedNode;
        child.classList.add("child");
        child.style.setProperty("--layer", `${i}`);
        this.container.append(child);
        child.addEventListener("pointerdown", (e: any) => {
          this.handleDown(e, "element");
        });
        child.addEventListener("pointermove", (e: any) => {
          this.handleMove(e, "element", (delta) => {
            this.moveElement(child, delta);
          });
        });
        child.setAttribute("draggable", "false");
        i++;
      }
    }
    this.requestUpdate();
    this.root.addEventListener("pointerdown", (e: any) => {
      this.handleDown(e, "canvas");
    });
    this.root.addEventListener("pointermove", (e: any) => {
      this.handleMove(e, "canvas", (delta) => {
        this.moveCanvas(delta);
        for (const node of Array.from(this.container.children)) {
          if (node instanceof SVGElement || node instanceof HTMLElement) {
            this.moveElement(node, delta);
          }
        }
      });
    });
    this.root.addEventListener("touchstart", function (e) {
      e.preventDefault();
    });
    this.root.addEventListener("pointerup", (e: any) => {
      this.handleUp(e);
    });
  }
}

interface Offset {
  x: number;
  y: number;
}

interface PointerData {
  id: number;
  startPos: Offset;
  currentPos: Offset;
}

まとめ

Lit を使った開発について詳しく知りたい場合は、ドキュメントをこちらでご覧いただけます。Lit playground にもこちらにサンプルがあります。 この例のソースはこちらで確認できます。

コメント