npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@rockyf/easy-design

v0.4.1

Published

纯 JS 开发的简易设计器画布,交互风格对齐 Figma 画布区。

Readme

Easy Design (PureJs)

纯 JS 开发的简易设计器画布,交互风格对齐 Figma 画布区。

库本身只提供画布与编辑逻辑,不内置工具栏 / 属性面板 / 历史面板 UI。撤销、缩放、属性修改等能力全部通过 API 与回调暴露,由宿主应用自行渲染控件。

截图

shot_0.png截图

目录

Intro

  1. 基于包围盒的树型节点可视化编辑,覆盖 Figma 画布区的主流纯操作(选择、操纵、导航、编辑、历史、反馈辅助)。
  2. 仅内置 NodeWrapper 与分组类型 group;节点内容由开发者通过 INodeImpl 实现(可参考 apps/demo/src/nodes)。
  3. 支持编辑态与运行态:编辑态叠加控制层(选中框、手柄、右键菜单、文本编辑等);运行态铺满容器、不可编辑。
  4. 不内置工具栏 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
  • hooksthis 为对应 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.xprops.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] ?? '操作',
}))

也可完全自建历史栈:在 onHistoryChangestack.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 与滚轮/触控缩放是可靠替代。

快捷键动作全部内部预制,对外只可通过 createStageshortcuts 选项重绑定触发键位(不能改行为):

  • 传内置 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: []},
  ],
})

StageShortcutKeykeyKeyboardEvent.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)           // 画布可操纵

细节见 docs/selection-model.md

自定义主题

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-designtypes 字段指向 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,选区高亮)
  • [ ] 自定义旋转原点
  • [ ] 完整隔离模式视觉(下钻时外部变暗)