Skip to main content
Version: 4.3

Add a property editor

A properties panel is a plain React component which uses JointJS+'s hooks. It uses JointJS+'s Selection component to provide interactivity and control over the selected elements. It reads whichever cell is selected and writes edits straight back to the graph, exactly the state flow from earlier chapters, now driving a real inspector:

import { useCallback, useEffect, useMemo } from 'react';
import {
  Diagram,
  Paper,
  PaperScroller,
  Selection,
  Stencil,
  useStencil,
  usePaperScroller,
  usePaperScrollerViewport,
  useSelection,
  useGraph,
  useCell,
  useCells,
  selectElementData,
  HTMLHost,
  linkRoutingOrthogonal,
  type CellId,
  type CellRecord,
  type ElementPort,
  type ElementRecord,
} from '@joint/react-plus';
import '@joint/react-plus/styles.css';
import { Slider } from './components/ui/slider';
import './example.css';

// ── Types & config ────────────────────────────────────────────────────────────

interface NodeData {
  readonly label: string;
  readonly description: string;
  readonly icon: string;
  readonly color: string;
}

const ORTHOGONAL_LINKS = linkRoutingOrthogonal();
const NODE_SIZE = { width: 160, height: 52 };
const MIN_ZOOM = 0.2;
const MAX_ZOOM = 3;

const PORTS: Record<string, ElementPort> = {
  in: { cx: 0, cy: '50%', width: 10, height: 10, passive: true },
  out: { cx: '100%', cy: '50%', width: 10, height: 10 },
};

const NODE_TYPES: NodeData[] = [
  { label: 'Trigger', description: 'Listens for events', icon: '⚡', color: '#f59e0b' },
  { label: 'Action', description: 'Performs a task', icon: '▶', color: '#22c55e' },
  { label: 'Condition', description: 'Branches the flow', icon: '◆', color: '#a855f7' },
  { label: 'AI Agent', description: 'Runs an AI model', icon: '🤖', color: '#3b82f6' },
];

const INITIAL_CELLS: readonly CellRecord[] = [
  {
    id: 'n1',
    type: 'element',
    position: { x: 60, y: 40 },
    size: NODE_SIZE,
    portMap: PORTS,
    data: { label: 'New Ticket', description: 'Listens for events', icon: '⚡', color: '#f59e0b' },
  },
  {
    id: 'n2',
    type: 'element',
    position: { x: 60, y: 160 },
    size: NODE_SIZE,
    portMap: PORTS,
    data: { label: 'Classify', description: 'Analyze ticket type', icon: '🤖', color: '#3b82f6' },
  },
  {
    id: 'n3',
    type: 'element',
    position: { x: 300, y: 100 },
    size: NODE_SIZE,
    portMap: PORTS,
    data: { label: 'Is Urgent?', description: 'Check priority', icon: '◆', color: '#a855f7' },
  },
  {
    id: 'n4',
    type: 'element',
    position: { x: 300, y: 240 },
    size: NODE_SIZE,
    portMap: PORTS,
    data: { label: 'Send Reply', description: 'Auto-respond', icon: '▶', color: '#22c55e' },
  },
  { id: 'n1→n3', type: 'link', source: { id: 'n1', port: 'out' }, target: { id: 'n3', port: 'in' }, style: { color: '#94a3b8' } },
  { id: 'n2→n3', type: 'link', source: { id: 'n2', port: 'out' }, target: { id: 'n3', port: 'in' }, style: { color: '#94a3b8' } },
  { id: 'n3→n4', type: 'link', source: { id: 'n3', port: 'out' }, target: { id: 'n4', port: 'in' }, style: { color: '#94a3b8' } },
];

// ── Element renderer ──────────────────────────────────────────────────────────

function RenderElement({ label, description, icon, color }: Readonly<NodeData>) {
  return (
    <HTMLHost useModelGeometry className="node">
      <span className="node__icon" style={{ color }}>
        {icon}
      </span>
      <div className="node__body">
        <div className="node__label">{label}</div>
        <div className="node__desc">{description}</div>
      </div>
    </HTMLHost>
  );
}

// ── Stencil palette ───────────────────────────────────────────────────────────

function PaletteItem({ nodeData }: Readonly<{ nodeData: NodeData }>) {
  const { startCellDrag } = useStencil();
  const cell: CellRecord<NodeData> = useMemo(
    () => ({ type: 'element', size: NODE_SIZE, portMap: PORTS, data: nodeData }),
    [nodeData]
  );
  return (
    <div className="palette__item" onPointerDown={(event) => startCellDrag(cell, event)}>
      <span style={{ color: nodeData.color }}>{nodeData.icon}</span>
      <span>{nodeData.label}</span>
    </div>
  );
}

function Palette() {
  return (
    <div className="palette">
      <div className="pane-title" style={{ padding: 0 }}>
        Components
      </div>
      {NODE_TYPES.map((node) => (
        <PaletteItem key={node.label} nodeData={node} />
      ))}
    </div>
  );
}

// ── Properties panel ──────────────────────────────────────────────────────────

function NodeEditor({ cellId }: Readonly<{ cellId: CellId }>) {
  const data = useCell(cellId, selectElementData<NodeData>);
  const { setCell, isElement } = useGraph<ElementRecord<NodeData>>();

  const update = useCallback(
    (field: 'label' | 'description') => (event: React.ChangeEvent<HTMLInputElement>) => {
      setCell(cellId, (cell) =>
        isElement(cell) ? { ...cell, data: { ...cell.data, [field]: event.target.value } } : cell
      );
    },
    [cellId, setCell, isElement]
  );

  return (
    <div className="editor">
      <div className="editor__head">
        <span style={{ color: data.color }}>{data.icon}</span>
        <span>{data.label}</span>
      </div>
      <label className="field">
        <span className="field__label">Label</span>
        <input className="field__input" value={data.label} onChange={update('label')} />
      </label>
      <label className="field">
        <span className="field__label">Description</span>
        <input className="field__input" value={data.description} onChange={update('description')} />
      </label>
    </div>
  );
}

function Properties() {
  const { collection } = useSelection();
  const selectedIds = useCells(collection, (cells) => (cells ?? []).map((c) => String(c.id)));

  if (selectedIds.length === 0) {
    return <div className="hint">Click a node to edit it.</div>;
  }

  const elementIds = selectedIds.filter((id) => collection?.get(id)?.isElement());
  if (elementIds.length === 0) {
    return <div className="hint">{selectedIds.length} link(s) selected</div>;
  }

  return (
    <>
      {elementIds.map((id) => (
        <NodeEditor key={id} cellId={id} />
      ))}
    </>
  );
}

// ── Canvas ────────────────────────────────────────────────────────────────────

function ZoomControl() {
  const { setZoom } = usePaperScroller();
  const { zoom } = usePaperScrollerViewport();

  return (
    <div className="zoom-control">
      <Slider
        className="zoom-control__slider"
        min={MIN_ZOOM}
        max={MAX_ZOOM}
        step={0.01}
        value={zoom}
        onValueChange={(value) => setZoom(value as number)}
      />
      <span className="zoom-control__value">{Math.round(zoom * 100)}%</span>
    </div>
  );
}

function Canvas() {
  const { paperScroller } = usePaperScroller();
  useEffect(() => {
    if (paperScroller) paperScroller.centerContent({ useModelGeometry: true });
  }, [paperScroller]);

  return (
    <div className="pane-center">
      <ZoomControl />
      <PaperScroller
        style={{ height: '100%' }}
        cursor="grab"
        scrollWhileDragging
        minZoom={MIN_ZOOM}
        maxZoom={MAX_ZOOM}
      >
        <Paper gridSize={10} renderElement={RenderElement} linkRouting={ORTHOGONAL_LINKS}>
          <Selection wrapper={{ handles: [] }} />
        </Paper>
      </PaperScroller>
    </div>
  );
}

// ── App ───────────────────────────────────────────────────────────────────────

export default function App() {
  return (
    <Diagram initialCells={INITIAL_CELLS}>
      <div className="app">
        <div className="pane-left">
          <Stencil renderElement={RenderElement}>
            <Palette />
          </Stencil>
        </div>
        <Canvas />
        <div className="pane-right">
          <div className="pane-title">Properties</div>
          <Properties />
        </div>
      </div>
    </Diagram>
  );
}


Click a node to select it (a <Selection> mounted inside Paper is what makes it clickable), and edit its label or description on the right. The pattern looks like this:

function PropertiesPanel() {
const { collection } = useSelection();
const [id] = useCells(collection, (cells) => cells.map((cell) => cell.id));
const { setCell } = useGraph();
const data = useCell(id, selectElementData<NodeData>);

if (!id || !data) return <p>Select a node to edit it.</p>;

return (
<input
value={data.label}
onChange={(event) =>
setCell(id, (cell) => ({ ...cell, data: { ...cell.data, label: event.target.value } }))
}
/>
);
}
  • useSelection() (from JointJS+) exposes the collection of currently selected cells; see Selection.
  • useCell(id, selector) reads that specific cell reactively, from anywhere in the tree, not just inside renderElement.
  • useGraph().setCell(id, updater) writes the edit back; Paper re-renders the node the moment the graph changes.

Together with hooks and interactivity, Selection also applies a CSS class to the selected element, so you can style it appropriately. In this example, the node's border color and width change when selected:

.joint-element.jj-is-selected .node {
border-color: var(--jj-color-primary);
}

Because the panel and the canvas both read from the same graph, there is nothing to synchronize by hand. Type in the input and the node on the canvas updates immediately, and dragging the node doesn't touch the panel's fields unless you read its position too.