跳转至

L1 · 原生无限画布

零依赖:三个变量(scale / panX / panY)+ 一条锚点缩放公式,就是一块能缩放、能平移、卡片能拖着走、还带连线和网格的无限画布。适合静态作品集、展示页、任何不需要持久化编辑的场景。

试这几下:

  • 滚轮缩放 —— 注意缩放始终锚定鼠标位置,鼠标底下那个点不漂移
  • 拖空白平移画布,拖卡片移动单张卡 —— 连线实时跟随
  • 缩放到很小再点适应全部,画布按内容包围盒回正

结构

整个画布只有三层,各司其职:

.viewport   裁剪窗口,overflow:hidden,自己永远不动,接收所有鼠标事件
├─ .grid    网格:CSS background,不进 transform 层
└─ .world   世界层:唯一被 transform 的元素
   ├─ svg.wires   连线(与节点同层,缩放时自动同步)
   └─ .card ×N    普通 absolute 定位 div,left/top 是 world 坐标

要点

  • 锚点缩放一条公式ratio = 1 - next/scale; panX += (ax - panX) * ratio —— 保证锚点处的 world 坐标缩放前后不变。这条公式 L1 到 L4 通用,值得背下来
  • 网格用 CSS background 而不是画图background-size 乘 scale、background-position28 * scale 取模,跟随视口几乎零开销;千万别用 SVG 画一整张网格
  • will-change: transform 把 world 层提升成独立合成层,平移缩放走 GPU,不触发重排
  • 坐标换算只有一对函数screenToWorld / worldToScreen,拖卡片时把指针换算到 world 坐标再减初始偏移,天然适配任意缩放级别
  • 一套 Pointer Events 通吃鼠标/触屏/触控笔;setPointerCapture 保证快速甩动不脱手;touch-action: none 防止触屏把拖动抢去做页面滚动
  • wheel 监听要 { passive: false } 才能 preventDefault,否则浏览器照常滚动页面
  • 连线是 world 层里一个 overflow: visible<svg>,三次贝塞尔从源卡右侧到目标卡左侧,只在卡片移动时重画

局限(升级信号)

  • 没有撤销/重做、没有序列化、没有视口剔除 —— 需要"编辑器"就该去 L2L4
  • 状态全靠全局变量,节点带业务逻辑(表单、播放器)后难以为继 —— 去 L2
  • 卡片上千后 DOM 本身拖慢渲染

源码(折叠)

demo 源码:assets/demo-l1-vanilla.html(自包含单页,零依赖)
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>L1 · 零依赖无限画布 — vanilla JS + CSS transform</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; cursor: grab; touch-action: none;
  }
  .viewport.panning { cursor: grabbing; }

  /* 网格:CSS background 而不是画图 —— size 跟缩放、position 跟平移 */
  .grid {
    position: absolute; inset: 0; pointer-events: none;
    background-image: radial-gradient(circle, rgba(30,41,59,.18) 1px, transparent 1px);
    background-size: 28px 28px;
  }

  /* 世界层:唯一被 transform 的元素,所有内容住在里面 */
  .world {
    position: absolute; top: 0; left: 0;
    transform-origin: 0 0; will-change: transform;
  }

  svg.wires { position: absolute; top: 0; left: 0; overflow: visible; pointer-events: none; }
  svg.wires path { fill: none; stroke: #94a3b8; stroke-width: 1.5; }

  .card {
    position: absolute; width: 190px; background: #fff;
    border: 1px solid #dbe1ea; border-radius: 10px;
    box-shadow: 0 2px 8px rgba(15,23,42,.08);
    cursor: grab; user-select: none;
  }
  .card.dragging { cursor: grabbing; box-shadow: 0 10px 26px rgba(15,23,42,.20); }
  .card h4 { font-size: 13px; padding: 9px 12px 8px; border-bottom: 1px solid #eef1f6; color: #0f172a; }
  .card h4 .kind { float: right; font-size: 10px; font-weight: normal; color: #6d8fd4; }
  .card p { font-size: 12px; color: #64748b; padding: 8px 12px 10px; line-height: 1.55; }

  .hud {
    position: absolute; left: 12px; bottom: 12px; z-index: 5;
    display: flex; gap: 8px; align-items: center;
    font-size: 12px; color: #475569;
  }
  .hud button {
    font: inherit; padding: 4px 12px; border-radius: 7px; cursor: pointer;
    border: 1px solid #cbd5e1; background: #fff; color: #334155;
  }
  .hud button:hover { border-color: #94a3b8; }
  .hud .zoom { min-width: 46px; text-align: center; 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">
  <div class="grid" id="grid"></div>
  <div class="world" id="world">
    <svg class="wires" id="wires" width="1" height="1"></svg>
    <!-- 卡片由 JS 渲染:普通 absolute 定位 div,样式全用 HTML/CSS 写 -->
  </div>
  <div class="tip">滚轮缩放(锚定鼠标)· 拖空白平移 · 拖卡片移动</div>
  <div class="hud">
    <button id="zoomOut"></button>
    <span class="zoom" id="zoomLabel">100%</span>
    <button id="zoomIn"></button>
    <button id="fit">适应全部</button>
  </div>
</div>

<script>
/* ============ 数据:world 坐标下的卡片与连线 ============ */
const cards = [
  { id: 'a', x: 120,  y: 140, title: '需求收集',  kind: '输入', body: '普通的 absolute 定位 div,鼠标事件直接绑,样式全是 HTML/CSS。' },
  { id: 'b', x: 470,  y: 60,  title: '方案设计',  kind: '加工', body: '拖我试试 —— 屏幕位移除以 scale 换算回 world 坐标。' },
  { id: 'c', x: 470,  y: 300, title: '技术评审',  kind: '加工', body: '连线是 world 层里的一个 <svg>,缩放时与节点同步变换。' },
  { id: 'd', x: 830,  y: 180, title: '发布上线',  kind: '输出', body: '网格用 CSS background 实现,跟随缩放几乎零开销。' },
];
const wires = [ ['a','b'], ['a','c'], ['b','d'], ['c','d'] ];

/* ============ 视口状态:三个数就是全部 ============ */
let scale = 1, panX = 0, panY = 0;
const GRID = 28, SCALE_MIN = 0.2, SCALE_MAX = 3;

const viewport = document.getElementById('viewport');
const world    = document.getElementById('world');
const grid     = document.getElementById('grid');
const wiresSvg = document.getElementById('wires');
const zoomLabel = document.getElementById('zoomLabel');

function applyTransform() {
  world.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
  // 网格跟随:size 乘 scale,position 取模平移 —— 关键技巧
  grid.style.backgroundSize = `${GRID * scale}px ${GRID * scale}px`;
  grid.style.backgroundPosition = `${panX % (GRID * scale)}px ${panY % (GRID * scale)}px`;
  zoomLabel.textContent = Math.round(scale * 100) + '%';
}

const screenToWorld = (sx, sy) => ({ x: (sx - panX) / scale, y: (sy - panY) / scale });

/* 缩放:以锚点(ax, ay)为不动点 */
function zoomAt(ax, ay, next) {
  next = Math.min(SCALE_MAX, Math.max(SCALE_MIN, next));
  if (next === scale) return;
  const ratio = 1 - next / scale;
  panX += (ax - panX) * ratio;
  panY += (ay - panY) * ratio;
  scale = next;
  applyTransform();
}

/* ============ 渲染卡片 & 连线 ============ */
const cardEls = {};
for (const c of cards) {
  const el = document.createElement('div');
  el.className = 'card';
  el.dataset.id = c.id;
  el.style.left = c.x + 'px';
  el.style.top  = c.y + 'px';
  el.innerHTML = `<h4>${c.title}<span class="kind">${c.kind}</span></h4><p>${c.body}</p>`;
  world.appendChild(el);
  cardEls[c.id] = el;
}

function drawWires() {
  const seg = [];
  for (const [from, to] of wires) {
    const a = cards.find(c => c.id === from), b = cards.find(c => c.id === to);
    const ea = cardEls[from], eb = cardEls[to];
    const x1 = a.x + ea.offsetWidth, y1 = a.y + ea.offsetHeight / 2;
    const x2 = b.x,                  y2 = b.y + eb.offsetHeight / 2;
    const mx = (x1 + x2) / 2;
    seg.push(`<path d="M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}"/>`);
  }
  wiresSvg.innerHTML = seg.join('');
}

/* ============ 交互:一套 pointer events 通吃 ============ */
let mode = null;          // 'pan' | 'card'
let dragCard = null, dragOffset = null, lastX = 0, lastY = 0;

viewport.addEventListener('pointerdown', (e) => {
  const cardEl = e.target.closest('.card');
  viewport.setPointerCapture(e.pointerId);
  if (cardEl) {
    mode = 'card';
    dragCard = cards.find(c => c.id === cardEl.dataset.id);
    const w = screenToWorld(e.clientX, e.clientY);
    dragOffset = { x: w.x - dragCard.x, y: w.y - dragCard.y };
    cardEl.classList.add('dragging');
  } else {
    mode = 'pan';
    lastX = e.clientX; lastY = e.clientY;
    viewport.classList.add('panning');
  }
});

viewport.addEventListener('pointermove', (e) => {
  if (mode === 'pan') {
    panX += e.clientX - lastX; panY += e.clientY - lastY;
    lastX = e.clientX; lastY = e.clientY;
    applyTransform();
  } else if (mode === 'card') {
    const w = screenToWorld(e.clientX, e.clientY);
    dragCard.x = w.x - dragOffset.x;
    dragCard.y = w.y - dragOffset.y;
    const el = cardEls[dragCard.id];
    el.style.left = dragCard.x + 'px';
    el.style.top  = dragCard.y + 'px';
    drawWires();
  }
});

function endDrag() {
  if (mode === 'card') cardEls[dragCard.id].classList.remove('dragging');
  viewport.classList.remove('panning');
  mode = null; dragCard = null;
}
viewport.addEventListener('pointerup', endDrag);
viewport.addEventListener('pointercancel', endDrag);

/* 滚轮缩放(指数步进,锚定鼠标位置) */
viewport.addEventListener('wheel', (e) => {
  e.preventDefault();
  const rect = viewport.getBoundingClientRect();
  zoomAt(e.clientX - rect.left, e.clientY - rect.top, scale * Math.exp(e.deltaY * -0.002));
}, { passive: false });

/* HUD */
document.getElementById('zoomIn').onclick  = () => zoomAt(viewport.clientWidth / 2, viewport.clientHeight / 2, scale * 1.25);
document.getElementById('zoomOut').onclick = () => zoomAt(viewport.clientWidth / 2, viewport.clientHeight / 2, scale / 1.25);
document.getElementById('fit').onclick = fitAll;

function fitAll() {
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
  for (const c of cards) {
    const el = cardEls[c.id];
    minX = Math.min(minX, c.x); minY = Math.min(minY, c.y);
    maxX = Math.max(maxX, c.x + el.offsetWidth);
    maxY = Math.max(maxY, c.y + el.offsetHeight);
  }
  const pad = 60;
  const sw = viewport.clientWidth  / (maxX - minX + pad * 2);
  const sh = viewport.clientHeight / (maxY - minY + pad * 2);
  scale = Math.min(SCALE_MAX, Math.min(sw, sh));
  panX = (viewport.clientWidth  - (maxX - minX) * scale) / 2 - minX * scale;
  panY = (viewport.clientHeight - (maxY - minY) * scale) / 2 - minY * scale;
  applyTransform();
}

drawWires();
fitAll();
</script>
</body>
</html>