使用JointJS免费版创建含按钮与动态输入框的自定义Rect求助
Angular + JointJS 自定义带交互元素的Rect实现方案
针对你遇到的HTML元素位置同步失效问题,本质是没考虑JointJS Paper的视口变换(平移、缩放),以及元素坐标到DOM坐标的正确转换。下面提供两种可行方案:
方案一:修复HTML元素同步逻辑
之前的实现只监听了元素位置变化,但忽略了Paper视口变换时的坐标偏移。调整思路:
- 将HTML元素挂载到Paper的DOM容器内,而非body
- 使用JointJS提供的坐标转换方法,把元素的SVG局部坐标转为容器内的DOM坐标
- 监听Paper的
translate和scale事件,实时更新元素位置
代码示例(Angular环境)
import { ViewChild, ElementRef, Component, OnInit } from '@angular/core'; import * as joint from 'jointjs'; @Component({ selector: 'app-joint-editor', template: '<div #paperContainer style="position: relative; width: 100%; height: 80vh;"></div>' }) export class JointEditorComponent implements OnInit { @ViewChild('paperContainer') paperContainer!: ElementRef<HTMLDivElement>; private paper!: joint.dia.Paper; private graph!: joint.dia.Graph; ngOnInit() { this.graph = new joint.dia.Graph(); this.paper = new joint.dia.Paper({ el: this.paperContainer.nativeElement, model: this.graph, width: 1200, height: 800, gridSize: 10, drawGrid: true, interactive: { move: true } }); this.createCustomRectWithHtml(); } private createCustomRectWithHtml() { // 创建基础Rect元素 const rect = new joint.shapes.standard.Rectangle({ position: { x: 150, y: 100 }, size: { width: 220, height: 320 }, attrs: { body: { fill: '#f0f0f0', stroke: '#666' } } }); this.graph.addCell(rect); // 创建输入框并挂载到Paper容器 const input = this.createInputField('请输入内容'); this.paperContainer.nativeElement.appendChild(input); // 创建按钮 const btnAdd = this.createButton('添加输入框', () => { // 这里可以扩展添加新输入框的逻辑 console.log('添加输入框'); }); const btnDelete = this.createButton('删除输入框', () => { console.log('删除输入框'); }); this.paperContainer.nativeElement.appendChild(btnAdd); this.paperContainer.nativeElement.appendChild(btnDelete); // 统一更新位置的方法 const updateElementPositions = () => { const elementBBox = rect.getBBox(); // 把SVG局部坐标转为容器内的DOM坐标 const clientPos = this.paper.localToClient(elementBBox); // 输入框位置(相对于Rect偏移) input.style.left = `${clientPos.x + 15}px`; input.style.top = `${clientPos.y + 30}px`; // 按钮位置 btnAdd.style.left = `${clientPos.x + 15}px`; btnAdd.style.top = `${clientPos.y + 70}px`; btnDelete.style.left = `${clientPos.x + 125}px`; btnDelete.style.top = `${clientPos.y + 70}px`; }; // 初始更新 updateElementPositions(); // 监听元素位置/尺寸变化 rect.on('change:position change:size', updateElementPositions); // 监听Paper视口变换(平移、缩放) this.paper.on('translate scale', updateElementPositions); // 元素删除时清理HTML元素 rect.on('remove', () => { input.remove(); btnAdd.remove(); btnDelete.remove(); }); } // 封装输入框创建逻辑 private createInputField(placeholder: string): HTMLInputElement { const input = document.createElement('input'); input.type = 'text'; input.placeholder = placeholder; input.style.position = 'absolute'; input.style.width = '190px'; input.style.height = '30px'; input.style.padding = '0 5px'; input.style.border = '1px solid #ddd'; input.style.borderRadius = '4px'; input.addEventListener('input', (e) => { // 把输入值存在元素的data中,方便后续使用 rect.set('data', { ...rect.get('data'), inputValue: (e.target as HTMLInputElement).value }); }); return input; } // 封装按钮创建逻辑 private createButton(text: string, onClick: () => void): HTMLButtonElement { const btn = document.createElement('button'); btn.textContent = text; btn.style.position = 'absolute'; btn.style.width = '90px'; btn.style.height = '30px'; btn.style.border = 'none'; btn.style.borderRadius = '4px'; btn.style.backgroundColor = '#2563eb'; btn.style.color = 'white'; btn.style.cursor = 'pointer'; btn.addEventListener('click', onClick); return btn; } }
方案二:纯SVG自定义元素(推荐)
利用JointJS的自定义元素能力,通过<foreignObject>嵌入HTML内容,这样内容属于SVG的一部分,会自动跟随元素和Paper的变换,无需手动同步坐标。同时用JointJS的Ports系统实现输入框的输出节点。
代码示例(Angular环境)
import { ViewChild, ElementRef, Component, OnInit } from '@angular/core'; import * as joint from 'jointjs'; // 自定义Rect元素 class InteractiveRect extends joint.shapes.standard.Rectangle { defaults() { return { ...super.defaults, size: { width: 250, height: 350 }, attrs: { body: { fill: '#f0f0f0', stroke: '#666', strokeWidth: 2 }, label: { text: '交互元素', fontSize: 14, fontWeight: 'bold', y: 20 } }, // 定义输出端口组 ports: { groups: { output: { position: 'right', attrs: { circle: { r: 6, fill: '#10b981', stroke: '#fff', strokeWidth: 2 } } } } }, // 存储输入框数据,支持动态增减 data: { inputs: [{ id: 'input1', value: '', hasPort: true }, { id: 'input2', value: '', hasPort: false }] } }; } // 自定义SVG标记,包含foreignObject嵌入HTML markup = joint.util.svg` <g class="rotatable"> <g class="scalable"> <rect @selector="body" /> </g> <text @selector="label" text-anchor="middle" x="125" /> <!-- 嵌入HTML内容的foreignObject --> <foreignObject @selector="content" x="10" y="40" width="230" height="290"> <body xmlns="http://www.w3.org/1999/xhtml"> <div class="content-wrapper" style="padding: 5px; font-family: sans-serif;"> </div> </body> </foreignObject> </g> `; initialize() { super.initialize(); // 初始化内容 this.renderContent(); // 监听数据变化更新内容 this.on('change:data', () => this.renderContent()); } // 动态渲染输入框和按钮 renderContent() { const contentWrapper = this.findSelector('content').querySelector('.content-wrapper'); if (!contentWrapper) return; contentWrapper.innerHTML = ''; // 添加操作按钮 const btnGroup = document.createElement('div'); btnGroup.style.display = 'flex'; btnGroup.style.gap = '5px'; btnGroup.style.marginBottom = '10px'; const btnAdd = document.createElement('button'); btnAdd.textContent = '添加输入框'; btnAdd.style.padding = '5px 10px'; btnAdd.style.border = 'none'; btnAdd.style.borderRadius = '4px'; btnAdd.style.backgroundColor = '#2563eb'; btnAdd.style.color = 'white'; btnAdd.style.cursor = 'pointer'; btnAdd.addEventListener('click', () => { const newInput = { id: `input${Date.now()}`, value: '', hasPort: false }; const inputs = [...this.get('data').inputs, newInput]; this.set('data', { ...this.get('data'), inputs }); // 如果需要给新输入框加端口 if (newInput.hasPort) { this.addPort({ group: 'output', id: `port-${inputs.length - 1}` }); } }); const btnDel = document.createElement('button'); btnDel.textContent = '删除输入框'; btnDel.style.padding = '5px 10px'; btnDel.style.border = 'none'; btnDel.style.borderRadius = '4px'; btnDel.style.backgroundColor = '#ef4444'; btnDel.style.color = 'white'; btnDel.style.cursor = 'pointer'; btnDel.addEventListener('click', () => { const inputs = this.get('data').inputs.slice(0, -1); if (inputs.length === 0) return; this.set('data', { ...this.get('data'), inputs }); // 删除对应端口 this.removePort(`port-${inputs.length}`); }); btnGroup.appendChild(btnAdd); btnGroup.appendChild(btnDel); contentWrapper.appendChild(btnGroup); // 渲染输入框 this.get('data').inputs.forEach((input, index) => { const inputRow = document.createElement('div'); inputRow.style.display = 'flex'; inputRow.style.alignItems = 'center'; inputRow.style.gap = '5px'; inputRow.style.marginBottom = '8px'; const inputField = document.createElement('input'); inputField.type = 'text'; inputField.value = input.value; inputField.style.flex = '1'; inputField.style.padding = '5px'; inputField.style.border = '1px solid #ddd'; inputField.style.borderRadius = '4px'; inputField.addEventListener('input', (e) => { const inputs = [...this.get('data').inputs]; inputs[index].value = (e.target as HTMLInputElement).value; this.set('data', { ...this.get('data'), inputs }); }); inputRow.appendChild(inputField); // 带输出端口的输入框显示标识 if (input.hasPort) { const portId = `port-${index}`; if (!this.getPort(portId)) { this.addPort({ group: 'output', id: portId }); } const portTag = document.createElement('span'); portTag.textContent = '🔗'; portTag.style.fontSize = '16px'; inputRow.appendChild(portTag); } contentWrapper.appendChild(inputRow); }); } } @Component({ selector: 'app-joint-editor', template: '<div #paperContainer style="position: relative; width: 100%; height: 80vh;"></div>' }) export class JointEditorComponent implements OnInit { @ViewChild('paperContainer') paperContainer!: ElementRef<HTMLDivElement>; private paper!: joint.dia.Paper; private graph!: joint.dia.Graph; ngOnInit() { this.graph = new joint.dia.Graph(); this.paper = new joint.dia.Paper({ el: this.paperContainer.nativeElement, model: this.graph, width: 1200, height: 800, gridSize: 10, drawGrid: true, interactive: true, // 允许foreignObject内的HTML交互 preventDefaultBlankAction: true, preventDefaultLinkAction: true }); // 创建自定义元素 const customRect = new InteractiveRect({ position: { x: 150, y: 100 } }); this.graph.addCell(customRect); // 测试端口连接 const targetRect = new joint.shapes.standard.Rectangle({ position: { x: 500, y: 150 }, size: { width: 120, height: 60 }, attrs: { body: { fill: '#3b82f6' } } }); this.graph.addCell(targetRect); const link = new joint.shapes.standard.Link({ source: { id: customRect.id, port: 'port-0' }, target: { id: targetRect.id } }); this.graph.addCell(link); } }
方案对比
- HTML元素方案:适合简单场景,HTML交互逻辑易上手,但需手动处理坐标同步,容易出现视口变换后失效的问题。
- 纯SVG自定义元素方案:推荐,内容自动跟随元素和Paper的所有变换,无需手动维护位置,且能无缝对接JointJS的端口系统实现节点连接,更符合JointJS的设计逻辑。
内容的提问来源于stack exchange,提问作者jimmyakiraly
相关产品推荐
相关产品推荐

