- 前端
- UI组件
【免费下载链接】joint
A proven SVG-based JavaScript diagramming library powering exceptional UIs
本篇技术指南以 custom-embedding Demo 为主线,讲解 JointJS 图形库中“元素嵌入(embedding)”机制的原理与自定义方法。你将掌握:如何用 Paper 的findParentBy回调替换默认的包围盒判定,如何利用getBBox({ deep: true })计算包含子元素的深层包围盒并膨胀命中区域,以及embed()编程式嵌入与层级排序的内在逻辑。读完可直接在你的项目中复现“不重叠也能嵌入、拖动时按祖先层级择优选择父级”的自定义交互。
一、先跑起来:Demo 的构建与运行
该 Demo 的官方说明(packages/joint-core/demo/custom-embedding/README.md)给出了最简运行路径:整个仓库是一个 Yarn workspace,需要先在仓库根目录安装依赖并构建 JointJS 核心库,再打开示例页面。
# 在仓库根目录执行 yarn install yarn run build根目录 package.json 中build脚本定义为yarn workspaces foreach --all -tvv run build,它会遍历所有 workspace 包执行各自的构建任务;其中@joint/core包(packages/joint-core/package.json,仓库内版本为 4.3.2)的build脚本对应grunt install。
构建完成后,进入示例目录并直接在浏览器打开index.html:
# 示例目录:packages/joint-core/demo/custom-embedding/ open index.html为什么“先构建”是必需步骤?看 index.html 的脚本引用就明白了:
<div id="paper"></div> <script src="../../build/joint.js"></script> <script src="./custom-embedding.js"></script>页面依赖../../build/joint.js,也就是packages/joint-core/build/joint.js。根据构建资源配置 grunt/resources/esm.js,这个文件是joint + plugins + vectorizer + geometry的 UMD 打包产物(umd: 'build/joint.js'),只有先执行构建才会生成。因此如果直接双击index.html而跳过了构建步骤,浏览器会因找不到joint.js而报错。
提示:页面加载的是打包后的
build/joint.js,而仓库源码位于packages/joint-core/src/下(137 个.mjs模块)。若想阅读或调试源码级行为,可直接查阅src/目录;本 Demo 的交互逻辑则在 custom-embedding.js 中。
二、嵌入机制概览:JointJS 默认如何找“父级”
在深入自定义逻辑之前,先理解 JointJS 内建的嵌入判定模型。相关选项集中定义在 src/dia/Paper.mjs 的 “EMBEDDING” 配置段(L428-L453):
| 选项 | 默认值 | 说明 |
|---|---|---|
embeddingMode | false | 是否启用嵌入。启用后,拖动元素时会把它重新挂到其下的元素之下,并保证所有链接与元素按嵌入层级可见 |
findParentBy | 'bbox' | 判定候选父级的方式。预设值:'bbox'、'center'、'origin'、'corner'、'topRight'、'bottomLeft';也可以是函数或'pointer' |
frontParentOnly | true | 是否只取最上层(z 值最高)的元素作为候选父级;为false时按从前往后顺序逐个测试,直到找到合法父级 |
validateEmbedding | 返回true | 拖拽过程中校验是否允许建立“子-父”关系的回调,签名(childView, parentView) |
其中findParentBy的三种取值对应三条不同的候选生成路径,这正是本 Demo 的核心切入点。从 src/dia/ElementView.mjs 的processEmbedding(L397-L455)可以看到:
const { findParentBy, frontParentOnly, validateEmbedding } = paper.options; let candidates; if (isFunction(findParentBy)) { // 自定义回调:由用户完全接管候选父级的筛选逻辑 candidates = toArray(findParentBy.call(graph, this, evt, x, y)); } else if (findParentBy === 'pointer') { // 取指针位置下的元素 candidates = graph.findElementsAtPoint({ x, y }); } else { // 按预设策略('bbox'/'center' 等)找被拖动元素下方的元素 candidates = graph.findElementsUnderElement(model, { searchBy: findParentBy }); }可见:只要传入一个函数,JointJS 就会放弃内建的几何判定,完全采用你的自定义逻辑(findElementsUnderElement的实现可进一步参见 src/dia/Graph.mjs L1413 及_filterCellsUnderElement)。本 Demo 演示的正是这一分支。
三、Demo 结构拆解:两种形状与两种“性格”
示例页面由 index.html(一个带边框的#paper容器 + 两个脚本引用)和 custom-embedding.js(全部交互逻辑)构成,总代码约 100 行。
首先定义两种基于joint.shapes.standard.Rectangle的元素:
var Child = joint.shapes.standard.Rectangle.define('standard.Child', { attrs: { body: { stroke: 'transparent', fill: 'green', rx: 5, ry: 5 }, label: { fontSize: 14, text: 'Child\n(default embedding logic)', fill: 'white' } } }); var Parent = joint.shapes.standard.Rectangle.define('standard.Parent', { customEmbedding: true, // ← 自定义业务标记,供 findParentBy 读取 attrs: { body: { stroke: 'transparent', fill: 'black', rx: 5, ry: 5 }, label: { fontSize: 14, text: 'Parent\n(custom embedding logic)', fill: 'white' } } });关键点在于Parent形状额外声明了一个自定义属性customEmbedding: true。这个属性不是 JointJS 的保留配置,而是 Demo 作者放在 model 上的业务标记,供稍后的findParentBy回调读取——它告诉我们:“对这类元素,请使用自定义嵌入判定,而不是默认包围盒逻辑”。这是自定义findParentBy时常见的模式:通过 model 上的自定义属性区分元素类型,进而采用不同的几何策略。
接着创建图(Graph)与纸(Paper):
var graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes }); var EMBEDDING_OFFSET = 59; new joint.dia.Paper({ el: document.getElementById('paper'), model: graph, cellViewNamespace: joint.shapes, width: 1000, height: 1000, gridSize: 20, drawGrid: 'mesh', embeddingMode: true, findParentBy: (draggedElement) => { // ... } });cellNamespace: joint.shapes与cellViewNamespace: joint.shapes:让 Graph/Paper 能按名称解析standard.Child、standard.Parent这些自定义形状;gridSize: 20与drawGrid: 'mesh':启用 20px 网格吸附与网格背景(拖拽时元素位置会经ElementView.snapToGrid对齐,见 src/dia/ElementView.mjs L809-L815);embeddingMode: true:开启嵌入交互,这是findParentBy生效的前提。
四、核心:自定义findParentBy逐行解读
Demo 的精髓是这一段回调(custom-embedding.js L28-L45):
findParentBy: (draggedElement) => { const draggedBBox = draggedElement.getBBox(); const elements = graph.getElements().filter((el) => { let elBBox; if (el.get('customEmbedding')) { // 对带 customEmbedding 标记的元素, // 使用“包含其子元素”的深层包围盒,并向外膨胀 elBBox = el.getBBox({ deep: true }); elBBox.inflate(EMBEDDING_OFFSET); } else { elBBox = el.getBBox(); } return joint.g.intersection.exists(draggedBBox, elBBox); }); // 按祖先数量排序:让外层元素排到最后 return joint.util.sortBy(elements, (el) => (-el.getAncestors().length)); }逐句拆解其语义:
- 获取被拖动元素的包围盒:
draggedElement.getBBox()返回普通包围盒(旋转时可带rotate选项)。 - 遍历图中所有元素(
graph.getElements(),返回所有 Element,见 src/dia/Graph.mjs L803-L806),对每个候选el分两种策略计算命中区域:- 带
customEmbedding标记的元素:调用el.getBBox({ deep: true })得到包含全部嵌入后代的外接盒,再inflate(EMBEDDING_OFFSET)(59px ≈ 3 个网格单元,与 Demo 提示文案“within 2 squares around”的手感一致)向外膨胀命中区域; - 普通元素:只用自己的包围盒
el.getBBox(),走默认判定。
- 带
- 相交判定:
joint.g.intersection.exists(draggedBBox, elBBox)调用几何工具库 src/g/intersection.mjs 的exists(shape1, shape2),返回两个图形是否相交的布尔值(该模块支持 Line、Rect、Polygon、Polyline、Path、Ellipse 等两两组合的相交测试)。 - 排序:
joint.util.sortBy(elements, (el) => (-el.getAncestors().length))按“祖先数量”的相反数升序排序。
这里的排序与frontParentOnly默认值true是联动生效的:在 processEmbedding 中,候选数组先被过滤(排除自身、排除已嵌入到被拖元素里的元素),随后candidates = candidates.slice(-1)只取数组最后一个元素。由于sortBy升序把祖先最多(嵌套最深)的元素排在最前、祖先最少(最外层)的元素排在最后,最终slice(-1)命中的就是相交候选中层级最外层、祖先最少的那一个。这意味着:当被拖元素同时压住一个嵌套子元素和它的父元素时,系统会优先把父元素选作嵌入目标,而不是误把子元素当父级——这正是该 Demo 注释“The element with the least number of ancestors comes first”要表达的意图(其相关实现可对照 src/dia/Cell.mjs L387-L405 的getAncestors,它沿parent链向上收集祖先)。
getBBox({ deep: true })的深层语义同样有源码依据:在 src/dia/Element.mjs L522-L543 中,deep选项会通过this.getEmbeddedCells({ deep: true, breadthFirst: true })用广度优先算法收集所有嵌套后代,连同自身一起交给graph.getCellsBBox()求并集,得到“覆盖整棵子树的包围盒”。这也是 Demo 中三个子元素明明不与 Parent #2 重叠(despite no overlap),却仍能“嵌入”到它下面的关键:命中区域是父元素深层包围盒向外膨胀 59px的范围,而非父元素本身的矩形。
五、源码链路:从拖拽到最终“认父”
自定义回调只是第一步,完整的嵌入流程发生在 src/dia/ElementView.mjs:
- 拖拽中(
drag,L817 起):元素位置按网格吸附并更新(element.position(elX, elY, { restrictedArea, deep: true, ui: true }),deep: true保证嵌套子元素随父移动);若paper.options.embeddingMode为真且指针产生位移,则调用processEmbedding开始候选判定(L829-L840)。 - 候选判定(
processEmbedding,L397-L455):按上文的分支生成候选数组 → 过滤(排除非 Cell、排除自身、排除el.isEmbeddedIn(model)以避免循环嵌套)→frontParentOnly默认取最后一项 → 若找到新候选则调用newCandidateView.highlight(..., { embedding: true })高亮潜在父级;若候选消失则clearEmbedding取消高亮。 - 最终嵌入(
finalizeEmbedding,L472-L506):松开鼠标时,若仍有候选父级,则执行candidateView.model.embed(element, { ui: true })建立父子关系,取消高亮;随后对paper.model.getConnectedLinks(element, { deep: true })中的每条链接调用link.reparent({ ui: true }),确保链接按新的嵌入层级重挂、始终保持可见。
embed()本身定义在 src/dia/Cell.mjs L313-L336:它先通过canEmbed检查递归嵌入(防止把元素嵌入到自己的后代里),必要时按opt.reparent处理重挂,再经_embedCells写入层级关系。此外,validateEmbedding(childView, parentView)(Paper 默认返回true)可在此链路中作为二次拦截,实现“某些父子组合不允许”的业务规则。
六、编程式嵌入:embed()与父级联动
除了拖拽触发的自动嵌入,Demo 后半部分演示了纯编程式嵌入:
var r = new Child({ attrs: { body: { fill: 'red' }, label: { text: 'Child #1\nEmbedded to Parent #2\n(despite no overlap)' } } }) .position(620, 60).size(160, 100).addTo(graph); // ... g、b 同理 new Parent({ attrs: { label: { text: 'Parent #2\n(custom embedding logic)\nThree embedded children' } } }) .position(620, 480).size(160, 100).addTo(graph) .embed(r).embed(g).embed(b); // 链式调用,把三个子元素挂到 Parent #2 下三个红色/绿色/蓝色子元素被刻意放在与 Parent #2完全不相交的位置,然后通过.embed(r).embed(g).embed(b)强行建立父子关系——证明嵌入关系本质上由模型层级(parent属性 + Graph 的层级索引)决定,与视觉位置无关。这也解释了上一节的深层包围盒逻辑:正因为嵌入可以脱离重叠,自定义findParentBy才需要用deep: true的包围盒来“补偿”这种非直观布局。
编程式嵌入还带来一个联动效果:父元素移动时子元素随之平移。这在 src/dia/Element.mjs 的translate实现中可见——它会invoke(this.getEmbeddedCells(), 'translate', tx, ty, opt)递归平移所有嵌入后代(L149-L163)。你可以拖拽 Parent #2 验证:三个子元素始终与它保持相对位置不变。
七、交互体验与实验要点
打开页面后,画布左列是三个 Child(红/绿/蓝,提示文案“Try to move me within 2 squares around a Parent element or an embedded child”),右侧是 Parent #1、Parent #2。建议依次实验:
- 拖拽 Child 靠近 Parent #1(普通包围盒命中):当 Child 的包围盒与 Parent #1 相交时,Parent #1 会被高亮,松开后 Child 成为其子元素。
- 拖拽 Child 靠近 Parent #2(膨胀命中):即使 Child 与 Parent #2 本体不重叠,只要进入其深层包围盒向外膨胀 59px的范围,同样会被嵌入。这正是
getBBox({ deep: true }) + inflate(EMBEDDING_OFFSET)的效果。 - 拖拽 Parent #2:三个已嵌入子元素整体跟随移动,链接若存在也会按层级重新归属。
- 调整验证:可把
EMBEDDING_OFFSET改小(如 0)观察命中范围收窄,或改大(如 120)观察更远的吸附;也可把frontParentOnly显式设为false观察候选遍历顺序的变化。
需要留意的是:该 Demo 的排序 +slice(-1)组合针对的是“最外层优先”的策略。若你的业务需要“最内层(嵌套最深)优先”,只需把排序反转(去掉负号),或在findParentBy返回后自行调整数组顺序——processEmbedding会忠实采纳你返回的候选顺序。
八、延伸阅读与自定义场景建议
仓库中还提供了其他嵌入相关示例,可对照阅读 packages/joint-core/demo/embedding 目录下的nested、nested2、nested-clone、front-and-back等页面:前者演示默认findParentBy的嵌套、克隆与前后遮挡行为,本 Demo 则展示如何用回调完全接管判定。二者结合,可以覆盖从“开箱即用”到“深度定制”的完整路线。
在真实项目中,自定义findParentBy的典型场景包括:
- 容器类元素:像“泳道”“分组框”这类视觉上允许内容越界、需要容错命中的元素,用
deep: true包围盒 + 膨胀偏移制造“松吸附”手感; - 按业务属性分流:通过
el.get('customEmbedding')之类的自定义属性区分元素类型,一部分走默认包围盒、一部分走定制几何(本 Demo 即此模式); - 策略优先级:在回调内实现“候选集 + 排序”管线,配合
frontParentOnly与validateEmbedding控制最终父级的选择与合法性校验。
若追求源码级理解,建议精读三个文件:嵌入交互的编排逻辑在 src/dia/ElementView.mjs(重点 L397-L506),选项语义在 src/dia/Paper.mjs(重点 L428-L453),层级与几何基础在 src/dia/Cell.mjs(getAncestors/getEmbeddedCells/embed)与 src/dia/Element.mjs(getBBox的deep实现)。
九、许可证说明
本 Demo 及其所属的 JointJS 库采用Mozilla Public License 2.0(MPL-2.0)授权,版权声明为 © 2013-2026 client IO;仓库内许可证原文见 packages/joint-core/LICENSE。在你自己的项目中复用上述findParentBy模式时,请遵守该许可证的条款。
说明:仓库内
@joint/core当前版本为 4.3.2(见 packages/joint-core/package.json);本文涉及的行为均以当前仓库源码(src/dia/与src/g/下的.mjs模块)为准。
- 前端
- UI组件
【免费下载链接】joint
A proven SVG-based JavaScript diagramming library powering exceptional UIs
相关推荐
LiteFlow自定义回调:回调处理逻辑
LiteFlow自定义回调:回调处理逻辑 LiteFlow 作为一款轻量级的组件式规则引擎,提供了强大的自定义回调机制,让开发者能够更好地控制流程执行过程中的各
后端流程编排工作流自动化人工智能AI AgentNetBox 自定义校验(Custom Validation):配置规则、自定义逻辑与删除保护实战指南
NetBox 自定义校验(Custom Validation):配置规则、自定义逻辑与删除保护实战指南 导读 NetBox 在对象写入数据库之前会执行一套内置校
后端网络数据建模factory_bot 自定义回调(Custom Callbacks)完全指南:为自定义策略挂接 before/after 钩子
factory_bot 自定义回调(Custom Callbacks)完全指南:为自定义策略挂接 before/after 钩子 导读 factory_bot
开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考