Text Controller
TextController 是一个框架无关的 Canvas 文本渲染和交互控制器,用于在 Canvas 上绘制可拖拽的文本项。
特性
- ✅ 框架无关:纯 TypeScript 实现,可在任何 JavaScript 环境中使用
- ✅ 交互式:支持文本项的拖拽、选择、边界检测
- ✅ 高性能:拖拽时使用临时状态,减少不必要的回调
- ✅ 类型安全:完整的 TypeScript 类型定义
- ✅ 易于集成:简洁的 API 设计,易于与任何 UI 框架集成
使用
import { TextController, type TextItem, type Color } from './text-controller';
核心概念
TextItem
文本项是控制器处理的基本单位:
type TextItem = {
id: string | number; // 唯一标识
text: string; // 文本内容
position: Point; // 位置坐标
};
受控模式
控制器采用受控模式设计:
- 外部通过
setItems()设置文本项 - 拖拽结束时通过
onItemsChange回调通知外部 - 拖拽过程中使用内部临时状态,不触发回调(性能优化)
API 文档
构造函数
const controller = new TextController({
color?: Color; // 文本颜色,默认 [0, 0, 0, 1]
items?: TextItem[]; // 初始文本项列表
onItemsChange?: (items: TextItem[]) => void; // 值变化回调
});
核心方法
setCanvas()
设置 Canvas 上下文和尺寸:
controller.setCanvas(
ctx: CanvasRenderingContext2D,
width: number,
height: number
): void
setItems()
更新文本项列表:
controller.setItems(items: TextItem[]): void
render()
渲染所有文本项:
controller.render(): void
交互方法
handleMouseDown()
处理鼠标按下事件:
controller.handleMouseDown(x: number, y: number): void
handleMouseMove()
处理鼠标移动事件:
controller.handleMouseMove(x: number, y: number): void
handleMouseUp()
处理鼠标松开事件(拖拽结束时触发 onItemsChange):
controller.handleMouseUp(): void
工具方法
setColor()
更新文本颜色:
controller.setColor(color: Color): void
getItems()
获取当前文本项列表:
controller.getItems(): TextItem[]
getCursorStyle()
获取当前光标样式:
controller.getCursorStyle(): string // 'default' | 'grab' | 'grabbing'
destroy()
销毁控制器,清理资源:
controller.destroy(): void
使用示例
基础用法
// 1. 创建控制器
const controller = new TextController({
color: [255, 255, 255, 1],
items: [
{ id: 1, text: 'Hello World', position: { x: 10, y: 10 } },
{ id: 2, text: 'Draggable Text', position: { x: 10, y: 50 } }
],
onItemsChange: (items) => {
console.log('Items changed:', items);
// 更新你的状态管理
}
});
// 2. 获取 Canvas 上下文
const canvas = document.getElementById('myCanvas') as HTMLCanvasElement;
const ctx = canvas.getContext('2d');
// 3. 设置 Canvas
controller.setCanvas(ctx, canvas.width, canvas.height);
// 4. 渲染
controller.render();
// 5. 绑定事件
canvas.addEventListener('mousedown', (e) => {
const rect = canvas.getBoundingClientRect();
controller.handleMouseDown(e.clientX - rect.left, e.clientY - rect.top);
controller.render();
});
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
controller.handleMouseMove(e.clientX - rect.left, e.clientY - rect.top);
});
canvas.addEventListener('mouseup', () => {
controller.handleMouseUp();
controller.render();
});
// 6. 更新光标样式
canvas.style.cursor = controller.getCursorStyle();
响应式更新
// 当外部数据变化时,更新控制器
function updateItems(newItems: TextItem[]) {
controller.setItems(newItems);
controller.render();
}
// 当颜色变化时
function updateColor(newColor: Color) {
controller.setColor(newColor);
controller.render();
}
框架集成示例
Svelte 集成
<script lang="ts">
import { onMount } from 'svelte';
import { TextController, type TextItem } from './text-controller';
let canvasRef: HTMLCanvasElement;
let items = $state<TextItem[]>([
{ id: 1, text: 'Hello', position: { x: 10, y: 10 } }
]);
const controller = new TextController({
items,
onItemsChange: (newItems) => {
items = newItems;
}
});
onMount(() => {
const ctx = canvasRef.getContext('2d');
if (ctx) {
controller.setCanvas(ctx, canvasRef.width, canvasRef.height);
controller.render();
}
return () => controller.destroy();
});
// 监听 items 变化
$effect(() => {
controller.setItems(items);
controller.render();
});
</script>
<canvas
bind:this={canvasRef}
width={800}
height={600}
onmousedown={(e) => {
const rect = canvasRef.getBoundingClientRect();
controller.handleMouseDown(e.clientX - rect.left, e.clientY - rect.top);
controller.render();
}}
onmousemove={(e) => {
const rect = canvasRef.getBoundingClientRect();
controller.handleMouseMove(e.clientX - rect.left, e.clientY - rect.top);
}}
onmouseup={() => {
controller.handleMouseUp();
controller.render();
}}
/>
React 集成
import { useEffect, useRef, useState } from 'react';
import { TextController, type TextItem } from './text-controller';
function CanvasTextEditor() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const controllerRef = useRef<TextController | null>(null);
const [items, setItems] = useState<TextItem[]>([
{ id: 1, text: 'Hello', position: { x: 10, y: 10 } }
]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// 创建控制器
const controller = new TextController({
items,
onItemsChange: setItems
});
controller.setCanvas(ctx, canvas.width, canvas.height);
controller.render();
controllerRef.current = controller;
return () => controller.destroy();
}, []);
// 当 items 变化时更新
useEffect(() => {
if (controllerRef.current) {
controllerRef.current.setItems(items);
controllerRef.current.render();
}
}, [items]);
const handleMouseDown = (e: React.MouseEvent) => {
const canvas = canvasRef.current;
if (!canvas || !controllerRef.current) return;
const rect = canvas.getBoundingClientRect();
controllerRef.current.handleMouseDown(
e.clientX - rect.left,
e.clientY - rect.top
);
controllerRef.current.render();
};
const handleMouseMove = (e: React.MouseEvent) => {
const canvas = canvasRef.current;
if (!canvas || !controllerRef.current) return;
const rect = canvas.getBoundingClientRect();
controllerRef.current.handleMouseMove(
e.clientX - rect.left,
e.clientY - rect.top
);
};
const handleMouseUp = () => {
if (controllerRef.current) {
controllerRef.current.handleMouseUp();
controllerRef.current.render();
}
};
return (
<canvas
ref={canvasRef}
width={800}
height={600}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
style={{ cursor: controllerRef.current?.getCursorStyle() || 'default' }}
/>
);
}
Vue 集成
<template>
<canvas
ref="canvasRef"
:width="800"
:height="600"
:style="{ cursor: cursorStyle }"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
/>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { TextController, type TextItem } from './text-controller';
const canvasRef = ref<HTMLCanvasElement | null>(null);
const cursorStyle = ref('default');
const items = ref<TextItem[]>([
{ id: 1, text: 'Hello', position: { x: 10, y: 10 } }
]);
let controller: TextController | null = null;
onMounted(() => {
const canvas = canvasRef.value;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
controller = new TextController({
items: items.value,
onItemsChange: (newItems) => {
items.value = newItems;
}
});
controller.setCanvas(ctx, canvas.width, canvas.height);
controller.render();
});
onUnmounted(() => {
controller?.destroy();
});
// 监听 items 变化
watch(items, (newItems) => {
if (controller) {
controller.setItems(newItems);
controller.render();
}
}, { deep: true });
// 监听光标样式变化
watch(() => controller?.getCursorStyle(), (style) => {
if (style) cursorStyle.value = style;
});
const handleMouseDown = (e: MouseEvent) => {
const canvas = canvasRef.value;
if (!canvas || !controller) return;
const rect = canvas.getBoundingClientRect();
controller.handleMouseDown(e.clientX - rect.left, e.clientY - rect.top);
controller.render();
cursorStyle.value = controller.getCursorStyle();
};
const handleMouseMove = (e: MouseEvent) => {
const canvas = canvasRef.value;
if (!canvas || !controller) return;
const rect = canvas.getBoundingClientRect();
controller.handleMouseMove(e.clientX - rect.left, e.clientY - rect.top);
cursorStyle.value = controller.getCursorStyle();
};
const handleMouseUp = () => {
if (controller) {
controller.handleMouseUp();
controller.render();
cursorStyle.value = controller.getCursorStyle();
}
};
</script>
最佳实践
1. 性能优化
避免频繁渲染
拖拽过程中,handleMouseMove 会频繁触发。控制器已经内置了优化,只在必要时调用 render():
// ✅ 好的做法:只在 mousemove 时调用 handleMouseMove,不调用 render
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
controller.handleMouseMove(e.clientX - rect.left, e.clientY - rect.top);
// render() 会在内部自动调用
});
// ❌ 不好的做法:每次都手动调用 render
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
controller.handleMouseMove(e.clientX - rect.left, e.clientY - rect.top);
controller.render(); // 不需要,handleMouseMove 内部已经处理
});
使用 requestAnimationFrame
对于复杂场景,可以使用 requestAnimationFrame 来节流渲染:
let rafId: number | null = null;
canvas.addEventListener('mousemove', (e) => {
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
const rect = canvas.getBoundingClientRect();
controller.handleMouseMove(e.clientX - rect.left, e.clientY - rect.top);
rafId = null;
});
});
2. 内存管理
及时销毁控制器
在组件卸载时,务必调用 destroy() 方法:
// React
useEffect(() => {
const controller = new TextController({ /* ... */ });
return () => controller.destroy(); // 清理资源
}, []);
// Vue
onUnmounted(() => {
controller?.destroy();
});
// Svelte
onMount(() => {
const controller = new TextController({ /* ... */ });
return () => controller.destroy();
});
3. 坐标转换
正确处理 Canvas 坐标
始终使用 getBoundingClientRect() 来转换鼠标坐标:
const handleMouseEvent = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// 使用转换后的坐标
controller.handleMouseDown(x, y);
};
处理高 DPI 屏幕
对于高 DPI 屏幕,需要调整 Canvas 的分辨率:
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
controller.setCanvas(ctx, width, height); // 使用逻辑尺寸
4. 状态管理
使用受控模式
控制器设计为受控组件,外部状态是唯一的数据源:
// ✅ 好的做法:外部状态驱动
const [items, setItems] = useState<TextItem[]>([]);
const controller = new TextController({
items,
onItemsChange: setItems // 拖拽结束时更新外部状态
});
// 当外部状态变化时,同步到控制器
useEffect(() => {
controller.setItems(items);
controller.render();
}, [items]);
常见问题
Q: 为什么拖拽时文本位置不更新?
A: 确保在 mouseup 事件中调用了 handleMouseUp()。控制器只在拖拽结束时通过 onItemsChange 回调通知外部:
canvas.addEventListener('mouseup', () => {
controller.handleMouseUp(); // 必须调用
controller.render();
});
Q: 如何处理 Canvas 尺寸变化?
A: 当 Canvas 尺寸变化时,需要重新设置 Canvas 上下文:
window.addEventListener('resize', () => {
const newWidth = canvas.offsetWidth;
const newHeight = canvas.offsetHeight;
canvas.width = newWidth;
canvas.height = newHeight;
const ctx = canvas.getContext('2d');
controller.setCanvas(ctx, newWidth, newHeight);
controller.render();
});
Q: 光标样式不更新怎么办?
A: 需要在鼠标事件中手动更新光标样式:
canvas.addEventListener('mousemove', (e) => {
controller.handleMouseMove(x, y);
canvas.style.cursor = controller.getCursorStyle(); // 更新光标
});
Q: 如何实现文本项的删除功能?
A: 通过过滤 items 数组来删除:
function deleteItem(id: string | number) {
const newItems = items.filter(item => item.id !== id);
controller.setItems(newItems);
controller.render();
onItemsChange?.(newItems); // 通知外部
}
Q: 如何实现撤销/重做功能?
A: 维护一个历史记录栈:
const history: TextItem[][] = [];
let historyIndex = -1;
function saveHistory(items: TextItem[]) {
history.splice(historyIndex + 1);
history.push([...items]);
historyIndex++;
}
function undo() {
if (historyIndex > 0) {
historyIndex--;
const items = history[historyIndex];
controller.setItems(items);
controller.render();
}
}
function redo() {
if (historyIndex < history.length - 1) {
historyIndex++;
const items = history[historyIndex];
controller.setItems(items);
controller.render();
}
}
高级用法
1. 自定义渲染样式
虽然控制器内部处理渲染,但你可以通过继承来自定义样式:
class CustomTextController extends TextController {
// 重写绘制方法以自定义样式
protected drawTextItem(text: string, position: Point, isSelected: boolean): void {
if (!this.ctx) return;
// 自定义字体
this.ctx.font = 'bold 16px Arial';
// 自定义阴影效果
this.ctx.shadowColor = 'rgba(0, 0, 0, 0.5)';
this.ctx.shadowBlur = 4;
this.ctx.shadowOffsetX = 2;
this.ctx.shadowOffsetY = 2;
// 调用父类方法或完全自定义
super.drawTextItem(text, position, isSelected);
}
}
2. 坐标归一化
在实际应用中,通常需要将像素坐标转换为归一化坐标(0-1 范围)以适应不同尺寸:
// 归一化坐标(保存到后端)
function normalizePosition(position: Point, size: Size): Point {
return {
x: position.x / size.width,
y: position.y / size.height
};
}
// 反归一化(从后端加载)
function denormalizePosition(normalized: Point, size: Size): Point {
return {
x: normalized.x * size.width,
y: normalized.y * size.height
};
}
// 使用示例
const controller = new TextController({
items: backendItems.map(item => ({
...item,
position: denormalizePosition(item.position, canvasSize)
})),
onItemsChange: (items) => {
const normalizedItems = items.map(item => ({
...item,
position: normalizePosition(item.position, canvasSize)
}));
saveToBackend(normalizedItems);
}
});
故障排查
文本不显示
可能原因:
- Canvas 上下文未正确设置
- 文本颜色与背景色相同
- 文本位置超出 Canvas 边界
解决方法:
// 检查 Canvas 上下文
const ctx = canvas.getContext('2d');
console.log('Canvas context:', ctx);
// 检查文本项
console.log('Items:', controller.getItems());
// 检查颜色
controller.setColor([255, 0, 0, 1]); // 设置为红色测试
拖拽不流畅
可能原因:
- 频繁的状态更新
- 未使用 requestAnimationFrame
解决方法:
// 使用节流
let rafId: number | null = null;
canvas.addEventListener('mousemove', (e) => {
if (rafId) return;
rafId = requestAnimationFrame(() => {
controller.handleMouseMove(x, y);
rafId = null;
});
});
内存泄漏
可能原因:
- 未调用
destroy()方法 - 事件监听器未清理
解决方法:
// 确保清理资源
useEffect(() => {
const controller = new TextController({ /* ... */ });
return () => {
controller.destroy(); // 必须调用
};
}, []);