Skip to content

packages/core/src/lib/manipulation/controller.ts

Read as Markdown

This is the source snapshot used to build these API details. View this revision on GitHub.

Back to reference

1 import {
2   batch,
3   signal,
4   isSignal,
5   untracked,
6   assertSignalCommandAllowed,
7   assertDirectSignalWriteAllowed,
8 } from "../signals/graph.js";
9 import { resolveSignalValue } from "../signals/resolve-signal-value.js";
10 import { requireHookContext, useHookSlot } from "../hooks/hook-slot.js";
11 import { queueBeginCommand } from "../scheduler/realm-scheduler.js";
12 import type {
13   PibblEventHandlers,
14   PibblEventTarget,
15   PibblPointerEvent,
16   PibblKeyboardEvent,
17 } from "../events/types.js";
18 import type { PibblComponentRefs, PibblInstance } from "../types.js";
19 import type { WritableSignal } from "../signals/types.js";
20 import {
21   observeGestureInputs,
22   claimGeometryGeneration,
23   restoreReleasedGesture,
24 } from "./ownership.js";
25 import { suppressManipulationActivation } from "../events/dispatch.js";
26 import { solveManipulation } from "./solve.js";
27 import {
28   currentRootCoordinateMapping,
29   sameRootCoordinateMapping,
30 } from "./coordinates.js";
31 import type { PibblResizePosition } from "./types.js";
32 import type {
33   PibblManipulationActive,
34   PibblManipulationCancelReason,
35   PibblManipulationController,
36   PibblManipulationEvent,
37   PibblManipulationGeometry,
38   PibblManipulationOperation,
39   PibblManipulationOptions,
40   PibblManipulationSnapshot,
41 } from "./controller-types.js";
42 
43 type PointerSample = Pick<
44   PibblPointerEvent,
45   | "pointerId"
46   | "x"
47   | "y"
48   | "clientX"
49   | "clientY"
50   | "shiftKey"
51   | "altKey"
52   | "ctrlKey"
53   | "metaKey"
54 >;
55 interface Session {
56   operation: PibblManipulationOperation;
57   handle?: PibblResizePosition;
58   pointerId: number;
59   target: PibblEventTarget;
60   before: PibblManipulationGeometry;
61   last: PibblManipulationGeometry;
62   point: DOMPoint;
63   clientX: number;
64   clientY: number;
65   parent: DOMMatrix;
66   started: boolean;
67   angle: number;
68   rotationDelta: number;
69   snapshot: PibblManipulationSnapshot;
70   coordinateMapping: readonly number[];
71   source?: object;
72   releaseObservation?: () => void;
73   sample?: PointerSample;
74 }
75 export interface ManipulationState {
76   controller: PibblManipulationController;
77   options: PibblManipulationOptions;
78   alive: boolean;
79   root: PibblInstance;
80   frame?: PibblComponentRefs<any>;
81   parent: DOMMatrix;
82   coordinateMapping?: readonly number[];
83   session?: Session;
84   active: WritableSignal<PibblManipulationActive | null>;
85   snapshot: WritableSignal<PibblManipulationSnapshot | null>;
86 }
87 const states = new WeakMap<PibblManipulationController, ManipulationState>();
88 const owners = new WeakMap<object, ManipulationState>();
89 const claimedPresses = new WeakSet<Event>();
90 export function manipulationState(
91   controller: PibblManipulationController,
92 ): ManipulationState {
93   const state = states.get(controller);
94   if (!state || !state.alive)
95     throw new Error(
96       "Pibbl manipulation requires a live useManipulation controller.",
97     );
98   return state;
99 }
100 function geometry(state: ManipulationState): PibblManipulationGeometry {
101   return untracked(() =>
102     Object.freeze({
103       box: Object.freeze({ ...state.options.box.get() }),
104       rotation: state.options.rotation?.get() ?? 0,
105     }),
106   );
107 }
108 function same(a: PibblManipulationGeometry, b: PibblManipulationGeometry): boolean {
109   return (
110     a.rotation === b.rotation &&
111     a.box.left === b.box.left &&
112     a.box.top === b.box.top &&
113     a.box.width === b.box.width &&
114     a.box.height === b.box.height
115   );
116 }
117 function event(session: Session): PibblManipulationEvent {
118   return Object.freeze({
119     ...session.snapshot,
120     before: session.before,
121     after: session.last,
122   });
123 }
124 function publish(
125   state: ManipulationState,
126   next: PibblManipulationGeometry,
127 ): void {
128   assertDirectSignalWriteAllowed(state.options.box);
129   if (state.options.rotation)
130     assertDirectSignalWriteAllowed(state.options.rotation);
131   batch(() => {
132     state.options.box.set(next.box);
133     if (state.options.rotation) state.options.rotation.set(next.rotation);
134   });
135 }
136 function release(state: ManipulationState, session: Session): void {
137   if (state.session !== session) return;
138   state.session = undefined;
139   session.releaseObservation?.();
140   session.releaseObservation = undefined;
141   if (state.options.rotation && owners.get(state.options.rotation) === state)
142     owners.delete(state.options.rotation);
143   if (owners.get(state.options.box) === state) owners.delete(state.options.box);
144   if (session.target.hasPointerCapture(session.pointerId))
145     session.target.releasePointerCapture(session.pointerId);
146 }
147 function cancel(
148   state: ManipulationState,
149   reason: PibblManipulationCancelReason,
150 ): void {
151   const session = state.session;
152   if (!session) return;
153   const restore =
154     same(geometry(state), session.last) && reason !== "external-change";
155   release(state, session);
156   batch(() => {
157     if (restore && session.started) publish(state, session.before);
158     state.active.set(null);
159   });
160   if (session.started)
161     state.options.onCancel?.(
162       Object.freeze({ ...event(session), after: geometry(state), reason }),
163     );
164 }
165 /** Release capture synchronously; rollback can defer under renderer write guards. */
166 export function detachManipulationFrame(state: ManipulationState): void {
167   const session = state.session;
168   if (!session) return;
169   const restore = same(geometry(state), session.last);
170   release(state, session);
171   restoreReleasedGesture({
172     box: state.options.box,
173     rotation: state.options.rotation,
174     before: restore ? session.before : geometry(state),
175     active: state.active,
176   });
177 }
178 function dispose(state: ManipulationState): void {
179   if (!state.alive) return;
180   state.alive = false;
181   detachManipulationFrame(state);
182 }
183 /**
184  * Creates a mount-owned controller for moving, resizing, and rotating declared geometry.
185  *
186  * @param options - Target state, enabled operations, constraints, and lifecycle callbacks. See
187  * {@link PibblManipulationOptions} .
188  * @returns Stable controls and state for the mounted manipulation interaction. See
189  * {@link PibblManipulationController} .
190  *
191  * @see {@link PibblManipulationOptions}
192  * @see {@link PibblManipulationController}
193  */
194 export function useManipulation(
195   options: PibblManipulationOptions,
196 ): PibblManipulationController {
197   const { pibblInstance } = requireHookContext();
198   const slot = useHookSlot<ManipulationState>("manipulation", (teardowns) => {
199     const state = {
200       options,
201       root: pibblInstance.eventRoot.rootInstance ?? pibblInstance,
202       alive: true,
203       parent: new DOMMatrix(),
204       active: signal<PibblManipulationActive | null>(null),
205       snapshot: signal<PibblManipulationSnapshot | null>(null),
206     } as ManipulationState;
207     const controller: PibblManipulationController = Object.freeze({
208       active: state.active.asReadonly(),
209       snapshot: state.snapshot.asReadonly(),
210       get frameProps() {
211         return frameProps;
212       },
213       cancel() {
214         if (state.alive) {
215           assertSignalCommandAllowed();
216           cancel(state, "explicit");
217         }
218       },
219     });
220     const frameProps = Object.freeze({ controller, style: options.box });
221     state.controller = controller;
222     states.set(controller, state);
223     teardowns.add(() => dispose(state));
224     return state;
225   });
226   const state = slot.value;
227   if (options.rotate && !options.rotation)
228     throw new Error(
229       "useManipulation rotate requires a writable rotation signal in radians.",
230     );
231   if (
232     options.box !== state.options.box ||
233     options.rotation !== state.options.rotation
234   )
235     throw new Error(
236       "useManipulation geometry signal identity must remain stable.",
237     );
238   state.options = options;
239   if (state.session && !enabled(state, state.session.operation)) {
240     const session = state.session;
241     queueBeginCommand(() => {
242       if (state.alive && state.session === session) cancel(state, "disabled");
243     });
244   }
245   return state.controller;
246 }
247 function enabled(
248   state: ManipulationState,
249   operation: PibblManipulationOperation,
250 ): boolean {
251   const options =
252     operation === "move"
253       ? state.options.move
254       : operation === "resize"
255         ? state.options.resize
256         : state.options.rotate;
257   return options !== undefined && resolveSignalValue(options.enabled ?? true);
258 }
259 function validateSession(state: ManipulationState): Session | undefined {
260   const session = state.session;
261   if (!session) return;
262   if (!same(geometry(state), session.last)) {
263     cancel(state, "external-change");
264     return;
265   }
266   if (!enabled(state, session.operation)) {
267     cancel(state, "disabled");
268     return;
269   }
270   if (
271     !sameRootCoordinateMapping(
272       session.coordinateMapping,
273       currentRootCoordinateMapping(state.root),
274     ) ||
275     (state.coordinateMapping !== undefined &&
276       !sameRootCoordinateMapping(
277         session.coordinateMapping,
278         state.coordinateMapping,
279       ))
280   ) {
281     cancel(state, "coordinate-space-changed");
282     return;
283   }
284   if (
285     ["a", "b", "c", "d", "e", "f"].some(
286       (key) =>
287         Math.abs(state.parent[key as "a"] - session.parent[key as "a"]) >
288         1e-10 *
289           Math.max(
290             1,
291             Math.abs(state.parent[key as "a"]),
292             Math.abs(session.parent[key as "a"]),
293           ),
294     )
295   ) {
296     cancel(state, "coordinate-space-changed");
297     return;
298   }
299   return session;
300 }
301 function resolvedOptions(state: ManipulationState) {
302   const options = state.options;
303   const move = options.move && {
304     ...options.move,
305     snap: options.move.snap && {
306       ...options.move.snap,
307       enabled: resolveSignalValue(options.move.snap.enabled ?? true),
308     },
309   };
310   const resize = options.resize && {
311     ...options.resize,
312     snap: options.resize.snap && {
313       ...options.resize.snap,
314       enabled: resolveSignalValue(options.resize.snap.enabled ?? true),
315     },
316   };
317   const rotate = options.rotate && {
318     ...options.rotate,
319     snap: options.rotate.snap && {
320       ...options.rotate.snap,
321       enabled: resolveSignalValue(options.rotate.snap.enabled ?? true),
322     },
323   };
324   return { move, resize, rotate };
325 }
326 function apply(state: ManipulationState, pointer: PointerSample): void {
327   const session = validateSession(state);
328   if (!session || pointer.pointerId !== session.pointerId) return;
329   session.sample = {
330     pointerId: pointer.pointerId,
331     x: pointer.x,
332     y: pointer.y,
333     clientX: pointer.clientX,
334     clientY: pointer.clientY,
335     shiftKey: pointer.shiftKey,
336     altKey: pointer.altKey,
337     ctrlKey: pointer.ctrlKey,
338     metaKey: pointer.metaKey,
339   };
340   if (!session.started) {
341     if (
342       Math.hypot(
343         pointer.clientX - session.clientX,
344         pointer.clientY - session.clientY,
345       ) < 3
346     )
347       return;
348     session.started = true;
349     suppressManipulationActivation(state.root, session.pointerId);
350     state.active.set(
351       Object.freeze({
352         operation: session.operation,
353         handle: session.handle ?? null,
354         pointerId: session.pointerId,
355       }),
356     );
357     state.options.onStart?.(event(session));
358     if (validateSession(state) !== session) return;
359   }
360   const { move, resize, rotate } = resolvedOptions(state);
361   const currentPoint = session.parent
362     .inverse()
363     .transformPoint(new DOMPoint(pointer.x, pointer.y));
364   if (session.operation === "rotate") {
365     const centerX = session.before.box.left + session.before.box.width / 2;
366     const centerY = session.before.box.top + session.before.box.height / 2;
367     if (currentPoint.x !== centerX || currentPoint.y !== centerY) {
368       const angle = Math.atan2(
369         currentPoint.y - centerY,
370         currentPoint.x - centerX,
371       );
372       const delta = angle - session.angle;
373       session.rotationDelta += Math.atan2(Math.sin(delta), Math.cos(delta));
374       session.angle = angle;
375     }
376   }
377   let result: ReturnType<typeof solveManipulation>;
378   try {
379     result = solveManipulation({
380       operation: session.operation,
381       handle: session.handle,
382       initial: session.before.box,
383       initialPoint: session.point,
384       currentPoint,
385       initialRotation: session.before.rotation,
386       rotationDelta: session.rotationDelta,
387       modifiers: {
388         shift: pointer.shiftKey,
389         alt: pointer.altKey,
390         ctrl: pointer.ctrlKey,
391         meta: pointer.metaKey,
392       },
393       move,
394       resize,
395       rotate,
396     });
397   } catch {
398     // Options can change after activation; the original preview still belongs
399     // to this gesture and must be restored through the normal terminal path.
400     cancel(state, "constraints-unsatisfiable");
401     return;
402   }
403   const next = Object.freeze({
404     box: result.applied,
405     rotation: result.appliedRotation,
406   });
407   const changed = !same(next, session.last);
408   session.last = next;
409   session.snapshot = Object.freeze({
410     operation: session.operation,
411     handle: session.handle ?? null,
412     initial: session.before,
413     proposed: Object.freeze({
414       box: result.proposed,
415       rotation: result.proposedRotation,
416     }),
417     applied: next,
418     snapActive: result.snapActive,
419     snapSatisfied: result.snapSatisfied,
420   });
421   batch(() => {
422     if (changed) publish(state, next);
423     state.snapshot.set(session.snapshot);
424   });
425   if (validateSession(state) !== session) return;
426   if (changed) state.options.onChange?.(event(session));
427 }
428 function applySafely(state: ManipulationState, pointer: PointerSample): void {
429   try {
430     apply(state, pointer);
431   } catch (error) {
432     try {
433       cancel(state, "explicit");
434     } catch (cleanupError) {
435       throw new AggregateError(
436         [error, cleanupError],
437         "Manipulation callback and cleanup failed.",
438         { cause: cleanupError },
439       );
440     }
441     throw error;
442   }
443 }
444 function updateModifiers(
445   state: ManipulationState,
446   key: PibblKeyboardEvent,
447 ): void {
448   const sample = state.session?.sample;
449   if (!sample || !["Shift", "Alt", "Control", "Meta"].includes(key.key)) return;
450   applySafely(state, {
451     ...sample,
452     shiftKey: key.shiftKey,
453     altKey: key.altKey,
454     ctrlKey: key.ctrlKey,
455     metaKey: key.metaKey,
456   });
457 }
458 
459 /** @internal Identifies one private handle source for readout presentation. */
460 export function isManipulationSourceActive(
461   controller: PibblManipulationController,
462   source: object,
463 ): boolean {
464   const state = manipulationState(controller);
465   return state.session?.started === true && state.session.source === source;
466 }
467 
468 export function manipulationHandlers(
469   controller: PibblManipulationController,
470   operation: PibblManipulationOperation,
471   handle?: PibblResizePosition,
472   source?: object,
473 ): PibblEventHandlers {
474   const state = manipulationState(controller);
475   return {
476     onPointerDown(pointer) {
477       if (
478         !state.alive ||
479         state.session ||
480         pointer.defaultPrevented ||
481         claimedPresses.has(pointer.originalEvent) ||
482         pointer.button !== 0 ||
483         !enabled(state, operation)
484       )
485         return;
486       if (
487         owners.has(state.options.box) ||
488         (state.options.rotation && owners.has(state.options.rotation))
489       )
490         throw new Error(
491           "Pibbl manipulation geometry already has an active owner.",
492         );
493       const before = geometry(state);
494       const point = state.parent
495         .inverse()
496         .transformPoint(new DOMPoint(pointer.x, pointer.y));
497       if (!Number.isFinite(point.x) || !Number.isFinite(point.y))
498         throw new Error(
499           "Pibbl manipulation cannot use a singular parent transform.",
500         );
501       assertDirectSignalWriteAllowed(state.options.box);
502       if (state.options.rotation)
503         assertDirectSignalWriteAllowed(state.options.rotation);
504       const resolved = resolvedOptions(state);
505       if (
506         operation === "resize" &&
507         resolved.resize?.snap?.target === "grid" &&
508         (Math.abs(state.parent.b) > 1e-10 || Math.abs(state.parent.c) > 1e-10)
509       ) {
510         throw new Error(
511           "Grid resize snapping requires an aligned parent coordinate mapping.",
512         );
513       }
514       solveManipulation({
515         operation,
516         handle,
517         initial: before.box,
518         initialRotation: before.rotation,
519         initialPoint: point,
520         currentPoint: point,
521         modifiers: {},
522         ...resolved,
523       });
524       pointer.currentTarget.setPointerCapture(pointer.pointerId);
525       if (!pointer.currentTarget.hasPointerCapture(pointer.pointerId)) return;
526       const snapshot = Object.freeze({
527         operation,
528         handle: handle ?? null,
529         initial: before,
530         proposed: before,
531         applied: before,
532         snapActive: false,
533         snapSatisfied: false,
534       });
535       state.session = {
536         operation,
537         handle,
538         pointerId: pointer.pointerId,
539         target: pointer.currentTarget,
540         before,
541         last: before,
542         point,
543         clientX: pointer.clientX,
544         clientY: pointer.clientY,
545         parent: DOMMatrix.fromMatrix(state.parent),
546         started: false,
547         angle: Math.atan2(
548           point.y - before.box.top - before.box.height / 2,
549           point.x - before.box.left - before.box.width / 2,
550         ),
551         rotationDelta: 0,
552         snapshot,
553         coordinateMapping: currentRootCoordinateMapping(state.root),
554         source,
555       };
556       owners.set(state.options.box, state);
557       if (state.options.rotation) owners.set(state.options.rotation, state);
558       claimGeometryGeneration(state.options.box, state.options.rotation);
559       const operationOptions =
560         operation === "move"
561           ? state.options.move
562           : operation === "resize"
563             ? state.options.resize
564             : state.options.rotate;
565       const values: unknown[] = [
566         state.options.box,
567         state.options.rotation,
568         operationOptions?.enabled,
569       ];
570       const inputs = values.filter(isSignal);
571       state.session.releaseObservation = observeGestureInputs(inputs, () => {
572         validateSession(state);
573       });
574       claimedPresses.add(pointer.originalEvent);
575     },
576     onPointerMove(pointer) {
577       applySafely(state, pointer);
578     },
579     onPointerUp(pointer) {
580       if (state.session?.pointerId !== pointer.pointerId) return;
581       applySafely(state, pointer);
582       const session = state.session;
583       if (!session) return;
584       release(state, session);
585       state.active.set(null);
586       if (session.started)
587         state.options.onCommit?.(
588           Object.freeze({
589             ...event(session),
590             changed: !same(session.before, session.last),
591           }),
592         );
593     },
594     onPointerCancel(pointer) {
595       if (state.session?.pointerId === pointer.pointerId)
596         cancel(state, "pointer-cancel");
597     },
598     onLostPointerCapture(pointer) {
599       if (state.session?.pointerId === pointer.pointerId)
600         cancel(state, "capture-lost");
601     },
602     onKeyUp(key) {
603       updateModifiers(state, key);
604     },
605     onKeyDown(key) {
606       if (key.key === "Escape") cancel(state, "escape");
607       else updateModifiers(state, key);
608     },
609   };
610 }
611 

Documentation built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.