Skip to content

fix(drag): pass the component to startDrag and let cancel() stop its operation - #447

Open
draedful wants to merge 1 commit into
mainfrom
fix/v1-start-drag-component-and-cancel
Open

draedful wants to merge 1 commit into
mainfrom
fix/v1-start-drag-component-and-cancel

Conversation

@draedful

@draedful draedful commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  1. DragService.startDrag() accepts DragOperationOptions.component but never passes it to dragListener, so the GraphLayer never captures events for it. GraphComponent.onDrag() passes component: this, so since DragService was introduced (feat: introduce DragSystem #198, 1.10.0) a component dragged with onDrag() no longer keeps the pointer: hover moves to the components under the cursor, and the mouseup is dispatched to whatever is under it.
  2. DragService.cancel() cancels only a component drag. An operation started with startDrag() (ConnectionLayer, NewBlockLayer, PortConnectionLayer, SelectionLayer, the minimap, GraphComponent.onDrag()) could only be stopped with stopDragListening(), which the package does not export, so there was no public way to cancel it, e.g. on Escape.

Fix

  • startDrag() passes component to dragListener. The component is captured with GraphLayer.captureEvents() when the drag starts and released when it ends or is cancelled (cancel(), window blur, hidden page, a new press).
  • DragService keeps the current startDrag() operation and forgets it once the operation ends or is cancelled. cancel() cancels the current drag of either kind: the component drag and the operation. A participant that throws does not leave the other one running.
  • JSDoc of DragService, cancel(), startDrag(), DragOperationOptions.component, DragOperationCallbacks.onCancel, the cancel events of ConnectionLayer and NewBlockLayer, and docs/system/drag-system.md are updated. They no longer point to stopDragListening().

Behaviour change

  • A component dragged with GraphComponent.onDrag(), or passed as component to startDrag(), keeps the pointer capture again while the drag runs: other components get no mouseenter/mouseleave, and the mouseup goes to the dragged component. This is the behaviour before 1.10.0 and what docs/components/canvas-graph-component.md describes. Built-in layers do not pass component and are not affected.
  • graph.dragService.cancel() also cancels a startDrag() operation. The operation gets onCancel, or onEnd when it has no onCancel (as on window blur), with the last event and coordinates; the cursor, auto-panning and the captured component are released, and the rest of the gesture is ignored. For the built-in layers this fires connection-create-cancel, block-add-cancel-from-shadow and port-connection-cancel, drops the selection rectangle and stops the minimap drag; GraphComponent.onDrag() gets onDrop. An operation that has not crossed the drag threshold is dropped without callbacks, as a component drag is. A repeated cancel() does nothing.
  • The return value of startDrag() and the existing ways to cancel (blur, visibilitychange, a new press) are unchanged.

Tests

  • Unit, src/services/drag/DragService.test.ts:
    • the component is captured once the drag starts (not before the threshold) and released on mouseup, on cancel(), blur, a hidden page and a new press, including through GraphComponent.onDrag();
    • cancel() during an operation calls onCancel, or onEnd without it, once with the last event and coordinates, and ignores the rest of the gesture;
    • cancel() before the threshold drops the operation silently;
    • a repeated cancel(), or one after the operation ended, does nothing;
    • a component drag and an operation running together are both cancelled, also when a participant throws.
    • The 12 new tests fail on main.
  • E2E, e2e/tests/drag/start-drag-operation.spec.ts:
    • hover stays on the captured block while it is dragged over another block, and follows the cursor after the release;
    • Escape bound to graph.dragService.cancel() cancels a ConnectionLayer drag and no connection is created.
    • Both fail on main.
  • npm run lint, npm run typecheck, all unit tests and the Playwright e2e suite pass.

🤖 Generated with Claude Code

Summary by Sourcery

Restore component pointer capture during drags and make DragService.cancel() stop both component drags and custom drag operations.

New Features:

  • Allow DragService.cancel() to cancel custom drag operations started with startDrag(), including consistent lifecycle callbacks and cleanup.
  • Support passing a component to startDrag() so its pointer events remain captured throughout the drag.

Bug Fixes:

  • Restore pointer capture for components dragged through GraphComponent.onDrag() and component-aware startDrag() operations, preventing hover and mouseup events from moving to underlying components.
  • Ensure simultaneous component and custom drag operations are both cancelled even when one cancellation participant throws.

Enhancements:

  • Track active custom drag operations and release their cursor, autopanning, and event-capture state when they end or are cancelled.

Documentation:

  • Update drag-system and layer event documentation to describe public cancellation through graph.dragService.cancel() and component event capture.

Tests:

  • Add unit and end-to-end coverage for component capture, cancellation behavior, cleanup, error handling, and cancelled connection drags.

…operation

DragService.startDrag accepted a `component` option but never passed it to
dragListener, so the GraphLayer never captured events for it. GraphComponent.onDrag
passes `component: this`, so components dragged with onDrag lost the capture they had
before DragService was introduced (#198): hover moved to the components under the
cursor and the mouseup was dispatched to whatever was under it. startDrag now passes
the component on: it is captured when the drag starts and released when the drag ends
or is cancelled.

DragService.cancel() only cancelled a component drag. An operation started with
startDrag() could only be stopped through stopDragListening(), which the package does
not export. cancel() now cancels the current drag of either kind: the operation gets
onCancel (or onEnd when onCancel is not provided) with the last event and coordinates,
the cursor, auto-panning and the captured component are released, and the rest of the
gesture is ignored. An operation that has not crossed the drag threshold is dropped
without callbacks, as a component drag is. DragService forgets the operation once it
ends or is cancelled, so a repeated cancel() does nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@draedful
draedful requested a review from Antamansid as a code owner November 5, 2026 16:24
@sourcery-ai

sourcery-ai Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

The PR fixes custom drag operations by forwarding component capture into the drag listener and by tracking active startDrag() operations so the public DragService.cancel() API can terminate either drag type, clean up capture/cursor/autopanning state, and remain robust when cancellation participants throw. Documentation and unit/E2E regression coverage are updated accordingly.

File-Level Changes

Change Details Files
Preserve pointer capture for components involved in custom drag operations.
  • Forward DragOperationOptions.component into the drag listener.
  • Capture the component only after the drag threshold and release it on completion or every cancellation path.
  • Ensure GraphComponent.onDrag() benefits from the restored capture behavior.
src/services/drag/DragService.ts
src/services/drag/types.ts
src/services/drag/DragService.test.ts
e2e/tests/drag/start-drag-operation.spec.ts
docs/system/drag-system.md
Make DragService.cancel() cancel both component drags and startDrag() operations safely.
  • Track the active operation emitter and clear it when the operation ends or is cancelled.
  • Invoke operation cancellation callbacks with the last event and coordinates, silently discard pre-threshold operations, and ignore subsequent gesture events.
  • Cancel both participants even when one cancellation callback throws.
src/services/drag/DragService.ts
src/services/drag/DragService.test.ts
e2e/tests/drag/start-drag-operation.spec.ts
Update public documentation and layer cancellation event documentation for the new cancellation API.
  • Document graph.dragService.cancel() as the public cancellation mechanism for custom operations.
  • Document component capture semantics and the updated onCancel behavior.
  • Add DragService.cancel() to cancellation conditions for connection, new-block, and port-connection layers.
docs/system/drag-system.md
src/components/canvas/layers/connectionLayer/ConnectionLayer.md
src/components/canvas/layers/connectionLayer/ConnectionLayer.ts
src/components/canvas/layers/newBlockLayer/NewBlockLayer.md
src/components/canvas/layers/newBlockLayer/NewBlockLayer.ts
src/components/canvas/layers/portConnectionLayer/PortConnectionLayer.ts
Add regression coverage for capture, cancellation lifecycle, error handling, and built-in layer behavior.
  • Test capture timing and release across mouseup, explicit cancel, blur, hidden pages, new presses, and GraphComponent.onDrag().
  • Test callback selection, last-event coordinates, threshold handling, repeated cancellation, and gesture suppression.
  • Add an end-to-end hover-capture test and Escape cancellation test for connection creation.
src/services/drag/DragService.test.ts
e2e/tests/drag/start-drag-operation.spec.ts

Possibly linked issues

  • #unknown: The PR implements the issue’s core fix: DragService.cancel() now ends component drags, and blur or hidden-page events cancel drags and release resources.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@gravity-ui-bot

Copy link
Copy Markdown
Contributor

Preview is ready.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 4 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="src/services/drag/DragService.ts" line_range="424" />
<code_context>
     const {
       document: doc,
       cursor,
+      component,
       autopanning = true,
       stopOnMouseLeave,
</code_context>
<issue_to_address>
**Drag survives graph unmount**

When the graph unmounts while a `startDrag()` operation is active, `DragService.destroy()` does not cancel `currentOperationEmitter`, so its document listeners and callbacks remain active; when the operation captured a component, `GraphLayer` also retains that target. Later pointer events still reach the unmounted graph.

Cancel the active operation in `destroy()` before completing cleanup.

Also at `src/services/drag/DragService.ts:436`.
</issue_to_address>

### Comment 2
<location path="src/services/drag/DragService.ts" line_range="436" />
<code_context>
+      }
+    };
+
+    this.currentOperationEmitter = emitter;
+    return emitter
       .on(EVENTS.DRAG_START, (event: MouseEvent) => {
</code_context>
<issue_to_address>
**Cancelled drags keep running**

When a second `startDrag()` begins before the first operation ends, `startDrag()` overwrites `currentOperationEmitter` without stopping the earlier emitter, so cancellation stops only the newer operation and the earlier callbacks can still run through mouseup. For component-backed operations, the later operation also replaces the graph layer’s capture, and either cleanup can release capture while the other operation is still running.

Stop the currently tracked operation before replacing it with the new emitter.

Also at `src/services/drag/DragService.ts:424`.
</issue_to_address>

### Comment 3
<location path="src/services/drag/DragService.ts" line_range="424" />
<code_context>
     const {
       document: doc,
       cursor,
+      component,
       autopanning = true,
       stopOnMouseLeave,
</code_context>
<issue_to_address>
**Capture survives a throwing start callback**

When a component-backed operation’s `onStart` callback throws, `dragListener` captures the component before emitting `DRAG_START`; if the operation’s `onStart` callback throws, setup exits before the move listener is installed and before drag cleanup runs. The component remains captured and drag resources stay active after the exception.

Guard drag-start setup so an exception from a start callback releases capture and other drag resources before propagating.
</issue_to_address>

### Comment 4
<location path="src/services/drag/DragService.test.ts" line_range="319-320" />
<code_context>
+
+    expect(callbacks.onCancel).toHaveBeenCalledTimes(1);
+    expect(callbacks.onCancel).toHaveBeenCalledWith(lastMove, world(graph, 20, 30));
+    expect(graph.getCursorLayer().isAuto()).toBe(true);
+    expect(graph.cameraService.getCameraState().autoPanningEnabled).toBe(false);
+
+    const updatesBeforeCancel = callbacks.onUpdate.mock.calls.length;
</code_context>
<issue_to_address>
**Default states mask cleanup regressions**

When `startDrag()` fails to activate the cursor or auto-panning before cancellation, in `calls onCancel once with the last event and coordinates and ignores the rest of the gesture`, the cursor and auto-panning assertions pass if `startDrag()` never activates either resource: the cursor starts in auto mode and auto-panning starts disabled. The test therefore does not catch a regression that leaves these resources unmanaged.

Assert that the cursor is locked and auto-panning is enabled before calling `cancel()`.
</issue_to_address>

Sourcery assessment

Approval pending. 4 findings to address first.

Blocking findings: src/services/drag/DragService.ts:424, src/services/drag/DragService.ts:436, src/services/drag/DragService.ts:424, src/services/drag/DragService.test.ts:320


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

const emitter = dragListener(targetDocument, {
graph: this.graph,
dragCursor: cursor,
component,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Drag survives graph unmount

When the graph unmounts while a startDrag() operation is active, DragService.destroy() does not cancel currentOperationEmitter, so its document listeners and callbacks remain active; when the operation captured a component, GraphLayer also retains that target. Later pointer events still reach the unmounted graph.

Cancel the active operation in destroy() before completing cleanup.

Also at src/services/drag/DragService.ts:436.

Prompt for AI agents
In `src/services/drag/DragService.ts` at line 424:

**Drag survives graph unmount**

When the graph unmounts while a `startDrag()` operation is active, `DragService.destroy()` does not cancel `currentOperationEmitter`, so its document listeners and callbacks remain active; when the operation captured a component, `GraphLayer` also retains that target. Later pointer events still reach the unmounted graph.

Cancel the active operation in `destroy()` before completing cleanup.

Also at `src/services/drag/DragService.ts:436`.

}
};

this.currentOperationEmitter = emitter;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Cancelled drags keep running

When a second startDrag() begins before the first operation ends, startDrag() overwrites currentOperationEmitter without stopping the earlier emitter, so cancellation stops only the newer operation and the earlier callbacks can still run through mouseup. For component-backed operations, the later operation also replaces the graph layer’s capture, and either cleanup can release capture while the other operation is still running.

Stop the currently tracked operation before replacing it with the new emitter.

Also at src/services/drag/DragService.ts:424.

Prompt for AI agents
In `src/services/drag/DragService.ts` at line 436:

**Cancelled drags keep running**

When a second `startDrag()` begins before the first operation ends, `startDrag()` overwrites `currentOperationEmitter` without stopping the earlier emitter, so cancellation stops only the newer operation and the earlier callbacks can still run through mouseup. For component-backed operations, the later operation also replaces the graph layer’s capture, and either cleanup can release capture while the other operation is still running.

Stop the currently tracked operation before replacing it with the new emitter.

Also at `src/services/drag/DragService.ts:424`.

const emitter = dragListener(targetDocument, {
graph: this.graph,
dragCursor: cursor,
component,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Capture survives a throwing start callback

When a component-backed operation’s onStart callback throws, dragListener captures the component before emitting DRAG_START; if the operation’s onStart callback throws, setup exits before the move listener is installed and before drag cleanup runs. The component remains captured and drag resources stay active after the exception.

Guard drag-start setup so an exception from a start callback releases capture and other drag resources before propagating.

Prompt for AI agents
In `src/services/drag/DragService.ts` at line 424:

**Capture survives a throwing start callback**

When a component-backed operation’s `onStart` callback throws, `dragListener` captures the component before emitting `DRAG_START`; if the operation’s `onStart` callback throws, setup exits before the move listener is installed and before drag cleanup runs. The component remains captured and drag resources stay active after the exception.

Guard drag-start setup so an exception from a start callback releases capture and other drag resources before propagating.

Comment on lines +319 to +320
expect(graph.getCursorLayer().isAuto()).toBe(true);
expect(graph.cameraService.getCameraState().autoPanningEnabled).toBe(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Default states mask cleanup regressions

When startDrag() fails to activate the cursor or auto-panning before cancellation, in calls onCancel once with the last event and coordinates and ignores the rest of the gesture, the cursor and auto-panning assertions pass if startDrag() never activates either resource: the cursor starts in auto mode and auto-panning starts disabled. The test therefore does not catch a regression that leaves these resources unmanaged.

Assert that the cursor is locked and auto-panning is enabled before calling cancel().

Prompt for AI agents
In `src/services/drag/DragService.test.ts` at lines 319-320:

**Default states mask cleanup regressions**

When `startDrag()` fails to activate the cursor or auto-panning before cancellation, in `calls onCancel once with the last event and coordinates and ignores the rest of the gesture`, the cursor and auto-panning assertions pass if `startDrag()` never activates either resource: the cursor starts in auto mode and auto-panning starts disabled. The test therefore does not catch a regression that leaves these resources unmanaged.

Assert that the cursor is locked and auto-panning is enabled before calling `cancel()`.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants