mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-25 10:18:31 +02:00
Add tree rename (on Enter) and global rename hotkeys (#279)
This commit is contained in:
@@ -85,6 +85,11 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
|||||||
action: 'settings.show',
|
action: 'settings.show',
|
||||||
onSelect: () => openSettings.mutate(null),
|
onSelect: () => openSettings.mutate(null),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'folder.create',
|
||||||
|
label: 'Create Folder',
|
||||||
|
onSelect: () => createFolder.mutate({}),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'app.create',
|
key: 'app.create',
|
||||||
label: 'Create Workspace',
|
label: 'Create Workspace',
|
||||||
@@ -177,7 +182,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
commands.push({
|
commands.push({
|
||||||
key: 'sidebar.delete_selected_item',
|
key: 'sidebar.selected.delete',
|
||||||
label: 'Delete Request',
|
label: 'Delete Request',
|
||||||
onSelect: () => deleteModelWithConfirm(activeRequest),
|
onSelect: () => deleteModelWithConfirm(activeRequest),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
import { activeRequestAtom } from '../hooks/useActiveRequest';
|
||||||
import { useSubscribeActiveWorkspaceId } from '../hooks/useActiveWorkspace';
|
import { useSubscribeActiveWorkspaceId } from '../hooks/useActiveWorkspace';
|
||||||
import { useActiveWorkspaceChangedToast } from '../hooks/useActiveWorkspaceChangedToast';
|
import { useActiveWorkspaceChangedToast } from '../hooks/useActiveWorkspaceChangedToast';
|
||||||
import { useSubscribeHotKeys } from '../hooks/useHotKey';
|
import { useHotKey, useSubscribeHotKeys } from '../hooks/useHotKey';
|
||||||
import { useSubscribeHttpAuthentication } from '../hooks/useHttpAuthentication';
|
import { useSubscribeHttpAuthentication } from '../hooks/useHttpAuthentication';
|
||||||
import { useSyncFontSizeSetting } from '../hooks/useSyncFontSizeSetting';
|
import { useSyncFontSizeSetting } from '../hooks/useSyncFontSizeSetting';
|
||||||
import { useSyncWorkspaceChildModels } from '../hooks/useSyncWorkspaceChildModels';
|
import { useSyncWorkspaceChildModels } from '../hooks/useSyncWorkspaceChildModels';
|
||||||
import { useSyncZoomSetting } from '../hooks/useSyncZoomSetting';
|
import { useSyncZoomSetting } from '../hooks/useSyncZoomSetting';
|
||||||
import { useSubscribeTemplateFunctions } from '../hooks/useTemplateFunctions';
|
import { useSubscribeTemplateFunctions } from '../hooks/useTemplateFunctions';
|
||||||
|
import { jotaiStore } from '../lib/jotai';
|
||||||
|
import { renameModelWithPrompt } from '../lib/renameModelWithPrompt';
|
||||||
|
|
||||||
export function GlobalHooks() {
|
export function GlobalHooks() {
|
||||||
useSyncZoomSetting();
|
useSyncZoomSetting();
|
||||||
@@ -21,5 +24,15 @@ export function GlobalHooks() {
|
|||||||
useActiveWorkspaceChangedToast();
|
useActiveWorkspaceChangedToast();
|
||||||
useSubscribeHotKeys();
|
useSubscribeHotKeys();
|
||||||
|
|
||||||
|
useHotKey(
|
||||||
|
'request.rename',
|
||||||
|
async () => {
|
||||||
|
const model = jotaiStore.get(activeRequestAtom);
|
||||||
|
if (model == null) return;
|
||||||
|
await renameModelWithPrompt(model);
|
||||||
|
},
|
||||||
|
{ allowDefault: true },
|
||||||
|
);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,38 +256,53 @@ const sidebarTreeAtom = atom((get) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
'sidebar.delete_selected_item': async function (items: SidebarModel[]) {
|
'sidebar.selected.delete': {
|
||||||
await deleteModelWithConfirm(items);
|
enable: isSidebarFocused,
|
||||||
|
cb: async function (_: TreeHandle, items: SidebarModel[]) {
|
||||||
|
await deleteModelWithConfirm(items);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
'model.duplicate': async function (items: SidebarModel[]) {
|
'sidebar.selected.rename': {
|
||||||
if (items.length === 1) {
|
enable: isSidebarFocused,
|
||||||
const item = items[0]!;
|
allowDefault: true,
|
||||||
const newId = await duplicateModel(item);
|
cb: async function (tree: TreeHandle, items: SidebarModel[]) {
|
||||||
navigateToRequestOrFolderOrWorkspace(newId, item.model);
|
const item = items[0];
|
||||||
} else {
|
if (items.length === 1 && item != null) {
|
||||||
await Promise.all(items.map(duplicateModel));
|
tree.renameItem(item.id);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
'request.send': async function (items: SidebarModel[]) {
|
'sidebar.selected.duplicate': {
|
||||||
await Promise.all(
|
priority: 999,
|
||||||
items.filter((i) => i.model === 'http_request').map((i) => sendAnyHttpRequest.mutate(i.id)),
|
enable: isSidebarFocused,
|
||||||
);
|
cb: async function (_: TreeHandle, items: SidebarModel[]) {
|
||||||
|
if (items.length === 1) {
|
||||||
|
const item = items[0]!;
|
||||||
|
const newId = await duplicateModel(item);
|
||||||
|
navigateToRequestOrFolderOrWorkspace(newId, item.model);
|
||||||
|
} else {
|
||||||
|
await Promise.all(items.map(duplicateModel));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'request.send': {
|
||||||
|
enable: isSidebarFocused,
|
||||||
|
cb: async function (_: TreeHandle, items: SidebarModel[]) {
|
||||||
|
await Promise.all(
|
||||||
|
items.filter((i) => i.model === 'http_request').map((i) => sendAnyHttpRequest.mutate(i.id)),
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const hotkeys: TreeProps<SidebarModel>['hotkeys'] = {
|
const hotkeys: TreeProps<SidebarModel>['hotkeys'] = { actions };
|
||||||
priority: 10, // So these ones take precedence over global hotkeys when the sidebar is focused
|
|
||||||
actions,
|
|
||||||
enable: () => isSidebarFocused(),
|
|
||||||
};
|
|
||||||
|
|
||||||
async function getContextMenu(items: SidebarModel[]): Promise<DropdownItem[]> {
|
async function getContextMenu(tree: TreeHandle, items: SidebarModel[]): Promise<DropdownItem[]> {
|
||||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||||
const child = items[0];
|
const child = items[0];
|
||||||
|
|
||||||
// No children means we're in the root
|
// No children means we're in the root
|
||||||
if (child == null) {
|
if (child == null) {
|
||||||
console.log('HELLO', child);
|
|
||||||
return getCreateDropdownItems({ workspaceId, activeRequest: null, folderId: null });
|
return getCreateDropdownItems({ workspaceId, activeRequest: null, folderId: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +336,7 @@ async function getContextMenu(items: SidebarModel[]): Promise<DropdownItem[]> {
|
|||||||
hotKeyLabelOnly: true,
|
hotKeyLabelOnly: true,
|
||||||
hidden: !onlyHttpRequests,
|
hidden: !onlyHttpRequests,
|
||||||
leftSlot: <Icon icon="send_horizontal" />,
|
leftSlot: <Icon icon="send_horizontal" />,
|
||||||
onSelect: () => actions['request.send'](items),
|
onSelect: () => actions['request.send'].cb(tree, items),
|
||||||
},
|
},
|
||||||
...(items.length === 1 && child.model === 'http_request'
|
...(items.length === 1 && child.model === 'http_request'
|
||||||
? await getHttpRequestActions()
|
? await getHttpRequestActions()
|
||||||
@@ -362,6 +377,8 @@ async function getContextMenu(items: SidebarModel[]): Promise<DropdownItem[]> {
|
|||||||
label: 'Rename',
|
label: 'Rename',
|
||||||
leftSlot: <Icon icon="pencil" />,
|
leftSlot: <Icon icon="pencil" />,
|
||||||
hidden: items.length > 1,
|
hidden: items.length > 1,
|
||||||
|
hotKeyAction: 'sidebar.selected.rename',
|
||||||
|
hotKeyLabelOnly: true,
|
||||||
onSelect: async () => {
|
onSelect: async () => {
|
||||||
const request = getModel(
|
const request = getModel(
|
||||||
['folder', 'http_request', 'grpc_request', 'websocket_request'],
|
['folder', 'http_request', 'grpc_request', 'websocket_request'],
|
||||||
@@ -375,7 +392,7 @@ async function getContextMenu(items: SidebarModel[]): Promise<DropdownItem[]> {
|
|||||||
hotKeyAction: 'model.duplicate',
|
hotKeyAction: 'model.duplicate',
|
||||||
hotKeyLabelOnly: true, // Would trigger for every request (bad)
|
hotKeyLabelOnly: true, // Would trigger for every request (bad)
|
||||||
leftSlot: <Icon icon="copy" />,
|
leftSlot: <Icon icon="copy" />,
|
||||||
onSelect: () => actions['model.duplicate'](items),
|
onSelect: () => actions['sidebar.selected.duplicate'].cb(tree, items),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Move',
|
label: 'Move',
|
||||||
@@ -393,10 +410,10 @@ async function getContextMenu(items: SidebarModel[]): Promise<DropdownItem[]> {
|
|||||||
{
|
{
|
||||||
color: 'danger',
|
color: 'danger',
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
hotKeyAction: 'sidebar.delete_selected_item',
|
hotKeyAction: 'sidebar.selected.delete',
|
||||||
hotKeyLabelOnly: true,
|
hotKeyLabelOnly: true,
|
||||||
leftSlot: <Icon icon="trash" />,
|
leftSlot: <Icon icon="trash" />,
|
||||||
onSelect: () => actions['sidebar.delete_selected_item'](items),
|
onSelect: () => actions['sidebar.selected.delete'].cb(tree, items),
|
||||||
},
|
},
|
||||||
...modelCreationItems,
|
...modelCreationItems,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function RecentRequestsDropdown({ className }: Props) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
useHotKey('request_switcher.prev', () => {
|
useHotKey('switcher.prev', () => {
|
||||||
if (!dropdownRef.current?.isOpen) {
|
if (!dropdownRef.current?.isOpen) {
|
||||||
// Select the second because the first is the current request
|
// Select the second because the first is the current request
|
||||||
dropdownRef.current?.open(1);
|
dropdownRef.current?.open(1);
|
||||||
@@ -41,7 +41,7 @@ export function RecentRequestsDropdown({ className }: Props) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
useHotKey('request_switcher.next', () => {
|
useHotKey('switcher.next', () => {
|
||||||
if (!dropdownRef.current?.isOpen) dropdownRef.current?.open();
|
if (!dropdownRef.current?.isOpen) dropdownRef.current?.open();
|
||||||
dropdownRef.current?.prev?.();
|
dropdownRef.current?.prev?.();
|
||||||
});
|
});
|
||||||
@@ -87,7 +87,7 @@ export function RecentRequestsDropdown({ className }: Props) {
|
|||||||
<Dropdown ref={dropdownRef} items={items}>
|
<Dropdown ref={dropdownRef} items={items}>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
hotkeyAction="request_switcher.toggle"
|
hotkeyAction="switcher.toggle"
|
||||||
className={classNames(
|
className={classNames(
|
||||||
className,
|
className,
|
||||||
'truncate pointer-events-auto',
|
'truncate pointer-events-auto',
|
||||||
|
|||||||
@@ -413,7 +413,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
|||||||
close: handleClose,
|
close: handleClose,
|
||||||
prev: handlePrev,
|
prev: handlePrev,
|
||||||
next: handleNext,
|
next: handleNext,
|
||||||
async select() {
|
select: async () => {
|
||||||
const item = items[selectedIndexRef.current ?? -1] ?? null;
|
const item = items[selectedIndexRef.current ?? -1] ?? null;
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
await handleSelect(item);
|
await handleSelect(item);
|
||||||
@@ -569,10 +569,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
|
|||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={classNames('my-1 mx-2 max-w-xs')}
|
className={classNames('my-1 mx-2 max-w-xs')}
|
||||||
onClick={() => {
|
onClick={onClose}
|
||||||
// Ensure the dropdown is closed when anything in the content is clicked
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import {
|
|||||||
import type { SelectableTreeNode, TreeNode } from './common';
|
import type { SelectableTreeNode, TreeNode } from './common';
|
||||||
import { equalSubtree, getSelectedItems, hasAncestor } from './common';
|
import { equalSubtree, getSelectedItems, hasAncestor } from './common';
|
||||||
import { TreeDragOverlay } from './TreeDragOverlay';
|
import { TreeDragOverlay } from './TreeDragOverlay';
|
||||||
import type { TreeItemProps } from './TreeItem';
|
import type { TreeItemHandle, TreeItemProps } from './TreeItem';
|
||||||
import type { TreeItemListProps } from './TreeItemList';
|
import type { TreeItemListProps } from './TreeItemList';
|
||||||
import { TreeItemList } from './TreeItemList';
|
import { TreeItemList } from './TreeItemList';
|
||||||
import { useSelectableItems } from './useSelectableItems';
|
import { useSelectableItems } from './useSelectableItems';
|
||||||
@@ -45,13 +45,23 @@ export interface TreeProps<T extends { id: string }> {
|
|||||||
root: TreeNode<T>;
|
root: TreeNode<T>;
|
||||||
treeId: string;
|
treeId: string;
|
||||||
getItemKey: (item: T) => string;
|
getItemKey: (item: T) => string;
|
||||||
getContextMenu?: (items: T[]) => Promise<ContextMenuProps['items']>;
|
getContextMenu?: (t: TreeHandle, items: T[]) => Promise<ContextMenuProps['items']>;
|
||||||
ItemInner: ComponentType<{ treeId: string; item: T }>;
|
ItemInner: ComponentType<{ treeId: string; item: T }>;
|
||||||
ItemLeftSlot?: ComponentType<{ treeId: string; item: T }>;
|
ItemLeftSlot?: ComponentType<{ treeId: string; item: T }>;
|
||||||
className?: string;
|
className?: string;
|
||||||
onActivate?: (item: T) => void;
|
onActivate?: (item: T) => void;
|
||||||
onDragEnd?: (opt: { items: T[]; parent: T; children: T[]; insertAt: number }) => void;
|
onDragEnd?: (opt: { items: T[]; parent: T; children: T[]; insertAt: number }) => void;
|
||||||
hotkeys?: { actions: Partial<Record<HotkeyAction, (items: T[]) => void>> } & HotKeyOptions;
|
hotkeys?: {
|
||||||
|
actions: Partial<
|
||||||
|
Record<
|
||||||
|
HotkeyAction,
|
||||||
|
{
|
||||||
|
cb: (h: TreeHandle, items: T[]) => void;
|
||||||
|
enable?: boolean | ((h: TreeHandle) => boolean);
|
||||||
|
} & Omit<HotKeyOptions, 'enable'>
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
};
|
||||||
getEditOptions?: (item: T) => {
|
getEditOptions?: (item: T) => {
|
||||||
defaultValue: string;
|
defaultValue: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
@@ -62,6 +72,7 @@ export interface TreeProps<T extends { id: string }> {
|
|||||||
export interface TreeHandle {
|
export interface TreeHandle {
|
||||||
focus: () => void;
|
focus: () => void;
|
||||||
selectItem: (id: string) => void;
|
selectItem: (id: string) => void;
|
||||||
|
renameItem: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TreeInner<T extends { id: string }>(
|
function TreeInner<T extends { id: string }>(
|
||||||
@@ -87,6 +98,15 @@ function TreeInner<T extends { id: string }>(
|
|||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const treeItemRefs = useRef<Record<string, TreeItemHandle>>({});
|
||||||
|
|
||||||
|
const handleAddTreeItemRef = useCallback((item: T, r: TreeItemHandle | null) => {
|
||||||
|
if (r == null) {
|
||||||
|
delete treeItemRefs.current[item.id];
|
||||||
|
} else {
|
||||||
|
treeItemRefs.current[item.id] = r;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleCloseContextMenu = useCallback(() => {
|
const handleCloseContextMenu = useCallback(() => {
|
||||||
setShowContextMenu(null);
|
setShowContextMenu(null);
|
||||||
@@ -105,11 +125,11 @@ function TreeInner<T extends { id: string }>(
|
|||||||
[treeId, tryFocus],
|
[treeId, tryFocus],
|
||||||
);
|
);
|
||||||
|
|
||||||
useImperativeHandle(
|
const treeHandle = useMemo<TreeHandle>(
|
||||||
ref,
|
() => ({
|
||||||
(): TreeHandle => ({
|
|
||||||
focus: tryFocus,
|
focus: tryFocus,
|
||||||
selectItem(id) {
|
renameItem: (id) => treeItemRefs.current[id]?.rename(),
|
||||||
|
selectItem: (id) => {
|
||||||
setSelected([id], false);
|
setSelected([id], false);
|
||||||
jotaiStore.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
|
jotaiStore.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
|
||||||
},
|
},
|
||||||
@@ -117,6 +137,8 @@ function TreeInner<T extends { id: string }>(
|
|||||||
[setSelected, treeId, tryFocus],
|
[setSelected, treeId, tryFocus],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, (): TreeHandle => treeHandle, [treeHandle]);
|
||||||
|
|
||||||
const handleGetContextMenu = useMemo(() => {
|
const handleGetContextMenu = useMemo(() => {
|
||||||
if (getContextMenu == null) return;
|
if (getContextMenu == null) return;
|
||||||
return (item: T) => {
|
return (item: T) => {
|
||||||
@@ -124,16 +146,16 @@ function TreeInner<T extends { id: string }>(
|
|||||||
const isSelected = items.find((i) => i.id === item.id);
|
const isSelected = items.find((i) => i.id === item.id);
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
// If right-clicked an item that was in the multiple-selection, use the entire selection
|
// If right-clicked an item that was in the multiple-selection, use the entire selection
|
||||||
return getContextMenu(items);
|
return getContextMenu(treeHandle, items);
|
||||||
} else {
|
} else {
|
||||||
// If right-clicked an item that was NOT in the multiple-selection, just use that one
|
// If right-clicked an item that was NOT in the multiple-selection, just use that one
|
||||||
// Also update the selection with it
|
// Also update the selection with it
|
||||||
jotaiStore.set(selectedIdsFamily(treeId), [item.id]);
|
jotaiStore.set(selectedIdsFamily(treeId), [item.id]);
|
||||||
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
|
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
|
||||||
return getContextMenu([item]);
|
return getContextMenu(treeHandle, [item]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [getContextMenu, selectableItems, treeId]);
|
}, [getContextMenu, selectableItems, treeHandle, treeId]);
|
||||||
|
|
||||||
const handleSelect = useCallback<NonNullable<TreeItemProps<T>['onClick']>>(
|
const handleSelect = useCallback<NonNullable<TreeItemProps<T>['onClick']>>(
|
||||||
(item, { shiftKey, metaKey, ctrlKey }) => {
|
(item, { shiftKey, metaKey, ctrlKey }) => {
|
||||||
@@ -141,7 +163,7 @@ function TreeInner<T extends { id: string }>(
|
|||||||
const selectedIdsAtom = selectedIdsFamily(treeId);
|
const selectedIdsAtom = selectedIdsFamily(treeId);
|
||||||
const selectedIds = jotaiStore.get(selectedIdsAtom);
|
const selectedIds = jotaiStore.get(selectedIdsAtom);
|
||||||
|
|
||||||
// Mark item as the last one selected
|
// Mark the item as the last one selected
|
||||||
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
|
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
|
||||||
|
|
||||||
if (shiftKey) {
|
if (shiftKey) {
|
||||||
@@ -427,17 +449,22 @@ function TreeInner<T extends { id: string }>(
|
|||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const items = await getContextMenu([]);
|
const items = await getContextMenu(treeHandle, []);
|
||||||
setShowContextMenu({ items, x: e.clientX, y: e.clientY });
|
setShowContextMenu({ items, x: e.clientX, y: e.clientY });
|
||||||
},
|
},
|
||||||
[getContextMenu],
|
[getContextMenu, treeHandle],
|
||||||
);
|
);
|
||||||
|
|
||||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TreeHotKeys treeId={treeId} hotkeys={hotkeys} selectableItems={selectableItems} />
|
<TreeHotKeys
|
||||||
|
treeHandle={treeHandle}
|
||||||
|
treeId={treeId}
|
||||||
|
hotkeys={hotkeys}
|
||||||
|
selectableItems={selectableItems}
|
||||||
|
/>
|
||||||
{showContextMenu && (
|
{showContextMenu && (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
items={showContextMenu.items}
|
items={showContextMenu.items}
|
||||||
@@ -479,7 +506,12 @@ function TreeInner<T extends { id: string }>(
|
|||||||
'[&_.tree-item.selected:has(+.drop-marker+.tree-item.selected)]:rounded-b-none',
|
'[&_.tree-item.selected:has(+.drop-marker+.tree-item.selected)]:rounded-b-none',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TreeItemList nodes={selectableItems} treeId={treeId} {...treeItemListProps} />
|
<TreeItemList
|
||||||
|
addTreeItemRef={handleAddTreeItemRef}
|
||||||
|
nodes={selectableItems}
|
||||||
|
treeId={treeId}
|
||||||
|
{...treeItemListProps}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Assign root ID so we can reuse our same move/end logic */}
|
{/* Assign root ID so we can reuse our same move/end logic */}
|
||||||
<DropRegionAfterList id={root.item.id} onContextMenu={handleContextMenu} />
|
<DropRegionAfterList id={root.item.id} onContextMenu={handleContextMenu} />
|
||||||
@@ -523,11 +555,14 @@ function DropRegionAfterList({
|
|||||||
return <div ref={setNodeRef} onContextMenu={onContextMenu} />;
|
return <div ref={setNodeRef} onContextMenu={onContextMenu} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TreeHotKeyProps<T extends { id: string }> extends HotKeyOptions {
|
interface TreeHotKeyProps<T extends { id: string }> {
|
||||||
action: HotkeyAction;
|
action: HotkeyAction;
|
||||||
selectableItems: SelectableTreeNode<T>[];
|
selectableItems: SelectableTreeNode<T>[];
|
||||||
treeId: string;
|
treeId: string;
|
||||||
onDone: (items: T[]) => void;
|
onDone: (h: TreeHandle, items: T[]) => void;
|
||||||
|
treeHandle: TreeHandle;
|
||||||
|
priority?: number;
|
||||||
|
enable?: boolean | ((h: TreeHandle) => boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TreeHotKey<T extends { id: string }>({
|
function TreeHotKey<T extends { id: string }>({
|
||||||
@@ -535,14 +570,23 @@ function TreeHotKey<T extends { id: string }>({
|
|||||||
action,
|
action,
|
||||||
onDone,
|
onDone,
|
||||||
selectableItems,
|
selectableItems,
|
||||||
|
treeHandle,
|
||||||
|
enable,
|
||||||
...options
|
...options
|
||||||
}: TreeHotKeyProps<T>) {
|
}: TreeHotKeyProps<T>) {
|
||||||
useHotKey(
|
useHotKey(
|
||||||
action,
|
action,
|
||||||
() => {
|
() => {
|
||||||
onDone(getSelectedItems(treeId, selectableItems));
|
onDone(treeHandle, getSelectedItems(treeId, selectableItems));
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
enable: () => {
|
||||||
|
if (enable == null) return true;
|
||||||
|
if (typeof enable === 'function') return enable(treeHandle);
|
||||||
|
else return enable;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
options,
|
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -551,24 +595,26 @@ function TreeHotKeys<T extends { id: string }>({
|
|||||||
treeId,
|
treeId,
|
||||||
hotkeys,
|
hotkeys,
|
||||||
selectableItems,
|
selectableItems,
|
||||||
|
treeHandle,
|
||||||
}: {
|
}: {
|
||||||
treeId: string;
|
treeId: string;
|
||||||
hotkeys: TreeProps<T>['hotkeys'];
|
hotkeys: TreeProps<T>['hotkeys'];
|
||||||
selectableItems: SelectableTreeNode<T>[];
|
selectableItems: SelectableTreeNode<T>[];
|
||||||
|
treeHandle: TreeHandle;
|
||||||
}) {
|
}) {
|
||||||
if (hotkeys == null) return null;
|
if (hotkeys == null) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{Object.entries(hotkeys.actions).map(([hotkey, onDone]) => (
|
{Object.entries(hotkeys.actions).map(([hotkey, { cb, ...options }]) => (
|
||||||
<TreeHotKey
|
<TreeHotKey
|
||||||
key={hotkey}
|
key={hotkey}
|
||||||
action={hotkey as HotkeyAction}
|
action={hotkey as HotkeyAction}
|
||||||
priority={hotkeys.priority}
|
|
||||||
enable={hotkeys.enable}
|
|
||||||
treeId={treeId}
|
treeId={treeId}
|
||||||
onDone={onDone}
|
onDone={cb}
|
||||||
|
treeHandle={treeHandle}
|
||||||
selectableItems={selectableItems}
|
selectableItems={selectableItems}
|
||||||
|
{...options}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { useDndMonitor, useDraggable, useDroppable } from '@dnd-kit/core';
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { selectAtom } from 'jotai/utils';
|
import { selectAtom } from 'jotai/utils';
|
||||||
import type { MouseEvent, PointerEvent } from 'react';
|
import type { MouseEvent, PointerEvent, ReactElement, RefAttributes } from 'react';
|
||||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { computeSideForDragMove } from '../../../lib/dnd';
|
import { computeSideForDragMove } from '../../../lib/dnd';
|
||||||
import { jotaiStore } from '../../../lib/jotai';
|
import { jotaiStore } from '../../../lib/jotai';
|
||||||
import type { ContextMenuProps, DropdownItem } from '../Dropdown';
|
import type { ContextMenuProps, DropdownItem } from '../Dropdown';
|
||||||
@@ -30,11 +30,17 @@ export type TreeItemProps<T extends { id: string }> = Pick<
|
|||||||
onClick?: (item: T, e: OnClickEvent) => void;
|
onClick?: (item: T, e: OnClickEvent) => void;
|
||||||
getContextMenu?: (item: T) => Promise<ContextMenuProps['items']>;
|
getContextMenu?: (item: T) => Promise<ContextMenuProps['items']>;
|
||||||
depth: number;
|
depth: number;
|
||||||
|
addRef?: (item: T, n: TreeItemHandle | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface TreeItemHandle {
|
||||||
|
rename: () => void;
|
||||||
|
isRenaming: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const HOVER_CLOSED_FOLDER_DELAY = 800;
|
const HOVER_CLOSED_FOLDER_DELAY = 800;
|
||||||
|
|
||||||
function TreeItem_<T extends { id: string }>({
|
function TreeItemInner<T extends { id: string }>({
|
||||||
treeId,
|
treeId,
|
||||||
node,
|
node,
|
||||||
ItemInner,
|
ItemInner,
|
||||||
@@ -44,8 +50,9 @@ function TreeItem_<T extends { id: string }>({
|
|||||||
getEditOptions,
|
getEditOptions,
|
||||||
className,
|
className,
|
||||||
depth,
|
depth,
|
||||||
|
addRef,
|
||||||
}: TreeItemProps<T>) {
|
}: TreeItemProps<T>) {
|
||||||
const ref = useRef<HTMLLIElement>(null);
|
const listItemRef = useRef<HTMLLIElement>(null);
|
||||||
const draggableRef = useRef<HTMLButtonElement>(null);
|
const draggableRef = useRef<HTMLButtonElement>(null);
|
||||||
const isSelected = useAtomValue(isSelectedFamily({ treeId, itemId: node.item.id }));
|
const isSelected = useAtomValue(isSelectedFamily({ treeId, itemId: node.item.id }));
|
||||||
const isCollapsed = useAtomValue(isCollapsedFamily({ treeId, itemId: node.item.id }));
|
const isCollapsed = useAtomValue(isCollapsedFamily({ treeId, itemId: node.item.id }));
|
||||||
@@ -54,6 +61,17 @@ function TreeItem_<T extends { id: string }>({
|
|||||||
const [dropHover, setDropHover] = useState<null | 'drop' | 'animate'>(null);
|
const [dropHover, setDropHover] = useState<null | 'drop' | 'animate'>(null);
|
||||||
const startedHoverTimeout = useRef<NodeJS.Timeout>(undefined);
|
const startedHoverTimeout = useRef<NodeJS.Timeout>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
addRef?.(node.item, {
|
||||||
|
rename: () => {
|
||||||
|
if (getEditOptions != null) {
|
||||||
|
setEditing(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isRenaming: editing,
|
||||||
|
});
|
||||||
|
}, [addRef, editing, getEditOptions, node.item]);
|
||||||
|
|
||||||
const isAncestorCollapsedAtom = useMemo(
|
const isAncestorCollapsedAtom = useMemo(
|
||||||
() =>
|
() =>
|
||||||
selectAtom(
|
selectAtom(
|
||||||
@@ -80,7 +98,7 @@ function TreeItem_<T extends { id: string }>({
|
|||||||
useEffect(
|
useEffect(
|
||||||
function scrollIntoViewWhenSelected() {
|
function scrollIntoViewWhenSelected() {
|
||||||
return jotaiStore.sub(isSelectedFamily({ treeId, itemId: node.item.id }), () => {
|
return jotaiStore.sub(isSelectedFamily({ treeId, itemId: node.item.id }), () => {
|
||||||
ref.current?.scrollIntoView({ block: 'nearest' });
|
listItemRef.current?.scrollIntoView({ block: 'nearest' });
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[node.item.id, treeId],
|
[node.item.id, treeId],
|
||||||
@@ -103,10 +121,11 @@ function TreeItem_<T extends { id: string }>({
|
|||||||
const handleSubmitNameEdit = useCallback(
|
const handleSubmitNameEdit = useCallback(
|
||||||
async function submitNameEdit(el: HTMLInputElement) {
|
async function submitNameEdit(el: HTMLInputElement) {
|
||||||
getEditOptions?.(node.item).onChange(node.item, el.value);
|
getEditOptions?.(node.item).onChange(node.item, el.value);
|
||||||
|
onClick?.(node.item, { shiftKey: false, ctrlKey: false, metaKey: false });
|
||||||
// Slight delay for the model to propagate to the local store
|
// Slight delay for the model to propagate to the local store
|
||||||
setTimeout(() => setEditing(false), 200);
|
setTimeout(() => setEditing(false), 200);
|
||||||
},
|
},
|
||||||
[getEditOptions, node.item],
|
[getEditOptions, node.item, onClick],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleEditFocus = useCallback(function handleEditFocus(el: HTMLInputElement | null) {
|
const handleEditFocus = useCallback(function handleEditFocus(el: HTMLInputElement | null) {
|
||||||
@@ -126,8 +145,10 @@ function TreeItem_<T extends { id: string }>({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
e.preventDefault();
|
if (editing) {
|
||||||
await handleSubmitNameEdit(e.currentTarget);
|
e.preventDefault();
|
||||||
|
await handleSubmitNameEdit(e.currentTarget);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'Escape':
|
case 'Escape':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -135,7 +156,7 @@ function TreeItem_<T extends { id: string }>({
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleSubmitNameEdit],
|
[editing, handleSubmitNameEdit],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDoubleClick = useCallback(() => {
|
const handleDoubleClick = useCallback(() => {
|
||||||
@@ -222,7 +243,7 @@ function TreeItem_<T extends { id: string }>({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
ref={ref}
|
ref={listItemRef}
|
||||||
role="treeitem"
|
role="treeitem"
|
||||||
aria-level={depth + 1}
|
aria-level={depth + 1}
|
||||||
aria-expanded={node.children == null ? undefined : !isCollapsed}
|
aria-expanded={node.children == null ? undefined : !isCollapsed}
|
||||||
@@ -304,6 +325,11 @@ function TreeItem_<T extends { id: string }>({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1) Preserve generics through forwardRef:
|
||||||
|
const TreeItem_ = forwardRef(TreeItemInner) as <T extends { id: string }>(
|
||||||
|
props: TreeItemProps<T> & RefAttributes<TreeItemHandle>,
|
||||||
|
) => ReactElement | null;
|
||||||
|
|
||||||
export const TreeItem = memo(
|
export const TreeItem = memo(
|
||||||
TreeItem_,
|
TreeItem_,
|
||||||
({ node: prevNode, ...prevProps }, { node: nextNode, ...nextProps }) => {
|
({ node: prevNode, ...prevProps }, { node: nextNode, ...nextProps }) => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Fragment, memo } from 'react';
|
|||||||
import type { SelectableTreeNode } from './common';
|
import type { SelectableTreeNode } from './common';
|
||||||
import type { TreeProps } from './Tree';
|
import type { TreeProps } from './Tree';
|
||||||
import { TreeDropMarker } from './TreeDropMarker';
|
import { TreeDropMarker } from './TreeDropMarker';
|
||||||
import type { TreeItemProps } from './TreeItem';
|
import type { TreeItemHandle, TreeItemProps } from './TreeItem';
|
||||||
import { TreeItem } from './TreeItem';
|
import { TreeItem } from './TreeItem';
|
||||||
|
|
||||||
export type TreeItemListProps<T extends { id: string }> = Pick<
|
export type TreeItemListProps<T extends { id: string }> = Pick<
|
||||||
@@ -15,6 +15,7 @@ export type TreeItemListProps<T extends { id: string }> = Pick<
|
|||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
className?: string;
|
className?: string;
|
||||||
forceDepth?: number;
|
forceDepth?: number;
|
||||||
|
addTreeItemRef?: (item: T, n: TreeItemHandle | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function TreeItemList_<T extends { id: string }>({
|
function TreeItemList_<T extends { id: string }>({
|
||||||
@@ -29,6 +30,7 @@ function TreeItemList_<T extends { id: string }>({
|
|||||||
style,
|
style,
|
||||||
treeId,
|
treeId,
|
||||||
forceDepth,
|
forceDepth,
|
||||||
|
addTreeItemRef,
|
||||||
}: TreeItemListProps<T>) {
|
}: TreeItemListProps<T>) {
|
||||||
return (
|
return (
|
||||||
<ul role="tree" style={style} className={className}>
|
<ul role="tree" style={style} className={className}>
|
||||||
@@ -36,6 +38,7 @@ function TreeItemList_<T extends { id: string }>({
|
|||||||
{nodes.map((child, i) => (
|
{nodes.map((child, i) => (
|
||||||
<Fragment key={getItemKey(child.node.item)}>
|
<Fragment key={getItemKey(child.node.item)}>
|
||||||
<TreeItem
|
<TreeItem
|
||||||
|
addRef={addTreeItemRef}
|
||||||
treeId={treeId}
|
treeId={treeId}
|
||||||
node={child.node}
|
node={child.node}
|
||||||
ItemInner={ItemInner}
|
ItemInner={ItemInner}
|
||||||
@@ -46,7 +49,7 @@ function TreeItemList_<T extends { id: string }>({
|
|||||||
getItemKey={getItemKey}
|
getItemKey={getItemKey}
|
||||||
depth={forceDepth == null ? child.depth : forceDepth}
|
depth={forceDepth == null ? child.depth : forceDepth}
|
||||||
/>
|
/>
|
||||||
<TreeDropMarker node={child.node} treeId={treeId} index={i+1} />
|
<TreeDropMarker node={child.node} treeId={treeId} index={i + 1} />
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { capitalize } from '../lib/capitalize';
|
|||||||
import { jotaiStore } from '../lib/jotai';
|
import { jotaiStore } from '../lib/jotai';
|
||||||
|
|
||||||
const HOLD_KEYS = ['Shift', 'Control', 'Command', 'Alt', 'Meta'];
|
const HOLD_KEYS = ['Shift', 'Control', 'Command', 'Alt', 'Meta'];
|
||||||
|
const SINGLE_WHITELIST = ['Delete', 'Enter', 'Backspace'];
|
||||||
|
|
||||||
export type HotkeyAction =
|
export type HotkeyAction =
|
||||||
| 'app.zoom_in'
|
| 'app.zoom_in'
|
||||||
@@ -17,11 +18,14 @@ export type HotkeyAction =
|
|||||||
| 'model.create'
|
| 'model.create'
|
||||||
| 'model.duplicate'
|
| 'model.duplicate'
|
||||||
| 'request.send'
|
| 'request.send'
|
||||||
| 'request_switcher.next'
|
| 'request.rename'
|
||||||
| 'request_switcher.prev'
|
| 'switcher.next'
|
||||||
| 'request_switcher.toggle'
|
| 'switcher.prev'
|
||||||
|
| 'switcher.toggle'
|
||||||
| 'settings.show'
|
| 'settings.show'
|
||||||
| 'sidebar.delete_selected_item'
|
| 'sidebar.selected.delete'
|
||||||
|
| 'sidebar.selected.duplicate'
|
||||||
|
| 'sidebar.selected.rename'
|
||||||
| 'sidebar.focus'
|
| 'sidebar.focus'
|
||||||
| 'url_bar.focus'
|
| 'url_bar.focus'
|
||||||
| 'workspace_settings.show';
|
| 'workspace_settings.show';
|
||||||
@@ -32,15 +36,18 @@ const hotkeys: Record<HotkeyAction, string[]> = {
|
|||||||
'app.zoom_reset': ['CmdCtrl+0'],
|
'app.zoom_reset': ['CmdCtrl+0'],
|
||||||
'command_palette.toggle': ['CmdCtrl+k'],
|
'command_palette.toggle': ['CmdCtrl+k'],
|
||||||
'environmentEditor.toggle': ['CmdCtrl+Shift+E', 'CmdCtrl+Shift+e'],
|
'environmentEditor.toggle': ['CmdCtrl+Shift+E', 'CmdCtrl+Shift+e'],
|
||||||
|
'request.rename': type() === 'macos' ? ['Control+Shift+r'] : ['F2'],
|
||||||
'request.send': ['CmdCtrl+Enter', 'CmdCtrl+r'],
|
'request.send': ['CmdCtrl+Enter', 'CmdCtrl+r'],
|
||||||
'hotkeys.showHelp': ['CmdCtrl+Shift+/', 'CmdCtrl+Shift+?'], // when shift is pressed, it might be a question mark
|
'hotkeys.showHelp': ['CmdCtrl+Shift+/', 'CmdCtrl+Shift+?'], // when shift is pressed, it might be a question mark
|
||||||
'model.create': ['CmdCtrl+n'],
|
'model.create': ['CmdCtrl+n'],
|
||||||
'model.duplicate': ['CmdCtrl+d'],
|
'model.duplicate': ['CmdCtrl+d'],
|
||||||
'request_switcher.next': ['Control+Shift+Tab'],
|
'switcher.next': ['Control+Shift+Tab'],
|
||||||
'request_switcher.prev': ['Control+Tab'],
|
'switcher.prev': ['Control+Tab'],
|
||||||
'request_switcher.toggle': ['CmdCtrl+p'],
|
'switcher.toggle': ['CmdCtrl+p'],
|
||||||
'settings.show': ['CmdCtrl+,'],
|
'settings.show': ['CmdCtrl+,'],
|
||||||
'sidebar.delete_selected_item': ['Delete', 'CmdCtrl+Backspace'],
|
'sidebar.selected.delete': ['Delete', 'CmdCtrl+Backspace'],
|
||||||
|
'sidebar.selected.duplicate': ['CmdCtrl+d'],
|
||||||
|
'sidebar.selected.rename': ['Enter'],
|
||||||
'sidebar.focus': ['CmdCtrl+b'],
|
'sidebar.focus': ['CmdCtrl+b'],
|
||||||
'url_bar.focus': ['CmdCtrl+l'],
|
'url_bar.focus': ['CmdCtrl+l'],
|
||||||
'workspace_settings.show': ['CmdCtrl+;'],
|
'workspace_settings.show': ['CmdCtrl+;'],
|
||||||
@@ -55,12 +62,15 @@ const hotkeyLabels: Record<HotkeyAction, string> = {
|
|||||||
'hotkeys.showHelp': 'Show Keyboard Shortcuts',
|
'hotkeys.showHelp': 'Show Keyboard Shortcuts',
|
||||||
'model.create': 'New Request',
|
'model.create': 'New Request',
|
||||||
'model.duplicate': 'Duplicate Request',
|
'model.duplicate': 'Duplicate Request',
|
||||||
|
'request.rename': 'Rename',
|
||||||
'request.send': 'Send',
|
'request.send': 'Send',
|
||||||
'request_switcher.next': 'Go To Previous Request',
|
'switcher.next': 'Go To Previous Request',
|
||||||
'request_switcher.prev': 'Go To Next Request',
|
'switcher.prev': 'Go To Next Request',
|
||||||
'request_switcher.toggle': 'Toggle Request Switcher',
|
'switcher.toggle': 'Toggle Request Switcher',
|
||||||
'settings.show': 'Open Settings',
|
'settings.show': 'Open Settings',
|
||||||
'sidebar.delete_selected_item': 'Delete Request',
|
'sidebar.selected.delete': 'Delete',
|
||||||
|
'sidebar.selected.duplicate': 'Duplicate',
|
||||||
|
'sidebar.selected.rename': 'Rename',
|
||||||
'sidebar.focus': 'Focus or Toggle Sidebar',
|
'sidebar.focus': 'Focus or Toggle Sidebar',
|
||||||
'url_bar.focus': 'Focus URL',
|
'url_bar.focus': 'Focus URL',
|
||||||
'workspace_settings.show': 'Open Workspace Settings',
|
'workspace_settings.show': 'Open Workspace Settings',
|
||||||
@@ -73,6 +83,7 @@ export const hotkeyActions: HotkeyAction[] = Object.keys(hotkeys) as (keyof type
|
|||||||
export type HotKeyOptions = {
|
export type HotKeyOptions = {
|
||||||
enable?: boolean | (() => boolean);
|
enable?: boolean | (() => boolean);
|
||||||
priority?: number;
|
priority?: number;
|
||||||
|
allowDefault?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Callback {
|
interface Callback {
|
||||||
@@ -142,7 +153,7 @@ function handleKeyUp(e: KeyboardEvent) {
|
|||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
// Don't add key if not holding modifier
|
// Don't add key if not holding modifier
|
||||||
const isValidKeymapKey =
|
const isValidKeymapKey =
|
||||||
e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || e.key === 'Backspace' || e.key === 'Delete';
|
e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || SINGLE_WHITELIST.includes(e.key);
|
||||||
if (!isValidKeymapKey) {
|
if (!isValidKeymapKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -162,7 +173,7 @@ function handleKeyDown(e: KeyboardEvent) {
|
|||||||
if (e.metaKey) currentKeysWithModifiers.add('Meta');
|
if (e.metaKey) currentKeysWithModifiers.add('Meta');
|
||||||
if (e.shiftKey) currentKeysWithModifiers.add('Shift');
|
if (e.shiftKey) currentKeysWithModifiers.add('Shift');
|
||||||
|
|
||||||
for (const [hkAction, hkKeys] of Object.entries(hotkeys) as [HotkeyAction, string[]][]) {
|
outer: for (const [hkAction, hkKeys] of Object.entries(hotkeys) as [HotkeyAction, string[]][]) {
|
||||||
if (
|
if (
|
||||||
(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) &&
|
(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) &&
|
||||||
currentKeysWithModifiers.size === 1 &&
|
currentKeysWithModifiers.size === 1 &&
|
||||||
@@ -175,24 +186,24 @@ function handleKeyDown(e: KeyboardEvent) {
|
|||||||
|
|
||||||
const executed: string[] = [];
|
const executed: string[] = [];
|
||||||
for (const { action, callback, options } of jotaiStore.get(sortedCallbacksAtom)) {
|
for (const { action, callback, options } of jotaiStore.get(sortedCallbacksAtom)) {
|
||||||
const enable = typeof options.enable === 'function' ? options.enable() : options.enable;
|
if (hkAction !== action) {
|
||||||
if (enable === false) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (hkAction !== action) {
|
const enable = typeof options.enable === 'function' ? options.enable() : options.enable;
|
||||||
|
if (enable === false) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const hkKey of hkKeys) {
|
for (const hkKey of hkKeys) {
|
||||||
const keys = hkKey.split('+').map(resolveHotkeyKey);
|
const keys = hkKey.split('+').map(resolveHotkeyKey);
|
||||||
if (
|
if (compareKeys(keys, Array.from(currentKeysWithModifiers))) {
|
||||||
keys.length === currentKeysWithModifiers.size &&
|
if (!options.allowDefault) {
|
||||||
keys.every((key) => currentKeysWithModifiers.has(key))
|
e.preventDefault();
|
||||||
) {
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
}
|
||||||
e.stopPropagation();
|
|
||||||
callback(e);
|
callback(e);
|
||||||
executed.push(`${action} ${options.priority ?? 0}`);
|
executed.push(`${action} ${options.priority ?? 0}`);
|
||||||
|
break outer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,3 +269,10 @@ const resolveHotkeyKey = (key: string) => {
|
|||||||
else if (key === 'CmdCtrl') return 'Control';
|
else if (key === 'CmdCtrl') return 'Control';
|
||||||
else return key;
|
else return key;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function compareKeys(keysA: string[], keysB: string[]) {
|
||||||
|
if (keysA.length !== keysB.length) return false;
|
||||||
|
const sortedA = keysA.map((k) => k.toLowerCase()).sort().join('::');
|
||||||
|
const sortedB = keysB.map((k) => k.toLowerCase()).sort().join('::');
|
||||||
|
return sortedA === sortedB;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user