From be431e9f519c608e249c5229d1f956e9c66320e9 Mon Sep 17 00:00:00 2001 From: PraharshNagpure Date: Tue, 1 Sep 2026 23:37:04 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20v0.16.0=20=E2=80=94=20magnetic=20dockin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 about 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 landing on exactly the same spot, with the line already drawn between them. Because a line stores PORTS and not coordinates, pulling the pair apart afterwards stretches the pipe instead of breaking it. Details that make it behave - The line class comes from the two port kinds, not the toolbar: a controller docking onto a valve's signal boss gets signal.electric even with a process class selected. Incompatible pairings never dock at all. - The reach is judged in SCREEN px so it feels the same at any zoom, clamped in sheet space so an extreme zoom can neither make it unhittable nor grabby enough to swallow neighbouring symbols. - A pair that is already joined will not dock again, so nudging a docked symbol cannot stack a second identical line on the first. - The symbol and the line it docked onto are ONE undo step, whether they came from the palette (addBatch) or from a drag across the sheet (dockNode). - The hint ring lives inside .joint-layers, the group carrying the pan/zoom transform, so it sits on the sheet rather than floating over the viewport. - The palette exports the drag in flight, because dragover is not allowed to read dataTransfer — browsers only hand the payload over on drop — and the canvas needs to know WHAT is being dragged to preview where it would dock. Also honours prefers-reduced-motion: the ring still marks the spot, it just stops pulsing. Tests: 10 unit cases over findDock/dockEdge/dockRadius covering rotation, zoom clamping, closest-candidate selection, incompatible ports and the already-joined guard; 3 e2e covering a palette drop that docks, a drop clear of everything that simply lands, and a drag that docks then stretches when pulled away. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 20 ++++ README.md | 3 + e2e/autoconnect.spec.ts | 141 +++++++++++++++++++++++++ package.json | 2 +- src/app.css | 18 ++++ src/canvas/autoConnect.ts | 174 +++++++++++++++++++++++++++++++ src/canvas/dropHandling.ts | 105 ++++++++++++++++--- src/canvas/interactions.ts | 43 +++++++- src/canvas/shapes.ts | 7 +- src/panels/Palette.tsx | 11 ++ src/store/store.ts | 12 +++ tests/canvas/autoConnect.test.ts | 117 +++++++++++++++++++++ 12 files changed, 631 insertions(+), 22 deletions(-) create mode 100644 e2e/autoconnect.spec.ts create mode 100644 src/canvas/autoConnect.ts create mode 100644 tests/canvas/autoConnect.test.ts 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 + }) +})