You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

使用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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.22 05:24:55