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:
Wassim SAMAD
2026-05-14 12:43:12 -04:00
co-authored by Claude Opus 4.7
parent 386df62b43
commit e799ff70da
4 changed files with 487 additions and 0 deletions
@@ -0,0 +1,231 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test'
import { z } from 'zod'
import { nodeRegistry, registerNode } from '../registry/registry'
import type { AnyNodeDefinition, DragAction, Relations, SceneApi } from '../registry/types'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { createDragSession } from './drag-session'
const id = (s: string) => s as AnyNodeId
function makeSpyScene(initial: Record<string, AnyNode> = {}): SceneApi & {
_calls: {
pauseHistory: number
resumeHistory: number
restoreAll: number
markedDirty: AnyNodeId[]
updated: Array<[AnyNodeId, Partial<AnyNode>]>
}
} {
const calls = {
pauseHistory: 0,
resumeHistory: 0,
restoreAll: 0,
markedDirty: [] as AnyNodeId[],
updated: [] as Array<[AnyNodeId, Partial<AnyNode>]>,
}
const nodes = { ...initial }
return {
get: ((nid: AnyNodeId) => nodes[nid as string]) as SceneApi['get'],
update: (nid, patch) => {
calls.updated.push([nid, patch])
const existing = nodes[nid as string]
if (existing) nodes[nid as string] = { ...existing, ...patch } as AnyNode
},
upsert: (n: AnyNode) => {
nodes[n.id as string] = n
return n.id
},
delete: (nid) => {
delete nodes[nid as string]
},
restore: () => {},
restoreAll: () => {
calls.restoreAll += 1
},
markDirty: (nid) => {
calls.markedDirty.push(nid)
},
pauseHistory: () => {
calls.pauseHistory += 1
},
resumeHistory: () => {
calls.resumeHistory += 1
},
_calls: calls,
}
}
function makeDef(kind: string, relations?: Relations): AnyNodeDefinition {
return {
kind,
schemaVersion: 1,
schema: z.object({ type: z.literal(kind) }) as any,
category: 'utility',
defaults: () => ({}) as any,
capabilities: {},
relations,
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
}
}
function makeAction(): DragAction<{ id: AnyNodeId }, { x: number }> {
return {
begin: ({ node }) => ({ id: node?.id ?? id('default') }),
preview: (_ctx, point) => ({ x: point[0] }),
apply: (_draft, ctx) => [ctx.id],
cancel: () => {},
}
}
describe('createDragSession', () => {
beforeEach(() => {
nodeRegistry._reset()
})
test('start pauses history; commit resumes', () => {
const scene = makeSpyScene()
const session = createDragSession(makeAction(), scene)
session.start({ point: [0, 0] })
expect(scene._calls.pauseHistory).toBe(1)
expect(scene._calls.resumeHistory).toBe(0)
session.commit()
expect(scene._calls.resumeHistory).toBe(1)
})
test('cancel resumes history and calls restoreAll', () => {
const scene = makeSpyScene()
const session = createDragSession(makeAction(), scene)
session.start({ point: [0, 0] })
session.cancel()
expect(scene._calls.resumeHistory).toBe(1)
expect(scene._calls.restoreAll).toBe(1)
})
test('move runs preview + apply and marks the returned id dirty', () => {
const scene = makeSpyScene({ a: { id: id('a'), type: 'thing' } as any })
const session = createDragSession(makeAction(), scene)
session.start({ point: [0, 0], node: { id: id('a') } as any })
session.move([1, 0], { shift: false, alt: false, ctrl: false, meta: false })
expect(session.getDraft()).toEqual({ x: 1 })
expect(scene._calls.markedDirty).toContain(id('a'))
})
test('snap callback is invoked when defined', () => {
const action: DragAction<{ id: AnyNodeId }, { x: number }> = {
...makeAction(),
snap: (draft) => ({ x: Math.round(draft.x) }),
}
const scene = makeSpyScene({ a: { id: id('a'), type: 'thing' } as any })
const session = createDragSession(action, scene)
session.start({ point: [0, 0], node: { id: id('a') } as any })
session.move([0.7, 0], { shift: false, alt: false, ctrl: false, meta: false })
expect(session.getDraft()).toEqual({ x: 1 })
})
test('commit returns false when action.commit returns false; calls action.cancel and restoreAll', () => {
const cancelSpy = mock(() => {})
const action: DragAction<{ id: AnyNodeId }, { x: number }> = {
...makeAction(),
cancel: cancelSpy,
commit: () => false,
}
const scene = makeSpyScene({ a: { id: id('a'), type: 'thing' } as any })
const session = createDragSession(action, scene)
session.start({ point: [0, 0], node: { id: id('a') } as any })
session.move([1, 0], { shift: false, alt: false, ctrl: false, meta: false })
const result = session.commit()
expect(result).toBe(false)
expect(cancelSpy).toHaveBeenCalledTimes(1)
expect(scene._calls.restoreAll).toBe(1)
})
test('move is a no-op when session is not active', () => {
const scene = makeSpyScene()
const session = createDragSession(makeAction(), scene)
session.move([1, 0], { shift: false, alt: false, ctrl: false, meta: false })
expect(scene._calls.markedDirty.length).toBe(0)
})
test('repeated start is a no-op (re-entry guard)', () => {
const scene = makeSpyScene()
const session = createDragSession(makeAction(), scene)
session.start({ point: [0, 0] })
session.start({ point: [99, 99] })
expect(scene._calls.pauseHistory).toBe(1) // only one pause
})
test('dispose mid-drag cancels and cleans up', () => {
const scene = makeSpyScene()
const session = createDragSession(makeAction(), scene)
session.start({ point: [0, 0] })
expect(session.isActive()).toBe(true)
session.dispose()
expect(session.isActive()).toBe(false)
expect(scene._calls.resumeHistory).toBe(1)
expect(scene._calls.restoreAll).toBe(1)
})
test('dispose when inactive is a no-op', () => {
const scene = makeSpyScene()
const session = createDragSession(makeAction(), scene)
session.dispose()
expect(scene._calls.pauseHistory).toBe(0)
expect(scene._calls.resumeHistory).toBe(0)
})
test('onCommit callback fires on successful commit', () => {
const onCommit = mock(() => {})
const onCancel = mock(() => {})
const scene = makeSpyScene()
const session = createDragSession(makeAction(), scene, { onCommit, onCancel })
session.start({ point: [0, 0] })
session.commit()
expect(onCommit).toHaveBeenCalledTimes(1)
expect(onCancel).toHaveBeenCalledTimes(0)
})
test('onCancel callback fires on explicit cancel', () => {
const onCommit = mock(() => {})
const onCancel = mock(() => {})
const scene = makeSpyScene()
const session = createDragSession(makeAction(), scene, { onCommit, onCancel })
session.start({ point: [0, 0] })
session.cancel()
expect(onCommit).toHaveBeenCalledTimes(0)
expect(onCancel).toHaveBeenCalledTimes(1)
})
test('dirty cascade fires once per id even across multiple move ticks', () => {
// Register a kind with no relations — cascade returns just {startId}.
registerNode(makeDef('thing'))
const scene = makeSpyScene({ a: { id: id('a'), type: 'thing' } as any })
const session = createDragSession(makeAction(), scene)
session.start({ point: [0, 0], node: { id: id('a') } as any })
session.move([1, 0], { shift: false, alt: false, ctrl: false, meta: false })
session.move([2, 0], { shift: false, alt: false, ctrl: false, meta: false })
session.move([3, 0], { shift: false, alt: false, ctrl: false, meta: false })
// a is marked once, not three times
expect(scene._calls.markedDirty.filter((mid) => mid === id('a')).length).toBe(1)
})
test('dirty cascade follows hosts relations from the registry', () => {
registerNode(makeDef('wall', { hosts: ['door'] }))
registerNode(makeDef('door'))
const scene = makeSpyScene({
w: { id: id('w'), type: 'wall', children: [id('d')] } as any,
d: { id: id('d'), type: 'door', parentId: id('w') } as any,
})
const action: DragAction<{ id: AnyNodeId }, { x: number }> = {
begin: () => ({ id: id('w') }),
preview: (_ctx, point) => ({ x: point[0] }),
apply: (_draft, ctx) => [ctx.id],
cancel: () => {},
}
const session = createDragSession(action, scene)
session.start({ point: [0, 0], node: { id: id('w') } as any })
session.move([1, 0], { shift: false, alt: false, ctrl: false, meta: false })
// both wall and door marked dirty
expect(scene._calls.markedDirty).toContain(id('w'))
expect(scene._calls.markedDirty).toContain(id('d'))
})
})
+143
View File
@@ -0,0 +1,143 @@
import { type ChildQuery, cascadeDirty, type SpatialQuery } from '../registry/relations-resolver'
import type { DragAction, Modifiers, SceneApi } from '../registry/types'
import type { AnyNode, AnyNodeId } from '../schema/types'
import type { Vec2 } from './snap'
/**
* Pure orchestrator for a single `DragAction` lifecycle:
* begin → (preview → snap? → apply → cascade dirty)* → commit | cancel
*
* Bracketed by `pauseHistory()` / `resumeHistory()` so the entire drag is one
* undo step. The React hook (`useDragAction` in `@pascal-app/editor`) wraps
* this with event subscriptions; tests drive it directly.
*/
export type DragSessionInput = {
node?: AnyNode
point: Vec2
handleId?: string
modifiers?: Modifiers
}
export type DragSessionOptions = {
spatialQuery?: SpatialQuery
childQuery?: ChildQuery
/** Called once the session terminates via `commit()`. */
onCommit?: () => void
/** Called once the session terminates via `cancel()` or `dispose()`. */
onCancel?: () => void
}
export type DragSession<Ctx, Draft> = {
/** Begin the drag — pause history, capture ctx via `action.begin`. */
start: (input: DragSessionInput) => void
/** Per-pointer-move tick — run preview/snap/apply and cascade dirty marks. */
move: (point: Vec2, modifiers: Modifiers) => void
/** Pointer-up / discrete commit. Returns true if `action.commit` agreed. */
commit: () => boolean
/** Pointer-cancel / Esc / external abort — restores all touched nodes. */
cancel: () => void
/** Returns the latest draft `apply` produced (or null before first move). */
getDraft: () => Draft | null
isActive: () => boolean
/** Idempotent cleanup. If active, equivalent to `cancel()`. */
dispose: () => void
}
const EMPTY_MODIFIERS: Modifiers = { shift: false, alt: false, ctrl: false, meta: false }
export function createDragSession<Ctx, Draft>(
action: DragAction<Ctx, Draft>,
scene: SceneApi,
options: DragSessionOptions = {},
): DragSession<Ctx, Draft> {
let active = false
let ctx: Ctx | null = null
let draft: Draft | null = null
let dirtyMarked = new Set<AnyNodeId>()
function markWithCascade(id: AnyNodeId): void {
if (dirtyMarked.has(id)) return
const ids = cascadeDirty(id, {
scene,
spatialQuery: options.spatialQuery,
childQuery: options.childQuery,
})
for (const dirtyId of ids) {
if (!dirtyMarked.has(dirtyId)) {
scene.markDirty(dirtyId)
dirtyMarked.add(dirtyId)
}
}
}
function terminate(committed: boolean): void {
if (!active) return
active = false
ctx = null
draft = null
dirtyMarked = new Set()
scene.resumeHistory()
if (committed) options.onCommit?.()
else options.onCancel?.()
}
return {
start(input) {
if (active) return // ignore re-entry
scene.pauseHistory()
ctx = action.begin({
node: input.node,
point: input.point,
handleId: input.handleId,
modifiers: input.modifiers ?? EMPTY_MODIFIERS,
})
active = true
},
move(point, modifiers) {
if (!active || ctx == null) return
let next = action.preview(ctx, point, modifiers)
if (action.snap) {
next = action.snap(next, ctx, undefined)
}
draft = next
const dirtyIds = action.apply(next, ctx, scene)
for (const id of dirtyIds) markWithCascade(id)
},
commit() {
if (!active || ctx == null) return false
const ok = action.commit?.(draft as Draft, ctx, scene) ?? true
if (!ok) {
action.cancel(ctx, scene)
scene.restoreAll()
}
terminate(ok)
return ok
},
cancel() {
if (!active || ctx == null) return
action.cancel(ctx, scene)
scene.restoreAll()
terminate(false)
},
getDraft() {
return draft
},
isActive() {
return active
},
dispose() {
if (active && ctx != null) {
action.cancel(ctx, scene)
scene.restoreAll()
terminate(false)
}
},
}
}
+6
View File
@@ -1,3 +1,9 @@
export {
createDragSession,
type DragSession,
type DragSessionInput,
type DragSessionOptions,
} from './drag-session'
export {
type AttachError,
type AttachResult,
@@ -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 }