packages/core/src/lib/manipulation/controller.ts
This is the source snapshot used to build these API details. View this revision on GitHub.
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 version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.