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