Skip to content

feat: Smart Guides — alignment guides + magnetic snapping for floating groups#1381

Merged
mathuo merged 9 commits into
v8-branchfrom
feat/smart-guides-phase2
Jun 30, 2026
Merged

feat: Smart Guides — alignment guides + magnetic snapping for floating groups#1381
mathuo merged 9 commits into
v8-branchfrom
feat/smart-guides-phase2

Conversation

@mathuo

@mathuo mathuo commented Jun 27, 2026

Copy link
Copy Markdown
Owner

Figma-style alignment guides + magnetic snapping for floating groups. While a floating window is dragged, it snaps into alignment with other floats, the container, or the grid behind it, drawing crisp guide lines; bringing one flush over another suggests docking/merging them.

Built additively on the existing per-frame float drag-position hook — with the option unset the float drag loop is byte-for-byte unchanged.

What's included (by phase)

  • Phase 1 — alignment guides + single-edge magnetic snap to other floats.
  • Phase 2 — edges and centres; container edges/centre (+ optional inset margins); X and Y resolved independently (two guides at once); asymmetric engage/release hysteresis (no boundary jitter).
  • Phase 3snap-together: edge-adjacency dock-beside + tab-strip-overlap tabset merge, shown as a drop-preview and committed on drop via the existing moveGroupOrPanel primitive (so events + undo cover it).
  • Phase 4Alt-to-disable modifier gate (configurable), snap to grid splitters, per-float opt-out (disableSmartGuides), runtime API (setSmartGuidesEnabled / updateSmartGuidesOptions + onDidSnapFloat / onDidSnapTogether).
  • Phase 5 — theming via CSS variables (styles moved to the stylesheet), per-frame guide-write dedup.
  • 2 review passes — a self-review + a follow-up fixing a redock-abort session leak, a removed-target merge guard, over-eager tabset merges, silent merges under showGuides:false, sticky-probe jumps on tiny floats, nearest-target selection, runtime-vs-option precedence, and cleanups.

API surface

DockviewOptions.smartGuides (option types in core); DockviewApi.setSmartGuidesEnabled / updateSmartGuidesOptions / smartGuidesEnabled + onDidSnapFloat / onDidSnapTogether; per-float disableSmartGuides. The module lives in dockview-modules; core holds only the contracts + the composed drag-transform seam.

Tests

  • core + modules: 1244 unit tests (37 Smart Guides incl. centres, container, splitters, independent axes, hysteresis, snap-together, modifier gate, runtime options, events, and every review-fix regression).
  • e2e: 5 Smart Guides (edge snap, two-axis corner, real merge-on-drop, Alt-suspend, no-guide-away) + 12 total green.
  • Serialization unaffected (pure interaction overlay — nothing added to toJSON).

Notes

  • The last commit enables Smart Guides in the docs demo for try-out; drop it if you'd prefer a core+module-only PR.
  • CI format:check may flag a pre-existing paneview.scss drift on v8-branch — unrelated to this change.

🤖 Generated with Claude Code

mathuo and others added 9 commits June 26, 2026 23:10
… groups (phase 1)

Add a SmartGuides module that snaps a dragged floating group's edge to another
float's edge (within snapDistance) and renders one alignment guide, torn down on
drop. The float drag loop stays owned by Overlay; the component composes the
module into the existing per-frame transformFloatingGroupDrag seam so an
app-provided transform and the module can both nudge a single drag. Additive:
with the module/option absent the loop is byte-for-byte unchanged.

- core: SmartGuidesOptions + `smartGuides` option; ISmartGuidesHost /
  ISmartGuidesService contracts + service slot; getFloatingContainer() host
  method; onDidEndFloatingGroupDrag drag-end signal; composed drag transform
- modules: SmartGuidesService (single-axis edge snap + guide overlay layer),
  registered in the Modules bundle (auto-removability covered)
- tests: jsdom service spec + Playwright real-geometry e2e + fixture handle

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pass `smartGuides: { snapDistance: 8 }` to the demo DockviewReact so floating
groups snap + show alignment guides when dragged near each other.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…argets, hysteresis (phase 2)

Extend the float snapping from a single edge to the full candidate set:

- centers as well as edges; container edges + center (and optional inset
  margin lines) alongside other floats, via `snapTargets`
- X and Y resolved independently, so a float can snap horizontally to one
  neighbour and vertically to another; every alignment the snapped box lands
  on is drawn (e.g. left-edges + tops = two guides)
- contested snaps ranked edge-over-center, then source (floats > container),
  then nearest
- asymmetric engage/release hysteresis (`releaseDistance`) so a snap doesn't
  oscillate at the threshold
- guide lines pooled in the overlay layer (style-only writes per frame)

Options gain `releaseDistance` + `snapTargets` (SmartGuidesSnapTargets). Unit
specs cover centers/container/inset/independent-axes/hysteresis; the e2e adds a
two-axis corner-snap (float-only fixture for deterministic geometry).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…se 3)

When a dragged float comes flush against another float, suggest docking or
merging it and commit on drop:

- edge-adjacency (a dragged edge meets a target's opposite edge with >= 50%
  perpendicular overlap) suggests docking beside the target; overlapping tab
  strips (tops flush + stacked) suggest a center/tabset merge
- a drop-preview rectangle shows where the float will land; on drop the move
  goes through the existing `moveGroupOrPanel` primitive so events + undo cover
  it (self-drop guarded)
- gated by `snapTogether` (default on)

Adds `ISmartGuidesHost.getFloatingGroupSnapshots` (float identity + box) and
`mergeFloatInto`, plus the `SmartGuidesSnapPosition` type and the `snapTogether`
option. Unit specs cover adjacency/center detection, overlap threshold, pending
clears on move-away, gating + commit-on-drop; e2e drops a float onto another and
asserts the real merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…+ events (phase 4)

Round out the floating-group snapping:

- modifier gate: holding a configurable modifier (default Alt) while dragging
  suspends snapping + guides and the float follows the pointer freely; releasing
  re-engages. Plumbed via a new `modifiers` field on the drag context (carried
  from the pointer event through the overlay loop), so the public
  `transformFloatingGroupDrag` option sees it too
- `snapTargets.splitters` (default off): align to the underlying grid's sash
  positions, read via a new `getGridSplitterRects` host method
- per-float opt-out: `disableSmartGuides` on the floating-group options excludes
  a window from snapping (the component skips composing the module for it)
- runtime control: `DockviewApi.setSmartGuidesEnabled` /
  `updateSmartGuidesOptions` (+ `smartGuidesEnabled`) and `onDidSnapFloat` /
  `onDidSnapTogether` events, backed by service-side overrides + emitters

Adds the `SnapModifier`, `DragModifiers`, `SmartGuidesSnapEvent` and
`SmartGuidesSnapTogetherEvent` types. Unit specs cover the gate (default +
configurable + off), splitter snapping + default-off, runtime enable/options,
and both events; e2e holds Alt and asserts the float is not pulled to the edge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… 5 polish)

- move guide-line + drop-preview cosmetics (colour, border) out of inline JS
  into the stylesheet (`.dv-smart-guide` / `.dv-smart-guide-preview` in
  overlay.scss), themable via `--dv-smart-guides-color` /
  `--dv-smart-guides-preview-color`; the module now only sets geometry inline
- dedup the per-frame guide DOM writes: a frame whose guides are unchanged
  skips the write entirely (the render path has no interleaved layout reads, so
  what does change still coalesces into a single reflow at paint). `clear()`
  invalidates the cache so a re-acquired snap always redraws

Popout correctness needs no change: every coordinate is container-relative
(from the drag context) and the guide layer lives in the float container, so
there is no cross-document geometry to get wrong.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ds, perf

Addresses findings from a code review of the branch:

- redock long-press aborts a float drag via `cancelPendingDrag` (no end event),
  which left the guide session + DOM layer leaked and reused stale candidates on
  the next drag. Add an `onDidStartFloatingGroupDrag` host signal (from the
  overlay's `onDidStartMoving`) and reset the session on each drag start.
- `mergeFloatInto` now bails if the captured target was removed mid-drag (it
  could be closed async before the drop), instead of moving into a dead group.
- tab-strip (center) merge now requires the dragged float's centre to sit over
  the target, not merely top-alignment + edge overlap — so aligning two
  overlapping floats no longer reads as a merge (§11).
- the dock/merge drop-preview now shows even when `showGuides:false` — a merge
  that commits on drop must never be silent (showGuides governs alignment lines
  only).
- skip zero-area (hidden/collapsed) sashes so they can't become a phantom
  splitter candidate at coord 0.
- correct the `_buildFloatingDragTransform` doc to be precise about the
  present-but-inert case, and cache `_opts` by reference so the inert per-frame
  path allocates nothing.
- cleanup: `_gatherFloatingGroupBoxes` delegates to `getFloatingGroupSnapshots`
  (one geometry source); alias the module's `Rect` to the core `Box`.

Regression tests added for each behavioural fix. core+modules 1241 green; e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ge, option precedence

Address the deferred review findings:

- hysteresis now tracks WHICH probe (leading edge / centre / trailing edge)
  latched onto a line and keeps gluing that one, instead of re-deriving the
  nearest of all three each frame — so a sub-24px float no longer silently
  re-snaps its centre onto an edge line and jumps.
- snap-together evaluates every candidate target and picks the best (a centre
  stack outranks an adjacency; within a kind the nearest wins) rather than the
  first float in iteration order, so clustered floats dock into the closest.
- a fresh app-level `smartGuides` option update (a new option reference) now
  drops earlier `setSmartGuidesEnabled` / `updateSmartGuidesOptions` runtime
  overrides, so the two no longer fight as permanent competing sources of truth.

Left as-is: snap-together targets the floating window's anchor group with its
whole-window box — intentional per the spec's "use the outer overlay rect for
multi-group floats" guidance, not a defect.

Regression tests added for each. core+modules 1244 green; e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s-phase2

# Conflicts:
#	e2e/fixtures/index.html
#	packages/dockview-core/src/dockview/dockviewComponent.ts
#	packages/dockview-core/src/dockview/moduleContracts.ts
#	packages/dockview-core/src/dockview/modules.ts
#	packages/dockview-core/src/dockview/options.ts
#	packages/dockview-core/src/index.ts
#	packages/dockview-modules/src/index.ts
@sonarqubecloud

Copy link
Copy Markdown

@mathuo mathuo merged commit 9576f34 into v8-branch Jun 30, 2026
9 checks passed
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.

1 participant