Skip to content

Schema graph: multi-select + group-move of nodes (marquee, Shift-click, ⌘A) with single-step undo #66

Description

@BorisTyshkevich

⚠️ Reconciled 2026-06-30 (#88). Settled approach: imperative + pure-core, no framework (CLAUDE.md rule 5). Before implementing, extract the interaction surface so this doesn't grow inside explain-graph.js: src/core/graph-selection.js (pure — selection set, nodesInRect marquee hit-test, select-all/connected, batch move + align/distribute, batch undo op) and src/ui/graph-surface.js (pan/zoom, pointer capture, marquee rect, keybindings, selection CSS, toolbar — shared with the EXPLAIN pipeline graph where possible). The Implementation notes below still apply, but land in those extracted modules rather than directly in attachSchemaInteractions. See #68 Phase 5.


Problem

In the full-screen schema/data-flow view, layout is hand-tunable by ⌘/Ctrl-dragging a card — but one node at a time. Re-arranging a group of related tables means dragging each individually, which is slow and tedious. There's no way to select several nodes and move them together.

Proposal

Add a selection set of nodes and let the whole set be dragged as one unit, with light group-layout actions. Build entirely on the existing single-node move machinery (no new runtime deps).

Selecting nodes (all additive to today's bindings):

  • Marquee: Shift-drag on empty canvas draws a selection rectangle; cards it touches join the selection. (Plain drag and ⌘/Ctrl-drag stay pan, so Shift-drag is the free gesture for marquee.)
  • Shift-click a card: toggle it in/out of the selection (doesn't open the detail pane).
  • ⌘A: select every node (when the view has focus).
  • Select a whole database / connected lineage component: a one-shot action — e.g. Alt/double-click a card, or a small "select DB / select connected" control — so a related cluster can be grabbed at once.
  • Deselect: plain-click empty canvas, or Esc.

Moving the group: with ≥1 node selected, ⌘/Ctrl-drag any selected card moves the entire selection by the same delta; edges within and touching the set re-route live. ⌘/Ctrl-drag on an unselected card keeps today's single-node behavior.

Group layout actions (shown only when ≥2 are selected): align (left/right/top/bottom), distribute evenly, and optionally re-tidy (re-run dagre on just the selected subgraph). Layout math stays pure in src/core/.

Persistence & undo: the group move persists per-node like single moves (savedPositions) and is undone/redone as one step (a single ⌘Z reverts the whole drag / align).

Current implementation (reuse what exists)

The single-node move path is fully built and is the template — multi-node is additive on top of it.

  • Interaction wiring: attachSchemaInteractions(...)src/ui/explain-graph.js:423. onDown (:478) gates plain-press (pan / open detail pane) vs ⌘/Ctrl-drag (move one node); onMove (:496) → placeAt (:443); onUp (:502) records one undo op.
  • Pure helpers, src/core/graph-layout.js (all reusable for a group): dragDeltaToSvg (:47, viewBox-aware — the same delta applies to every selected node), straightEdgePoints (:31), incidentEdges (:36), applyPositions (:55, already loops), recordPosition (:65), createMoveHistory (:77, op shape {id,from,to}).
  • Persistence: positions map { "db.table": {x,y} } is savedPositions on the result (app.js:586), reloaded via applyPositions (explain-graph.js:257).
  • Existing single "selected" ring (markSelected/clearSchemaSelection, .eg-card--selected + .eg-card-ring) in src/ui/schema-detail.js + styles.css:703 is coupled to the detail pane and mutually-exclusive. A multi-select set is a distinct concept and must be reconciled with it.
  • No marquee / multi-select infrastructure exists yet. Modifier conventions today: plain-click node → detail pane; ⌘/Ctrl-drag node → move; plain/⌘ drag empty canvas → pan; ⌘/Ctrl+wheel → zoom; ⌘Z/⌘⇧Z/⌘Y → undo/redo.

Implementation notes

  • src/core/graph-layout.js (pure, 100% covered): extend createMoveHistory to a batch op { moves: [{id,from,to}] } so undo/redo applies to the whole set in one step; add nodesInRect(nodes, rect) for marquee hit-testing and pure align/distribute helpers. Reuse dragDeltaToSvg, straightEdgePoints, incidentEdges, recordPosition, applyPositions as-is.
  • src/ui/explain-graph.js attachSchemaInteractions: hold a Set of selected ids; add Shift-click toggle, the Shift-drag marquee (draw an SVG rect, hit-test on mouseup), ⌘A, and select-DB/component; in onDown/onMove loop placeAt over the selection and straighten the union of incident edges once per frame; record one batch undo op in onUp.
  • src/ui/schema-detail.js + styles.css: reconcile the existing detail-pane ring with the new multi-select highlight — either unify into one selection set (pane shows the most-recently-clicked) or give the move-selection its own class. Add a marquee-rectangle style.
  • No new runtime deps (DOM/SVG only); tests in the same change (pure helpers to 100%, integration for the wiring) per the CLAUDE.md coverage gate.

Acceptance criteria

  • Shift-drag marquee, Shift-click toggle, ⌘A, and select-DB/connected all build a multi-node selection with a clear highlight.
  • ⌘/Ctrl-dragging a selected card moves the whole selection; all incident edges re-route live; single-node drag of an unselected card is unchanged.
  • Align + distribute (and/or re-tidy) act on the selection.
  • The group move/align persists in savedPositions and is undone/redone as a single step.
  • Plain-click empty canvas / Esc clears the selection; detail-pane ring behavior reconciled.
  • npm test green at the per-file coverage gate; no new runtime dependency.

Open design questions

  • Exact bindings (Shift-drag marquee vs a toggled "select mode"; how "select DB/component" is triggered).
  • Marquee hit policy: intersect vs full-containment.
  • Unify the detail-pane ring with the move-selection, or keep them visually distinct?
  • Scope of "auto-layout": just align/distribute, or also re-dagre the selected subset?

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions