ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JointJS 自定义元素工具实战:用 dia.ToolView 实现 Port 拖拽重排

JointJS 自定义元素工具实战:用 dia.ToolView 实现 Port 拖拽重排 前端UI组件【免费下载链接】jointA proven SVG-based JavaScript diagramming library powering exceptional UIs项目地址https://gitcode.com/gh_mirrors/jo/joint点击查看免费下载JointJS 内置的元素工具Element Tools覆盖了常用交互但诸如「拖动 Port 并改变其排列顺序」这类自定义交互并不在其中。本示例 examples/port-reordering-tool-js 演示了如何基于 JointJS 的自定义元素工具Custom Element Tool机制从零构建一个 Port 重排工具点击 Port 即可唤出一组拖拽手柄拖动后 Port 会实时预览插入位置松开后模型中的ports.items顺序被改写连线随之更新。读完本文你将掌握mvc.View手柄视图、dia.ToolView工具封装、addTools/removeTools装配与事件驱动的完整链路。项目结构与运行方式示例是一个基于 Vite 的 ESM 前端应用源码入口与依赖如下入口页面examples/port-reordering-tool-js/index.html含#paper-container容器与 Logo核心逻辑examples/port-reordering-tool-js/src/main.js页面样式examples/port-reordering-tool-js/src/styles.css依赖配置examples/port-reordering-tool-js/package.json其中joint/core以workspace:^形式引用 monorepo 内的 packages/joint-core其余仅依赖vite ^7.3.1从仓库根目录安装依赖并构建然后进入示例目录启动开发服务器yarn install yarn run build进入示例目录运行开发模式终端会打印本地地址通常为http://localhost:5173yarn dev构建生产产物并本地预览yarn build # tsc 类型检查 vite build产物输出到 dist/ yarn preview # 本地预览生产构建按 examples/port-reordering-tool-js/package.json 的脚本定义build实际执行的是tsc vite builddev为vitepreview为vite preview。思路总览为什么需要自定义元素工具JointJS 官方内置了一批工具如边界框、旋转、连接点吸附等但对「Port 重排」这类业务专属交互没有现成方案。示例文档 examples/port-reordering-tool-js/README.md 给出的核心思路是利用 JointJS 的自定义元素工具机制把任意 SVG 子视图挂载到元素视图之上。该机制建立在两层基础设施上dia.ToolView所有工具视图的基类继承自mvc.View根节点为g元素见 packages/joint-core/src/dia/ToolView.mjs。通过configure(view, toolsView)获得relatedView与paper引用update()方法随元素视图的更新而被调用guard(evt)判断事件是否应交给画布处理。cellView.addTools(toolsView)/paper.removeTools()前者把dia.ToolsView挂到元素视图上并使其可见后者销毁当前挂载的工具见 packages/joint-core/src/dia/CellView.mjs。示例将这一机制拆成两层实现手柄层每个 Port 一个可拖拽的 SVG 圆与工具层负责手柄的创建、定位、预览与模型改写。搭建画布元素、Port 与连线先看画布初始化config.layerAttribute graphLayer为图启用分层渲染属性随后创建dia.Graph与dia.Paperimport { V, g, dia, shapes, mvc, config } from joint/core; import ./styles.css; config.layerAttribute graphLayer; const paperContainer document.getElementById(paper-container); const graph new dia.Graph({}, { cellNamespace: shapes }); const paper new dia.Paper({ model: graph, cellViewNamespace: shapes, width: 100%, height: 100%, gridSize: 20, async: true, sorting: dia.Paper.sorting.APPROX, background: { color: #F3F7F6 }, defaultLink: () new shapes.standard.Link({ z: -1 }), defaultConnectionPoint: { name: boundary }, clickThreshold: 10, magnetThreshold: onleave, linkPinning: false, validateConnection: (sourceView, _, targetView) sourceView ! targetView, snapLinks: true }); paperContainer.appendChild(paper.el); paper.setGrid(mesh);值得注意的几个配置async: true启用异步渲染magnetThreshold: onleave决定 Port 作为连接磁铁的响应时机snapLinks: true让连线端点自动吸附validateConnection禁止元素连回自身。paper.setGrid(mesh)打开网格用于对齐参考。接下来创建一个左右两侧各带 Port 的标准矩形shapes.standard.Rectangle。除常规 attrs 外关键点是自定义 Port 的 markup——portMarkup定义每个 Port 自身的 SVG 结构圆形portBodyportLabelMarkup定义其标签结构背景矩形 文本const r1 new shapes.standard.Rectangle({ layer: group1, position: { x: 100, y: 100 }, size: { width: 200, height: 200 }, attrs: { root: { magnet: false }, body: { stroke: #333333, fill: #fff, rx: 10, ry: 10 } }, portMarkup: [ { tagName: circle, selector: portBody, attributes: { r: 10, fill: #FFFFFF, stroke: #333333 } } ], portLabelMarkup: [ { tagName: rect, selector: portLabelBackground }, { tagName: text, selector: portLabel, attributes: { fill: #333333 } } ], ports: { /* groups items见下文 */ } });Port 配置被组织为groups决定排布与样式与items具体 Port 实例。本示例定义left/right两组组内position: left/right指定排布方向label.position使用outsideoffset: 30将标签放到 Port 外侧 30 像素组 attrs 中用portLabelBackground的calc(...)表达式让背景矩形贴合文本大小并给portBody设置strokeWidth: 2与magnet: active。左侧 4 个入 Portp1–p4、右侧 3 个出 Portout1–out3。随后克隆出第二个元素并建立连线const r2 r1.clone().translate(400); const l1 new shapes.standard.Link({ z: -1 }); const l2 l1.clone(); l1.source(r1, { port: out2 }).target(r2, { port: p3 }); l2.source(r1, { port: out1 }).target(r2, { port: p1 }); graph.addCells([r1, r2, l1, l2]);连线通过source(el, { port: id })语法绑定到具体 Port 上后续 Port 在ports.items中的顺序变化不会破坏连线引用这正是后面安全改写模型的前提。手柄层一个可拖拽的 SVG 圆形视图工具由一个个**手柄Handle**组成。示例用mvc.View直接定义一个 SVGcircle手柄视图而不是依赖 JointJS 内置的 handle 基类展示自定义的完全自由度const PortHandle mvc.View.extend({ tagName: circle, svgElement: true, className: port-handle, events: { mousedown: onPointerDown, touchstart: onPointerDown, dblclick: onDoubleClick, dbltap: onDoubleClick }, documentEvents: { mousemove: onPointerMove, touchmove: onPointerMove, mouseup: onPointerUp, touchend: onPointerUp, touchcancel: onPointerUp }, attributes: { r: 13, fill: transparent, stroke: gray, stroke-width: 2, cursor: grab }, position: function(x, y) { this.vel.attr({ cx: x, cy: y }); }, color: function(color) { this.el.style.stroke color || this.attributes.stroke; }, onPointerDown: function(evt) { if (this.options.guard(evt)) return; evt.stopPropagation(); evt.preventDefault(); this.options.paper.undelegateEvents(); this.delegateDocumentEvents(null, evt.data); this.trigger(will-change, this, evt); }, onPointerMove: function(evt) { this.trigger(changing, this, evt); }, onDoubleClick: function(evt) { this.trigger(remove, this, evt); }, onPointerUp: function(evt) { this.trigger(changed, this, evt); this.undelegateDocumentEvents(); this.options.paper.delegateEvents(); } });该手柄的设计要点交互事件分两层events处理手柄自身的按下/双击含触摸touchstart/dbltapdocumentEvents处理按下之后在 document 上跟随的移动/抬起mousemove/mouseup及触摸对应事件从而支持拖出元素边界后依然能跟踪指针。按下瞬间的接管onPointerDown先调用guard做拦截判断然后paper.undelegateEvents()暂停画布事件、delegateDocumentEvents把后续事件委托到 document最后触发will-change通知工具层“拖拽即将开始”。用事件而非硬编码回调手柄只负责触发will-change/changing/changed/remove四个生命周期事件具体行为全部由工具层监听决定职责划分清晰。指针抬起收尾onPointerUp触发changed后立即undelegateDocumentEvents()并paper.delegateEvents()恢复画布事件监听。工具层Ports 工具的完整生命周期工具层继承dia.ToolView名为ports并附带一个预览图标子节点。整体框架如下const Ports dia.ToolView.extend({ name: ports, options: { handleClass: PortHandle, activeColor: #4666E5 }, children: [ { tagName: path, selector: preview, className: joint-ports-preview, attributes: { d: M -30 -5 -20 0 -30 5 M -20 -5 -10 0 -20 5 M 20 -5 10 0 20 5 M 30 -5 20 0 30 5, stroke-width: 2, fill: none } } ], handles: null, onRender: function() { /* 渲染子节点、重置并渲染手柄 */ }, update: function() { /* 元素更新时同步手柄位置 */ }, resetHandles: function() { /* 销毁全部手柄 */ }, renderHandles: function() { /* 按当前位置创建手柄 */ }, updateHandles: function() { /* 复用已有手柄更新坐标 */ }, updatePreview: function(candidateIndex) { /* 显示/隐藏插入位置预览 */ }, getPositions: function() { /* 计算各 Port 的页面坐标 */ }, getPortIndex: function(handle, x, y) { /* 根据指针算出目标下标 */ }, onHandleWillChange / onHandleChanging / onHandleChanged: function() { /* 拖拽各阶段 */ }, onRemove: function() { this.resetHandles(); } });计算 Port 的世界坐标getPositions手柄必须跟随 Port 渲染而 Port 坐标默认是元素局部坐标。示例用元素视图的根变换矩阵把它转为画布坐标系getPositions: function() { const { relatedView } this; const translateMatrix relatedView.getRootTranslateMatrix(); const rotateMatrix relatedView.getRootRotateMatrix(); const matrix translateMatrix.multiply(rotateMatrix); const portsPositions this.relatedView.model.getPortsPositions(this.options.group); const positions []; for (const id in portsPositions) { const point V.transformPoint(portsPositions[id], matrix); positions.push({ x: point.x, y: point.y, id }); } return positions; }getPortsPositions(groupName)是元素模型上的公开方法返回该组内每个 Port 的{x, y, angle}局部坐标见 packages/joint-core/src/dia/ports.mjs其内部通过getGroupPortsMetrics结合元素当前尺寸计算。外层再叠乘getRootTranslateMatrix()与getRootRotateMatrix()即把局部坐标变换到画布坐标同时保证手柄与 Port 的视觉位置严格一致即使元素被平移或旋转也不失真。手柄渲染与复用renderHandles()为每个 Port 创建一个handleClass实例传入index、portId、paper与guard回调渲染后调用position(x, y)定位、simulateRelatedView(handle.el)将model-id属性标到手柄节点上工具层的simulateRelatedView实现见 packages/joint-core/src/dia/ToolView.mjs再appendTo工具根节点并开始监听三个拖拽事件。update()是每个 ToolView 都需实现的同步入口基类中为空实现见 packages/joint-core/src/dia/ToolView.mjs元素尺寸、位置或 Port 数量变化时会被调用。示例的策略是手柄数量与 Port 数量一致时仅updateHandles()平移坐标不一致如 Port 增删则销毁重建update: function() { const positions this.getPositions(); if (positions.length this.handles.length) { this.updateHandles(); } else { this.resetHandles(); this.renderHandles(); } this.updatePreview(null); return this; }拖拽过程从 will-change 到 changed拖拽分三个阶段由手柄触发的事件驱动will-changefocus()高亮工具手柄变色为activeColor并把对应 Port 的根节点opacity设为0.2形成“被拿起”的视觉反馈。changing通过paper.getPointerArgs(evt)取得指针坐标调用getPortIndex(handle, x, y)计算目标下标若目标不同于当前下标则updatePreview(candidateIndex)显示插入预览。预览逻辑会取第一个与最后一个 Port 的中点计算间距distance把preview路径平移到目标 Port 上方或列表末尾下方。changed恢复手柄颜色与 Port 透明度隐藏预览若newIndex ! index则执行模型改写见下节随后重建手柄。getPortIndex的核心是“按 Y 坐标找插入点”positions.findIndex(p p.y y)找到第一个 Y 大于指针的 Port 作为候选如果候选与当前下标相同、是当前下标的下一个、或末尾时指针本来就在末尾则维持原下标从而保证松手在原来位置时不会误触发重排。改写模型更新 ports.items重排的本质是改写元素模型的ports.items数组顺序连线会自动跟随const portsPath [ports, items]; const ports model.prop(portsPath); const positionIndex ports.findIndex((port) port.id position.id); const port ports[positionIndex]; const newPositionIndex newPosition ? ports.findIndex((port) port.id newPosition.id) : ports.length; const newPorts ports.slice(); newPorts.splice(positionIndex, 1); newPorts.splice(newPositionIndex - (index newIndex ? 1 : 0), 0, port); model.prop(portsPath, newPorts, { rewrite: true, tool: this.cid });这段代码的关键细节取出整个ports.items数组浅拷贝后先移除被拖动的 Port 再插入新位置——先删后插的次序配合index newIndex时的减一修正保证向后移动时下标不漂移。model.prop(path, value, { rewrite: true })以“重写”语义提交数组而非逐项 diff并传入tool: this.cid标注变更来源。提交后调用resetHandles() renderHandles()手柄按新顺序重建同时update()链会触发布局刷新连线端点自然跟随新 Port 位置。装配工具按需显示与自动清理工具需要挂到具体元素视图上。示例通过两处装配实现按需显隐交互触发——点击某个 Port 时为其所在组打开工具点击空白或任意元素时关闭paper.on(element:magnet:pointerclick, (elementView, evt, magnet) { paper.removeTools(); const group elementView.findAttribute(port-group, magnet); elementView.addTools( new dia.ToolsView({ tools: [new Ports({ group })] }) ); }); paper.on(blank:pointerdown cell:pointerdown, () { paper.removeTools(); });elementView.findAttribute(port-group, magnet)从被点击的磁铁节点上反查其所属 Port 组据此创建只作用于该组的Ports工具。dia.ToolsView是工具的容器视图这里通过new dia.ToolsView({ tools: [...] })直接实例化装配。初始化预置——示例还演示了不依赖点击、直接给元素挂工具的方式r2.findView(paper).addTools( new dia.ToolsView({ tools: [new Ports({ group: left })] }) );启动时右侧元素r2的左组 Port 工具即处于激活状态。addTools会先removeTools()清掉旧工具再挂载新工具并在元素视图被移除时自动清理见 packages/joint-core/src/dia/CellView.mjs 的onRemove与removeTools实现因此无需手动回收。样式细节examples/port-reordering-tool-js/src/styles.css 中#paper-container铺满视口并允许滚动Logo 固定于右下角。工具内部的joint-ports-preview路径类名用于预览指示器的样式挂载点手柄类名port-handle同样可用于 CSS 微调如 hover 态。小结与扩展方向通过本例你可以提炼出一套可复用的「自定义元素工具」模板用mvc.View定义轻量交互子视图手柄只对外抛出生命周期事件用dia.ToolView聚合这些子视图实现getPositions、update、onRender等钩子完成坐标计算、渲染与事件转发通过paper的element:magnet:pointerclick事件按需addTools/removeTools用model.proprewrite原子化改写ports.items重排后重建手柄并依赖元素更新机制刷新连线。在这个模板之上你可以自然扩展出纵向排列元素的垂直重排、跨组 Port 移动改group属性、对重排结果做 undo/redo 快照或将拖拽吸附阈值改为基于水平方向的距离计算等能力。整个示例完整代码位于 examples/port-reordering-tool-js/src/main.js可以直接作为自定义工具开发的起点。赞分享前端UI组件【免费下载链接】jointA proven SVG-based JavaScript diagramming library powering exceptional UIs项目地址https://gitcode.com/gh_mirrors/jo/joint点击查看免费下载相关推荐BetterDiscord拖放功能实现自定义界面元素拖拽交互BetterDiscord拖放功能实现自定义界面元素拖拽交互 拖放功能概述 BetterDiscord的拖放功能允许用户通过直观的拖拽操作自定义界面元素布局桌面应用UI组件React Beautiful DND 拖拽元素的自定义形状非矩形拖拽区域实现React Beautiful DND 拖拽元素的自定义形状非矩形拖拽区域实现 在使用React Beautiful DND构建拖放界面时默认的矩形拖拽区域Superpowers 完整指南让 AI 编程助手按流程写代码Superpowers 完整指南让 AI 编程助手按流程写代码 Superpowers 是一套给 AI 编程助手用的技能框架把先设计、再计划、后编码、审完AI 技能AI 插件开发工具上一篇rkt容器多架构镜像构建性能原生构建与交叉编译下一篇TTTAttributedLabel全面性能调优从入门到专家创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表