feat(engine): canvas panning, wheel zoom and link editing tools

Drag empty space to pan, mouse-wheel to zoom, and selecting a link now
shows vertex/segment/arrowhead/remove tools so connectors can be rerouted
and deleted interactively.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilya Ashikhmin 2026-07-02 23:39:55 +02:00
parent 8779c88e66
commit e43ace1853

View File

@ -3,7 +3,7 @@
* framework-agnostic API for the React shell: add nodes, edit properties, * framework-agnostic API for the React shell: add nodes, edit properties,
* select/delete/copy, undo/redo, configure, save/load and run the mock flow. * select/delete/copy, undo/redo, configure, save/load and run the mock flow.
*/ */
import { dia, shapes, highlighters, g } from '@joint/core' import { dia, shapes, highlighters, linkTools, g } from '@joint/core'
import { import {
PipelineNode, PipelineNode,
createNode, createNode,
@ -113,10 +113,47 @@ export class DiagramController {
} }
}) })
// Pan the canvas by dragging empty space.
let pan: { tx: number; ty: number; x: number; y: number } | null = null
paper.on('blank:pointerdown', (evt: dia.Event) => {
const t = paper.translate()
pan = { tx: t.tx, ty: t.ty, x: evt.clientX ?? 0, y: evt.clientY ?? 0 }
})
paper.on('blank:pointermove', (evt: dia.Event) => {
if (!pan) return
paper.translate(pan.tx + ((evt.clientX ?? 0) - pan.x), pan.ty + ((evt.clientY ?? 0) - pan.y))
})
paper.on('blank:pointerup', () => (pan = null))
// Wheel to zoom toward the cursor.
paper.on('blank:mousewheel', (evt: dia.Event, _x, _y, delta: number) => {
evt.preventDefault?.()
this.zoom(delta > 0 ? 1.1 : 1 / 1.1)
})
paper.on('cell:mousewheel', (_v, evt: dia.Event, _x, _y, delta: number) => {
evt.preventDefault?.()
this.zoom(delta > 0 ? 1.1 : 1 / 1.1)
})
this.graph.on('remove', () => !this.restoring && this.fire('change')) this.graph.on('remove', () => !this.restoring && this.fire('change'))
this.graph.on('add', () => !this.restoring && this.fire('change')) this.graph.on('add', () => !this.restoring && this.fire('change'))
} }
private addLinkTools(link: dia.Link): void {
const view = this.paper?.findViewByModel(link)
if (!view) return
view.addTools(
new dia.ToolsView({
tools: [
new linkTools.Vertices(),
new linkTools.Segments(),
new linkTools.TargetArrowhead(),
new linkTools.Remove({ distance: 20 })
]
})
)
}
private validateConnection = ( private validateConnection = (
sourceView: dia.CellView, sourceView: dia.CellView,
sourceMagnet: SVGElement | null, sourceMagnet: SVGElement | null,
@ -197,6 +234,7 @@ export class DiagramController {
padding: 4, padding: 4,
attrs: { stroke: '#f0c000', 'stroke-width': 2 } attrs: { stroke: '#f0c000', 'stroke-width': 2 }
}) })
if (cell.isLink()) this.addLinkTools(cell as dia.Link)
} }
} }
@ -206,7 +244,10 @@ export class DiagramController {
if (!cell) continue if (!cell) continue
if (!cell.isLink()) this.setPortsVisible(cell as dia.Element, false) if (!cell.isLink()) this.setPortsVisible(cell as dia.Element, false)
const view = this.paper?.findViewByModel(cell) const view = this.paper?.findViewByModel(cell)
if (view) highlighters.mask.remove(view, 'sel') if (view) {
highlighters.mask.remove(view, 'sel')
if (cell.isLink()) view.removeTools()
}
} }
} }