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,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'))
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user