关于 draggable-dom-with-lit
这项技能讲解如何用 Lit 和 TypeScript 构建可拖动的 DOM 画布 Web 组件,支持通过指针交互移动画布及其插槽中的 HTML 或 SVG 元素。
主要功能
- 01设置 Lit Web 组件
- 02拖动 HTML 和 SVG 元素
- 03通过指针事件平移画布
- 04跟踪多个指针
- 05使用 CSS 变换移动元素
适用场景
- 01构建可拖动的 DOM 画布
- 02移动单独的 HTML 或 SVG 项
- 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;
}
这里,我们只需设置一些样板代码,渲染一个 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 设置为当前索引。
目前,与元素交互时还不会发生任何变化,但元素应该已经能够渲染。
![]()
现在,在类中添加以下内容,为指针事件添加事件处理程序:
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 上也有一个示例. 此示例的源代码可在此处找到。