Sv443 преди 4 седмици
родител
ревизия
cf6fde6b39
променени са 2 файла, в които са добавени 172 реда и са изтрити 87 реда
  1. 75 47
      docs.md
  2. 97 40
      lib/Mixins.ts

+ 75 - 47
docs.md

@@ -1870,32 +1870,38 @@ fooDialog.open();
 ### Mixins
 Signature:  
 ```ts
-new Mixins<TMixinMap extends Record<string, (arg: any, ctx?: any) => any>>(defaultConfigOverrides?: Partial<MixinConfig>): Mixins<TMixinMap>
+new Mixins<
+  TMixinMap extends Record<string, (arg: any, ctx?: any) => any>,
+>(
+  config?: Partial<MixinsConstructorConfig>,
+)
 ```
   
 A class that provides a way to apply multiple mixin functions to any value, which is a convenient way of letting multiple sources modify the same value in a controlled way.  
   
-This could be used for a plugin system, for example, where multiple plugins can modify the same object or value.  
-Another more day-to-day example would be a configuration object that is modified by multiple sources with different and varying priorities and conditions.  
+In a day-to-day example, this class could be used to manage a configuration object that is modified by multiple sources with different and varying priorities, conditions and availability.  
+This could be used for a plugin system, for example, where multiple plugins are allowed to modify the same object or value, with much more control about how they override or get combined with each other.  
   
-The `TMixinMap` generic is used to define the mixin functions that can be applied to the value.  
-It needs to be an object where the keys are the mixin names and the values are functions that take the value to be modified as the first argument and an optional context object as the second argument and returns the modified value.  
-**Important:** the argument and return type need to be the same, otherwise TypeScript will get confused.  
+It gives you utmost flexibility, as you can either use the default order and apply changes from all sources, or you can tweak the priority and stop propagation at any point in the chain.  
+This class might be too "barebones" for some use cases, but it can be easily extended to fit your needs, like constraining the priority to a certain range, reserving certain priorities or keys, or adding more complex conditions for stopping the propagation. The world is your oyster.  
   
-The default configuration object can be overridden by passing an object with the same properties as the default configuration object.  
-Unless overridden in the [`resolve()`](#mixinsresolve) method, the default configuration object will be used.  
-If no value is provided, the priority will default to 0.  
+The `TMixinMap` template generic is used to define the mixin functions that can be applied to the value.  
+It needs to be an object where the keys are the mixin names and the values are functions that take the value to be modified as the first argument and an optional context object as the second argument and returns the modified value.  
+**Important:** the first argument and return type need to be the same. Also, if a context object is defined, it must be passed as the third argument in the [`resolve()`](#mixinsresolve) method.  
   
-The properties of the MixinConfig object are:
+The properties of the `MixinsConstructorConfig` object in the constructor are:
 | Property | Description |
 | :-- | :-- |
-| `priority: number` | The priority of the mixin function. Higher numbers will be applied first. Negative values are also allowed. Defaults to 0. |
-| `stopPropagation: boolean` | If set to `true`, the mixin function will prevent all upcoming mixin functions from being called. Defaults to `false`. |
-| `signal: AbortSignal` | An optional AbortSignal that can be used to stop applying the mixin function. |
-  
+| `autoIncrementPriority?` | If true, when no priority is specified, an auto-incrementing integer priority will be used (unique per mixin key). Defaults to `false`. |
+| `defaultPriority?` | The default priority for mixins that do not specify one. Defaults to `0`. |
+| `defaultStopPropagation?` | The default `stopPropagation` value for mixins that do not specify one. Defaults to `false`. |
+| `defaultSignal?` | The default `AbortSignal` for mixins that do not specify one. Defaults to `undefined`. |
+
+<br>
+
 ### Methods:
 #### `Mixins.resolve()`
-Signature: `resolve(mixinKey: string, inputValue: any, inputCtx?: any): TArg`  
+Signature: `resolve<TArg extends any, TCtx extends any>(mixinKey: string, inputValue: TArg, inputCtx?: TCtx): TArg`  
 Applies all mixin functions that were registered with [`add()`](#mixinsadd) for the given mixin key to resolve the input value.  
 Goes in order of highest to lowest priority and returns the resolved value, which is of the same type as the input value.  
 If no mixin functions are registered for the given key, the input value will be returned unchanged.  
@@ -1903,11 +1909,23 @@ If no mixin functions are registered for the given key, the input value will be
 <br>
 
 #### `Mixins.add()`
-Signature: `add(mixinKey: string, mixinFn: (arg: any, ctx?: any) => any, config?: Partial<MixinConfig>): () => void`  
+Signature: `add<TArg extends any, TCtx extends any>(mixinKey: string, mixinFn: (arg: TArg, ctx?: TCtx) => TArg, config?: Partial<MixinConfig>): () => void`  
 Registers a mixin function for the given key.  
 The function will be called with the input value (possibly modified by previous mixins) and possibly a context object.  
 When a value for the context parameter is defined in the main generic of the `Mixins` class, the ctx parameter will be required. Otherwise it should always be unspecified.  
-Returns a function that can be called to remove the mixin function from the list of registered mixins.
+  
+Returns a function that can be called to remove the mixin function from the list of registered mixins.  
+This is an alternative to providing a `signal` property in the config object, which allows for removing many different mixins from multiple instances with the same `AbortSignal`.  
+  
+To stop a mixin chain at any point, add a mixin with the desired priority and a function that just returns the input value without modifying it and with the `stopPropagation` property set to `true`.  
+For example, if you need to stop the chain *after* the mixins with the priorities 0, but *before* 1, use the priority 0.5 for the stopping mixin.
+  
+The properties of the `MixinConfig` object are:
+| Property | Description |
+| :-- | :-- |
+| `priority?` | The higher, the earlier the mixin will be applied. Supports floating-point and negative numbers too. 0 by default. |
+| `stopPropagation?` | If true, no further mixins will be applied after this one. |
+| `signal?` | If set, the mixin will only be applied if the given signal is not yet aborted. Allows for better orchestration than with the cleanup functions returned by `add()` in some cases. |
 
 <br>
 
@@ -1923,36 +1941,38 @@ Doesn't return the mixin functions themselves.
 ```ts
 import { Mixins } from "@sv443-network/userutils";
 
+const ac = new AbortController();
+// if removeAllMixins() is called, all mixins will be removed from the myMixins instance:
+const { abort: removeAllMixins } = ac;
 
+// create Mixins instance with auto-incrementing priority:
 const myMixins = new Mixins<{
   foo: (val: number) => number;
-  bar: (v: string, ctx: { barGlobal: number }) => string;
-}>();
+  bar: (v: string, ctx: { baz: number }) => string;
+}>({
+  autoIncrementPriority: true,
+  defaultSignal: ac.signal,
+});
 
 
 // foo:
 
-// internal function:
-export function calcFoo(fooVal: number) {
+// main function:
+function calcFoo(val: number) {
   // order of operations:
-  // 1. fooVal ** 2
-  // 2. / 2 (critical prio)
-  // 3. + 1 (added first)
-  // 4. * 2 (added second)
-  return myMixins.use("foo", fooVal ** 2);
+  // 1. val ** 2   (this function)
+  // 2. val / 2    (source 2 mixin)
+  // 3. val * 2    (source 3 mixin)
+  // 4. val + 1    (source 1 mixin)
+  return myMixins.resolve("foo", val ** 2);
 }
 
-// mixin from source 1:
+// mixin from source 1 (priority 0):
 myMixins.add("foo", (val) => {
   return val + 1;
 });
 
-// mixin from source 2:
-myMixins.add("foo", (val) => {
-  return val * 2;
-});
-
-// mixin from source 3 with critical priority:
+// mixin from source 2 (highest possible priority):
 myMixins.add("foo", (val) => {
   return val / 2;
 }, {
@@ -1960,31 +1980,39 @@ myMixins.add("foo", (val) => {
   priority: Number.MAX_SAFE_INTEGER,
 });
 
+// mixin from source 3 (priority 1):
+myMixins.add("foo", (val) => {
+  return val * 2;
+});
+
+getFoo(10); // 10 ** 2 / 2 * 2 + 1 = 101
+
 
 // bar:
 
-const barGlobal = 1337;
+// some global variable that will be provided as context to the mixin:
+export let baz = 1337;
 
-// internal function:
-export function getBar(barVal: string) {
+// main function:
+function getBar(barVal: string) {
   // order of operations:
-  // 1. barVal + "-" + barGlobal (highest priority & has stopPropagation)
+  // 1. barVal + "-" + baz   (source 2 mixin, highest priority & stopPropagation)
   // result: "Hello-1337"
-  return myMixins.use("bar", barVal, { barGlobal });
+
+  // context object is mandatory because of the generic type at `new Mixins<...>()`:
+  return myMixins.resolve("bar", barVal, { baz });
 }
 
-// mixin from source 1:
-myMixins.add("bar", (val) => {
-  return val + " this is ignored";
-});
+// mixin from source 1 (priority 0):
+myMixins.add("bar", (val) => `*this will never be applied* ${val}`);
 
-// mixin from source 2:
-myMixins.add("bar", (val, ctx) => {
-  return val + "-" + ctx.barGlobal;
-}, {
+// mixin from source 2 (priority 1 & stopPropagation):
+myMixins.add("bar", (val, ctx) => `${val}-${ctx.baz}`, {
   priority: 1,
   stopPropagation: true,
 });
+
+getBar(); // "Hello-1337"
 ```
 </details>
 
@@ -1993,7 +2021,7 @@ myMixins.add("bar", (val, ctx) => {
 ### NanoEmitter
 Signature:  
 ```ts
-new NanoEmitter<TEventMap = EventsMap>(options?: NanoEmitterOptions): NanoEmitter<TEventMap>
+new NanoEmitter<TEventMap = EventsMap>(options?: NanoEmitterOptions)
 ```  
   
 A class that provides a minimalistic event emitter with a tiny footprint powered by [nanoevents.](https://npmjs.com/package/nanoevents)  

+ 97 - 40
lib/Mixins.ts

@@ -5,6 +5,8 @@
 
 /* eslint-disable @typescript-eslint/no-explicit-any */
 
+import { purifyObj } from "./misc.js";
+
 /** Full mixin object as it is stored in the instance's mixin array. */
 export type MixinObj<TArg, TCtx> = {
   /** The public identifier key (purpose) of the mixin */
@@ -15,7 +17,7 @@ export type MixinObj<TArg, TCtx> = {
 
 /** Configuration object for a mixin function */
 export type MixinConfig = {
-  /** The higher, the earlier the mixin will be applied. Supports negative numbers too. 0 by default. */
+  /** The higher, the earlier the mixin will be applied. Supports floating-point and negative numbers too. 0 by default. */
   priority: number;
   /** If true, no further mixins will be applied after this one. */
   stopPropagation: boolean;
@@ -23,64 +25,92 @@ export type MixinConfig = {
   signal?: AbortSignal;
 }
 
+/** Configuration object for the Mixins class */
+export type MixinsConstructorConfig = {
+  /** If true, when no priority is specified, an auto-incrementing integer priority will be used (unique per mixin key). Defaults to false. */
+  autoIncrementPriority: boolean;
+  /** The default priority for mixins that do not specify one. Defaults to 0. */
+  defaultPriority: number;
+  /** The default stopPropagation value for mixins that do not specify one. Defaults to false. */
+  defaultStopPropagation: boolean;
+  /** The default AbortSignal for mixins that do not specify one. Defaults to undefined. */
+  defaultSignal?: AbortSignal;
+}
+
+//#region class
+
 /**
  * The mixin class allows for defining and applying mixin functions to allow multiple sources to modify values in a controlled way.  
  * Mixins are identified via their string key and can be added with {@linkcode add()}  
  * When calling {@linkcode resolve()}, all registered mixin functions with the same key will be applied to the input value in the order of their priority.  
  * If a mixin has the stopPropagation flag set to true, no further mixins will be applied after it.  
- *   
+ * @template TMixinMap A map of mixin keys to their respective function signatures. The first argument of the function is the input value, the second argument is an optional context object. If it is defined here, it must be passed as the third argument in {@linkcode resolve()}.
  * @example ```ts
+ * const ac = new AbortController();
+ * const { abort: removeAllMixins } = ac;
+ * 
  * const mathMixins = new Mixins<{
- *   foo: (val: number) => number;
- *   bar: (val: string, ctx: { baz: number }) => string;
- * }>();
+ *   foo: (val: number, ctx: { baz: string }) => number;
+ *   // first argument and return value have to be of the same type:
+ *   bar: (val: bigint) => bigint;
+ *   // ...
+ * }>({
+ *   autoIncrementPriority: true,
+ *   defaultPriority: 0,
+ *   defaultSignal: ac.signal,
+ * });
  * 
- * // will be applied second due to base priority of 0:
- * mathMixins.add("foo", (val) => val * 2);
- * // this mixin will be applied first, even though the above one was called first:
+ * // will be applied last due to base priority of 0:
+ * mathMixins.add("foo", (val, ctx) => val * 2 + ctx.baz.length);
+ * // will be applied second due to manually set priority of 1:
  * mathMixins.add("foo", (val) => val + 1, { priority: 1 });
+ * // will be applied first, even though the above ones were called first, because of the auto-incrementing priority of 2:
+ * mathMixins.add("foo", (val) => val / 2);
  * 
- * function getFoo() {
- *   // 1. start with 5 as the base value
- *   // 2. add 1
- *   // 3. multiply by 2
- *   // result: 12
- *   return mathMixins.resolve("foo", 5);
- * }
+ * const result = mathMixins.resolve("foo", 10, { baz: "my length is 15" });
+ * // order of application:
+ * // input value: 10
+ * // 10 / 2 = 5
+ * // 5 + 1 = 6
+ * // 6 * 2 + 15 = 27
+ * // result = 27
  * 
- * 
- * mathMixins.add("bar", (val, ctx) => `${val} ${btoa(ctx.baz)}`);
- * mathMixins.add("bar", (val, ctx) => `${val}!`);
- * 
- * function getBar() {
- *   // 1. start with "Hello" as the base value
- *   // 2. append base64-encoded value in ctx.baz
- *   // 3. append "!"
- *   // result: "Hello d29ybGQ=!"
- *   return mathMixins.resolve("bar", "Hello", { baz: "world" });
- * }
+ * // removes all mixins added without a `signal` property:
+ * removeAllMixins();
  * ```
  */
 export class Mixins<TMixinMap extends Record<string, (arg: any, ctx?: any) => any>> {
+  /** List of all registered mixins */
   protected mixins: MixinObj<any, any>[] = [];
-  protected readonly defaultConfig: MixinConfig;
+
+  /** Default configuration object for mixins */
+  protected readonly defaultMixinCfg: MixinConfig;
+
+  /** Whether the priorities should auto-increment if not specified */
+  protected readonly aiPriorityEnabled: boolean;
+  /** The current auto-increment priority counter */
+  protected aiPriorityCounter = new Map<string, number>();
 
   /**
    * Creates a new Mixins instance.
-   * @param defaultConfigOverrides An object to override the default configuration values for all mixin functions.
+   * @param config Configuration object to customize the mixin behavior.
    */
-  constructor(defaultConfigOverrides: Partial<MixinConfig> = {}) {
-    this.defaultConfig = {
-      priority: 0,
-      stopPropagation: false,
-      ...defaultConfigOverrides,
-    };
+  constructor(config: Partial<MixinsConstructorConfig> = {}) {
+    this.defaultMixinCfg = purifyObj({
+      priority: config.defaultPriority ?? 0,
+      stopPropagation: config.defaultStopPropagation ?? false,
+      signal: config.defaultSignal,
+    });
+    this.aiPriorityEnabled = config.autoIncrementPriority ?? false;
   }
 
+  //#region public
+
   /**
-   * Adds a modifier function to the mixin with the given {@linkcode mixinKey}.
+   * Adds a mixin function to the given {@linkcode mixinKey}.  
+   * If no priority is specified, it will be calculated via the protected method {@linkcode calcPriority()} based on the constructor configuration, or fall back to the default priority.
    * @param mixinKey The key to identify the mixin function.
-   * @param mixinFn The function to be called to apply the mixin.
+   * @param mixinFn The function to be called to apply the mixin. The first argument is the input value, the second argument is the context object (if any).
    * @param config Configuration object to customize the mixin behavior.
    * @returns Returns a cleanup function, to be called when this mixin is no longer needed.
    */
@@ -91,14 +121,16 @@ export class Mixins<TMixinMap extends Record<string, (arg: any, ctx?: any) => an
   >(
     mixinKey: TMixinKey,
     mixinFn: (arg: TArg, ...ctx: TCtx extends undefined ? [void] : [TCtx]) => TArg,
-    config: Partial<MixinConfig> = {}
+    config: Partial<MixinConfig> = purifyObj({}),
   ): () => void {
-    const mixin = {
+    const calcPrio = this.calcPriority(mixinKey, config);
+    const mixin = purifyObj({
+      ...this.defaultMixinCfg,
       key: mixinKey as string,
       fn: mixinFn,
-      ...this.defaultConfig,
       ...config,
-    } as MixinObj<TArg, TCtx>;
+      ...(typeof calcPrio === "number" && !isNaN(calcPrio) ? { priority: calcPrio } : {}),
+    }) as MixinObj<TArg, TCtx>;
     this.mixins.push(mixin);
 
     const clean = (): void => {
@@ -139,6 +171,31 @@ export class Mixins<TMixinMap extends Record<string, (arg: any, ctx?: any) => an
     return result;
   }
 
+  //#region protected
+
+  /** Calculates the priority for a mixin based on the given configuration and the current auto-increment state of the instance */
+  protected calcPriority(mixinKey: string, config: Partial<MixinConfig>): number | undefined {
+    // if prio specified, skip calculation
+    if(config.priority !== undefined)
+      return undefined;
+
+    // if a-i disabled, use default prio
+    if(!this.aiPriorityEnabled)
+      return config.priority ?? this.defaultMixinCfg.priority;
+
+    // initialize a-i map to default prio
+    if(!this.aiPriorityCounter.has(mixinKey))
+      this.aiPriorityCounter.set(mixinKey, this.defaultMixinCfg.priority);
+
+    // increment a-i prio until unique
+    let prio = this.aiPriorityCounter.get(mixinKey)!;
+    while(this.mixins.some((m) => m.key === mixinKey && m.priority === prio))
+      prio++;
+    this.aiPriorityCounter.set(mixinKey, prio + 1);
+
+    return prio;
+  }
+
   /** Removes all mixins with the given key */
   protected removeAll<TMixinKey extends keyof TMixinMap>(mixinKey: TMixinKey): void {
     this.mixins.filter((m) => m.key === mixinKey);