Add DragSession + useDragAction hook (Phase 1, 5/6)
Pure orchestrator in core, thin React wrapper in editor: - `core/services/drag-session.ts` — `createDragSession(action, scene, options)` returns an imperative session with `start / move / commit / cancel / dispose / isActive / getDraft`. Pauses history on start, resumes on terminate. Per-move runs preview → snap → apply, then cascades dirty marks via the relations resolver (deduped across ticks). Re-entry guard, idempotent dispose, fires onCommit/onCancel callbacks. All tested in bun:test — no React needed. - `editor/src/hooks/use-drag-action.ts` — wraps the session with the editor's grid-event emitter and an Esc-to-cancel keyboard listener. Builds a `SceneApi` once via `createSceneApi(useScene)` at module init. The hook itself is small enough to read top-to-bottom; all behavior lives in the session. Tests (13 cases) cover the hard parts: history pause/resume bracket, explicit cancel restoring all touched nodes, dispose mid-drag, commit returning false short-circuiting to cancel, snap callback wired in, re-entry rejected, deduped dirty-mark across multiple move ticks, hosts cascade from the registry firing in apply. No callers yet — Phase 2 column and shelf tools are the first consumers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
386df62b43
commit
e799ff70da
@@ -0,0 +1,107 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type ChildQuery,
|
||||
createDragSession,
|
||||
createSceneApi,
|
||||
type DragAction,
|
||||
type DragSessionInput,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
type Modifiers,
|
||||
type SpatialQuery,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
const sceneApi = createSceneApi(useScene)
|
||||
|
||||
function modifiersFromGridEvent(event: GridEvent): Modifiers {
|
||||
const ne = event.nativeEvent?.nativeEvent as Partial<KeyboardEvent> | undefined
|
||||
return {
|
||||
shift: ne?.shiftKey ?? false,
|
||||
alt: ne?.altKey ?? false,
|
||||
ctrl: ne?.ctrlKey ?? false,
|
||||
meta: ne?.metaKey ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
export type UseDragActionArgs<Ctx, Draft> = {
|
||||
/** When true the session is live: subscribes to grid events + Esc.
|
||||
* Flipping to false (or unmount) cancels and cleans up. */
|
||||
active: boolean
|
||||
action: DragAction<Ctx, Draft>
|
||||
/** Captured once at the moment `active` flips to true. */
|
||||
initial: DragSessionInput
|
||||
/** Relations cascade plumbing. */
|
||||
spatialQuery?: SpatialQuery
|
||||
childQuery?: ChildQuery
|
||||
/** Fires once after `action.commit` returns true. */
|
||||
onCommit?: () => void
|
||||
/** Fires once after `action.cancel` (Esc, unmount, or commit-returns-false). */
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook wrapping the pure `createDragSession` orchestrator with the
|
||||
* editor's grid event emitter and an Esc-to-cancel keyboard binding.
|
||||
*
|
||||
* - Pauses scene history when active → resumes on commit/cancel/unmount
|
||||
* - Per `grid:move` runs preview + snap + apply and cascades dirty marks
|
||||
* - `grid:click` triggers commit; Escape triggers cancel
|
||||
*
|
||||
* For tests of the underlying behavior, drive `createDragSession` directly
|
||||
* (no React needed). This hook is the thin glue.
|
||||
*/
|
||||
export function useDragAction<Ctx, Draft>(args: UseDragActionArgs<Ctx, Draft>) {
|
||||
// Stable refs so handlers don't re-bind when callbacks change.
|
||||
const argsRef = useRef(args)
|
||||
argsRef.current = args
|
||||
|
||||
useEffect(() => {
|
||||
if (!args.active) return
|
||||
|
||||
const session = createDragSession<Ctx, Draft>(argsRef.current.action, sceneApi, {
|
||||
spatialQuery: argsRef.current.spatialQuery,
|
||||
childQuery: argsRef.current.childQuery,
|
||||
onCommit: () => argsRef.current.onCommit?.(),
|
||||
onCancel: () => argsRef.current.onCancel?.(),
|
||||
})
|
||||
|
||||
session.start(argsRef.current.initial)
|
||||
|
||||
const onMove = (event: GridEvent) => {
|
||||
const point: readonly [number, number] = [event.localPosition[0], event.localPosition[2]]
|
||||
session.move(point, modifiersFromGridEvent(event))
|
||||
}
|
||||
|
||||
const onClick = (_event: GridEvent) => {
|
||||
session.commit()
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') session.cancel()
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onMove)
|
||||
emitter.on('grid:click', onClick)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
}
|
||||
|
||||
return () => {
|
||||
emitter.off('grid:move', onMove)
|
||||
emitter.off('grid:click', onClick)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
// If the parent flipped `active` to false (or unmounted) while we were
|
||||
// still mid-drag, treat it as a cancel — no dangling history pause.
|
||||
session.dispose()
|
||||
}
|
||||
}, [args.active])
|
||||
}
|
||||
|
||||
export type { AnyNode, AnyNodeId, DragAction, Modifiers }
|
||||
Reference in New Issue
Block a user