Files
console/src/lib/commandCenter/commands.ts
T
2023-07-17 13:17:41 +01:00

275 lines
8.5 KiB
TypeScript

import { debounce } from '$lib/helpers/debounce';
import { isMac } from '$lib/helpers/platform';
import { derived, writable, type Updater } from 'svelte/store';
// Stores
export type CommandGroup =
| 'ungrouped'
| 'navigation'
| 'projects'
| 'organizations'
| 'auth'
| 'help'
| 'account'
| 'platforms'
| 'databases'
| 'functions'
| 'storage'
| 'domains'
| 'webhooks'
| 'integrations'
| 'migrations';
type BaseCommand = {
callback: () => void;
label?: string;
disabled?: boolean;
forceEnable?: boolean;
group?: CommandGroup;
icon?: string;
rank?: number;
};
type KeyedCommand = BaseCommand & {
keys: string[];
/* Ctrl on Windows/Linux, Meta on Mac */
ctrl?: boolean;
shift?: boolean;
/* Alt on Windows/Linux, Option on Mac */
alt?: boolean;
};
function isKeyedCommand(command: Command): command is KeyedCommand {
return 'keys' in command;
}
export type Command = KeyedCommand | BaseCommand;
export const commandMap = writable<Map<string, Command[]>>(new Map());
export const disabledMap = writable<Map<string, boolean>>(new Map());
// Derived stores
export const commands = derived(commandMap, ($commandMap) => {
return Array.from($commandMap.values()).flat();
});
const commandsEnabled = derived(disabledMap, ($disabledMap) => {
// If there's an item on the disabledMap that's true, then disable the command center
return Array.from($disabledMap.values()).every((disabled) => !disabled);
});
function isInputEvent(event: KeyboardEvent) {
return ['INPUT', 'TEXTAREA', 'SELECT'].includes((event.target as HTMLElement).tagName);
}
function getCommandRank(command: KeyedCommand) {
const { keys, ctrl: meta, shift, alt } = command;
const modifiers = [meta, shift, alt].filter(Boolean).length;
return keys.length + modifiers * 10;
}
function hasDisputing(command: KeyedCommand, allCommands: Command[]) {
return allCommands.some((otherCommand) => {
if (command === otherCommand) {
return false;
}
if (!isKeyedCommand(otherCommand)) {
return false;
}
const keysString = command.keys.join('+');
const otherKeysString = otherCommand.keys.join('+');
const cmdRank = getCommandRank(command);
const otherCmdRank = getCommandRank(otherCommand);
return (
(keysString.includes(otherKeysString) || otherKeysString.includes(keysString)) &&
cmdRank <= otherCmdRank
);
});
}
export const commandCenterKeyDownHandler = derived(
[commandMap, commandsEnabled],
([$commandMap, enabled]) => {
const commandsArr = Array.from($commandMap.values()).flat();
let recentKeyCodes: number[] = [];
let validCommands: KeyedCommand[] = [];
const reset = debounce(() => {
recentKeyCodes = [];
validCommands = [];
}, 2000);
const getHighestPriorityCommand = () => {
if (!validCommands.length) return;
if (validCommands.length === 1) {
return validCommands[0];
}
// Rank commands by how many keys and modifiers they have.
// Each key is worth 1 point, each modifier is worth 10 points.
// The command with the highest score wins.
const rankedCommands = validCommands.map((command) => {
return { command, score: getCommandRank(command) };
});
const highestScore = Math.max(...rankedCommands.map(({ score }) => score));
const highestScoreCommands = rankedCommands.filter(
({ score }) => score === highestScore
);
if (highestScoreCommands.length === 1) {
return highestScoreCommands[0].command;
}
// If there's still a tie, the command with the most modifiers wins.
// And if even that's a tie, the first command wins.
const mostModifiers = Math.max(
...highestScoreCommands.map(({ command }) => {
const { ctrl: meta, shift, alt } = command;
return [meta, shift, alt].filter(Boolean).length;
})
);
const mostModifiersCommands = highestScoreCommands.filter(({ command }) => {
const { ctrl: meta, shift, alt } = command;
return [meta, shift, alt].filter(Boolean).length === mostModifiers;
});
return mostModifiersCommands[0]?.command;
};
const rankAndExecute = debounce(() => {
const command = getHighestPriorityCommand();
command.callback();
reset.immediate();
}, 200);
const execute = (command: KeyedCommand) => {
if (hasDisputing(command, commandsArr)) {
validCommands.push(command);
rankAndExecute();
} else {
command.callback();
reset.immediate();
}
};
return (event: KeyboardEvent) => {
recentKeyCodes.push(event.keyCode);
reset();
for (const command of commandsArr) {
if (!isKeyedCommand(command)) continue;
if (!command.forceEnable) {
if (command.disabled) continue;
if (!enabled) continue;
if (isInputEvent(event)) continue;
}
const { keys, ctrl: meta, shift, alt } = command;
const isMetaPressed = meta ? (isMac() ? event.metaKey : event.ctrlKey) : true;
const isShiftPressed = shift ? event.shiftKey : true;
const isAltPressed = alt ? event.altKey : true;
const commandKeyCodes = keys.map((key) => key.toUpperCase().charCodeAt(0));
const allKeysPressed = recentKeyCodes.join('').includes(commandKeyCodes.join(''));
if (allKeysPressed && isMetaPressed && isShiftPressed && isAltPressed) {
event.preventDefault();
execute(command);
}
}
};
}
);
// Methods
export const registerCommands = {
subscribe(runner: (cb: (newCommands: Command[]) => void) => void) {
const uuid = crypto.randomUUID();
runner((newCommands: Command[]) => {
commandMap.update((curr) => {
curr.set(uuid, newCommands);
return curr;
});
});
return () => {
commandMap.update((curr) => {
curr.delete(uuid);
return curr;
});
};
}
};
export const disableCommands = {
subscribe(runner: (cb: (disabled: boolean) => void) => void) {
const uuid = crypto.randomUUID();
runner((disabled: boolean) => {
disabledMap.update((curr) => {
curr.set(uuid, disabled);
return curr;
});
});
return () => {
disabledMap.update((curr) => {
curr.delete(uuid);
return curr;
});
};
}
};
type CommandGroupRanks = Record<CommandGroup, number>;
type CommandGroupRankTransformations = Map<string, Updater<CommandGroupRanks>>;
const groupRankTransformations = writable<CommandGroupRankTransformations>(new Map());
export const updateCommandGroupRanks = {
subscribe(runner: (cb: (updater: Updater<CommandGroupRanks>) => void) => void) {
const uuid = crypto.randomUUID();
runner((updater: Updater<CommandGroupRanks>) => {
groupRankTransformations.update((curr) => {
curr.set(uuid, updater);
return curr;
});
});
return () => {
groupRankTransformations.update((curr) => {
curr.delete(uuid);
return curr;
});
};
}
};
export const commandGroupRanks = derived(groupRankTransformations, ($groupRankTransformations) => {
const initialRanks: CommandGroupRanks = {
ungrouped: 9999,
domains: 0,
webhooks: 0,
navigation: 1,
projects: 0,
account: 0,
organizations: 0,
auth: 0,
platforms: 0,
databases: 0,
functions: 0,
storage: 0,
integrations: 0,
migrations: 0,
help: -1
};
const transformations = Array.from($groupRankTransformations.values());
return transformations.reduce((prev, curr) => curr(prev), initialRanks);
});