Skip to content

packages/core/src/lib/manipulation/handles.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 type { PibblResizePosition } from "./types.js";
2 import type { PibblManipulationWhen } from './types.js';
3 import type { PibblManipulableProps } from './manipulable.js';
4 import { createElement } from "../element/create-element.js";
5 import {
6   createGroupTransform,
7   setPrimitivePresentation,
8 } from "../primitive-presentation.js";
9 import { getCurrentWorldTransform } from "../render-target.js";
10 import { GLOBAL_STATE } from "../global-state.js";
11 import { definePrimitive } from "../define-primitive.js";
12 import { useEventListener } from "../hooks/use-event-listener.js";
13 import { useEventTarget } from "../hooks/use-event-target.js";
14 import { useSignal } from "../hooks/use-signal.js";
15 import { useConst } from "../hooks/use-const.js";
16 import type { GStyle } from "../components/g.js";
17 import { stripLayoutParticipation } from "../style/resolve-built-in.js";
18 import type { ResolvedDrawingStyle } from "../style/types.js";
19 import { getLayoutBox } from "../layout/context.js";
20 import type { PibblNode } from "../element/types.js";
21 import type { RenderingContext2D, SystemStyle } from "../types.js";
22 import type { PibblManipulationController } from "./controller-types.js";
23 import {
24   isManipulationSourceActive,
25   manipulationHandlers,
26   type ManipulationState,
27 } from "./controller.js";
28 import { requireManipulationFrame } from "./frame.js";
29 
30 /**
31  * Authored inputs for DragHandle, including the declared data and presentation options.
32  *
33  * @see {@link PibblManipulationController}
34  * @see {@link PibblNode}
35  * @see {@link DragHandle}
36  * @see {@link PibblManipulationWhen}
37  */
38 export interface PibblDragHandleProps {
39   /**
40    * Formats the value shown by the manipulation readout. See {@link PibblDragHandleProps}.
41    * @param value - Current left and top coordinates.
42    * @returns Text to display in the drag readout.
43    */
44   readonly formatReadout?: (value: {
45     /** Left edge coordinate or inset. See {@link PibblDragHandleProps}. */
46     readonly left: number;
47     /** Top edge value or top envelope guide. See {@link PibblDragHandleProps}. */
48     readonly top: number;
49   }) => string;
50   /**
51    * Controller owning the current interaction or mounted scenario. See
52    * {@link PibblManipulationController}.
53    */
54   readonly controller: PibblManipulationController;
55   /** Descendant content or the callback that supplies it. See {@link PibblNode}. */
56   readonly children?: PibblNode;
57 }
58 /**
59  * Supported geometry and presentation properties for DragHandle.
60  *
61  * @see {@link GStyle}
62  * @see {@link PibblManipulationReadoutStyle}
63  * @see {@link DragHandle}
64  * @see {@link PibblManipulableProps}
65  */
66 export interface PibblDragHandleStyle extends GStyle {
67   /** Presentation of the manipulation value readout. See {@link PibblManipulationReadoutStyle}. */
68   readonly readout?: false | true | PibblManipulationReadoutStyle;
69 }
70 type ResolvedDragStyle = ResolvedDrawingStyle<PibblDragHandleStyle>;
71 interface CssHandleContentProps {
72   readonly children?: PibblNode;
73 }
74 interface CssHandleContentStyle extends SystemStyle {
75   readonly transform: DOMMatrix;
76 }
77 /** Private CSS-pixel basis for author-provided handle visuals. */
78 const CssHandleContent = definePrimitive<
79   CssHandleContentProps,
80   CssHandleContentStyle
81 >(
82   function CssHandleContent(props) {
83     return props.children;
84   },
85   { childInput: "structural" },
86 );
87 setPrimitivePresentation<CssHandleContentStyle>(CssHandleContent, (style) => ({
88   transform: style.transform,
89 }));
90 
91 /**
92  * Draws an interactive handle that starts translation of its manipulation target.
93  *
94  * @param props - Authored component inputs, supplied through JSX or createElement. See the linked
95  * props and style types.
96  * @throws When called directly; Pibbl mounts this component through JSX or createElement.
97  *
98  * @see {@link PibblDragHandleProps}
99  * @see {@link PibblDragHandleStyle}
100  */
101 export const DragHandle = definePrimitive<
102   PibblDragHandleProps,
103   PibblDragHandleStyle,
104   PibblDragHandleStyle,
105   ResolvedDragStyle
106 >(
107   function DragHandle(props, style) {
108     requireManipulationFrame(props.controller);
109     const focused = useSignal(false);
110     const source = useConst(() => ({}));
111     const handlers = manipulationHandlers(
112       props.controller,
113       "move",
114       undefined,
115       source,
116     );
117     useEventListener({
118       ...handlers,
119       onFocusIn: () => focused.set(true),
120       onFocusOut: () => focused.set(false),
121     });
122     const box = getLayoutBox();
123     return [
124       props.children,
125       createElement(DragReadout, {
126         controller: props.controller,
127         formatReadout: props.formatReadout,
128         readout: style.readout,
129         focused: focused.get(),
130         source,
131         anchorX: box.width / 2,
132       }),
133     ];
134   },
135   {
136     childInput: "structural",
137     resolveStyle: (style) =>
138       stripLayoutParticipation(style) as ResolvedDragStyle,
139   },
140 );
141 
142 setPrimitivePresentation<ResolvedDragStyle>(DragHandle, (style) => ({
143   transform: createGroupTransform(style),
144 }));
145 
146 const DragReadout = definePrimitive<
147   PibblDragHandleProps & {
148     readonly readout?: false | true | PibblManipulationReadoutStyle;
149     readonly focused: boolean;
150     readonly source: object;
151     readonly anchorX: number;
152   }
153 >(function DragReadout(props, _style, ctx) {
154   if (!readoutEnabled(props.readout, props.formatReadout)) return;
155   const state = requireManipulationFrame(props.controller);
156   paintReadout(
157     ctx,
158     state,
159     cssPoint(state, props.anchorX, 0),
160     () => {
161       const { left, top } = state.options.box.get();
162       const value = props.formatReadout
163         ? props.formatReadout({ left, top })
164         : `left ${round(left)} · top ${round(top)} px`;
165       if (typeof value !== "string")
166         throw new Error(
167           "Manipulation readout formatReadout must return a string.",
168         );
169       return value;
170     },
171     props.readout,
172     props.focused,
173     isManipulationSourceActive(props.controller, props.source),
174     "top",
175     40,
176   );
177 });
178 
179 /**
180  * Supported geometry and presentation properties for ManipulationReadoutState.
181  *
182  * @see {@link PibblManipulationReadoutStyle}
183  */
184 export interface PibblManipulationReadoutStateStyle {
185   /** Minimum outward spacing in CSS pixels; defaults to 40 for drag, 5 otherwise. */
186   readonly gap?: number;
187   /**
188    * Canvas font string used to draw or measure text. See {@link PibblManipulationReadoutStateStyle}
189    * .
190    */
191   readonly font?: string;
192   /** Paint used for the interior. See {@link PibblManipulationReadoutStateStyle}. */
193   readonly fill?: string;
194   /** Paint used for the outline. See {@link PibblManipulationReadoutStateStyle}. */
195   readonly stroke?: string;
196   /** Width of the painted outline. See {@link PibblManipulationReadoutStateStyle}. */
197   readonly strokeWidth?: number;
198   /** Opacity of the painted result. See {@link PibblManipulationReadoutStateStyle}. */
199   readonly opacity?: number;
200   /** Whether this presentation is visible. See {@link PibblManipulationReadoutStateStyle}. */
201   readonly visible?: boolean;
202 }
203 /**
204  * Supported geometry and presentation properties for ManipulationReadout.
205  *
206  * @see {@link PibblManipulationReadoutStateStyle}
207  * @see {@link PibblDragHandleStyle}
208  * @see {@link PibblResizeHandleStyle}
209  * @see {@link PibblRotationHandleStyle}
210  */
211 export interface PibblManipulationReadoutStyle extends PibblManipulationReadoutStateStyle {
212   /**
213    * Readout presentation while no interaction is active. See
214    * {@link PibblManipulationReadoutStateStyle}.
215    */
216   readonly inactive?: PibblManipulationReadoutStateStyle;
217   /**
218    * Readout presentation while the handle has keyboard focus. See
219    * {@link PibblManipulationReadoutStateStyle}.
220    */
221   readonly focused?: PibblManipulationReadoutStateStyle;
222   /**
223    * Current active operation or its presentation configuration. See
224    * {@link PibblManipulationReadoutStateStyle}.
225    */
226   readonly active?: PibblManipulationReadoutStateStyle;
227 }
228 /**
229  * Supported geometry and presentation properties for ResizeHandle.
230  *
231  * @see {@link SystemStyle}
232  * @see {@link PibblManipulationReadoutStyle}
233  * @see {@link ResizeHandle}
234  * @see {@link PibblResizeHandlesProps}
235  */
236 export interface PibblResizeHandleStyle extends SystemStyle {
237   /** Presentation of the manipulation value readout. See {@link PibblManipulationReadoutStyle}. */
238   readonly readout?: false | true | PibblManipulationReadoutStyle;
239 }
240 /**
241  * Supported geometry and presentation properties for RotationConnector.
242  *
243  * @see {@link PibblRotationHandleStyle}
244  */
245 export interface PibblRotationConnectorStyle {
246   /** Paint used for the outline. See {@link PibblRotationConnectorStyle}. */
247   readonly stroke?: string;
248   /** Width of the painted outline. See {@link PibblRotationConnectorStyle}. */
249   readonly strokeWidth?: number;
250   /** Opacity of the painted result. See {@link PibblRotationConnectorStyle}. */
251   readonly opacity?: number;
252 }
253 /**
254  * Supported geometry and presentation properties for RotationArc.
255  *
256  * @see {@link PibblRotationHandleStyle}
257  */
258 export interface PibblRotationArcStyle {
259   /** Color used by the mark or effect. See {@link PibblRotationArcStyle}. */
260   readonly color?: string;
261   /** Angular extent of the arc. See {@link PibblRotationArcStyle}. */
262   readonly sweep?: number;
263   /** Width of the painted outline. See {@link PibblRotationArcStyle}. */
264   readonly strokeWidth?: number;
265   /** Opacity of the painted result. See {@link PibblRotationArcStyle}. */
266   readonly opacity?: number;
267 }
268 /**
269  * Supported geometry and presentation properties for RotationHandle.
270  *
271  * @see {@link SystemStyle}
272  * @see {@link PibblRotationConnectorStyle}
273  * @see {@link PibblRotationArcStyle}
274  * @see {@link PibblManipulationReadoutStyle}
275  * @see {@link RotationHandle}
276  * @see {@link PibblManipulableProps}
277  */
278 export interface PibblRotationHandleStyle extends SystemStyle {
279   /**
280    * Presentation of the line connecting the rotation handle to its target. See
281    * {@link PibblRotationConnectorStyle}.
282    */
283   readonly connector?: false | true | PibblRotationConnectorStyle;
284   /** Presentation of the rotation-angle arc. See {@link PibblRotationArcStyle}. */
285   readonly arc?: false | true | PibblRotationArcStyle;
286   /** Presentation of the manipulation value readout. See {@link PibblManipulationReadoutStyle}. */
287   readonly readout?: false | true | PibblManipulationReadoutStyle;
288 }
289 /**
290  * Authored inputs for ResizeHandle, including the declared data and presentation options.
291  *
292  * @see {@link PibblManipulationController}
293  * @see {@link PibblNode}
294  * @see {@link ResizeHandle}
295  * @see {@link PibblResizeHandlesProps}
296  * @see {@link PibblResizePosition}
297  */
298 export interface PibblResizeHandleProps {
299   /**
300    * Controller owning the current interaction or mounted scenario. See
301    * {@link PibblManipulationController}.
302    */
303   readonly controller: PibblManipulationController;
304   /**
305    * Position of the geometry, source, or selected resize handle. See {@link PibblResizeHandleProps}
306    * .
307    */
308   readonly position: import("./types.js").PibblResizePosition;
309   /** Size of the handle's interactive hit region. See {@link PibblResizeHandleProps}. */
310   readonly hitSize?: number;
311   /**
312    * Formats the value shown by the manipulation readout. See {@link PibblResizeHandleProps}.
313    * @param value - Current width and height.
314    * @returns Text to display in the resize readout.
315    */
316   readonly formatReadout?: (value: {
317     /**
318      * Horizontal extent in the units of the containing geometry or surface. See
319      * {@link PibblResizeHandleProps}.
320      */
321     readonly width: number;
322     /**
323      * Vertical extent in the units of the containing geometry or surface. See
324      * {@link PibblResizeHandleProps}.
325      */
326     readonly height: number;
327   }) => string;
328   /** Descendant content or the callback that supplies it. See {@link PibblNode}. */
329   readonly children?: PibblNode;
330 }
331 /**
332  * Authored inputs for ResizeHandles, including the declared data and presentation options.
333  *
334  * @see {@link PibblManipulationController}
335  * @see {@link PibblResizeHandleStyle}
336  * @see {@link PibblResizeHandleProps}
337  * @see {@link ResizeHandles}
338  * @see {@link PibblManipulableProps}
339  * @see {@link PibblResizePosition}
340  */
341 export interface PibblResizeHandlesProps {
342   /**
343    * Controller owning the current interaction or mounted scenario. See
344    * {@link PibblManipulationController}.
345    */
346   readonly controller: PibblManipulationController;
347   /** Edges and corners at which resize handles are created. See {@link PibblResizeHandlesProps}. */
348   readonly positions?:
349     "corners" | "edges-and-corners" | readonly import("./types.js").PibblResizePosition[];
350   /** Size of the handle's interactive hit region. See {@link PibblResizeHandlesProps}. */
351   readonly hitSize?: number;
352   /** Declared presentation and layout properties. See {@link PibblResizeHandleStyle}. */
353   readonly style?: PibblResizeHandleStyle;
354   /** Formats the value shown by the manipulation readout. See {@link PibblResizeHandleProps}. */
355   readonly formatReadout?: PibblResizeHandleProps["formatReadout"];
356 }
357 const corners = [
358   "top-left",
359   "top-right",
360   "bottom-right",
361   "bottom-left",
362 ] as const;
363 const allHandles = [
364   "top-left",
365   "top",
366   "top-right",
367   "right",
368   "bottom-right",
369   "bottom",
370   "bottom-left",
371   "left",
372 ] as const;
373 type ResizePosition = (typeof allHandles)[number];
374 interface CollectionReadout {
375   readonly controller: PibblManipulationController;
376   readonly positions: readonly ResizePosition[];
377   readonly owner: ResizePosition;
378   readonly style: PibblResizeHandleStyle | undefined;
379   readonly formatReadout: PibblResizeHandleProps["formatReadout"];
380   readonly focused: Map<ResizePosition, boolean>;
381 }
382 const collectionStack: CollectionReadout[] = [];
383 const ResizeCollection = definePrimitive<{
384   readonly collection: CollectionReadout;
385   readonly children?: PibblNode;
386 }>(
387   function ResizeCollection(props) {
388     collectionStack.push(props.collection);
389     GLOBAL_STATE.componentRefs!.onAfterRender = () => {
390       const collection = collectionStack.pop();
391       if (collection !== props.collection)
392         throw new Error(
393           "Manipulation resize collection render context was unbalanced.",
394         );
395     };
396     return props.children;
397   },
398   { childInput: "structural" },
399 );
400 
401 /**
402  * Draws an interactive handle for one edge or corner of a manipulation target.
403  *
404  * @param props - Authored component inputs, supplied through JSX or createElement. See the linked
405  * props and style types.
406  * @throws When called directly; Pibbl mounts this component through JSX or createElement.
407  *
408  * @see {@link PibblResizeHandleProps}
409  * @see {@link PibblResizeHandleStyle}
410  */
411 export const ResizeHandle = definePrimitive<
412   PibblResizeHandleProps,
413   PibblResizeHandleStyle
414 >(function ResizeHandle(props, style, ctx) {
415   const state = requireManipulationFrame(props.controller);
416   const box = getLayoutBox();
417   const position = props.position as ResizePosition;
418   if (!allHandles.includes(position))
419     throw new Error("ResizeHandle requires a valid edge or corner position.");
420   const x = position.includes("left")
421     ? 0
422     : position.includes("right")
423       ? box.width
424       : box.width / 2;
425   const y = position.includes("top")
426     ? 0
427     : position.includes("bottom")
428       ? box.height
429       : box.height / 2;
430   const hitSize = props.hitSize ?? 24;
431   if (!Number.isFinite(hitSize) || hitSize <= 0)
432     throw new Error("ResizeHandle hitSize must be positive finite CSS pixels.");
433   const handlers = manipulationHandlers(props.controller, "resize", position);
434   useEventListener(handlers);
435   const focus = useEventTarget(handlers, {
436     path: cssHandlePath(state, x, y, hitSize),
437     fill: true,
438     cursor:
439       position === "left" || position === "right"
440         ? "ew-resize"
441         : position === "top" || position === "bottom"
442           ? "ns-resize"
443           : position === "top-left" || position === "bottom-right"
444             ? "nwse-resize"
445             : "nesw-resize",
446     keyboardFocusable: true,
447     keyboardNavigationBounds: { x: x - 4, y: y - 4, width: 8, height: 8 },
448   });
449   const active = state.controller.active.get();
450   const relevant = active?.operation === "resize" && active.handle === position;
451   const collection = collectionStack.at(-1);
452   if (
453     collection?.controller === props.controller &&
454     collection.positions.includes(position)
455   ) {
456     collection.focused.set(position, focus.isFocused);
457   } else if (readoutEnabled(style.readout, props.formatReadout)) {
458     paintReadout(
459       ctx,
460       state,
461       cssPoint(state, x, y),
462       () => formatResize(state, props.formatReadout),
463       style.readout,
464       focus.isFocused,
465       relevant,
466       position,
467     );
468   }
469   if (props.children !== undefined)
470     return createElement(CssHandleContent, {
471       style: { transform: cssHandleChildTransform(state, x, y) },
472       children: props.children,
473     });
474   paintDefaultHandle(ctx, state, x, y);
475 });
476 /**
477  * Creates the configured collection of resize handles around a manipulation target.
478  *
479  * @param props - Controller, handle placement, and presentation options. See
480  * {@link PibblResizeHandlesProps} .
481  * @returns Pibbl nodes rendering the resize handles. See {@link PibblNode}.
482  *
483  * @see {@link PibblResizeHandlesProps}
484  * @see {@link PibblNode}
485  */
486 export function ResizeHandles(props: PibblResizeHandlesProps): PibblNode {
487   const positions = (
488     props.positions === undefined || props.positions === "corners"
489       ? corners
490       : props.positions === "edges-and-corners"
491         ? allHandles
492         : props.positions
493   ) as readonly ResizePosition[];
494   if (positions.length === 0) return [];
495   const owner = positions.includes("bottom-right")
496     ? "bottom-right"
497     : positions.at(-1)!;
498   const collection: CollectionReadout = {
499     controller: props.controller,
500     positions,
501     owner,
502     style: props.style,
503     formatReadout: props.formatReadout,
504     focused: new Map(),
505   };
506   return createElement(ResizeCollection, {
507     collection,
508     children: [
509       ...positions.map((position) =>
510         createElement(ResizeHandle, {
511           key: position,
512           controller: props.controller,
513           position,
514           hitSize: props.hitSize,
515           style: props.style,
516           formatReadout: props.formatReadout,
517         }),
518       ),
519       createElement(ResizeCollectionReadout, {
520         key: "readout",
521         controller: props.controller,
522         collection,
523       }),
524     ],
525   });
526 }
527 const ResizeCollectionReadout = definePrimitive<{
528   readonly controller: PibblManipulationController;
529   readonly collection: CollectionReadout;
530 }>(function ResizeCollectionReadout(props, _style, ctx) {
531   const state = requireManipulationFrame(props.controller);
532   if (
533     !readoutEnabled(
534       props.collection.style?.readout,
535       props.collection.formatReadout,
536     )
537   )
538     return;
539   const active = state.controller.active.get();
540   const position =
541     active?.operation === "resize" &&
542     active.handle &&
543     props.collection.positions.includes(active.handle)
544       ? active.handle
545       : (props.collection.positions.find((candidate) =>
546           props.collection.focused.get(candidate),
547         ) ?? props.collection.owner);
548   const box = getLayoutBox();
549   const x = position.includes("left")
550     ? 0
551     : position.includes("right")
552       ? box.width
553       : box.width / 2;
554   const y = position.includes("top")
555     ? 0
556     : position.includes("bottom")
557       ? box.height
558       : box.height / 2;
559   paintReadout(
560     ctx,
561     state,
562     cssPoint(state, x, y),
563     () => formatResize(state, props.collection.formatReadout),
564     props.collection.style?.readout,
565     props.collection.focused.get(position) ?? false,
566     active?.operation === "resize" && active.handle === position,
567     position,
568   );
569 });
570 
571 /**
572  * Authored inputs for RotationHandle, including the declared data and presentation options.
573  *
574  * @see {@link PibblManipulationController}
575  * @see {@link PibblNode}
576  * @see {@link RotationHandle}
577  * @see {@link PibblManipulableProps}
578  */
579 export interface PibblRotationHandleProps {
580   /**
581    * Controller owning the current interaction or mounted scenario. See
582    * {@link PibblManipulationController}.
583    */
584   readonly controller: PibblManipulationController;
585   /** Size of the handle's interactive hit region. See {@link PibblRotationHandleProps}. */
586   readonly hitSize?: number;
587   /** Selects `"degrees"`, `"radians"` for readout unit. See {@link PibblRotationHandleProps}. */
588   readonly readoutUnit?: "degrees" | "radians";
589   /**
590    * Formats the value shown by the manipulation readout. See {@link PibblRotationHandleProps}.
591    * @param value - Current rotation.
592    * @returns Text to display in the rotation readout.
593    */
594   readonly formatReadout?: (value: {
595     /** Rotation applied to the containing geometry. See {@link PibblRotationHandleProps}. */
596     readonly rotation: number;
597   }) => string;
598   /** Descendant content or the callback that supplies it. See {@link PibblNode}. */
599   readonly children?: PibblNode;
600 }
601 /**
602  * Draws an interactive handle for rotating a manipulation target.
603  *
604  * @param props - Authored component inputs, supplied through JSX or createElement. See the linked
605  * props and style types.
606  * @throws When called directly; Pibbl mounts this component through JSX or createElement.
607  *
608  * @see {@link PibblRotationHandleProps}
609  * @see {@link PibblRotationHandleStyle}
610  */
611 export const RotationHandle = definePrimitive<
612   PibblRotationHandleProps,
613   PibblRotationHandleStyle
614 >(function RotationHandle(props, style, ctx) {
615   const state = requireManipulationFrame(props.controller);
616   const box = getLayoutBox();
617   const anchor = rotationAnchor(state, box.width, box.height);
618   const hitSize = props.hitSize ?? 24;
619   if (!Number.isFinite(hitSize) || hitSize <= 0)
620     throw new Error(
621       "RotationHandle hitSize must be positive finite CSS pixels.",
622     );
623   const handlers = manipulationHandlers(props.controller, "rotate");
624   useEventListener(handlers);
625   const focus = useEventTarget(handlers, {
626     path: cssHandlePath(state, anchor.local.x, anchor.local.y, hitSize),
627     fill: true,
628     cursor: "grab",
629     keyboardFocusable: true,
630     keyboardNavigationBounds: {
631       x: anchor.local.x - 4,
632       y: anchor.local.y - 4,
633       width: 8,
634       height: 8,
635     },
636   });
637   const relevant = state.controller.active.get()?.operation === "rotate";
638   paintRotationDecorations(ctx, state, anchor, style);
639   if (readoutEnabled(style.readout, props.formatReadout || props.readoutUnit))
640     paintReadout(
641       ctx,
642       state,
643       anchor.css,
644       () => formatRotation(state, props),
645       style.readout,
646       focus.isFocused,
647       relevant,
648       "top",
649     );
650   if (props.children !== undefined)
651     return createElement(CssHandleContent, {
652       style: {
653         transform: cssHandleChildTransform(
654           state,
655           anchor.local.x,
656           anchor.local.y,
657         ),
658       },
659       children: props.children,
660     });
661   paintDefaultHandle(ctx, state, anchor.local.x, anchor.local.y);
662 });
663 
664 function frameToCss(state: ManipulationState): DOMMatrix {
665   const surface = state.root.surface.snapshot;
666   const world = getCurrentWorldTransform();
667   return surface
668     ? new DOMMatrix()
669         .scale(
670           1 / surface.backingPixelsPerCssPixelX,
671           1 / surface.backingPixelsPerCssPixelY,
672         )
673         .multiply(surface.renderTransform)
674         .multiply(world)
675     : world;
676 }
677 function cssPoint(state: ManipulationState, x: number, y: number): DOMPoint {
678   return frameToCss(state).transformPoint(new DOMPoint(x, y));
679 }
680 function cssBasis(state: ManipulationState): {
681   css: DOMMatrix;
682   inverse: DOMMatrix;
683   angle: number;
684 } {
685   const css = frameToCss(state);
686   const inverse = new DOMMatrix([css.a, css.b, css.c, css.d, 0, 0]).inverse();
687   if (![inverse.a, inverse.b, inverse.c, inverse.d].every(Number.isFinite))
688     throw new Error(
689       "Manipulation handle cannot use a singular presentation transform.",
690     );
691   return { css, inverse, angle: Math.atan2(css.b, css.a) };
692 }
693 function cssHandlePath(
694   state: ManipulationState,
695   x: number,
696   y: number,
697   size: number,
698 ): Path2D {
699   const { inverse, angle } = cssBasis(state);
700   const path = new Path2D();
701   const half = size / 2;
702   const cos = Math.cos(angle),
703     sin = Math.sin(angle);
704   for (const [i, point] of [
705     [-half, -half],
706     [half, -half],
707     [half, half],
708     [-half, half],
709   ].entries()) {
710     const sx = point[0] * cos - point[1] * sin,
711       sy = point[0] * sin + point[1] * cos;
712     const local = inverse.transformPoint(new DOMPoint(sx, sy));
713     if (i === 0) path.moveTo(x + local.x, y + local.y);
714     else path.lineTo(x + local.x, y + local.y);
715   }
716   path.closePath();
717   return path;
718 }
719 function cssHandleChildTransform(
720   state: ManipulationState,
721   x: number,
722   y: number,
723 ): DOMMatrix {
724   const { inverse, angle } = cssBasis(state);
725   const rotate = new DOMMatrix().rotate((angle * 180) / Math.PI);
726   const basis = inverse.multiply(rotate);
727   return new DOMMatrix([basis.a, basis.b, basis.c, basis.d, x, y]);
728 }
729 function paintDefaultHandle(
730   ctx: RenderingContext2D,
731   state: ManipulationState,
732   x: number,
733   y: number,
734 ): void {
735   ctx.fillStyle = "#2563eb";
736   ctx.fill(cssHandlePath(state, x, y, 8));
737   ctx.fillStyle = "#ffffff";
738   ctx.fill(cssHandlePath(state, x, y, 6));
739 }
740 
741 function rotationAnchor(
742   state: ManipulationState,
743   width: number,
744   height: number,
745 ) {
746   const css = frameToCss(state);
747   const top = css.transformPoint(new DOMPoint(width / 2, 0));
748   const pivot = css.transformPoint(new DOMPoint(width / 2, height / 2));
749   const edge = new DOMPoint(css.a, css.b);
750   const mappedOut = new DOMPoint(-css.c, -css.d);
751   let normal = new DOMPoint(-edge.y, edge.x);
752   const length = Math.hypot(normal.x, normal.y);
753   if (!Number.isFinite(length) || length === 0)
754     throw new Error(
755       "RotationHandle cannot use a singular presentation transform.",
756     );
757   normal = new DOMPoint(normal.x / length, normal.y / length);
758   if (normal.x * mappedOut.x + normal.y * mappedOut.y < 0)
759     normal = new DOMPoint(-normal.x, -normal.y);
760   const handle = new DOMPoint(top.x + normal.x * 24, top.y + normal.y * 24);
761   const local = css.inverse().transformPoint(handle);
762   return { css: handle, local, top, pivot, normal };
763 }
764 function paintRotationDecorations(
765   ctx: RenderingContext2D,
766   state: ManipulationState,
767   anchor: ReturnType<typeof rotationAnchor>,
768   style: PibblRotationHandleStyle,
769 ): void {
770   validateRotationStyle(style);
771   const connector =
772     style.connector === undefined || style.connector === true
773       ? {}
774       : style.connector;
775   const arc = style.arc === true ? {} : style.arc;
776   if (connector === false && !arc) return;
777   withCssContext(ctx, state, () => {
778     const baseAlpha = ctx.globalAlpha;
779     if (connector !== false) {
780       ctx.strokeStyle = connector.stroke ?? "#2563eb";
781       ctx.lineWidth = connector.strokeWidth ?? 1;
782       ctx.globalAlpha *= connector.opacity ?? 1;
783       ctx.beginPath();
784       ctx.moveTo(anchor.top.x, anchor.top.y);
785       ctx.lineTo(anchor.css.x, anchor.css.y);
786       ctx.stroke();
787       ctx.globalAlpha = baseAlpha;
788     }
789     if (arc) {
790       const radius = Math.hypot(
791         anchor.css.x - anchor.pivot.x,
792         anchor.css.y - anchor.pivot.y,
793       );
794       const center = Math.atan2(
795         anchor.css.y - anchor.pivot.y,
796         anchor.css.x - anchor.pivot.x,
797       );
798       const sweep = arc.sweep ?? Math.PI / 3;
799       for (let i = 0; i < 24; i++) {
800         const a0 = center - sweep / 2 + (sweep * i) / 24,
801           a1 = center - sweep / 2 + (sweep * (i + 1)) / 24;
802         const alpha = (1 - Math.abs((i + 0.5) / 12 - 1)) * (arc.opacity ?? 1);
803         ctx.strokeStyle = arc.color ?? "#2563eb";
804         ctx.lineWidth = arc.strokeWidth ?? 1;
805         ctx.globalAlpha = baseAlpha * alpha;
806         ctx.beginPath();
807         ctx.arc(anchor.pivot.x, anchor.pivot.y, radius, a0, a1);
808         ctx.stroke();
809       }
810       ctx.globalAlpha = baseAlpha;
811     }
812   });
813 }
814 function validateReadoutStyle(
815   value: false | true | PibblManipulationReadoutStyle | undefined,
816 ): void {
817   if (!value || value === true) return;
818   for (const style of [value, value.inactive, value.focused, value.active]) {
819     if (!style) continue;
820     if (
821       style.gap !== undefined &&
822       (!Number.isFinite(style.gap) || style.gap < 0)
823     )
824       throw new Error(
825         "Manipulation readout gap must be finite non-negative CSS pixels.",
826       );
827     if (
828       style.strokeWidth !== undefined &&
829       (!Number.isFinite(style.strokeWidth) || style.strokeWidth < 0)
830     )
831       throw new Error(
832         "Manipulation readout strokeWidth must be finite and non-negative.",
833       );
834     if (
835       style.opacity !== undefined &&
836       (!Number.isFinite(style.opacity) || style.opacity < 0)
837     )
838       throw new Error(
839         "Manipulation readout opacity must be finite and non-negative.",
840       );
841   }
842 }
843 function validateRotationStyle(style: PibblRotationHandleStyle): void {
844   const connector =
845     style.connector && style.connector !== true ? style.connector : undefined;
846   const arc = style.arc && style.arc !== true ? style.arc : undefined;
847   for (const value of [connector?.strokeWidth, arc?.strokeWidth])
848     if (value !== undefined && (!Number.isFinite(value) || value < 0))
849       throw new Error(
850         "Manipulation guide strokeWidth must be finite and non-negative.",
851       );
852   for (const value of [connector?.opacity, arc?.opacity])
853     if (value !== undefined && (!Number.isFinite(value) || value < 0))
854       throw new Error(
855         "Manipulation guide opacity must be finite and non-negative.",
856       );
857   if (
858     arc?.sweep !== undefined &&
859     (!Number.isFinite(arc.sweep) || arc.sweep <= 0 || arc.sweep > Math.PI * 2)
860   )
861     throw new Error(
862       "Manipulation arc sweep must be finite, positive, and no greater than 2π radians.",
863     );
864 }
865 
866 function readoutEnabled(
867   readout: false | true | PibblManipulationReadoutStyle | undefined,
868   formatter: unknown,
869 ): boolean {
870   return (
871     readout !== false && (readout !== undefined || formatter !== undefined)
872   );
873 }
874 function formatResize(
875   state: ManipulationState,
876   format: PibblResizeHandleProps["formatReadout"],
877 ): string {
878   const box = state.options.box.get();
879   const value = format
880     ? format({ width: box.width, height: box.height })
881     : `${round(box.width)} × ${round(box.height)} px`;
882   if (typeof value !== "string")
883     throw new Error("Manipulation readout formatReadout must return a string.");
884   return value;
885 }
886 function formatRotation(
887   state: ManipulationState,
888   props: PibblRotationHandleProps,
889 ): string {
890   const rotation = state.options.rotation?.get() ?? 0;
891   const value = props.formatReadout
892     ? props.formatReadout({ rotation })
893     : props.readoutUnit === "radians"
894       ? `${round(rotation)} rad`
895       : `${round((rotation * 180) / Math.PI)}°`;
896   if (typeof value !== "string")
897     throw new Error("Manipulation readout formatReadout must return a string.");
898   return value;
899 }
900 function round(value: number): string {
901   return Number.isInteger(value)
902     ? String(value)
903     : String(Number(value.toFixed(2)));
904 }
905 function paintReadout(
906   ctx: RenderingContext2D,
907   state: ManipulationState,
908   anchor: DOMPoint,
909   format: () => string,
910   readout: false | true | PibblManipulationReadoutStyle | undefined,
911   focused: boolean,
912   active: boolean,
913   position?: ResizePosition,
914   defaultGap = 5,
915 ): void {
916   validateReadoutStyle(readout);
917   const resolved = resolveReadout(
918     readout,
919     state.controller.active.get() === null,
920     focused,
921     active,
922     defaultGap,
923   );
924   if (!resolved.visible) return;
925   const text = format();
926   if (!text) return;
927   const frameMapping = frameToCss(state);
928   withCssContext(ctx, state, () => {
929     ctx.font = resolved.font;
930     ctx.textAlign = "left";
931     ctx.textBaseline = "alphabetic";
932     const surface = state.root.surface.snapshot;
933     if (!surface) return;
934     const stroke = resolved.stroke ? resolved.strokeWidth : 0;
935     const available = surface.displayWidth - 16 - stroke;
936     const value = ellipsis(ctx, text, available);
937     if (!value) return;
938     const metrics = measureLabel(ctx, value);
939     const glyphLeft = Math.max(0, metrics.actualBoundingBoxLeft);
940     const glyphRight = Math.max(metrics.width, metrics.actualBoundingBoxRight);
941     const ascent = Math.max(0, metrics.actualBoundingBoxAscent);
942     const descent = Math.max(0, metrics.actualBoundingBoxDescent);
943     const height = ascent + descent + 6 + stroke;
944     const width = glyphLeft + glyphRight + 8 + stroke;
945     if (width > surface.displayWidth - 8 || height > surface.displayHeight - 8)
946       return;
947     const offset = outwardReadoutOffset(
948       frameMapping,
949       position,
950       width,
951       height,
952       resolved.gap,
953     );
954     let left = anchor.x + offset.x - width / 2;
955     let top = anchor.y + offset.y - height / 2;
956     left = Math.max(4, Math.min(surface.displayWidth - 4 - width, left));
957     top = Math.max(4, Math.min(surface.displayHeight - 4 - height, top));
958     ctx.globalAlpha *= resolved.opacity;
959     ctx.fillStyle = resolved.fill;
960     if (resolved.stroke) {
961       ctx.strokeStyle = resolved.stroke;
962       ctx.lineWidth = resolved.strokeWidth;
963       ctx.strokeText(
964         value,
965         left + 4 + glyphLeft + stroke / 2,
966         top + 3 + ascent + stroke / 2,
967       );
968     }
969     ctx.fillText(
970       value,
971       left + 4 + glyphLeft + stroke / 2,
972       top + 3 + ascent + stroke / 2,
973     );
974   });
975 }
976 /** Separate the upright label from the selected edges in displayed CSS space. */
977 function outwardReadoutOffset(
978   frameMapping: DOMMatrix,
979   position: ResizePosition | undefined,
980   width: number,
981   height: number,
982   gap: number,
983 ): DOMPoint {
984   if (!position) return new DOMPoint(width / 2 + gap, 0);
985   const inverse = frameMapping.inverse();
986   const normals: DOMPoint[] = [];
987   const add = (x: number, y: number, sign: number) => {
988     const length = Math.hypot(x, y);
989     normals.push(new DOMPoint((sign * x) / length, (sign * y) / length));
990   };
991   if (position.includes("left")) add(inverse.a, inverse.c, -1);
992   if (position.includes("right")) add(inverse.a, inverse.c, 1);
993   if (position.includes("top")) add(inverse.b, inverse.d, -1);
994   if (position.includes("bottom")) add(inverse.b, inverse.d, 1);
995   const support = (n: DOMPoint) =>
996     (Math.abs(n.x) * width) / 2 + (Math.abs(n.y) * height) / 2 + gap;
997   const [a, b] = normals;
998   const da = support(a);
999   if (!b) return new DOMPoint(a.x * da, a.y * da);
1000   const db = support(b),
1001     determinant = a.x * b.y - a.y * b.x;
1002   return new DOMPoint(
1003     (da * b.y - a.y * db) / determinant,
1004     (a.x * db - da * b.x) / determinant,
1005   );
1006 }
1007 function resolveReadout(
1008   value: false | true | PibblManipulationReadoutStyle | undefined,
1009   inactive: boolean,
1010   focused: boolean,
1011   active: boolean,
1012   defaultGap: number,
1013 ): Required<PibblManipulationReadoutStateStyle> {
1014   const source = value && value !== true ? value : {};
1015   const base: Required<PibblManipulationReadoutStateStyle> = {
1016     gap: source.gap ?? defaultGap,
1017     font: source.font ?? "12px sans-serif",
1018     fill: source.fill ?? "#1d4ed8",
1019     stroke: source.stroke ?? "",
1020     strokeWidth: source.strokeWidth ?? 0,
1021     opacity: source.opacity ?? 1,
1022     visible: source.visible ?? false,
1023   };
1024   const patch = (next: PibblManipulationReadoutStateStyle | undefined) => {
1025     if (next) Object.assign(base, next);
1026   };
1027   if (inactive) patch(source.inactive);
1028   if (focused) patch(source.focused);
1029   if (active) (patch({ visible: true }), patch(source.active));
1030   return base;
1031 }
1032 const labelMeasurements = new WeakMap<
1033   RenderingContext2D,
1034   Map<string, TextMetrics>
1035 >();
1036 function measureLabel(ctx: RenderingContext2D, text: string): TextMetrics {
1037   let cache = labelMeasurements.get(ctx);
1038   if (!cache) labelMeasurements.set(ctx, (cache = new Map()));
1039   const key = JSON.stringify([
1040     ctx.font,
1041     ctx.direction,
1042     ctx.fontKerning,
1043     ctx.letterSpacing,
1044     ctx.wordSpacing,
1045     text,
1046   ]);
1047   const found = cache.get(key);
1048   if (found) return found;
1049   const result = ctx.measureText(text);
1050   if (cache.size >= 64) cache.delete(cache.keys().next().value!);
1051   cache.set(key, result);
1052   return result;
1053 }
1054 function ellipsis(
1055   ctx: RenderingContext2D,
1056   text: string,
1057   width: number,
1058 ): string {
1059   const fits = (value: string) => {
1060     const m = measureLabel(ctx, value);
1061     return (
1062       Math.max(0, m.actualBoundingBoxLeft) +
1063         Math.max(m.width, m.actualBoundingBoxRight) <=
1064       width
1065     );
1066   };
1067   if (width <= 0) return "";
1068   if (fits(text)) return text;
1069   const suffix = "…";
1070   if (!fits(suffix)) return "";
1071   const points = Array.from(text);
1072   let low = 0,
1073     high = points.length;
1074   while (low < high) {
1075     const mid = Math.ceil((low + high) / 2);
1076     if (fits(points.slice(0, mid).join("") + suffix)) low = mid;
1077     else high = mid - 1;
1078   }
1079   return points.slice(0, low).join("") + suffix;
1080 }
1081 function withCssContext(
1082   ctx: RenderingContext2D,
1083   state: ManipulationState,
1084   draw: () => void,
1085 ): void {
1086   const surface = state.root.surface.snapshot;
1087   if (!surface) return;
1088   const cssToCurrent = ctx.getTransform().multiply(frameToCss(state).inverse());
1089   ctx.save();
1090   try {
1091     ctx.setTransform(cssToCurrent);
1092     ctx.beginPath();
1093     ctx.rect(0, 0, surface.displayWidth, surface.displayHeight);
1094     ctx.clip();
1095     draw();
1096   } finally {
1097     ctx.restore();
1098   }
1099 }
1100 

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