Interaction Model
SelectionManager, InteractionHelper, drag-drop, and HistoryManager undo/redo
Key Classes: SelectionManager (355 lines), InteractionHelper, HistoryManager — Key File: DrawingManager.Interaction.cs
Table of Contents
SelectionManager
SelectionManager (355 lines) handles all selection-state logic for components, connection lines, and connection points. It works in tandem with DrawingManager to provide a cohesive selection experience:
Selection Operations
| Operation | Method | Behavior |
|---|---|---|
| Single Select | Select(IDrawableComponent) | Clears current selection, selects the given component, fires SelectionChanged. |
| Add to Selection | AddToSelection(IDrawableComponent) | Adds the component to the existing selection without clearing. Triggered by Ctrl+click. |
| Toggle Selection | ToggleSelection(IDrawableComponent) | If selected, deselects; if not selected, selects. Triggered by Ctrl+click on an already-selected item. |
| Rubber-Band Select | SelectInRect(SKRect rect) | Selects all components whose bounds intersect the marquee rectangle. Drawn as a dashed rectangle during drag. |
| Select All | SelectAll() | Selects every component on the canvas. Does not select connection lines. |
| Clear Selection | ClearSelection() | Deselects everything. Clicking on empty canvas space triggers this. |
| Select Connection | Select(ConnectionLine) | Selects a connection line. Only one connection can be selected at a time. Clicking near a line's path triggers this. |
Clipboard Operations
| Operation | Method | Behavior |
|---|---|---|
| Copy | CopySelectedComponents() | Serializes selected components (and their internal connections) to a DiagramDto JSON string held by the manager. |
| Cut | CopySelectedComponents() + DeleteSelectedComponents() | Copies the selection, then removes it from the canvas. Both steps push onto the undo stack. |
| Paste | PasteComponents(SKPoint position) | Deserializes the clipboard payload, instantiates new components with an offset, adds them to the canvas, and pushes a PasteComponentsAction onto the undo stack. |
| Delete | DeleteSelectedComponents() | Removes the selection and its attached lines as a single undoable action. |
Selection Usage
drawingManager.SelectionManager.SelectComponent(node1);
drawingManager.SelectionManager.AddToSelection(node2);
foreach (var comp in drawingManager.SelectionManager.SelectedComponents)
{
comp.NodeProperties["StrokeColor"].ParameterCurrentValue = SKColors.DodgerBlue;
comp.InvalidateVisual();
}
drawingManager.SelectionManager.SelectComponentsInRect(
new SKRect(100, 100, 500, 400),
drawingManager.GetComponents());
drawingManager.CopySelectedComponents();
drawingManager.DeleteSelectedComponents();
drawingManager.PasteComponents(new SKPoint(20, 20));
drawingManager.SelectionManager.ClearSelection();
drawingManager.RequestRedraw();
InteractionHelper
InteractionHelper processes raw mouse and keyboard events from the canvas surface and routes them to the appropriate handler based on the current interaction context. It manages a state machine with distinct drag modes:
| Drag Mode | Enum Value | Trigger | Behavior |
|---|---|---|---|
| Component Move | DragMode.ComponentMove | Mouse down on a component body, then drag. | Repositions the component (and all selected components if part of a multi-select). Snaps to grid if enabled. Fires ComponentMoved on mouse up. |
| Port Connect | DragMode.PortConnect | Mouse down on a connection point (port), then drag. | Draws a temporary rubber-band line from the port to the cursor. On mouse up over a compatible port, creates a ConnectionLine. Otherwise, cancels. |
| Marquee Select | DragMode.MarqueeSelect | Mouse down on empty canvas, then drag. | Draws a dashed selection rectangle. On mouse up, selects all components that intersect the rectangle. |
| Canvas Pan | DragMode.CanvasPan | Mouse down with middle button, or Space+drag with left button, then drag. | Scrolls the viewport by adjusting PanOffset. The cursor changes to a hand icon. |
| Resize | DragMode.Resize | Mouse down on a resize handle, then drag. | Adjusts component Width/Height. Eight handles: N, NE, E, SE, S, SW, W, NW. Snaps to grid if enabled. |
| None | DragMode.None | Default idle state. | Cursor updates based on what is under the mouse (pointer, move, crosshair). |
Mouse Event Routing
public void OnMouseDown(SKPoint screenPoint, MouseButton button)
{
var worldPoint = drawingManager.ScreenToWorld(screenPoint);
if (button == MouseButton.Middle ||
(button == MouseButton.Left && IsSpaceHeld))
{
_dragMode = DragMode.CanvasPan;
_dragStart = screenPoint;
_panStart = drawingManager.PanOffset;
return;
}
var hitResult = HitTestChain(worldPoint);
switch (hitResult.Type)
{
case HitTestType.ConnectionPoint:
_dragMode = DragMode.PortConnect;
_connectSource = hitResult.Point;
break;
case HitTestType.ResizeHandle:
_dragMode = DragMode.Resize;
_resizeHandle = hitResult.Handle;
_dragStart = worldPoint;
break;
case HitTestType.Component:
_dragMode = DragMode.ComponentMove;
_dragStart = worldPoint;
if (!IsCtrlHeld)
SelectionManager.ClearSelection();
SelectionManager.Select(hitResult.Component);
break;
case HitTestType.ConnectionLine:
SelectionManager.Select(hitResult.Connection);
break;
case HitTestType.None:
_dragMode = DragMode.MarqueeSelect;
_marqueeStart = worldPoint;
SelectionManager.ClearSelection();
break;
}
}
Hit-Test Priority Chain
When the user clicks on the canvas, the hit-test chain determines which entity receives the event. The chain is evaluated in strict priority order — the first match wins:
Highest priority. Each port has a small hit-test radius (typically 8 pixels in screen-space). This ensures users can reliably grab connection ports even on small components.
If the hit misses ports but falls within a resize handle's 6-pixel radius, the resize operation starts. Only active on selected components.
Tests components in reverse Z-order (topmost first). Uses Bounds.Contains(point) for axis-aligned hit testing. Components can override HitTest(SKPoint) for precise path-based hit testing.
Tests against the computed path geometry of each connection line. Uses distance-to-path calculation with a configurable tolerance (default: 6 pixels).
Lowest priority. If nothing else is hit, the canvas background receives the event. Triggers ClearSelection() and starts marquee selection if dragging.
Hit-Test Chain Implementation
private HitTestResult HitTestChain(SKPoint worldPoint)
{
var portHit = FindConnectionPointAt(worldPoint, PortHitRadius);
if (portHit != null)
return HitTestResult.ConnectionPoint(portHit);
foreach (var comp in GetSelectedComponents())
{
var handle = comp.GetResizeHandleAt(worldPoint, ResizeHandleRadius);
if (handle != ResizeHandle.None)
return HitTestResult.ResizeHandle(comp, handle);
}
for (int i = Components.Count - 1; i >= 0; i--)
{
var comp = Components[i];
if (comp.HitTest(worldPoint))
return HitTestResult.Component(comp);
}
foreach (var conn in Connections)
{
if (conn.HitTest(worldPoint, ConnectionHitTolerance))
return HitTestResult.ConnectionLine(conn);
}
return HitTestResult.None;
}
Drag & Drop
Beep.Skia supports two distinct drag-and-drop workflows:
1. Component Palette Drag-Drop
The palette raises ComponentDragEnded with the dragged ComponentItem and the pointer position. The host creates the instance through the registry and converts the screen point to world coordinates using the manager's viewport (PanOffset and Zoom):
Palette-to-Canvas Drag-Drop
palette.ComponentDragEnded += (s, args) =>
{
if (args.State != ComponentDragState.Ended) return;
var component = SkiaComponentRegistry.CreateComponentInstance(args.Component.ClassDefinition);
if (component == null) return;
// Screen -> world: subtract the pan offset, then divide by zoom
float zoom = drawingManager.Zoom == 0 ? 1f : drawingManager.Zoom;
component.X = (args.MousePosition.X - drawingManager.PanOffset.X) / zoom;
component.Y = (args.MousePosition.Y - drawingManager.PanOffset.Y) / zoom;
drawingManager.AddComponent(component); // pushed onto the undo stack
drawingManager.RequestRedraw();
};
2. Within-Canvas Repositioning
Dragging existing components is handled by InteractionHelper. While a component drag is in progress, IsDragging and DraggingComponent report the state; when the drag ends, the manager raises ComponentDropped with the final canvas and screen positions:
Observing Component Drops
drawingManager.ComponentDropped += (s, e) =>
{
// e.Component - the dropped component
// e.CanvasPosition - final position in world space (e.Location is an alias)
// e.ScreenPosition - final position in screen space
// e.Bounds - final bounds in world space
Console.WriteLine($"Dropped {e.Component.Name} at {e.CanvasPosition}");
};
// Programmatic move of the current selection (undoable)
drawingManager.MoveSelectedComponents(new SKPoint(dx: 20, dy: 0));
// Undo/redo is exposed directly on the manager
if (drawingManager.CanUndo)
drawingManager.Undo();
HistoryManager
HistoryManager implements a command-pattern undo/redo system that records every mutation to the diagram state. Each operation is represented as a DrawingAction subclass with Execute() and Undo() methods:
| Action Class | Description | Undo Behavior |
|---|---|---|
AddComponentAction | Records a component addition. | Removes the component from the canvas and disconnects all its connections. |
RemoveComponentAction | Records a component removal with all its connections. | Re-adds the component and restores all its connections using saved GUIDs. |
MoveComponentAction | Records a component position change. | Restores the component to its previous X, Y position. Multiple moves during a single drag are coalesced. |
ConnectAction | Records a connection creation between two ports. | Disconnects the line and unbinds the ports. |
DisconnectAction | Records a connection removal. | Re-establishes the connection using the saved port GUIDs. |
PropertyChangeAction | Records a change to one or more NodeProperties values. | Restores the previous property values and invalidates the component. |
ResizeComponentAction | Records a change to Width/Height via resize handles. | Restores previous Width and Height values. |
CompositeAction | Groups multiple actions into a single undoable unit. | Undoes all child actions in reverse order. Used for multi-select operations. |
Undo/Redo Architecture
The history manager maintains two stacks: an undo stack (past actions) and a redo stack (undone actions that can be re-applied). The redo stack is cleared whenever a new action is pushed, following the standard undo/redo model:
HistoryManager Core Logic
public class HistoryManager
{
private Stack<DrawingAction> _undoStack = new();
private Stack<DrawingAction> _redoStack = new();
private const int MaxHistory = 256;
public bool CanUndo => _undoStack.Count > 0;
public bool CanRedo => _redoStack.Count > 0;
public void Push(DrawingAction action)
{
_undoStack.Push(action);
_redoStack.Clear();
if (_undoStack.Count > MaxHistory)
{
var oldest = new Stack<DrawingAction>(
_undoStack.Take(MaxHistory));
_undoStack = oldest;
}
}
public void Undo()
{
if (!CanUndo) return;
var action = _undoStack.Pop();
action.Undo(drawingManager);
_redoStack.Push(action);
drawingManager.Invalidate();
}
public void Redo()
{
if (!CanRedo) return;
var action = _redoStack.Pop();
action.Execute(drawingManager);
_undoStack.Push(action);
drawingManager.Invalidate();
}
}
Tip
The CompositeAction is essential for multi-select operations. When a user drags 5 selected components, all 5 positions are recorded in a single composite action — so one Ctrl+Z moves all 5 back, not just one.
Keyboard Shortcuts
The interaction system defines a standard set of keyboard shortcuts that are processed by InteractionHelper before any canvas-level key handling:
| Shortcut | Action | Scope | Description |
|---|---|---|---|
| Ctrl + Z | Undo | Global | Reverts the most recent diagram mutation. Repeated presses continue undoing through the history stack. |
| Ctrl + Y | Redo | Global | Re-applies the most recently undone action. Disabled after any new action is performed. |
| Ctrl + A | Select All | Global | Selects every component on the canvas. Connection lines are not selected by Select All. |
| Delete / Backspace | Delete Selected | Selection | Removes all selected components and connections. Pushes CompositeAction onto the undo stack. |
| Ctrl + C | Copy | Selection | Copies selected components and their internal connections to the clipboard as JSON. |
| Ctrl + X | Cut | Selection | Copies selected components to clipboard, then removes them from the canvas. |
| Ctrl + V | Paste | Global | Pastes clipboard contents onto the canvas with a cascading offset from the original positions. |
| Ctrl + D | Duplicate | Selection | Copy + paste in one operation. Convenience shortcut for rapidly cloning components. |
| Escape | Cancel / Deselect | Global | Cancels the current drag operation (port connect, resize, move) or clears selection if idle. |
| Ctrl + G | Toggle Grid | Global | Toggles ShowGrid on the drawing manager. |
| Ctrl + + / = | Zoom In | Global | Increases ZoomFactor by one step. Centers zoom on the viewport center. |
| Ctrl + - | Zoom Out | Global | Decreases ZoomFactor by one step. Centers zoom on the viewport center. |
| Ctrl + 0 | Reset Zoom | Global | Resets ZoomFactor to 1.0 and centers the diagram via ResetView(). |
| Space + Drag | Canvas Pan | Global | Holding Space temporarily switches to canvas pan mode. Left-click drag scrolls the viewport. |
| Arrow Keys | Nudge Selection | Selection | Moves selected components by 1 grid unit in the arrow direction. Hold Shift for 10x nudge distance. |