A graph that grew past one screen needs somewhere to say what a part of it is for (#38)
game-maker / suite (push) Successful in 6m13s
game-maker / suite (push) Successful in 6m13s
The graph could hold any number of nodes and said nothing about which of them belonged together. Groups and comments are two separate things, as decided in the issue: a group is a titled box that carries the nodes it encloses, a comment is free text that carries nothing. Both live as optional arrays on Graph, not as registry nodes. A comment that were a real node would run through the interpreter, show up in the drop palettes and be compiled into a shader — the wrong place for a piece of text. As arrays on Graph they exist in all four graph kinds at once, and a document without them means exactly "no groups", so no format bump. Membership is computed when the box is grabbed, never stored: the center of a node inside the box travels with it, and a node dragged out is simply out. A box inside a box travels with the outer one, its own nodes included. A group is grabbed by its title bar only — its body stays transparent to the pointer, or a rubber band could never be drawn across the nodes it holds. Deleting a box deletes the box; the nodes stay where they are, because a box that silently took ten nodes with it would be the most expensive surprise in the feature. The color is a free picker, and the title color follows from it: relative luminance per WCAG, threshold at 0.179 where the two contrast ratios meet — not 0.5, which would pick white on mid grey where black reads better. Resizing snaps the far edge to the grid, not the width, so the corner that is not being dragged stays put. The minimum size rises in whole steps for that reason: 80 is no multiple of 24, so a box squeezed to the stop would have sat off-grid in exactly the mode meant to keep it on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1135205d69
commit
b9f1a9d319
21 files changed
+1473
-22
No files matched your search
@@ -10,6 +10,16 @@ import {
|
||||
} from '../../core/commands/graphCommands';
|
||||
import { SetMachineStartCommand } from '../../core/commands/assetCommands';
|
||||
import {
|
||||
AddAnnotationCommand,
|
||||
type AnnotationBox,
|
||||
annotationNoun,
|
||||
normalizeBox,
|
||||
SetAnnotationBoxCommand,
|
||||
} from '../../core/commands/annotationCommands';
|
||||
import {
|
||||
type AnnotationKind,
|
||||
ANNOTATION_KINDS,
|
||||
annotationsOf,
|
||||
BEHAVIOR_PARAM,
|
||||
callProblem,
|
||||
canConnect,
|
||||
@@ -18,11 +28,14 @@ import {
|
||||
dropTargetPort,
|
||||
edgesInto,
|
||||
edgesOutOf,
|
||||
findAnnotation,
|
||||
firstPortFor,
|
||||
ghostProblem,
|
||||
type Graph,
|
||||
type GraphAnnotation,
|
||||
type GraphNode,
|
||||
type GraphLookup,
|
||||
MIN_ANNOTATION_SIZE,
|
||||
type PortSide,
|
||||
type PortType,
|
||||
portTypeOf,
|
||||
@@ -52,17 +65,27 @@ import {
|
||||
type Size,
|
||||
Theme,
|
||||
textStyle,
|
||||
toHex,
|
||||
truncate,
|
||||
ui,
|
||||
Widget,
|
||||
zoomFactor,
|
||||
} from '../../ui';
|
||||
import { type EditorState, nodeSelection } from '../state';
|
||||
import { type Box, boxesInBand, dragOffset, frameBoxes } from '../framing';
|
||||
import {
|
||||
type Box,
|
||||
boundsOf,
|
||||
boxesCentredIn,
|
||||
boxesInBand,
|
||||
dragOffset,
|
||||
frameBoxes,
|
||||
resizeSize,
|
||||
} from '../framing';
|
||||
import { ANNOTATION_TITLE_HEIGHT, AnnotationView, RESIZE_HANDLE } from './AnnotationView';
|
||||
import { arrowHead, bezier, EdgeLayer } from './EdgeLayer';
|
||||
import { arrowShaft, type Point, transitionPreview } from './transitions';
|
||||
import { openLinkMenu, openNodeMenu } from './graphMenus';
|
||||
import { NodePalette } from './NodePalette';
|
||||
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';
|
||||
|
||||
@@ -88,6 +111,11 @@ const FIT_MARGIN = 24;
|
||||
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. */
|
||||
const NEW_GROUP_SIZE = { w: 320, h: 200 };
|
||||
const NEW_NOTE_SIZE = { w: 220, h: 96 };
|
||||
/** Air a group keeps around the nodes it was drawn around; the top also holds the title bar. */
|
||||
const GROUP_MARGIN = 20;
|
||||
|
||||
/**
|
||||
* The node graph of one behavior or state machine asset. Which one that is comes from the outside
|
||||
@@ -98,6 +126,8 @@ 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 edgeLayer = new EdgeLayer();
|
||||
private readonly nodeLayer = new Container();
|
||||
private readonly tempEdge = new Graphics();
|
||||
@@ -105,6 +135,7 @@ export class GraphCanvas extends Widget {
|
||||
private readonly band = new Graphics();
|
||||
private readonly hint = new Text({ text: '', style: textStyle('muted') });
|
||||
private readonly views = new Map<string, NodeView>();
|
||||
private readonly annotations = new Map<string, AnnotationView>();
|
||||
private assetId = '';
|
||||
private pan = { x: 0, y: 0 };
|
||||
private zoom = 1;
|
||||
@@ -114,6 +145,21 @@ export class GraphCanvas extends Widget {
|
||||
pointer: { x: number; y: number };
|
||||
moved: boolean;
|
||||
} | null = null;
|
||||
/**
|
||||
* A group or comment being moved or resized. `members` 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: {
|
||||
id: string;
|
||||
kind: AnnotationKind;
|
||||
mode: 'move' | 'resize';
|
||||
start: AnnotationBox;
|
||||
members: { nodeId: string; start: { x: number; y: number } }[];
|
||||
nested: { id: string; kind: AnnotationKind; start: AnnotationBox }[];
|
||||
pointer: { x: number; y: number };
|
||||
moved: boolean;
|
||||
} | null = null;
|
||||
private snapping = false;
|
||||
/** A rubber-band selection in progress, in graph coordinates. */
|
||||
private marquee: {
|
||||
@@ -156,7 +202,7 @@ export class GraphCanvas extends Widget {
|
||||
private readonly noun = 'behavior',
|
||||
) {
|
||||
super();
|
||||
this.canvas.addChild(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.canvas.mask = this.clip;
|
||||
this.cursor = 'default';
|
||||
@@ -242,9 +288,11 @@ export class GraphCanvas extends Widget {
|
||||
this.cancelTransition();
|
||||
for (const view of this.views.values()) view.destroy({ children: true });
|
||||
this.views.clear();
|
||||
this.clearAnnotations();
|
||||
this.redraw();
|
||||
return;
|
||||
}
|
||||
this.syncAnnotations(asset.graph);
|
||||
const seen = new Set<string>();
|
||||
const lookup = graphLookup(this.state.project);
|
||||
// In a compiled graph a node's problem is the compiler's, not a call's.
|
||||
@@ -281,6 +329,197 @@ 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<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 {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
const id = view.annotation.id;
|
||||
this.state.select({ kind: 'annotation', assetId: this.assetId, annotationId: id });
|
||||
if (e.button === 2) {
|
||||
openAnnotationMenu(this.state, this.assetId, view.kind, id, e.global.x, e.global.y);
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) return;
|
||||
const local = view.toLocal(e.global);
|
||||
const p = this.toLocal(e.global);
|
||||
const pointer = this.screenToGraph(p.x, p.y);
|
||||
const start = boxOf(view.annotation);
|
||||
const resizing = view.handleAt(local.x, local.y);
|
||||
this.annotationDrag = {
|
||||
id,
|
||||
kind: view.kind,
|
||||
mode: resizing ? 'resize' : 'move',
|
||||
start,
|
||||
// A comment carries nothing, and resizing carries nothing either: membership is re-derived
|
||||
// from where the box ends up, so a node the corner sweeps over simply belongs to it after.
|
||||
members: view.kind === 'group' && !resizing ? this.nodeMembers(start) : [],
|
||||
nested: view.kind === 'group' && !resizing ? this.nestedMembers(id, start) : [],
|
||||
pointer,
|
||||
moved: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** The nodes a group at this box carries – the centre rule, against what is drawn right now. */
|
||||
private nodeMembers(box: AnnotationBox): { nodeId: string; start: { x: number; y: number } }[] {
|
||||
const graph = this.asset()?.graph;
|
||||
const ids = new Set(boxesCentredIn(this.nodeBoxes(), box));
|
||||
return (graph?.nodes ?? [])
|
||||
.filter((n) => ids.has(n.id))
|
||||
.map((n) => ({ nodeId: n.id, start: { x: n.x, y: n.y } }));
|
||||
}
|
||||
|
||||
/** Groups and comments inside a group: the outer one takes them, and with them their own nodes. */
|
||||
private nestedMembers(
|
||||
selfId: string,
|
||||
box: AnnotationBox,
|
||||
): { id: string; kind: AnnotationKind; start: AnnotationBox }[] {
|
||||
const graph = this.asset()?.graph;
|
||||
if (!graph) return [];
|
||||
const inside: { id: string; kind: AnnotationKind; start: AnnotationBox }[] = [];
|
||||
for (const kind of ANNOTATION_KINDS) {
|
||||
const list = annotationsOf(graph, kind).filter((a) => a.id !== selfId);
|
||||
const ids = new Set(
|
||||
boxesCentredIn(
|
||||
list.map((a) => ({ id: a.id, ...boxOf(a) })),
|
||||
box,
|
||||
),
|
||||
);
|
||||
for (const a of list) if (ids.has(a.id)) inside.push({ id: a.id, kind, start: boxOf(a) });
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/** 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 self = findAnnotation(graph, drag.id)?.annotation;
|
||||
if (!self) return;
|
||||
const step = this.snapping ? GRID_STEP : 0;
|
||||
if (drag.mode === 'resize') {
|
||||
const size = resizeSize(drag.start, dx, dy, step, MIN_ANNOTATION_SIZE);
|
||||
Object.assign(self, normalizeBox({ ...drag.start, ...size }));
|
||||
this.state.preview();
|
||||
return;
|
||||
}
|
||||
const offset = dragOffset(drag.start, dx, dy, step);
|
||||
self.x = drag.start.x + offset.x;
|
||||
self.y = drag.start.y + offset.y;
|
||||
for (const member of drag.members) {
|
||||
const node = graph.nodes.find((n) => n.id === member.nodeId);
|
||||
if (!node) continue;
|
||||
node.x = member.start.x + offset.x;
|
||||
node.y = member.start.y + offset.y;
|
||||
}
|
||||
for (const member of drag.nested) {
|
||||
const nested = findAnnotation(graph, member.id)?.annotation;
|
||||
if (!nested) continue;
|
||||
nested.x = member.start.x + offset.x;
|
||||
nested.y = member.start.y + offset.y;
|
||||
}
|
||||
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 self = findAnnotation(graph, drag.id)?.annotation;
|
||||
if (!self) return;
|
||||
const commands: Command[] = [];
|
||||
const take = (a: GraphAnnotation, kind: AnnotationKind, id: string, start: AnnotationBox) => {
|
||||
const next = boxOf(a);
|
||||
Object.assign(a, start);
|
||||
commands.push(new SetAnnotationBoxCommand(this.assetId, kind, id, next, label));
|
||||
};
|
||||
const label = drag.mode === 'resize' ? `Resize ${annotationNoun(drag.kind)}` : undefined;
|
||||
take(self, drag.kind, drag.id, drag.start);
|
||||
for (const member of drag.nested) {
|
||||
const nested = findAnnotation(graph, member.id)?.annotation;
|
||||
if (nested) take(nested, member.kind, member.id, member.start);
|
||||
}
|
||||
for (const member of drag.members) {
|
||||
const node = graph.nodes.find((n) => n.id === member.nodeId);
|
||||
if (!node) continue;
|
||||
const next = { x: node.x, y: node.y };
|
||||
node.x = member.start.x;
|
||||
node.y = member.start.y;
|
||||
commands.push(new MoveNodeCommand(this.assetId, member.nodeId, next.x, next.y));
|
||||
}
|
||||
const verb = drag.mode === 'resize' ? 'Resize' : 'Move';
|
||||
this.state.execute(oneStep(`${verb} ${annotationNoun(drag.kind)}`, commands));
|
||||
}
|
||||
|
||||
/** Adds a group or comment and selects it, so its title can be typed straight away. */
|
||||
private addAnnotation(kind: AnnotationKind, box: AnnotationBox, text: string): void {
|
||||
if (!this.assetId) return;
|
||||
const command = new AddAnnotationCommand(this.assetId, kind, box, text);
|
||||
if (this.state.execute(command))
|
||||
this.state.select({
|
||||
kind: 'annotation',
|
||||
assetId: this.assetId,
|
||||
annotationId: command.annotation.id,
|
||||
});
|
||||
}
|
||||
|
||||
/** A group drawn around the selected nodes, with room for the title bar above them. */
|
||||
groupSelectedNodes(): void {
|
||||
const ids = new Set(this.state.selectedNodeIds);
|
||||
const bounds = boundsOf(this.nodeBoxes().filter((b) => ids.has(b.id)));
|
||||
if (!bounds) return;
|
||||
this.addAnnotation(
|
||||
'group',
|
||||
{
|
||||
x: bounds.minX - GROUP_MARGIN,
|
||||
y: bounds.minY - GROUP_MARGIN - ANNOTATION_TITLE_HEIGHT,
|
||||
w: bounds.maxX - bounds.minX + GROUP_MARGIN * 2,
|
||||
h: bounds.maxY - bounds.minY + GROUP_MARGIN * 2 + ANNOTATION_TITLE_HEIGHT,
|
||||
},
|
||||
'Group',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one entry that marks a state as the machine's start, or takes the mark off it again – the
|
||||
* same place, so it reads as the switch it is. Only a state machine has one to give.
|
||||
@@ -438,13 +677,19 @@ export class GraphCanvas extends Widget {
|
||||
if (!this.state.selectedNodeIds.includes(id)) this.state.select(nodeSelection(this.assetId, [id]));
|
||||
const at = this.toLocal(e.global);
|
||||
const graphPoint = this.screenToGraph(at.x, at.y);
|
||||
const extras: MenuItem[] =
|
||||
view.def.transitionEdges === true
|
||||
const picked = this.state.selectedNodeIds.length;
|
||||
const extras: MenuItem[] = [
|
||||
...(view.def.transitionEdges === true
|
||||
? [
|
||||
{ label: 'Make transition', onSelect: () => this.armTransition(id, graphPoint) },
|
||||
...this.startItems(id),
|
||||
]
|
||||
: [];
|
||||
: []),
|
||||
{
|
||||
label: picked > 1 ? `Group these ${picked} nodes` : 'Group this node',
|
||||
onSelect: () => this.groupSelectedNodes(),
|
||||
},
|
||||
];
|
||||
openNodeMenu(this.state, this.assetId, this.selectedNodes(), e.global.x, e.global.y, extras);
|
||||
}
|
||||
return;
|
||||
@@ -515,7 +760,9 @@ export class GraphCanvas extends Widget {
|
||||
}
|
||||
const local = this.toLocal(e.global);
|
||||
const g = this.screenToGraph(local.x, local.y);
|
||||
if (this.dragging) {
|
||||
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;
|
||||
@@ -593,6 +840,11 @@ export class GraphCanvas extends Widget {
|
||||
return;
|
||||
}
|
||||
this.panning = null;
|
||||
if (this.annotationDrag) {
|
||||
this.commitAnnotationDrag();
|
||||
this.redraw();
|
||||
return;
|
||||
}
|
||||
const marquee = this.marquee;
|
||||
this.marquee = null;
|
||||
if (marquee && this.assetId) {
|
||||
@@ -729,7 +981,32 @@ export class GraphCanvas extends Widget {
|
||||
}
|
||||
|
||||
private openPalette(globalX: number, globalY: number, at: { x: number; y: number }): void {
|
||||
new NodePalette(this.entries(), (entry) => this.addNode(entry, at)).open(globalX, globalY);
|
||||
new NodePalette(
|
||||
this.entries(),
|
||||
(entry) => this.addNode(entry, at),
|
||||
undefined,
|
||||
this.annotationActions(at),
|
||||
).open(globalX, globalY);
|
||||
}
|
||||
|
||||
/** The two things the palette offers that are not nodes. Both land where it was opened. */
|
||||
private annotationActions(at: { x: number; y: number }): PaletteAction[] {
|
||||
return [
|
||||
{
|
||||
id: 'add-group',
|
||||
label: 'Add group',
|
||||
hint: 'box',
|
||||
match: 'group box frame',
|
||||
onSelect: () => this.addAnnotation('group', { ...at, ...NEW_GROUP_SIZE }, 'Group'),
|
||||
},
|
||||
{
|
||||
id: 'add-comment',
|
||||
label: 'Add comment',
|
||||
hint: 'text',
|
||||
match: 'comment note text',
|
||||
onSelect: () => this.addAnnotation('note', { ...at, ...NEW_NOTE_SIZE }, 'Comment'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private entries(): PaletteEntry[] {
|
||||
@@ -864,6 +1141,12 @@ export class GraphCanvas extends Widget {
|
||||
return t !== undefined && now - t <= TRACE_FADE;
|
||||
};
|
||||
const selectedNodes = new Set(this.state.selectedNodeIds);
|
||||
const selectedAnnotation =
|
||||
this.state.selection?.kind === 'annotation' ? this.state.selection.annotationId : '';
|
||||
for (const view of this.annotations.values()) {
|
||||
view.selected = view.annotation.id === selectedAnnotation;
|
||||
view.redraw();
|
||||
}
|
||||
const target = this.dropTarget;
|
||||
const wall = performance.now();
|
||||
const values = trace && wall - this.valuesShownAt >= LIVE_VALUE_INTERVAL_MS ? trace.values : null;
|
||||
@@ -1089,6 +1372,41 @@ export class GraphCanvas extends Widget {
|
||||
return { x: g.x, y: g.y };
|
||||
}
|
||||
|
||||
/** Every group and comment as it is drawn: the box, the text on it, and both its colours. */
|
||||
annotationsDrawn(): {
|
||||
id: string;
|
||||
kind: AnnotationKind;
|
||||
text: string;
|
||||
color: string;
|
||||
textColor: string;
|
||||
selected: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}[] {
|
||||
return [...this.annotations.values()].map((view) => ({
|
||||
id: view.annotation.id,
|
||||
kind: view.kind,
|
||||
text: view.drawnText(),
|
||||
color: toHex(view.color),
|
||||
textColor: toHex(view.textColor),
|
||||
selected: view.selected,
|
||||
...boxOf(view.annotation),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 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);
|
||||
if (!view) return null;
|
||||
const local = corner
|
||||
? { x: view.w - RESIZE_HANDLE / 2, y: view.h - RESIZE_HANDLE / 2 }
|
||||
: { x: view.w / 2, y: ANNOTATION_TITLE_HEIGHT / 2 };
|
||||
const g = view.toGlobal(local);
|
||||
return { x: g.x, y: g.y };
|
||||
}
|
||||
|
||||
chevronGlobal(nodeId: string): { x: number; y: number } | null {
|
||||
const g = this.views.get(nodeId)?.chevronGlobal();
|
||||
return g ? { x: g.x, y: g.y } : null;
|
||||
@@ -1103,6 +1421,10 @@ function globalRect(target: Container, w: number, h: number): Rect {
|
||||
return { x: topLeft.x, y: topLeft.y, w: bottomRight.x - topLeft.x, h: bottomRight.y - topLeft.y };
|
||||
}
|
||||
|
||||
function boxOf(annotation: GraphAnnotation): AnnotationBox {
|
||||
return { x: annotation.x, y: annotation.y, w: annotation.w, h: annotation.h };
|
||||
}
|
||||
|
||||
/** 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];
|
||||
|
||||
Reference in new issue
Block a user