diff --git a/README.md b/README.md index 84948c58..e1dea9c4 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1313 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1314 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index 17e49bf4..a3079fce 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -1,42 +1,22 @@ import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js'; import { textOf } from '../../core/model/Properties'; -import type { Command } from '../../core/commands/Command'; -import { oneStep } from '../../core/commands/CompoundCommand'; -import { AddNodeCommand, CollapseNodesCommand, ConnectCommand } from '../../core/commands/graphCommands'; +import { AddNodeCommand, CollapseNodesCommand } from '../../core/commands/graphCommands'; import { SetMachineStartCommand } from '../../core/commands/assetCommands'; -import { - AddAnnotationCommand, - type AnnotationBox, - annotationNoun, - normalizeBox, -} from '../../core/commands/annotationCommands'; +import { AddAnnotationCommand, type AnnotationBox } from '../../core/commands/annotationCommands'; import { type AnnotationKind, - ANNOTATION_KINDS, - annotationsOf, BEHAVIOR_PARAM, callProblem, - canConnect, - defAcceptsFrom, drawnDef, - dropTargetPort, edgesInto, edgesOutOf, - findAnnotation, - firstPortFor, ghostProblem, type Graph, type GraphNode, - type GraphLookup, - MIN_ANNOTATION_SIZE, type PortDef, type PortSide, - type PortType, - portTypeOf, resolveDef, RUN_BEHAVIOR, - STATE_IN, - STATE_OUT, startStateOf, } from '../../core/graph'; import type { GraphAsset } from '../../core/model/Asset'; @@ -45,7 +25,7 @@ import { particleLibrary } from '../../viewport/ParticleLibrary'; import { shaderLibrary } from '../../viewport/ShaderLibrary'; import { findAsset, findGraphAsset } from '../../core/model/Project'; import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem'; -import { beadsInFlight, launchBead } from './beads'; +import { BeadClock } from './beads'; import { activeDrag, type DragPayload, @@ -59,38 +39,23 @@ import { textStyle, toHex, truncate, - ui, Widget, zoomFactor, } from '../../ui'; import { annotationSelection, type EditorState, graphSelection, nodeSelection } from '../state'; -import { - type Box, - boundsOf, - boxesInBand, - boxesWithinBand, - dragOffset, - frameBoxes, - resizeSize, -} from '../framing'; -import { - annotationBoxes, - boxOf, - type BoxSeed, - boxSeeds, - placeTravellers, - recordTravellers, - travellers, - type Travellers, -} from './annotationDrag'; -import { ANNOTATION_TITLE_HEIGHT, AnnotationView, RESIZE_HANDLE } from './AnnotationView'; -import { arrowHead, bezier, EdgeLayer, loopPath } from './EdgeLayer'; -import { arrowShaft, type Point, selfLoop, transitionPreview } from './transitions'; +import { type Box, boundsOf, boxesInBand, boxesWithinBand, frameBoxes } from '../framing'; +import { annotationBoxes, boxOf } from './annotationDrag'; +import { AnnotationLayer } from './annotationLayer'; +import { ANNOTATION_TITLE_HEIGHT, type AnnotationView, RESIZE_HANDLE } from './AnnotationView'; +import { EdgeLayer } from './EdgeLayer'; +import { DRAG_THRESHOLD, GraphDrags } from './graphDrags'; +import type { GraphCanvasHost } from './graphHost'; import { openAnnotationMenu, openLinkMenu, openNodeMenu } from './graphMenus'; import { NodePalette, type PaletteAction } from './NodePalette'; import { type PaletteEntry, paletteEntries } from './paletteEntries'; import { NODE_WIDTH, NodeView, TITLE_HEIGHT } from './NodeView'; -import { drawGraphGrid, FIT_MARGIN, GRID_STEP, ZOOM_LIMITS, zoomedAbout } from './graphView'; +import { drawGraphGrid, FIT_MARGIN, ZOOM_LIMITS, zoomedAbout } from './graphView'; +import { GraphWiring } from './wiring'; /** One running instance's answer for a machine: which state it holds, and whose instance it is. */ export interface LiveMachineState { @@ -102,8 +67,6 @@ export interface LiveMachineState { /** Stand-in node height for a node with no view yet – real nodes vary with their port count. */ const NODE_HEIGHT_ESTIMATE = 80; -/** Pointer travel (screen pixels) before a press turns into a drag rather than a click. */ -const DRAG_THRESHOLD = 3; /** Live values change every frame; ten updates a second are still readable and cost far less text layout. */ const LIVE_VALUE_INTERVAL_MS = 100; /** A group or comment created out of nothing – large enough to drop a few nodes into. */ @@ -121,8 +84,7 @@ export class GraphCanvas extends Widget { private readonly grid = new Graphics(); private readonly clip = new Graphics(); private readonly canvas = new Container(); - /** Behind the edges: a group is a backdrop, and a comment must never cover a node. */ - private readonly annotationLayer = new Container(); + private readonly annotationLayer = new AnnotationLayer((view, e) => this.onAnnotationPointerDown(view, e)); private readonly edgeLayer = new EdgeLayer(); private readonly nodeLayer = new Container(); private readonly tempEdge = new Graphics(); @@ -130,26 +92,13 @@ export class GraphCanvas extends Widget { private readonly band = new Graphics(); private readonly hint = new Text({ text: '', style: textStyle('muted') }); private readonly views = new Map(); - private readonly annotations = new Map(); private assetId = ''; private pan = { x: 0, y: 0 }; private zoom = 1; private panning: { startX: number; startY: number; panX: number; panY: number } | null = null; - /** Nodes being dragged, with the boxes a band caught alongside them – they travel together. */ - private dragging: { along: Travellers; pointer: { x: number; y: number }; moved: boolean } | null = null; - /** - * A group or comment being moved or resized. `along` is fixed when the drag begins: which nodes a - * group carries is a question about where they were when it was grabbed, not a running one – - * otherwise a node would join the group halfway through the gesture that passes over it. - */ - private annotationDrag: { - grabbed: BoxSeed; - mode: 'move' | 'resize'; - along: Travellers; - pointer: { x: number; y: number }; - moved: boolean; - } | null = null; private snapping = false; + private readonly wiring: GraphWiring; + private readonly drags: GraphDrags; /** A rubber-band selection in progress, in graph coordinates. */ private marquee: { start: { x: number; y: number }; @@ -158,25 +107,10 @@ export class GraphCanvas extends Widget { } | null = null; /** A behavior dragged in from the asset panel, and why it may not land. */ private incoming: { assetId: string; at: { x: number; y: number }; rejection: string } | null = null; - private connecting: { from: PortSide; pointer: { x: number; y: number } } | null = null; - /** A finished drag whose palette is still open – drawn like the running one, so the connection - * being made stays visible while the node type is chosen. */ - private pendingDrop: { from: PortSide; pointer: { x: number; y: number } } | null = null; - /** A transition begun from a state's menu, waiting for the click that names its target. It takes - * the shape of a drag because it is one in everything but the held button – that way the running - * preview draws it without a second code path. `release` takes the Escape handler back off. */ - private armed: { from: PortSide; pointer: { x: number; y: number }; release: () => void } | null = null; - /** Where the running drag would land, recomputed on every move so the drop holds no surprise. */ - private dropTarget: PortSide | null = null; - /** Whether the last redraw actually stroked the dangling edge. */ - private tempEdgeShown = false; /** Fit the view once the editor has a real size (it may be hidden behind the Scene tab). */ private needsFit = true; private valuesShownAt = 0; - /** In game time, so a paused game holds its beads. */ - private readonly beadTimes = new Map(); - private readonly beadFired = new Map(); - private beadAsset = ''; + private readonly beads = new BeadClock(); /** Supplies live execution data while playing (null otherwise). */ traceProvider: (assetId: string) => Trace | null = () => null; /** Which state each running instance of this machine holds; empty when nothing runs. */ @@ -191,6 +125,26 @@ export class GraphCanvas extends Widget { private readonly noun = 'behavior', ) { super(); + const host: GraphCanvasHost = { + state: this.state, + assetId: () => this.assetId, + asset: () => this.asset(), + views: () => this.views, + topDownViews: () => this.topDownViews(), + nodeUnder: (global) => this.nodeUnder(global.x, global.y), + nodeBoxes: () => this.nodeBoxes(), + toGraph: (global) => { + const local = this.toLocal(global); + return this.screenToGraph(local.x, local.y); + }, + zoom: () => this.zoom, + snapping: () => this.snapping, + entries: () => this.entries(), + newNode: (entry, at) => this.newNode(entry, at), + redraw: () => this.redraw(), + }; + this.wiring = new GraphWiring(host); + this.drags = new GraphDrags(host); this.canvas.addChild(this.annotationLayer, this.edgeLayer, this.nodeLayer, this.tempEdge); this.addChild(this.bg, this.grid, this.canvas, this.band, this.hint, this.clip); this.canvas.mask = this.clip; @@ -229,7 +183,7 @@ export class GraphCanvas extends Widget { show(assetId: string): void { if (this.assetId === assetId) return; // Its source node is in the graph being left, so the click that finished it would land nowhere. - this.cancelTransition(); + this.wiring.cancel(); this.assetId = assetId; this.sync(); this.needsFit = true; @@ -272,10 +226,6 @@ export class GraphCanvas extends Widget { return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) }; } - private behaviors(): GraphLookup { - return graphLookup(this.state.project); - } - private asset(): GraphAsset | null { return (this.assetId && findGraphAsset(this.state.project, this.assetId)) || null; } @@ -286,14 +236,14 @@ export class GraphCanvas extends Widget { if (!asset) { // An asset that was deleted (or undone away) must not linger as a graph. this.assetId = ''; - this.cancelTransition(); + this.wiring.cancel(); for (const view of this.views.values()) view.destroy({ children: true }); this.views.clear(); - this.clearAnnotations(); + this.annotationLayer.clearViews(); this.redraw(); return; } - this.syncAnnotations(asset.graph); + this.annotationLayer.sync(asset.graph); const seen = new Set(); const lookup = graphLookup(this.state.project); // In a compiled graph a node's problem is the compiler's, not a call's. @@ -330,42 +280,12 @@ export class GraphCanvas extends Widget { this.redraw(); } - private clearAnnotations(): void { - for (const view of this.annotations.values()) view.destroy({ children: true }); - this.annotations.clear(); - } - - /** Same shape as `sync`: views are reused by id, and a box that is gone takes its view with it. */ - private syncAnnotations(graph: Graph): void { - const seen = new Set(); - for (const kind of ANNOTATION_KINDS) { - for (const annotation of annotationsOf(graph, kind)) { - seen.add(annotation.id); - let view = this.annotations.get(annotation.id); - if (!view || view.kind !== kind) { - view?.destroy({ children: true }); - view = new AnnotationView(annotation, kind); - view.on('pointerdown', (e: FederatedPointerEvent) => this.onAnnotationPointerDown(view!, e)); - this.annotations.set(annotation.id, view); - this.annotationLayer.addChild(view); - } - view.sync(annotation); - } - } - for (const [id, view] of this.annotations) { - if (!seen.has(id)) { - view.destroy({ children: true }); - this.annotations.delete(id); - } - } - } - private onAnnotationPointerDown(view: AnnotationView, e: FederatedPointerEvent): void { if (e.button === 1 || !this.assetId) return; e.stopPropagation(); // An armed transition eats the click, exactly as it does over a node. - if (this.armed && (e.button === 0 || e.button === 2)) { - this.finishTransition(e.global.x, e.global.y, false); + if (this.wiring.isArmed && (e.button === 0 || e.button === 2)) { + this.wiring.finish(e.global, false); return; } const id = view.annotation.id; @@ -377,61 +297,7 @@ export class GraphCanvas extends Widget { openAnnotationMenu(this.state, this.assetId, view.kind, id, e.global.x, e.global.y); return; } - if (e.button !== 0) return; - const graph = this.asset()?.graph; - if (!graph) return; - const local = view.toLocal(e.global); - const p = this.toLocal(e.global); - const grabbed: BoxSeed = { id, kind: view.kind, start: boxOf(view.annotation) }; - const resizing = view.handleAt(local.x, local.y); - // A resize carries nothing: membership is re-derived from where the box ends up, so a node the - // corner sweeps over simply belongs to it after. - const seeds = [grabbed, ...boxSeeds(graph, this.state.selectedAnnotationIds, id)]; - const along = resizing - ? { boxes: [grabbed], nodes: [] } - : travellers(graph, this.nodeBoxes(), seeds, this.state.selectedNodeIds); - this.annotationDrag = { - grabbed, - mode: resizing ? 'resize' : 'move', - along, - pointer: this.screenToGraph(p.x, p.y), - moved: false, - }; - } - - /** Moves or resizes the dragged box in the model, for the preview; committed on release. */ - private previewAnnotationDrag(g: { x: number; y: number }): void { - const drag = this.annotationDrag; - const graph = this.asset()?.graph; - if (!drag || !graph) return; - const dx = g.x - drag.pointer.x; - const dy = g.y - drag.pointer.y; - if (!drag.moved && Math.hypot(dx, dy) * this.zoom < DRAG_THRESHOLD) return; - drag.moved = true; - const step = this.snapping ? GRID_STEP : 0; - if (drag.mode === 'resize') { - const self = findAnnotation(graph, drag.grabbed.id)?.annotation; - if (!self) return; - const size = resizeSize(drag.grabbed.start, dx, dy, step, MIN_ANNOTATION_SIZE); - Object.assign(self, normalizeBox({ ...drag.grabbed.start, ...size })); - } else { - // One offset for all of it, the grabbed box's: what travels with a group keeps its distance. - const offset = dragOffset(drag.grabbed.start, dx, dy, step); - placeTravellers(graph, drag.along, () => offset); - } - this.state.preview(); - } - - /** Puts the previewed boxes back and records the whole gesture as one undo step. */ - private commitAnnotationDrag(): void { - const drag = this.annotationDrag; - this.annotationDrag = null; - const graph = this.asset()?.graph; - if (!drag?.moved || !graph || !this.assetId) return; - const noun = annotationNoun(drag.grabbed.kind); - const label = drag.mode === 'resize' ? `Resize ${noun}` : undefined; - const commands = recordTravellers(graph, this.assetId, drag.along, label); - this.state.execute(oneStep(`${drag.mode === 'resize' ? 'Resize' : 'Move'} ${noun}`, commands)); + if (e.button === 0) this.drags.grabBox(view, e.global); } /** Adds a group or comment and selects it, so its title can be typed straight away. */ @@ -565,8 +431,8 @@ export class GraphCanvas extends Widget { } if (e.target !== this) return; // Away from every state, so there is nothing to transition to: the click drops the attempt. - if (this.armed && (e.button === 0 || e.button === 2)) { - this.finishTransition(e.global.x, e.global.y, false); + if (this.wiring.isArmed && (e.button === 0 || e.button === 2)) { + this.wiring.finish(e.global, false); return; } // Shift-drag on empty space pans as well, as it does in the scene view. @@ -600,8 +466,8 @@ export class GraphCanvas extends Widget { e.stopPropagation(); // An armed transition eats the next click whole: on another state it lands, anywhere else it // is dropped. Selecting or dragging the node underneath would be a second meaning for one click. - if (this.armed && (e.button === 0 || e.button === 2)) { - this.finishTransition(e.global.x, e.global.y, e.button === 0); + if (this.wiring.isArmed && (e.button === 0 || e.button === 2)) { + this.wiring.finish(e.global, e.button === 0); return; } const local = view.toLocal(e.global); @@ -620,7 +486,7 @@ export class GraphCanvas extends Widget { const extras: MenuItem[] = [ ...(view.def.transitionEdges === true ? [ - { label: 'Make transition', onSelect: () => this.armTransition(id, graphPoint) }, + { label: 'Make transition', onSelect: () => this.wiring.arm(id, graphPoint) }, ...this.startItems(id), ] : []), @@ -638,13 +504,7 @@ export class GraphCanvas extends Widget { // node instead would make a press on the very edge of it move the whole node. if (hit?.port.ghost) return; if (hit) { - const p = this.toLocal(e.global); - this.connecting = { - from: { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput }, - pointer: this.screenToGraph(p.x, p.y), - }; - this.dropTarget = null; - this.redraw(); + this.wiring.startDrag({ nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput }, e.global); return; } // Like the hierarchy's chevron: it folds, and leaves the selection alone. @@ -660,16 +520,7 @@ export class GraphCanvas extends Widget { // Clicking a selected node keeps the selection so the whole group can be dragged. if (!this.state.selectedNodeIds.includes(view.node.id)) this.state.select(nodeSelection(this.assetId, [view.node.id])); - const graph = this.asset()?.graph; - if (!graph) return; - const p = this.toLocal(e.global); - const seeds = boxSeeds(graph, this.state.selectedAnnotationIds); - this.dragging = { - along: travellers(graph, this.nodeBoxes(), seeds, this.state.selectedNodeIds), - pointer: this.screenToGraph(p.x, p.y), - moved: false, - }; - this.nodeLayer.addChild(view); // raise + if (this.drags.grabNodes(e.global)) this.nodeLayer.addChild(view); // raise } private openCalledBehavior(node: GraphNode): boolean { @@ -700,30 +551,10 @@ export class GraphCanvas extends Widget { this.applyView(); return; } + if (this.drags.move(e.global) || this.wiring.move(e.global)) return; const local = this.toLocal(e.global); const g = this.screenToGraph(local.x, local.y); - if (this.annotationDrag) { - this.previewAnnotationDrag(g); - } else if (this.dragging) { - const dx = g.x - this.dragging.pointer.x; - const dy = g.y - this.dragging.pointer.y; - if (!this.dragging.moved && Math.hypot(dx, dy) * this.zoom < DRAG_THRESHOLD) return; - this.dragging.moved = true; - const graph = this.asset()?.graph; - if (!graph) return; - const step = this.snapping ? GRID_STEP : 0; - // Each thing snaps for itself, node or box, so what lands on the grid is what was grabbed. - placeTravellers(graph, this.dragging.along, (start) => dragOffset(start, dx, dy, step)); - this.state.preview(); - } else if (this.connecting) { - this.connecting.pointer = g; - this.dropTarget = this.resolveDrop(e.global.x, e.global.y); - this.redraw(); - } else if (this.armed) { - this.armed.pointer = g; - this.dropTarget = this.armedTarget(e.global.x, e.global.y); - this.redraw(); - } else if (this.marquee) { + if (this.marquee) { this.marquee.current = g; const d = Math.hypot(g.x - this.marquee.start.x, g.y - this.marquee.start.y) * this.zoom; if (!this.marquee.active && d < DRAG_THRESHOLD) return; @@ -777,8 +608,7 @@ export class GraphCanvas extends Widget { return; } this.panning = null; - if (this.annotationDrag) { - this.commitAnnotationDrag(); + if (this.drags.releaseBox()) { this.redraw(); return; } @@ -788,23 +618,8 @@ export class GraphCanvas extends Widget { if (marquee.active) this.selectInMarquee(marquee); else this.state.select({ kind: 'asset', assetId: this.assetId }); } - const drag = this.dragging; - this.dragging = null; - const graph = this.asset()?.graph; - if (drag?.moved && graph && this.assetId) { - // The boxes that came along belong in the same step: one gesture, one undo. - const moves = recordTravellers(graph, this.assetId, drag.along); - if (moves.length > 0) this.state.execute(oneStep('Move nodes', moves)); - } - const connecting = this.connecting; - const target = connecting ? (this.dropTarget ?? this.resolveDrop(e.global.x, e.global.y)) : null; - this.connecting = null; - this.dropTarget = null; - if (connecting && this.assetId) { - if (target) this.connect(connecting.from, target); - else if (!this.nodeUnder(e.global.x, e.global.y)) - this.openPaletteForDrop(connecting, e.global.x, e.global.y); - } + this.drags.releaseNodes(); + this.wiring.release(e.global); this.redraw(); } @@ -827,76 +642,6 @@ export class GraphCanvas extends Widget { this.state.select(graphSelection(this.assetId, ids, boxIds) ?? { kind: 'asset', assetId: this.assetId }); } - private connect(from: PortSide, to: PortSide): void { - if (this.assetId) this.state.execute(connectSides(this.assetId, from, to)); - } - - /** - * Dropped into empty space: offer the node types this edge could reach, then wire the new node - * up the same way a drop on its body would. - */ - private openPaletteForDrop( - drag: { from: PortSide; pointer: { x: number; y: number } }, - globalX: number, - globalY: number, - ): void { - const graph = this.asset()?.graph; - const from = drag.from; - const fromType = graph && portTypeOf(graph, from, this.behaviors()); - if (!graph || fromType === undefined) return; - const local = this.toLocal({ x: globalX, y: globalY }); - const at = this.screenToGraph(local.x, local.y); - this.pendingDrop = drag; - const palette = new NodePalette( - this.entries(), - (entry) => this.addConnectedNode(entry, from, fromType, at), - (def) => defAcceptsFrom(def, fromType, from.isOutput), - ); - palette.onClose = () => { - this.pendingDrop = null; - this.redraw(); - }; - palette.open(globalX, globalY); - } - - /** - * Where the running drag would connect: the port under the pointer, or – dropped on a node's - * body – the port that node picks for it. Null means the drop does nothing. - */ - private resolveDrop(globalX: number, globalY: number): PortSide | null { - const graph = this.asset()?.graph; - const from = this.connecting?.from; - if (!graph || !from) return null; - const lookup = this.behaviors(); - const hit = this.portUnder(globalX, globalY); - if (hit) return canConnect(graph, from, hit, lookup) ? hit : null; - const node = this.nodeUnder(globalX, globalY); - if (!node || this.views.get(node)?.collapsed) return null; - return dropTargetPort(graph, from, node, lookup); - } - - private portUnder(globalX: number, globalY: number): PortSide | null { - for (const view of this.topDownViews()) { - const local = view.toLocal({ x: globalX, y: globalY }); - const hit = view.portAt(local.x, local.y); - // A ghost is no target, and it must not shadow the node body underneath it either. - if (hit && !hit.port.ghost) return { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput }; - } - return null; - } - - /** Null unless the drag started on a state: only those draw as a straight arrow. */ - private transitionPreviewOf( - from: PortSide, - target: PortSide | null, - pointer: Point, - ): { start: Point; end: Point } | null { - const source = this.views.get(from.nodeId); - if (source?.def.transitionEdges !== true) return null; - const to = target ? this.views.get(target.nodeId) : undefined; - return transitionPreview(source, to?.def.transitionEdges === true ? to : null, pointer); - } - private nodeUnder(globalX: number, globalY: number): string | null { for (const view of this.topDownViews()) { const p = view.toLocal({ x: globalX, y: globalY }); @@ -957,79 +702,11 @@ export class GraphCanvas extends Widget { if (this.state.execute(command)) this.state.select(nodeSelection(this.assetId, [command.node.id])); } - /** Adding and wiring go in as one command, so a single undo takes the whole gesture back. */ - private addConnectedNode( - entry: PaletteEntry, - from: PortSide, - fromType: PortType, - at: { x: number; y: number }, - ): void { - if (!this.assetId) return; - const add = this.newNode(entry, at); - const port = firstPortFor(entry.def, fromType, from.isOutput); - const commands: Command[] = [add]; - if (port) - commands.push( - connectSides(this.assetId, from, { nodeId: add.node.id, port, isOutput: !from.isOutput }), - ); - if (this.state.execute(oneStep(add.label, commands))) - this.state.select(nodeSelection(this.assetId, [add.node.id])); - } - private openLinks(pick: (graph: Graph) => Graph['edges'], at: { x: number; y: number }): void { const graph = this.asset()?.graph; if (graph) openLinkMenu(this.state, this.assetId, pick(graph), at.x, at.y); } - /** Begin a transition out of this state; the next click on another one completes it. */ - private armTransition(nodeId: string, pointer: { x: number; y: number }): void { - this.cancelTransition(); - const handlers = ui().focus.globalHandlers; - const onKey = (event: KeyboardEvent): boolean => { - if (event.key !== 'Escape' || !this.armed) return false; - this.cancelTransition(); - this.redraw(); - return true; - }; - handlers.push(onKey); - this.armed = { - from: { nodeId, port: STATE_OUT, isOutput: true }, - pointer, - release: () => { - const at = handlers.indexOf(onKey); - if (at >= 0) handlers.splice(at, 1); - }, - }; - this.dropTarget = null; - this.redraw(); - } - - private cancelTransition(): void { - this.armed?.release(); - this.armed = null; - } - - /** Where an armed transition would land: the state under the pointer, its own source included. */ - private armedTarget(globalX: number, globalY: number): PortSide | null { - if (!this.armed) return null; - const node = this.nodeUnder(globalX, globalY); - if (!node) return null; - const view = this.views.get(node); - return view?.def.transitionEdges === true ? { nodeId: node, port: STATE_IN, isOutput: false } : null; - } - - /** Finish or abandon an armed transition. True when the click belonged to it. */ - private finishTransition(globalX: number, globalY: number, connect: boolean): boolean { - const armed = this.armed; - if (!armed) return false; - const target = connect ? this.armedTarget(globalX, globalY) : null; - this.cancelTransition(); - this.dropTarget = null; - if (target) this.connect(armed.from, target); - this.redraw(); - return true; - } - /** Shortened to the canvas: since the Particles tab put a preview beside it, a canvas can be narrow. */ private setHint(text: string): void { this.hint.text = truncate(text, this.hint.style, this.w - Theme.padding * 2); @@ -1053,7 +730,7 @@ export class GraphCanvas extends Widget { } const playing = this.state.mode === 'play'; this.setHint( - this.armed + this.wiring.isArmed ? 'Click the state to transition to, this one to re-enter it – anywhere else, or Escape, cancels' : asset.graph.nodes.length === 0 ? 'Right-click to add a node' @@ -1064,18 +741,15 @@ export class GraphCanvas extends Widget { const trace = playing ? this.traceProvider(asset.id) : null; const now = this.timeProvider(); - this.launchBeads(asset.id, trace); + this.beads.launch(asset.id, trace?.edges ?? null); const isHot = (map: Map | undefined, id: string) => { const t = map?.get(id); return t !== undefined && now - t <= TRACE_FADE; }; const selectedNodes = new Set(this.state.selectedNodeIds); const selectedAnnotations = new Set(this.state.selectedAnnotationIds); - for (const view of this.annotations.values()) { - view.selected = selectedAnnotations.has(view.annotation.id); - view.redraw(); - } - const target = this.dropTarget; + this.annotationLayer.redrawViews(selectedAnnotations); + const target = this.wiring.target; const wall = performance.now(); const values = trace && wall - this.valuesShownAt >= LIVE_VALUE_INTERVAL_MS ? trace.values : null; if (values) this.valuesShownAt = wall; @@ -1097,38 +771,10 @@ export class GraphCanvas extends Widget { this.views, (id) => isHot(trace?.edges, id), selectedNodes, - (id) => beadsInFlight(this.beadTimes.get(id) ?? [], now), + (id) => this.beads.inFlight(id, now), ); - this.tempEdge.clear(); - this.tempEdgeShown = false; - const drag = this.connecting ?? this.pendingDrop ?? this.armed; - const a = drag && this.anchorPoint(drag.from); - const home = drag && target?.nodeId === drag.from.nodeId ? this.views.get(target.nodeId) : undefined; - if (home?.def.transitionEdges === true) { - const ring = asset.graph.edges.filter( - (e) => e.from.node === home.node.id && e.to.node === home.node.id, - ).length; - const loop = selfLoop(home, ring); - loopPath(this.tempEdge, loop); - this.tempEdge.stroke({ width: 2, color: c.textMuted, alpha: 0.9 }); - arrowHead(this.tempEdge, loop.c2, loop.end, c.textMuted); - this.tempEdgeShown = true; - } else if (drag && a) { - // Snapping to the resolved port makes the prediction the ring shows unmistakable. - const b = this.anchorPoint(target) ?? drag.pointer; - // Dragged off a state, the line already looks like the transition it is about to become. - const preview = this.transitionPreviewOf(drag.from, target, drag.pointer); - const ends = preview ? [preview.start, preview.end] : [a, b]; - const [from, to] = (drag.from.isOutput ? ends : [ends[1], ends[0]]) as [Point, Point]; - if (preview) { - const shaft = arrowShaft(from, to); - this.tempEdge.moveTo(from.x, from.y).lineTo(shaft.x, shaft.y); - } else bezier(this.tempEdge, from.x, from.y, to.x, to.y); - this.tempEdge.stroke({ width: 2, color: c.textMuted, alpha: 0.9 }); - if (preview) arrowHead(this.tempEdge, from, to, c.textMuted); - this.tempEdgeShown = true; - } + this.wiring.draw(this.tempEdge, asset.graph); this.band.clear(); const m = this.marquee; @@ -1150,29 +796,6 @@ export class GraphCanvas extends Widget { } } - private launchBeads(assetId: string, trace: Trace | null): void { - if (!trace || assetId !== this.beadAsset) { - this.beadTimes.clear(); - this.beadFired.clear(); - this.beadAsset = assetId; - } - for (const [id, time] of trace?.edges ?? []) { - if (this.beadFired.get(id) === time) continue; - this.beadFired.set(id, time); - const times = this.beadTimes.get(id) ?? []; - this.beadTimes.set(id, times); - launchBead(times, time); - } - } - - /** A port's centre in graph coordinates. */ - private anchorPoint(side: PortSide | null): { x: number; y: number } | null { - if (!side) return null; - const view = this.views.get(side.nodeId); - const p = view?.portPosition(side.port, side.isOutput); - return view && p ? { x: view.x + p.x, y: view.y + p.y } : null; - } - private syncGrid(): void { drawGraphGrid(this.grid, { pan: this.pan, zoom: this.zoom }, this.w, this.h); } @@ -1186,12 +809,12 @@ export class GraphCanvas extends Widget { /** The port a running edge drag would connect to, or null when the drop would do nothing. */ pendingDropTarget(): PortSide | null { - return this.dropTarget; + return this.wiring.target; } /** Whether the dangling edge is on screen right now (running drag, or a drop awaiting its node). */ danglingEdgeVisible(): boolean { - return this.tempEdgeShown; + return this.wiring.edgeShown; } /** Whether the last redraw stroked this edge as a curve. */ @@ -1342,7 +965,7 @@ export class GraphCanvas extends Widget { w: number; h: number; }[] { - return [...this.annotations.values()].map((view) => ({ + return [...this.annotationLayer.views.values()].map((view) => ({ id: view.annotation.id, kind: view.kind, text: view.drawnText(), @@ -1355,7 +978,7 @@ export class GraphCanvas extends Widget { /** Where a group or comment is grabbed: its title bar, or – with `corner` – its resize handle. */ annotationGlobal(id: string, corner = false): { x: number; y: number } | null { - const view = this.annotations.get(id); + const view = this.annotationLayer.views.get(id); if (!view) return null; const local = corner ? { x: view.w - RESIZE_HANDLE / 2, y: view.h - RESIZE_HANDLE / 2 } @@ -1365,7 +988,7 @@ export class GraphCanvas extends Widget { /** The rectangle a box is drawn as, on screen – what a fit has to bring into view. */ annotationRect(id: string): Rect | null { - const view = this.annotations.get(id); + const view = this.annotationLayer.views.get(id); return view ? globalRect(view, view.w, view.h) : null; } @@ -1394,13 +1017,3 @@ function globalRect(target: Container, w: number, h: number): Rect { const bottomRight = target.toGlobal({ x: w, y: h }); return { x: topLeft.x, y: topLeft.y, w: bottomRight.x - topLeft.x, h: bottomRight.y - topLeft.y }; } - -/** A drag may start at either end; the command wants the output first. */ -function connectSides(assetId: string, a: PortSide, b: PortSide): ConnectCommand { - const [out, inp] = a.isOutput ? [a, b] : [b, a]; - return new ConnectCommand( - assetId, - { node: out.nodeId, port: out.port }, - { node: inp.nodeId, port: inp.port }, - ); -} diff --git a/src/editor/graph/annotationLayer.ts b/src/editor/graph/annotationLayer.ts new file mode 100644 index 00000000..9dbe5a9d --- /dev/null +++ b/src/editor/graph/annotationLayer.ts @@ -0,0 +1,54 @@ +import { Container, type FederatedPointerEvent } from 'pixi.js'; +import { ANNOTATION_KINDS, annotationsOf, type Graph } from '../../core/graph'; +import { AnnotationView } from './AnnotationView'; + +/** The groups and comments of a graph, behind its edges: a group is a backdrop, and a comment must never cover a node. */ +export class AnnotationLayer extends Container { + private readonly byId = new Map(); + + constructor(private readonly onPress: (view: AnnotationView, e: FederatedPointerEvent) => void) { + super(); + } + + get views(): ReadonlyMap { + return this.byId; + } + + clearViews(): void { + for (const view of this.byId.values()) view.destroy({ children: true }); + this.byId.clear(); + } + + /** Views are reused by id, and a box that is gone takes its view with it. */ + sync(graph: Graph): void { + const seen = new Set(); + for (const kind of ANNOTATION_KINDS) { + for (const annotation of annotationsOf(graph, kind)) { + seen.add(annotation.id); + let view = this.byId.get(annotation.id); + if (!view || view.kind !== kind) { + view?.destroy({ children: true }); + view = new AnnotationView(annotation, kind); + const pressed = view; + view.on('pointerdown', (e: FederatedPointerEvent) => this.onPress(pressed, e)); + this.byId.set(annotation.id, view); + this.addChild(view); + } + view.sync(annotation); + } + } + for (const [id, view] of this.byId) { + if (!seen.has(id)) { + view.destroy({ children: true }); + this.byId.delete(id); + } + } + } + + redrawViews(selected: ReadonlySet): void { + for (const view of this.byId.values()) { + view.selected = selected.has(view.annotation.id); + view.redraw(); + } + } +} diff --git a/src/editor/graph/beads.ts b/src/editor/graph/beads.ts index ee4145c7..a6e6f07c 100644 --- a/src/editor/graph/beads.ts +++ b/src/editor/graph/beads.ts @@ -17,6 +17,33 @@ export function beadsInFlight(times: readonly number[], now: number): number[] { return times.map((t) => (now - t) / BEAD_TRAVEL).filter((p) => p >= 0 && p <= 1); } +/** The beads of one graph's edges, in game time so a paused game holds them. */ +export class BeadClock { + private readonly times = new Map(); + private readonly fired = new Map(); + private assetId = ''; + + /** Launches a bead for every edge that fired since the last call; without a trace all are dropped. */ + launch(assetId: string, fired: ReadonlyMap | null): void { + if (!fired || assetId !== this.assetId) { + this.times.clear(); + this.fired.clear(); + this.assetId = assetId; + } + for (const [id, time] of fired ?? []) { + if (this.fired.get(id) === time) continue; + this.fired.set(id, time); + const times = this.times.get(id) ?? []; + this.times.set(id, times); + launchBead(times, time); + } + } + + inFlight(edgeId: string, now: number): number[] { + return beadsInFlight(this.times.get(edgeId) ?? [], now); + } +} + /** How far an edge's curve reaches out of its ports before it bends towards the other end. */ export function curveReach(x1: number, x2: number): number { return Math.max(40, Math.abs(x2 - x1) * 0.5); diff --git a/src/editor/graph/graphDrags.ts b/src/editor/graph/graphDrags.ts new file mode 100644 index 00000000..b3ca769d --- /dev/null +++ b/src/editor/graph/graphDrags.ts @@ -0,0 +1,149 @@ +import { annotationNoun, normalizeBox } from '../../core/commands/annotationCommands'; +import { oneStep } from '../../core/commands/CompoundCommand'; +import { findAnnotation, MIN_ANNOTATION_SIZE } from '../../core/graph'; +import { dragOffset, resizeSize } from '../framing'; +import { + boxOf, + type BoxSeed, + boxSeeds, + placeTravellers, + recordTravellers, + travellers, + type Travellers, +} from './annotationDrag'; +import type { AnnotationView } from './AnnotationView'; +import type { GraphCanvasHost } from './graphHost'; +import { GRID_STEP } from './graphView'; +import type { Point } from './transitions'; + +/** Pointer travel (screen pixels) before a press turns into a drag rather than a click. */ +export const DRAG_THRESHOLD = 3; + +/** Nodes, groups and comments dragged about the canvas, each gesture one undo step. */ +export class GraphDrags { + /** Nodes being dragged, with the boxes a band caught alongside them – they travel together. */ + private nodes: { along: Travellers; pointer: Point; moved: boolean } | null = null; + /** + * A group or comment being moved or resized. `along` is fixed when the drag begins: which nodes a + * group carries is a question about where they were when it was grabbed, not a running one – + * otherwise a node would join the group halfway through the gesture that passes over it. + */ + private box: { + grabbed: BoxSeed; + mode: 'move' | 'resize'; + along: Travellers; + pointer: Point; + moved: boolean; + } | null = null; + + constructor(private readonly host: GraphCanvasHost) {} + + /** A press on a node: the selection travels with it. False when there is no graph to move in. */ + grabNodes(global: Point): boolean { + const graph = this.host.asset()?.graph; + if (!graph) return false; + const state = this.host.state; + const seeds = boxSeeds(graph, state.selectedAnnotationIds); + this.nodes = { + along: travellers(graph, this.host.nodeBoxes(), seeds, state.selectedNodeIds), + pointer: this.host.toGraph(global), + moved: false, + }; + return true; + } + + /** A press on a group or comment: its handle resizes it, anywhere else moves it and what it holds. */ + grabBox(view: AnnotationView, global: Point): void { + const graph = this.host.asset()?.graph; + if (!graph) return; + const state = this.host.state; + const id = view.annotation.id; + const local = view.toLocal(global); + const grabbed: BoxSeed = { id, kind: view.kind, start: boxOf(view.annotation) }; + const resizing = view.handleAt(local.x, local.y); + // A resize carries nothing: membership is re-derived from where the box ends up, so a node the + // corner sweeps over simply belongs to it after. + const seeds = [grabbed, ...boxSeeds(graph, state.selectedAnnotationIds, id)]; + const along = resizing + ? { boxes: [grabbed], nodes: [] } + : travellers(graph, this.host.nodeBoxes(), seeds, state.selectedNodeIds); + this.box = { + grabbed, + mode: resizing ? 'resize' : 'move', + along, + pointer: this.host.toGraph(global), + moved: false, + }; + } + + /** Moves what is dragged in the model, for the preview; false when nothing is. */ + move(global: Point): boolean { + if (this.box) this.previewBox(this.host.toGraph(global)); + else if (this.nodes) this.previewNodes(this.host.toGraph(global)); + else return false; + return true; + } + + private previewNodes(g: Point): void { + const drag = this.nodes!; + const dx = g.x - drag.pointer.x; + const dy = g.y - drag.pointer.y; + if (!drag.moved && Math.hypot(dx, dy) * this.host.zoom() < DRAG_THRESHOLD) return; + drag.moved = true; + const graph = this.host.asset()?.graph; + if (!graph) return; + const step = this.host.snapping() ? GRID_STEP : 0; + // Each thing snaps for itself, node or box, so what lands on the grid is what was grabbed. + placeTravellers(graph, drag.along, (start) => dragOffset(start, dx, dy, step)); + this.host.state.preview(); + } + + /** Moves or resizes the dragged box in the model, for the preview; committed on release. */ + private previewBox(g: Point): void { + const drag = this.box!; + const graph = this.host.asset()?.graph; + if (!graph) return; + const dx = g.x - drag.pointer.x; + const dy = g.y - drag.pointer.y; + if (!drag.moved && Math.hypot(dx, dy) * this.host.zoom() < DRAG_THRESHOLD) return; + drag.moved = true; + const step = this.host.snapping() ? GRID_STEP : 0; + if (drag.mode === 'resize') { + const self = findAnnotation(graph, drag.grabbed.id)?.annotation; + if (!self) return; + const size = resizeSize(drag.grabbed.start, dx, dy, step, MIN_ANNOTATION_SIZE); + Object.assign(self, normalizeBox({ ...drag.grabbed.start, ...size })); + } else { + // One offset for all of it, the grabbed box's: what travels with a group keeps its distance. + const offset = dragOffset(drag.grabbed.start, dx, dy, step); + placeTravellers(graph, drag.along, () => offset); + } + this.host.state.preview(); + } + + /** Puts the previewed boxes back and records the whole gesture as one undo step. False when no box was dragged. */ + releaseBox(): boolean { + const drag = this.box; + if (!drag) return false; + this.box = null; + const graph = this.host.asset()?.graph; + const assetId = this.host.assetId(); + if (!drag.moved || !graph || !assetId) return true; + const noun = annotationNoun(drag.grabbed.kind); + const label = drag.mode === 'resize' ? `Resize ${noun}` : undefined; + const commands = recordTravellers(graph, assetId, drag.along, label); + this.host.state.execute(oneStep(`${drag.mode === 'resize' ? 'Resize' : 'Move'} ${noun}`, commands)); + return true; + } + + /** The boxes that came along belong in the same step as the nodes: one gesture, one undo. */ + releaseNodes(): void { + const drag = this.nodes; + this.nodes = null; + const graph = this.host.asset()?.graph; + const assetId = this.host.assetId(); + if (!drag?.moved || !graph || !assetId) return; + const moves = recordTravellers(graph, assetId, drag.along); + if (moves.length > 0) this.host.state.execute(oneStep('Move nodes', moves)); + } +} diff --git a/src/editor/graph/graphHost.ts b/src/editor/graph/graphHost.ts new file mode 100644 index 00000000..9f982302 --- /dev/null +++ b/src/editor/graph/graphHost.ts @@ -0,0 +1,31 @@ +import type { AddNodeCommand } from '../../core/commands/graphCommands'; +import type { GraphAsset } from '../../core/model/Asset'; +import type { Box } from '../framing'; +import type { EditorState } from '../state'; +import type { NodeView } from './NodeView'; +import type { PaletteEntry } from './paletteEntries'; +import type { Point } from './transitions'; + +/** What the graph canvas lends the gestures that work inside it. */ +export interface GraphCanvasHost { + readonly state: EditorState; + /** The asset on the canvas; empty when there is none. */ + assetId(): string; + asset(): GraphAsset | null; + views(): ReadonlyMap; + /** Views in stacking order, topmost first – a node drawn over another one wins the pointer. */ + topDownViews(): Iterable; + nodeUnder(global: Point): string | null; + /** Each node as the rectangle it is actually drawn as. */ + nodeBoxes(): Box[]; + /** The graph point under a global pointer position. */ + toGraph(global: Point): Point; + /** Screen pixels per graph unit. */ + zoom(): number; + snapping(): boolean; + /** What the palette offers for this graph. */ + entries(): PaletteEntry[]; + /** The command that adds a node from the palette at a graph point. */ + newNode(entry: PaletteEntry, at: Point): AddNodeCommand; + redraw(): void; +} diff --git a/src/editor/graph/wiring.ts b/src/editor/graph/wiring.ts new file mode 100644 index 00000000..90edd7f1 --- /dev/null +++ b/src/editor/graph/wiring.ts @@ -0,0 +1,275 @@ +import type { Graphics } from 'pixi.js'; +import type { Command } from '../../core/commands/Command'; +import { oneStep } from '../../core/commands/CompoundCommand'; +import { ConnectCommand } from '../../core/commands/graphCommands'; +import { + canConnect, + defAcceptsFrom, + dropTargetPort, + firstPortFor, + type Graph, + type PortSide, + type PortType, + portTypeOf, + STATE_IN, + STATE_OUT, +} from '../../core/graph'; +import { graphLookup } from '../../core/model/graphLookup'; +import { Theme, ui } from '../../ui'; +import { nodeSelection } from '../state'; +import { arrowHead, bezier, loopPath } from './EdgeLayer'; +import type { GraphCanvasHost } from './graphHost'; +import { NodePalette } from './NodePalette'; +import type { PaletteEntry } from './paletteEntries'; +import { arrowShaft, type Point, selfLoop, transitionPreview } from './transitions'; + +/** + * Making edges: a drag from a pin, the palette a drag into empty space opens, and a transition + * armed from a state's menu that waits for the click naming its target. + */ +export class GraphWiring { + private connecting: { from: PortSide; pointer: Point } | null = null; + /** A finished drag whose palette is still open – drawn like the running one, so the connection + * being made stays visible while the node type is chosen. */ + private pendingDrop: { from: PortSide; pointer: Point } | null = null; + /** A transition begun from a state's menu, waiting for the click that names its target. It takes + * the shape of a drag because it is one in everything but the held button – that way the running + * preview draws it without a second code path. `release` takes the Escape handler back off. */ + private armed: { from: PortSide; pointer: Point; release: () => void } | null = null; + /** Where the running drag would land, recomputed on every move so the drop holds no surprise. */ + private dropTarget: PortSide | null = null; + /** Whether the last redraw actually stroked the dangling edge. */ + private tempEdgeShown = false; + + constructor(private readonly host: GraphCanvasHost) {} + + /** A transition is waiting for its target. */ + get isArmed(): boolean { + return this.armed !== null; + } + + /** The port a running edge drag would connect to, or null when the drop would do nothing. */ + get target(): PortSide | null { + return this.dropTarget; + } + + /** Whether the dangling edge is on screen right now (running drag, or a drop awaiting its node). */ + get edgeShown(): boolean { + return this.tempEdgeShown; + } + + /** A press on a pin: the edge follows the pointer until the button comes up. */ + startDrag(from: PortSide, global: Point): void { + this.connecting = { from, pointer: this.host.toGraph(global) }; + this.dropTarget = null; + this.host.redraw(); + } + + /** Follows the pointer with a drag or an armed transition; false when there is neither. */ + move(global: Point): boolean { + const g = this.host.toGraph(global); + if (this.connecting) { + this.connecting.pointer = g; + this.dropTarget = this.resolveDrop(global); + } else if (this.armed) { + this.armed.pointer = g; + this.dropTarget = this.armedTarget(global); + } else return false; + this.host.redraw(); + return true; + } + + /** Ends a drag: onto a port it connects, into empty space it offers the palette. */ + release(global: Point): void { + const connecting = this.connecting; + const target = connecting ? (this.dropTarget ?? this.resolveDrop(global)) : null; + this.connecting = null; + this.dropTarget = null; + if (!connecting || !this.host.assetId()) return; + if (target) this.connect(connecting.from, target); + else if (!this.host.nodeUnder(global)) this.openPaletteForDrop(connecting, global); + } + + private connect(from: PortSide, to: PortSide): void { + const assetId = this.host.assetId(); + if (assetId) this.host.state.execute(connectSides(assetId, from, to)); + } + + /** + * Dropped into empty space: offer the node types this edge could reach, then wire the new node + * up the same way a drop on its body would. + */ + private openPaletteForDrop(drag: { from: PortSide; pointer: Point }, global: Point): void { + const graph = this.host.asset()?.graph; + const from = drag.from; + const fromType = graph && portTypeOf(graph, from, graphLookup(this.host.state.project)); + if (!graph || fromType === undefined) return; + const at = this.host.toGraph(global); + this.pendingDrop = drag; + const palette = new NodePalette( + this.host.entries(), + (entry) => this.addConnectedNode(entry, from, fromType, at), + (def) => defAcceptsFrom(def, fromType, from.isOutput), + ); + palette.onClose = () => { + this.pendingDrop = null; + this.host.redraw(); + }; + palette.open(global.x, global.y); + } + + /** Adding and wiring go in as one command, so a single undo takes the whole gesture back. */ + private addConnectedNode(entry: PaletteEntry, from: PortSide, fromType: PortType, at: Point): void { + const assetId = this.host.assetId(); + if (!assetId) return; + const add = this.host.newNode(entry, at); + const port = firstPortFor(entry.def, fromType, from.isOutput); + const commands: Command[] = [add]; + if (port) + commands.push(connectSides(assetId, from, { nodeId: add.node.id, port, isOutput: !from.isOutput })); + if (this.host.state.execute(oneStep(add.label, commands))) + this.host.state.select(nodeSelection(assetId, [add.node.id])); + } + + /** + * Where the running drag would connect: the port under the pointer, or – dropped on a node's + * body – the port that node picks for it. Null means the drop does nothing. + */ + private resolveDrop(global: Point): PortSide | null { + const graph = this.host.asset()?.graph; + const from = this.connecting?.from; + if (!graph || !from) return null; + const lookup = graphLookup(this.host.state.project); + const hit = this.portUnder(global); + if (hit) return canConnect(graph, from, hit, lookup) ? hit : null; + const node = this.host.nodeUnder(global); + if (!node || this.host.views().get(node)?.collapsed) return null; + return dropTargetPort(graph, from, node, lookup); + } + + private portUnder(global: Point): PortSide | null { + for (const view of this.host.topDownViews()) { + const local = view.toLocal(global); + const hit = view.portAt(local.x, local.y); + // A ghost is no target, and it must not shadow the node body underneath it either. + if (hit && !hit.port.ghost) return { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput }; + } + return null; + } + + /** Begin a transition out of this state; the next click on another one completes it. */ + arm(nodeId: string, pointer: Point): void { + this.cancel(); + const handlers = ui().focus.globalHandlers; + const onKey = (event: KeyboardEvent): boolean => { + if (event.key !== 'Escape' || !this.armed) return false; + this.cancel(); + this.host.redraw(); + return true; + }; + handlers.push(onKey); + this.armed = { + from: { nodeId, port: STATE_OUT, isOutput: true }, + pointer, + release: () => { + const at = handlers.indexOf(onKey); + if (at >= 0) handlers.splice(at, 1); + }, + }; + this.dropTarget = null; + this.host.redraw(); + } + + cancel(): void { + this.armed?.release(); + this.armed = null; + } + + /** Where an armed transition would land: the state under the pointer, its own source included. */ + private armedTarget(global: Point): PortSide | null { + if (!this.armed) return null; + const node = this.host.nodeUnder(global); + if (!node) return null; + const view = this.host.views().get(node); + return view?.def.transitionEdges === true ? { nodeId: node, port: STATE_IN, isOutput: false } : null; + } + + /** Finish or abandon an armed transition. True when the click belonged to it. */ + finish(global: Point, connect: boolean): boolean { + const armed = this.armed; + if (!armed) return false; + const target = connect ? this.armedTarget(global) : null; + this.cancel(); + this.dropTarget = null; + if (target) this.connect(armed.from, target); + this.host.redraw(); + return true; + } + + /** The edge being made, from its pin to the pointer – or to the port it would land on. */ + draw(g: Graphics, graph: Graph): void { + const c = Theme.colors; + const views = this.host.views(); + const target = this.dropTarget; + g.clear(); + this.tempEdgeShown = false; + const drag = this.connecting ?? this.pendingDrop ?? this.armed; + const a = drag && this.anchorPoint(drag.from); + const home = drag && target?.nodeId === drag.from.nodeId ? views.get(target.nodeId) : undefined; + if (home?.def.transitionEdges === true) { + const ring = graph.edges.filter( + (e) => e.from.node === home.node.id && e.to.node === home.node.id, + ).length; + const loop = selfLoop(home, ring); + loopPath(g, loop); + g.stroke({ width: 2, color: c.textMuted, alpha: 0.9 }); + arrowHead(g, loop.c2, loop.end, c.textMuted); + this.tempEdgeShown = true; + } else if (drag && a) { + // Snapping to the resolved port makes the prediction the ring shows unmistakable. + const b = this.anchorPoint(target) ?? drag.pointer; + // Dragged off a state, the line already looks like the transition it is about to become. + const preview = this.transitionPreviewOf(drag.from, target, drag.pointer); + const ends = preview ? [preview.start, preview.end] : [a, b]; + const [from, to] = (drag.from.isOutput ? ends : [ends[1], ends[0]]) as [Point, Point]; + if (preview) { + const shaft = arrowShaft(from, to); + g.moveTo(from.x, from.y).lineTo(shaft.x, shaft.y); + } else bezier(g, from.x, from.y, to.x, to.y); + g.stroke({ width: 2, color: c.textMuted, alpha: 0.9 }); + if (preview) arrowHead(g, from, to, c.textMuted); + this.tempEdgeShown = true; + } + } + + /** Null unless the drag started on a state: only those draw as a straight arrow. */ + private transitionPreviewOf( + from: PortSide, + target: PortSide | null, + pointer: Point, + ): { start: Point; end: Point } | null { + const views = this.host.views(); + const source = views.get(from.nodeId); + if (source?.def.transitionEdges !== true) return null; + const to = target ? views.get(target.nodeId) : undefined; + return transitionPreview(source, to?.def.transitionEdges === true ? to : null, pointer); + } + + /** A port's centre in graph coordinates. */ + private anchorPoint(side: PortSide | null): Point | null { + if (!side) return null; + const view = this.host.views().get(side.nodeId); + const p = view?.portPosition(side.port, side.isOutput); + return view && p ? { x: view.x + p.x, y: view.y + p.y } : null; + } +} + +/** A drag may start at either end; the command wants the output first. */ +function connectSides(assetId: string, a: PortSide, b: PortSide): ConnectCommand { + const [out, inp] = a.isOutput ? [a, b] : [b, a]; + return new ConnectCommand( + assetId, + { node: out.nodeId, port: out.port }, + { node: inp.nodeId, port: inp.port }, + ); +} diff --git a/tests/editor/beads.test.ts b/tests/editor/beads.test.ts index 85c09137..2d7a3716 100644 --- a/tests/editor/beads.test.ts +++ b/tests/editor/beads.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { BEAD_SPACING, BEAD_TRAVEL, + BeadClock, beadsInFlight, curvePoint, launchBead, @@ -41,6 +42,27 @@ describe('beads on a firing edge', () => { }); }); +describe('the beads of one graph', () => { + it('launches once per fire, even when the same trace is read on every frame', () => { + const clock = new BeadClock(); + const fired = new Map([['e1', 1]]); + clock.launch('g', fired); + clock.launch('g', fired); + expect(clock.inFlight('e1', 1)).toEqual([0]); + expect(clock.inFlight('e2', 1)).toEqual([]); + }); + + it('drops every bead when the trace goes away or another graph is shown', () => { + const clock = new BeadClock(); + clock.launch('g', new Map([['e1', 1]])); + clock.launch('g', null); + expect(clock.inFlight('e1', 1)).toEqual([]); + clock.launch('g', new Map([['e1', 2]])); + clock.launch('other', new Map()); + expect(clock.inFlight('e1', 2)).toEqual([]); + }); +}); + describe('the curve a bead follows', () => { it('starts in the source port and ends in the target port', () => { expect(curvePoint(10, 20, 300, 80, 0)).toEqual({ x: 10, y: 20 }); diff --git a/tools/e2e/36-graph-groups.mjs b/tools/e2e/36-graph-groups.mjs index ed2f317e..e8da0c20 100644 --- a/tools/e2e/36-graph-groups.mjs +++ b/tools/e2e/36-graph-groups.mjs @@ -270,6 +270,12 @@ export async function graphGroups(t) { caught.nodeIds.includes('gg-start'), JSON.stringify(caught), ); + const drawnCaught = (await boxes()).find((box) => box.id === away.id); + check( + 'the comment the band caught is drawn as selected', + drawnCaught?.selected === true, + JSON.stringify(drawnCaught), + ); // 36l. But a band that merely crosses a box leaves it alone – the rule that keeps a group, which // wraps the nodes it holds, from joining every band drawn over any of them. It starts to the