pts 0.12.8 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/UI.ts ADDED
@@ -0,0 +1,757 @@
1
+ /*! Pts.js is licensed under Apache License 2.0. Copyright © 2017-current William Ngan and contributors. (https://github.com/williamngan/pts) */
2
+
3
+ import { Pt, Group } from "./Pt";
4
+ import { Rectangle, Circle, Polygon } from "./Op";
5
+ import {
6
+ type UIHandler,
7
+ type UIActionEvent,
8
+ type GroupLike,
9
+ type PtLike,
10
+ type PtLikeIterable,
11
+ } from "./Types";
12
+
13
+ /** A hit-test function for a UI shape: given the UI's group, a point, and the UI's states, return whether the point is within the shape. */
14
+ export type UIShapeTest = (
15
+ group: Group,
16
+ pt: PtLike,
17
+ states: { [key: string]: any },
18
+ ) => boolean;
19
+
20
+ function _withinSegment(
21
+ a: PtLike,
22
+ b: PtLike,
23
+ pt: PtLike,
24
+ threshold: number,
25
+ ): boolean {
26
+ if (threshold < 0) return false;
27
+ const dx = b[0] - a[0];
28
+ const dy = b[1] - a[1];
29
+ const lengthSq = dx * dx + dy * dy;
30
+ const t =
31
+ lengthSq === 0
32
+ ? 0
33
+ : Math.max(
34
+ 0,
35
+ Math.min(1, ((pt[0] - a[0]) * dx + (pt[1] - a[1]) * dy) / lengthSq),
36
+ );
37
+ const px = pt[0] - a[0] - t * dx;
38
+ const py = pt[1] - a[1] - t * dy;
39
+ return px * px + py * py <= threshold * threshold;
40
+ }
41
+
42
+ // Shape hit tests, keyed by shape name. Extensible via `UI.registerShape`.
43
+ const _shapeTests: { [key: string]: UIShapeTest } = {
44
+ rectangle: (group, pt) => Rectangle.withinBound(group, pt),
45
+ circle: (group, pt) => Circle.withinBound(group, pt),
46
+ polygon: (group, pt) => Polygon.hasIntersectPoint(group, pt),
47
+ line: (group, pt, states) => {
48
+ const threshold = states.lineThreshold ?? 5;
49
+ return (
50
+ group.length >= 2 && _withinSegment(group[0], group[1], pt, threshold)
51
+ );
52
+ },
53
+ polyline: (group, pt, states) => {
54
+ const threshold = states.lineThreshold ?? 5;
55
+ for (let i = 0, len = group.length - 1; i < len; i++) {
56
+ if (_withinSegment(group[i], group[i + 1], pt, threshold)) return true;
57
+ }
58
+ return false;
59
+ },
60
+ };
61
+
62
+ /**
63
+ * **[Experimental]** A set of string constatns to represent different UI types, for use in [`UI`](#link) instances.
64
+ */
65
+ export const UIShape = {
66
+ rectangle: "rectangle",
67
+ circle: "circle",
68
+ polygon: "polygon",
69
+ polyline: "polyline",
70
+ line: "line",
71
+ };
72
+
73
+ /**
74
+ * **[Experimental]** A set of string constants to represent different UI event types.
75
+ */
76
+ export const UIPointerActions = {
77
+ up: "up",
78
+ down: "down",
79
+ move: "move",
80
+ drag: "drag",
81
+ uidrag: "uidrag",
82
+ drop: "drop",
83
+ uidrop: "uidrop",
84
+ over: "over",
85
+ out: "out",
86
+ enter: "enter",
87
+ leave: "leave",
88
+ click: "click",
89
+ keydown: "keydown",
90
+ keyup: "keyup",
91
+ pointerdown: "pointerdown",
92
+ pointerup: "pointerup",
93
+ contextmenu: "contextmenu",
94
+ all: "all",
95
+ } as const;
96
+
97
+ /** A known pointer, touch, or keyboard action dispatched by a Pts space. */
98
+ export type UIPointerAction =
99
+ (typeof UIPointerActions)[keyof typeof UIPointerActions];
100
+
101
+ /**
102
+ * **[Experimental]** An abstract class that represents an UI element. It wraps a [`Group`](#link) and supports UI event handling.
103
+ * Extend this class to create custom UI elements.
104
+ */
105
+ export class UI {
106
+ private _abortCleanup: { [type: string]: Map<number, () => void> } = {};
107
+ _group: Group;
108
+ _shape: string;
109
+
110
+ protected static _counter: number = 0;
111
+ protected _id: string;
112
+ protected _actions: { [type: string]: (UIHandler | null)[] };
113
+ // built-in machinery (UIButton hover, UIDragger drag) registers here, so
114
+ // public `off(type)` cannot remove it along with user handlers
115
+ protected _sysActions: { [type: string]: (UIHandler | null)[] };
116
+ protected _states: { [key: string]: any };
117
+
118
+ protected _holds = new Map<number, string>();
119
+
120
+ /**
121
+ * Create an UI element. You may also create a new UI using one of the static helper like [`UI.fromRectangle`](#link) or [`UI.fromCircle`](#link).
122
+ * @param group a Group or an Iterable<PtLike> that defines the UI's appearance
123
+ * @param shape specifies the shape of the Group
124
+ * @param states optional a state object keep track of custom states for this UI
125
+ * @param id optional id string
126
+ */
127
+ constructor(
128
+ group: PtLikeIterable,
129
+ shape: string,
130
+ states: { [key: string]: any } = {},
131
+ id?: string,
132
+ ) {
133
+ this._group = Group.fromArray(group);
134
+ this._shape = shape;
135
+ this._id = id === undefined ? `ui_${UI._counter++}` : id;
136
+ this._states = states;
137
+ this._actions = {};
138
+ this._sysActions = {};
139
+ }
140
+
141
+ /**
142
+ * Register a custom shape hit test, or override a built-in one. The shape
143
+ * name can then be used when constructing a UI.
144
+ * @param shape shape name
145
+ * @param fn a function `(group, pt, states) => boolean` that returns whether the point hits the shape
146
+ */
147
+ static registerShape(shape: string, fn: UIShapeTest): void {
148
+ _shapeTests[shape] = fn;
149
+ }
150
+
151
+ /**
152
+ * A static helper function to create a Rectangle UI.
153
+ * @param group a Group or an Iterable<PtLike> with 2 Pt representing a rectangle
154
+ * @param states optional a state object keep track of custom states for this UI
155
+ * @param id optional id string
156
+ */
157
+ static fromRectangle(group: PtLikeIterable, states: {}, id?: string): UI {
158
+ return new this(group, UIShape.rectangle, states, id);
159
+ }
160
+
161
+ /**
162
+ * A static helper function to create a Circle UI.
163
+ * @param group a Group or an Iterable<PtLike> with 2 Pt representing a circle
164
+ * @param states optional a state object keep track of custom states for this UI
165
+ * @param id optional id string
166
+ */
167
+ static fromCircle(group: PtLikeIterable, states: {}, id?: string): UI {
168
+ return new this(group, UIShape.circle, states, id);
169
+ }
170
+
171
+ /**
172
+ * A static helper function to create a Polygon UI.
173
+ * @param group a Group or an Iterable<PtLike> representing a polygon
174
+ * @param states optional a state object keep track of custom states for this UI
175
+ * @param id optional id string
176
+ */
177
+ static fromPolygon(group: PtLikeIterable, states: {}, id?: string): UI {
178
+ return new this(group, UIShape.polygon, states, id);
179
+ }
180
+
181
+ /**
182
+ * A static helper function to create a new UI based on another UI.
183
+ * @param ui base UI
184
+ * @param states optional a state object keep track of custom states for this UI
185
+ */
186
+ static fromUI(ui: UI, states?: object, id?: string): UI {
187
+ // copy the source states so the new UI doesn't share mutations
188
+ return new this(ui.group, ui.shape, states || { ...ui._states }, id);
189
+ }
190
+
191
+ /**
192
+ * An unique id of the UI.
193
+ */
194
+ get id(): string {
195
+ return this._id;
196
+ }
197
+ set id(d: string) {
198
+ this._id = d;
199
+ }
200
+
201
+ /**
202
+ * A group of Pts that defines this UI's shape.
203
+ */
204
+ get group(): Group {
205
+ return this._group;
206
+ }
207
+ set group(d: Group) {
208
+ this._group = d;
209
+ }
210
+
211
+ /**
212
+ * A string that describes this UI's shape.
213
+ */
214
+ get shape(): string {
215
+ return this._shape;
216
+ }
217
+ set shape(d: string) {
218
+ this._shape = d;
219
+ }
220
+
221
+ /**
222
+ * Get and/or set a specific UI state.
223
+ * @param key state's name
224
+ * @param value optionally set a new value for this state.key
225
+ * @returns If `value` is changed, return this instance. Otherwise, return the value of the specific key.
226
+ */
227
+ state(key: string, value?: any): any {
228
+ if (!key) return null;
229
+ if (value !== undefined) {
230
+ this._states[key] = value;
231
+ return this;
232
+ }
233
+ return this._states[key];
234
+ }
235
+
236
+ /**
237
+ * Get a specific UI state. Unlike [`UI.state`](#link), this is a plain typed getter.
238
+ * @param key state's name
239
+ */
240
+ getState<T = any>(key: string): T {
241
+ return this._states[key];
242
+ }
243
+
244
+ /**
245
+ * Set a specific UI state. Unlike [`UI.state`](#link), this can also store `undefined`.
246
+ * @param key state's name
247
+ * @param value the value to set
248
+ */
249
+ setState(key: string, value: any): this {
250
+ this._states[key] = value;
251
+ return this;
252
+ }
253
+
254
+ /**
255
+ * Add an event handler. Remember this UI will also need to be tracked for events, via `UI.track` or [`MultiTouchSpace.track`](#link).
256
+ * @param type event type, either one of [`UIPointerActions`](#link) or a custom type
257
+ * @param fn a [`UIHandler`](#link) callback function: `fn( target:UI, pt:Pt, type:string, evt:MouseEvent )`
258
+ * @param options optionally `{ once }` to remove the handler after its first call, and/or `{ signal }` with an [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) that removes it on abort (an already-aborted signal registers nothing)
259
+ * @returns an id number that reference to this handler, for use in [`UI.off`](#link), or -1 if nothing was registered
260
+ */
261
+ on(
262
+ type: UIPointerAction | (string & {}),
263
+ fn: UIHandler,
264
+ options?: { once?: boolean; signal?: AbortSignal },
265
+ ): number {
266
+ if (!fn) return -1;
267
+ if (options?.signal?.aborted) return -1;
268
+ if (!this._actions[type]) this._actions[type] = [];
269
+
270
+ let handler = fn;
271
+ let id = -1;
272
+ if (options?.once) {
273
+ handler = (t, p, ty, e) => {
274
+ this.off(type, id);
275
+ fn(t, p, ty, e);
276
+ };
277
+ }
278
+ id = UI._addHandler(this._actions[type], handler);
279
+ if (options?.signal) {
280
+ const signal = options.signal;
281
+ const abort = () => this.off(type, id);
282
+ signal.addEventListener("abort", abort, { once: true });
283
+ if (!this._abortCleanup[type]) this._abortCleanup[type] = new Map();
284
+ this._abortCleanup[type].set(id, () =>
285
+ signal.removeEventListener("abort", abort),
286
+ );
287
+ }
288
+ return id;
289
+ }
290
+
291
+ /**
292
+ * Remove an event handler.
293
+ * @param type event type
294
+ * @param which an ID number returned by [`UI.on`](#link). If this is not defined, all handlers in this type will be removed (built-in machinery like UIButton's click counting is unaffected). Note that after removal an id is stale — removing it twice may affect a handler that has since reused the slot.
295
+ */
296
+ off(type: UIPointerAction | (string & {}), which?: number): boolean {
297
+ if (!this._actions[type]) return false;
298
+ if (which === undefined) {
299
+ this._abortCleanup[type]?.forEach((cleanup) => cleanup());
300
+ delete this._abortCleanup[type];
301
+ delete this._actions[type];
302
+ return true;
303
+ } else {
304
+ this._abortCleanup[type]?.get(which)?.();
305
+ this._abortCleanup[type]?.delete(which);
306
+ return UI._removeHandler(this._actions[type], which);
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Listen for UI events and trigger action handlers.
312
+ * @param type an action type. Can be one of UIPointerActions or a custom one.
313
+ * @param p a point to check
314
+ * @param evt a MouseEvent emitted by the browser (See [MDN docs](https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent))
315
+ */
316
+ listen(
317
+ type: UIPointerAction | (string & {}),
318
+ p: PtLike,
319
+ evt: UIActionEvent,
320
+ ): boolean {
321
+ let fired = false;
322
+ const userActions = this._actions[type];
323
+ const sysActions = this._sysActions[type];
324
+
325
+ if (userActions || sysActions) {
326
+ if (this._within(p) || this._holdsType(type)) {
327
+ if (sysActions) {
328
+ UI._trigger(sysActions, this, p, type, evt);
329
+ fired = true;
330
+ }
331
+ if (userActions) {
332
+ UI._trigger(userActions, this, p, type, evt);
333
+ fired = true;
334
+ }
335
+ }
336
+ }
337
+
338
+ // "all" handlers observe every event, regardless of position
339
+ if (this._actions["all"]) {
340
+ UI._trigger(this._actions["all"], this, p, type, evt);
341
+ fired = true;
342
+ }
343
+
344
+ return fired;
345
+ }
346
+
347
+ /** Check whether an action type is currently held, without allocating. */
348
+ private _holdsType(type: string): boolean {
349
+ for (const held of this._holds.values()) {
350
+ if (held === type) return true;
351
+ }
352
+ return false;
353
+ }
354
+
355
+ /** Register a built-in handler unaffected by public `off`. */
356
+ protected _sysOn(type: string, fn: UIHandler): number {
357
+ if (!this._sysActions[type]) this._sysActions[type] = [];
358
+ return UI._addHandler(this._sysActions[type], fn);
359
+ }
360
+
361
+ /** Remove a built-in handler registered with `_sysOn`. */
362
+ protected _sysOff(type: string, which: number): boolean {
363
+ if (!this._sysActions[type]) return false;
364
+ return UI._removeHandler(this._sysActions[type], which);
365
+ }
366
+
367
+ /**
368
+ * Continue to keep track of an actions even if it's not within this UI. Useful for hover-leave and drag-outside.
369
+ * @param type a string defined in [`UIPointerActions`](#link)
370
+ */
371
+ protected hold(type: string): number {
372
+ let newKey = Math.max(0, ...Array.from(this._holds.keys())) + 1;
373
+ this._holds.set(newKey, type);
374
+ return newKey;
375
+ }
376
+
377
+ /**
378
+ * Stop keeping track of this action
379
+ * @param key an id returned by the [`UI.hold`](#link) function
380
+ */
381
+ protected unhold(key?: number): void {
382
+ if (key !== undefined) {
383
+ this._holds.delete(key);
384
+ } else {
385
+ this._holds.clear();
386
+ }
387
+ }
388
+
389
+ /**
390
+ * A static function to listen for a list of UIs. See also [`UI.listen`](#link).
391
+ * @param uis an array of UI
392
+ * @param type an action type. Can be one of `UIPointerActions` or a custom one.
393
+ * @param p a point to check
394
+ * @param evt a MouseEvent emitted by the browser (See [MDN docs](https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent))
395
+ */
396
+ static track(uis: UI[], type: string, p: PtLike, evt: UIActionEvent): void {
397
+ for (let i = 0, len = uis.length; i < len; i++) {
398
+ uis[i].listen(type, p, evt);
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Take a custom render function to render this UI.
404
+ * @param fn a render function
405
+ */
406
+ render(fn: (group: Group, states: { [key: string]: any }) => void): void {
407
+ fn(this._group, this._states);
408
+ }
409
+
410
+ /**
411
+ * Returns a string representation of this UI
412
+ */
413
+ toString(): string {
414
+ return `UI ${this.group.toString()}`;
415
+ }
416
+
417
+ /**
418
+ * Check intersection using the hit test registered for this UI's shape.
419
+ * @param p a point to check
420
+ * @returns a boolean to indicate if the event should be triggered
421
+ */
422
+ protected _within(p: PtLike): boolean {
423
+ const fn = _shapeTests[this._shape];
424
+ if (!fn) return false;
425
+ return fn(this._group, p, this._states);
426
+ }
427
+
428
+ /**
429
+ * Static function to trigger an array of UIHandlers
430
+ */
431
+ protected static _trigger(
432
+ fns: (UIHandler | null)[],
433
+ target: UI,
434
+ pt: PtLike,
435
+ type: string,
436
+ evt: UIActionEvent,
437
+ ) {
438
+ if (fns) {
439
+ for (let i = 0, len = fns.length; i < len; i++) {
440
+ if (fns[i]) fns[i]!(target, pt, type, evt);
441
+ }
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Static function to add a new handler to an array store of UIHandlers.
447
+ */
448
+ protected static _addHandler(
449
+ fns: (UIHandler | null)[],
450
+ fn: UIHandler,
451
+ ): number {
452
+ if (!fn) return -1;
453
+ // reuse a removed slot so ids stay stable and the array doesn't grow
454
+ // unboundedly when handlers are added and removed repeatedly
455
+ for (let i = 0, len = fns.length; i < len; i++) {
456
+ if (fns[i] === null) {
457
+ fns[i] = fn;
458
+ return i;
459
+ }
460
+ }
461
+ fns.push(fn);
462
+ return fns.length - 1;
463
+ }
464
+
465
+ /**
466
+ * Static function to remove an existing handler from an array store of UIHandlers.
467
+ * The slot is nulled (not spliced) so other handlers' ids remain valid.
468
+ */
469
+ protected static _removeHandler(
470
+ fns: (UIHandler | null)[],
471
+ index: number,
472
+ ): boolean {
473
+ if (index >= 0 && index < fns.length && fns[index]) {
474
+ fns[index] = null;
475
+ return true;
476
+ }
477
+ return false;
478
+ }
479
+ }
480
+
481
+ /**
482
+ * **[Experimental]** A simple button that extends [`UI`](#link) to track clicks and hovers.
483
+ */
484
+ export class UIButton extends UI {
485
+ private _hoverID: number = -1;
486
+
487
+ /**
488
+ * Create an UIButton. A button has 2 states, "clicks" (number) and "hover" (boolean), which you can access through [`UI.state`](#link) function. You may also create a new UIButton using one of the static helper like [`UI.fromRectangle`](#link) or [`UI.fromCircle`](#link).
489
+ * @param group a Group or an Iterable<PtLike> that defines the UI's appearance
490
+ * @param shape specifies the shape of the Group
491
+ * @param states Optional default state object
492
+ * @param id Optional id string
493
+ */
494
+ constructor(
495
+ group: PtLikeIterable,
496
+ shape: string,
497
+ states: { [key: string]: any } = {},
498
+ id?: string,
499
+ ) {
500
+ super(group, shape, states, id);
501
+
502
+ if (states.hover === undefined) this._states["hover"] = false;
503
+ if (states.clicks === undefined) this._states["clicks"] = 0;
504
+
505
+ const UA = UIPointerActions;
506
+
507
+ // listen for clicks when mouse up and increment clicks
508
+ this._sysOn(UA.up, () => {
509
+ this.state("clicks", this._states.clicks + 1);
510
+ });
511
+
512
+ // listen for move events and fire enter and leave events accordingly
513
+ this._sysOn(
514
+ UA.move,
515
+ (target: UI, pt: PtLike, type: string, evt: UIActionEvent) => {
516
+ let hover = this._within(pt);
517
+
518
+ // hover on
519
+ if (hover && !this._states.hover) {
520
+ this.state("hover", true);
521
+
522
+ // enter trigger
523
+ UI._trigger(this._actions[UA.enter], this, pt, UA.enter, evt);
524
+
525
+ // listen for hover off
526
+ let _capID = this.hold(UA.move); // keep hold of second move
527
+ this._hoverID = this._sysOn(
528
+ UA.move,
529
+ (t: UI, p: PtLike, ty: string, e: UIActionEvent) => {
530
+ if (!this._within(p) && !this.state("dragging")) {
531
+ this.state("hover", false);
532
+ // leave trigger, with the current position and event
533
+ UI._trigger(this._actions[UA.leave], this, p, UA.leave, e);
534
+ this._sysOff(UA.move, this._hoverID); // remove second move listener
535
+ this.unhold(_capID); // stop keeping hold of second move
536
+ }
537
+ },
538
+ );
539
+ }
540
+ },
541
+ );
542
+ }
543
+
544
+ /**
545
+ * Add a new click handler. Remember this button will also need to be tracked for events via `UI.track`. If you want to track right clicks, you may also consider [`UIButton.onContextMenu`](#link).
546
+ * @param fn a [`UIHandler`](#link) callback function: `fn( target:UI, pt:Pt, type:string, evt:MouseEvent )`
547
+ * @returns an id number that refers to this handler, for use in [`UIButton.offClick`](#link) or [`UI.off`](#link).
548
+ */
549
+ onClick(fn: UIHandler): number {
550
+ return this.on(UIPointerActions.up, fn);
551
+ }
552
+
553
+ /**
554
+ * Remove an existing click handler
555
+ * @param id an ID number returned by [`UIButton.onClick`](#link). If this is not defined, all handlers in this type will be removed.
556
+ * @returns a boolean indicating whether the handler was removed successfully
557
+ */
558
+ offClick(id: number): boolean {
559
+ return this.off(UIPointerActions.up, id);
560
+ }
561
+
562
+ /**
563
+ * Add a new contextmenu handler. `contextmenu` is similar to right click, see the [MDN docs](https://developer.mozilla.org/en-US/docs/Web/API/Element/contextmenu_event). Remember this button will also need to be tracked for events via `UI.track`. Also note that you may need to use `event.preventDefault()` in the callback function to prevent other events from triggering.
564
+ * @param fn a [`UIHandler`](#link) callback function: `fn( target:UI, pt:Pt, type:string, evt:MouseEvent )`
565
+ * @returns an id number that refers to this handler, for use in [`UIButton.offContextMenu`](#link) or [`UI.off`](#link).
566
+ */
567
+ onContextMenu(fn: UIHandler): number {
568
+ return this.on(UIPointerActions.contextmenu, fn);
569
+ }
570
+
571
+ /**
572
+ * Remove an existing contextmenu handler
573
+ * @param id an ID number returned by [`UIButton.onContextMenu`](#link). If this is not defined, all handlers in this type will be removed.
574
+ * @returns a boolean indicating whether the handler was removed successfully
575
+ */
576
+ offContextMenu(id: number): boolean {
577
+ return this.off(UIPointerActions.contextmenu, id);
578
+ }
579
+
580
+ /**
581
+ * Add handlers for hover events. Remember this button will also need to be tracked for events via `UI.track`.
582
+ * @param enter an optional [`UIHandler`](#link) function to handle when pointer enters hover. Eg, `fn( target:UI, pt:Pt, type:string, evt:MouseEvent )`
583
+ * @param leave an optional [`UIHandler`](#link) function to handle when pointer exits hover. Eg, `fn( target:UI, pt:Pt, type:string, evt:MouseEvent )`
584
+ * @returns id numbers that refer to enter/leave handlers, for use in [`UIButton.offHover`](#link) or [`UI.off`](#link).
585
+ */
586
+ onHover(enter?: UIHandler, leave?: UIHandler): (number | undefined)[] {
587
+ let ids: (number | undefined)[] = [undefined, undefined];
588
+ if (enter) ids[0] = this.on(UIPointerActions.enter, enter);
589
+ if (leave) ids[1] = this.on(UIPointerActions.leave, leave);
590
+ return ids;
591
+ }
592
+
593
+ /**
594
+ * Remove handlers for hover events.
595
+ * @param enterID an ID number returned by [`UI.onClick`](#link), or -1 to skip. If this is not defined, all handlers in this type will be removed.
596
+ * @param leaveID an ID number returned by [`UI.onClick`](#link), or -1 to skip. If this is not defined, all handlers in this type will be removed.
597
+ * @returns an array of booleans indicating whether the handlers were removed successfully
598
+ */
599
+ offHover(enterID?: number, leaveID?: number): boolean[] {
600
+ let s = [false, false];
601
+ if (enterID === undefined || enterID >= 0)
602
+ s[0] = this.off(UIPointerActions.enter, enterID);
603
+ if (leaveID === undefined || leaveID >= 0)
604
+ s[1] = this.off(UIPointerActions.leave, leaveID);
605
+ return s;
606
+ }
607
+ }
608
+
609
+ /**
610
+ * [Experimental] A draggable UI that provides handler such as [`UIDragger.onDrag`](#link) and [`UIDragger.onDrop`](#link).
611
+ */
612
+ export class UIDragger extends UIButton {
613
+ private _draggingID: number = -1;
614
+ private _dragID: number = -1;
615
+ private _moveHoldID: number = -1;
616
+ private _dragHoldID: number = -1;
617
+ private _dropHoldID: number = -1;
618
+ private _upHoldID: number = -1;
619
+ private _outHoldID: number = -1;
620
+ private _lastMoveEvent: UIActionEvent | undefined;
621
+
622
+ /**
623
+ * Create a dragger which has all the states in UIButton, with additional "dragging" (a boolean indicating whether it's currently being dragged) and "offset" (a Pt representing the offset between this UI's position and the pointer's position when dragged) states. (See [`UI.state`](#link)) You may also create a new UIDragger using one of the static helper like [`UI.fromRectangle`](#link) or [`UI.fromCircle`](#link).
624
+ * @param group a Group or an Iterable<PtLike> that defines the UI's appearance
625
+ * @param shape specifies the shape of the Group
626
+ * @param states Optional default state object
627
+ * @param id Optional id string
628
+ */
629
+ constructor(
630
+ group: PtLikeIterable,
631
+ shape: string,
632
+ states: { [key: string]: any } = {},
633
+ id?: string,
634
+ ) {
635
+ super(group, shape, states, id);
636
+ if (states.dragging === undefined) this._states["dragging"] = false;
637
+ if (states.moved === undefined) this._states["moved"] = false;
638
+ if (states.offset === undefined) this._states["offset"] = new Pt();
639
+
640
+ const UA = UIPointerActions;
641
+
642
+ /*
643
+ * Note: drag/drop is implemented in Space.ts, uidrag/uidrop is
644
+ * reimplemented here so that we can keep track of move events happening
645
+ * outside of the UI element. E.g. when the mouse moves faster than the
646
+ * UI refreshes.
647
+ */
648
+
649
+ // Handle pointer down and begin dragging
650
+ this._sysOn(
651
+ UA.down,
652
+ (target: UI, pt: PtLike, type: string, evt: UIActionEvent) => {
653
+ // begin listening for all events after dragging starts
654
+ if (this._moveHoldID === -1) {
655
+ this.state("dragging", true);
656
+ this.state("offset", new Pt(pt).subtract(target.group[0]));
657
+ this._moveHoldID = this.hold(UA.move); // keep hold of move
658
+ this._dragHoldID = this.hold(UA.drag);
659
+ this._outHoldID = this.hold(UA.out);
660
+ }
661
+ if (this._dropHoldID === -1) {
662
+ this._dropHoldID = this.hold(UA.drop); // keep hold of drop (normal drag and drop)
663
+ }
664
+ if (this._upHoldID === -1) {
665
+ this._upHoldID = this.hold(UA.up); // keep hold of up (cancel dragging if simple click)
666
+ }
667
+ if (this._draggingID === -1) {
668
+ const drag = (t: UI, p: PtLike, ty: string, e: UIActionEvent) => {
669
+ // Touch movement forwards both move and drag for the same event.
670
+ const paired = ty === UA.drag && e === this._lastMoveEvent;
671
+ this._lastMoveEvent = ty === UA.move ? e : undefined;
672
+ if (paired) return;
673
+ if (this.state("dragging")) {
674
+ UI._trigger(this._actions[UA.uidrag], t, p, UA.uidrag, e);
675
+ this.state("moved", true);
676
+ }
677
+ };
678
+ this._draggingID = this._sysOn(UA.move, drag);
679
+ this._dragID = this._sysOn(UA.drag, drag);
680
+ }
681
+ },
682
+ );
683
+
684
+ // Handle pointer drop or up and end dragging
685
+ const endDrag = (
686
+ target: UI,
687
+ pt: PtLike,
688
+ type: string,
689
+ evt: UIActionEvent,
690
+ ) => {
691
+ this.state("dragging", false);
692
+ // remove move listener
693
+ this._sysOff(UA.move, this._draggingID);
694
+ this._sysOff(UA.drag, this._dragID);
695
+ this._draggingID = -1;
696
+ this._dragID = -1;
697
+ this._lastMoveEvent = undefined;
698
+ // stop keeping hold of move
699
+ this.unhold(this._moveHoldID);
700
+ this._moveHoldID = -1;
701
+ this.unhold(this._dragHoldID);
702
+ this._dragHoldID = -1;
703
+ this.unhold(this._outHoldID);
704
+ this._outHoldID = -1;
705
+ // stop keeping hold of drop
706
+ this.unhold(this._dropHoldID);
707
+ this._dropHoldID = -1;
708
+ // stop keeping hold of up
709
+ this.unhold(this._upHoldID);
710
+ this._upHoldID = -1;
711
+ // trigger event
712
+ if (this.state("moved")) {
713
+ UI._trigger(this._actions[UA.uidrop], target, pt, UA.uidrop, evt);
714
+ this.state("moved", false);
715
+ }
716
+ };
717
+ this._sysOn(UA.drop, endDrag);
718
+ this._sysOn(UA.up, endDrag);
719
+ this._sysOn(UA.out, endDrag);
720
+ }
721
+
722
+ /**
723
+ * Add a new drag handler. Remember this button will also need to be tracked for events via `UI.track`.
724
+ * @param fn a [`UIHandler`](#link) callback function: `fn( target:UI, pt:Pt, type:string, evt:MouseEvent )`. You can access the states "dragging" and "offset" (See [`UI.state`](#link)) in the callback.
725
+ * @returns an id number that refers to this handler, for use in [`UIDragger.offDrag`](#link) or [`UI.off`](#link).
726
+ */
727
+ onDrag(fn: UIHandler): number {
728
+ return this.on(UIPointerActions.uidrag, fn);
729
+ }
730
+
731
+ /**
732
+ * Remove an existing drag handler
733
+ * @param id an ID number returned by [`UIDragger.onDrag`](#link). If this is not defined, all handlers in this type will be removed.
734
+ * @returns a boolean indicating whether the handler was removed successfully
735
+ */
736
+ offDrag(id: number): boolean {
737
+ return this.off(UIPointerActions.uidrag, id);
738
+ }
739
+
740
+ /**
741
+ * Add a new drop handler. Remember this button will also need to be tracked for events via `UI.track`.
742
+ * @param fn a [`UIHandler`](#link) callback function: `fn( target:UI, pt:Pt, type:string, evt:MouseEvent )`
743
+ * @returns an id number that refers to this handler, for use in [`UIDragger.offDrop`](#link) or [`UI.off`](#link).
744
+ */
745
+ onDrop(fn: UIHandler): number {
746
+ return this.on(UIPointerActions.uidrop, fn);
747
+ }
748
+
749
+ /**
750
+ * Remove an existing drop handler
751
+ * @param id an ID number returned by [`UIDragger.onDrag`](#link). If this is not defined, all handlers in this type will be removed.
752
+ * @returns a boolean indicating whether the handler was removed successfully
753
+ */
754
+ offDrop(id: number): boolean {
755
+ return this.off(UIPointerActions.uidrop, id);
756
+ }
757
+ }