Skip to content

feat: Drop Guide (compass) drag-drop overlay#1383

Merged
mathuo merged 14 commits into
v8-branchfrom
feat/drop-guide-compass
Jun 30, 2026
Merged

feat: Drop Guide (compass) drag-drop overlay#1383
mathuo merged 14 commits into
v8-branchfrom
feat/drop-guide-compass

Conversation

@mathuo

@mathuo mathuo commented Jun 28, 2026

Copy link
Copy Markdown
Owner

Drop Guide ("compass")

A VS Code / Visual Studio–style aim-at-a-cell drop overlay for group docking. While a panel or group is dragged over a group, a cross of cells is painted over it and the drop snaps to whichever cell the cursor is on — instead of the cursor-quadrant of the target.

Opt-in via a single option; off by default, so existing drag behaviour is byte-for-byte unchanged.

new DockviewComponent(el, { dndGuide: true });
// or React: <DockviewReact dndGuide />

Behaviour

  • Inner ringcenter tabs into the hovered group; top/bottom/left/right split it. These reuse the drop target's own overlay as the landing preview.
  • Outer ringtop/bottom/left/right dock against the whole layout edge, with a translucent landing-region preview styled from the existing --dv-drag-over-* theme variables (reads identically to any other drop preview).
  • Per-cell gating — only cells whose drop is actually legal are shown (same veto the drop target uses).
  • Aimed-cell highlight — the cell under the cursor lights up; clears when the cursor enters a dead zone.

Options

  • dndGuide: boolean | { zones?: Position[]; edges?: boolean } — restrict the inner cells (zones) or hide the outer ring (edges: false).
  • Themeable: --dv-drop-guide-color, --dv-drop-guide-cell-color, --dv-drop-guide-edge-cell-color, --dv-drop-guide-active-cell-color.

Core seam

The feature is a module (DropGuideModule, depends on AdvancedDnDModule); the only core additions are reusable, neutral primitives:

  • A pluggable PositionResolver at the drop-target seam (dropPositionResolver / lazy getter) — both DnD backends consult it; unset ⇒ the default quadrant logic, unchanged.
  • An edge flag on the resolver result that core routes to a whole-layout-edge dock (factored into dockToLayoutEdge, shared with the existing root-edge drop).
  • A shared canDisplayContentOverlay predicate on the group model (the content drop target and the compass gating now use one source of truth).

Tests

  • Module unit tests (resolver hit-test, gating, edge routing, active-cell + edge-preview lifecycle, containing-block pinning, once-per-direction veto).
  • Core unit tests for the edge flag plumbing in both DnD backends.
  • Playwright e2e: compass appears + cell drop docks, outer-cell layout-edge dock, the compass holds position inner→outer, and feedback clears in dead zones.

All green (core+modules unit + e2e). Enabled in the dockview demo.

🤖 Generated with Claude Code

mathuo and others added 14 commits June 27, 2026 22:08
Add a DropGuideModule that shows a VS Code-style "compass" while dragging over a
group: a cross of cells painted over the group, with the dragged item snapping
to whichever cell the cursor is over instead of the cursor-quadrant of the
target. Drop resolution + commit stay in core — the module installs a
`PositionResolver` (cell hit-test) at the merged drop-target seam and paints the
widget; the existing overlay renders the aimed cell's preview.

- core: `dndGuide` option (`boolean | { zones }`), `IDropGuideHost` /
  `IDropGuideService` contracts + service slot, a composed
  `getDropPositionResolver()` (app `dropPositionResolver` option ?? the module's
  compass resolver) threaded into the group content drop targets, and the
  `.dv-drop-guide*` theme styles. Off by default ⇒ cursor-quadrant unchanged.
- module (`dependsOn: AdvancedDnDModule`): CompassResolver (centred-cross
  hit-test, gated by accepted zones ∩ `dndGuide.zones`), CompassWidget (cross
  painted on the hovered group's content), lifecycle via `onWillShowOverlay` +
  drag-end teardown.

Phase 1 = inner cells (split/merge this group) on the content target. Deferred:
outer cells → whole-layout-edge dock, per-cell veto gating, theming polish.

Tests: 7 unit (hit-test, zone gating, disabled-state, widget lifecycle) + a
component integration test + a real-drag e2e (compass appears, centre-cell drop
merges the groups). core+modules 1220 green; e2e 9 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the position resolver's `edge` flag behavior-bearing so an "outer" cell can
dock against the whole layout from over a group, and add the compass's outer
ring on top of it.

- core (generic seam capability): the drop targets surface the resolver's
  `edge` flag on their overlay/drop events (both DnD backends); an edge cell
  renders no group overlay (it isn't a group split), and an `edge`-flagged
  content drop routes to a new `dockToLayoutEdge` (factored from the root-edge
  drop: `orthogonalize` + `moveGroupOrPanel` to the layout edge). Default
  (no resolver / no edge) is byte-for-byte unchanged.
- module: the compass gains an outer ring of directional cells that return
  `edge:true`; `dndGuide.edges` (default on) toggles them; outer cells read
  visually distinct (`.dv-drop-guide-cell-edge`).

Deferred to polish: the separate root-edge *preview* overlay while aiming an
outer cell (the cell highlight is the affordance for now), and per-cell veto
gating.

Tests: core edge-cell unit test (reports edge, renders no overlay) + module
outer-cell/`edges` tests; e2e drops on an outer cell and asserts a
layout-edge dock (stays 2 groups, vs the centre-cell merge to 1).
core+modules 1223 green; e2e 10 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- outer-cell edge preview: aiming an outer cell now paints a band over the
  whole-layout edge it would dock against. Module-owned (drawn into the layout
  element via a new `getLayoutElement` host method) rather than reusing the root
  drop target's overlay — the root target clears its shared anchor every pointer
  frame while the cursor is over a group, so it can't be borrowed for a preview.
- per-cell gating: the compass only paints cells whose drop is actually allowed,
  via a new `canDropOnGroup` host method that mirrors the content drop target's
  `canDisplayOverlay` (same-component drag always allowed; otherwise the
  per-position veto). The `edge` flag is surfaced on
  `DockviewWillShowOverlayLocationEvent` so the module knows an outer cell is aimed.
- demo: enable `dndGuide` in the dockview demo so the compass is tryable.

Tests: module gating + edge-preview lifecycle units; e2e asserts the edge band
appears while aiming an outer cell. core+modules 1225 green; e2e 10 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- share the content-overlay predicate: extract `canDisplayContentOverlay` onto
  the group model as the single source of truth, used by both the content drop
  target (content.ts) and the compass cell gating (`canDropOnGroup`). Removes the
  duplicated locked/shift/same-component/veto logic that could drift.
- align edge-cell gating with how edge cells commit: the drop target routes an
  edge drop to the layout edge only after the group's content veto passes, so the
  compass now gates outer cells by the same veto (was unconditional) — no more
  painting an outer cell that does nothing on drop.
- strengthen the outer-cell e2e: assert the dragged panel actually relocated to
  the layout edge (its tab is now right of the sibling), not just that the group
  count stayed 2 — a silent dock no-op would have passed before.
- hoist the per-frame edge-preview inset map to module scope; note the band is a
  half-size approximation of the real dock.

core+modules 1225 green; e2e 10 green.

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

The compass cells drifted from where a drop actually resolved — worst toward the
edges. The content drop target measures its quadrants against the `dndPanelOverlay`
outline (the whole group, including the tab header, when `dndPanelOverlay: 'group'`
— which every built-in theme sets), but the compass widget measured only the
content container. Different origin and size, so the painted cross and the
hit-test diverged.

Expose that outline via a new `getDropOverlayElement` host method and paint the
cells in the outline's frame, translated into the widget's own box (a no-op when
the two coincide, e.g. `dndPanelOverlay: 'content'` or a themeless component). Now
the cell you see is the cell you drop on.

core+modules 1226 green (added a translation unit test); e2e 10 green.

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

The whole-layout-edge band was sized with `50%` insets and appended to the grid
element — which is `position: static`, so the percentages resolved against a
larger positioned ancestor (the dockview root, offset by any titlebar). The band
escaped the grid box and that overflow shifted the content, so the compass
appeared to jump the moment the edge overlay showed.

Position the band with explicit pixels computed from the layout element's rect
relative to the band's offset parent (the same translation the compass cells
use), so it stays inside the layout box and never reflows the content.

core+modules 1226 green; e2e 10 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hovering an outer (edge) cell painted a half-layout band at z-index 1001 in a
subtree above the compass, covering half the cross — which read as the compass
"shifting" the moment the band appeared. The band was a deferred nice-to-have
and has been a repeated source of layering/positioning trouble for no functional
gain.

Drop it: the outer cells still dock to the whole-layout edge (the core
`edge`-flag routing is untouched), and stay visually distinct via the dashed
edge-cell style. Removes the `getLayoutElement` host method, `_showEdgePreview`
machinery, and the `.dv-drop-guide-edge-preview` rule.

core+modules 1225 green; e2e 10 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Verified the real cause with a Playwright probe: hovering an outer cell moved
the compass element's own box from {y:35,h:685} (the content container) to
{y:0,h:720} (a higher ancestor) — it re-anchored, the cells stayed put relative
to it, so the whole cross jumped.

Why: the compass is `position: absolute; inset: 0`, so it needs a positioned
ancestor. The only one it had was the element the drop target marks with
`.dv-drop-target` (which carries `position: relative`) — and that class is added
for inner-cell overlays but removed by the edge-cell `removeDropTarget()`. So on
an outer cell the ancestor went static and the compass re-anchored.

Fix: the widget pins `position: relative` on its own mount container for its
lifetime (restored on dispose), giving the cross a stable containing block
independent of the drop target's transient class. Measured delta after the fix:
{dx:0, dy:0}. New e2e asserts the cross holds position inner -> outer.

core+modules 1225 green; e2e 11 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The drop target draws its overlay only for inner cells; outer (edge) cells skip
it, and with the edge-preview band gone they had no hover feedback at all. Light
up whichever cell the cursor is over instead: the per-drag-over event already
carries position + edge, so the widget toggles a `.dv-drop-guide-cell-active`
class on the matching cell (edge flag disambiguates the inner vs outer cell of a
shared direction). Themeable via `--dv-drop-guide-active-cell-color`.

core+modules 1226 green; e2e 11 green (asserts the outer cell lights up when aimed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Visual Studio always shades the region a guide would dock into; our inner cells
already do (the drop target's own overlay), but outer cells had no landing
preview. Restore it: aiming an outer cell now shades the half of the layout the
panel will occupy, styled with the same `--dv-drag-over-*` theme variables as a
real drop overlay so it reads identically, drawn beneath the compass cross.

This is the band removed earlier — but that removal was chasing the wrong cause
(the "jump" was the compass re-anchoring on the `.dv-drop-target` class removal,
fixed separately by pinning the containing block). With that fixed, the preview
is safe: verified the cross stays put (dx/dy 0) and the band covers the correct
half over the positioned layout root.

Not driven through the root drop target directly: it clears its shared anchor
every pointer frame while the cursor is mid-layout, so a module-owned element
with shared styling is the clean equivalent.

core+modules 1227 green; e2e 11 green (asserts the region previews on an outer aim).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review follow-ups:
- if the compass can't mount (no `.dv-content-container`), bail before driving
  the active-cell highlight or the edge preview, so a stray band can't show with
  no compass behind it.
- strengthen the "compass holds position" e2e: assert an outer (edge) cell is
  actually active at the probe point, so the regression (the `.dv-drop-target`
  removal that once re-anchored the cross) is genuinely exercised, not silently
  skipped if the geometry shifts.

core+modules 1228 green; e2e 11 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The two real findings from the review:

- Stale feedback (#2): the active-cell highlight + edge preview are set from
  `onWillShowOverlay`, which only fires while the cursor is over a cell — so they
  lingered when you moved into a dead zone (the cross is small relative to the
  group, so most of it is dead zone). Add a move listener that clears the
  feedback when the resolver reports no cell. It only ever clears (setting stays
  with `onWillShowOverlay`), so the two never race. Verified: band/highlight show
  on an outer cell, clear off the cross, return on the cell.

- onUnhandledDragOver fan-out (#1): cell gating called `canDropOnGroup` once per
  cell (9), and a cross-component drag fires `onUnhandledDragOver` from it. The
  inner and outer cell of a direction share a position, so cache per direction —
  at most one veto query per position (9 -> <=5).

core+modules 1229 green; e2e 12 green (dead-zone clear + once-per-direction veto).

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

# 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-modules/src/index.ts
…eat/drop-guide-compass

# 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-modules/src/index.ts
#	packages/docs/sandboxes/react/dockview/demo-dockview/src/app.tsx
@sonarqubecloud

Copy link
Copy Markdown

@mathuo mathuo merged commit a0bd0ea 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