mirror of
https://github.com/makeplane/plane.git
synced 2025-12-16 20:07:56 +01:00
refactor command palette into modular command system
This commit is contained in:
135
apps/web/core/components/command-palette/command-config.ts
Normal file
135
apps/web/core/components/command-palette/command-config.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import React from "react";
|
||||
import { FolderPlus, Settings } from "lucide-react";
|
||||
import { LayersIcon } from "@plane/propel/icons";
|
||||
import {
|
||||
PROJECT_TRACKER_ELEMENTS,
|
||||
WORK_ITEM_TRACKER_ELEMENTS,
|
||||
} from "@plane/constants";
|
||||
import { captureClick } from "@/helpers/event-tracker.helper";
|
||||
import { PaletteCommandGroup } from "./types";
|
||||
|
||||
interface BuildParams {
|
||||
workspaceSlug?: string | string[];
|
||||
pages: string[];
|
||||
workspaceProjectIds?: string[];
|
||||
canPerformAnyCreateAction: boolean;
|
||||
canPerformWorkspaceActions: boolean;
|
||||
closePalette: () => void;
|
||||
toggleCreateIssueModal: (v: boolean) => void;
|
||||
toggleCreateProjectModal: (v: boolean) => void;
|
||||
setPages: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
setPlaceholder: (v: string) => void;
|
||||
setSearchTerm: (v: string) => void;
|
||||
createNewWorkspace: () => void;
|
||||
}
|
||||
|
||||
export const buildCommandGroups = (params: BuildParams): PaletteCommandGroup[] => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
pages,
|
||||
workspaceProjectIds,
|
||||
canPerformAnyCreateAction,
|
||||
canPerformWorkspaceActions,
|
||||
closePalette,
|
||||
toggleCreateIssueModal,
|
||||
toggleCreateProjectModal,
|
||||
setPages,
|
||||
setPlaceholder,
|
||||
setSearchTerm,
|
||||
createNewWorkspace,
|
||||
} = params;
|
||||
|
||||
const groups: PaletteCommandGroup[] = [];
|
||||
|
||||
if (
|
||||
workspaceSlug &&
|
||||
workspaceProjectIds &&
|
||||
workspaceProjectIds.length > 0 &&
|
||||
canPerformAnyCreateAction
|
||||
) {
|
||||
groups.push({
|
||||
id: "work-item",
|
||||
heading: "Work item",
|
||||
commands: [
|
||||
{
|
||||
id: "create-work-item",
|
||||
label: "Create new work item",
|
||||
shortcut: "C",
|
||||
icon: LayersIcon,
|
||||
perform: () => {
|
||||
closePalette();
|
||||
captureClick({ elementName: WORK_ITEM_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_BUTTON });
|
||||
toggleCreateIssueModal(true);
|
||||
},
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (workspaceSlug && canPerformWorkspaceActions) {
|
||||
groups.push({
|
||||
id: "project",
|
||||
heading: "Project",
|
||||
commands: [
|
||||
{
|
||||
id: "create-project",
|
||||
label: "Create new project",
|
||||
shortcut: "P",
|
||||
icon: FolderPlus,
|
||||
perform: () => {
|
||||
closePalette();
|
||||
captureClick({ elementName: PROJECT_TRACKER_ELEMENTS.COMMAND_PALETTE_CREATE_BUTTON });
|
||||
toggleCreateProjectModal(true);
|
||||
},
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
groups.push({
|
||||
id: "workspace-settings",
|
||||
heading: "Workspace Settings",
|
||||
commands: [
|
||||
{
|
||||
id: "search-settings",
|
||||
label: "Search settings...",
|
||||
icon: Settings,
|
||||
perform: () => {
|
||||
setPlaceholder("Search workspace settings...");
|
||||
setSearchTerm("");
|
||||
setPages((p) => [...p, "settings"]);
|
||||
},
|
||||
enabled: !!(canPerformWorkspaceActions && workspaceSlug),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
groups.push({
|
||||
id: "account",
|
||||
heading: "Account",
|
||||
commands: [
|
||||
{
|
||||
id: "create-workspace",
|
||||
label: "Create new workspace",
|
||||
icon: FolderPlus,
|
||||
perform: createNewWorkspace,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "change-interface-theme",
|
||||
label: "Change interface theme...",
|
||||
icon: Settings,
|
||||
perform: () => {
|
||||
setPlaceholder("Change interface theme...");
|
||||
setSearchTerm("");
|
||||
setPages((p) => [...p, "change-interface-theme"]);
|
||||
},
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return groups;
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root";
|
||||
// helpers
|
||||
// hooks
|
||||
import { captureClick } from "@/helpers/event-tracker.helper";
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
@@ -46,6 +45,8 @@ import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web components
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
|
||||
import { buildCommandGroups } from "./command-config";
|
||||
import { PaletteCommandGroup } from "./types";
|
||||
// plane web services
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
|
||||
@@ -124,6 +125,38 @@ export const CommandModal: React.FC = observer(() => {
|
||||
router.push("/create-workspace");
|
||||
};
|
||||
|
||||
const commandGroups: PaletteCommandGroup[] = useMemo(
|
||||
() =>
|
||||
buildCommandGroups({
|
||||
workspaceSlug,
|
||||
pages,
|
||||
workspaceProjectIds,
|
||||
canPerformAnyCreateAction,
|
||||
canPerformWorkspaceActions,
|
||||
closePalette,
|
||||
toggleCreateIssueModal,
|
||||
toggleCreateProjectModal,
|
||||
setPages,
|
||||
setPlaceholder,
|
||||
setSearchTerm,
|
||||
createNewWorkspace,
|
||||
}),
|
||||
[
|
||||
workspaceSlug,
|
||||
pages,
|
||||
workspaceProjectIds,
|
||||
canPerformAnyCreateAction,
|
||||
canPerformWorkspaceActions,
|
||||
closePalette,
|
||||
toggleCreateIssueModal,
|
||||
toggleCreateProjectModal,
|
||||
setPages,
|
||||
setPlaceholder,
|
||||
setSearchTerm,
|
||||
createNewWorkspace,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
if (!workspaceSlug) return;
|
||||
@@ -341,91 +374,32 @@ export const CommandModal: React.FC = observer(() => {
|
||||
setSearchTerm={(newSearchTerm) => setSearchTerm(newSearchTerm)}
|
||||
/>
|
||||
)}
|
||||
{workspaceSlug &&
|
||||
workspaceProjectIds &&
|
||||
workspaceProjectIds.length > 0 &&
|
||||
{commandGroups.map((group) => (
|
||||
<React.Fragment key={group.id}>
|
||||
{group.id === "workspace-settings" &&
|
||||
projectId &&
|
||||
canPerformAnyCreateAction && (
|
||||
<Command.Group heading="Work item">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
captureClick({
|
||||
elementName: WORK_ITEM_TRACKER_ELEMENTS.COMMAND_PALETTE_ADD_BUTTON,
|
||||
});
|
||||
toggleCreateIssueModal(true);
|
||||
}}
|
||||
className="focus:bg-custom-background-80"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<LayersIcon className="h-3.5 w-3.5" />
|
||||
Create new work item
|
||||
</div>
|
||||
<kbd>C</kbd>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
)}
|
||||
{workspaceSlug && canPerformWorkspaceActions && (
|
||||
<Command.Group heading="Project">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
captureClick({ elementName: PROJECT_TRACKER_ELEMENTS.COMMAND_PALETTE_CREATE_BUTTON });
|
||||
toggleCreateProjectModal(true);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<FolderPlus className="h-3.5 w-3.5" />
|
||||
Create new project
|
||||
</div>
|
||||
<kbd>P</kbd>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
)}
|
||||
|
||||
{/* project actions */}
|
||||
{projectId && canPerformAnyCreateAction && (
|
||||
<CommandPaletteProjectActions closePalette={closePalette} />
|
||||
)}
|
||||
{canPerformWorkspaceActions && (
|
||||
<Command.Group heading="Workspace Settings">
|
||||
<Command.Group heading={group.heading}>
|
||||
{group.commands
|
||||
.filter((c) => c.enabled !== false)
|
||||
.map((c) => (
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setPlaceholder("Search workspace settings...");
|
||||
setSearchTerm("");
|
||||
setPages([...pages, "settings"]);
|
||||
}}
|
||||
key={c.id}
|
||||
onSelect={() => c.perform()}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
Search settings...
|
||||
{c.icon && <c.icon className="h-3.5 w-3.5" />}
|
||||
{c.label}
|
||||
</div>
|
||||
{c.shortcut && <kbd>{c.shortcut}</kbd>}
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.Group>
|
||||
)}
|
||||
<Command.Group heading="Account">
|
||||
<Command.Item onSelect={createNewWorkspace} className="focus:outline-none">
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<FolderPlus className="h-3.5 w-3.5" />
|
||||
Create new workspace
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setPlaceholder("Change interface theme...");
|
||||
setSearchTerm("");
|
||||
setPages([...pages, "change-interface-theme"]);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
Change interface theme...
|
||||
</div>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
|
||||
</React.Fragment>
|
||||
))}
|
||||
{/* help options */}
|
||||
<CommandPaletteHelpActions closePalette={closePalette} />
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, FC, useMemo } from "react";
|
||||
import React, { useCallback, FC, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
@@ -10,6 +10,7 @@ import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { copyTextToClipboard } from "@plane/utils";
|
||||
import { CommandModal, ShortcutsModal } from "@/components/command-palette";
|
||||
import { useShortcuts, Shortcut } from "./use-shortcuts";
|
||||
// helpers
|
||||
// hooks
|
||||
import { captureClick } from "@/helpers/event-tracker.helper";
|
||||
@@ -158,98 +159,119 @@ export const CommandPalette: FC = observer(() => {
|
||||
[]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
const { key, ctrlKey, metaKey, altKey, shiftKey } = e;
|
||||
if (!key) return;
|
||||
const isEditable = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
return (
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLInputElement ||
|
||||
target.isContentEditable ||
|
||||
target.classList.contains("ProseMirror")
|
||||
);
|
||||
};
|
||||
|
||||
const keyPressed = key.toLowerCase();
|
||||
const cmdClicked = ctrlKey || metaKey;
|
||||
const shiftClicked = shiftKey;
|
||||
const deleteKey = keyPressed === "backspace" || keyPressed === "delete";
|
||||
|
||||
if (cmdClicked && keyPressed === "k" && !isAnyModalOpen) {
|
||||
e.preventDefault();
|
||||
toggleCommandPaletteModal(true);
|
||||
}
|
||||
|
||||
// if on input, textarea or editor, don't do anything
|
||||
if (
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLInputElement ||
|
||||
(e.target as Element)?.classList?.contains("ProseMirror")
|
||||
)
|
||||
return;
|
||||
|
||||
if (shiftClicked && (keyPressed === "?" || keyPressed === "/") && !isAnyModalOpen) {
|
||||
e.preventDefault();
|
||||
toggleShortcutModal(true);
|
||||
}
|
||||
|
||||
if (deleteKey) {
|
||||
if (performProjectBulkDeleteActions()) {
|
||||
shortcutsList.project.delete.action();
|
||||
}
|
||||
} else if (cmdClicked) {
|
||||
if (keyPressed === "c" && ((platform === "MacOS" && ctrlKey) || altKey)) {
|
||||
e.preventDefault();
|
||||
copyIssueUrlToClipboard();
|
||||
} else if (keyPressed === "b") {
|
||||
e.preventDefault();
|
||||
toggleSidebar();
|
||||
}
|
||||
} else if (!isAnyModalOpen) {
|
||||
captureClick({ elementName: COMMAND_PALETTE_TRACKER_ELEMENTS.COMMAND_PALETTE_SHORTCUT_KEY });
|
||||
if (
|
||||
Object.keys(shortcutsList.global).includes(keyPressed) &&
|
||||
((!projectId && performAnyProjectCreateActions()) || performProjectCreateActions())
|
||||
) {
|
||||
shortcutsList.global[keyPressed].action();
|
||||
}
|
||||
// workspace authorized actions
|
||||
else if (
|
||||
Object.keys(shortcutsList.workspace).includes(keyPressed) &&
|
||||
workspaceSlug &&
|
||||
performWorkspaceCreateActions()
|
||||
) {
|
||||
e.preventDefault();
|
||||
shortcutsList.workspace[keyPressed].action();
|
||||
}
|
||||
// project authorized actions
|
||||
else if (
|
||||
Object.keys(shortcutsList.project).includes(keyPressed) &&
|
||||
projectId &&
|
||||
performProjectCreateActions()
|
||||
) {
|
||||
e.preventDefault();
|
||||
// actions that can be performed only inside a project
|
||||
shortcutsList.project[keyPressed].action();
|
||||
}
|
||||
}
|
||||
// Additional keydown events
|
||||
handleAdditionalKeyDownEvents(e);
|
||||
const shortcuts: Shortcut[] = useMemo(() => {
|
||||
const list: Shortcut[] = [
|
||||
{
|
||||
keys: ["meta", "k"],
|
||||
handler: () => toggleCommandPaletteModal(true),
|
||||
enabled: () => !isAnyModalOpen,
|
||||
},
|
||||
[
|
||||
{
|
||||
keys: ["control", "k"],
|
||||
handler: () => toggleCommandPaletteModal(true),
|
||||
enabled: () => !isAnyModalOpen,
|
||||
},
|
||||
{
|
||||
keys: ["shift", "?"],
|
||||
handler: () => toggleShortcutModal(true),
|
||||
enabled: () => !isAnyModalOpen,
|
||||
},
|
||||
{
|
||||
keys: ["shift", "/"],
|
||||
handler: () => toggleShortcutModal(true),
|
||||
enabled: () => !isAnyModalOpen,
|
||||
},
|
||||
{
|
||||
keys: platform === "MacOS" ? ["control", "meta", "c"] : ["control", "alt", "c"],
|
||||
handler: () => copyIssueUrlToClipboard(),
|
||||
enabled: () => !!workItem,
|
||||
},
|
||||
{
|
||||
keys: ["meta", "b"],
|
||||
handler: () => toggleSidebar(),
|
||||
enabled: (e) => !isEditable(e),
|
||||
},
|
||||
{
|
||||
keys: ["control", "b"],
|
||||
handler: () => toggleSidebar(),
|
||||
enabled: (e) => !isEditable(e),
|
||||
},
|
||||
];
|
||||
|
||||
Object.keys(shortcutsList.global).forEach((k) => {
|
||||
list.push({
|
||||
sequence: [k],
|
||||
handler: () => {
|
||||
captureClick({ elementName: COMMAND_PALETTE_TRACKER_ELEMENTS.COMMAND_PALETTE_SHORTCUT_KEY });
|
||||
shortcutsList.global[k].action();
|
||||
},
|
||||
enabled: (e) =>
|
||||
!isEditable(e) &&
|
||||
!isAnyModalOpen &&
|
||||
(((!projectId && performAnyProjectCreateActions()) || performProjectCreateActions())),
|
||||
});
|
||||
});
|
||||
|
||||
Object.keys(shortcutsList.workspace).forEach((k) => {
|
||||
list.push({
|
||||
sequence: [k],
|
||||
handler: () => {
|
||||
captureClick({ elementName: COMMAND_PALETTE_TRACKER_ELEMENTS.COMMAND_PALETTE_SHORTCUT_KEY });
|
||||
shortcutsList.workspace[k].action();
|
||||
},
|
||||
enabled: (e) =>
|
||||
!isEditable(e) &&
|
||||
!isAnyModalOpen &&
|
||||
!!workspaceSlug &&
|
||||
performWorkspaceCreateActions(),
|
||||
});
|
||||
});
|
||||
|
||||
Object.entries(shortcutsList.project).forEach(([k, v]) => {
|
||||
const isDeleteKey = k === "delete" || k === "backspace";
|
||||
list.push({
|
||||
sequence: [k],
|
||||
handler: () => {
|
||||
captureClick({ elementName: COMMAND_PALETTE_TRACKER_ELEMENTS.COMMAND_PALETTE_SHORTCUT_KEY });
|
||||
v.action();
|
||||
},
|
||||
enabled: (e) =>
|
||||
!isEditable(e) &&
|
||||
!isAnyModalOpen &&
|
||||
!!projectId &&
|
||||
(isDeleteKey ? performProjectBulkDeleteActions() : performProjectCreateActions()),
|
||||
});
|
||||
});
|
||||
|
||||
return list;
|
||||
}, [
|
||||
copyIssueUrlToClipboard,
|
||||
isAnyModalOpen,
|
||||
platform,
|
||||
performAnyProjectCreateActions,
|
||||
performProjectBulkDeleteActions,
|
||||
performProjectCreateActions,
|
||||
performWorkspaceCreateActions,
|
||||
platform,
|
||||
projectId,
|
||||
shortcutsList,
|
||||
toggleCommandPaletteModal,
|
||||
toggleShortcutModal,
|
||||
toggleSidebar,
|
||||
workspaceSlug,
|
||||
]
|
||||
);
|
||||
workItem,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
useShortcuts(shortcuts, { additional: handleAdditionalKeyDownEvents });
|
||||
|
||||
if (!currentUser) return null;
|
||||
|
||||
|
||||
16
apps/web/core/components/command-palette/types.ts
Normal file
16
apps/web/core/components/command-palette/types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
|
||||
export interface PaletteCommand {
|
||||
id: string;
|
||||
label: string;
|
||||
shortcut?: string;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
perform: () => void;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface PaletteCommandGroup {
|
||||
id: string;
|
||||
heading: string;
|
||||
commands: PaletteCommand[];
|
||||
}
|
||||
67
apps/web/core/components/command-palette/use-shortcuts.ts
Normal file
67
apps/web/core/components/command-palette/use-shortcuts.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
export interface Shortcut {
|
||||
keys?: string[]; // simultaneous combination
|
||||
sequence?: string[]; // sequential keys
|
||||
handler: (e: KeyboardEvent) => void;
|
||||
enabled?: (e: KeyboardEvent) => boolean;
|
||||
preventDefault?: boolean;
|
||||
}
|
||||
|
||||
const matchCombo = (pressed: string[], combo: string[]) => {
|
||||
if (pressed.length !== combo.length) return false;
|
||||
return combo.every((k) => pressed.includes(k));
|
||||
};
|
||||
|
||||
const matchSequence = (buffer: string[], sequence: string[]) => {
|
||||
if (buffer.length < sequence.length) return false;
|
||||
const start = buffer.length - sequence.length;
|
||||
return sequence.every((k, i) => buffer[start + i] === k);
|
||||
};
|
||||
|
||||
export const useShortcuts = (
|
||||
shortcuts: Shortcut[],
|
||||
options?: { filter?: (e: KeyboardEvent) => boolean; additional?: (e: KeyboardEvent) => void }
|
||||
) => {
|
||||
const bufferRef = useRef<string[]>([]);
|
||||
const timerRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
const listener = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (options?.filter && !options.filter(e)) return;
|
||||
|
||||
const pressed: string[] = [];
|
||||
if (e.metaKey) pressed.push("meta");
|
||||
if (e.ctrlKey) pressed.push("control");
|
||||
if (e.altKey) pressed.push("alt");
|
||||
if (e.shiftKey) pressed.push("shift");
|
||||
pressed.push(e.key.toLowerCase());
|
||||
|
||||
bufferRef.current.push(e.key.toLowerCase());
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => {
|
||||
bufferRef.current = [];
|
||||
}, 1000);
|
||||
|
||||
shortcuts.forEach((s) => {
|
||||
if (s.enabled && !s.enabled(e)) return;
|
||||
if (s.keys && matchCombo(pressed, s.keys)) {
|
||||
if (s.preventDefault ?? true) e.preventDefault();
|
||||
s.handler(e);
|
||||
} else if (s.sequence && matchSequence(bufferRef.current, s.sequence)) {
|
||||
if (s.preventDefault ?? true) e.preventDefault();
|
||||
bufferRef.current = [];
|
||||
s.handler(e);
|
||||
}
|
||||
});
|
||||
|
||||
options?.additional?.(e);
|
||||
},
|
||||
[shortcuts, options]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("keydown", listener);
|
||||
return () => document.removeEventListener("keydown", listener);
|
||||
}, [listener]);
|
||||
};
|
||||
Reference in New Issue
Block a user