DesignCanvas
design-canvas视觉设计画布 · 无限平移缩放 + 元素选择框 + 拖拽移动与八向 resize · 受控 items 托管几何、children 作自绘图层 · 键盘可达、几何抽纯函数带单测 · 区别 Flow(节点编排):这是自由排列的设计画布
用法
基础用法
受控 items:拖元素移动、拖八向手柄改尺寸、点空白取消选中;Tab 在元素间走、方向键微调、Delete 删除。
首屏横幅
特性卡片
行动按钮
tsx
const [items, setItems] = useState(initial);
const [selected, setSelected] = useState<string | null>("hero");
<div className="h-[360px] w-full overflow-hidden rounded border border-border">
<DesignCanvas
items={items}
onItemsChange={setItems}
selectedElement={selected}
onSelect={setSelected}
onItemDelete={(id) => setItems((p) => p.filter((i) => i.id !== id))}
renderItem={(item) => <div className="h-full w-full …">{item.label}</div>}
/>
</div>网格吸附
grid 画底纹、snap 定步长;两者独立,可以只画不吸附,也可以吸附到比底纹更细的步长。
首屏横幅
特性卡片
行动按钮
tsx
<DesignCanvas items={items} onItemsChange={setItems} grid={20} snap={20} … />锁定元素
locked 的元素仍可选中、仍能 Tab 到,但拖不动也不出 resize 手柄。
锁定的画板
特性卡片
行动按钮
tsx
<DesignCanvas
items={[{ id: "hero", x: 40, y: 32, width: 260, height: 120, locked: true }, …]}
onItemsChange={setItems}
/>受控视口 + 自绘图层
zoom / pan 受控后可与外部工具条联动;children 直接挂进世界坐标层,跟随平移缩放但几何自持。
缩放 80%平移 0 / 0
首屏横幅
特性卡片
行动按钮
画板 A · 320×280
tsx
const [zoom, setZoom] = useState(0.8);
const [pan, setPan] = useState({ x: 0, y: 0 });
<DesignCanvas
items={items}
onItemsChange={setItems}
zoom={zoom}
onZoomChange={setZoom}
pan={pan}
onPanChange={setPan}
>
<div className="pointer-events-none absolute left-[40px] top-[8px] text-[10px] text-muted">
画板 A · 320×280
</div>
</DesignCanvas>何时用
在一块无限画布上自由摆放矩形元素——页面草稿、看板画板、海报/幻灯排版、低代码可视化编辑器的画布区。元素的位置和尺寸就是数据本身,没有「谁连到谁」的拓扑。
- 要编排的是节点与连线的拓扑(AI 工作流、DAG、流程图)用 Flow:它管连接桩、贝塞尔连线、按拓扑自动分层,本组件一根线都不画。
- 只要把一个固定设计尺寸等比缩放铺满容器(大屏可视化)用 FitScreen:它没有平移、没有选中、没有编辑。
- 只要多列卡片在列间流转用 Kanban;单列表排序用 Sortable。
DesignCanvas 与 Flow 共用同一套视口数学(screenToCanvas / zoomAtPoint / clampZoom 复用自 Flow 的几何模块),所以两者的滚轮手感、坐标约定完全一致,同一页面里混用不会有「一个跟手一个不跟手」的割裂。
导入
ts
import { DesignCanvas, canvasToScreen, itemsBounds, moveRect, normalizeRect, resizeRect, snapTo } from "@hulianui/ui"Props
| 名称 | 类型 | 默认 | 说明 |
|---|---|---|---|
| items | DesignCanvasItem[] | [] | 受控元素数组({ id, x, y, width, height, locked?, label? })。画布托管这些元素的几何 |
| zoom | number | — | 缩放受控。传了就以它为准,组件只回吐 onZoomChange |
| defaultZoom | number | 1 | 非受控初始缩放 |
| pan | { x, y } | — | 平移受控(画布原点在容器内的屏幕像素偏移) |
| defaultPan | { x, y } | { x: 0, y: 0 } | 非受控初始平移 |
| selectedElement | string | null | — | 选中受控(元素 id / 路径) |
| defaultSelectedElement | string | null | null | 非受控初始选中 |
| minZoom | number | 0.1 | 缩放下限 |
| maxZoom | number | 4 | 缩放上限 |
| grid | boolean | number | true | 网格底纹:true=40 世界单位,数字=自定义边长,false 关闭 |
| snap | number | 0 | 拖动 / resize / 方向键的吸附步长(世界单位),0 = 不吸附 |
| minItemSize | number | 8 | 元素最小宽高(世界单位) |
| wheelBehavior | "zoom" | "pan" | "zoom" | 滚轮默认行为,按住 Ctrl/⌘ 反转 |
| controls | boolean | true | 是否显示右下角缩放工具条 |
| readOnly | boolean | false | 禁用拖动 / resize / 删除,仍可选中、平移、缩放 |
| className | string | — | 画布外层类名(须有确定高度,组件填满) |
| labels | Partial\<DesignCanvasLabels\> | — | 覆盖取自 locale 的文案(canvas / item / zoomIn / zoomOut / fitView / resetView);不传则跟随 ConfigProvider |
| apiRef | MutableRefObject\<DesignCanvasApi | null\> | — | 命令式句柄(zoomIn / zoomOut / reset / fitView / screenToCanvas) |
Events
| 事件 | 类型 | 说明 |
|---|---|---|
| onItemsChange | (items: DesignCanvasItem[]) => void | 几何变化后回吐整组新 items。拖动 / resize 在指针抬起时提交一次,方向键每次按下提交一次 |
| onItemDelete | (id: string) => void | 选中元素后按 Delete / Backspace。不传则不响应删除键 |
| onSelect | (elementPath: string | null) => void | 选中变化(点元素 = 它的 id,点空白 = null) |
| onZoomChange | (zoom: number) => void | 缩放变化 |
| onPanChange | (pan: { x, y }) => void | 平移变化 |
Slots
| 插槽 | 类型 | 说明 |
|---|---|---|
| renderItem | (item, state: { selected, dragging, resizing }) => ReactNode | 渲染元素内容(定位、选择框、手柄由组件负责)。不传则渲染占位空框 |
| children | ReactNode | 自绘图层,直接挂进世界坐标层(跟随平移缩放)。几何由你自己摆 |
无障碍
- 画布是
role="application"+tabIndex=0,聚焦时有可见焦点环;aria-label走labels.canvas。文案优先级是labelsprop > ConfigProvider 的 locale > 内置中文兜底,所以整站换语言时画布跟着变,不必逐处传labels。 - 每个
items元素是可聚焦节点,Tab 依次走过;聚焦即选中(onSelect会回吐),选中项带aria-current="true"与data-selected,焦点环用focus-visible:ring。 - 元素的无障碍名取
label,缺省回落到id——所以id请用人能读的串("hero"而不是"a1f3")。 - 纯键盘通路完整:方向键移动 1 世界单位(开了
snap则按网格步进),Shift+方向键十倍粗调,Alt+方向键改尺寸,Delete/Backspace删除。八个 resize 手柄因此是aria-hidden的纯指针装饰,不占 Tab 序。 - 焦点落在元素内部的
input/textarea/contenteditable时,方向键与 Delete 交还给控件,不被画布劫持。
禁忌 / 坑
- 外层
className必须有确定高度(如h-[420px]或父级撑满),画布按外层尺寸填充——高度塌缩则画布不可见。 - 全受控:
onItemsChange回吐的是整组新 items,不写回 state 画布就弹回原位。拖动 / resize 中途只改内部草稿,不会每帧回吐;要实时联动外部面板请读renderItem的dragging/resizing。 children里的元素画布不托管几何:给它加data-canvas-item只买到「可被选中」,买不到拖拽与 resize,也不会出选择框。要托管就放进items。- 元素内部放按钮 / 输入框请照常写——按在这类控件上只选中不起拖(与 Kanban / Sortable 同一口径)。要让某个自定义元素也免于拖拽,给它加
data-no-drag。 - 右键在画布上是「拖拽平移」手势的一部分,因此系统右键菜单被抑制。需要自定义右键菜单请自行在
renderItem内监听contextmenu并stopPropagation。 - 滚轮事件用
{ passive: false }注册并preventDefault,画布区域不会滚动祖先容器——这是有意为之,别把画布塞进需要靠滚轮滚动的窄栏里。 resize拖过锚定边会翻转(与 Figma 一致),不是卡死在minItemSize。若你的业务不接受翻转,请在onItemsChange里自行拒绝。- 客户端组件(原生 Pointer Events),必须在 client 上下文用。
相关
Flow · FitScreen · Kanban · Sortable · GridPattern · ImageViewer