@witchcraft/spellcraft 0.1.0 → 0.2.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.
Files changed (37) hide show
  1. package/dist/core/createManager.js +3 -2
  2. package/dist/core/createManagerOptions.js +1 -0
  3. package/dist/core/createShortcuts.d.ts +1 -1
  4. package/dist/core/setShortcutsProp.js +3 -3
  5. package/dist/defaults/defaultConditionEquals.d.ts +2 -16
  6. package/dist/defaults/defaultConditionEquals.js +1 -1
  7. package/dist/helpers/doesShortcutConflict.d.ts +1 -1
  8. package/dist/helpers/doesShortcutConflict.js +15 -3
  9. package/dist/helpers/equalsCommand.js +1 -1
  10. package/dist/helpers/equalsShortcut.d.ts +6 -4
  11. package/dist/helpers/equalsShortcut.js +5 -2
  12. package/dist/helpers/getKeyCodesFromKeyIds.d.ts +12 -0
  13. package/dist/helpers/getKeyCodesFromKeyIds.js +6 -0
  14. package/dist/helpers/getKeyCodesFromKeys.js +1 -1
  15. package/dist/module.json +1 -1
  16. package/dist/runtime/types.d.ts +2 -1
  17. package/dist/runtime/utils/shortcutToId.d.ts +1 -1
  18. package/dist/runtime/utils/shortcutToId.js +8 -6
  19. package/dist/types/condition.d.ts +3 -1
  20. package/dist/types/manager.d.ts +13 -1
  21. package/dist/types/shortcuts.d.ts +2 -2
  22. package/package.json +5 -5
  23. package/src/core/createManager.ts +3 -2
  24. package/src/core/createManagerOptions.ts +1 -0
  25. package/src/core/createShortcuts.ts +2 -2
  26. package/src/core/setShortcutsProp.ts +3 -3
  27. package/src/defaults/defaultConditionEquals.ts +4 -17
  28. package/src/helpers/doesShortcutConflict.ts +19 -4
  29. package/src/helpers/equalsCommand.ts +1 -1
  30. package/src/helpers/equalsShortcut.ts +17 -5
  31. package/src/helpers/getKeyCodesFromKeyIds.ts +20 -0
  32. package/src/helpers/getKeyCodesFromKeys.ts +1 -1
  33. package/src/runtime/types.ts +2 -1
  34. package/src/runtime/utils/shortcutToId.ts +9 -7
  35. package/src/types/condition.ts +3 -1
  36. package/src/types/manager.ts +14 -1
  37. package/src/types/shortcuts.ts +2 -2
@@ -51,7 +51,7 @@ export function createManager(rawManager, additionalOpts = {}) {
51
51
  if (!shortcuts || isArray(shortcuts)) {
52
52
  castType(shortcuts);
53
53
  const shortcutsList = [];
54
- const m2 = { ...m, commands, keys };
54
+ const m2 = { ...m, commands, keys, context: rawManager.context };
55
55
  if (shortcuts) {
56
56
  for (const shortcut of shortcuts) {
57
57
  const res2 = createShortcut(shortcut, m2);
@@ -62,7 +62,8 @@ export function createManager(rawManager, additionalOpts = {}) {
62
62
  const res = createShortcuts(shortcutsList, {
63
63
  keys,
64
64
  commands,
65
- options
65
+ options,
66
+ context: rawManager.context
66
67
  }, additionalOpts?.shortcuts);
67
68
  if (res.isError) return res;
68
69
  shortcuts = res.value;
@@ -4,6 +4,7 @@ import { defaultSorter } from "../defaults/KeysSorter.js";
4
4
  import { defaultStringifier } from "../defaults/Stringifier.js";
5
5
  export function createManagerOptions(rawOpts) {
6
6
  const options = {
7
+ shortcutEqualityStrategy: "ignoreCommand",
7
8
  sorter: defaultSorter,
8
9
  stringifier: defaultStringifier,
9
10
  conditionEquals: defaultConditionEquals,
@@ -6,7 +6,7 @@ import type { CanHookErrors, Manager, MultipleErrors, PickManager, Shortcut, Sho
6
6
  * Creates a set of shortcuts.
7
7
  *
8
8
  */
9
- export declare function createShortcuts<THooks extends Manager["hooks"], TRawShortcuts extends Shortcut[], TCheck extends boolean | "only" = true>(shortcutsList: TRawShortcuts, manager: Pick<Manager, "keys" | "commands"> & PickManager<"options", "evaluateCondition" | "conditionEquals" | "stringifier" | "sorter"> & {
9
+ export declare function createShortcuts<THooks extends Manager["hooks"], TRawShortcuts extends Shortcut[], TCheck extends boolean | "only" = true>(shortcutsList: TRawShortcuts, manager: Pick<Manager, "keys" | "commands" | "context"> & PickManager<"options", "evaluateCondition" | "conditionEquals" | "stringifier" | "sorter" | "shortcutEqualityStrategy"> & {
10
10
  hooks?: THooks;
11
11
  }, opts?: Partial<Pick<Shortcuts, "ignoreModifierConflicts" | "ignoreChainConflicts">>, { check }?: {
12
12
  check?: boolean | "only";
@@ -20,7 +20,7 @@ export function setShortcutsProp(prop, val, manager, {
20
20
  castType(manager);
21
21
  const shortcut = val;
22
22
  const existing = shortcuts.entries.find(
23
- (_) => equalsShortcut(shortcut, _, manager, { ignoreCommand: true }) || doesShortcutConflict(_, shortcut, manager)
23
+ (_) => equalsShortcut(shortcut, _, manager) || doesShortcutConflict(_, shortcut, manager)
24
24
  );
25
25
  if (existing) {
26
26
  return Err(new KnownError(
@@ -41,7 +41,7 @@ export function setShortcutsProp(prop, val, manager, {
41
41
  case "entries@remove": {
42
42
  const shortcut = val;
43
43
  const existing = shortcuts.entries.find(
44
- (_) => _ === shortcut || equalsShortcut(shortcut, _, manager, { ignoreCommand: false })
44
+ (_) => _ === shortcut || equalsShortcut(shortcut, _, manager, { shortcutEqualityStrategy: "all" })
45
45
  );
46
46
  if (existing === void 0) {
47
47
  return Err(new KnownError(
@@ -77,7 +77,7 @@ export function setShortcutsProp(prop, val, manager, {
77
77
  }
78
78
  case "entries@remove": {
79
79
  const shortcut = val;
80
- const i = shortcuts.entries.findIndex((_) => _ === shortcut || equalsShortcut(shortcut, _, manager, { ignoreCommand: false }));
80
+ const i = shortcuts.entries.findIndex((_) => _ === shortcut || equalsShortcut(shortcut, _, manager, { shortcutEqualityStrategy: "all" }));
81
81
  if (i < 0) {
82
82
  throw new Error("If used correctly, shortcut should exist at this point, but it does not.");
83
83
  }
@@ -1,19 +1,5 @@
1
1
  import type { Condition } from "../types/index.js";
2
2
  /**
3
- * Returns whether the condition passed is equal to this one.
4
- *
5
- * The default method does a simplistic object check, and otherwise a check of the `text` property for equality. If both conditions are undefined, note this will return true.
6
- *
7
- * If you override the default conditionEquals option you will likely want it to just always return false.
8
- *
9
- * Why? Because unless you're using simple single variable conditions that you can presort to make them uniquely identifiable (i.e. not boolean expressions, e.g. `!a b !c`), this will return A LOT of false negatives.
10
- *
11
- * Why the false negatives? Because two conditions might be functionally equal but have differing representations (e.g: `a && b`, `b && a`). You might think, lets normalize them all, but normalizing boolean expressions (converting them to CNF) can be dangerous with very long expressions because it can take exponential time.
12
- *
13
- * Now the main reason for checking the equality of two conditions is to check if two shortcuts might conflict. If we're using boolean expressions it just can't be done safely.
14
- *
15
- * This is a personal preference, but if we have a method that gives false negatives it can be confusing that some shortcuts immediately error when added because their conditions are simple, while others don't until triggered. The simpler, more consistent alternative is to only have them error on triggering. Aditionally conflicting conditions can be shown on the keyboard layout when then user picks contexts to check against.
16
- *
17
- * Why use the default implementation at all then? Well, shortcuts aren't the only ones that have conditions, commands can too, but unlike shortcuts, usually it's developers who are in charge of assigning a command's condition, and since they are usually simple, it's more possible to make sure the conditions are unique (e.g. tests could enforce they're unique by converting them all to CNF and pre-checking them for equality).
3
+ * IMPORTANT: Please read {@link ConditionComparer} before using.
18
4
  */
19
- export declare function defaultConditionEquals<TCondition extends Condition>(conditionA?: TCondition, conditionB?: Condition): conditionB is TCondition;
5
+ export declare function defaultConditionEquals<TCondition extends Condition>(conditionA: TCondition | undefined, conditionB: Condition | undefined, _type: "shortcut" | "command"): conditionB is TCondition;
@@ -1,4 +1,4 @@
1
- export function defaultConditionEquals(conditionA, conditionB) {
1
+ export function defaultConditionEquals(conditionA, conditionB, _type) {
2
2
  if (conditionA === conditionB) return true;
3
3
  if (!conditionA || !conditionB) return false;
4
4
  return conditionA.text === conditionB.text;
@@ -15,4 +15,4 @@ import type { Manager, PickManager, Shortcut } from "../types/index.js";
15
15
  */
16
16
  export declare function doesShortcutConflict<TShortcut extends Shortcut>(shortcutA: TShortcut, shortcutB: Shortcut, manager: Pick<Manager, "keys" | "commands" | "shortcuts"> & {
17
17
  context?: Manager["context"];
18
- } & PickManager<"options", "evaluateCondition" | "conditionEquals">): boolean;
18
+ } & PickManager<"options", "evaluateCondition" | "conditionEquals" | "shortcutEqualityStrategy">): boolean;
@@ -16,15 +16,27 @@ export function doesShortcutConflict(shortcutA, shortcutB, manager) {
16
16
  if (equalsShortcut(shortcutA, shortcutB, manager)) return true;
17
17
  const evaluateCondition = manager.options.evaluateCondition;
18
18
  const conditionEquals = manager.options.conditionEquals;
19
+ const commandA = shortcutA.command ? manager.commands.entries[shortcutA.command] : void 0;
20
+ const commandB = shortcutB.command ? manager.commands.entries[shortcutB.command] : void 0;
21
+ if ((shortcutA.condition || shortcutB.condition || commandA?.condition || commandB?.condition) && context === void 0) {
22
+ console.warn("Condition comparisons will not work without a context (the base conditionComparer is still used).");
23
+ }
19
24
  if (context) {
20
25
  if (shortcutA.condition && shortcutB.condition) {
21
26
  const shortcutCondition = evaluateCondition(shortcutA.condition, context);
22
27
  const otherCondition = evaluateCondition(shortcutB.condition, context);
23
- if (shortcutCondition !== otherCondition) return false;
24
- if (!shortcutCondition) return false;
28
+ if (!shortcutCondition || shortcutCondition !== otherCondition) return false;
29
+ }
30
+ if (commandA?.condition && commandB?.condition) {
31
+ const commandCondition = evaluateCondition(commandA.condition, context);
32
+ const otherCondition = evaluateCondition(commandB.condition, context);
33
+ if (!commandCondition || commandCondition !== otherCondition) return false;
25
34
  }
26
35
  } else {
27
- if (!conditionEquals(shortcutA.condition, shortcutB.condition)) return false;
36
+ if (!conditionEquals(shortcutA.condition, shortcutB.condition, "shortcut")) return false;
37
+ if (commandA?.condition && commandB?.condition) {
38
+ if (!conditionEquals(commandA?.condition, commandB?.condition, "command")) return false;
39
+ }
28
40
  }
29
41
  const { keys } = manager;
30
42
  if (shortcutA.chain.length === 0 || shortcutB.chain.length === 0) {
@@ -1,4 +1,4 @@
1
1
  export function equalsCommand(commandA, commandB, manager) {
2
2
  if (commandA === commandB) return true;
3
- return commandA.name === commandB.name && commandA.execute === commandB.execute && manager.options.conditionEquals(commandA.condition, commandB.condition) && commandA.description === commandB.description;
3
+ return commandA.name === commandB.name && commandA.execute === commandB.execute && manager.options.conditionEquals(commandA.condition, commandB.condition, "command") && commandA.description === commandB.description;
4
4
  }
@@ -2,8 +2,10 @@ import type { Manager, PickManager, Shortcut } from "../types/index.js";
2
2
  /**
3
3
  * Returns whether the shortcut passed is equal to shortcutA one.
4
4
  *
5
- * To return true, their keys and command must be equal (unless ignoreCommand is passed), their condition must be equal according to shortcutA shortcut's condition.
5
+ * To return true, their keys, conditions, and commands (depends on the configured `manager.options.shortcutEqualityStrategy`) must be equal.
6
+ *
7
+ * See {@link Manager.options.shortcutEqualityStrategy} for details.
8
+ *
9
+ * You can temporarily override the strategy witht the third parameter.
6
10
  */
7
- export declare function equalsShortcut<TShortcut extends Shortcut>(shortcutA: TShortcut, shortcutB: Shortcut, manager: Pick<Manager, "keys" | "commands"> & PickManager<"options", "evaluateCondition" | "conditionEquals">, { ignoreCommand }?: {
8
- ignoreCommand?: boolean;
9
- }): shortcutB is TShortcut;
11
+ export declare function equalsShortcut<TShortcut extends Shortcut>(shortcutA: TShortcut, shortcutB: Shortcut, manager: Pick<Manager, "keys" | "commands"> & PickManager<"options", "evaluateCondition" | "conditionEquals" | "shortcutEqualityStrategy">, overrides?: PickManager<"options", "shortcutEqualityStrategy">["options"]): shortcutB is TShortcut;
@@ -1,7 +1,10 @@
1
1
  import { equalsCommand } from "./equalsCommand.js";
2
2
  import { equalsKeys } from "../utils/equalsKeys.js";
3
- export function equalsShortcut(shortcutA, shortcutB, manager, { ignoreCommand = false } = {}) {
3
+ export function equalsShortcut(shortcutA, shortcutB, manager, overrides) {
4
4
  if (shortcutA.forceUnequal || shortcutB.forceUnequal) return false;
5
5
  if (shortcutA === shortcutB) return true;
6
- return equalsKeys(shortcutA.chain, shortcutB.chain, manager.keys, void 0, { allowVariants: true }) && manager.options.conditionEquals(shortcutA.condition, shortcutB.condition) && (ignoreCommand || shortcutA.command === shortcutB.command && shortcutA.command === void 0 || shortcutA.command !== void 0 && shortcutB.command !== void 0 && equalsCommand(manager.commands.entries[shortcutA.command], manager.commands.entries[shortcutB.command], manager));
6
+ const commandA = shortcutA.command ? manager.commands.entries[shortcutA.command] : void 0;
7
+ const commandB = shortcutB.command ? manager.commands.entries[shortcutB.command] : void 0;
8
+ const shortcutEqualityStrategy = overrides?.shortcutEqualityStrategy ?? manager.options.shortcutEqualityStrategy;
9
+ return equalsKeys(shortcutA.chain, shortcutB.chain, manager.keys, void 0, { allowVariants: true }) && manager.options.conditionEquals(shortcutA.condition, shortcutB.condition, "shortcut") && (shortcutEqualityStrategy === "ignoreCommand" || shortcutEqualityStrategy === "ignoreCommandWithDifferentCondition" && commandA?.condition && commandB?.condition && manager.options.conditionEquals(commandA.condition, commandB.condition, "command") || shortcutA.command === shortcutB.command && shortcutA.command === void 0 || shortcutA.command !== void 0 && shortcutB.command !== void 0 && equalsCommand(manager.commands.entries[shortcutA.command], manager.commands.entries[shortcutB.command], manager));
7
10
  }
@@ -0,0 +1,12 @@
1
+ import { getKeyCodesFromKeys } from "./getKeyCodesFromKeys.js";
2
+ import type { Keys } from "../types/keys.js";
3
+ /**
4
+ * Given an list of key ids, returns an array of deduped key codes. If you have a list of `Key`s instead use {@link getKeyCodesFromKeys}.
5
+ *
6
+ * Useful for when the codes must be passed to a third-party library (for example, a dragging library that takes a list modifiers).
7
+ *
8
+ * This properly takes a look at the key and all it's variants.
9
+ *
10
+ * `skipIdIfHasVariants` is true by default and will skip adding the key's id if it has variants as it's assumed the id is not a valid name.
11
+ */
12
+ export declare function getKeyCodesFromKeyIds(keyIds: string[], keys: Keys, opts: Parameters<typeof getKeyCodesFromKeys>[1]): string[];
@@ -0,0 +1,6 @@
1
+ import { getKeyCodesFromKeys } from "./getKeyCodesFromKeys.js";
2
+ import { getKeyFromIdOrVariant } from "./getKeyFromIdOrVariant.js";
3
+ export function getKeyCodesFromKeyIds(keyIds, keys, opts) {
4
+ const keysList = keyIds.flatMap((id) => getKeyFromIdOrVariant(id, keys).unwrap());
5
+ return getKeyCodesFromKeys(keysList, opts);
6
+ }
@@ -1,7 +1,7 @@
1
1
  export function getKeyCodesFromKeys(keys, { skipIdIfHasVariants = true } = {}) {
2
2
  const res = /* @__PURE__ */ new Set();
3
3
  for (const key of keys) {
4
- if (key.variants) {
4
+ if (key.variants && key.variants.length > 0) {
5
5
  if (!skipIdIfHasVariants) {
6
6
  res.add(key.id);
7
7
  }
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "witchcraftSpellcraft",
3
3
  "configKey": "witchcraftSpellcraft",
4
- "version": "0.1.0",
4
+ "version": "0.2.1",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
@@ -1,7 +1,8 @@
1
+ import type { OrToAnd } from "@alanscodelog/utils/types";
1
2
  export interface Register {
2
3
  }
3
4
  export type ExtendedShortcutManagerInfo = keyof Register extends `ShortcutManagerContext${infer T}` ? Register extends Record<`ShortcutManagerContext${T}`, infer U> ? U : {} : {};
4
- export type ContextInfo = ExtendedShortcutManagerInfo & {
5
+ export type ContextInfo = OrToAnd<ExtendedShortcutManagerInfo> & {
5
6
  count: Record<string, number>;
6
7
  isActive: Record<string, boolean>;
7
8
  };
@@ -2,4 +2,4 @@ import type { PickManager } from "../../types/general.js";
2
2
  import type { Manager } from "../../types/manager.js";
3
3
  import type { Shortcut } from "../../types/shortcuts.js";
4
4
  /** Turns a shortcut into an id to use in a v-for loop. */
5
- export declare const shortcutToId: (shortcut: Shortcut, manager: PickManager<"options", "stringifier"> & Pick<Manager, "keys">) => string;
5
+ export declare function shortcutToId(shortcut: Shortcut, manager: PickManager<"options", "stringifier"> & Pick<Manager, "keys">): string;
@@ -1,6 +1,8 @@
1
- export const shortcutToId = (shortcut, manager) => [
2
- shortcut.enabled,
3
- manager.options.stringifier.stringify(shortcut.chain, manager),
4
- shortcut.command ?? "",
5
- shortcut.condition.text
6
- ].join("--");
1
+ export function shortcutToId(shortcut, manager) {
2
+ return [
3
+ shortcut.enabled,
4
+ manager.options.stringifier.stringify(shortcut.chain, manager),
5
+ shortcut.command ?? "",
6
+ shortcut.condition.text
7
+ ].join("--");
8
+ }
@@ -24,6 +24,8 @@ export interface Condition {
24
24
  * This is a personal preference, but if we have a method that gives false negatives it can be confusing that some shortcuts immediately error when added because their conditions are simple, while others don't until triggered. The simpler, more consistent alternative is to only have them error on triggering. Aditionally conflicting conditions can be shown on the keyboard layout when then user picks contexts to check against.
25
25
  *
26
26
  * Why use the default implementation at all then? Well, shortcuts aren't the only ones that have conditions, commands can too, but unlike shortcuts, usually it's developers who are in charge of assigning a command's condition, and since they are usually simple, it's more possible to make sure the conditions are unique (e.g. tests could enforce they're unique by converting them all to CNF and pre-checking them for equality).
27
+ *
28
+ * This is why the manager now has the option `shortcutEqualityStrategy` which can be se to `ignoreCommandWithDifferentCondition`. You can implement a comparer that only checks when a command is being checked.
27
29
  */
28
- export type ConditionComparer = (conditionA?: Condition, conditionB?: Condition) => boolean;
30
+ export type ConditionComparer = (conditionA: Condition | undefined, conditionB: Condition | undefined, type: "shortcut" | "command") => boolean;
29
31
  export type ConditionEvaluator<TContext extends Context<any>> = (condition: Condition, context: TContext) => boolean;
@@ -114,9 +114,21 @@ export type Manager<THooks extends Partial<Hooks> = Partial<Hooks>, TKeys extend
114
114
  /**
115
115
  * Determines if two conditions are equal.
116
116
  *
117
- * This is actually not a good idea to implement if you use boolean conditions. See {@link ConditionComparer} for why.
117
+ * This is actually not a good idea to implement for shortcuts if you use boolean conditions. See {@link ConditionComparer} for why.
118
+ *
119
+ * See also {@link Manager.options.shortcutEqualityStrategy}.
118
120
  */
119
121
  conditionEquals: ConditionComparer;
122
+ /**
123
+ * Determines how shortcuts are compared.
124
+ *
125
+ * - `ignoreCommand` (default) will ignore both shortcuts' commands. If everything else is equal, they are considered equal.
126
+ * - `ignoreCommandWithDifferentCondition` will ignore both commands' names and only compare their conditions. If everything else is equal, they are considered equal. Use it if you allow command conditions to equal eachother. See {@link ConditionComparer} for details.
127
+ * - `all` will compare everything. Useful for finding an exact shortcut match. Should not be used when comparing shortcuts to find conflicts as it will allow shortcuts with the same chain but different commands to not be equal.
128
+ *
129
+ * Note that regardless of this setting shortcut conflicts can arise for other reasons due to `doesShortcutConflict` which checks other conditions.
130
+ */
131
+ shortcutEqualityStrategy: "ignoreCommand" | "ignoreCommandWithDifferentCondition" | "all";
120
132
  /** Determines how conditions are evaluated. */
121
133
  evaluateCondition: ConditionEvaluator<TContext>;
122
134
  /** Enable/disable triggering of shortcuts. The manager otherwise works as normal. */
@@ -126,12 +126,12 @@ export type CanHookShortcutProps = Exclude<OnHookShortcutProps, "forceUnequal">;
126
126
  export type SyntheticOnHookShortcutsProps = "entries@add" | "entries@remove";
127
127
  export type CanHookShortcutsProps = SyntheticOnHookShortcutsProps;
128
128
  export type OnHookShortcutsProps = SyntheticOnHookShortcutsProps;
129
- type BaseShortcutsManager = Pick<Manager, "keys" | "commands" | "shortcuts"> & PickManager<"options", "evaluateCondition" | "conditionEquals" | "stringifier"> & Record<any, any>;
129
+ type BaseShortcutsManager = Pick<Manager, "keys" | "commands" | "shortcuts"> & PickManager<"options", "evaluateCondition" | "conditionEquals" | "stringifier" | "shortcutEqualityStrategy"> & Record<any, any>;
130
130
  export type ShortcutsSetEntries = {
131
131
  "entries@add": {
132
132
  val: Shortcut;
133
133
  hooks: GetShortcutHooks<`entries@add`>;
134
- manager: BaseShortcutsManager & PickManager<"options", "sorter">;
134
+ manager: BaseShortcutsManager & PickManager<"options", "sorter" | "shortcutEqualityStrategy">;
135
135
  error: typeof SHORTCUT_ERROR.DUPLICATE_SHORTCUT | typeof SHORTCUT_ERROR.UNKNOWN_COMMAND | ChainError;
136
136
  };
137
137
  "entries@remove": {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@witchcraft/spellcraft",
3
3
  "description": "A shortcut manager library for handling ALL the shortcut needs of an application.",
4
- "version": "0.1.0",
4
+ "version": "0.2.1",
5
5
  "type": "module",
6
6
  "main": "./dist/core/index.js",
7
7
  "sideEffects": false,
@@ -39,8 +39,8 @@
39
39
  }
40
40
  },
41
41
  "peerDependencies": {
42
- "@witchcraft/expressit": "^0.4.1",
43
- "@witchcraft/ui": "^0.3.20"
42
+ "@witchcraft/expressit": "^0.4.2",
43
+ "@witchcraft/ui": "^0.3.24"
44
44
  },
45
45
  "peerDependenciesMeta": {
46
46
  "@witchcraft/ui": {
@@ -52,8 +52,8 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@alanscodelog/utils": "^6.0.2",
55
- "@witchcraft/expressit": "^0.4.1",
56
- "@witchcraft/ui": "^0.3.20",
55
+ "@witchcraft/expressit": "^0.4.2",
56
+ "@witchcraft/ui": "^0.3.24",
57
57
  "reka-ui": "^2.8.0",
58
58
  "vue": "^3.5.27"
59
59
  },
@@ -175,7 +175,7 @@ export function createManager<
175
175
  if (!shortcuts || isArray(shortcuts)) {
176
176
  castType<RawShortcut[]>(shortcuts)
177
177
  const shortcutsList: Shortcut[] = []
178
- const m2 = { ...m, commands: commands as Commands, keys: keys as Keys }
178
+ const m2 = { ...m, commands: commands as Commands, keys: keys as Keys, context: rawManager.context! }
179
179
  if (shortcuts) {
180
180
  for (const shortcut of shortcuts) {
181
181
  const res = createShortcut(shortcut, m2)
@@ -187,7 +187,8 @@ export function createManager<
187
187
  const res = createShortcuts(shortcutsList, {
188
188
  keys: keys! as Keys,
189
189
  commands: commands! as Commands,
190
- options
190
+ options,
191
+ context: rawManager.context!
191
192
  }, additionalOpts?.shortcuts)
192
193
  if (res.isError) return res as any
193
194
  shortcuts = res.value satisfies Shortcuts as any
@@ -16,6 +16,7 @@ export function createManagerOptions(
16
16
  rawOpts: Parameters<typeof createManager>[0]["options"]
17
17
  ): Manager["options"] {
18
18
  const options: Manager["options"] = {
19
+ shortcutEqualityStrategy: "ignoreCommand",
19
20
  sorter: defaultSorter,
20
21
  stringifier: defaultStringifier,
21
22
  conditionEquals: defaultConditionEquals,
@@ -16,8 +16,8 @@ export function createShortcuts<
16
16
  TCheck extends boolean | "only" = true
17
17
  >(
18
18
  shortcutsList: TRawShortcuts,
19
- manager: Pick<Manager, "keys" | "commands">
20
- & PickManager<"options", | "evaluateCondition" | "conditionEquals" | "stringifier" | "sorter">
19
+ manager: Pick<Manager, "keys" | "commands" | "context">
20
+ & PickManager<"options", | "evaluateCondition" | "conditionEquals" | "stringifier" | "sorter" | "shortcutEqualityStrategy">
21
21
  & { hooks?: THooks },
22
22
  opts?: Partial<Pick<Shortcuts, "ignoreModifierConflicts" | "ignoreChainConflicts">>,
23
23
  {
@@ -43,7 +43,7 @@ export function setShortcutsProp<
43
43
 
44
44
  const shortcut = val as any as Shortcut
45
45
  const existing = (shortcuts.entries).find(_ =>
46
- equalsShortcut(shortcut, _, manager, { ignoreCommand: true })
46
+ equalsShortcut(shortcut, _, manager)
47
47
  || doesShortcutConflict(_, shortcut, manager)
48
48
  )
49
49
 
@@ -67,7 +67,7 @@ export function setShortcutsProp<
67
67
  const shortcut = val as any as Shortcut
68
68
  // note we don't ignore the command here, we want to find an exact match
69
69
  const existing = (shortcuts.entries).find(_ =>
70
- _ === shortcut || equalsShortcut(shortcut, _, manager, { ignoreCommand: false })
70
+ _ === shortcut || equalsShortcut(shortcut, _, manager, { shortcutEqualityStrategy: "all" })
71
71
  )
72
72
  if (existing === undefined) {
73
73
  return Err(new KnownError(
@@ -105,7 +105,7 @@ export function setShortcutsProp<
105
105
  }
106
106
  case "entries@remove": {
107
107
  const shortcut = val
108
- const i = shortcuts.entries.findIndex(_ => _ === shortcut || equalsShortcut(shortcut, _, manager, { ignoreCommand: false }))
108
+ const i = shortcuts.entries.findIndex(_ => _ === shortcut || equalsShortcut(shortcut, _, manager, { shortcutEqualityStrategy: "all" }))
109
109
  if (i < 0) {
110
110
  throw new Error("If used correctly, shortcut should exist at this point, but it does not.")
111
111
  }
@@ -1,25 +1,12 @@
1
1
  import type { Condition } from "../types/index.js"
2
2
 
3
3
  /**
4
- * Returns whether the condition passed is equal to this one.
5
- *
6
- * The default method does a simplistic object check, and otherwise a check of the `text` property for equality. If both conditions are undefined, note this will return true.
7
- *
8
- * If you override the default conditionEquals option you will likely want it to just always return false.
9
- *
10
- * Why? Because unless you're using simple single variable conditions that you can presort to make them uniquely identifiable (i.e. not boolean expressions, e.g. `!a b !c`), this will return A LOT of false negatives.
11
- *
12
- * Why the false negatives? Because two conditions might be functionally equal but have differing representations (e.g: `a && b`, `b && a`). You might think, lets normalize them all, but normalizing boolean expressions (converting them to CNF) can be dangerous with very long expressions because it can take exponential time.
13
- *
14
- * Now the main reason for checking the equality of two conditions is to check if two shortcuts might conflict. If we're using boolean expressions it just can't be done safely.
15
- *
16
- * This is a personal preference, but if we have a method that gives false negatives it can be confusing that some shortcuts immediately error when added because their conditions are simple, while others don't until triggered. The simpler, more consistent alternative is to only have them error on triggering. Aditionally conflicting conditions can be shown on the keyboard layout when then user picks contexts to check against.
17
- *
18
- * Why use the default implementation at all then? Well, shortcuts aren't the only ones that have conditions, commands can too, but unlike shortcuts, usually it's developers who are in charge of assigning a command's condition, and since they are usually simple, it's more possible to make sure the conditions are unique (e.g. tests could enforce they're unique by converting them all to CNF and pre-checking them for equality).
4
+ * IMPORTANT: Please read {@link ConditionComparer} before using.
19
5
  */
20
6
  export function defaultConditionEquals<TCondition extends Condition>(
21
- conditionA?: TCondition,
22
- conditionB?: Condition
7
+ conditionA: TCondition | undefined,
8
+ conditionB: Condition | undefined,
9
+ _type: "shortcut" | "command"
23
10
  ): conditionB is TCondition {
24
11
  // both are the same object or both undefined
25
12
  if (conditionA === conditionB) return true
@@ -23,7 +23,7 @@ export function doesShortcutConflict<TShortcut extends Shortcut>(
23
23
  shortcutA: TShortcut,
24
24
  shortcutB: Shortcut,
25
25
  manager: Pick<Manager, "keys" | "commands" | "shortcuts"> & { context?: Manager["context"] }
26
- & PickManager<"options", "evaluateCondition" | "conditionEquals">
26
+ & PickManager<"options", "evaluateCondition" | "conditionEquals" | "shortcutEqualityStrategy">
27
27
  ): boolean {
28
28
  const context = manager.context
29
29
  if (!context && manager.shortcuts.useContextInConflictCheck) {
@@ -39,15 +39,30 @@ export function doesShortcutConflict<TShortcut extends Shortcut>(
39
39
  if (equalsShortcut(shortcutA, shortcutB, manager)) return true
40
40
  const evaluateCondition = manager.options.evaluateCondition
41
41
  const conditionEquals = manager.options.conditionEquals
42
+
43
+ const commandA = shortcutA.command ? manager.commands.entries[shortcutA.command] : undefined
44
+ const commandB = shortcutB.command ? manager.commands.entries[shortcutB.command] : undefined
45
+
46
+ if ((shortcutA.condition || shortcutB.condition || commandA?.condition || commandB?.condition) && context === undefined) {
47
+ // eslint-disable-next-line no-console
48
+ console.warn("Condition comparisons will not work without a context (the base conditionComparer is still used).")
49
+ }
42
50
  if (context) {
43
51
  if (shortcutA.condition && shortcutB.condition) {
44
52
  const shortcutCondition = evaluateCondition(shortcutA.condition, context)
45
53
  const otherCondition = evaluateCondition(shortcutB.condition, context)
46
- if (shortcutCondition !== otherCondition) return false
47
- if (!shortcutCondition) return false
54
+ if (!shortcutCondition || shortcutCondition !== otherCondition) return false
55
+ }
56
+ if (commandA?.condition && commandB?.condition) {
57
+ const commandCondition = evaluateCondition(commandA.condition, context)
58
+ const otherCondition = evaluateCondition(commandB.condition, context)
59
+ if (!commandCondition || commandCondition !== otherCondition) return false
48
60
  }
49
61
  } else {
50
- if (!conditionEquals(shortcutA.condition, shortcutB.condition)) return false
62
+ if (!conditionEquals(shortcutA.condition, shortcutB.condition, "shortcut")) return false
63
+ if (commandA?.condition && commandB?.condition) {
64
+ if (!conditionEquals(commandA?.condition, commandB?.condition, "command")) return false
65
+ }
51
66
  }
52
67
  const { keys } = manager
53
68
  // an empty chain is always in conflict ?
@@ -13,6 +13,6 @@ export function equalsCommand<TCommand extends Command>(
13
13
  if (commandA === commandB) return true
14
14
  return commandA.name === commandB.name
15
15
  && commandA.execute === commandB.execute
16
- && manager.options.conditionEquals(commandA.condition, commandB.condition)
16
+ && manager.options.conditionEquals(commandA.condition, commandB.condition, "command")
17
17
  && commandA.description === commandB.description
18
18
  }
@@ -6,20 +6,32 @@ import { equalsKeys } from "../utils/equalsKeys.js"
6
6
  /**
7
7
  * Returns whether the shortcut passed is equal to shortcutA one.
8
8
  *
9
- * To return true, their keys and command must be equal (unless ignoreCommand is passed), their condition must be equal according to shortcutA shortcut's condition.
9
+ * To return true, their keys, conditions, and commands (depends on the configured `manager.options.shortcutEqualityStrategy`) must be equal.
10
+ *
11
+ * See {@link Manager.options.shortcutEqualityStrategy} for details.
12
+ *
13
+ * You can temporarily override the strategy witht the third parameter.
10
14
  */
11
15
  export function equalsShortcut<TShortcut extends Shortcut>(
12
16
  shortcutA: TShortcut,
13
17
  shortcutB: Shortcut,
14
- manager: Pick<Manager, "keys" | "commands"> & PickManager<"options", | "evaluateCondition" | "conditionEquals">,
15
- { ignoreCommand = false }: { ignoreCommand?: boolean } = {}
18
+ manager: Pick<Manager, "keys" | "commands"> & PickManager<"options", | "evaluateCondition" | "conditionEquals" | "shortcutEqualityStrategy">,
19
+ overrides?: PickManager<"options", "shortcutEqualityStrategy">["options"]
16
20
  ): shortcutB is TShortcut {
17
21
  if (shortcutA.forceUnequal || shortcutB.forceUnequal) return false
18
22
  if (shortcutA === shortcutB) return true
23
+ const commandA = shortcutA.command ? manager.commands.entries[shortcutA.command] : undefined
24
+ const commandB = shortcutB.command ? manager.commands.entries[shortcutB.command] : undefined
25
+ const shortcutEqualityStrategy = overrides?.shortcutEqualityStrategy ?? manager.options.shortcutEqualityStrategy
19
26
  return (
20
27
  equalsKeys(shortcutA.chain, shortcutB.chain, manager.keys, undefined, { allowVariants: true })
21
- && manager.options.conditionEquals(shortcutA.condition, shortcutB.condition)
22
- && (ignoreCommand
28
+ && manager.options.conditionEquals(shortcutA.condition, shortcutB.condition, "shortcut")
29
+ && (shortcutEqualityStrategy === "ignoreCommand"
30
+ || (
31
+ shortcutEqualityStrategy === "ignoreCommandWithDifferentCondition"
32
+ && commandA?.condition && commandB?.condition
33
+ && manager.options.conditionEquals(commandA.condition, commandB.condition, "command")
34
+ )
23
35
  || (
24
36
  shortcutA.command === shortcutB.command
25
37
  && shortcutA.command === undefined
@@ -0,0 +1,20 @@
1
+ import { getKeyCodesFromKeys } from "./getKeyCodesFromKeys.js"
2
+ import { getKeyFromIdOrVariant } from "./getKeyFromIdOrVariant.js"
3
+
4
+ import type { Keys } from "../types/keys.js"
5
+
6
+ /**
7
+ * Given an list of key ids, returns an array of deduped key codes. If you have a list of `Key`s instead use {@link getKeyCodesFromKeys}.
8
+ *
9
+ * Useful for when the codes must be passed to a third-party library (for example, a dragging library that takes a list modifiers).
10
+ *
11
+ * This properly takes a look at the key and all it's variants.
12
+ *
13
+ * `skipIdIfHasVariants` is true by default and will skip adding the key's id if it has variants as it's assumed the id is not a valid name.
14
+ */
15
+
16
+
17
+ export function getKeyCodesFromKeyIds(keyIds: string[], keys: Keys, opts: Parameters<typeof getKeyCodesFromKeys>[1]): string[] {
18
+ const keysList = keyIds.flatMap(id => getKeyFromIdOrVariant(id, keys).unwrap())
19
+ return getKeyCodesFromKeys(keysList, opts)
20
+ }
@@ -13,7 +13,7 @@ import type { Key } from "../types/keys.js"
13
13
  export function getKeyCodesFromKeys(keys: Key[], { skipIdIfHasVariants = true}: { skipIdIfHasVariants?: boolean } = {}): string[] {
14
14
  const res = new Set<string>()
15
15
  for (const key of keys) {
16
- if (key.variants) {
16
+ if (key.variants && key.variants.length > 0) {
17
17
  if (!skipIdIfHasVariants) {
18
18
  res.add(key.id)
19
19
  }
@@ -1,3 +1,4 @@
1
+ import type { OrToAnd } from "@alanscodelog/utils/types"
1
2
  // allows users to register multiple contexts
2
3
  // @eslint-disable-next-line @typescript-eslint/no-empty-object-type
3
4
  export interface Register {}
@@ -16,7 +17,7 @@ export type ExtendedShortcutManagerInfo = keyof Register extends `ShortcutManage
16
17
  : {}
17
18
 
18
19
 
19
- export type ContextInfo = ExtendedShortcutManagerInfo & {
20
+ export type ContextInfo = OrToAnd<ExtendedShortcutManagerInfo> & {
20
21
  count: Record<string, number>
21
22
  isActive: Record<string, boolean>
22
23
  }
@@ -3,12 +3,14 @@ import type { Manager } from "../../types/manager.js"
3
3
  import type { Shortcut } from "../../types/shortcuts.js"
4
4
 
5
5
  /** Turns a shortcut into an id to use in a v-for loop. */
6
- export const shortcutToId = (
6
+ export function shortcutToId(
7
7
  shortcut: Shortcut,
8
8
  manager: PickManager<"options", "stringifier"> & Pick<Manager, "keys">
9
- ): string => [
10
- shortcut.enabled,
11
- manager.options.stringifier.stringify(shortcut.chain, manager),
12
- shortcut.command ?? "",
13
- shortcut.condition.text
14
- ].join("--")
9
+ ): string {
10
+ return [
11
+ shortcut.enabled,
12
+ manager.options.stringifier.stringify(shortcut.chain, manager),
13
+ shortcut.command ?? "",
14
+ shortcut.condition.text
15
+ ].join("--")
16
+ }
@@ -28,8 +28,10 @@ export interface Condition {
28
28
  * This is a personal preference, but if we have a method that gives false negatives it can be confusing that some shortcuts immediately error when added because their conditions are simple, while others don't until triggered. The simpler, more consistent alternative is to only have them error on triggering. Aditionally conflicting conditions can be shown on the keyboard layout when then user picks contexts to check against.
29
29
  *
30
30
  * Why use the default implementation at all then? Well, shortcuts aren't the only ones that have conditions, commands can too, but unlike shortcuts, usually it's developers who are in charge of assigning a command's condition, and since they are usually simple, it's more possible to make sure the conditions are unique (e.g. tests could enforce they're unique by converting them all to CNF and pre-checking them for equality).
31
+ *
32
+ * This is why the manager now has the option `shortcutEqualityStrategy` which can be se to `ignoreCommandWithDifferentCondition`. You can implement a comparer that only checks when a command is being checked.
31
33
  */
32
- export type ConditionComparer = (conditionA?: Condition, conditionB?: Condition) => boolean
34
+ export type ConditionComparer = (conditionA: Condition | undefined, conditionB: Condition | undefined, type: "shortcut" | "command") => boolean
33
35
 
34
36
  export type ConditionEvaluator<TContext extends Context<any>> = (condition: Condition, context: TContext) => boolean
35
37
 
@@ -212,9 +212,22 @@ export type Manager<
212
212
  /**
213
213
  * Determines if two conditions are equal.
214
214
  *
215
- * This is actually not a good idea to implement if you use boolean conditions. See {@link ConditionComparer} for why.
215
+ * This is actually not a good idea to implement for shortcuts if you use boolean conditions. See {@link ConditionComparer} for why.
216
+ *
217
+ * See also {@link Manager.options.shortcutEqualityStrategy}.
216
218
  */
217
219
  conditionEquals: ConditionComparer
220
+ /**
221
+ * Determines how shortcuts are compared.
222
+ *
223
+ * - `ignoreCommand` (default) will ignore both shortcuts' commands. If everything else is equal, they are considered equal.
224
+ * - `ignoreCommandWithDifferentCondition` will ignore both commands' names and only compare their conditions. If everything else is equal, they are considered equal. Use it if you allow command conditions to equal eachother. See {@link ConditionComparer} for details.
225
+ * - `all` will compare everything. Useful for finding an exact shortcut match. Should not be used when comparing shortcuts to find conflicts as it will allow shortcuts with the same chain but different commands to not be equal.
226
+ *
227
+ * Note that regardless of this setting shortcut conflicts can arise for other reasons due to `doesShortcutConflict` which checks other conditions.
228
+ */
229
+ shortcutEqualityStrategy: "ignoreCommand" | "ignoreCommandWithDifferentCondition" | "all"
230
+
218
231
  /** Determines how conditions are evaluated. */
219
232
  evaluateCondition: ConditionEvaluator<TContext>
220
233
  /** Enable/disable triggering of shortcuts. The manager otherwise works as normal. */
@@ -178,7 +178,7 @@ export type OnHookShortcutsProps = SyntheticOnHookShortcutsProps
178
178
 
179
179
  type BaseShortcutsManager
180
180
  = & Pick<Manager, "keys" | "commands" | "shortcuts">
181
- & PickManager<"options", | "evaluateCondition" | "conditionEquals" | "stringifier">
181
+ & PickManager<"options", | "evaluateCondition" | "conditionEquals" | "stringifier" | "shortcutEqualityStrategy">
182
182
  & Record<any, any>
183
183
 
184
184
  export type ShortcutsSetEntries = {
@@ -187,7 +187,7 @@ export type ShortcutsSetEntries = {
187
187
  val: Shortcut
188
188
  hooks: GetShortcutHooks<`entries@add`>
189
189
  manager: BaseShortcutsManager
190
- & PickManager<"options", "sorter">
190
+ & PickManager<"options", "sorter" | "shortcutEqualityStrategy">
191
191
 
192
192
  error:
193
193
  | typeof SHORTCUT_ERROR.DUPLICATE_SHORTCUT