diff --git a/CHANGELOG.md b/CHANGELOG.md index fab2b02..967ba54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ All notable changes to IPD Studio. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.16.0] — 2026-09-01 — magnetic docking + +### Added + +- **Magnetic docking** — connect by touching instead of by drawing. Drag a + symbol (in from the palette, or one already on the sheet) so one of its + connection points comes within ~18 screen px of another symbol's, and a + ring marks the point it has caught while every connection point on the + sheet lights up. Let go and the symbol clicks into place — the two points + land on exactly the same spot — with the line already drawn between them. + Because lines store ports and not coordinates, pulling the pair apart + afterwards stretches the pipe instead of breaking it. + - The line class is chosen from the two port kinds, so a controller docking + onto a valve's signal boss gets `signal.electric` even with a process + class selected in the toolbar; incompatible pairings never dock. + - The symbol and the line it docked onto are ONE undo step, whether they + arrived from the palette (`addBatch`) or from a drag (`dockNode`). + - A pair that is already joined won't dock again, so nudging a docked + symbol can't stack a second line on top of the first. + ## [0.15.0] — 2026-09-01 — the engineering registry Second step of the engineering-platform plan diff --git a/README.md b/README.md index 9b795d6..63201cb 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,9 @@ P&ID tool is a $2,600+/year desktop install. IPD Studio is the missing thing: - **Works offline** — installable PWA; the whole editor runs with no internet - **Editor power** — Ctrl+F find-any-tag across sheets, align/distribute, live snap guides, print-all-sheets PDF, line sequence auto-numbering +- **Magnetic docking** — drag a symbol so its connection point touches + another symbol's and let go: it clicks into place already piped up. Pull + them apart and the line stretches to follow - **Obstacle-avoiding orthogonal routing** with draggable waypoints, ports with connection rules (a pneumatic signal won't connect to a pipe nozzle) - **Live validation**: duplicate tags, missing tags, illegal ISA letters, diff --git a/e2e/autoconnect.spec.ts b/e2e/autoconnect.spec.ts new file mode 100644 index 0000000..1c84d32 --- /dev/null +++ b/e2e/autoconnect.spec.ts @@ -0,0 +1,141 @@ +import { expect, test, type Page } from '@playwright/test' + +/* Magnetic docking — connect by touching, not by drawing: + - drop a palette symbol onto an existing symbol's connection point and it + clicks into place already connected + - drag a symbol already on the sheet the same way, in one undo step + - pull the pair apart afterwards and the pipe stretches instead of breaking */ + +interface PidHook { + useStore: { getState(): any; temporal: { getState(): any } } + canvasRef: { paper?: any } +} +declare global { + interface Window { __pid: PidHook } +} + +async function clientPoint(page: Page, x: number, y: number): Promise<{ x: number; y: number }> { + return page.evaluate(([lx, ly]) => { + const p = window.__pid.canvasRef.paper!.localToClientPoint({ x: lx, y: ly }) + return { x: p.x, y: p.y } + }, [x, y]) +} + +async function drag(page: Page, from: { x: number; y: number }, to: { x: number; y: number }, steps = 16) { + await page.mouse.move(from.x, from.y) + await page.mouse.down() + await page.mouse.move(to.x, to.y, { steps }) + await page.mouse.up() +} + +const sheet = (page: Page) => page.evaluate(() => window.__pid.useStore.getState().doc.sheets[0]) +const undoDepth = (page: Page) => + page.evaluate(() => window.__pid.useStore.temporal.getState().pastStates.length) + +/** Drop a palette symbol on the canvas at a sheet point, the way the browser + * does it: a real DragEvent carrying the palette's MIME payload. */ +async function dropSymbol(page: Page, symbolId: string, at: { x: number; y: number }) { + const client = await clientPoint(page, at.x, at.y) + await page.evaluate( + ([id, cx, cy]) => { + const dt = new DataTransfer() + dt.setData('application/x-pid-symbol', JSON.stringify({ symbolId: id })) + const host = document.querySelector('[data-testid="canvas"]')! + host.dispatchEvent( + new DragEvent('drop', { dataTransfer: dt, clientX: cx as number, clientY: cy as number, bubbles: true, cancelable: true }), + ) + }, + [symbolId, client.x, client.y] as [string, number, number], + ) +} + +/** A single gate valve at (200,200): 32x16, port w at (200,208), e at (232,208). */ +async function withGateValve(page: Page): Promise { + await page.goto('/app') + await page.waitForFunction(() => '__pid' in window) + await page.waitForFunction(() => Boolean(window.__pid)) + const id = await page.evaluate(() => { + const s = window.__pid.useStore.getState() + const id = s.addNode({ symbolId: 'valve.gate', kind: 'valve', x: 200, y: 200, rotation: 0 }) + s.setSelection([]) + return id as string + }) + await expect(page.locator('[model-id]')).toHaveCount(1) + return id +} + +test('dropping a palette symbol on a connection point docks and connects it', async ({ page }) => { + const gate = await withGateValve(page) + + // Aim so the dropped valve's own w port lands 8px off the fixed valve's e + // port: centred on (254,210) it is placed at (240,200), w at (240,208). + await dropSymbol(page, 'valve.gate', { x: 254, y: 210 }) + + await expect.poll(async () => (await sheet(page)).nodes.length).toBe(2) + const after = await sheet(page) + const dropped = after.nodes.find((n: any) => n.id !== gate) + + // pulled into place so the two connection points are the same point + expect({ x: dropped.x, y: dropped.y }).toEqual({ x: 232, y: 200 }) + expect(after.edges).toHaveLength(1) + expect(after.edges[0].source).toEqual({ nodeId: dropped.id, portId: 'w' }) + expect(after.edges[0].target).toEqual({ nodeId: gate, portId: 'e' }) + expect(after.edges[0].lineClass).toBe('process.major') + + // symbol + line arrived together, so one undo takes both back + await page.evaluate(() => window.__pid.useStore.getState().undo()) + const undone = await sheet(page) + expect(undone.nodes).toHaveLength(1) + expect(undone.edges).toHaveLength(0) +}) + +test('a symbol dropped clear of every connection point just lands there', async ({ page }) => { + await withGateValve(page) + await dropSymbol(page, 'valve.gate', { x: 500, y: 500 }) + await expect.poll(async () => (await sheet(page)).nodes.length).toBe(2) + expect((await sheet(page)).edges).toHaveLength(0) +}) + +test('dragging a placed symbol onto a connection point docks it, and pulling away stretches the line', async ({ page }) => { + const gate = await withGateValve(page) + const moving = await page.evaluate(() => { + const s = window.__pid.useStore.getState() + const id = s.addNode({ symbolId: 'valve.gate', kind: 'valve', x: 400, y: 300, rotation: 0 }) + s.setSelection([]) + return id as string + }) + await expect(page.locator('[model-id]')).toHaveCount(2) + const before = await undoDepth(page) + + // Grab the moving valve by its centre (416,308) and bring its w port to + // within a few px of the fixed valve's e port at (232,208). + await drag(page, await clientPoint(page, 416, 308), await clientPoint(page, 254, 208)) + + await expect.poll(async () => (await sheet(page)).edges.length).toBe(1) + const docked = await sheet(page) + const node = docked.nodes.find((n: any) => n.id === moving) + expect({ x: node.x, y: node.y }).toEqual({ x: 232, y: 200 }) + expect(docked.edges[0].source).toEqual({ nodeId: moving, portId: 'w' }) + expect(docked.edges[0].target).toEqual({ nodeId: gate, portId: 'e' }) + // the move and the line are one gesture, so they are one undo step + expect(await undoDepth(page)).toBe(before + 1) + + // docked: the ports coincide, so there is no pipe drawn between them yet + const edgeId = docked.edges[0].id + const length = () => + page.evaluate((id) => { + const paper = window.__pid.canvasRef.paper! + const view = paper.model.getCell(id).findView(paper) + return view.getConnectionLength() as number + }, edgeId) + await expect.poll(length).toBeLessThan(2) + + // pull it away and the pipe stretches to follow instead of breaking + await drag(page, await clientPoint(page, 248, 208), await clientPoint(page, 448, 208)) + const moved = await sheet(page) + expect(moved.edges).toHaveLength(1) + expect(moved.edges[0].source).toEqual({ nodeId: moving, portId: 'w' }) + expect(moved.edges[0].target).toEqual({ nodeId: gate, portId: 'e' }) + expect(moved.nodes.find((n: any) => n.id === moving).x).toBeGreaterThan(400) + await expect.poll(length).toBeGreaterThan(150) +}) diff --git a/package.json b/package.json index 02f21ad..819d8c6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ipd-studio", "private": true, - "version": "0.15.0", + "version": "0.16.0", "license": "PolyForm-Noncommercial-1.0.0", "type": "module", "scripts": { diff --git a/src/app.css b/src/app.css index 79d32eb..c16fb7d 100644 --- a/src/app.css +++ b/src/app.css @@ -258,6 +258,24 @@ button { font: inherit; } .pid-port-hit:hover { fill: rgba(43, 108, 176, 0.22); stroke: #2b6cb0; } /* While dragging a link, every legal target port shows a slim ring. */ .pid-port-hit.available-magnet { fill: rgba(43, 108, 176, 0.12); stroke: #2b6cb0; stroke-width: 1.25; r: 5px; } +/* Magnetic docking: dragging a symbol (from the palette or across the sheet) + raises every connection dot, and a ring marks the point it will click onto + and connect to when the drag is released. */ +.pid-docking .pid-port-dot { opacity: 1; } +.pid-dock-hint { + fill: rgba(43, 108, 176, 0.2); + stroke: #2b6cb0; + stroke-width: 1.75; + pointer-events: none; + animation: pid-dock-pulse 1.1s ease-in-out infinite; +} +@keyframes pid-dock-pulse { + 50% { fill: rgba(43, 108, 176, 0.38); r: 8.5px; } +} +/* The ring still marks the spot, it just stops pulsing. */ +@media (prefers-reduced-motion: reduce) { + .pid-dock-hint { animation: none; } +} /* --- quick line editor ---------------------------------------------------- */ .line-popover { diff --git a/src/canvas/autoConnect.ts b/src/canvas/autoConnect.ts new file mode 100644 index 0000000..cc851da --- /dev/null +++ b/src/canvas/autoConnect.ts @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 +// Copyright © 2026 Praharsh Nagpure — IPD Studio. Noncommercial use only; +// commercial use requires a paid license (see COMMERCIAL-LICENSE.md). + +/** + * Magnetic docking — connect by touching, not by drawing. + * + * Bring a symbol's connection point close to another symbol's connection + * point and let go: the symbol clicks into place so the two points coincide + * exactly, and the line between them is created for you. Because the line + * stores PORTS and not coordinates, pulling the symbols apart afterwards + * stretches the pipe instead of breaking it. + * + * Used from two places, one gesture each: + * - dropHandling.ts — a symbol dragged in from the palette + * - interactions.ts — a symbol already on the sheet, dragged by hand + */ + +import type { dia } from '@joint/core' +import type { LineClass, PlantEdge, PlantNode } from '../model/types' +import { isPortEnd } from '../model/types' +import type { PortKind } from '../symbols/types' +import { getSymbol } from '../symbols/registry' +import { portWorld } from './alignment' +import { compatibleKinds, pickLineClass } from './connectionRules' + +/** + * How near a port has to come before it docks, in SCREEN px — the distance is + * judged by what the user sees, so the reach feels the same zoomed in or out. + * Clamped in sheet space so an extreme zoom can't make it either unhittable + * or grabby enough to swallow neighbouring symbols. + */ +export const DOCK_SCREEN_PX = 18 +const MIN_SHEET_RADIUS = 6 +const MAX_SHEET_RADIUS = 48 + +export function dockRadius(scale: number): number { + const s = scale > 0 ? scale : 1 + return Math.min(MAX_SHEET_RADIUS, Math.max(MIN_SHEET_RADIUS, DOCK_SCREEN_PX / s)) +} + +export interface Dock { + /** Port on the symbol being moved. */ + movingPortId: string + targetNodeId: string + targetPortId: string + /** Where the moving symbol has to sit for the two ports to coincide. */ + x: number + y: number + /** Sheet point the ports meet at — where the hint ring is drawn. */ + at: { x: number; y: number } + lineClass: LineClass +} + +interface PortRef { + nodeId: string + portId: string + kind: PortKind + x: number + y: number +} + +/** Catalog ports plus any user-added pins. Unknown symbols have none. */ +function portsOf(node: PlantNode): { id: string; kind: PortKind }[] { + try { + return [...getSymbol(node.symbolId).ports, ...(node.extraPorts ?? [])].map((p) => ({ + id: p.id, + kind: p.kind, + })) + } catch { + return [] + } +} + +const endKey = (end: PlantEdge['source']): string => + isPortEnd(end) ? `${end.nodeId}/${end.portId}` : '' + +/** + * The best port pairing for `moving` at its current x/y, or null when nothing + * is in reach. `moving` may be a node that does not exist on the sheet yet + * (a palette drag in flight); `others` is simply scanned for a different id. + * + * Pairs that are already joined are skipped, so nudging a symbol that is + * docked doesn't stack a second identical line on top of the first. + */ +export function findDock( + moving: PlantNode, + others: PlantNode[], + edges: PlantEdge[], + activeLineClass: LineClass, + radius: number, +): Dock | null { + const mine = portsOf(moving) + if (!mine.length) return null + + const joined = new Set() + for (const e of edges) { + const a = endKey(e.source) + const b = endKey(e.target) + if (a && b) { + joined.add(`${a}|${b}`) + joined.add(`${b}|${a}`) + } + } + + // Resolve every candidate port once, not once per port of the moving symbol. + const targets: PortRef[] = [] + for (const other of others) { + if (other.id === moving.id) continue + for (const p of portsOf(other)) { + const at = portWorld(other, p.id) + if (at) targets.push({ nodeId: other.id, portId: p.id, kind: p.kind, x: at.x, y: at.y }) + } + } + + let best: Dock | null = null + let bestDistance = radius + for (const mp of mine) { + const from = portWorld(moving, mp.id) + if (!from) continue + for (const t of targets) { + const d = Math.hypot(t.x - from.x, t.y - from.y) + if (d > bestDistance) continue + if (!compatibleKinds(mp.kind, t.kind)) continue + if (joined.has(`${moving.id}/${mp.id}|${t.nodeId}/${t.portId}`)) continue + bestDistance = d + best = { + movingPortId: mp.id, + targetNodeId: t.nodeId, + targetPortId: t.portId, + x: Math.round(moving.x + t.x - from.x), + y: Math.round(moving.y + t.y - from.y), + at: { x: t.x, y: t.y }, + lineClass: pickLineClass(mp.kind, t.kind, activeLineClass), + } + } + } + return best +} + +/** The edge a dock creates, ready for addBatch/dockNode. */ +export function dockEdge(movingId: string, dock: Dock): Omit { + return { + lineClass: dock.lineClass, + source: { nodeId: movingId, portId: dock.movingPortId }, + target: { nodeId: dock.targetNodeId, portId: dock.targetPortId }, + } +} + +const HINT_CLASS = 'pid-dock-hint' +const NS = 'http://www.w3.org/2000/svg' + +/** + * Ring at the point the drag will dock onto — the visible promise that + * letting go connects. Lives inside `.joint-layers`, the group carrying the + * pan/zoom transform, so it sits on the sheet rather than on the viewport. + * Passing null takes it down. + */ +export function showDockHint(paper: dia.Paper, at: { x: number; y: number } | null): void { + const layer = paper.svg.querySelector('.joint-layers') + if (!layer) return + const existing = layer.querySelector(`.${HINT_CLASS}`) as SVGCircleElement | null + if (!at) { + existing?.remove() + return + } + const ring = existing ?? document.createElementNS(NS, 'circle') + ring.setAttribute('class', HINT_CLASS) + ring.setAttribute('r', '7') + ring.setAttribute('cx', String(at.x)) + ring.setAttribute('cy', String(at.y)) + ring.setAttribute('pointer-events', 'none') + if (!existing) layer.appendChild(ring) +} diff --git a/src/canvas/dropHandling.ts b/src/canvas/dropHandling.ts index 86e9e59..6546b06 100644 --- a/src/canvas/dropHandling.ts +++ b/src/canvas/dropHandling.ts @@ -3,14 +3,16 @@ // commercial use requires a paid license (see COMMERCIAL-LICENSE.md). import type { dia } from '@joint/core' -import { DRAG_MIME, type DragPayload } from '../panels/Palette' +import { ulid } from 'ulid' +import { DRAG_MIME, paletteDrag, type DragPayload } from '../panels/Palette' import { getSymbol } from '../symbols/registry' -import type { NodeKind } from '../model/types' +import type { NodeKind, PlantNode } from '../model/types' import type { SymbolDef } from '../symbols/types' import { nextLoopNumber } from '../isa/autonumber' import { buildTypical } from '../assist/typicals' -import { useStore } from '../store/store' +import { activeSheet, useStore } from '../store/store' import { canvasRef } from './paperSetup' +import { type Dock, dockEdge, dockRadius, findDock, showDockHint } from './autoConnect' export function kindForSymbol(def: Pick): NodeKind { switch (def.tagRule) { @@ -88,14 +90,83 @@ async function openDroppedFile(file: File): Promise { } } +/** Id for the not-yet-created symbol under the cursor during a palette drag. */ +const DRAG_ID = '__palette-drag__' + +/** The node a palette payload becomes, centred on a sheet point. */ +function placedNode(def: SymbolDef, local: { x: number; y: number }): PlantNode { + const w = def.gridSize.w * 8 + const h = def.gridSize.h * 8 + const node: PlantNode = { + id: DRAG_ID, + symbolId: def.id, + kind: kindForSymbol(def), + x: snap8(local.x - w / 2), + y: snap8(local.y - h / 2), + rotation: 0, + } + if (def.defaultConfig) node.config = { ...def.defaultConfig } + return node +} + +/** + * Where the dragged symbol would land and what it would dock onto — computed + * identically for the hover preview and for the drop itself, so the ring the + * user aims at is exactly the connection they get. + */ +function previewDrop( + payload: DragPayload, + paper: dia.Paper, + clientX: number, + clientY: number, +): { node: PlantNode; dock: Dock | null } | null { + let def: SymbolDef + try { + def = getSymbol(payload.symbolId) + } catch { + return null + } + const node = placedNode(def, paper.clientToLocalPoint({ x: clientX, y: clientY })) + const state = useStore.getState() + const sheet = activeSheet(state) + const dock = findDock( + node, + sheet.nodes, + sheet.edges, + state.activeLineClass, + dockRadius(paper.scale().sx), + ) + return { node, dock } +} + export function attachDropHandling(host: HTMLElement, paper: dia.Paper): () => void { + /** Take down the docking preview (ring + every symbol's connection dots). */ + const clearPreview = () => { + paper.el.classList.remove('pid-docking') + showDockHint(paper, null) + } + const onDragOver = (e: DragEvent) => { if (e.dataTransfer?.types.includes(DRAG_MIME) || e.dataTransfer?.types.includes('Files')) { e.preventDefault() e.dataTransfer.dropEffect = 'copy' } + const payload = paletteDrag.payload + if (!payload) return + // Connection dots come up across the sheet so the user can see what there + // is to aim at, and the ring says which one is currently caught. + paper.el.classList.add('pid-docking') + showDockHint(paper, previewDrop(payload, paper, e.clientX, e.clientY)?.dock?.at ?? null) + } + const onDragLeave = (e: DragEvent) => { + const to = e.relatedTarget + if (to instanceof Node && host.contains(to)) return + clearPreview() } + const onDragEnd = () => clearPreview() + const onDrop = (e: DragEvent) => { + clearPreview() const file = e.dataTransfer?.files?.[0] if (file && /\.(pnid|json|xml|dxf)$/i.test(file.name)) { e.preventDefault() @@ -106,29 +177,29 @@ export function attachDropHandling(host: HTMLElement, paper: dia.Paper): () => v if (!raw) return e.preventDefault() const payload = JSON.parse(raw) as DragPayload - const def = getSymbol(payload.symbolId) - const local = paper.clientToLocalPoint({ x: e.clientX, y: e.clientY }) + const preview = previewDrop(payload, paper, e.clientX, e.clientY) + if (!preview) return + const { dock } = preview const store = useStore.getState() - const w = def.gridSize.w * 8 - const h = def.gridSize.h * 8 - const node: Parameters[0] = { - symbolId: def.id, - kind: kindForSymbol(def), - x: snap8(local.x - w / 2), - y: snap8(local.y - h / 2), - rotation: 0, - } - if (def.defaultConfig) node.config = { ...def.defaultConfig } + const id = ulid() + const node: PlantNode = { ...preview.node, id, ...(dock ? { x: dock.x, y: dock.y } : {}) } if (payload.presetLetters) { node.tag = { letters: payload.presetLetters, loop: nextLoopNumber(store.doc, payload.presetLetters) } } - const id = store.addNode(node) - store.setSelection([id]) + // One batch either way: the symbol and the line it docked onto arrive + // together, and one undo takes both back. addBatch selects the new node. + store.addBatch([node], dock ? [{ ...dockEdge(id, dock), id: ulid() }] : []) } + host.addEventListener('dragover', onDragOver) + host.addEventListener('dragleave', onDragLeave) host.addEventListener('drop', onDrop) + window.addEventListener('dragend', onDragEnd) return () => { + clearPreview() host.removeEventListener('dragover', onDragOver) + host.removeEventListener('dragleave', onDragLeave) host.removeEventListener('drop', onDrop) + window.removeEventListener('dragend', onDragEnd) } } diff --git a/src/canvas/interactions.ts b/src/canvas/interactions.ts index abafe17..a67ccdf 100644 --- a/src/canvas/interactions.ts +++ b/src/canvas/interactions.ts @@ -10,6 +10,7 @@ import { isPortEnd } from '../model/types' import type { PortKind } from '../symbols/types' import { compatibleKinds, pickLineClass } from './connectionRules' import { alignNodes, distributeNodes, localPortPoint, portWorld, snapGuides } from './alignment' +import { dockEdge, dockRadius, findDock, showDockHint } from './autoConnect' import { cleanVertices } from './vertexClean' import { makeLink } from './shapes' import { getSymbol } from '../symbols/registry' @@ -260,6 +261,8 @@ export function attachInteractions(paper: dia.Paper, graph: dia.Graph): () => vo const onMagnetDown = () => paper.el.classList.add('pid-linking') const onGlobalPointerUp = () => { paper.el.classList.remove('pid-linking') + paper.el.classList.remove('pid-docking') + showDockHint(paper, null) // safety net: any grouped edit (typing, label drag) ends by now resumeHistory() } @@ -344,6 +347,23 @@ export function attachInteractions(paper: dia.Paper, graph: dia.Graph): () => vo layer.appendChild(line) guideEls.push(line) } + /** Grid/guide-resolved landing position for a node dragged to `p`. */ + const landing = (hit: ReturnType, p: { x: number; y: number }) => ({ + x: hit.x !== undefined ? Math.round(hit.x) : snap8(p.x), + y: hit.y !== undefined ? Math.round(hit.y) : snap8(p.y), + }) + /** What the dragged node would dock onto at its landing position. */ + const dockAt = (node: PlantNode, hit: ReturnType, p: { x: number; y: number }) => { + const sheet = activeSheet(store()) + return findDock( + { ...node, ...landing(hit, p) }, + sheet.nodes, + sheet.edges, + store().activeLineClass, + dockRadius(paper.scale().sx), + ) + } + const onElementPointerMove = (view: dia.ElementView) => { const id = String(view.model.id) const start = dragStart.get(id) @@ -370,6 +390,12 @@ export function attachInteractions(paper: dia.Paper, graph: dia.Graph): () => vo const hit = snapGuides({ ...node, x: p.x, y: p.y }, sheet.nodes, 4, sheet.edges) if (hit.guideX !== undefined) drawGuide(true, hit.guideX) if (hit.guideY !== undefined) drawGuide(false, hit.guideY) + // Magnetic docking preview. Connection dots come up across the sheet so + // the user can see what there is to touch, and the ring marks the point + // this symbol will click onto if they let go now. + paper.el.classList.add('pid-docking') + const dock = dragStart.size > 1 ? null : dockAt(node, hit, p) + showDockHint(paper, dock?.at ?? null) } const onElementPointerDownPos = (view: dia.ElementView) => { dragStart.clear() @@ -395,16 +421,28 @@ export function attachInteractions(paper: dia.Paper, graph: dia.Graph): () => vo } const onElementPointerUp = (view: dia.ElementView) => { clearGuides() + showDockHint(paper, null) + paper.el.classList.remove('pid-docking') const id = String(view.model.id) const start = dragStart.get(id) + const multi = dragStart.size > 1 dragStart.delete(id) if (!start) return const p = view.model.position() const sheet = activeSheet(store()) const node = sheet.nodes.find((n) => n.id === id) const hit = node ? snapGuides({ ...node, x: p.x, y: p.y }, sheet.nodes, 4, sheet.edges) : {} - const nx = hit.x !== undefined ? Math.round(hit.x) : snap8(p.x) - const ny = hit.y !== undefined ? Math.round(hit.y) : snap8(p.y) + const { x: nx, y: ny } = landing(hit, p) + // Touched a connection point on the way down: click onto it and draw the + // line. Checked before the "didn't move" exit so a symbol nudged back to + // where it started can still dock. + const dock = node && !multi ? dockAt(node, hit, p) : null + if (dock) { + store().dockNode(id, dock.x, dock.y, dockEdge(id, dock)) + dragStart.clear() + dragStartVerts.clear() + return + } if (nx === start.x && ny === start.y) return const dx = nx - start.x const dy = ny - start.y @@ -577,6 +615,7 @@ export function attachInteractions(paper: dia.Paper, graph: dia.Graph): () => vo return () => { clearGuides() + showDockHint(paper, null) unsubSelection() window.removeEventListener('keydown', onKeyDown) window.removeEventListener('pointerup', onGlobalPointerUp) diff --git a/src/canvas/shapes.ts b/src/canvas/shapes.ts index 982775d..c4a9782 100644 --- a/src/canvas/shapes.ts +++ b/src/canvas/shapes.ts @@ -275,13 +275,16 @@ function routerFor(edge: PlantEdge, nodes?: Map): Record pa.x && srcDir === 'right' && tgtDir === 'left') || diff --git a/src/panels/Palette.tsx b/src/panels/Palette.tsx index 0c7f9b2..d453c6f 100644 --- a/src/panels/Palette.tsx +++ b/src/panels/Palette.tsx @@ -19,6 +19,13 @@ export interface DragPayload { presetLetters?: string } +/** + * The palette drag in flight. `dragover` is not allowed to read dataTransfer + * — browsers only hand the payload over on drop — so the canvas reads WHAT is + * being dragged from here in order to preview where it would dock. + */ +export const paletteDrag: { payload: DragPayload | null } = { payload: null } + const CATEGORY_ORDER: [SymbolCategory, string][] = [ ['custom', 'Custom'], ['instruments', 'Instruments'], @@ -61,9 +68,13 @@ function Entry({ def, label, title, presetLetters }: { def: SymbolDef; label: st onDragStart={(e) => { const payload: DragPayload = { symbolId: def.id } if (presetLetters) payload.presetLetters = presetLetters + paletteDrag.payload = payload e.dataTransfer.setData(DRAG_MIME, JSON.stringify(payload)) e.dataTransfer.effectAllowed = 'copy' }} + onDragEnd={() => { + paletteDrag.payload = null + }} > {label} diff --git a/src/store/store.ts b/src/store/store.ts index 23c18ea..232bd6a 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -30,6 +30,10 @@ export interface StoreState { addNode(partial: Omit): string setNodePos(id: string, x: number, y: number): void moveNodes(ids: string[], dx: number, dy: number): void + /** Magnetic docking: drop a component onto another component's + * connection point. The move and the line it creates are ONE undo step + * because they are one gesture to the user. */ + dockNode(id: string, x: number, y: number, edge: Omit): void rotateNode(id: string): void setNodeScale(id: string, scale: number): void /** Per-axis stretch (longer horizontal vessel etc.). 1/1 clears all scaling. */ @@ -216,6 +220,14 @@ export const useStore = create()( })) }, + dockNode(id, x, y, edge) { + patchSheet((sh) => ({ + ...sh, + nodes: sh.nodes.map((n) => (n.id === id ? { ...n, x, y } : n)), + edges: [...sh.edges, { ...edge, id: ulid() }], + })) + }, + rotateNode(id) { patchSheet((sh) => ({ ...sh, diff --git a/tests/canvas/autoConnect.test.ts b/tests/canvas/autoConnect.test.ts new file mode 100644 index 0000000..abead3c --- /dev/null +++ b/tests/canvas/autoConnect.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest' +import '../../src/symbols/lib/index' +import { dockEdge, dockRadius, findDock } from '../../src/canvas/autoConnect' +import { portWorld } from '../../src/canvas/alignment' +import type { PlantEdge, PlantNode } from '../../src/model/types' + +let n = 0 +const mk = (symbolId: string, x: number, y: number, kind: PlantNode['kind'] = 'valve'): PlantNode => ({ + id: `n${n++}`, + symbolId, + kind, + x, + y, + rotation: 0, +}) + +// valve.gate is 32x16 with process ports w(0,8) and e(32,8). +// cv.globe is 64x48 with process w(0,36)/e(64,36) and signal sig(32,0). +// instr.bubble is 40x40 with n/e/s/w ports of kind 'both'. + +describe('findDock', () => { + it('docks the near port pair and returns the position that makes them coincide', () => { + const fixed = mk('valve.gate', 200, 200) // e port at (232, 208) + const moving = mk('valve.gate', 240, 200) // w port at (240, 208) — 8px away + const dock = findDock(moving, [fixed], [], 'process.major', 18) + expect(dock).not.toBeNull() + expect(dock!.movingPortId).toBe('w') + expect(dock!.targetNodeId).toBe(fixed.id) + expect(dock!.targetPortId).toBe('e') + expect(dock!.lineClass).toBe('process.major') + // the docked position puts the two connection points on the same spot + const docked = { ...moving, x: dock!.x, y: dock!.y } + expect(portWorld(docked, 'w')).toEqual(portWorld(fixed, 'e')) + expect(dock!.at).toEqual({ x: 232, y: 208 }) + }) + + it('finds nothing when no port is within reach', () => { + const fixed = mk('valve.gate', 200, 200) + const moving = mk('valve.gate', 400, 400) + expect(findDock(moving, [fixed], [], 'process.major', 18)).toBeNull() + }) + + it('prefers the closest of several candidate ports', () => { + const near = mk('valve.gate', 200, 200) // e at (232, 208) + const far = mk('valve.gate', 200, 216) // e at (232, 224) + const moving = mk('valve.gate', 236, 202) // w at (236, 210) + const dock = findDock(moving, [near, far], [], 'process.major', 24) + expect(dock!.targetNodeId).toBe(near.id) + }) + + it('skips a pair that is already connected, so a nudge cannot stack a second line', () => { + const fixed = mk('valve.gate', 200, 200) + const moving = mk('valve.gate', 240, 200) + const edge: PlantEdge = { + id: 'e1', + lineClass: 'process.major', + source: { nodeId: fixed.id, portId: 'e' }, + target: { nodeId: moving.id, portId: 'w' }, + } + expect(findDock(moving, [fixed], [edge], 'process.major', 18)).toBeNull() + }) + + it('never docks a signal-only port onto a process-only port', () => { + const cv = mk('cv.globe', 0, 0) // sig at (32, 0), process w/e at y=36 + const gate = mk('valve.gate', 24, -8) // w at (24, 0) — 8px from sig + expect(findDock(gate, [cv], [], 'process.major', 18)).toBeNull() + }) + + it('picks the signal family when docking onto a signal port, whatever the toolbar says', () => { + const cv = mk('cv.globe', 100, 100) // sig at (132, 100) + const bubble = mk('instr.bubble', 114, 64, 'instrument') // s at (134, 104) + const dock = findDock(bubble, [cv], [], 'process.major', 18) + expect(dock!.movingPortId).toBe('s') + expect(dock!.targetPortId).toBe('sig') + expect(dock!.lineClass).toBe('signal.electric') + expect({ x: dock!.x, y: dock!.y }).toEqual({ x: 112, y: 60 }) + }) + + it('honors rotation when locating the ports', () => { + const fixed = mk('valve.gate', 200, 200) // e at (232, 208) + // A quarter-turned gate valve: ports run vertically instead. + const moving: PlantNode = { ...mk('valve.gate', 0, 0), rotation: 90 } + const at = portWorld(moving, 'w')! + const shifted = { ...moving, x: moving.x + (232 - at.x) + 5, y: moving.y + (208 - at.y) + 5 } + const dock = findDock(shifted, [fixed], [], 'process.major', 18) + expect(dock!.movingPortId).toBe('w') + expect(portWorld({ ...shifted, x: dock!.x, y: dock!.y }, 'w')).toEqual({ x: 232, y: 208 }) + }) + + it('has nothing to dock for a symbol without ports', () => { + const fixed = mk('valve.gate', 200, 200) + const note = mk('ann.text', 200, 200, 'annotation') + expect(findDock(note, [fixed], [], 'process.major', 18)).toBeNull() + }) +}) + +describe('dockEdge', () => { + it('draws the line from the moved symbol to the one it landed on', () => { + const fixed = mk('valve.gate', 200, 200) + const moving = mk('valve.gate', 240, 200) + const dock = findDock(moving, [fixed], [], 'process.major', 18)! + expect(dockEdge(moving.id, dock)).toEqual({ + lineClass: 'process.major', + source: { nodeId: moving.id, portId: 'w' }, + target: { nodeId: fixed.id, portId: 'e' }, + }) + }) +}) + +describe('dockRadius', () => { + it('keeps the reach constant on screen, clamped at extreme zoom', () => { + expect(dockRadius(1)).toBe(18) + expect(dockRadius(2)).toBe(9) + expect(dockRadius(4)).toBe(6) // clamped: never smaller than 6 sheet px + expect(dockRadius(0.05)).toBe(48) // clamped: never grabbier than 48 + }) +})