From e799ff70dade0cbef2ebb01fe752554ccd4fc04b Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 14 May 2026 12:43:12 -0400 Subject: [PATCH] Add DragSession + useDragAction hook (Phase 1, 5/6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../core/src/services/drag-session.test.ts | 231 ++++++++++++++++++ packages/core/src/services/drag-session.ts | 143 +++++++++++ packages/core/src/services/index.ts | 6 + packages/editor/src/hooks/use-drag-action.ts | 107 ++++++++ 4 files changed, 487 insertions(+) create mode 100644 packages/core/src/services/drag-session.test.ts create mode 100644 packages/core/src/services/drag-session.ts create mode 100644 packages/editor/src/hooks/use-drag-action.ts diff --git a/packages/core/src/services/drag-session.test.ts b/packages/core/src/services/drag-session.test.ts new file mode 100644 index 00000000..02cf4d85 --- /dev/null +++ b/packages/core/src/services/drag-session.test.ts @@ -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 = {}): SceneApi & { + _calls: { + pauseHistory: number + resumeHistory: number + restoreAll: number + markedDirty: AnyNodeId[] + updated: Array<[AnyNodeId, Partial]> + } +} { + const calls = { + pauseHistory: 0, + resumeHistory: 0, + restoreAll: 0, + markedDirty: [] as AnyNodeId[], + updated: [] as Array<[AnyNodeId, Partial]>, + } + 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')) + }) +}) diff --git a/packages/core/src/services/drag-session.ts b/packages/core/src/services/drag-session.ts new file mode 100644 index 00000000..615456de --- /dev/null +++ b/packages/core/src/services/drag-session.ts @@ -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 = { + /** 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( + action: DragAction, + scene: SceneApi, + options: DragSessionOptions = {}, +): DragSession { + let active = false + let ctx: Ctx | null = null + let draft: Draft | null = null + let dirtyMarked = new Set() + + 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) + } + }, + } +} diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index 74966e1f..ac4826f8 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -1,3 +1,9 @@ +export { + createDragSession, + type DragSession, + type DragSessionInput, + type DragSessionOptions, +} from './drag-session' export { type AttachError, type AttachResult, diff --git a/packages/editor/src/hooks/use-drag-action.ts b/packages/editor/src/hooks/use-drag-action.ts new file mode 100644 index 00000000..c66b2bd3 --- /dev/null +++ b/packages/editor/src/hooks/use-drag-action.ts @@ -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 | undefined + return { + shift: ne?.shiftKey ?? false, + alt: ne?.altKey ?? false, + ctrl: ne?.ctrlKey ?? false, + meta: ne?.metaKey ?? false, + } +} + +export type UseDragActionArgs = { + /** When true the session is live: subscribes to grid events + Esc. + * Flipping to false (or unmount) cancels and cleans up. */ + active: boolean + action: DragAction + /** 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(args: UseDragActionArgs) { + // 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(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 }