game-maker / suite (push) Successful in 6m22s
Fit measured the nodes alone: a group drawn around them was cut off, and a graph holding nothing but a comment framed the empty origin. It now frames both sorts together. The rubber band ignored boxes entirely. It catches them now, into the *same* selection as the nodes — `nodeIds` and `annotationIds` side by side — so one drag moves all of it, one Delete removes all of it, and one undo takes it back. A box counts only when the band holds all of it, where a node counts on touching: a group wraps the nodes it holds and therefore overlaps every band drawn over any of them, and since it drags those nodes along, catching it by accident would be felt at once. A node inside a selected group is moved once, not twice — `travellers()` collects everything one gesture carries and lets each thing appear in it a single time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
99 lines
3.7 KiB
TypeScript
99 lines
3.7 KiB
TypeScript
import type { Command } from '../../core/commands/Command';
|
||
import { oneStep } from '../../core/commands/CompoundCommand';
|
||
import { CollapseNodesCommand, DisconnectCommand, FoldEdgeCommand } from '../../core/commands/graphCommands';
|
||
import { annotationNoun } from '../../core/commands/annotationCommands';
|
||
import type { AnnotationKind, GraphEdge, GraphNode } from '../../core/graph';
|
||
import { Menu, type MenuItem } from '../../ui';
|
||
import { removeGraphSelection, removeNodes } from '../actions';
|
||
import type { EditorState } from '../state';
|
||
|
||
/** For the selected nodes; a selection mixing both states offers both directions. */
|
||
export function openNodeMenu(
|
||
state: EditorState,
|
||
assetId: string,
|
||
nodes: readonly GraphNode[],
|
||
x: number,
|
||
y: number,
|
||
extras: readonly MenuItem[] = [],
|
||
): void {
|
||
const ids = state.selectedNodeIds;
|
||
const fold = (list: readonly GraphNode[], verb: string, to: boolean): MenuItem[] => {
|
||
if (list.length === 0) return [];
|
||
const listIds = list.map((n) => n.id);
|
||
const label = list.length > 1 ? `${verb} ${list.length} nodes` : `${verb} node`;
|
||
return [{ label, onSelect: () => state.execute(new CollapseNodesCommand(assetId, listIds, to)) }];
|
||
};
|
||
const expanded = nodes.filter((n) => !n.collapsed);
|
||
const collapsed = nodes.filter((n) => n.collapsed);
|
||
new Menu([
|
||
...extras,
|
||
...fold(expanded, 'Collapse', true),
|
||
...fold(collapsed, 'Expand', false),
|
||
{
|
||
label: ids.length > 1 ? `Delete ${ids.length} nodes` : 'Delete node',
|
||
danger: true,
|
||
onSelect: () => removeNodes(state, assetId, ids),
|
||
},
|
||
]).open(x, y);
|
||
}
|
||
|
||
/**
|
||
* For one group or comment. Only deleting lives here: the title, the text and the colour are
|
||
* edited in the inspector, where every other selected thing is edited too.
|
||
*/
|
||
export function openAnnotationMenu(
|
||
state: EditorState,
|
||
assetId: string,
|
||
kind: AnnotationKind,
|
||
id: string,
|
||
x: number,
|
||
y: number,
|
||
): void {
|
||
// Right-clicking a box keeps whatever else is selected, so the entry speaks about all of it –
|
||
// a menu that silently deleted one of five marked boxes would be the worse surprise.
|
||
const held = state.selectedAnnotationIds.includes(id);
|
||
const boxes = held ? state.selectedAnnotationIds : [id];
|
||
const nodes = held ? state.selectedNodeIds : [];
|
||
const count = boxes.length + nodes.length;
|
||
new Menu([
|
||
{
|
||
label: count > 1 ? `Delete ${count} items` : `Delete ${annotationNoun(kind)}`,
|
||
danger: true,
|
||
onSelect: () => removeGraphSelection(state, assetId, nodes, boxes),
|
||
},
|
||
]).open(x, y);
|
||
}
|
||
|
||
/**
|
||
* The links at one port, or behind one fold mark – the mark is the only handle a hidden link has,
|
||
* so it carries the way back. Every entry is one undo step, however many links it covers.
|
||
*/
|
||
export function openLinkMenu(
|
||
state: EditorState,
|
||
assetId: string,
|
||
edges: readonly GraphEdge[],
|
||
x: number,
|
||
y: number,
|
||
): void {
|
||
if (edges.length === 0) return;
|
||
const item = (
|
||
verb: string,
|
||
single: string,
|
||
list: readonly GraphEdge[],
|
||
make: (edgeId: string) => Command,
|
||
danger = false,
|
||
): MenuItem[] => {
|
||
if (list.length === 0) return [];
|
||
const label = list.length === 1 ? single : `${verb} ${list.length} links`;
|
||
const commands = () => list.map((e) => make(e.id));
|
||
return [{ label, danger, onSelect: () => state.execute(oneStep(`${verb} links`, commands())) }];
|
||
};
|
||
const shown = edges.filter((e) => !e.folded);
|
||
const hidden = edges.filter((e) => e.folded);
|
||
new Menu([
|
||
...item('Hide', 'Hide link', shown, (id) => new FoldEdgeCommand(assetId, id, true)),
|
||
...item('Show', 'Show link', hidden, (id) => new FoldEdgeCommand(assetId, id, false)),
|
||
...item('Disconnect', 'Disconnect', edges, (id) => new DisconnectCommand(assetId, id), true),
|
||
]).open(x, y);
|
||
}
|