<div id="stencil-container"></div>
<div id="paper-container"></div>
<a target="_blank" href="https://www.jointjs.com">
  <img id="logo" src="https://assets.codepen.io/7589991/jointjs-logo.svg" width="200" height="50"></img>
</a>
#stencil-container {
  position: absolute;
  left: 0;
  top: 0;
  width: 100px;
  bottom: 0;
}

#paper-container {
  position: absolute;
  right: 0;
  top: 0;
  left: 100px;
  bottom: 0;
}

#logo {
  position: absolute;
  bottom: 20px;
  right: 0;
}

.invalid-drop-target {
  stroke: #940027;
}
const { dia, shapes, mvc, ui, highlighters, util } = joint;

const paperContainerEl = document.getElementById("paper-container");
const stencilContainerEl = document.getElementById("stencil-container");
// Paper
// -----

const graph = new dia.Graph({}, { cellNamespace: shapes });
const paper = new dia.Paper({
  model: graph,
  cellViewNamespace: shapes,
  width: "100%",
  height: "100%",
  gridSize: 20,
  drawGrid: { name: "mesh" },
  async: true,
  sorting: dia.Paper.sorting.APPROX,
  background: { color: "#F3F7F6" },
  defaultLink: () => new joint.shapes.standard.Link(),
  defaultConnectionPoint: { name: "boundary" },
  clickThreshold: 10,
  magnetThreshold: "onleave",
  linkPinning: false,
  validateConnection: (sourceView, _, targetView) => sourceView !== targetView,
  snapLinks: { radius: 10 }
});

paperContainerEl.appendChild(paper.el);

paper.on("element:magnet:pointerclick", (elementView, evt, magnet) => {
  paper.removeTools();
  elementView.addTools(new dia.ToolsView({ tools: [new Ports()] }));
});

paper.on("blank:pointerdown cell:pointerdown", () => {
  paper.removeTools();
});

// Stencil
// -------

const stencil = new ui.Stencil({
  paper,
  usePaperGrid: true,
  width: 100,
  height: "100%",
  paperOptions: () => {
    return {
      model: new dia.Graph({}, { cellNamespace: shapes }),
      cellViewNamespace: shapes,
      background: {
        color: "#FCFCFC"
      }
    };
  },
  groups: {
    elements: {},
    ports: {}
  },
  layout: {
    columns: 1,
    rowHeight: "compact",
    rowGap: 10,
    columnWidth: 100,
    marginY: 10,
    // reset defaults
    resizeToFit: false,
    dx: 0,
    dy: 0
  },
  usePaperGrid: true,
  dragStartClone: (cell) => {
    const clone = cell.clone();
    if (clone.get("port")) {
      const { width, height } = clone.size();
      clone.attr("body/fill", "lightgray");
      // Maker sure the center of the port is in the grid.
      clone.attr("body/transform", `translate(-${width / 2}, -${height / 2})`);
    } else {
      clone.resize(200, 200);
    }
    return clone;
  }
});

stencil.render();
stencilContainerEl.appendChild(stencil.el);

const stencilElements = [
  {
    type: "standard.Rectangle",
    size: { width: 80, height: 60 },
    attrs: {
      body: {
        fill: "#80ffd5"
      }
    }
  },
  {
    type: "standard.Rectangle",
    size: { width: 80, height: 60 },
    attrs: {
      body: {
        rx: 10,
        ry: 10,
        fill: "#48cba4"
      }
    }
  }
];

// Every stencil port element has to have a `port` property.
// The `port` property describes the port itself after it's dropped on the paper.
const stencilPorts = [
  {
    type: "standard.Rectangle",
    size: { width: 24, height: 24 },
    attrs: {
      body: {
        fill: "#ff9580"
      }
    },
    port: {
      markup: joint.util.svg/*xml*/ `
                <rect @selector="portBody"
                    x="-12"
                    y="-12"
                    width="24"
                    height="24"
                    fill="#ff9580"
                    stroke="#333333"
                    stroke-width="2"
                    magnet="active"
                />
            `
    }
  },
  {
    type: "standard.Path",
    size: { width: 30, height: 30 },
    attrs: {
      body: {
        d:
          "M calc(0.5*w) 0 calc(w) calc(0.5*h) calc(0.5*w) calc(h) 0 calc(0.5*h) Z",
        fill: "#c86653",
        stroke: "#333333"
      }
    },
    port: {
      markup: joint.util.svg/*xml*/ `
                <path @selector="portBody"
                    d="M 0 -15 L 15 0 L 0 15 L -15 0 Z"
                    fill="#c86653"
                    stroke="#333333"
                    stroke-width="2"
                    magnet="active"
                />
            `
    }
  },
  {
    type: "standard.Circle",
    size: { width: 30, height: 30 },
    attrs: {
      body: {
        fill: "#80aaff",
        stroke: "#333333"
      }
    },
    port: {
      markup: joint.util.svg/*xml*/ `
                <circle @selector="portBody"
                    r="15"
                    fill="#80aaff"
                    stroke="#333333"
                    stroke-width="2"
                    magnet="active"
                />
            `
    }
  }
];

stencilElements.forEach(
  (element) =>
    (element.ports = {
      groups: {
        absolute: {
          position: "absolute",
          label: {
            position: { name: "inside", args: { offset: 22 } },
            markup: joint.util.svg/*xml*/ `
                    <text @selector="portLabel"
                        y="0.3em"
                        fill="#333"
                        text-anchor="middle"
                        font-size="15"
                        font-family="sans-serif"
                    />
                `
          }
        }
      },
      items: []
    })
);

stencil.load({
  elements: stencilElements,
  ports: stencilPorts
});

let portIdCounter = 1;

function addElementPort(element, port, position) {
  const portId = `P-${portIdCounter++}`;
  element.addPort({
    id: portId,
    group: "absolute",
    args: position,
    ...util.merge(port, {
      attrs: {
        portLabel: {
          text: portId
        }
      }
    })
  });
  return portId;
}

stencil.on({
  "element:dragstart": (cloneView, evt) => {
    const clone = cloneView.model;
    evt.data.isPort = clone.get("port");
    paper.removeTools();
  },
  "element:dragstart element:drag": (cloneView, evt, cloneArea) => {
    if (!evt.data.isPort) {
      return;
    }
    // Note: cloneArea `topLeft` points to the center of the port because of
    // the `translate(-${width/2}, -${height/2})` transform we added to the port
    // in the `dragStartClone` callback.
    const [dropTarget] = graph.findModelsFromPoint(cloneArea.topLeft());
    if (dropTarget) {
      evt.data.dropTarget = dropTarget;
      highlighters.mask.add(
        dropTarget.findView(paper),
        "body",
        "valid-drop-target",
        {
          layer: dia.Paper.Layers.BACK,
          attrs: {
            stroke: "#9580ff",
            "stroke-width": 2
          }
        }
      );
      highlighters.addClass.removeAll(cloneView.paper, "invalid-drop-target");
    } else {
      evt.data.dropTarget = null;
      highlighters.addClass.add(cloneView, "body", "invalid-drop-target", {
        className: "invalid-drop-target"
      });
      highlighters.mask.removeAll(paper, "valid-drop-target");
    }
  },
  "element:dragend": (cloneView, evt, cloneArea) => {
    if (!evt.data.isPort) {
      return;
    }
    const clone = cloneView.model;
    const { dropTarget } = evt.data;
    if (dropTarget) {
      stencil.cancelDrag();
      addElementPort(
        dropTarget,
        clone.get("port"),
        cloneArea.topLeft().difference(dropTarget.position()).toJSON()
      );
    } else {
      // An invalid drop target. Animate the port back to the stencil.
      stencil.cancelDrag({ dropAnimation: true });
    }
    highlighters.mask.removeAll(paper, "valid-drop-target");
  }
});

// Port Move Tool
// --------------

// A custom element tool that allows to move a port of an element.
// The source code comes from `joint.linkTools.Segment` and has been modified for this sample.

const PortHandle = mvc.View.extend({
  tagName: "circle",
  svgElement: true,
  className: "port-handle",
  events: {
    mousedown: "onPointerDown",
    touchstart: "onPointerDown"
  },
  documentEvents: {
    mousemove: "onPointerMove",
    touchmove: "onPointerMove",
    mouseup: "onPointerUp",
    touchend: "onPointerUp",
    touchcancel: "onPointerUp"
  },
  attributes: {
    r: 20,
    fill: "transparent",
    stroke: "#002b33",
    "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);
  },
  onPointerUp: function (evt) {
    if (evt.detail === 2) {
      this.trigger("remove", this, evt);
    } else {
      this.trigger("changed", this, evt);
      this.undelegateDocumentEvents();
    }
    this.options.paper.delegateEvents();
  }
});

const Ports = dia.ToolView.extend({
  name: "ports",
  options: {
    handleClass: PortHandle,
    activeColor: "#4666E5"
  },
  children: [
    {
      tagName: "circle",
      selector: "preview",
      className: "joint-ports-preview",
      attributes: {
        r: 13,
        "stroke-width": 2,
        fill: "#4666E5",
        "fill-opacity": 0.3,
        stroke: "#4666E5",
        "pointer-events": "none"
      }
    }
  ],
  handles: null,
  onRender: function () {
    this.renderChildren();
    this.updatePreview(null);
    this.resetHandles();
    this.renderHandles();
    return this;
  },
  update: function () {
    const positions = this.getPortPositions();
    if (positions.length === this.handles.length) {
      this.updateHandles();
    } else {
      this.resetHandles();
      this.renderHandles();
    }
    this.updatePreview(null);
    return this;
  },
  resetHandles: function () {
    const handles = this.handles;
    this.handles = [];
    this.stopListening();
    if (!Array.isArray(handles)) return;
    for (let i = 0, n = handles.length; i < n; i++) {
      handles[i].remove();
    }
  },
  renderHandles: function () {
    const positions = this.getPortPositions();
    for (let i = 0, n = positions.length; i < n; i++) {
      const position = positions[i];
      const handle = new this.options.handleClass({
        index: i,
        portId: position.id,
        paper: this.paper,
        guard: (evt) => this.guard(evt)
      });
      handle.render();
      handle.position(position.x, position.y);
      this.simulateRelatedView(handle.el);
      handle.vel.appendTo(this.el);
      this.handles.push(handle);
      this.startHandleListening(handle);
    }
  },
  updateHandles: function () {
    const positions = this.getPortPositions();
    for (let i = 0, n = positions.length; i < n; i++) {
      const position = positions[i];
      const handle = this.handles[i];
      if (!handle) return;
      handle.position(position.x, position.y);
    }
  },
  updatePreview: function (x, y) {
    const { preview } = this.childNodes;
    if (!preview) return;
    if (!Number.isFinite(x)) {
      preview.setAttribute("display", "none");
    } else {
      preview.removeAttribute("display");
      preview.setAttribute("transform", `translate(${x},${y})`);
    }
  },
  startHandleListening: function (handle) {
    this.listenTo(handle, "will-change", this.onHandleWillChange);
    this.listenTo(handle, "changing", this.onHandleChanging);
    this.listenTo(handle, "changed", this.onHandleChanged);
    this.listenTo(handle, "remove", this.onHandleRemove);
  },
  onHandleWillChange: function (handle, evt) {
    this.focus();
    handle.color(this.options.activeColor);
    const portNode = this.relatedView.findPortNode(
      handle.options.portId,
      "root"
    );
    portNode.style.opacity = 0.2;
  },
  onHandleChanging: function (handle, evt) {
    const { x, y } = this.getPositionFromEvent(evt);
    this.updatePreview(x, y);
  },
  onHandleChanged: function (handle, evt) {
    const { relatedView } = this;
    const { model } = relatedView;
    const portId = handle.options.portId;
    handle.color(null);
    const portNode = this.relatedView.findPortNode(portId, "root");
    portNode.style.opacity = "";
    this.updatePreview(null);
    const delta = this.getPositionFromEvent(evt).difference(
      relatedView.model.position()
    );
    model.portProp(
      portId,
      "args",
      { x: delta.x, y: delta.y },
      { rewrite: true, tool: this.cid }
    );
    this.resetHandles();
    this.renderHandles();
  },
  onHandleRemove: function (handle, evt) {
    const { relatedView } = this;
    const { model } = relatedView;
    const portId = handle.options.portId;
    handle.color(null);
    const portNode = this.relatedView.findPortNode(portId, "root");
    portNode.style.opacity = "";
    this.updatePreview(null);
    model.removePort(portId, { tool: this.cid });
    this.resetHandles();
    this.renderHandles();
  },
  // Get an array with all the port positions.
  getPortPositions: function () {
    const { relatedView } = this;
    const translateMatrix = relatedView.getRootTranslateMatrix();
    const rotateMatrix = relatedView.getRootRotateMatrix();
    const matrix = translateMatrix.multiply(rotateMatrix);
    const groupNames = Object.keys(relatedView.model.prop("ports/groups"));
    const portsPositions = {};
    for (let i = 0, n = groupNames.length; i < n; i++) {
      Object.assign(
        portsPositions,
        relatedView.model.getPortsPositions(groupNames[i])
      );
    }
    const positions = [];
    for (let id in portsPositions) {
      const point = V.transformPoint(portsPositions[id], matrix);
      positions.push({
        x: point.x,
        y: point.y,
        id
      });
    }
    return positions;
  },
  // Get the port position from the event coordinates.
  // The position is snapped to the point inside the element's bbox.
  getPositionFromEvent: function (evt) {
    const { relatedView } = this;
    const bbox = relatedView.model.getBBox();
    const [, x, y] = relatedView.paper.getPointerArgs(evt);
    const p = new g.Point(x, y);
    if (bbox.containsPoint(p)) {
      return p;
    }
    return bbox.pointNearestToPoint(p);
  },
  onRemove: function () {
    this.resetHandles();
  }
});

// Example graph
// -------------

graph.addCells([
  {
    ...stencilElements[0],
    id: "r1",
    position: { x: 40, y: 100 },
    size: { width: 200, height: 200 }
  },
  {
    ...stencilElements[1],
    id: "r2",
    position: { x: 400, y: 100 },
    size: { width: 200, height: 200 }
  }
]);

const r1 = graph.getCell("r1");
const r1p1 = addElementPort(r1, stencilPorts[0].port, { x: "100%", y: 20 });
const r1p2 = addElementPort(r1, stencilPorts[0].port, { x: "100%", y: 60 });
addElementPort(r1, stencilPorts[0].port, { x: "100%", y: 100 });
addElementPort(r1, stencilPorts[2].port, { x: "100%", y: 180 });

const r2 = graph.getCell("r2");
const r2p1 = addElementPort(r2, stencilPorts[1].port, { x: 0, y: 40 });
const r2p2 = addElementPort(r2, stencilPorts[1].port, { x: 0, y: 160 });

graph.addCells([
  {
    type: "standard.Link",
    source: { id: "r1", port: r1p1 },
    target: { id: "r2", port: r2p1 }
  },
  {
    type: "standard.Link",
    source: { id: "r1", port: r1p2 },
    target: { id: "r2", port: r2p2 }
  }
]);

graph
  .getCell("r2")
  .findView(paper)
  .addTools(
    new dia.ToolsView({
      tools: [new Ports()]
    })
  );

External CSS

  1. https://resources.jointjs.com/demos/rappid/build/package/rappid.css

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js
  2. https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js
  3. https://cdnjs.cloudflare.com/ajax/libs/backbone.js/1.4.0/backbone-min.js
  4. https://resources.jointjs.com/demos/rappid/build/package/rappid.js