Pan with the middle button, select several nodes, and fit properly
Three things the graph view did differently from the scene view, or not at all: Panning moves to the middle button, which works from anywhere – a node under the pointer included, since NodeView now lets that one button bubble up. Shift-drag on empty canvas pans too. The left button pulls a rubber band instead and no longer pans. Nodes can be selected several at a time: by band, by ctrl/shift-click, moved together, deleted together, each as one undo step. The selection model gains `nodeIds` alongside the objects' `objectIds`. Fit really fits. It used to pin the zoom at 1 and merely centre, so a graph larger than the view always left nodes hanging out, and it sized every node with one guessed height although real ones vary with their port count. Both sums now live in editor/graph/framing.ts as pure functions, which is what made the defect testable at all. The band selects any node it overlaps rather than one whose position it contains, unlike the scene view: nodes are large rectangles, and a band drawn visibly across one has to catch it.
This commit is contained in:
1 parent
ed1fe31c77
commit
e0d43e12aa
12 files changed
+656
-61
No files matched your search
+46
-8
@@ -20,18 +20,28 @@ export type Selection =
|
||||
* holds the whole selection, primary included.
|
||||
*/
|
||||
| { kind: 'object'; holderId: string; objectId: string; objectIds: string[] }
|
||||
/** A node inside a behavior's graph. */
|
||||
| { kind: 'node'; assetId: string; nodeId: string }
|
||||
/** One or more nodes of a behavior's graph, `nodeId` being the primary one (as with objects). */
|
||||
| { kind: 'node'; assetId: string; nodeId: string; nodeIds: string[] }
|
||||
/** A frame rectangle inside a texture asset (the Texture tab). */
|
||||
| { kind: 'frame'; assetId: string; frameId: string }
|
||||
| null;
|
||||
|
||||
/** Builds an object selection; the last id is the primary one unless given explicitly. */
|
||||
export function objectSelection(holderId: string, objectIds: readonly string[], primary?: string): Selection {
|
||||
const ids = [...new Set(objectIds)];
|
||||
const picked = pickIds(objectIds, primary);
|
||||
return picked && { kind: 'object', holderId, objectId: picked.primary, objectIds: picked.ids };
|
||||
}
|
||||
|
||||
/** Builds a node selection; the last id is the primary one unless given explicitly. */
|
||||
export function nodeSelection(assetId: string, nodeIds: readonly string[], primary?: string): Selection {
|
||||
const picked = pickIds(nodeIds, primary);
|
||||
return picked && { kind: 'node', assetId, nodeId: picked.primary, nodeIds: picked.ids };
|
||||
}
|
||||
|
||||
function pickIds(input: readonly string[], primary?: string): { ids: string[]; primary: string } | null {
|
||||
const ids = [...new Set(input)];
|
||||
if (ids.length === 0) return null;
|
||||
const objectId = primary && ids.includes(primary) ? primary : ids[ids.length - 1]!;
|
||||
return { kind: 'object', holderId, objectId, objectIds: ids };
|
||||
return { ids, primary: primary && ids.includes(primary) ? primary : ids[ids.length - 1]! };
|
||||
}
|
||||
|
||||
export type EditorMode = 'edit' | 'play';
|
||||
@@ -206,6 +216,9 @@ export class EditorState {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (selection?.kind === 'object' && !selection.objectIds)
|
||||
selection = objectSelection(selection.holderId, [selection.objectId]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (selection?.kind === 'node' && !selection.nodeIds)
|
||||
selection = nodeSelection(selection.assetId, [selection.nodeId]);
|
||||
if (sameSelection(this.selection, selection)) return;
|
||||
this.selection = selection;
|
||||
this.emit('selection');
|
||||
@@ -221,11 +234,26 @@ export class EditorState {
|
||||
this.select(objectSelection(holderId, next, current.includes(objectId) ? undefined : objectId));
|
||||
}
|
||||
|
||||
/** Ctrl/Shift-click: adds the node to the selection or removes it again. */
|
||||
toggleNode(assetId: string, nodeId: string): void {
|
||||
const s = this.selection;
|
||||
const current = s?.kind === 'node' && s.assetId === assetId ? s.nodeIds : [];
|
||||
const held = current.includes(nodeId);
|
||||
const next = held ? current.filter((id) => id !== nodeId) : [...current, nodeId];
|
||||
// Emptied by the toggle, the behavior itself stays selected – the graph must not close.
|
||||
this.select(nodeSelection(assetId, next, held ? undefined : nodeId) ?? { kind: 'asset', assetId });
|
||||
}
|
||||
|
||||
/** Ids of the selected scene objects (empty unless objects are selected). */
|
||||
get selectedObjectIds(): string[] {
|
||||
return this.selection?.kind === 'object' ? this.selection.objectIds : [];
|
||||
}
|
||||
|
||||
/** Ids of the selected graph nodes (empty unless nodes are selected). */
|
||||
get selectedNodeIds(): string[] {
|
||||
return this.selection?.kind === 'node' ? this.selection.nodeIds : [];
|
||||
}
|
||||
|
||||
/** Drops selected targets that no longer exist (after delete/undo). */
|
||||
private validateSelection(): void {
|
||||
const s = this.selection;
|
||||
@@ -241,8 +269,11 @@ export class EditorState {
|
||||
const asset = findAsset(this.project, s.assetId);
|
||||
// Fall back to the behavior asset so the graph stays open when its node vanished (undo).
|
||||
if (!asset || asset.type !== 'behavior') this.select(null);
|
||||
else if (!asset.graph.nodes.some((n) => n.id === s.nodeId))
|
||||
this.select({ kind: 'asset', assetId: s.assetId });
|
||||
else {
|
||||
const alive = s.nodeIds.filter((id) => asset.graph.nodes.some((n) => n.id === id));
|
||||
if (alive.length !== s.nodeIds.length)
|
||||
this.select(nodeSelection(s.assetId, alive, s.nodeId) ?? { kind: 'asset', assetId: s.assetId });
|
||||
}
|
||||
}
|
||||
if (s.kind === 'frame') {
|
||||
const asset = findAsset(this.project, s.assetId);
|
||||
@@ -321,7 +352,14 @@ export function sameSelection(a: Selection, b: Selection): boolean {
|
||||
a.objectIds.every((id, i) => id === b.objectIds[i])
|
||||
);
|
||||
}
|
||||
if (a.kind === 'node' && b.kind === 'node') return a.assetId === b.assetId && a.nodeId === b.nodeId;
|
||||
if (a.kind === 'node' && b.kind === 'node') {
|
||||
return (
|
||||
a.assetId === b.assetId &&
|
||||
a.nodeId === b.nodeId &&
|
||||
a.nodeIds.length === b.nodeIds.length &&
|
||||
a.nodeIds.every((id, i) => id === b.nodeIds[i])
|
||||
);
|
||||
}
|
||||
if (a.kind === 'frame' && b.kind === 'frame') return a.assetId === b.assetId && a.frameId === b.frameId;
|
||||
return false;
|
||||
}
|
||||
Reference in new issue
Block a user