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 {
|
export {
|
||||||
type AttachError,
|
type AttachError,
|
||||||
type AttachResult,
|
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