@rockyf/easy-design
v0.4.1
Published
纯 JS 开发的简易设计器画布,交互风格对齐 Figma 画布区。
Readme
Easy Design (PureJs)
纯 JS 开发的简易设计器画布,交互风格对齐 Figma 画布区。
库本身只提供画布与编辑逻辑,不内置工具栏 / 属性面板 / 历史面板 UI。撤销、缩放、属性修改等能力全部通过 API 与回调暴露,由宿主应用自行渲染控件。
截图


目录
Intro
- 基于包围盒的树型节点可视化编辑,覆盖 Figma 画布区的主流纯操作(选择、操纵、导航、编辑、历史、反馈辅助)。
- 仅内置
NodeWrapper与分组类型group;节点内容由开发者通过INodeImpl实现(可参考apps/demo/src/nodes)。 - 支持编辑态与运行态:编辑态叠加控制层(选中框、手柄、右键菜单、文本编辑等);运行态铺满容器、不可编辑。
- 不内置工具栏 UI。历史栈、缩放、属性读写均有公开 API,宿主用自己的 UI 调用即可。
架构(src/core)
| 目录 | 职责 |
|------|------|
| control/ | CtrlLayer 编排、指针 InteractionSession、chrome DOM |
| selection/ | 选择/命中/锁定策略、活动层 |
| viewport/ | zoom/pan 单源与离散过渡 |
| history/ | 撤销/重做栈 |
| manipulation/ | 移动/缩放/旋转/选中批操作 |
| ui/ | 标尺、参考线、右键菜单、默认快捷键 |
| Stage / CommandManager / NodeWrapper | 文档、命令、节点树 |
选择/锁定契约见 docs/selection-model.md。
Install
pnpm add @rockyf/easy-design
# or
yarn add @rockyf/easy-design
# or
npm i -S @rockyf/easy-design样式需一并引入(构建产物):
import '@rockyf/easy-design/style.css'
// 或
import '@rockyf/easy-design/dist/easy-design.css'Usage
基础用法
import {createStage} from '@rockyf/easy-design'
import '@rockyf/easy-design/style.css'
import labelNode from './nodes/label'
const stage = createStage({
el: '#container',
editMode: true,
theme: 'light',
designBounds: {w: 400, h: 350},
nodeImplFactoryMap: {
label: labelNode,
},
onHistoryChange: (cmd) => {},
onHistoryStackChange: ({historyLength, redoLength}) => {},
onZoomChange: (zoom) => {},
onSelectionChange: (ids) => {},
onActiveNodeChange: (node) => {},
})
stage.setNodes([
{id: 1, nodeType: 'group', label: 'root'},
{
id: 2, pid: 1, nodeType: 'label', label: 'hello',
bounds: {x: 0, y: 0, w: 100, h: 25},
props: {text: 'Hello 😄'},
},
])- 编辑态(
editMode: true):画布尺寸由designBounds({w, h})决定,打开/进入编辑/换文档时自动 100% 居中,叠加控制层。 - 运行态(
editMode: false,默认):铺满容器,仅渲染节点。 - 运行时切换:
stage.setEditMode(true | false)(stage.editMode只读当前值)。
createStage 选项
ICreateStageOptions 全量字段:
| 字段 | 类型 | 默认 | 说明 |
|------|------|------|------|
| el | string \| HTMLElement | (必填) | 挂载容器选择器或元素 |
| nodeImplFactoryMap | Record<string, NodeImplFactory> | — | 节点类型 → 工厂;group 内置无需注册 |
| theme | ThemeConfig | 'light' | 见 自定义主题 |
| autoResize | boolean | true | 监听容器尺寸变化并 resize |
| editMode | boolean | false | 是否编辑态(控制层 / 快捷键 / 历史) |
| designBounds | {w, h} | — | 设计稿宽高;编辑态布局与 fit 基准 |
| snap | boolean | true | 移动时吸附其他节点边与画布中线 |
| zoomMin / zoomMax | number | 0.01 / 64 | 缩放范围(1% ~ 6400%) |
| zoomTransitionMs | number | 180 | 离散缩放过渡 ms;0 关闭。滚轮/捏合始终即时 |
| shortcuts | StageShortcut[] | 内置表 | 仅重绑定键位,见 快捷键 |
| rulers | boolean | true | 编辑态顶部/左侧标尺 |
| onHistoryChange | (command) => void | — | 新命令入栈 |
| onHistoryStackChange | ({historyLength, redoLength}) => void | — | 栈长度变化(undo/redo/push/clear) |
| onZoomChange | (zoom) => void | — | 缩放变化(过渡中会多次回调) |
| onSelectionChange | (ids: number[]) => void | — | 选中 id 列表变化 |
| onHoverNode | (node?) => void | — | 画布 hover 节点变化 |
| onActiveNodeChange | (node) => void | — | 活动层(下钻容器)变化;未下钻为 root |
| onAddNode / onRemoveNode | (node: INodeData) => void | — | 节点增删(数据快照) |
createStage 不接收初始 nodes;创建后用 setNodes 注入。
自定义节点
import {type INodeImpl} from '@rockyf/easy-design'
export default function (): INodeImpl {
return {
setup(): HTMLElement {
const el = document.createElement('div')
el.classList.add('node-label')
return el
},
hooks: {
create() {},
update(props) {
this.host!.innerHTML = props?.text
},
resize() {},
themeChanged(theme, props, tokens) {},
destroy() {},
},
}
}也可用内置 JSX 运行时:
import {h, type INodeImpl} from '@rockyf/easy-design'
setup() {
return h('div', {className: 'node-label'}, 'Hello')
}- 工厂签名:
(nodeData: INodeData) => INodeImpl。 hooks的this为对应NodeWrapper实例。- 分组使用
nodeType: 'group',无需注册。
节点数据结构
interface INodeData {
id?: number // 唯一 id(缺省自动生成)
pid?: number // 父节点 id,缺省挂到根
nodeType: string // 对应 nodeImplFactoryMap 的 key;group 为内置
label: string // 节点名
bounds?: Bounds // {x, y, w, h} 相对父节点
rotation?: number // 旋转角度(度,顺时针,绕包围盒中心)
locked?: boolean // 锁定:画布不可点选/操纵;列表可用 allowLocked 选中
visible?: boolean // false = 隐藏:不渲染、不可选
props?: Record<string, any>
}新建节点命令侧常用 ICreateNodeData:至少含 label + bounds,其余可选。
节点树读写(打开 / 保存)
文档格式为扁平 INodeData[](含 root,pid 表示父子),可直接 JSON.stringify / JSON.parse 落盘。createStage 不接收 nodes;创建后用 setNodes 注入,与打开文件同一路径。
// ---------- 保存 ----------
const nodes = stage.getNodes() // 深拷贝;等同 stage.pureRoot
const json = JSON.stringify(nodes, null, 2)
// 浏览器下载示例
const blob = new Blob([json], {type: 'application/json'})
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = 'document.json'
a.click()
// ---------- 打开 ----------
// 文件内容可以是纯数组,或 { nodes: [...] } 包装
const data = JSON.parse(await file.text())
const list = Array.isArray(data) ? data : data.nodes
stage.setNodes(list) // 整体替换;默认清空选中与历史栈
// 保留历史(一般不需要)
// stage.setNodes(list, {clearHistory: false})
// ---------- 新建空画布 ----------
stage.setNodes()
// 或 stage.setNodes([])文档示例(与 stage.getNodes() 输出一致):
[
{ "id": 1, "nodeType": "group", "label": "root" },
{
"id": 2,
"pid": 1,
"nodeType": "label",
"label": "hello",
"bounds": { "x": 0, "y": 0, "w": 100, "h": 25 },
"rotation": 0,
"props": { "text": "Hello" }
}
]| API | 说明 |
|-----|------|
| stage.getNodes() | 导出完整节点树(深拷贝) |
| stage.setNodes(nodes?, options?) | 整体替换文档;options.clearHistory 默认 true |
| stage.pureRoot | 同 getNodes() |
apps/demo 顶部 Document → Open / Save 即为该 API 的示例。
选择 / 下钻 / 锁定
完整契约见 docs/selection-model.md。宿主侧常用:
// 当前选中
stage.selections // number[]
stage.$findNode(id) // NodeWrapper | undefined
// 程序选中(图层面板:允许锁定项)
stage.ctrl?.selectNode(node, {
multiMode: true, // Shift 多选语义
toggleMode: true, // 已选中则取消
allowLocked: true, // 列表场景必须 true;画布内置为 false
changeHistory: true, // 是否进历史(默认 true)
})
// 设置 hover(图层面板 hover 同步到画布描边)
stage.setHoverNode(id)
// 活动层(下钻组);未下钻为 root
stage.activeNode
// 监听:onActiveNodeChange| 场景 | 行为摘要 |
|------|----------|
| 画布点选 / 框选 / 全选 | 跳过锁定子树与隐藏节点 |
| 列表 allowLocked: true | 可选中锁定项;chrome 为 locked-select(无手柄) |
| 删除 / 编组 / 对齐等批处理 | 仅 canOperate 节点(锁定跳过) |
| 解锁 / 显隐 / 属性 | 可对锁定选中生效 |
| 双击 group / Enter | 进入组为 activeNode |
| Esc / 组外点击 | 退层或取消选中 |
命令系统
结构性操作走 stage.commands,成功时返回 Command(含 undo / redo),并自动入内置历史栈。
const cmds = stage.commands
// 增删与层级
cmds.addNode(
{nodeType: 'label', label: 'new', bounds: {x: 0, y: 0, w: 100, h: 25}, props: {text: 'Hi'}},
parentId, // 缺省 root
insertIndex, // 缺省末尾
)
cmds.removeNode(nodeId) // 锁定节点会被拒绝
cmds.moveNode(nodeId, targetParentId, targetIndex)
cmds.duplicateNodes([id1, id2])
cmds.pasteNodes(clipboardFlatList, parentId, {at?: {x, y}}) // at = Paste here 局部坐标
// 编组
cmds.groupNodes([id1, id2])
cmds.ungroupNode(groupId)
// 对齐 / 分布(仅可操作节点)
cmds.alignNodes(ids, 'left' | 'centerX' | 'right' | 'top' | 'centerY' | 'bottom')
cmds.distributeNodes(ids, 'horizontal' | 'vertical')
// 图层排序
cmds.bringForward(id)
cmds.sendBackward(id)
cmds.bringToFront(id)
cmds.sendToBack(id)
// 属性 / 状态
cmds.modifyNodes(
[{node, data: {'bounds.x': 10, rotation: 15}, oldData: {bounds: {...node.bounds}, rotation: node.rotation}}],
stage.selections.concat(),
)
cmds.setLocked(ids, true | false)
cmds.setVisible(ids, true | false)
cmds.editText(nodeId, text)
// 选中(一般走 ctrl.selectNode;以下为命令封装)
cmds.selectNode(node, options)
cmds.setSelections(nextIds, oldIds)
// 画布批处理过滤(跳过锁定子树)
const operable = cmds.assertOperable(ids)| 命令 | 说明 |
|------|------|
| addNode | 添加节点 |
| removeNode | 删除(锁定拒绝) |
| moveNode | 改父级 / 同级排序 |
| duplicateNodes | 创建副本(偏移 +20,+20) |
| pasteNodes | 粘贴扁平剪贴板;可选 at 贴到坐标 |
| groupNodes / ungroupNode | 编组 / 取消编组 |
| alignNodes / distributeNodes | 对齐 / 等距分布 |
| bringForward / sendBackward / bringToFront / sendToBack | 图层排序 |
| modifyNodes | 通用属性修改(点路径如 bounds.x、props.text) |
| setLocked / setVisible | 锁定 / 显隐 |
| editText | 改 props.text |
| assertOperable | 过滤可操纵节点(不入栈) |
CommandType 与展示文案:
import {COMMAND_LABELS, CommandType} from '@rockyf/easy-design'
COMMAND_LABELS[CommandType.MODIFY_NODES] // '修改属性'
// 另有:SELECT_NODES, ADD_NODE, REMOVE_NODE, MOVE_NODES, DUPLICATE_NODES,
// GROUP_NODES, UNGROUP_NODES, ALIGN_NODES, DISTRIBUTE_NODES, REORDER_NODES历史 / 撤销重做(外部 UI)
编辑态内置历史栈,不渲染面板。通过 Stage API 驱动:
stage.undo() // 撤销(与 Cmd/Ctrl+Z 同源)
stage.redo() // 重做(Cmd/Ctrl+Shift+Z 或 Cmd/Ctrl+Y)
stage.undoTo(index) // 回退到栈底起第 index 条之后(用于历史列表点击)
stage.clearHistory() // 清空 undo/redo 栈(不执行 undo,画布状态不变)
stage.history // 只读快照:Command[](栈底 → 栈顶)
stage.historyLength
stage.redoLength展示文案可使用导出的 COMMAND_LABELS:
import {COMMAND_LABELS} from '@rockyf/easy-design'
stage.history.map((cmd, i) => ({
index: i,
label: COMMAND_LABELS[cmd.type] ?? '操作',
}))也可完全自建历史栈:在 onHistoryChange 里 stack.push(command),按钮直接 command.undo() / command.redo()。注意自建栈与内置快捷键栈是两套,建议二选一,或统一调用 stage.undo() / stage.redo()。
缩放 / 平移(外部 UI)
缩放行为对齐 Figma:范围为 1% ~ 6400%(zoomMin / zoomMax 可覆盖),zoomFit 可放大超过 100%;滚轮缩放以光标为锚点且比例随滚动量连续变化;键盘 ± 走 2 的幂离散档位并以视口中心为锚点。
离散缩放短过渡:zoomIn / zoomOut / zoom100 / zoom200 / zoomFit / zoomToFit / setZoom(..., {animate:true}) 默认约 180ms ease-out(zoom+pan 同步插值,覆盖层每帧刷新)。滚轮与双指捏合始终即时。可用 zoomTransitionMs 调节,设为 0 关闭。
stage.zoom // 当前缩放比
stage.setZoom(1.2) // 即时设置(可选 anchor;第三参 {animate:true} 走过渡)
stage.zoomIn() // 下一档离散缩放(中心锚点 + 过渡)
stage.zoomOut() // 上一档离散缩放(中心锚点 + 过渡)
stage.zoom100() // = 1(视口中心锚点 + 过渡)
stage.zoom200() // = 2(+ 过渡)
stage.centerAt100() // = 1 且 designBounds 居中(打开文档默认视口,无过渡)
stage.zoomFit() // 适应整张 designBounds 并居中(可 >100%,+ 过渡)
stage.zoomFitSelection()// 适应选中图层包围盒(+ 过渡)
stage.zoomToFit() // 有选中适配选区,否则适配整稿(⌘0,+ 过渡)滚轮、中键/空格拖拽平移、触控双指捏合由控制层内置;外部只需在 onZoomChange 里同步百分比展示(过渡期间会多次回调)。
快捷键(内置 / 可替换)
编辑态快捷键由统一注册表声明式管理(ShortcutManager 按注册顺序匹配,⌘ 与 Ctrl 等价),在 document keydown 上统一调度;文本输入 / 可编辑元素内自动放行不拦截。
内置默认表:
| 快捷键 | 动作 | | --- | --- | | ⌘⇧Z / ⌘Y | 重做 | | ⌘Z | 撤销 | | ⌘C / ⌘X / ⌘V / ⌘D / ⌘A | 复制 / 剪切 / 粘贴 / 创建副本 / 全选 | | ⌘G / ⌘⇧G | 编组 / 取消编组 | | Delete / Backspace | 删除 | | Esc | 取消选中 / 退出分组 / 关闭菜单 | | Enter | 进入分组(单选分组时) | | 方向键(Shift 为 10px) | 微调 1px | | 空格 + 拖拽 / 中键拖拽 / 滚轮 | 平移 / 缩放 | | ⌘+ / ⌘- | 离散档位放大 / 缩小 | | ⌘0 | 有选中适配选区,否则适配整稿 | | ⌘1 / Shift+0 | 100% | | ⌘2 | 200% | | Shift+1 | 适配整稿 | | Shift+2 | 适配选区 |
注:部分浏览器把
⌘0/⌘1/⌘±保留为页面缩放 / 切标签页快捷键,可能无法被页面拦截;Shift+0 / 1 / 2与滚轮/触控缩放是可靠替代。
快捷键动作全部内部预制,对外只可通过 createStage 的 shortcuts 选项重绑定触发键位(不能改行为):
- 传内置
id+ 新的keys→ 用新键位替换默认键位 keys: []→ 禁用该动作- 未配置的默认键位全部保留
内置 id:undo redo copy cut paste duplicate select-all group ungroup escape delete enter-group nudge-up|down|left|right zoom-in zoom-out zoom-to-fit zoom-100 zoom-200 fit-all fit-selection。
createStage({
el: '#container',
editMode: true,
shortcuts: [
// 把「适配整稿」从 Shift+1 改绑到 ⌘F
{id: 'fit-all', keys: [{mod: true, key: 'f'}]},
// 禁用 ⌘⇧Z 重做(保留 ⌘Y)
{id: 'redo', keys: [{mod: true, key: 'y'}]},
// 禁用 Delete 删除
{id: 'delete', keys: []},
],
})StageShortcutKey:key(KeyboardEvent.key 小写)、可选 mod / shift / alt。
标尺(编辑态)
编辑态默认在视口顶部与左侧显示标尺(设计坐标 px),随 pan / zoom 同步,并高亮当前选区范围;指针移动时在标尺上显示游标。
createStage({
// ...
rulers: true, // 默认 true;false 关闭
})
stage.setRulersVisible(false) // 运行时切换
stage.setRulersVisible(true)属性修改(外部 UI)
选中变化时通过 onSelectionChange 刷新面板,提交时走命令:
const node = stage.$findNode(stage.selections[0])
if (!node) return
stage.commands.modifyNodes(
[{
node,
data: {
'bounds.x': 10,
'bounds.y': 20,
'bounds.w': 100,
'bounds.h': 40,
rotation: 15,
'props.text': 'Hello',
locked: false,
visible: true,
label: 'renamed',
},
oldData: {
bounds: {...node.bounds},
rotation: node.rotation,
props: {...node.nodeData.props},
locked: node.nodeData.locked,
visible: node.nodeData.visible,
label: node.nodeData.label,
},
}],
stage.selections.concat(),
)完整外部工具栏示例见 apps/demo(顶部 Undo/Redo/Zoom,侧栏 Properties + History + 图层树)。
事件回调
createStage({
el: '#app',
editMode: true,
onHistoryChange: (command) => {},
onHistoryStackChange: ({historyLength, redoLength}) => {},
onZoomChange: (zoom) => {},
onSelectionChange: (ids: number[]) => {},
onHoverNode: (node?: NodeWrapper) => {},
onActiveNodeChange: (node: NodeWrapper) => {
// node === stage.root 表示未下钻
},
onAddNode: (node: INodeData) => {},
onRemoveNode: (node: INodeData) => {},
})Stage 常用 API
// 文档与宿主
stage.root // 根 NodeWrapper(运行时实例树)
stage.host // 挂载 DOM
stage.options // 创建时选项(只读引用)
stage.editMode / setEditMode(bool)
stage.getNodes() // 导出扁平节点数据(保存)
stage.setNodes(nodes?, {clearHistory?})
stage.pureRoot // 同 getNodes()
stage.$findNode(id)
stage.resize() // 容器尺寸变化后重算布局
stage.destroy()
// 编辑控制
stage.commands // CommandManager
stage.ctrl // 编辑态 CtrlLayer;运行态为 undefined
stage.selections // 当前选中 id[]
stage.activeNode // 活动层(下钻容器;未下钻 = root)
stage.setHoverNode(id)
// 视口
stage.zoom
stage.setZoom(z, anchor?, {animate?})
stage.zoomIn() / zoomOut()
stage.zoom100() / zoom200()
stage.centerAt100()
stage.zoomFit() / zoomFitSelection() / zoomToFit()
stage.setRulersVisible(bool)
// 历史
stage.history / historyLength / redoLength
stage.undo() / redo() / undoTo(index) / clearHistory()
// 主题
stage.themeName / themeBase / themeTokens
stage.changeTheme('dark' | CustomThemeConfig)SelectionPolicy
包导出策略工具,供图层面板等与画布规则对齐:
import {SelectionPolicy} from '@rockyf/easy-design'
const root = stage.root
const node = stage.$findNode(id)!
SelectionPolicy.isHidden(node)
SelectionPolicy.isEffectivelyLocked(node, root) // 自身或祖先 locked
SelectionPolicy.canSelect(node, root, {allowLocked: true})
SelectionPolicy.canOperate(node, root) // 画布可操纵自定义主题
const stage = createStage({
el: '#container',
theme: {
base: 'dark',
themeName: 'custom',
primaryColor: '#8B5CF6',
designBgColor: '#1F2937',
},
})
stage.changeTheme({base: 'dark', primaryColor: '#22D3EE'})
stage.changeTheme('light')可用 token:designBgColor / stageBgColor / textColor / primaryColor / primaryColor70。primaryColor70 缺省由 primaryColor 派生(alpha 0.3)。
高级:也可直接使用导出的主题工具:
import {
applyThemeVars,
clearThemeVars,
DEFAULT_THEME_TOKENS,
resolveTheme,
} from '@rockyf/easy-design'类型声明
发布用 types/ 由 pnpm build:types 生成(scripts/build-types.mjs)。本地改源码后若要校验声明是否过期:
pnpm check:types-fresh
# 或一站式
pnpm ci # test + typecheck + check:types-fresh包导出入口:@rockyf/easy-design(types 字段指向 types/index.d.ts)。
RoadMap
- [x] 鼠标单选/多选(Shift+单击)
- [x] 鼠标框选(Shift 追加;只扫当前活动层)
- [x] 选择层级对齐 Figma:默认点当前层、⌘/Ctrl 穿透、双击/Enter 进组、Esc 退层
- [x] 鼠标修改包围盒 / 拖动画布
- [x] 添加/删除节点、移动层级
- [x] 撤销/重做(内置历史栈 + 快捷键;UI 由外部实现)
- [x] 主题 light/dark + 自定义主题
- [x] 多选统一包围盒 + 等比缩放
- [x] Shift 等比例 / Alt 中心缩放 / Shift 轴向锁定
- [x] 旋转(单选自转;多选绕选区中心公转)
- [x] 方向键微调
- [x] 画布缩放(滚轮 / 离散档 / fit / 100% / 过渡)
- [x] 复制/粘贴/剪切、Duplicate、Alt+拖动复制
- [x] 对齐 / 分布 / 图层排序
- [x] 编组 / 取消编组
- [x] 双击文本编辑(props.text)
- [x] 右键菜单(画布内)
- [x] 智能参考线 + 吸附
- [x] hover 高亮 / move 光标
- [x] 锁定 / 隐藏(列表 allowLocked;画布批处理不操纵锁定)
- [x] 属性数值修改 API(面板由外部实现)
- [x] 工具栏 / 历史面板 / 属性面板 UI 外置
- [x] 节点树读写
getNodes/setNodes(打开 / 保存文档) - [x] 顶部 / 左侧标尺(随 pan/zoom,选区高亮)
- [ ] 自定义旋转原点
- [ ] 完整隔离模式视觉(下钻时外部变暗)
