mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-30 16:47:54 +09:00
212 lines
6.2 KiB
TypeScript
212 lines
6.2 KiB
TypeScript
import { atom } from "nanostores";
|
|
import { $publisher, subscribe } from "~/shared/pubsub";
|
|
import { clientSyncStore } from "~/shared/sync/sync-stores";
|
|
|
|
export type CommandMeta<CommandName extends string> = {
|
|
name: CommandName;
|
|
label?: string;
|
|
/** brief description shown in keyboard shortcuts dialog */
|
|
description?: string;
|
|
/** category for grouping commands in UI for shortcuts dialog */
|
|
category?: "General" | "Panels" | "Navigator" | "Top bar" | "Style panel";
|
|
/** default because hotkeys can be customized from ui */
|
|
defaultHotkeys?: string[];
|
|
/** set to false when default browser or radix behavior is desired */
|
|
preventDefault?: boolean;
|
|
/** listen hotkeys only locally without sharing with other apps */
|
|
disableHotkeyOutsideApp?: boolean;
|
|
/**
|
|
* input, select and textarea, content editable and role=option used in Radix will not invoke command when hotkey is hit
|
|
* with the exception when default event behavior is prevented
|
|
**/
|
|
disableOnInputLikeControls?: boolean;
|
|
/**
|
|
* hide the command in meta+k panel
|
|
*/
|
|
hidden?: boolean;
|
|
/**
|
|
* keep command panel open after executing the command
|
|
*/
|
|
keepCommandPanelOpen?: boolean;
|
|
};
|
|
|
|
type CommandHandler = (source: string) => void;
|
|
|
|
/**
|
|
* Command can be registered by builder, canvas or plugin
|
|
*/
|
|
export type Command<CommandName extends string> = CommandMeta<CommandName> & {
|
|
/**
|
|
* Command handler accepting source where was triggered
|
|
* which is builder, canvas or plugin name
|
|
*/
|
|
handler: CommandHandler;
|
|
};
|
|
|
|
/*
|
|
* expose command metas to synchronize between builder, canvas and plugins
|
|
*/
|
|
export const $commandMetas = atom(new Map<string, CommandMeta<string>>());
|
|
clientSyncStore.register("commandMetas", $commandMetas);
|
|
|
|
const suppressedCommandEvents = new WeakSet<KeyboardEvent>();
|
|
|
|
export const suppressCommandsForEvent = (event: KeyboardEvent) => {
|
|
suppressedCommandEvents.add(event);
|
|
};
|
|
|
|
// Copied from https://github.com/ai/keyux/blob/main/hotkey.js#L1C1-L2C1
|
|
// eslint-disable-next-line no-control-regex
|
|
const nonEnglishLayout = /^[^\x00-\x7F]$/;
|
|
|
|
const getKey = (event: KeyboardEvent) => {
|
|
if (nonEnglishLayout.test(event.key) && /^Key.$/.test(event.code)) {
|
|
return event.code.replace(/^Key/, "").toLowerCase();
|
|
}
|
|
return event.key;
|
|
};
|
|
|
|
const findCommandsMatchingHotkeys = (event: KeyboardEvent) => {
|
|
const { ctrlKey, metaKey, shiftKey, altKey } = event;
|
|
const key = getKey(event);
|
|
const pressedKeys = new Set<string>();
|
|
pressedKeys.add(key.toLocaleLowerCase());
|
|
if (ctrlKey) {
|
|
pressedKeys.add("ctrl");
|
|
}
|
|
if (metaKey) {
|
|
pressedKeys.add("meta");
|
|
}
|
|
if (shiftKey) {
|
|
pressedKeys.add("shift");
|
|
}
|
|
if (altKey) {
|
|
pressedKeys.add("alt");
|
|
}
|
|
|
|
const commandMetas = $commandMetas.get();
|
|
const matchingCommands = new Set<CommandMeta<string>>();
|
|
for (const commandMeta of commandMetas.values()) {
|
|
if (commandMeta.defaultHotkeys === undefined) {
|
|
continue;
|
|
}
|
|
for (const hotkey of commandMeta.defaultHotkeys) {
|
|
const keys = hotkey.toLocaleLowerCase().split("+");
|
|
if (
|
|
keys.length === pressedKeys.size &&
|
|
keys.every((key) => pressedKeys.has(key))
|
|
) {
|
|
matchingCommands.add(commandMeta);
|
|
}
|
|
}
|
|
}
|
|
return matchingCommands;
|
|
};
|
|
|
|
export const createCommandsEmitter = <CommandName extends string>({
|
|
source,
|
|
commands,
|
|
}: {
|
|
source: string;
|
|
// type only input to describe available commands from builder or other plugins
|
|
externalCommands?: CommandName[];
|
|
commands: Command<CommandName>[];
|
|
}) => {
|
|
const commandHandlers = new Map<string, CommandHandler>();
|
|
for (const { handler, ...meta } of commands) {
|
|
commandHandlers.set(meta.name, handler);
|
|
}
|
|
|
|
const emitCommand = (name: CommandName) => {
|
|
const { publish } = $publisher.get();
|
|
// continue to work without emitter
|
|
// for example in tests
|
|
if (publish) {
|
|
publish({
|
|
type: "command",
|
|
payload: {
|
|
source,
|
|
name,
|
|
},
|
|
});
|
|
} else {
|
|
commandHandlers.get(name)?.(source);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* subscribe to keydown in every app and emit command globally
|
|
* actual handlers are executed in app where defined
|
|
*/
|
|
const subscribeCommands = () => {
|
|
// synchronize commands with other apps
|
|
// whenever current app is initialized
|
|
if (commands.length > 0) {
|
|
clientSyncStore.createTransaction([$commandMetas], (commandMetas) => {
|
|
for (const { handler, ...meta } of commands) {
|
|
commandMetas.set(meta.name, meta);
|
|
}
|
|
});
|
|
}
|
|
|
|
const unsubscribePubsub = subscribe("command", ({ name, source }) => {
|
|
commandHandlers.get(name)?.(source);
|
|
});
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (suppressedCommandEvents.has(event)) {
|
|
return;
|
|
}
|
|
let emitted = false;
|
|
let preventDefault = true;
|
|
for (const commandMeta of findCommandsMatchingHotkeys(event)) {
|
|
if (
|
|
commandMeta.disableHotkeyOutsideApp &&
|
|
commandHandlers.has(commandMeta.name) === false
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const { disableOnInputLikeControls } = commandMeta;
|
|
|
|
if (disableOnInputLikeControls) {
|
|
const element = event.target;
|
|
const isOnInputLikeControl =
|
|
element instanceof HTMLElement &&
|
|
(["input", "select", "textarea", "color-input"].includes(
|
|
element.tagName.toLowerCase()
|
|
) ||
|
|
element.isContentEditable ||
|
|
// Detect Radix select, dropdown and co.
|
|
element.getAttribute("role") === "option");
|
|
|
|
if (isOnInputLikeControl) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
emitted = true;
|
|
if (commandMeta.preventDefault === false) {
|
|
preventDefault = false;
|
|
}
|
|
emitCommand(commandMeta.name as CommandName);
|
|
}
|
|
// command can redefine browser hotkeys
|
|
// prevent to avoid unexpected behavior
|
|
// unless at least one matching command disabled prevent default
|
|
if (emitted && preventDefault) {
|
|
event.preventDefault();
|
|
}
|
|
};
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return () => {
|
|
unsubscribePubsub();
|
|
document.removeEventListener("keydown", handleKeyDown);
|
|
};
|
|
};
|
|
|
|
return {
|
|
emitCommand,
|
|
subscribeCommands,
|
|
};
|
|
};
|