@lumjs/core 1.30.0 → 1.31.1

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.
@@ -0,0 +1,737 @@
1
+ "use strict";
2
+
3
+ const {S,F,isObj,def,isIterable} = require('../types');
4
+ const Listener = require('./listener');
5
+ const RegSym = Symbol('@lumjs/core/events:registry');
6
+
7
+ const DEF_EXTENDS =
8
+ {
9
+ registry: 'events',
10
+ listen: 'on',
11
+ emit: 'emit',
12
+ remove: null,
13
+ once: null,
14
+ }
15
+
16
+ const DEF_OPTIONS =
17
+ {
18
+ delimiter: /\s+/,
19
+ multiMatch: false,
20
+ wildcard: '*',
21
+ }
22
+
23
+ /**
24
+ * Has a target object been registered with an event registry?
25
+ * @param {object} target
26
+ * @returns {boolean}
27
+ * @alias module:@lumjs/core/events.Registry.isRegistered
28
+ */
29
+ const isRegistered = target => isObj(target[RegSym]);
30
+
31
+ /**
32
+ * Get event registry metadata from a target
33
+ * @private
34
+ * @param {object} target - Object to get metadata for
35
+ * @param {boolean} [create=false] Create metadata if it's not found?
36
+ * @returns {object} metadata (TODO: schema docs)
37
+ * @alias module:@lumjs/core/events.Registry.getMetadata
38
+ */
39
+ function getMetadata(target, create=false)
40
+ {
41
+ if (isRegistered(target))
42
+ { // Existing registry metadata found
43
+ return target[RegSym];
44
+ }
45
+ else if (create)
46
+ { // Create new metadata
47
+ const tpm =
48
+ {
49
+ r: new Map(),
50
+ p: {},
51
+ }
52
+ def(target, RegSym, tpm);
53
+ return tpm;
54
+ }
55
+ }
56
+
57
+ function targetsAre(targets)
58
+ {
59
+ const isaSet = (targets instanceof Set);
60
+ const isaArr = (!isaSet && Array.isArray(targets));
61
+ const tis =
62
+ {
63
+ set: isaSet,
64
+ array: isaArr,
65
+ handled: (isaSet || isaArr),
66
+ }
67
+ return tis;
68
+ }
69
+
70
+ /**
71
+ * A class that handles events for target objects
72
+ *
73
+ * @prop {module:@lumjs/core/events~GetTargets} getTargets
74
+ * A constructor-assigned callback method that returns a set of targets.
75
+ * @prop {object} options - Registry-level options
76
+ * @prop {Set.<module:@lumjs/core/events.Listener>} allListeners
77
+ * All registered event listeners
78
+ * @prop {Map.<string,Set.<module:@lumjs/core/events.Listener>>} listenersFor
79
+ * Each key is a single event name, and the value is a Set of
80
+ * listener objects that handle that event.
81
+ *
82
+ * @alias module:@lumjs/core/events.Registry
83
+ */
84
+ class LumEventRegistry
85
+ {
86
+ /**
87
+ * Create a new registry instance for one or more target objects
88
+ *
89
+ * @param {(object|module:@lumjs/core/events~GetTargets)} targets
90
+ *
91
+ * If this is an `object`, then any kind of `Iterable` may be used
92
+ * to represent multiple targets, while any non-Iterable object will
93
+ * be considered as single target.
94
+ *
95
+ * If this is a `function`, it will be called to dynamically get a
96
+ * list of target objects whenever an event is triggered.
97
+ *
98
+ * @param {object} [opts] Options (saved to `options` property).
99
+ *
100
+ * @param {(RegExp|string)} [opts.delimiter=/\s+/] Used to split event names
101
+ *
102
+ * @param {(object|boolean)} [opts.extend]
103
+ * This option determines the rules for adding wrapper methods and
104
+ * other extension properties to the target objects.
105
+ *
106
+ * If this is `true` (default when `targets` is an `object`), then
107
+ * the target objects will be extended using the default property names.
108
+ *
109
+ * If this is set to `false` (default when `targets` is a `function`),
110
+ * it disables adding extension properties entirely.
111
+ *
112
+ * If it is an `object` then each nested property may be set to a string
113
+ * to override the default, or `null` to skip adding that property.
114
+ *
115
+ * @param {?string} [opts.extend.registry="events"] Registry property
116
+ * @param {?string} [opts.extend.emit="emit"] `emit()` proxy method
117
+ * @param {?string} [opts.extend.listen="on"] `listen()` proxy method
118
+ * @param {?string} [opts.extend.once=null] `once()` proxy method
119
+ * @param {?string} [opts.extend.remove=null] `remove()` proxy method
120
+ *
121
+ * @param {boolean} [opts.multiMatch=false]
122
+ * If a registered listener has multiple event names, and a call
123
+ * to `emit()` also has multiple event names, the value of this
124
+ * option will determine if the same listener will have its
125
+ * handler function called more than once.
126
+ *
127
+ * If this is `true`, the handler will be called once for every
128
+ * combination of target and event name.
129
+ *
130
+ * If this is `false` (default), then only the first matching event
131
+ * name will be called for each target.
132
+ *
133
+ * @param {boolean} [opts.overwrite=false] Overwrite existing properties?
134
+ *
135
+ * If `true` then when adding wrapper methods, the properties from
136
+ * `opts.extend` will replace any existing ones in each target.
137
+ *
138
+ * @param {function} [opts.setupEvent] Initialize each Event object?
139
+ *
140
+ * If this is specified (either here or in individual listeners),
141
+ * it will be called and passed the Event object at the very end of
142
+ * its constructor.
143
+ *
144
+ * @param {string} [opts.wildcard='*'] Wildcard event name.
145
+ *
146
+ * - If you use this in `listen()` the handler will be used regardless
147
+ * as to what event name was triggered. You can always see which
148
+ * event name was actually triggered by using `event.name`.
149
+ * - If you use this in `remove()` it calls `removeAll()` to remove all
150
+ * registered listeners.
151
+ */
152
+ constructor(targets, opts={})
153
+ {
154
+ let defExt; // Default opts.extend value
155
+ if (typeof targets === F)
156
+ { // A dynamic getter method
157
+ this.funTargets = true;
158
+ this.getTargets = targets;
159
+ targets = this.getTargets();
160
+ defExt = false;
161
+ }
162
+ else
163
+ { // Simple getter for a static value
164
+ if (!(targets instanceof Set))
165
+ {
166
+ if (!isIterable(targets))
167
+ targets = [targets];
168
+ targets = new Set(targets);
169
+ }
170
+
171
+ this.funTargets = false;
172
+ this.getTargets = () => targets;
173
+ defExt = true;
174
+ }
175
+
176
+ this.options = Object.assign({extend: defExt}, DEF_OPTIONS, opts);
177
+
178
+ this.allListeners = new Set();
179
+ this.listenersFor = new Map();
180
+
181
+ this.extend(targets);
182
+ } // constructor()
183
+
184
+ /**
185
+ * Add extension methods to target objects;
186
+ * used by `constructor` and `register()`,
187
+ * not meant to be called from outside code.
188
+ * @private
189
+ * @param {Iterable} targets - Targets to extend
190
+ * @returns {module:@lumjs/core/events.Registry} `this`
191
+ */
192
+ extend(targets)
193
+ {
194
+ const opts = this.options;
195
+ const extOpts = opts.extend;
196
+
197
+ let intNames = null, extNames = null;
198
+
199
+ if (extOpts)
200
+ {
201
+ intNames = Object.keys(DEF_EXTENDS);
202
+ extNames = Object.assign({}, DEF_EXTENDS, extOpts);
203
+ }
204
+
205
+ for (const target of targets)
206
+ {
207
+ const tps = {}, tpm = getMetadata(target, true);
208
+ tpm.r.set(this, tps);
209
+
210
+ if (extOpts)
211
+ {
212
+ for (const iname of intNames)
213
+ {
214
+ if (typeof extNames[iname] === S && extNames[iname].trim() !== '')
215
+ {
216
+ const ename = extNames[iname];
217
+ const value = iname === 'registry'
218
+ ? this // The registry instance itself
219
+ : (...args) => this[iname](...args) // A proxy method
220
+ for (const target of targets)
221
+ {
222
+ if (opts.overwrite || target[ename] === undefined)
223
+ {
224
+ def(target, ename, {value});
225
+ tps[ename] = iname;
226
+ tpm.p[ename] = this;
227
+ }
228
+ else
229
+ {
230
+ console.error("Won't overwrite existing property",
231
+ {target,iname,ename,registry: this});
232
+ }
233
+ }
234
+ }
235
+ }
236
+ }
237
+ }
238
+ return this;
239
+ }
240
+
241
+ /**
242
+ * Build a new Listener instance; used by `listen()` method.
243
+ *
244
+ * @param {(string|object)} eventNames
245
+ * What this does depends on the type, and the number of arguments passed.
246
+ *
247
+ * If this is an `object` **AND** is *the only argument* passed,
248
+ * it will be used as the `spec`, and the `spec.eventNames`
249
+ * and `spec.listener` properties will become mandatory.
250
+ *
251
+ * If it's a string or there is more than one argument, this
252
+ * will be used as the `spec.eventNames` property.
253
+ *
254
+ * @param {module:@lumjs/core/events~Handler} [handler]
255
+ * Used as the `spec.handler` property if specified.
256
+ *
257
+ * This is mandatory if `eventNames` argument is a `string`!
258
+ *
259
+ * @param {object} [spec] The listener specification rules
260
+ *
261
+ * @param {(string|Iterable)} [spec.eventNames] Event names to listen for
262
+ *
263
+ * See {@link module:@lumjs/core/events.Registry#getEventNames} for details.
264
+ *
265
+ * @param {module:@lumjs/core/events~Handler} [spec.handler] Event handler.
266
+ *
267
+ * @param {(function|object)} [spec.listener] An alias for `handler`
268
+ *
269
+ * @param {object} [spec.options] Options for the listener
270
+ *
271
+ * The option properties can be included directly in the `spec` itself
272
+ * for brevity, but a nested `options` object is supported to be more
273
+ * like the `DOM.addEventListener()` method. Either way works fine.
274
+ *
275
+ * If `spec.options` is used, the properties in it take precedence over
276
+ * those directly in the `spec` object. Note that you cannot use the
277
+ * names `listener`, `handler` or `eventNames` as option properties,
278
+ * and if found, they will be removed.
279
+ *
280
+ * You may also override the `setupEvent` registry option here.
281
+ *
282
+ * @param {boolean} [spec.options.once=false] Only use the listener once?
283
+ *
284
+ * If this is set to `true`, then the first time this listener is used in
285
+ * an {@link module:@lumjs/core/events.Registry#emit emit()} call, it will
286
+ * be removed from the registry at the end of the emit process (after all
287
+ * events for all targets have been triggered).
288
+ *
289
+ * @returns {module:@lumjs/core/events.Listener} A new `Listener` instance
290
+ */
291
+ makeListener(...args)
292
+ {
293
+ let spec;
294
+
295
+ if (args.length === 0 || args.length > 3)
296
+ {
297
+ console.error({args, registry: this});
298
+ throw new RangeError("Invalid number of arguments");
299
+ }
300
+ else if (args.length === 1 && isObj(args[0]))
301
+ { // listen(spec)
302
+ spec = Object.assign({}, args[0]);
303
+ }
304
+ else
305
+ { // listen(eventNames, listener, [spec])
306
+ spec = Object.assign({}, args[2]);
307
+ spec.eventNames = args[0];
308
+ spec.handler = args[1];
309
+ }
310
+
311
+ return new Listener(this, spec);
312
+ }
313
+
314
+ /**
315
+ * Assign a new event listener.
316
+ *
317
+ * Calls `this.makeListener()` passing all arguments to it.
318
+ * Then calls `this.add(listener)` passing the newly make `Listener`.
319
+ *
320
+ * @param {...mixed} args
321
+ * @returns {module:@lumjs/core/events.Listener}
322
+ */
323
+ listen()
324
+ {
325
+ const listener = this.makeListener(...arguments)
326
+ this.add(listener);
327
+ return listener;
328
+ }
329
+
330
+ /**
331
+ * Assign a new event listener that will only run once.
332
+ *
333
+ * Calls `this.listen()` passing all arguments to it,
334
+ * then sets the `listener.options.once` to `true`.
335
+ *
336
+ * @param {...mixed} args
337
+ * @returns {module:@lumjs/core/events.Listener}
338
+ */
339
+ once()
340
+ {
341
+ const listener = this.listen(...arguments);
342
+ listener.options.once = true;
343
+ return listener;
344
+ }
345
+
346
+ /**
347
+ * Add a Listener instance.
348
+ *
349
+ * You'd generally use `listen()` or `once()` rather than this, but if
350
+ * you need to (re-)add an existing instance, this is the way to do it.
351
+ *
352
+ * @param {module:@lumjs/core/events.Listener} listener - Listener instance
353
+ *
354
+ * If the same instance is passed more than once it will have no affect,
355
+ * as we store the instances in a `Set` internally, so it'll only ever
356
+ * be stored once.
357
+ *
358
+ * @returns {module:@lumjs/core/events.Registry} `this`
359
+ */
360
+ add(listener)
361
+ {
362
+ if (!(listener instanceof Listener))
363
+ {
364
+ console.error({listener, registry: this});
365
+ throw new TypeError("Invalid listener instance");
366
+ }
367
+
368
+ this.allListeners.add(listener);
369
+
370
+ for (const ename of listener.eventNames)
371
+ {
372
+ let lset;
373
+ if (this.listenersFor.has(ename))
374
+ {
375
+ lset = this.listenersFor.get(ename);
376
+ }
377
+ else
378
+ {
379
+ lset = new Set();
380
+ this.listenersFor.set(ename, lset);
381
+ }
382
+
383
+ lset.add(listener);
384
+ }
385
+
386
+ return this;
387
+ }
388
+
389
+ /**
390
+ * Remove **ALL** registered event listeners!
391
+ * @returns {module:@lumjs/core/events.Registry} `this`
392
+ */
393
+ removeAll()
394
+ {
395
+ this.allListeners.clear();
396
+ this.listenersFor.clear();
397
+ return this;
398
+ }
399
+
400
+ /**
401
+ * Remove specific event names.
402
+ *
403
+ * It will remove any of the the specified event names
404
+ * from applicable listener instances, and clear the
405
+ * associated `listenersFor` set.
406
+ *
407
+ * If a listener has no more event names left, that listener
408
+ * will be removed from the `allListeners` set as well.
409
+ *
410
+ * @param {...string} names - Event names to remove
411
+ *
412
+ * If the `wildcard` string is specified here, this will simply
413
+ * remove any wildcard listeners currently registered.
414
+ * See `remove(wildcard)` or `removeAll()` if you really want
415
+ * to remove **ALL** listeners.
416
+ *
417
+ * @returns {module:@lumjs/core/events.Registry} `this`
418
+ */
419
+ removeEvents(...names)
420
+ {
421
+ for (const name of names)
422
+ {
423
+ if (this.listenersFor.has(name))
424
+ {
425
+ const eventListeners = this.listenersFor.get(name);
426
+ for (const lsnr of eventListeners)
427
+ {
428
+ lsnr.eventNames.delete(name);
429
+ if (!lsnr.hasEvents)
430
+ { // The last event name was removed.
431
+ this.removeListeners(lsnr);
432
+ }
433
+ }
434
+ eventListeners.clear();
435
+ }
436
+ }
437
+ return this;
438
+ }
439
+
440
+ /**
441
+ * Remove specific Listener instances
442
+ * @param {...module:@lumjs/core/events.Listener} listeners
443
+ * @returns {module:@lumjs/core/events.Registry} `this`
444
+ */
445
+ removeListeners(...listeners)
446
+ {
447
+ for (const listener of listeners)
448
+ {
449
+ if (this.allListeners.has(listener))
450
+ { // First remove it from allListeners
451
+ this.allListeners.delete(listener);
452
+
453
+ for (const ename of listener.eventNames)
454
+ {
455
+ if (this.listenersFor.has(ename))
456
+ {
457
+ const lset = this.listenersFor.get(ename);
458
+ lset.delete(listener);
459
+ }
460
+ }
461
+ }
462
+ }
463
+ return this;
464
+ }
465
+
466
+ /**
467
+ * Remove listeners based on the value type used.
468
+ *
469
+ * @param {(string|module:@lumjs/core/events.Listener)} what
470
+ *
471
+ * - If this is the `wildcard` string, then this will call `removeAll()`.
472
+ * - If this is any other `string` it will be split using `splitNames()`,
473
+ * and the resulting strings passed as arguments to `removeEvents()`.
474
+ * - If this is a `Listener` instance, its passed to `removeListeners()`.
475
+ *
476
+ * @returns {module:@lumjs/core/events.Registry} `this`
477
+ * @throws {TypeError} If `what` is none of the above values.
478
+ */
479
+ remove(what)
480
+ {
481
+ if (what === this.options.wildcard)
482
+ {
483
+ return this.removeAll();
484
+ }
485
+ else if (typeof what === S)
486
+ {
487
+ const events = this.splitNames(what);
488
+ return this.removeEvents(...events);
489
+ }
490
+ else if (what instanceof Listener)
491
+ {
492
+ return this.removeListeners(what);
493
+ }
494
+ else
495
+ {
496
+ console.error({what, registry: this});
497
+ throw new TypeError("Invalid event name or listener instance");
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Emit (trigger) one or more events.
503
+ *
504
+ * @param {(string|string[])} eventNames - Events to emit.
505
+ *
506
+ * If this is a single `string` it will be split via `splitNames()`.
507
+ *
508
+ * @param {object} [data] A data object (highly recommended);
509
+ * will be assigned to `event.data` if specified.
510
+ *
511
+ * @param {...any} [args] Any other arguments;
512
+ * will be assigned to `event.args`.
513
+ *
514
+ * Note: if a `data` object argument was passed, it will always
515
+ * be the first item in `event.args`.
516
+ *
517
+ * @returns {module:@lumjs/core/events~Status}
518
+ */
519
+ emit(eventNames, ...args)
520
+ {
521
+ const sti =
522
+ {
523
+ eventNames: this.getEventNames(eventNames),
524
+ multiMatch: this.options.multiMatch,
525
+ onceRemoved: new Set(),
526
+ stopEmitting: false,
527
+ emitted: [],
528
+ }
529
+
530
+ { // Get the targets.
531
+ const tgs = this.getTargets(sti);
532
+ sti.targets = (tgs instanceof Set) ? tgs : new Set(tgs);
533
+ }
534
+
535
+ const wilds = this.listenersFor.get(this.options.wildcard);
536
+
537
+ emitting: for (const tg of sti.targets)
538
+ {
539
+ const called = sti.targetListeners = new Set();
540
+ for (const ename of sti.eventNames)
541
+ {
542
+ if (!this.listenersFor.has(ename)) continue;
543
+
544
+ let listeners = this.listenersFor.get(ename);
545
+ if (wilds) listeners = listeners.union(wilds);
546
+
547
+ for (const lsnr of listeners)
548
+ {
549
+ if (sti.multiMatch || !called.has(lsnr))
550
+ { // Let's emit an event!
551
+ called.add(lsnr);
552
+ const event = lsnr.emitEvent(ename, tg, args, sti);
553
+ sti.emitted.push(event);
554
+ if (sti.stopEmitting)
555
+ {
556
+ break emitting;
557
+ }
558
+ }
559
+ }
560
+ }
561
+ }
562
+
563
+ // Nix the targetListeners property.
564
+ delete sti.targetListeners;
565
+
566
+ // Handle any `onceRemoved` listeners.
567
+ for (const lsnr of sti.onceRemoved)
568
+ {
569
+ this.removeListeners(lsnr);
570
+ }
571
+
572
+ // Return the final status.
573
+ return sti;
574
+ }
575
+
576
+
577
+
578
+ /**
579
+ * Register additional target objects
580
+ * @param {...object} addTargets - Target objects to register
581
+ * @returns {module:@lumjs/core/events.Registry} `this`
582
+ */
583
+ register(...addTargets)
584
+ {
585
+ const allTargets = this.getTargets();
586
+ const tis = targetsAre(allTargets);
587
+
588
+ if (tis.handled)
589
+ {
590
+ for (const target of addTargets)
591
+ {
592
+ if (tis.set)
593
+ {
594
+ allTargets.add(target);
595
+ }
596
+ else if (tis.array)
597
+ {
598
+ if (allTargets.indexOf(target) === -1)
599
+ {
600
+ allTargets.push(target);
601
+ }
602
+ }
603
+ }
604
+ }
605
+ else
606
+ {
607
+ if (!tis.handled)
608
+ {
609
+ console.warn("cannot add targets to collection",
610
+ {addTargets, allTargets, registry: this});
611
+ }
612
+ }
613
+
614
+ this.extend(addTargets);
615
+ return this;
616
+ }
617
+
618
+ /**
619
+ * Remove a target object from the registry.
620
+ *
621
+ * This will also remove any extension properties added to the
622
+ * target object by this registry instance.
623
+ *
624
+ * @param {...object} [delTargets] Targets to unregister
625
+ *
626
+ * If no targets are specified, this will unregister **ALL** targets
627
+ * from this registry!
628
+ *
629
+ * @returns {module:@lumjs/core/events.Registry} `this`
630
+ */
631
+ unregister(...delTargets)
632
+ {
633
+ const allTargets = this.getTargets();
634
+ const tis = targetsAre(allTargets);
635
+
636
+ if (delTargets.length === 0)
637
+ { // Unregister ALL targets.
638
+ delTargets = allTargets;
639
+ }
640
+
641
+ if (!tis.handled)
642
+ {
643
+ console.warn("cannot remove targets from collection",
644
+ {delTargets, allTargets, registry: this});
645
+ }
646
+
647
+ for (const target of delTargets)
648
+ {
649
+ if (!isRegistered(target)) continue;
650
+
651
+ const tpm = target[RegSym];
652
+ const tp = tpm.r.get(this);
653
+
654
+ if (tis.set)
655
+ {
656
+ allTargets.delete(target);
657
+ }
658
+ else if (tis.array)
659
+ {
660
+ const tin = allTargets.indexOf(target);
661
+ if (tin !== -1)
662
+ {
663
+ allTargets.splice(tin, 1);
664
+ }
665
+ }
666
+
667
+ if (isObj(tp))
668
+ { // Remove any added extension properties
669
+ for (const ep in tp)
670
+ {
671
+ if (tpm.p[ep] === this)
672
+ { // Remove it from the target.
673
+ delete target[ep];
674
+ delete tpm.p[ep];
675
+ }
676
+ }
677
+ tpm.r.delete(this);
678
+ }
679
+
680
+ if (tpm.r.size === 0)
681
+ { // No registries left, remove the metadata too
682
+ delete target[RegSym];
683
+ }
684
+ }
685
+
686
+ return this;
687
+ }
688
+
689
+ /**
690
+ * Get a Set of event names from various kinds of values
691
+ * @param {(string|Iterable)} names - Event names source
692
+ *
693
+ * If this is a string, it'll be passed to `splitNames()`.
694
+ * If it's any kind of `Iterable`, it'll be converted to a `Set`.
695
+ *
696
+ * @returns {Set}
697
+ * @throws {TypeError} If `names` is not a valid value
698
+ */
699
+ getEventNames(names)
700
+ {
701
+ if (typeof names === S)
702
+ {
703
+ return this.splitNames(names);
704
+ }
705
+ else if (names instanceof Set)
706
+ {
707
+ return names;
708
+ }
709
+ else if (isIterable(names))
710
+ {
711
+ return new Set(names);
712
+ }
713
+ else
714
+ {
715
+ console.error({names, registry: this});
716
+ throw new TypeError("Invalid event names");
717
+ }
718
+ }
719
+
720
+ /**
721
+ * Split a (trimmed) string using `this.options.delimiter`
722
+ * @param {string} names - String to split
723
+ * @returns {Set}
724
+ */
725
+ splitNames(names)
726
+ {
727
+ return new Set(names.trim().split(this.options.delimiter));
728
+ }
729
+
730
+ }
731
+
732
+ Object.assign(LumEventRegistry,
733
+ {
734
+ isRegistered, getMetadata, targetsAre,
735
+ });
736
+
737
+ module.exports = LumEventRegistry;