Split wiring, drags and the frame layer out of GraphCanvas (#215)
GraphCanvas had grown to 1406 lines. Wiring, dragging and the frame
views each carry their own state, so they now live in their own
classes behind a narrow host interface. Behaviour is unchanged.
- GraphCanvasHost is what the new classes see of the canvas.
- Wiring owns dragging a pin, the palette on a drop into empty space
and "Make transition".
- GraphDrags owns moving nodes and frames, including snapping.
- AnnotationLayer holds the frame views. The test hooks stay on the
canvas and read the views through the layer.
- BeadClock moves into beads.ts and can now be tested without a
browser.
- The canvas builds host, wiring and drags in its constructor because
state is a parameter property and not set during field init.
- GraphCanvas is down to 1019 lines.
Tests:
- ./check.sh: 145 files, 2192 tests, CHECK EXIT 0 (+2 for BeadClock)
- e2e 1-28,31,36: ALL PASSED, 932 checks
- New e2e check in 36k: a frame caught by the rubber band is also
drawn as selected. No check covered that before. Against a bent
build it fails with selected: false.
- Counter-checks for drop target, snapping and beads fail exactly
the expected checks.
- Editor check sum is now 1314 (CI measured 1313 at 487b3aa).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e31db3df1d
commit
b830f9c747
9 files changed
+631
-454
No files matched your search
@@ -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:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```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
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
+66
-453
@@ -1,42 +1,22 @@
|
|||||||
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
|
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
|
||||||
import { textOf } from '../../core/model/Properties';
|
import { textOf } from '../../core/model/Properties';
|
||||||
import type { Command } from '../../core/commands/Command';
|
import { AddNodeCommand, CollapseNodesCommand } from '../../core/commands/graphCommands';
|
||||||
import { oneStep } from '../../core/commands/CompoundCommand';
|
|
||||||
import { AddNodeCommand, CollapseNodesCommand, ConnectCommand } from '../../core/commands/graphCommands';
|
|
||||||
import { SetMachineStartCommand } from '../../core/commands/assetCommands';
|
import { SetMachineStartCommand } from '../../core/commands/assetCommands';
|
||||||
import {
|
import { AddAnnotationCommand, type AnnotationBox } from '../../core/commands/annotationCommands';
|
||||||
AddAnnotationCommand,
|
|
||||||
type AnnotationBox,
|
|
||||||
annotationNoun,
|
|
||||||
normalizeBox,
|
|
||||||
} from '../../core/commands/annotationCommands';
|
|
||||||
import {
|
import {
|
||||||
type AnnotationKind,
|
type AnnotationKind,
|
||||||
ANNOTATION_KINDS,
|
|
||||||
annotationsOf,
|
|
||||||
BEHAVIOR_PARAM,
|
BEHAVIOR_PARAM,
|
||||||
callProblem,
|
callProblem,
|
||||||
canConnect,
|
|
||||||
defAcceptsFrom,
|
|
||||||
drawnDef,
|
drawnDef,
|
||||||
dropTargetPort,
|
|
||||||
edgesInto,
|
edgesInto,
|
||||||
edgesOutOf,
|
edgesOutOf,
|
||||||
findAnnotation,
|
|
||||||
firstPortFor,
|
|
||||||
ghostProblem,
|
ghostProblem,
|
||||||
type Graph,
|
type Graph,
|
||||||
type GraphNode,
|
type GraphNode,
|
||||||
type GraphLookup,
|
|
||||||
MIN_ANNOTATION_SIZE,
|
|
||||||
type PortDef,
|
type PortDef,
|
||||||
type PortSide,
|
type PortSide,
|
||||||
type PortType,
|
|
||||||
portTypeOf,
|
|
||||||
resolveDef,
|
resolveDef,
|
||||||
RUN_BEHAVIOR,
|
RUN_BEHAVIOR,
|
||||||
STATE_IN,
|
|
||||||
STATE_OUT,
|
|
||||||
startStateOf,
|
startStateOf,
|
||||||
} from '../../core/graph';
|
} from '../../core/graph';
|
||||||
import type { GraphAsset } from '../../core/model/Asset';
|
import type { GraphAsset } from '../../core/model/Asset';
|
||||||
@@ -45,7 +25,7 @@ import { particleLibrary } from '../../viewport/ParticleLibrary';
|
|||||||
import { shaderLibrary } from '../../viewport/ShaderLibrary';
|
import { shaderLibrary } from '../../viewport/ShaderLibrary';
|
||||||
import { findAsset, findGraphAsset } from '../../core/model/Project';
|
import { findAsset, findGraphAsset } from '../../core/model/Project';
|
||||||
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
|
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
|
||||||
import { beadsInFlight, launchBead } from './beads';
|
import { BeadClock } from './beads';
|
||||||
import {
|
import {
|
||||||
activeDrag,
|
activeDrag,
|
||||||
type DragPayload,
|
type DragPayload,
|
||||||
@@ -59,38 +39,23 @@ import {
|
|||||||
textStyle,
|
textStyle,
|
||||||
toHex,
|
toHex,
|
||||||
truncate,
|
truncate,
|
||||||
ui,
|
|
||||||
Widget,
|
Widget,
|
||||||
zoomFactor,
|
zoomFactor,
|
||||||
} from '../../ui';
|
} from '../../ui';
|
||||||
import { annotationSelection, type EditorState, graphSelection, nodeSelection } from '../state';
|
import { annotationSelection, type EditorState, graphSelection, nodeSelection } from '../state';
|
||||||
import {
|
import { type Box, boundsOf, boxesInBand, boxesWithinBand, frameBoxes } from '../framing';
|
||||||
type Box,
|
import { annotationBoxes, boxOf } from './annotationDrag';
|
||||||
boundsOf,
|
import { AnnotationLayer } from './annotationLayer';
|
||||||
boxesInBand,
|
import { ANNOTATION_TITLE_HEIGHT, type AnnotationView, RESIZE_HANDLE } from './AnnotationView';
|
||||||
boxesWithinBand,
|
import { EdgeLayer } from './EdgeLayer';
|
||||||
dragOffset,
|
import { DRAG_THRESHOLD, GraphDrags } from './graphDrags';
|
||||||
frameBoxes,
|
import type { GraphCanvasHost } from './graphHost';
|
||||||
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 { openAnnotationMenu, openLinkMenu, openNodeMenu } from './graphMenus';
|
import { openAnnotationMenu, openLinkMenu, openNodeMenu } from './graphMenus';
|
||||||
import { NodePalette, type PaletteAction } from './NodePalette';
|
import { NodePalette, type PaletteAction } from './NodePalette';
|
||||||
import { type PaletteEntry, paletteEntries } from './paletteEntries';
|
import { type PaletteEntry, paletteEntries } from './paletteEntries';
|
||||||
import { NODE_WIDTH, NodeView, TITLE_HEIGHT } from './NodeView';
|
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. */
|
/** One running instance's answer for a machine: which state it holds, and whose instance it is. */
|
||||||
export interface LiveMachineState {
|
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. */
|
/** Stand-in node height for a node with no view yet – real nodes vary with their port count. */
|
||||||
const NODE_HEIGHT_ESTIMATE = 80;
|
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. */
|
/** Live values change every frame; ten updates a second are still readable and cost far less text layout. */
|
||||||
const LIVE_VALUE_INTERVAL_MS = 100;
|
const LIVE_VALUE_INTERVAL_MS = 100;
|
||||||
/** A group or comment created out of nothing – large enough to drop a few nodes into. */
|
/** 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 grid = new Graphics();
|
||||||
private readonly clip = new Graphics();
|
private readonly clip = new Graphics();
|
||||||
private readonly canvas = new Container();
|
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 AnnotationLayer((view, e) => this.onAnnotationPointerDown(view, e));
|
||||||
private readonly annotationLayer = new Container();
|
|
||||||
private readonly edgeLayer = new EdgeLayer();
|
private readonly edgeLayer = new EdgeLayer();
|
||||||
private readonly nodeLayer = new Container();
|
private readonly nodeLayer = new Container();
|
||||||
private readonly tempEdge = new Graphics();
|
private readonly tempEdge = new Graphics();
|
||||||
@@ -130,26 +92,13 @@ export class GraphCanvas extends Widget {
|
|||||||
private readonly band = new Graphics();
|
private readonly band = new Graphics();
|
||||||
private readonly hint = new Text({ text: '', style: textStyle('muted') });
|
private readonly hint = new Text({ text: '', style: textStyle('muted') });
|
||||||
private readonly views = new Map<string, NodeView>();
|
private readonly views = new Map<string, NodeView>();
|
||||||
private readonly annotations = new Map<string, AnnotationView>();
|
|
||||||
private assetId = '';
|
private assetId = '';
|
||||||
private pan = { x: 0, y: 0 };
|
private pan = { x: 0, y: 0 };
|
||||||
private zoom = 1;
|
private zoom = 1;
|
||||||
private panning: { startX: number; startY: number; panX: number; panY: number } | null = null;
|
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 snapping = false;
|
||||||
|
private readonly wiring: GraphWiring;
|
||||||
|
private readonly drags: GraphDrags;
|
||||||
/** A rubber-band selection in progress, in graph coordinates. */
|
/** A rubber-band selection in progress, in graph coordinates. */
|
||||||
private marquee: {
|
private marquee: {
|
||||||
start: { x: number; y: number };
|
start: { x: number; y: number };
|
||||||
@@ -158,25 +107,10 @@ export class GraphCanvas extends Widget {
|
|||||||
} | null = null;
|
} | null = null;
|
||||||
/** A behavior dragged in from the asset panel, and why it may not land. */
|
/** 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 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). */
|
/** Fit the view once the editor has a real size (it may be hidden behind the Scene tab). */
|
||||||
private needsFit = true;
|
private needsFit = true;
|
||||||
private valuesShownAt = 0;
|
private valuesShownAt = 0;
|
||||||
/** In game time, so a paused game holds its beads. */
|
private readonly beads = new BeadClock();
|
||||||
private readonly beadTimes = new Map<string, number[]>();
|
|
||||||
private readonly beadFired = new Map<string, number>();
|
|
||||||
private beadAsset = '';
|
|
||||||
/** Supplies live execution data while playing (null otherwise). */
|
/** Supplies live execution data while playing (null otherwise). */
|
||||||
traceProvider: (assetId: string) => Trace | null = () => null;
|
traceProvider: (assetId: string) => Trace | null = () => null;
|
||||||
/** Which state each running instance of this machine holds; empty when nothing runs. */
|
/** 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',
|
private readonly noun = 'behavior',
|
||||||
) {
|
) {
|
||||||
super();
|
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.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.addChild(this.bg, this.grid, this.canvas, this.band, this.hint, this.clip);
|
||||||
this.canvas.mask = this.clip;
|
this.canvas.mask = this.clip;
|
||||||
@@ -229,7 +183,7 @@ export class GraphCanvas extends Widget {
|
|||||||
show(assetId: string): void {
|
show(assetId: string): void {
|
||||||
if (this.assetId === assetId) return;
|
if (this.assetId === assetId) return;
|
||||||
// Its source node is in the graph being left, so the click that finished it would land nowhere.
|
// 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.assetId = assetId;
|
||||||
this.sync();
|
this.sync();
|
||||||
this.needsFit = true;
|
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) };
|
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 {
|
private asset(): GraphAsset | null {
|
||||||
return (this.assetId && findGraphAsset(this.state.project, this.assetId)) || null;
|
return (this.assetId && findGraphAsset(this.state.project, this.assetId)) || null;
|
||||||
}
|
}
|
||||||
@@ -286,14 +236,14 @@ export class GraphCanvas extends Widget {
|
|||||||
if (!asset) {
|
if (!asset) {
|
||||||
// An asset that was deleted (or undone away) must not linger as a graph.
|
// An asset that was deleted (or undone away) must not linger as a graph.
|
||||||
this.assetId = '';
|
this.assetId = '';
|
||||||
this.cancelTransition();
|
this.wiring.cancel();
|
||||||
for (const view of this.views.values()) view.destroy({ children: true });
|
for (const view of this.views.values()) view.destroy({ children: true });
|
||||||
this.views.clear();
|
this.views.clear();
|
||||||
this.clearAnnotations();
|
this.annotationLayer.clearViews();
|
||||||
this.redraw();
|
this.redraw();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.syncAnnotations(asset.graph);
|
this.annotationLayer.sync(asset.graph);
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const lookup = graphLookup(this.state.project);
|
const lookup = graphLookup(this.state.project);
|
||||||
// In a compiled graph a node's problem is the compiler's, not a call's.
|
// 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();
|
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<string>();
|
|
||||||
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 {
|
private onAnnotationPointerDown(view: AnnotationView, e: FederatedPointerEvent): void {
|
||||||
if (e.button === 1 || !this.assetId) return;
|
if (e.button === 1 || !this.assetId) return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
// An armed transition eats the click, exactly as it does over a node.
|
// An armed transition eats the click, exactly as it does over a node.
|
||||||
if (this.armed && (e.button === 0 || e.button === 2)) {
|
if (this.wiring.isArmed && (e.button === 0 || e.button === 2)) {
|
||||||
this.finishTransition(e.global.x, e.global.y, false);
|
this.wiring.finish(e.global, false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const id = view.annotation.id;
|
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);
|
openAnnotationMenu(this.state, this.assetId, view.kind, id, e.global.x, e.global.y);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.button !== 0) return;
|
if (e.button === 0) this.drags.grabBox(view, e.global);
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Adds a group or comment and selects it, so its title can be typed straight away. */
|
/** 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;
|
if (e.target !== this) return;
|
||||||
// Away from every state, so there is nothing to transition to: the click drops the attempt.
|
// 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)) {
|
if (this.wiring.isArmed && (e.button === 0 || e.button === 2)) {
|
||||||
this.finishTransition(e.global.x, e.global.y, false);
|
this.wiring.finish(e.global, false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Shift-drag on empty space pans as well, as it does in the scene view.
|
// 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();
|
e.stopPropagation();
|
||||||
// An armed transition eats the next click whole: on another state it lands, anywhere else it
|
// 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.
|
// 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)) {
|
if (this.wiring.isArmed && (e.button === 0 || e.button === 2)) {
|
||||||
this.finishTransition(e.global.x, e.global.y, e.button === 0);
|
this.wiring.finish(e.global, e.button === 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const local = view.toLocal(e.global);
|
const local = view.toLocal(e.global);
|
||||||
@@ -620,7 +486,7 @@ export class GraphCanvas extends Widget {
|
|||||||
const extras: MenuItem[] = [
|
const extras: MenuItem[] = [
|
||||||
...(view.def.transitionEdges === true
|
...(view.def.transitionEdges === true
|
||||||
? [
|
? [
|
||||||
{ label: 'Make transition', onSelect: () => this.armTransition(id, graphPoint) },
|
{ label: 'Make transition', onSelect: () => this.wiring.arm(id, graphPoint) },
|
||||||
...this.startItems(id),
|
...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.
|
// node instead would make a press on the very edge of it move the whole node.
|
||||||
if (hit?.port.ghost) return;
|
if (hit?.port.ghost) return;
|
||||||
if (hit) {
|
if (hit) {
|
||||||
const p = this.toLocal(e.global);
|
this.wiring.startDrag({ nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput }, 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();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Like the hierarchy's chevron: it folds, and leaves the selection alone.
|
// 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.
|
// Clicking a selected node keeps the selection so the whole group can be dragged.
|
||||||
if (!this.state.selectedNodeIds.includes(view.node.id))
|
if (!this.state.selectedNodeIds.includes(view.node.id))
|
||||||
this.state.select(nodeSelection(this.assetId, [view.node.id]));
|
this.state.select(nodeSelection(this.assetId, [view.node.id]));
|
||||||
const graph = this.asset()?.graph;
|
if (this.drags.grabNodes(e.global)) this.nodeLayer.addChild(view); // raise
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private openCalledBehavior(node: GraphNode): boolean {
|
private openCalledBehavior(node: GraphNode): boolean {
|
||||||
@@ -700,30 +551,10 @@ export class GraphCanvas extends Widget {
|
|||||||
this.applyView();
|
this.applyView();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this.drags.move(e.global) || this.wiring.move(e.global)) return;
|
||||||
const local = this.toLocal(e.global);
|
const local = this.toLocal(e.global);
|
||||||
const g = this.screenToGraph(local.x, local.y);
|
const g = this.screenToGraph(local.x, local.y);
|
||||||
if (this.annotationDrag) {
|
if (this.marquee) {
|
||||||
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) {
|
|
||||||
this.marquee.current = g;
|
this.marquee.current = g;
|
||||||
const d = Math.hypot(g.x - this.marquee.start.x, g.y - this.marquee.start.y) * this.zoom;
|
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;
|
if (!this.marquee.active && d < DRAG_THRESHOLD) return;
|
||||||
@@ -777,8 +608,7 @@ export class GraphCanvas extends Widget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.panning = null;
|
this.panning = null;
|
||||||
if (this.annotationDrag) {
|
if (this.drags.releaseBox()) {
|
||||||
this.commitAnnotationDrag();
|
|
||||||
this.redraw();
|
this.redraw();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -788,23 +618,8 @@ export class GraphCanvas extends Widget {
|
|||||||
if (marquee.active) this.selectInMarquee(marquee);
|
if (marquee.active) this.selectInMarquee(marquee);
|
||||||
else this.state.select({ kind: 'asset', assetId: this.assetId });
|
else this.state.select({ kind: 'asset', assetId: this.assetId });
|
||||||
}
|
}
|
||||||
const drag = this.dragging;
|
this.drags.releaseNodes();
|
||||||
this.dragging = null;
|
this.wiring.release(e.global);
|
||||||
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.redraw();
|
this.redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -827,76 +642,6 @@ export class GraphCanvas extends Widget {
|
|||||||
this.state.select(graphSelection(this.assetId, ids, boxIds) ?? { kind: 'asset', assetId: this.assetId });
|
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 {
|
private nodeUnder(globalX: number, globalY: number): string | null {
|
||||||
for (const view of this.topDownViews()) {
|
for (const view of this.topDownViews()) {
|
||||||
const p = view.toLocal({ x: globalX, y: globalY });
|
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]));
|
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 {
|
private openLinks(pick: (graph: Graph) => Graph['edges'], at: { x: number; y: number }): void {
|
||||||
const graph = this.asset()?.graph;
|
const graph = this.asset()?.graph;
|
||||||
if (graph) openLinkMenu(this.state, this.assetId, pick(graph), at.x, at.y);
|
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. */
|
/** Shortened to the canvas: since the Particles tab put a preview beside it, a canvas can be narrow. */
|
||||||
private setHint(text: string): void {
|
private setHint(text: string): void {
|
||||||
this.hint.text = truncate(text, this.hint.style, this.w - Theme.padding * 2);
|
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';
|
const playing = this.state.mode === 'play';
|
||||||
this.setHint(
|
this.setHint(
|
||||||
this.armed
|
this.wiring.isArmed
|
||||||
? 'Click the state to transition to, this one to re-enter it – anywhere else, or Escape, cancels'
|
? 'Click the state to transition to, this one to re-enter it – anywhere else, or Escape, cancels'
|
||||||
: asset.graph.nodes.length === 0
|
: asset.graph.nodes.length === 0
|
||||||
? 'Right-click to add a node'
|
? 'Right-click to add a node'
|
||||||
@@ -1064,18 +741,15 @@ export class GraphCanvas extends Widget {
|
|||||||
|
|
||||||
const trace = playing ? this.traceProvider(asset.id) : null;
|
const trace = playing ? this.traceProvider(asset.id) : null;
|
||||||
const now = this.timeProvider();
|
const now = this.timeProvider();
|
||||||
this.launchBeads(asset.id, trace);
|
this.beads.launch(asset.id, trace?.edges ?? null);
|
||||||
const isHot = (map: Map<string, number> | undefined, id: string) => {
|
const isHot = (map: Map<string, number> | undefined, id: string) => {
|
||||||
const t = map?.get(id);
|
const t = map?.get(id);
|
||||||
return t !== undefined && now - t <= TRACE_FADE;
|
return t !== undefined && now - t <= TRACE_FADE;
|
||||||
};
|
};
|
||||||
const selectedNodes = new Set(this.state.selectedNodeIds);
|
const selectedNodes = new Set(this.state.selectedNodeIds);
|
||||||
const selectedAnnotations = new Set(this.state.selectedAnnotationIds);
|
const selectedAnnotations = new Set(this.state.selectedAnnotationIds);
|
||||||
for (const view of this.annotations.values()) {
|
this.annotationLayer.redrawViews(selectedAnnotations);
|
||||||
view.selected = selectedAnnotations.has(view.annotation.id);
|
const target = this.wiring.target;
|
||||||
view.redraw();
|
|
||||||
}
|
|
||||||
const target = this.dropTarget;
|
|
||||||
const wall = performance.now();
|
const wall = performance.now();
|
||||||
const values = trace && wall - this.valuesShownAt >= LIVE_VALUE_INTERVAL_MS ? trace.values : null;
|
const values = trace && wall - this.valuesShownAt >= LIVE_VALUE_INTERVAL_MS ? trace.values : null;
|
||||||
if (values) this.valuesShownAt = wall;
|
if (values) this.valuesShownAt = wall;
|
||||||
@@ -1097,38 +771,10 @@ export class GraphCanvas extends Widget {
|
|||||||
this.views,
|
this.views,
|
||||||
(id) => isHot(trace?.edges, id),
|
(id) => isHot(trace?.edges, id),
|
||||||
selectedNodes,
|
selectedNodes,
|
||||||
(id) => beadsInFlight(this.beadTimes.get(id) ?? [], now),
|
(id) => this.beads.inFlight(id, now),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.tempEdge.clear();
|
this.wiring.draw(this.tempEdge, asset.graph);
|
||||||
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.band.clear();
|
this.band.clear();
|
||||||
const m = this.marquee;
|
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 {
|
private syncGrid(): void {
|
||||||
drawGraphGrid(this.grid, { pan: this.pan, zoom: this.zoom }, this.w, this.h);
|
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. */
|
/** The port a running edge drag would connect to, or null when the drop would do nothing. */
|
||||||
pendingDropTarget(): PortSide | null {
|
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). */
|
/** Whether the dangling edge is on screen right now (running drag, or a drop awaiting its node). */
|
||||||
danglingEdgeVisible(): boolean {
|
danglingEdgeVisible(): boolean {
|
||||||
return this.tempEdgeShown;
|
return this.wiring.edgeShown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether the last redraw stroked this edge as a curve. */
|
/** Whether the last redraw stroked this edge as a curve. */
|
||||||
@@ -1342,7 +965,7 @@ export class GraphCanvas extends Widget {
|
|||||||
w: number;
|
w: number;
|
||||||
h: number;
|
h: number;
|
||||||
}[] {
|
}[] {
|
||||||
return [...this.annotations.values()].map((view) => ({
|
return [...this.annotationLayer.views.values()].map((view) => ({
|
||||||
id: view.annotation.id,
|
id: view.annotation.id,
|
||||||
kind: view.kind,
|
kind: view.kind,
|
||||||
text: view.drawnText(),
|
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. */
|
/** 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 {
|
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;
|
if (!view) return null;
|
||||||
const local = corner
|
const local = corner
|
||||||
? { x: view.w - RESIZE_HANDLE / 2, y: view.h - RESIZE_HANDLE / 2 }
|
? { 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. */
|
/** The rectangle a box is drawn as, on screen – what a fit has to bring into view. */
|
||||||
annotationRect(id: string): Rect | null {
|
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;
|
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 });
|
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 };
|
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 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<string, AnnotationView>();
|
||||||
|
|
||||||
|
constructor(private readonly onPress: (view: AnnotationView, e: FederatedPointerEvent) => void) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
get views(): ReadonlyMap<string, AnnotationView> {
|
||||||
|
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<string>();
|
||||||
|
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<string>): void {
|
||||||
|
for (const view of this.byId.values()) {
|
||||||
|
view.selected = selected.has(view.annotation.id);
|
||||||
|
view.redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
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<string, number[]>();
|
||||||
|
private readonly fired = new Map<string, number>();
|
||||||
|
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<string, number> | 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. */
|
/** 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 {
|
export function curveReach(x1: number, x2: number): number {
|
||||||
return Math.max(40, Math.abs(x2 - x1) * 0.5);
|
return Math.max(40, Math.abs(x2 - x1) * 0.5);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, NodeView>;
|
||||||
|
/** Views in stacking order, topmost first – a node drawn over another one wins the pointer. */
|
||||||
|
topDownViews(): Iterable<NodeView>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
BEAD_SPACING,
|
BEAD_SPACING,
|
||||||
BEAD_TRAVEL,
|
BEAD_TRAVEL,
|
||||||
|
BeadClock,
|
||||||
beadsInFlight,
|
beadsInFlight,
|
||||||
curvePoint,
|
curvePoint,
|
||||||
launchBead,
|
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', () => {
|
describe('the curve a bead follows', () => {
|
||||||
it('starts in the source port and ends in the target port', () => {
|
it('starts in the source port and ends in the target port', () => {
|
||||||
expect(curvePoint(10, 20, 300, 80, 0)).toEqual({ x: 10, y: 20 });
|
expect(curvePoint(10, 20, 300, 80, 0)).toEqual({ x: 10, y: 20 });
|
||||||
|
|||||||
@@ -270,6 +270,12 @@ export async function graphGroups(t) {
|
|||||||
caught.nodeIds.includes('gg-start'),
|
caught.nodeIds.includes('gg-start'),
|
||||||
JSON.stringify(caught),
|
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
|
// 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
|
// wraps the nodes it holds, from joining every band drawn over any of them. It starts to the
|
||||||
|
|||||||
Reference in new issue
Block a user