Feature request: one pointer-driven drag for sections and floating panels #
- Status: RESOLVED (2026-11-03), on branch
feat/pointer-section-drag(src/layout/sectionDrag.ts,src/layout/dockHitTest.ts). Accepted by eye in Chromium, Firefox, and on a real iPhone. - Differs from the request: a grip press with no travel does nothing, rather than collapsing.
Sectionalready treats the chevron as the only collapse control. - Component:
layout/DockZone.tsx,layout/StageFloatHost.tsx,layout/Section.tsx,layout/insertLine.ts,theme/base.css; docs indocs/dnd.md,docs/architecture.md,docs/feature-ideas.md,docs/testing.md - Pulled by: dsts-viewer (first consumer to drag sections over a live animated WebGL canvas)
- Severity: high for WebGL consumers: every move of a section drag visibly drops frames
- Date: 2026-11-01
What happens today #
In the viewer, with a model animating:
- Dragging a floating panel by its header is perfectly smooth.
- Dragging a section off any panel, side or floating, makes the animation stutter on every move. Holding the section still restores the framerate.
Cause #
The two gestures use different drag systems.
- Floating panel header: pointer events and pointer capture, in
FloatingPanel. - Section grip: a native HTML5 drag, with FormKit for reorder and transfer, and the float host
reading the document
dragendfor float-out.
Evidence. Profiled in Chromium with the native session bypassed (Input.setInterceptDrags, so
no OS-level drag and no native ghost), section handlers cost about 1ms per move at 60fps, the same
range as the smooth header drag (0.6 to 1.3ms). So neither our code, FormKit nor layout is the cost,
and nothing re-renders per move. What remains is the native session and its ghost, which matches
Nuffle's by-eye result: only the native path lags. Whether the session or the ghost image costs more
was not separated, because this request removes both.
Proposal #
Replace the native path with one custom pointer layer that drives every section and panel drag,
built on what FloatingPanel already does, and remove @formkit/drag-and-drop.
docs/feature-ideas.md already wants these streams consolidated and records that doing float-out
through FormKit hit dead-ends, naming a custom pointer layer as an option. This request picks it.
Ghost #
- Build it once at drag start and move it only with
transform: translate3d(...), on its own layer (will-change: transform), written at most once per animation frame. pointer-events: none, so hit-testing sees what's under it.- Keep today's look (a clone dressed as a floating panel, inside
.stage-rootso tokens apply). If a full clone of a tall section proves costly to paint, clamp its height or use a title-only ghost.
Per-move work #
Coalesce hit-testing and insert-line updates to one per animation frame. hitTestDock reads rects
for every card, and pointer events can arrive faster than frames.
Behavior that must survive #
The current drag layers carry hard-won behavior. Each of these needs to keep working, and most have
comments in DockZone.tsx and StageFloatHost.tsx explaining why:
- Press versus drag on the grip: a press with no travel collapses, and a drag past a small threshold starts moving.
- Text selection and interaction in section bodies: only the grip starts a drag. A press in a body must not be swallowed, which is what once broke document-delegated Kobalte selects.
- Outcomes on release: over the same dock it reorders and keeps collapse state. Over another dock it moves and lands expanded. Over no dock inside the host it floats. Outside the host it does nothing.
- Accordion docks: an arriving section becomes the sole open one.
- Insertion line: same look and clamping, on the existing
createInsertLine. - Float placement: lands where the ghost was, using the grab offset, respects
floatRegion, and seeds width as it does today (or asrequests/section-preferred-width.mdlands, if that goes first). - Empty floating panels are pruned, and a source panel's prune must not end the drag early.
- Movement lock: no grip, no drag.
- Source dimming: the dragged section stays in place at
.stage-movable-section-dragginguntil drop.
New with pointer events, and needed because the browser no longer provides them:
- Cancel: Escape and
pointercancelend the drag with no change. - Touch:
touch-action: noneon grips only, so a body or panel still scrolls. Suppress nativedraggableentirely, so iOS can't hijack a long-press. - Text selection during a drag: suppress it while dragging.
Acceptance #
- The original report: with a model animating in dsts-viewer, dragging a section off a panel looks as smooth as dragging a floating panel by its header. This needs a person's eyes or a real drag. Synthetic CDP drags never showed the lag, so they can't prove the fix.
- Measured: frame intervals during a real section drag match the header drag on the same scene.
- Every item under "Behavior that must survive" has a test, using real
PointerEvents. - Float-out, re-dock, reorder and cross-dock transfer work with a finger on iOS Safari. The drop position is
right and the section's background stays intact, fixing both gaps in
docs/feature-ideas.md. @formkit/drag-and-dropis gone frompackage.json, and the docs no longer describe a native path.- No public API changes:
createSectionLayout,DockZone,StageFloatHostandSectionDefare the same for consumers.
Out of scope #
- Moving floating panels with
transforminstead ofleft/top. The header drag already measures smooth, so it isn't needed for this, but it would be an easy follow-up once the pointer layer is shared. - Long-press to start a drag, or other new touch gestures. Scroll-versus-drag only needs the grip rule above.