diff --git a/README.md b/README.md index 2afd5961..0ba7befd 100644 --- a/README.md +++ b/README.md @@ -88,8 +88,14 @@ shown on the node and editable in the inspector. `On key down (Space)` sets `vy = −jumpSpeed` when `onGround`. - **Editing**: right-click the canvas for the searchable palette, drag port → port to connect (types are checked; a data input holds one link and is replaced), drag nodes, right-click a - node or port to delete/disconnect, `Delete` removes the selected node, wheel zooms, dragging - the empty canvas pans. Everything is undoable. + node or port to delete/disconnect, `Delete` removes the selection, wheel zooms. Everything is + undoable. +- **Mouse and selection work as in the scene view.** The **middle button pans** from anywhere, + a node under the pointer included (shift-drag on empty canvas does too); the left button never + pans. Dragging on empty canvas pulls a **rubber band** instead, which selects every node it + touches – nodes are large rectangles, so touching counts, unlike the scene view's test of an + object's position. Ctrl/Shift-click adds or removes single nodes, dragging any node of a + selection moves all of them, and `Delete` removes all of them – one undo step either way. - **Dragging an edge tells you where it would land** before you let go: the port it would connect to wears a white ring and the line snaps to it. Dropping on a node's *body* rather than a port takes that node's first free compatible port – or, when none is free, the last compatible one, @@ -100,7 +106,8 @@ shown on the node and editable in the inspector. still visible while its node is chosen. - **Header bar** (the same one the Texture and Container tabs have): the behavior picker, **Add node…** (the palette, dropping the node in the middle of the view – the right-click nobody - finds), **Fit**, and the size of the graph as "N nodes · M links". + finds), **Fit** (which zooms out far enough to hold every node, measuring each by its own drawn + height – `editor/graph/framing.ts`), and the size of the graph as "N nodes · M links". - **Selection-following, not selection-bound**: selecting a behavior asset, a placed behavior or one of its nodes shows that graph; a selection about anything else (a sprite, nothing at all) leaves the tab on the behavior you were editing, because checking a sprite's position @@ -433,7 +440,7 @@ npm run format # Prettier over src, tests and tools npm run check # typecheck + lint + format:check + tests, what CI would run npm run build # writes dist/ and regenerates index.html export GM_REMOTE=user@host # where the browser runs; see below -node tools/seed-e2e.mjs && node tools/e2e.mjs # 299 editor checks, screenshots in /tmp/gm-e2e-*.png +node tools/seed-e2e.mjs && node tools/e2e.mjs # 312 editor checks, screenshots in /tmp/gm-e2e-*.png node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end ``` diff --git a/src/editor/actions.ts b/src/editor/actions.ts index d282387c..23467548 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -4,7 +4,9 @@ */ import { DeleteAssetCommand } from '../core/commands/assetCommands'; import { AddColliderCommand } from '../core/commands/colliderCommands'; +import { CompoundCommand } from '../core/commands/CompoundCommand'; import { CreateContainerCommand } from '../core/commands/containerCommands'; +import { RemoveNodeCommand } from '../core/commands/graphCommands'; import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands'; import { findAsset, findPropertyUsages, findUsages } from '../core/model/Project'; import { textureRefIds } from '../core/model/texture'; @@ -32,6 +34,14 @@ export function removeObjects(state: EditorState, holderId: string, objectIds: r return state.execute(new RemoveSceneObjectsCommand(holderId, objectIds)); } +/** Deletes graph nodes as one undo step, however many were selected. */ +export function removeNodes(state: EditorState, assetId: string, nodeIds: readonly string[]): boolean { + const commands = nodeIds.map((id) => new RemoveNodeCommand(assetId, id)); + if (commands.length === 0) return false; + if (commands.length === 1) return state.execute(commands[0]!); + return state.execute(new CompoundCommand(`Delete ${commands.length} nodes`, commands)); +} + /** Asks before deleting an asset, spelling out what goes with it. */ export function confirmDeleteAsset(state: EditorState, assetId: string): void { const asset = findAsset(state.project, assetId); diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 7331063b..ee098a40 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -155,6 +155,10 @@ export function installDebugHook(app: Application, state: EditorState, editor: E portAt: (nodeId: string, port: string, isOutput: boolean) => graphEditor.canvas.portGlobal(nodeId, port, isOutput), nodeAt: (nodeId: string) => graphEditor.canvas.nodeGlobal(nodeId), + /** Drawn rectangles on screen: what "fit" has to keep inside `canvasRect`. */ + nodeRect: (nodeId: string) => graphEditor.canvas.nodeRect(nodeId), + canvasRect: () => graphEditor.canvas.canvasRect(), + selected: () => state.selectedNodeIds, dropTarget: () => graphEditor.canvas.pendingDropTarget(), danglingEdge: () => graphEditor.canvas.danglingEdgeVisible(), // The canvas, not the whole tab: a centre measured through the header sits above the diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index 1fc18c3b..d7d86ae3 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -6,7 +6,6 @@ import { ConnectCommand, DisconnectCommand, MoveNodeCommand, - RemoveNodeCommand, } from '../../core/commands/graphCommands'; import { canConnect, @@ -16,6 +15,7 @@ import { edgesOutOf, firstPortFor, type GraphEdge, + type GraphNode, type NodeDef, nodeDef, type PortSide, @@ -27,7 +27,9 @@ import type { BehaviorAsset } from '../../core/model/Asset'; import { findAsset } from '../../core/model/Project'; import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem'; import { clamp, drawGrid, Menu, type Size, Theme, textStyle, Widget, zoomFactor } from '../../ui'; -import type { EditorState } from '../state'; +import { removeNodes } from '../actions'; +import { type EditorState, nodeSelection } from '../state'; +import { type Box, boxesInBand, frameBoxes } from './framing'; import { NodePalette } from './NodePalette'; import { NODE_WIDTH, NodeView, portColor, TITLE_HEIGHT } from './NodeView'; @@ -36,8 +38,12 @@ const GRID_STEP = 24; const MIN_GRID_STEP = 8; const MIN_ZOOM = 0.25; const MAX_ZOOM = 2.5; -/** Stand-in node height for centring the view – 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; +/** Breathing room around the framed graph, so nodes do not touch the edge. */ +const FIT_MARGIN = 24; +/** Pointer travel (screen pixels) before a press turns into a drag rather than a click. */ +const DRAG_THRESHOLD = 3; /** * The node graph of one behavior asset. Which behavior that is comes from the outside (`show`), @@ -51,6 +57,8 @@ export class GraphCanvas extends Widget { private readonly edges = new Graphics(); private readonly nodeLayer = new Container(); private readonly tempEdge = new Graphics(); + /** Drawn in screen space, so the band keeps a hairline border at any zoom. */ + private readonly band = new Graphics(); private readonly hint = new Text({ text: '', style: textStyle('muted') }); private readonly views = new Map(); private assetId: string | null = null; @@ -58,11 +66,16 @@ export class GraphCanvas extends Widget { private zoom = 1; private panning: { startX: number; startY: number; panX: number; panY: number } | null = null; private dragging: { - nodeId: string; - start: { x: number; y: number }; + items: { nodeId: string; start: { x: number; y: number } }[]; pointer: { x: number; y: number }; moved: boolean; } | null = null; + /** A rubber-band selection in progress, in graph coordinates. */ + private marquee: { + start: { x: number; y: number }; + current: { x: number; y: number }; + active: boolean; + } | 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. */ @@ -80,7 +93,7 @@ export class GraphCanvas extends Widget { constructor(private readonly state: EditorState) { super(); this.canvas.addChild(this.edges, this.nodeLayer, this.tempEdge); - this.addChild(this.bg, this.grid, this.canvas, this.hint, this.clip); + this.addChild(this.bg, this.grid, this.canvas, this.band, this.hint, this.clip); this.canvas.mask = this.clip; this.cursor = 'default'; @@ -178,7 +191,7 @@ export class GraphCanvas extends Widget { this.redraw(); } - /** Centres the graph's nodes (or the origin) in the view. Waits for a real size. */ + /** Frames the graph's nodes (or the origin) in the view. Waits for a real size. */ private fitView(): void { if (this.w === 0 || this.h === 0) return; this.needsFit = false; @@ -186,17 +199,32 @@ export class GraphCanvas extends Widget { if (!asset || asset.graph.nodes.length === 0) { this.pan = { x: this.w / 2 - NODE_WIDTH / 2, y: this.h / 3 }; this.zoom = 1; - } else { - const xs = asset.graph.nodes.map((n) => n.x); - const ys = asset.graph.nodes.map((n) => n.y); - const cx = (Math.min(...xs) + Math.max(...xs) + NODE_WIDTH) / 2; - const cy = (Math.min(...ys) + Math.max(...ys) + NODE_HEIGHT_ESTIMATE) / 2; - this.zoom = 1; - this.pan = { x: this.w / 2 - cx, y: this.h / 2 - cy }; + this.applyView(); + return; } + const framing = frameBoxes(this.nodeBoxes(), { w: this.w, h: this.h }, FIT_MARGIN, { + min: MIN_ZOOM, + max: MAX_ZOOM, + }); + this.zoom = framing.zoom; + this.pan = { x: framing.panX, y: framing.panY }; this.applyView(); } + /** Each node as the rectangle it is actually drawn as (a view knows its own height). */ + private nodeBoxes(): Box[] { + return (this.asset()?.graph.nodes ?? []).map((node) => { + const view = this.views.get(node.id); + return { + id: node.id, + x: node.x, + y: node.y, + w: view?.w ?? NODE_WIDTH, + h: view?.h ?? NODE_HEIGHT_ESTIMATE, + }; + }); + } + private applyView(): void { this.canvas.position.set(Math.round(this.pan.x), Math.round(this.pan.y)); this.canvas.scale.set(this.zoom); @@ -207,24 +235,42 @@ export class GraphCanvas extends Widget { return { x: (sx - this.pan.x) / this.zoom, y: (sy - this.pan.y) / this.zoom }; } + private graphToScreen(gx: number, gy: number): { x: number; y: number } { + return { x: gx * this.zoom + this.pan.x, y: gy * this.zoom + this.pan.y }; + } + // --- pointer ----------------------------------------------------------------------------- private onCanvasPointerDown(e: FederatedPointerEvent): void { + // The middle button pans from anywhere, a node under the pointer included – it is the one + // gesture that never means "grab this". Nodes let it through for exactly that reason. + if (e.button === 1) { + this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y }; + e.stopPropagation(); + return; + } if (e.target !== this) return; + // Shift-drag on empty space pans as well, as it does in the scene view. + if (e.button === 0 && e.shiftKey) { + this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y }; + return; + } const local = this.toLocal(e.global); if (e.button === 2) { if (this.assetId && this.state.mode === 'edit') this.openPalette(e.global.x, e.global.y, this.screenToGraph(local.x, local.y)); return; } - if (e.button === 0 || e.button === 1) { - if (e.button === 0 && this.assetId && this.state.mode === 'edit') - this.state.select({ kind: 'asset', assetId: this.assetId }); - this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y }; + if (e.button === 0) { + // Empty space: a click selects the behavior, a drag becomes a rubber band – decided on release. + const g = this.screenToGraph(local.x, local.y); + this.marquee = { start: g, current: g, active: false }; } } private onNodePointerDown(view: NodeView, e: FederatedPointerEvent): void { + // Panning takes precedence over the node: let the middle button bubble up to the canvas. + if (e.button === 1) return; if (!this.assetId || this.state.mode !== 'edit') return; e.stopPropagation(); const local = view.toLocal(e.global); @@ -245,17 +291,29 @@ export class GraphCanvas extends Widget { this.redraw(); return; } - this.state.select({ kind: 'node', assetId: this.assetId, nodeId: view.node.id }); + if (e.ctrlKey || e.metaKey || e.shiftKey) { + this.state.toggleNode(this.assetId, view.node.id); + return; + } + // Clicking a selected node keeps the selection so the whole group can be dragged. + if (!this.state.selectedNodeIds.includes(view.node.id)) + this.state.select(nodeSelection(this.assetId, [view.node.id])); const p = this.toLocal(e.global); this.dragging = { - nodeId: view.node.id, - start: { x: view.node.x, y: view.node.y }, + items: this.selectedNodes().map((n) => ({ nodeId: n.id, start: { x: n.x, y: n.y } })), pointer: this.screenToGraph(p.x, p.y), moved: false, }; this.nodeLayer.addChild(view); // raise } + private selectedNodes(): GraphNode[] { + const graph = this.asset()?.graph; + if (!graph) return []; + const ids = new Set(this.state.selectedNodeIds); + return graph.nodes.filter((n) => ids.has(n.id)); + } + private onPointerMove(e: FederatedPointerEvent): void { if (this.panning) { this.pan.x = this.panning.panX + (e.global.x - this.panning.startX); @@ -268,33 +326,54 @@ export class GraphCanvas extends Widget { 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 < 3) return; + if (!this.dragging.moved && Math.hypot(dx, dy) * this.zoom < DRAG_THRESHOLD) return; this.dragging.moved = true; - const node = this.asset()?.graph.nodes.find((n) => n.id === this.dragging?.nodeId); - if (!node) return; - node.x = Math.round(this.dragging.start.x + dx); - node.y = Math.round(this.dragging.start.y + dy); + const graph = this.asset()?.graph; + if (!graph) return; + for (const item of this.dragging.items) { + const node = graph.nodes.find((n) => n.id === item.nodeId); + if (!node) continue; + node.x = Math.round(item.start.x + dx); + node.y = Math.round(item.start.y + dy); + } 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.marquee) { + this.marquee.current = g; + const d = Math.hypot(g.x - this.marquee.start.x, g.y - this.marquee.start.y) * this.zoom; + if (!this.marquee.active && d < DRAG_THRESHOLD) return; + this.marquee.active = true; + this.redraw(); } } private onPointerUp(e: FederatedPointerEvent): void { this.panning = null; + const marquee = this.marquee; + this.marquee = null; + if (marquee && this.assetId && this.state.mode === 'edit') { + if (marquee.active) this.selectInMarquee(marquee); + else this.state.select({ kind: 'asset', assetId: this.assetId }); + } const drag = this.dragging; this.dragging = null; - if (drag && this.assetId) { - const node = this.asset()?.graph.nodes.find((n) => n.id === drag.nodeId); - if (node && drag.moved) { + if (drag?.moved && this.assetId) { + const graph = this.asset()?.graph; + const moves: MoveNodeCommand[] = []; + for (const item of drag.items) { + const node = graph?.nodes.find((n) => n.id === item.nodeId); + if (!node) continue; // The preview moved the node already; put it back so the command records the start. const next = { x: node.x, y: node.y }; - node.x = drag.start.x; - node.y = drag.start.y; - this.state.execute(new MoveNodeCommand(this.assetId, drag.nodeId, next.x, next.y)); + node.x = item.start.x; + node.y = item.start.y; + moves.push(new MoveNodeCommand(this.assetId, item.nodeId, next.x, next.y)); } + if (moves.length === 1) this.state.execute(moves[0]!); + else if (moves.length > 1) this.state.execute(new CompoundCommand('Move nodes', moves)); } const connecting = this.connecting; const target = connecting ? (this.dropTarget ?? this.resolveDrop(e.global.x, e.global.y)) : null; @@ -308,6 +387,21 @@ export class GraphCanvas extends Widget { this.redraw(); } + private selectInMarquee(marquee: { + start: { x: number; y: number }; + current: { x: number; y: number }; + }): void { + if (!this.assetId) return; + const ids = boxesInBand(this.nodeBoxes(), { + x1: marquee.start.x, + y1: marquee.start.y, + x2: marquee.current.x, + y2: marquee.current.y, + }); + // An empty band clears the nodes but keeps the behavior selected, as a plain click does. + this.state.select(nodeSelection(this.assetId, ids) ?? { kind: 'asset', assetId: this.assetId }); + } + private connect(from: PortSide, to: PortSide): void { if (!this.assetId) return; const [out, inp] = from.isOutput ? [from, to] : [to, from]; @@ -395,8 +489,7 @@ export class GraphCanvas extends Widget { private addNode(def: NodeDef, at: { x: number; y: number }): void { if (!this.assetId) return; const command = new AddNodeCommand(this.assetId, def.type, Math.round(at.x), Math.round(at.y)); - if (this.state.execute(command)) - this.state.select({ kind: 'node', assetId: this.assetId, nodeId: 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. */ @@ -421,18 +514,21 @@ export class GraphCanvas extends Widget { ), ); if (this.state.execute(new CompoundCommand(add.label, commands))) - this.state.select({ kind: 'node', assetId: this.assetId, nodeId: add.node.id }); + this.state.select(nodeSelection(this.assetId, [add.node.id])); } private openNodeMenu(view: NodeView, globalX: number, globalY: number): void { const assetId = this.assetId; if (!assetId) return; - this.state.select({ kind: 'node', assetId, nodeId: view.node.id }); + // Right-clicking inside a multi-selection acts on all of it; outside it, on that node alone. + if (!this.state.selectedNodeIds.includes(view.node.id)) + this.state.select(nodeSelection(assetId, [view.node.id])); + const ids = this.state.selectedNodeIds; new Menu([ { - label: 'Delete node', + label: ids.length > 1 ? `Delete ${ids.length} nodes` : 'Delete node', danger: true, - onSelect: () => this.state.execute(new RemoveNodeCommand(assetId, view.node.id)), + onSelect: () => removeNodes(this.state, assetId, ids), }, ]).open(globalX, globalY); } @@ -488,10 +584,10 @@ export class GraphCanvas extends Widget { const t = map?.get(id); return t !== undefined && now - t <= TRACE_FADE; }; - const selectedNode = this.state.selection?.kind === 'node' ? this.state.selection.nodeId : null; + const selectedNodes = new Set(this.state.selectedNodeIds); const target = this.dropTarget; for (const view of this.views.values()) { - view.selected = view.node.id === selectedNode; + view.selected = selectedNodes.has(view.node.id); view.hot = isHot(trace?.nodes, view.node.id); view.targetPort = target && target.nodeId === view.node.id ? { port: target.port, isOutput: target.isOutput } : null; @@ -517,6 +613,17 @@ export class GraphCanvas extends Widget { this.tempEdgeShown = true; } } + + this.band.clear(); + const m = this.marquee; + if (m?.active) { + const a = this.graphToScreen(m.start.x, m.start.y); + const b = this.graphToScreen(m.current.x, m.current.y); + this.band + .rect(Math.min(a.x, b.x), Math.min(a.y, b.y), Math.abs(b.x - a.x), Math.abs(b.y - a.y)) + .fill({ color: c.marquee, alpha: 0.12 }) + .stroke({ width: 1, color: c.marquee }); + } } /** A port's centre in graph coordinates. */ @@ -580,6 +687,31 @@ export class GraphCanvas extends Widget { return this.tempEdgeShown; } + /** A node's drawn rectangle on screen, and the canvas' own – "fit" is a claim about both. */ + nodeRect(nodeId: string): { x: number; y: number; w: number; h: number } | null { + const view = this.views.get(nodeId); + if (!view) return null; + const topLeft = view.toGlobal({ x: 0, y: 0 }); + const bottomRight = view.toGlobal({ x: view.w, y: view.h }); + return { + x: topLeft.x, + y: topLeft.y, + w: bottomRight.x - topLeft.x, + h: bottomRight.y - topLeft.y, + }; + } + + canvasRect(): { x: number; y: number; w: number; h: number } { + const topLeft = this.toGlobal({ x: 0, y: 0 }); + const bottomRight = this.toGlobal({ x: this.w, y: this.h }); + return { + x: topLeft.x, + y: topLeft.y, + w: bottomRight.x - topLeft.x, + h: bottomRight.y - topLeft.y, + }; + } + /** Global screen position of a node's title bar centre. */ nodeGlobal(nodeId: string): { x: number; y: number } | null { const view = this.views.get(nodeId); diff --git a/src/editor/graph/framing.ts b/src/editor/graph/framing.ts new file mode 100644 index 00000000..1dc08ee4 --- /dev/null +++ b/src/editor/graph/framing.ts @@ -0,0 +1,73 @@ +/** + * Geometry of the graph view: which nodes a rubber band caught, and what view frames them all. + * Both are decisions about rectangles, so they live here rather than in the canvas – the "fit" + * that left nodes hanging out of the view was a sum that no test could reach. + */ + +export interface Box { + id: string; + x: number; + y: number; + w: number; + h: number; +} + +export interface Band { + x1: number; + y1: number; + x2: number; + y2: number; +} + +export interface Framing { + zoom: number; + panX: number; + panY: number; +} + +/** + * Ids of every box the band overlaps. Nodes are large rectangles, so – unlike the scene view, + * which tests an object's position – touching counts: a band drawn across a node that visibly + * caught it would otherwise leave it out. + */ +export function boxesInBand(boxes: readonly Box[], band: Band): string[] { + const minX = Math.min(band.x1, band.x2); + const maxX = Math.max(band.x1, band.x2); + const minY = Math.min(band.y1, band.y2); + const maxY = Math.max(band.y1, band.y2); + return boxes + .filter((b) => b.x <= maxX && b.x + b.w >= minX && b.y <= maxY && b.y + b.h >= minY) + .map((b) => b.id); +} + +/** + * The view that shows every box at once, centred, with `margin` screen pixels to spare. + * Never zooms past 1: a two-node graph blown up to fill the screen helps nobody. + */ +export function frameBoxes( + boxes: readonly Box[], + view: { w: number; h: number }, + margin: number, + limits: { min: number; max: number }, +): Framing { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const b of boxes) { + minX = Math.min(minX, b.x); + minY = Math.min(minY, b.y); + maxX = Math.max(maxX, b.x + b.w); + maxY = Math.max(maxY, b.y + b.h); + } + if (!Number.isFinite(minX)) return { zoom: 1, panX: view.w / 2, panY: view.h / 2 }; + const width = Math.max(maxX - minX, 1); + const height = Math.max(maxY - minY, 1); + const fitted = Math.min((view.w - margin * 2) / width, (view.h - margin * 2) / height, 1); + const zoom = Math.min(Math.max(fitted, limits.min), limits.max); + return { + zoom, + panX: view.w / 2 - ((minX + maxX) / 2) * zoom, + panY: view.h / 2 - ((minY + maxY) / 2) * zoom, + }; +} diff --git a/src/editor/shortcuts.ts b/src/editor/shortcuts.ts index dce1d4f3..698aaaa3 100644 --- a/src/editor/shortcuts.ts +++ b/src/editor/shortcuts.ts @@ -1,7 +1,6 @@ import { RemoveFrameCommand } from '../core/commands/frameCommands'; -import { RemoveNodeCommand } from '../core/commands/graphCommands'; import { ui } from '../ui'; -import { confirmDeleteAsset, createContainer, removeObjects } from './actions'; +import { confirmDeleteAsset, createContainer, removeNodes, removeObjects } from './actions'; import type { EditorState } from './state'; export interface ShortcutActions { @@ -38,7 +37,7 @@ export function installShortcuts(state: EditorState, actions: ShortcutActions): if (event.key === 'Delete' || event.key === 'Backspace') { const s = state.selection; if (s?.kind === 'object') removeObjects(state, s.holderId, s.objectIds); - else if (s?.kind === 'node') state.execute(new RemoveNodeCommand(s.assetId, s.nodeId)); + else if (s?.kind === 'node') removeNodes(state, s.assetId, s.nodeIds); else if (s?.kind === 'frame') state.execute(new RemoveFrameCommand(s.assetId, s.frameId)); else if (s?.kind === 'asset') confirmDeleteAsset(state, s.assetId); return s !== null; diff --git a/src/editor/state.ts b/src/editor/state.ts index f2363aa6..39436aca 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -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; } diff --git a/tests/editor/graph-framing.test.ts b/tests/editor/graph-framing.test.ts new file mode 100644 index 00000000..f30b5edd --- /dev/null +++ b/tests/editor/graph-framing.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import { type Box, boxesInBand, frameBoxes } from '../../src/editor/graph/framing'; + +const LIMITS = { min: 0.25, max: 2.5 }; + +/** Two nodes side by side, the right one twice as tall. */ +const boxes: Box[] = [ + { id: 'a', x: 0, y: 0, w: 100, h: 60 }, + { id: 'b', x: 200, y: 0, w: 100, h: 120 }, +]; + +describe('rubber band', () => { + it('catches a node the band merely touches, not only one it encloses', () => { + // The band clips a's right edge and never reaches b. + expect(boxesInBand(boxes, { x1: 90, y1: 10, x2: 150, y2: 20 })).toEqual(['a']); + }); + + it('catches both when the band spans them', () => { + expect(boxesInBand(boxes, { x1: -10, y1: -10, x2: 310, y2: 130 })).toEqual(['a', 'b']); + }); + + it('reads a band dragged up and to the left the same way', () => { + expect(boxesInBand(boxes, { x1: 310, y1: 130, x2: -10, y2: -10 })).toEqual(['a', 'b']); + }); + + it('catches nothing in empty space', () => { + expect(boxesInBand(boxes, { x1: 120, y1: 200, x2: 180, y2: 260 })).toEqual([]); + }); +}); + +describe('fit', () => { + /** Where a graph point lands on screen under a framing. */ + const toScreen = (f: { zoom: number; panX: number; panY: number }, x: number, y: number) => ({ + x: x * f.zoom + f.panX, + y: y * f.zoom + f.panY, + }); + + it('leaves no node outside the view – the reported bug', () => { + // Wider than the view at zoom 1, so framing has to zoom out rather than just centre. + const view = { w: 200, h: 200 }; + const f = frameBoxes(boxes, view, 24, LIMITS); + expect(f.zoom).toBeLessThan(1); + for (const b of boxes) { + const topLeft = toScreen(f, b.x, b.y); + const bottomRight = toScreen(f, b.x + b.w, b.y + b.h); + expect(topLeft.x).toBeGreaterThanOrEqual(0); + expect(topLeft.y).toBeGreaterThanOrEqual(0); + expect(bottomRight.x).toBeLessThanOrEqual(view.w); + expect(bottomRight.y).toBeLessThanOrEqual(view.h); + } + }); + + it('measures each node by its own height', () => { + // b is twice as tall as a: pretending both were 60 high would frame too little. + const tall = frameBoxes([{ id: 'b', x: 0, y: 0, w: 100, h: 400 }], { w: 200, h: 200 }, 24, LIMITS); + const short = frameBoxes([{ id: 'a', x: 0, y: 0, w: 100, h: 60 }], { w: 200, h: 200 }, 24, LIMITS); + expect(tall.zoom).toBeLessThan(short.zoom); + }); + + it('centres what it frames', () => { + const view = { w: 200, h: 200 }; + const f = frameBoxes(boxes, view, 24, LIMITS); + const centre = toScreen(f, 150, 60); + expect(centre.x).toBeCloseTo(100); + expect(centre.y).toBeCloseTo(100); + }); + + it('does not blow a small graph up past its natural size', () => { + const f = frameBoxes([{ id: 'a', x: 0, y: 0, w: 10, h: 10 }], { w: 800, h: 600 }, 24, LIMITS); + expect(f.zoom).toBe(1); + }); + + it('never zooms out past the limit, however far the nodes are apart', () => { + const far: Box[] = [ + { id: 'a', x: 0, y: 0, w: 100, h: 60 }, + { id: 'b', x: 100000, y: 0, w: 100, h: 60 }, + ]; + expect(frameBoxes(far, { w: 200, h: 200 }, 24, LIMITS).zoom).toBe(LIMITS.min); + }); + + it('centres the view on an empty graph instead of returning nonsense', () => { + const f = frameBoxes([], { w: 200, h: 100 }, 24, LIMITS); + expect(f).toEqual({ zoom: 1, panX: 100, panY: 50 }); + }); +}); diff --git a/tests/editor/node-selection.test.ts b/tests/editor/node-selection.test.ts new file mode 100644 index 00000000..19cf35d4 --- /dev/null +++ b/tests/editor/node-selection.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import { AddNodeCommand } from '../../src/core/commands/graphCommands'; +import type { BehaviorAsset } from '../../src/core/model/Asset'; +import { createEmptyProject } from '../../src/core/model/Project'; +import { removeNodes } from '../../src/editor/actions'; +import { EditorState, nodeSelection } from '../../src/editor/state'; + +function setup(): { state: EditorState; behavior: BehaviorAsset; ids: string[] } { + const state = new EditorState(); + state.loadProject(createEmptyProject('p', 'P'), 1); + const create = new CreateAssetCommand('behavior', 'B'); + // A rejected command only returns false, so an unasserted setup would leave an empty graph + // and let every assertion below pass for the wrong reason. + expect(state.execute(create)).toBe(true); + const behavior = create.asset as BehaviorAsset; + const ids = ['OnUpdate', 'Number', 'Number'].map((type) => { + const add = new AddNodeCommand(behavior.id, type, 0, 0); + expect(state.execute(add)).toBe(true); + return add.node.id; + }); + return { state, behavior, ids }; +} + +describe('selecting several graph nodes', () => { + let f: ReturnType; + beforeEach(() => { + f = setup(); + }); + + it('keeps the last picked one as the primary, which is what the inspector shows', () => { + f.state.select(nodeSelection(f.behavior.id, f.ids)); + expect(f.state.selectedNodeIds).toEqual(f.ids); + expect(f.state.selection).toMatchObject({ kind: 'node', nodeId: f.ids[2] }); + }); + + it('adds and removes a node on ctrl-click', () => { + f.state.select(nodeSelection(f.behavior.id, [f.ids[0]!])); + f.state.toggleNode(f.behavior.id, f.ids[1]!); + expect(f.state.selectedNodeIds).toEqual([f.ids[0], f.ids[1]]); + f.state.toggleNode(f.behavior.id, f.ids[0]!); + expect(f.state.selectedNodeIds).toEqual([f.ids[1]]); + }); + + it('falls back to the behavior when the last node is toggled off, so the graph stays open', () => { + f.state.select(nodeSelection(f.behavior.id, [f.ids[0]!])); + f.state.toggleNode(f.behavior.id, f.ids[0]!); + expect(f.state.selection).toEqual({ kind: 'asset', assetId: f.behavior.id }); + }); + + it('accepts the short form a test hook passes, filling in the list', () => { + f.state.select({ kind: 'node', assetId: f.behavior.id, nodeId: f.ids[0]! } as never); + expect(f.state.selectedNodeIds).toEqual([f.ids[0]]); + }); + + it('deletes the whole selection in one undo step', () => { + f.state.select(nodeSelection(f.behavior.id, [f.ids[0]!, f.ids[1]!])); + removeNodes(f.state, f.behavior.id, f.state.selectedNodeIds); + const graph = () => (f.state.project.assets.find((a) => a.id === f.behavior.id) as BehaviorAsset).graph; + expect(graph().nodes.map((n) => n.id)).toEqual([f.ids[2]]); + f.state.undo(); + expect(graph().nodes).toHaveLength(3); + }); + + it('drops deleted nodes from the selection but keeps the survivors', () => { + f.state.select(nodeSelection(f.behavior.id, f.ids)); + removeNodes(f.state, f.behavior.id, [f.ids[0]!]); + expect(f.state.selectedNodeIds).toEqual([f.ids[1], f.ids[2]]); + }); +}); diff --git a/todo-notes/done/graph-editor-multiselect-und-mausverhalten.md b/todo-notes/done/graph-editor-multiselect-und-mausverhalten.md new file mode 100644 index 00000000..1eee52ce --- /dev/null +++ b/todo-notes/done/graph-editor-multiselect-und-mausverhalten.md @@ -0,0 +1,29 @@ +1. panning per linksklick drag soll raus, dafür beim mittelklick (aber egal ob man ins leere klick oder sich über einem node befindet) = wie bei scene view +2. mehrere nodes gleichzeitig auswählbar um dadurch mehrere zu bewegen oder zu löschen. auch mit rect-auswahl selektierbar = auch wie bei scene view +3. was anderes: wenn ich in der graph view auf "fit" klicke, dann fittet es nicht wirklich (es befinden sich nodes halb außerhalb) + +--- + +**Umgesetzt (2026-09-09), alle drei Punkte.** + +1. **Panning nur noch mit der mittleren Taste** — und zwar von ueberall, auch wenn der Zeiger + ueber einem Node steht: der NodeView laesst den Mittelklick bewusst durchblubbern, statt ihn + wie jeden anderen Klick zu schlucken. Shift+Links auf leerer Flaeche pannt zusaetzlich, genau + wie im Scene-View. Die linke Taste pannt gar nicht mehr. +2. **Mehrfachauswahl**: Ziehen auf leerer Flaeche spannt ein Gummiband auf, das jeden **beruehrten** + Node auswaehlt; Ctrl/Shift-Klick nimmt einzelne dazu oder raus; das Ziehen eines Nodes der + Auswahl bewegt alle; `Delete` (und das Kontextmenue) loescht alle — jeweils als **ein** + Undo-Schritt (`CompoundCommand`). + Bewusster Unterschied zum Scene-View: dort entscheidet die *Position* eines Objekts, hier die + *Ueberschneidung* mit dem Rechteck. Nodes sind grosse Kaesten — ein Band, das sichtbar quer + durch einen Node geht, muss ihn fangen. +3. **Fit fittet wirklich.** Die alte Fassung setzte den Zoom hart auf `1` und *zentrierte* nur — + war der Graph groesser als die Ansicht, hingen Nodes danach zwangslaeufig heraus. Ausserdem + rechnete sie mit einer *geschaetzten* Node-Hoehe (80) fuer alle, obwohl echte Nodes je nach + Portzahl deutlich hoeher sind. Jetzt wird jeder Node mit seiner **gezeichneten** Groesse + gemessen und der Zoom so gewaehlt, dass alles hineinpasst (nie ueber 1 hinaus vergroessert). + +Die Geometrie dahinter liegt als reine Funktion in `src/editor/graph/framing.ts` +(`boxesInBand`, `frameBoxes`), nicht im Panel — genau diese Rechnung war der Bug aus Punkt 3 und +war vorher von keinem Test erreichbar. Die Mehrfachauswahl selbst sitzt im Selektionsmodell +(`nodeSelection`/`toggleNode`, symmetrisch zu den Szenenobjekten), nicht im Canvas. diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs index f5840159..ba0829e1 100644 --- a/tools/e2e/06-graph.mjs +++ b/tools/e2e/06-graph.mjs @@ -326,5 +326,142 @@ export async function graph(t) { 'graph editor rebinds when a behavior is selected again', (await evaluate(`__gm.graph.current()`)) === 'mover', ); + + // 13i. Mouse behaviour, as in the scene view: the middle button pans from anywhere – a node + // under it included – and the left button no longer pans at all. + await clickText('Fit'); + await sleep(250); + const positions = async () => + (await asset('mover')).graph.nodes.map((n) => `${n.id}:${n.x},${n.y}`).join(' '); + const area = await json('__gm.graph.canvasRect()'); + const before13i = await positions(); + const axisScreenBefore = await json(`__gm.graph.nodeAt('n-axis')`); + await drag(axisScreenBefore.x, axisScreenBefore.y, axisScreenBefore.x + 120, axisScreenBefore.y + 60, { + button: 'middle', + }); + const axisScreenPanned = await json(`__gm.graph.nodeAt('n-axis')`); + check( + 'the middle button pans even when it starts on a node', + Math.abs(axisScreenPanned.x - axisScreenBefore.x - 120) < 3 && + Math.abs(axisScreenPanned.y - axisScreenBefore.y - 60) < 3, + JSON.stringify({ axisScreenBefore, axisScreenPanned }), + ); + check( + 'panning over a node leaves the node where it was', + (await positions()) === before13i, + await positions(), + ); + + // An empty spot: after a fit every node sits at least a margin away from the corner. + const corner = { x: area.x + 6, y: area.y + 6 }; + const opposite = { x: area.x + area.w - 6, y: area.y + area.h - 6 }; + const axisBeforeBand = await json(`__gm.graph.nodeAt('n-axis')`); + await drag(corner.x, corner.y, corner.x + 60, corner.y + 40); + const axisAfterBand = await json(`__gm.graph.nodeAt('n-axis')`); + check( + 'dragging with the left button no longer pans', + Math.abs(axisAfterBand.x - axisBeforeBand.x) < 3 && Math.abs(axisAfterBand.y - axisBeforeBand.y) < 3, + JSON.stringify({ axisBeforeBand, axisAfterBand }), + ); + + // 13j. A rubber band across the whole canvas takes every node – the count comes from the model, + // so this cannot agree with the canvas by sharing its rule. + const nodeCount = (await asset('mover')).graph.nodes.length; + await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 }); + const banded = await json('__gm.graph.selected()'); + check( + 'a rubber band selects every node it spans', + banded.length === nodeCount, + `${banded.length} of ${nodeCount}`, + ); + await shot('21-graph-multiselect'); + + // 13k. The whole selection moves as one, and one undo takes all of it back. + const beforeMove = (await asset('mover')).graph.nodes.map((n) => ({ id: n.id, x: n.x, y: n.y })); + const grabAt = await json(`__gm.graph.nodeAt('n-axis')`); + await drag(grabAt.x, grabAt.y, grabAt.x + 40, grabAt.y + 30); + const afterMove = await asset('mover'); + const movedTogether = beforeMove.every((n) => { + const now = afterMove.graph.nodes.find((m) => m.id === n.id); + return now.x === n.x + 40 && now.y === n.y + 30; + }); + check('dragging one of them moves the whole selection', movedTogether, await positions()); + await undo(); + check('one undo takes the group move back', (await positions()) === before13i, await positions()); + + // 13l. Delete acts on the selection, not just the primary node. + await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 }); + const beforeDelete = await graphCounts(); + await press('Delete'); + await sleep(200); + const afterDelete = await graphCounts(); + check( + 'Delete removes every selected node, edges with them', + afterDelete.nodes === 0 && afterDelete.edges === 0, + JSON.stringify(afterDelete), + ); + await undo(); + const restoredAll = await graphCounts(); + check( + 'one undo brings the whole graph back', + restoredAll.nodes === beforeDelete.nodes && restoredAll.edges === beforeDelete.edges, + JSON.stringify(restoredAll), + ); + + // 13m. Ctrl-click adds and removes single nodes. Fit first, so every node is really on screen + // and a click cannot miss one that the earlier pan pushed over the edge. + await select({ kind: 'asset', assetId: 'mover' }); + await clickText('Fit'); + await sleep(250); + const axisRow = await json(`__gm.graph.nodeAt('n-axis')`); + await click(axisRow.x, axisRow.y); + const second = (await asset('mover')).graph.nodes.find((n) => n.id !== 'n-axis'); + const secondAt = await json(`__gm.graph.nodeAt(${JSON.stringify(second.id)})`); + await click(secondAt.x, secondAt.y, 'left', { ctrl: true }); + check( + 'ctrl-click adds a second node to the selection', + JSON.stringify(await json('__gm.graph.selected()')) === JSON.stringify(['n-axis', second.id]), + JSON.stringify(await json('__gm.graph.selected()')), + ); + await click(secondAt.x, secondAt.y, 'left', { ctrl: true }); + check( + 'ctrl-clicking it again removes it', + JSON.stringify(await json('__gm.graph.selected()')) === JSON.stringify(['n-axis']), + JSON.stringify(await json('__gm.graph.selected()')), + ); + + // 13n. Fit really fits: proven against a node parked far outside, and against the fact that the + // graph is then wider than the canvas – otherwise centring alone would pass this check. + await evaluate( + `(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x += 2500; __gm.state.preview(); })()`, + ); + await sleep(250); + await clickText('Fit'); + await sleep(300); + const rects = []; + for (const node of (await asset('mover')).graph.nodes) + rects.push(await json(`__gm.graph.nodeRect(${JSON.stringify(node.id)})`)); + const span = Math.max(...rects.map((r) => r.x + r.w)) - Math.min(...rects.map((r) => r.x)); + check( + 'the parked node makes the graph wider than the canvas, so fitting has to zoom out', + span > area.w * 0.5, + `span=${Math.round(span)} canvas=${Math.round(area.w)}`, + ); + const outside = rects.filter( + (r) => r.x < area.x || r.y < area.y || r.x + r.w > area.x + area.w || r.y + r.h > area.y + area.h, + ); + check( + 'Fit leaves no node hanging out of the view', + outside.length === 0, + `${outside.length} outside: ${JSON.stringify(outside.slice(0, 2))}`, + ); + await shot('22-graph-fitted'); + await evaluate( + `(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x -= 2500; __gm.state.preview(); })()`, + ); + await clickText('Fit'); + await sleep(250); + check('the parked node is back where it started', (await positions()) === before13i, await positions()); + await evaluate(`__gm.showTab('scene')`); } diff --git a/tools/lib/harness.mjs b/tools/lib/harness.mjs index 9de17b31..973d7f0b 100644 --- a/tools/lib/harness.mjs +++ b/tools/lib/harness.mjs @@ -174,16 +174,27 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) { const p = await locate(text); await click(p.x, p.y, button); }; - const drag = async (x1, y1, x2, y2, steps = 12) => { + /** CDP reports held buttons as a bitmask, which is not the button name it takes elsewhere. */ + const BUTTON_MASK = { left: 1, right: 2, middle: 4 }; + /** + * Drags with the left button unless told otherwise. The fifth argument is either a step count + * (its long-standing meaning) or `{ steps, button, modifiers }`. + */ + const drag = async (x1, y1, x2, y2, options = {}) => { + const { + steps = 12, + button = 'left', + modifiers = {}, + } = typeof options === 'number' ? { steps: options } : options; + const extra = { modifiers: modifierBits(modifiers) }; + const held = { ...extra, buttons: BUTTON_MASK[button] }; await mouse('mouseMoved', x1, y1); - await mouse('mousePressed', x1, y1); + await mouse('mousePressed', x1, y1, button, extra); for (let i = 1; i <= steps; i++) { - await mouse('mouseMoved', x1 + ((x2 - x1) * i) / steps, y1 + ((y2 - y1) * i) / steps, 'left', { - buttons: 1, - }); + await mouse('mouseMoved', x1 + ((x2 - x1) * i) / steps, y1 + ((y2 - y1) * i) / steps, button, held); await sleep(16); } - await mouse('mouseReleased', x2, y2); + await mouse('mouseReleased', x2, y2, button, extra); await sleep(150); };