fence curve: single-undo dance in commit — fix undo skipping past drag

The previous commit() just returned true, relying on
createDragSession.terminate() to resumeHistory. But that alone never
captures the drag in zundo's pastStates — the pause window's mutations
are skipped entirely. After commit, Ctrl-Z jumped past the curve *and*
past the prior fence creation.

Adds the same dance now used by move-endpoint: restoreAll →
resumeHistory → re-apply the final draft. Zundo records one undo step
for the whole drag. cancel() becomes a no-op (orchestrator's
restoreAll covers it).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-15 17:57:51 -04:00
co-authored by Claude Opus 4.7
parent 05efa25edb
commit 5e67ffd4e8
+22 -25
View File
@@ -25,23 +25,15 @@ import {
* - **snap**: optional grid snap unless `modifiers.shift` (free place). * - **snap**: optional grid snap unless `modifiers.shift` (free place).
* - **apply**: write the new curveOffset onto the fence node. Returns * - **apply**: write the new curveOffset onto the fence node. Returns
* the dirty IDs the cascade resolver should walk. * the dirty IDs the cascade resolver should walk.
* - **commit**: returns true → drag finalizes. `useDragAction` * - **commit**: single-undo dance — `restoreAll` → `resumeHistory` →
* resumes history so the post-pause final write lands as a single * re-apply final draft so zundo captures the whole drag as one
* undo step. * Ctrl-Z step. Rejected when the offset didn't actually change.
* - **cancel**: restore the original curveOffset. Called on Esc / * - **cancel**: no-op — `createDragSession.cancel()` calls
* component unmount / commit-returns-false. * `scene.restoreAll()` via the snapshot.
* *
* Pure data: trivially unit-testable, doesn't import React. The * Pure data: trivially unit-testable, doesn't import React.
* orchestrator (`createDragSession`) handles pauseHistory / resumeHistory
* automatically.
*/ */
const GRID_STEP = 0.5
function snapScalar(value: number): number {
return Math.round(value / GRID_STEP) * GRID_STEP
}
type CurveFenceCtx = { type CurveFenceCtx = {
nodeId: AnyNodeId nodeId: AnyNodeId
originalCurveOffset: number originalCurveOffset: number
@@ -95,19 +87,24 @@ export const curveFenceDragAction: DragAction<CurveFenceCtx, CurveFenceDraft> =
return [ctx.nodeId] return [ctx.nodeId]
}, },
commit: (_draft, _ctx, _scene) => { commit: (draft, ctx, scene) => {
// Returning true tells the orchestrator to finalize. The orchestrator // Reject when the offset didn't actually change — createDragSession
// resumes history and re-applies the final draft — yields a single // will fall through to cancel + scene.restoreAll() (no zundo entry).
// undo step for the whole drag. if (draft.curveOffset === ctx.originalCurveOffset) return false
// Single-undo dance: revert via the snapshot (paused history → no
// zundo record), resume history, then re-apply the final draft so
// zundo captures the whole drag as one undo step. Without this the
// pause window's mutations never reach pastStates and Ctrl-Z jumps
// past the drag back to the state before activation.
scene.restoreAll()
scene.resumeHistory()
scene.update(ctx.nodeId, { curveOffset: draft.curveOffset } as Partial<AnyNode>)
return true return true
}, },
cancel: (ctx, scene) => { cancel: (_ctx, _scene) => {
// Restore the original curve offset (history was paused, so nothing // No-op — createDragSession.cancel() calls scene.restoreAll() which
// intermediate is on the undo stack). // puts every touched node back via the snapshot.
scene.update(ctx.nodeId, {
curveOffset: ctx.originalCurveOffset,
} as Partial<AnyNode>)
scene.markDirty(ctx.nodeId)
}, },
} }