feat: add support for keyboard shortcuts (#255)
* feat: add support for keyboard shortcuts * fix: recenter icons on button * feat: extend tooltip delay * chore: add translations * refactor: tweak kbd styling * feat: move shortcuts to user menu --------- Co-authored-by: Henry <henry_ball@hotmail.co.uk>
This commit is contained in:
592
apps/web/src/providers/keyboard-shortcuts.tsx
Normal file
592
apps/web/src/providers/keyboard-shortcuts.tsx
Normal file
@@ -0,0 +1,592 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBackdrop,
|
||||
DialogPanel,
|
||||
DialogTitle,
|
||||
} from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { useEventListener } from "~/hooks/useEventListener";
|
||||
|
||||
const ModifierKey = {
|
||||
CONTROL: "CONTROL",
|
||||
META: "META",
|
||||
ALT: "ALT",
|
||||
SHIFT: "SHIFT",
|
||||
} as const;
|
||||
type ModifierKey = (typeof ModifierKey)[keyof typeof ModifierKey];
|
||||
|
||||
const ModifierKeyInfo: Record<
|
||||
ModifierKey,
|
||||
{
|
||||
macSymbol: string;
|
||||
winName: string;
|
||||
linuxName: string;
|
||||
}
|
||||
> = {
|
||||
CONTROL: { macSymbol: "⌃", winName: "Ctrl", linuxName: "Ctrl" },
|
||||
META: { macSymbol: "⌘", winName: "Win", linuxName: "Super" },
|
||||
ALT: { macSymbol: "⌥", winName: "Alt", linuxName: "Alt" },
|
||||
SHIFT: { macSymbol: "⇧", winName: "Shift", linuxName: "Shift" },
|
||||
};
|
||||
|
||||
const ShortcutGroup = {
|
||||
GENERAL: "GENERAL",
|
||||
NAVIGATION: "NAVIGATION",
|
||||
ACTIONS: "ACTIONS",
|
||||
} as const;
|
||||
type ShortcutGroup = (typeof ShortcutGroup)[keyof typeof ShortcutGroup];
|
||||
|
||||
const getShortcutGroupInfo = (): Record<ShortcutGroup, { label: string }> => ({
|
||||
GENERAL: { label: t`General` },
|
||||
NAVIGATION: { label: t`Navigation` },
|
||||
ACTIONS: { label: t`Actions` },
|
||||
});
|
||||
|
||||
interface KeyStroke {
|
||||
key: string;
|
||||
modifiers?: ModifierKey[];
|
||||
}
|
||||
|
||||
interface Press {
|
||||
type: "PRESS";
|
||||
stroke: KeyStroke;
|
||||
}
|
||||
|
||||
interface Sequence {
|
||||
type: "SEQUENCE";
|
||||
strokes: KeyStroke[];
|
||||
}
|
||||
|
||||
export type KeyboardShortcut = {
|
||||
action: () => void;
|
||||
description: string;
|
||||
group: ShortcutGroup;
|
||||
} & (Press | Sequence);
|
||||
|
||||
interface ShortcutTreeStepNode {
|
||||
type: "STEP";
|
||||
children: ShortcutTreeLevel;
|
||||
}
|
||||
interface ShortcutTreeActionNode {
|
||||
type: "ACTION";
|
||||
shortcut: KeyboardShortcut;
|
||||
}
|
||||
type ShortcutTreeNode = ShortcutTreeStepNode | ShortcutTreeActionNode;
|
||||
type ShortcutTreeLevel = Record<string, ShortcutTreeNode>;
|
||||
|
||||
const SEQUENCE_TIMEOUT_MS = 1000;
|
||||
|
||||
const ShortcutConflictCode = {
|
||||
ACTION_ON_SEQUENCE: "ACTION_ON_SEQUENCE",
|
||||
DUPLICATE_ACTION: "DUPLICATE_ACTION",
|
||||
SEQUENCE_ON_ACTION: "SEQUENCE_ON_ACTION",
|
||||
} as const;
|
||||
type ShortcutConflictCode = keyof typeof ShortcutConflictCode;
|
||||
|
||||
interface ShortcutConflictErrorOptions {
|
||||
code: ShortcutConflictCode;
|
||||
shortcut: KeyboardShortcut;
|
||||
conflictPath: string;
|
||||
existingShortcut?: KeyboardShortcut;
|
||||
}
|
||||
|
||||
class ShortcutConflictError extends Error {
|
||||
public readonly code: ShortcutConflictCode;
|
||||
public readonly shortcut: KeyboardShortcut;
|
||||
public readonly conflictPath: string;
|
||||
public readonly existingShortcut?: KeyboardShortcut;
|
||||
|
||||
constructor(options: ShortcutConflictErrorOptions) {
|
||||
super(ShortcutConflictError.formatMessage(options));
|
||||
this.name = "ShortcutConflictError";
|
||||
this.code = options.code;
|
||||
this.shortcut = options.shortcut;
|
||||
this.conflictPath = options.conflictPath;
|
||||
this.existingShortcut = options.existingShortcut;
|
||||
}
|
||||
|
||||
private static stringifyShortcut(shortcut: KeyboardShortcut): string {
|
||||
if (shortcut.type === "SEQUENCE") {
|
||||
return shortcut.strokes.map(serializeKeyStroke).join(" → ");
|
||||
}
|
||||
return serializeKeyStroke(shortcut.stroke);
|
||||
}
|
||||
|
||||
private static formatMessage(options: ShortcutConflictErrorOptions): string {
|
||||
const formatted = ShortcutConflictError.stringifyShortcut(options.shortcut);
|
||||
switch (options.code) {
|
||||
case ShortcutConflictCode.ACTION_ON_SEQUENCE:
|
||||
return `Cannot register "${formatted}": desired action conflicts with existing sequence at "${options.conflictPath}"`;
|
||||
case ShortcutConflictCode.DUPLICATE_ACTION:
|
||||
return `Cannot register "${formatted}": action already registered`;
|
||||
case ShortcutConflictCode.SEQUENCE_ON_ACTION:
|
||||
return `Cannot register "${formatted}": desired sequence conflicts with existing action at "${options.conflictPath}"`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface KeyboardShortcutContextType {
|
||||
registerShortcut: (shortcut: KeyboardShortcut) => () => void;
|
||||
openLegend: () => void;
|
||||
openLegendKeys: ReactNode;
|
||||
}
|
||||
|
||||
const KeyboardShortcutContext = createContext<
|
||||
KeyboardShortcutContextType | undefined
|
||||
>(undefined);
|
||||
|
||||
export function KeyboardShortcutProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const treeRootRef = useRef<ShortcutTreeLevel>({});
|
||||
const currentNodeRef = useRef<ShortcutTreeLevel>(treeRootRef.current);
|
||||
const shortcutsRef = useRef<Map<string, KeyboardShortcut>>(new Map());
|
||||
const sequenceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const [isLegendOpen, setIsLegendOpen] = useState(false);
|
||||
|
||||
const openLegendShortcut: KeyboardShortcut = useMemo(
|
||||
() => ({
|
||||
type: "PRESS",
|
||||
stroke: {
|
||||
key: "/",
|
||||
modifiers: ["META"],
|
||||
},
|
||||
action: () => setIsLegendOpen(true),
|
||||
description: t`Open keyboard shortcuts`,
|
||||
group: ShortcutGroup.GENERAL,
|
||||
}),
|
||||
[setIsLegendOpen],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback((event: KeyboardEvent) => {
|
||||
if (isTypingInInput(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sequenceTimeoutRef.current) {
|
||||
clearTimeout(sequenceTimeoutRef.current);
|
||||
sequenceTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
const serializedKey = serializeEvent(event);
|
||||
const node = currentNodeRef.current[serializedKey];
|
||||
|
||||
if (!node) {
|
||||
currentNodeRef.current = treeRootRef.current;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (node.type) {
|
||||
case "STEP":
|
||||
event.preventDefault();
|
||||
currentNodeRef.current = node.children;
|
||||
sequenceTimeoutRef.current = setTimeout(() => {
|
||||
currentNodeRef.current = treeRootRef.current;
|
||||
}, SEQUENCE_TIMEOUT_MS);
|
||||
return;
|
||||
case "ACTION":
|
||||
event.preventDefault();
|
||||
node.shortcut.action();
|
||||
currentNodeRef.current = treeRootRef.current;
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const registerShortcut = useCallback(
|
||||
(shortcut: KeyboardShortcut): (() => void) => {
|
||||
const strokes =
|
||||
shortcut.type === "SEQUENCE" ? shortcut.strokes : [shortcut.stroke];
|
||||
|
||||
const path = strokes.map(serializeKeyStroke);
|
||||
const pathKey = path.join(" → ");
|
||||
|
||||
path.reduce((currentLevel, key, i) => {
|
||||
const isLast = i === path.length - 1;
|
||||
const existingNode = currentLevel[key];
|
||||
|
||||
if (env.NODE_ENV === "development" && existingNode) {
|
||||
const conflictPath = path.slice(0, i + 1).join(" → ");
|
||||
validateNoConflict(existingNode, isLast, shortcut, conflictPath);
|
||||
}
|
||||
|
||||
if (isLast) {
|
||||
currentLevel[key] = {
|
||||
type: "ACTION",
|
||||
shortcut,
|
||||
};
|
||||
return currentLevel;
|
||||
}
|
||||
|
||||
if (existingNode?.type === "STEP") {
|
||||
return existingNode.children;
|
||||
}
|
||||
|
||||
const newNode: ShortcutTreeStepNode = {
|
||||
type: "STEP",
|
||||
children: {},
|
||||
};
|
||||
currentLevel[key] = newNode;
|
||||
return newNode.children;
|
||||
}, treeRootRef.current);
|
||||
|
||||
shortcutsRef.current.set(pathKey, shortcut);
|
||||
|
||||
return () => {
|
||||
removePathAndPrune(treeRootRef.current, path);
|
||||
shortcutsRef.current.delete(pathKey);
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEventListener("keydown", handleKeyDown);
|
||||
|
||||
// Register built-in shortcut to open legend
|
||||
useEffect(() => {
|
||||
const cleanup = registerShortcut(openLegendShortcut);
|
||||
return cleanup;
|
||||
}, [registerShortcut, openLegendShortcut]);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (sequenceTimeoutRef.current) {
|
||||
clearTimeout(sequenceTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const shortcutsArray = Array.from(shortcutsRef.current.values());
|
||||
const groupedShortcuts = shortcutsArray.reduce<
|
||||
Partial<Record<ShortcutGroup, KeyboardShortcut[]>>
|
||||
>((acc, shortcut) => {
|
||||
const group = shortcut.group;
|
||||
acc[group] ??= [];
|
||||
acc[group].push(shortcut);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const openLegend = useCallback(() => {
|
||||
setIsLegendOpen(true);
|
||||
}, []);
|
||||
|
||||
const openLegendKeys = useMemo(
|
||||
() => <FormattedShortcut shortcut={openLegendShortcut} />,
|
||||
[openLegendShortcut],
|
||||
);
|
||||
|
||||
return (
|
||||
<KeyboardShortcutContext.Provider
|
||||
value={{ registerShortcut, openLegend, openLegendKeys }}
|
||||
>
|
||||
{children}
|
||||
|
||||
{/* Shortcut Legend */}
|
||||
<Dialog
|
||||
className="relative z-50"
|
||||
open={isLegendOpen}
|
||||
onClose={() => setIsLegendOpen(false)}
|
||||
>
|
||||
<DialogBackdrop
|
||||
transition
|
||||
className="data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in fixed inset-0 bg-light-50 bg-opacity-40 transition-opacity dark:bg-dark-50 dark:bg-opacity-40"
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 flex min-h-full w-screen items-center justify-center overflow-y-auto p-4">
|
||||
<DialogPanel
|
||||
transition
|
||||
className="relative w-full max-w-sm transform overflow-hidden rounded-lg border border-light-600 bg-white shadow-3xl-light dark:border-dark-600 dark:bg-dark-100 dark:shadow-3xl-dark"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-light-300 px-6 py-4 dark:border-dark-300">
|
||||
<DialogTitle className="text-[14px] font-semibold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Keyboard Shortcuts`}
|
||||
</DialogTitle>
|
||||
<button
|
||||
onClick={() => setIsLegendOpen(false)}
|
||||
className="rounded p-1 hover:bg-light-200 dark:hover:bg-dark-200"
|
||||
>
|
||||
<HiXMark className="h-5 w-5 text-neutral-700 dark:text-dark-700" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[60vh] overflow-y-auto p-6">
|
||||
{shortcutsArray.length === 0 ? (
|
||||
<p className="text-center text-sm text-neutral-600 dark:text-dark-600">
|
||||
{t`No keyboard shortcuts registered.`}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{Object.values(ShortcutGroup).map((group, idx) => {
|
||||
const shortcuts = groupedShortcuts[group];
|
||||
if (!shortcuts?.length) return null;
|
||||
const groupInfo = getShortcutGroupInfo();
|
||||
return (
|
||||
<div key={`${group}-${idx}`}>
|
||||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-light-1000 dark:text-dark-1000">
|
||||
{groupInfo[group].label}
|
||||
</h3>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
{shortcuts.map((shortcut) => (
|
||||
<ShortcutListItem
|
||||
key={shortcut.description}
|
||||
shortcut={shortcut}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
</KeyboardShortcutContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to register a keyboard shortcut. Cleanup is handled automatically.
|
||||
* Returns both formatted keys and pre-built tooltip content.
|
||||
*
|
||||
* IMPORTANT: The shortcut object reference must be stable. If it changes
|
||||
* on every render, the shortcut will be continuously registered and unregistered.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { keys, tooltipContent } = useKeyboardShortcut({
|
||||
* type: "PRESS",
|
||||
* stroke: { key: "k", modifiers: ["META"] },
|
||||
* action: () => openCommandPalette(),
|
||||
* description: "Open command palette",
|
||||
* group: "GENERAL"
|
||||
* });
|
||||
*
|
||||
* // Use tooltip for Tooltip component
|
||||
* <Tooltip content={tooltipContent}>
|
||||
* <button>Search</button>
|
||||
* </Tooltip>
|
||||
*
|
||||
* // Or use keys directly for custom formatting
|
||||
* <span>Press {keys} to search</span>
|
||||
* ```
|
||||
*/
|
||||
export function useKeyboardShortcuts(): KeyboardShortcutContextType {
|
||||
const context = useContext(KeyboardShortcutContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useKeyboardShortcuts must be used within KeyboardShortcutProvider",
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcut(shortcut: KeyboardShortcut): {
|
||||
keys: ReactNode;
|
||||
tooltipContent: ReactNode;
|
||||
} {
|
||||
const context = useContext(KeyboardShortcutContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useKeyboardShortcut must be used within KeyboardShortcutProvider",
|
||||
);
|
||||
}
|
||||
|
||||
const { registerShortcut } = context;
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = registerShortcut(shortcut);
|
||||
return cleanup;
|
||||
}, [shortcut, registerShortcut]);
|
||||
|
||||
const keys = <FormattedShortcut shortcut={shortcut} />;
|
||||
const tooltipContent = (
|
||||
<div className="flex flex-row items-center gap-2 text-[11px]">
|
||||
{shortcut.description} {keys}
|
||||
</div>
|
||||
);
|
||||
|
||||
return { keys, tooltipContent };
|
||||
}
|
||||
|
||||
function ShortcutListItem({ shortcut }: { shortcut: KeyboardShortcut }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm text-dark-50 dark:text-dark-900">
|
||||
{shortcut.description}
|
||||
</span>
|
||||
<FormattedShortcut shortcut={shortcut} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FormattedShortcut({ shortcut }: { shortcut: KeyboardShortcut }) {
|
||||
const kbdClassName =
|
||||
"inline-flex h-5 w-5 items-center justify-center rounded border border-light-400 bg-light-200 px-1.5 py-0.5 font-mono text-[8px] font-semibold text-center text-neutral-900 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-950";
|
||||
|
||||
const stringifyModifier = (modifier: ModifierKey): string => {
|
||||
const isMac =
|
||||
typeof navigator !== "undefined" && navigator.userAgent.includes("Mac");
|
||||
const isLinux =
|
||||
typeof navigator !== "undefined" && navigator.userAgent.includes("Linux");
|
||||
|
||||
const info = ModifierKeyInfo[modifier];
|
||||
if (isMac) return info.macSymbol;
|
||||
if (isLinux) return info.linuxName;
|
||||
return info.winName;
|
||||
};
|
||||
|
||||
const formatStroke = (stroke: KeyStroke): ReactNode[] => {
|
||||
const parts: ReactNode[] = [];
|
||||
const modifierStrings = stroke.modifiers
|
||||
? stroke.modifiers.map(stringifyModifier)
|
||||
: [];
|
||||
|
||||
modifierStrings.forEach((mod) => {
|
||||
parts.push(<kbd className={kbdClassName}>{mod}</kbd>);
|
||||
});
|
||||
|
||||
parts.push(<kbd className={kbdClassName}>{stroke.key.toUpperCase()}</kbd>);
|
||||
|
||||
return parts;
|
||||
};
|
||||
|
||||
if (shortcut.type === "SEQUENCE") {
|
||||
const parts: ReactNode[] = [];
|
||||
shortcut.strokes.forEach((stroke) => {
|
||||
parts.push(...formatStroke(stroke));
|
||||
});
|
||||
return <span className="flex items-center gap-1 text-[11px]">{parts}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex flex-shrink-0 items-center gap-1 text-[11px]">
|
||||
{formatStroke(shortcut.stroke)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Checks for conflicts given existing nodes and current path
|
||||
* Throws an error if conflict is found
|
||||
*
|
||||
*/
|
||||
function validateNoConflict(
|
||||
existingNode: ShortcutTreeNode,
|
||||
isLastKey: boolean,
|
||||
shortcut: KeyboardShortcut,
|
||||
conflictPath: string,
|
||||
): void {
|
||||
if (isLastKey) {
|
||||
if (existingNode.type === "STEP") {
|
||||
throw new ShortcutConflictError({
|
||||
code: ShortcutConflictCode.ACTION_ON_SEQUENCE,
|
||||
shortcut,
|
||||
conflictPath,
|
||||
});
|
||||
} else {
|
||||
throw new ShortcutConflictError({
|
||||
code: ShortcutConflictCode.DUPLICATE_ACTION,
|
||||
shortcut,
|
||||
conflictPath,
|
||||
existingShortcut: existingNode.shortcut,
|
||||
});
|
||||
}
|
||||
} else if (existingNode.type === "ACTION") {
|
||||
throw new ShortcutConflictError({
|
||||
code: ShortcutConflictCode.SEQUENCE_ON_ACTION,
|
||||
shortcut,
|
||||
conflictPath,
|
||||
existingShortcut: existingNode.shortcut,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user is currently typing in an input field
|
||||
*/
|
||||
function isTypingInInput(event: KeyboardEvent): boolean {
|
||||
if (!event.target) return false;
|
||||
|
||||
const target = event.target as HTMLElement;
|
||||
const tagName = target.tagName.toLowerCase();
|
||||
const isInput = tagName === "input" || tagName === "textarea";
|
||||
const isContentEditable = target.isContentEditable;
|
||||
|
||||
return isInput || isContentEditable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a KeyStroke to a consistent string format
|
||||
* Returns lowercase string like "ctrl+shift+k" with alphabetically sorted modifiers
|
||||
*/
|
||||
function serializeKeyStroke(stroke: KeyStroke): string {
|
||||
const key = stroke.key.toLowerCase();
|
||||
if (!stroke.modifiers || stroke.modifiers.length === 0) return key;
|
||||
const sorted = [...stroke.modifiers].sort();
|
||||
return `${sorted.join("+")}+${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a keyboard event to a KeyStroke
|
||||
*/
|
||||
function eventToKeyStroke(event: KeyboardEvent): KeyStroke {
|
||||
const modifiers: ModifierKey[] = [];
|
||||
if (event.altKey) modifiers.push(ModifierKey.ALT);
|
||||
if (event.ctrlKey) modifiers.push(ModifierKey.CONTROL);
|
||||
if (event.metaKey) modifiers.push(ModifierKey.META);
|
||||
// Only include shift if the key is a letter (shift wasn't consumed to produce the character)
|
||||
if (event.shiftKey && /^[a-zA-Z]$/.test(event.key))
|
||||
modifiers.push(ModifierKey.SHIFT);
|
||||
return { key: event.key, modifiers };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a keyboard event to a consistent string format
|
||||
*/
|
||||
function serializeEvent(event: KeyboardEvent): string {
|
||||
return serializeKeyStroke(eventToKeyStroke(event));
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a path from the tree and prunes empty branches
|
||||
*/
|
||||
function removePathAndPrune(tree: ShortcutTreeLevel, path: string[]): void {
|
||||
const [first, ...rest] = path;
|
||||
if (!first) return;
|
||||
|
||||
if (rest.length === 0) {
|
||||
delete tree[first];
|
||||
return;
|
||||
}
|
||||
|
||||
const node = tree[first];
|
||||
if (!node || node.type !== "STEP") return;
|
||||
|
||||
removePathAndPrune(node.children, rest);
|
||||
|
||||
if (Object.keys(node.children).length === 0) {
|
||||
delete tree[first];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user