refactor command palette into modular command system

This commit is contained in:
Vihar Kurama
2025-09-13 18:28:37 +01:00
parent c3e7cfd16b
commit 036da787fe
5 changed files with 387 additions and 173 deletions

View 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;
};

View File

@@ -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} />
</>

View File

@@ -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;

View 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[];
}

View 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]);
};