import { debounce } from '$lib/helpers/debounce'; import { isMac } from '$lib/helpers/platform'; import { wizard } from '$lib/stores/wizard'; import { type ComponentType, onMount } from 'svelte'; import { derived, writable } from 'svelte/store'; import { nanoid } from 'nanoid/non-secure'; import { trackEvent } from '$lib/actions/analytics'; import { omit } from '$lib/helpers/omit'; const groups = [ 'ungrouped', 'navigation', 'projects', 'organizations', 'auth', 'help', 'account', 'platforms', 'databases', 'functions', 'messaging', 'messages', 'providers', 'topics', 'storage', 'domains', 'webhooks', 'integrations', 'migrations', 'users', 'tables', 'collections', 'columns', 'indexes', 'rows', 'documents', 'teams', 'security', 'buckets', 'files', 'misc', 'settings', 'sites' ] as const; export type CommandGroup = (typeof groups)[number]; type BaseCommand = { callback: () => void; label?: string; disabled?: boolean; forceEnable?: boolean; group?: CommandGroup; icon?: ComponentType; image?: string; rank?: number; nested?: boolean; keepOpen?: boolean; }; 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; }; export function isKeyedCommand(command: Command): command is KeyedCommand { return 'keys' in command && Array.isArray((command as KeyedCommand).keys); } export type Command = KeyedCommand | BaseCommand; export const commandMap = writable>(new Map()); export const disabledMap = writable>(new Map()); // Derived stores export const commands = derived(commandMap, ($commandMap) => { const res: Command[] = []; const keys = new Set(); const allCommands = Array.from($commandMap.values()).flat().toReversed(); for (const command of allCommands) { if (isKeyedCommand(command) && !command.disabled) { const keysString = command.keys.join('+'); if (keys.has(keysString)) { res.push(omit(command, 'keys')); continue; } keys.add(keysString); } res.push(command); } return res; }); 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); }); export function isTargetInputLike(element: EventTarget | null) { if (!(element instanceof HTMLElement)) return false; return !!element.closest( [ 'input', 'textarea', 'select', '[contenteditable]', '[role="combobox"]', '[role="textbox"]', '[role="searchbox"]', '[data-command-center-ignore]', '.cm-editor' ].join(',') ); } function getCommandRank(command: KeyedCommand) { const { keys, ctrl: meta, shift, alt } = command; const modifiers = [meta, shift, alt].filter(Boolean).length; return (keys?.length || 0) + 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( [commands, commandsEnabled, wizard], ([$commands, enabled, $wizard]) => { const commandsArr = $commands; let recentKeyCodes: number[] = []; let validCommands: KeyedCommand[] = []; const reset = debounce(() => { recentKeyCodes = []; validCommands = []; }, 1000); 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 || !enabled || isTargetInputLike(event.target) || $wizard.show ) { continue; } } const { keys, ctrl: meta, shift, alt } = command; const isMetaPressed = meta ? isMac() ? event.metaKey : event.ctrlKey : !(isMac() ? event.metaKey : event.ctrlKey); const isShiftPressed = shift ? event.shiftKey : !event.shiftKey; const isAltPressed = alt ? event.altKey : !event.altKey; const commandKeyCodes = keys?.map((key) => key.toUpperCase().charCodeAt(0)); const allKeysPressed = commandKeyCodes ? recentKeyCodes.join(',').includes(commandKeyCodes.join(',')) : false; if (allKeysPressed && isMetaPressed && isShiftPressed && isAltPressed) { event.preventDefault(); execute(command); } } }; } ); // Methods export const registerCommands = { subscribe(runner: (cb: (newCommands: Command[]) => void) => void) { const uuid = nanoid(); runner((newCommands: Command[]) => { commandMap.update((curr) => { const commandsWithTracking = newCommands.map((command) => { const trackingCallback = () => { if (command.label) { trackEvent('command', { label: command.label, group: command.group }); } command.callback(); }; return { ...command, callback: trackingCallback }; }); curr.set(uuid, commandsWithTracking); return curr; }); }); return () => { commandMap.update((curr) => { curr.delete(uuid); return curr; }); }; } }; export const disableCommands = { subscribe(runner: (cb: (disabled: boolean) => void) => void) { const uuid = nanoid(); runner((disabled: boolean) => { disabledMap.update((curr) => { curr.set(uuid, disabled); return curr; }); }); return () => { disabledMap.update((curr) => { curr.delete(uuid); return curr; }); }; } }; type CommandGroupRanks = Partial>; type GroupRanksMap = Map; const groupRanksMap = writable(new Map()); export const updateCommandGroupRanks = { subscribe(runner: (cb: (updater: CommandGroupRanks) => void) => void) { const uuid = nanoid(); runner((groupRank: CommandGroupRanks) => { groupRanksMap.update((curr) => { curr.set(uuid, groupRank); return curr; }); }); return () => { groupRanksMap.update((curr) => { curr.delete(uuid); return curr; }); }; } }; export const commandGroupRanks = derived(groupRanksMap, ($groupRankTransformations) => { const initialRanks = { ...Object.fromEntries(groups.map((group) => [group, 0])), ungrouped: 9999, databases: 50, users: 40, teams: 30, projects: 20, organizations: 10, navigation: 0, help: -20, misc: -30 } as CommandGroupRanks; const transformations = Array.from($groupRankTransformations.values()); return transformations.reduce((prev, curr) => ({ ...prev, ...curr }), initialRanks); }); export type Searcher = (query: string) => Promise; const searchersMap = writable>(new Map()); export const registerSearchers = { subscribe(runner: (cb: (...searchers: Searcher[]) => void) => void) { const uuid = nanoid(); runner((...searchers: Searcher[]) => { searchersMap.update((curr) => { curr.set(uuid, [...searchers]); return curr; }); }); return () => { searchersMap.update((curr) => { curr.delete(uuid); return curr; }); }; } }; export const searchers = derived(searchersMap, ($searchersMap) => { return Array.from($searchersMap.values()).flat(); }); export const initSearcher = (searcher: Searcher) => { const search = writable(''); const results = writable([]); const searcherDebounced = debounce(async (query: string) => { results.set(await searcher(query)); }, 500); onMount(() => { searcherDebounced.immediate(''); return search.subscribe((query) => { searcherDebounced(query); }); }); return { search, results }; };