L4 · 白板
真正的白板产品 —— 自由摆放、多选框选、撤销重做、序列化导出、协作。demo 是一个约 300 行的迷你白板:不是让你照抄它上生产,而是让你亲手摸一遍这些能力,从而理解 L4 SDK(tldraw)到底替你写了多少代码 —— 这个玩具大概覆盖了 tldraw 能力面的 5%。
试这几下:
- N 放便签、R 画矩形、双击便签改文字、拖空白框选、Shift 加选、多选后整体拖动
- 干几步再 Ctrl+Z / Ctrl+Shift+Z —— 左下角历史深度实时变化;一次拖拽 = 一步历史
- 导出 JSON 下载完整文档 —— 序列化就是这么回事
撤销/重做:80 行快照栈
demo 的历史引擎是最小可用实现,思路值得记住:
const undoStack = [], redoStack = [];
const serialize = () => JSON.stringify(shapes);
// 每个"用户动作"提交一次:把动作前的世界压栈
function commit(prevSnapshot) {
undoStack.push(prevSnapshot);
redoStack.length = 0; // 新动作作废重做线
}
function undo() {
redoStack.push(serialize());
shapes = JSON.parse(undoStack.pop());
}
两个容易做错的细节:拖拽必须整体算一步(pointerdown 时先拍快照,pointerup 且确实移动了才 commit,否则撤销一次只回退 1px);新动作清空 redo 栈。快照栈在形状几百个时完全够用,形状上万才需要 op-based(记操作而非全量)方案——而那正是 SDK 的地盘。
生产:tldraw
⚠ 许可证:tldraw v2 起非宽松开源——免费使用必须保留 "Made with tldraw" 水印,商用去水印需购买 business license。这是选型决策因子。不能接受时用 MIT 的 Excalidraw(白板能力类似,自定义形状扩展弱于 tldraw)。
内置能力(demo 里手写的一切 + 你没空写的一切):无限画布 + 视口剔除、完整撤销重做、snapshot 序列化、形状系统、多选/对齐/复制粘贴/快捷键、PNG/SVG/PDF 导出、协作方案、响应式 store(大量形状只重渲变化部分)。
自定义形状是核心扩展点。简单矩形盒直接继承 BaseBoxShapeUtil(免写 getGeometry):
import { BaseBoxShapeUtil, HTMLContainer, T } from 'tldraw';
import type { RecordProps, TLBaseShape } from 'tldraw';
type CardShape = TLBaseShape<'card', { w: number; h: number; title: string }>;
export class CardShapeUtil extends BaseBoxShapeUtil<CardShape> {
static override type = 'card' as const;
static override props: RecordProps<CardShape> = {
w: T.number, h: T.number, title: T.string,
};
getDefaultProps() { return { w: 240, h: 120, title: '' }; }
component(shape: CardShape) {
return <HTMLContainer>{/* 任意 React UI */}</HTMLContainer>;
}
indicator(shape: CardShape) {
return <rect width={shape.props.w} height={shape.props.h} rx={8} />;
}
}
<Tldraw shapeUtils={[CardShapeUtil]} />
要点
- 框选的坐标要算两遍:命中检测在 world 坐标(矩形相交),框子本身画在 viewport 层(screen 坐标 = world × scale + pan)——demo 里这两行对照着看
- 文档态天然可序列化是白板架构的根基:形状就是一个扁平 JSON 数组,导出/导入/协同/历史全部建立在这上面
- 行内编辑(双击便签)时记得让快捷键和拖拽让位给 contenteditable:
e.target.isContentEditable一律 return - 键盘可达:工具切换(V/H/N/R)、Delete、Ctrl+D,成本极低体验差别巨大
何时不要用 L4
源码(折叠)
demo 源码:assets/demo-l4-whiteboard.html(自包含单页,约 300 行)
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>L4 · 迷你白板 — 撤销/框选/序列化的最小实现</title>
<style>
* { box-sizing: border-box; margin: 0; }
html, body { height: 100%; overflow: hidden; }
body { font-family: system-ui, "PingFang SC", "Microsoft YaHei", sans-serif; }
.viewport {
position: relative; width: 100%; height: 100vh; overflow: hidden;
background: #f7f8fa; touch-action: none;
}
.viewport[data-tool="hand"] { cursor: grab; }
.viewport[data-tool="hand"].panning { cursor: grabbing; }
.viewport[data-tool="note"],
.viewport[data-tool="rect"] { cursor: crosshair; }
.grid {
position: absolute; inset: 0; pointer-events: none;
background-image: radial-gradient(circle, rgba(30,41,59,.16) 1px, transparent 1px);
background-size: 28px 28px;
}
.world { position: absolute; top: 0; left: 0; transform-origin: 0 0; will-change: transform; }
.shape { position: absolute; user-select: none; }
.shape.selected { outline: 2px solid #3b82f6; outline-offset: 2px; }
.shape.note {
padding: 10px 12px; font-size: 13px; line-height: 1.5; color: #493d1e;
border-radius: 4px; box-shadow: 0 3px 10px rgba(15,23,42,.14);
min-width: 120px; max-width: 220px; cursor: grab;
}
.shape.note[contenteditable="true"] { cursor: text; outline: 2px solid #f59e0b; }
.shape.rect {
border: 2px solid #64748b; border-radius: 8px;
background: rgba(148,163,184,.10); cursor: grab;
}
.marquee {
position: absolute; border: 1px solid #3b82f6;
background: rgba(59,130,246,.08); pointer-events: none; z-index: 4;
}
.toolbar {
position: absolute; left: 50%; transform: translateX(-50%); top: 12px; z-index: 6;
display: flex; gap: 4px; padding: 5px;
background: #fff; border: 1px solid #e2e8f0; border-radius: 12px;
box-shadow: 0 4px 16px rgba(15,23,42,.10);
}
.toolbar button {
font: inherit; font-size: 12.5px; padding: 6px 11px; cursor: pointer;
border: 1px solid transparent; border-radius: 8px; background: none; color: #334155;
}
.toolbar button:hover { background: #f1f5f9; }
.toolbar button.active { background: #dbeafe; color: #1d4ed8; }
.toolbar button:disabled { opacity: .35; cursor: default; }
.toolbar .sep { width: 1px; background: #e2e8f0; margin: 2px 3px; }
.hud {
position: absolute; left: 12px; bottom: 12px; z-index: 5;
font-size: 12px; color: #475569; display: flex; gap: 8px;
}
.hud span {
background: rgba(255,255,255,.9); border: 1px solid #e2e8f0;
border-radius: 8px; padding: 5px 10px; font-variant-numeric: tabular-nums;
}
.tip {
position: absolute; right: 12px; top: 12px; z-index: 5;
font-size: 11.5px; color: #94a3b8; background: rgba(255,255,255,.85);
border: 1px solid #e2e8f0; border-radius: 8px; padding: 6px 10px; line-height: 1.7;
}
</style>
</head>
<body>
<div class="viewport" id="viewport" data-tool="select">
<div class="grid" id="grid"></div>
<div class="world" id="world"></div>
<div class="toolbar">
<button data-tool="select" class="active" title="V">选择</button>
<button data-tool="hand" title="H">平移</button>
<button data-tool="note" title="N">便签</button>
<button data-tool="rect" title="R">矩形</button>
<div class="sep"></div>
<button id="undo" disabled title="Ctrl+Z">撤销</button>
<button id="redo" disabled title="Ctrl+Shift+Z">重做</button>
<div class="sep"></div>
<button id="dup" title="Ctrl+D">复制</button>
<button id="del" title="Delete">删除</button>
<button id="export">导出 JSON</button>
</div>
<div class="tip">V 选择 · N 便签 · R 矩形 · H 平移<br/>
框选空白多选 · Shift 加选 · 双击便签编辑文字<br/>
Ctrl+Z / Ctrl+Shift+Z 撤销重做 · Delete 删除</div>
<div class="hud">
<span id="statShapes">0 个形状</span>
<span id="statHistory">历史 0 / 0</span>
<span id="statZoom">100%</span>
</div>
</div>
<script>
/* ============ 文档态 ============ */
let shapes = [
{ id: 's1', type: 'note', x: 160, y: 150, color: '#fef3c7', text: '双击我改文字' },
{ id: 's2', type: 'note', x: 420, y: 240, color: '#dcfce7', text: '拖拽移动,Shift 点我加选' },
{ id: 's3', type: 'rect', x: 130, y: 300, w: 220, h: 120 },
];
let selection = new Set();
let uid = 4;
const NOTE_COLORS = ['#fef3c7', '#dcfce7', '#e0e7ff', '#fce7f3', '#cffafe'];
/* ============ 历史引擎:快照栈(做一步,先存旧世界) ============ */
const undoStack = [], redoStack = [];
const serialize = () => JSON.stringify(shapes);
function commit(prev) { // prev = 变更前的序列化快照
undoStack.push(prev);
redoStack.length = 0;
updateHud();
}
function undo() {
if (!undoStack.length) return;
redoStack.push(serialize());
shapes = JSON.parse(undoStack.pop());
selection.clear(); render();
}
function redo() {
if (!redoStack.length) return;
undoStack.push(serialize());
shapes = JSON.parse(redoStack.pop());
selection.clear(); render();
}
/* ============ 视口 ============ */
let scale = 1, panX = 60, panY = 40;
const GRID = 28;
const viewport = document.getElementById('viewport');
const world = document.getElementById('world');
const grid = document.getElementById('grid');
function applyTransform() {
world.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
grid.style.backgroundSize = `${GRID * scale}px ${GRID * scale}px`;
grid.style.backgroundPosition = `${panX % (GRID * scale)}px ${panY % (GRID * scale)}px`;
document.getElementById('statZoom').textContent = Math.round(scale * 100) + '%';
}
const screenToWorld = (sx, sy) => ({ x: (sx - panX) / scale, y: (sy - panY) / scale });
viewport.addEventListener('wheel', (e) => {
e.preventDefault();
const rect = viewport.getBoundingClientRect();
const ax = e.clientX - rect.left, ay = e.clientY - rect.top;
const next = Math.min(3, Math.max(0.2, scale * Math.exp(e.deltaY * -0.002)));
const ratio = 1 - next / scale;
panX += (ax - panX) * ratio; panY += (ay - panY) * ratio; scale = next;
applyTransform();
}, { passive: false });
/* ============ 渲染(全量重建,形状少时足够) ============ */
function render() {
world.innerHTML = '';
for (const s of shapes) {
const el = document.createElement('div');
el.className = `shape ${s.type}` + (selection.has(s.id) ? ' selected' : '');
el.dataset.id = s.id;
el.style.left = s.x + 'px';
el.style.top = s.y + 'px';
if (s.type === 'note') {
el.style.background = s.color;
el.textContent = s.text;
} else {
el.style.width = s.w + 'px';
el.style.height = s.h + 'px';
}
world.appendChild(el);
}
updateHud();
}
function updateHud() {
document.getElementById('statShapes').textContent = `${shapes.length} 个形状`;
document.getElementById('statHistory').textContent = `历史 ${undoStack.length} / ${redoStack.length}`;
document.getElementById('undo').disabled = !undoStack.length;
document.getElementById('redo').disabled = !redoStack.length;
}
/* ============ 工具切换 ============ */
let tool = 'select';
function setTool(t) {
tool = t;
viewport.dataset.tool = t;
document.querySelectorAll('.toolbar [data-tool]').forEach(b =>
b.classList.toggle('active', b.dataset.tool === t));
}
document.querySelectorAll('.toolbar [data-tool]').forEach(b =>
b.addEventListener('click', () => setTool(b.dataset.tool)));
/* ============ 指针交互 ============ */
let action = null; // {kind:'pan'|'move'|'marquee'|'draw', ...}
viewport.addEventListener('pointerdown', (e) => {
if (e.target.closest('.toolbar')) return;
if (e.target.isContentEditable) return;
viewport.setPointerCapture(e.pointerId);
const shapeEl = e.target.closest('.shape');
const w = screenToWorld(e.clientX - viewport.getBoundingClientRect().left,
e.clientY - viewport.getBoundingClientRect().top);
if (tool === 'hand' || e.button === 1) {
action = { kind: 'pan', lastX: e.clientX, lastY: e.clientY };
viewport.classList.add('panning');
return;
}
if (tool === 'note' || tool === 'rect') {
const prev = serialize();
const s = tool === 'note'
? { id: 's' + uid++, type: 'note', x: w.x, y: w.y,
color: NOTE_COLORS[shapes.length % NOTE_COLORS.length], text: '新便签' }
: { id: 's' + uid++, type: 'rect', x: w.x, y: w.y, w: 180, h: 110 };
shapes.push(s); commit(prev);
selection = new Set([s.id]);
setTool('select'); render();
return;
}
// select 工具
if (shapeEl) {
const id = shapeEl.dataset.id;
if (e.shiftKey) { selection.has(id) ? selection.delete(id) : selection.add(id); }
else if (!selection.has(id)) selection = new Set([id]);
render();
action = {
kind: 'move', start: w, moved: false, prev: serialize(),
origins: [...selection].map(sid => {
const s = shapes.find(x => x.id === sid);
return { s, x: s.x, y: s.y };
}),
};
} else {
if (!e.shiftKey) { selection.clear(); render(); }
const box = document.createElement('div');
box.className = 'marquee';
viewport.appendChild(box);
action = { kind: 'marquee', start: w, box, base: new Set(selection) };
}
});
viewport.addEventListener('pointermove', (e) => {
if (!action) return;
const rect = viewport.getBoundingClientRect();
const w = screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
if (action.kind === 'pan') {
panX += e.clientX - action.lastX; panY += e.clientY - action.lastY;
action.lastX = e.clientX; action.lastY = e.clientY;
applyTransform();
} else if (action.kind === 'move') {
const dx = w.x - action.start.x, dy = w.y - action.start.y;
if (Math.abs(dx) + Math.abs(dy) > 0.5) action.moved = true;
for (const o of action.origins) { o.s.x = o.x + dx; o.s.y = o.y + dy; }
// 拖拽中只改被拖元素的 style,不整树重渲
for (const o of action.origins) {
const el = world.querySelector(`[data-id="${o.s.id}"]`);
el.style.left = o.s.x + 'px'; el.style.top = o.s.y + 'px';
}
} else if (action.kind === 'marquee') {
const x1 = Math.min(action.start.x, w.x), y1 = Math.min(action.start.y, w.y);
const x2 = Math.max(action.start.x, w.x), y2 = Math.max(action.start.y, w.y);
// marquee 画在 viewport 层(screen 坐标)
action.box.style.left = (x1 * scale + panX) + 'px';
action.box.style.top = (y1 * scale + panY) + 'px';
action.box.style.width = ((x2 - x1) * scale) + 'px';
action.box.style.height = ((y2 - y1) * scale) + 'px';
// world 坐标命中检测
selection = new Set(action.base);
for (const s of shapes) {
const el = world.querySelector(`[data-id="${s.id}"]`);
const sw = s.w ?? el.offsetWidth, sh = s.h ?? el.offsetHeight;
if (s.x < x2 && s.x + sw > x1 && s.y < y2 && s.y + sh > y1) selection.add(s.id);
}
world.querySelectorAll('.shape').forEach(el =>
el.classList.toggle('selected', selection.has(el.dataset.id)));
}
});
function endAction() {
if (!action) return;
if (action.kind === 'move' && action.moved) commit(action.prev); // 一次拖拽 = 一步历史
if (action.kind === 'marquee') action.box.remove();
viewport.classList.remove('panning');
action = null;
updateHud();
}
viewport.addEventListener('pointerup', endAction);
viewport.addEventListener('pointercancel', endAction);
/* 双击便签 → 行内编辑 */
viewport.addEventListener('dblclick', (e) => {
const el = e.target.closest('.shape.note');
if (!el) return;
const s = shapes.find(x => x.id === el.dataset.id);
const prev = serialize();
el.contentEditable = 'true';
el.focus();
document.getSelection().selectAllChildren(el);
el.addEventListener('blur', () => {
el.contentEditable = 'false';
const text = el.textContent.trim() || '(空)';
if (text !== s.text) { s.text = text; commit(prev); }
render();
}, { once: true });
});
/* ============ 命令 ============ */
function deleteSelection() {
if (!selection.size) return;
const prev = serialize();
shapes = shapes.filter(s => !selection.has(s.id));
selection.clear(); commit(prev); render();
}
function duplicateSelection() {
if (!selection.size) return;
const prev = serialize();
const clones = shapes.filter(s => selection.has(s.id))
.map(s => ({ ...s, id: 's' + uid++, x: s.x + 24, y: s.y + 24 }));
shapes.push(...clones);
selection = new Set(clones.map(c => c.id));
commit(prev); render();
}
document.getElementById('undo').onclick = undo;
document.getElementById('redo').onclick = redo;
document.getElementById('del').onclick = deleteSelection;
document.getElementById('dup').onclick = duplicateSelection;
document.getElementById('export').onclick = () => {
const blob = new Blob([JSON.stringify({ version: 1, shapes }, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'whiteboard.json';
a.click();
URL.revokeObjectURL(a.href);
};
window.addEventListener('keydown', (e) => {
if (e.target.isContentEditable) return;
const mod = e.ctrlKey || e.metaKey;
if (mod && e.key.toLowerCase() === 'z') { e.preventDefault(); e.shiftKey ? redo() : undo(); }
else if (mod && e.key.toLowerCase() === 'y') { e.preventDefault(); redo(); }
else if (mod && e.key.toLowerCase() === 'd') { e.preventDefault(); duplicateSelection(); }
else if (e.key === 'Delete' || e.key === 'Backspace') deleteSelection();
else if (e.key.toLowerCase() === 'v') setTool('select');
else if (e.key.toLowerCase() === 'h') setTool('hand');
else if (e.key.toLowerCase() === 'n') setTool('note');
else if (e.key.toLowerCase() === 'r') setTool('rect');
});
applyTransform();
render();
</script>
</body>
</html>