mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-22 01:19:13 +01:00
Good hotkey support
This commit is contained in:
@@ -2,7 +2,7 @@ import classNames from 'classnames';
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import { forwardRef, memo, useImperativeHandle, useMemo, useRef } from 'react';
|
||||
import type { HotkeyAction } from '../../hooks/useHotkey';
|
||||
import { useHotkey } from '../../hooks/useHotkey';
|
||||
import { useFormattedHotkey, useHotkey } from '../../hooks/useHotkey';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
const colorStyles = {
|
||||
@@ -47,11 +47,15 @@ const _Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
rightSlot,
|
||||
disabled,
|
||||
hotkeyAction,
|
||||
title,
|
||||
onClick,
|
||||
...props
|
||||
}: ButtonProps,
|
||||
ref,
|
||||
) {
|
||||
const hotkeyTrigger = useFormattedHotkey(hotkeyAction ?? null);
|
||||
const fullTitle = hotkeyTrigger ? `${title} ${hotkeyTrigger}` : title;
|
||||
|
||||
const classes = useMemo(
|
||||
() =>
|
||||
classNames(
|
||||
@@ -88,6 +92,7 @@ const _Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
className={classes}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
title={fullTitle}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
|
||||
@@ -20,8 +20,10 @@ import React, {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useKey, useKeyPressEvent, useWindowSize } from 'react-use';
|
||||
import type { HotkeyAction } from '../../hooks/useHotkey';
|
||||
import { Overlay } from '../Overlay';
|
||||
import { Button } from './Button';
|
||||
import { HotKey } from './HotKey';
|
||||
import { Separator } from './Separator';
|
||||
import { VStack } from './Stacks';
|
||||
|
||||
@@ -30,19 +32,20 @@ export type DropdownItemSeparator = {
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type DropdownItem =
|
||||
| {
|
||||
key: string;
|
||||
type?: 'default';
|
||||
label: ReactNode;
|
||||
variant?: 'danger';
|
||||
disabled?: boolean;
|
||||
hidden?: boolean;
|
||||
leftSlot?: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
onSelect?: () => void;
|
||||
}
|
||||
| DropdownItemSeparator;
|
||||
export type DropdownItemDefault = {
|
||||
key: string;
|
||||
type?: 'default';
|
||||
label: ReactNode;
|
||||
hotkeyAction?: HotkeyAction;
|
||||
variant?: 'danger';
|
||||
disabled?: boolean;
|
||||
hidden?: boolean;
|
||||
leftSlot?: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
onSelect?: () => void;
|
||||
};
|
||||
|
||||
export type DropdownItem = DropdownItemDefault | DropdownItemSeparator;
|
||||
|
||||
export interface DropdownProps {
|
||||
children: ReactElement<HTMLAttributes<HTMLButtonElement>>;
|
||||
@@ -126,9 +129,10 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
|
||||
{open && triggerRect && (
|
||||
<Menu
|
||||
ref={menuRef}
|
||||
showTriangle
|
||||
defaultSelectedIndex={defaultSelectedIndex}
|
||||
items={items}
|
||||
triggerRect={triggerRect}
|
||||
triggerShape={triggerRect}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
)}
|
||||
@@ -136,16 +140,53 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
|
||||
);
|
||||
});
|
||||
|
||||
interface ContextMenuProps {
|
||||
show: { x: number; y: number } | null;
|
||||
className?: string;
|
||||
items: DropdownProps['items'];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ContextMenu = forwardRef<DropdownRef, ContextMenuProps>(function ContextMenu(
|
||||
{ show, className, items, onClose },
|
||||
ref,
|
||||
) {
|
||||
const triggerShape = useMemo(
|
||||
() => ({
|
||||
top: show?.y ?? 0,
|
||||
bottom: show?.y ?? 0,
|
||||
left: show?.x ?? 0,
|
||||
right: show?.x ?? 0,
|
||||
}),
|
||||
[show],
|
||||
);
|
||||
|
||||
if (show === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu
|
||||
className={className}
|
||||
ref={ref}
|
||||
items={items}
|
||||
onClose={onClose}
|
||||
triggerShape={triggerShape}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
interface MenuProps {
|
||||
className?: string;
|
||||
defaultSelectedIndex?: number;
|
||||
items: DropdownProps['items'];
|
||||
triggerRect: DOMRect;
|
||||
triggerShape: Pick<DOMRect, 'top' | 'bottom' | 'left' | 'right'>;
|
||||
onClose: () => void;
|
||||
showTriangle?: boolean;
|
||||
}
|
||||
|
||||
const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuProps>(function Menu(
|
||||
{ className, items, onClose, triggerRect, defaultSelectedIndex }: MenuProps,
|
||||
{ className, items, onClose, triggerShape, defaultSelectedIndex, showTriangle }: MenuProps,
|
||||
ref,
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -248,21 +289,27 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
|
||||
|
||||
const { containerStyles, triangleStyles } = useMemo<{
|
||||
containerStyles: CSSProperties;
|
||||
triangleStyles: CSSProperties;
|
||||
triangleStyles: CSSProperties | null;
|
||||
}>(() => {
|
||||
const docWidth = document.documentElement.getBoundingClientRect().width;
|
||||
const spaceRemaining = docWidth - triggerRect.left;
|
||||
const top = triggerRect?.bottom + 5;
|
||||
const onRight = spaceRemaining < 200;
|
||||
const containerStyles = onRight
|
||||
? { top, right: docWidth - triggerRect?.right }
|
||||
: { top, left: triggerRect?.left };
|
||||
const docRect = document.documentElement.getBoundingClientRect();
|
||||
const width = triggerShape.right - triggerShape.left;
|
||||
const hSpaceRemaining = docRect.width - triggerShape.left;
|
||||
const vSpaceRemaining = docRect.height - triggerShape.bottom;
|
||||
const top = triggerShape?.bottom + 5;
|
||||
const onRight = hSpaceRemaining < 200;
|
||||
const upsideDown = vSpaceRemaining < 200;
|
||||
const containerStyles = {
|
||||
top: !upsideDown ? top : undefined,
|
||||
bottom: upsideDown ? top : undefined,
|
||||
right: onRight ? docRect.width - triggerShape?.right : undefined,
|
||||
left: !onRight ? triggerShape?.left : undefined,
|
||||
};
|
||||
const size = { top: '-0.2rem', width: '0.4rem', height: '0.4rem' };
|
||||
const triangleStyles = onRight
|
||||
? { right: triggerRect.width / 2, marginRight: '-0.2rem', ...size }
|
||||
: { left: triggerRect.width / 2, marginLeft: '-0.2rem', ...size };
|
||||
? { right: width / 2, marginRight: '-0.2rem', ...size }
|
||||
: { left: width / 2, marginLeft: '-0.2rem', ...size };
|
||||
return { containerStyles, triangleStyles };
|
||||
}, [triggerRect]);
|
||||
}, [triggerShape]);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(i: DropdownItem) => {
|
||||
@@ -290,11 +337,13 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
|
||||
style={containerStyles}
|
||||
className={classNames(className, 'outline-none mt-1 pointer-events-auto fixed z-50')}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={triangleStyles}
|
||||
className="bg-gray-50 absolute rotate-45 border-gray-200 border-t border-l"
|
||||
/>
|
||||
{triangleStyles && showTriangle && (
|
||||
<span
|
||||
aria-hidden
|
||||
style={triangleStyles}
|
||||
className="bg-gray-50 absolute rotate-45 border-gray-200 border-t border-l"
|
||||
/>
|
||||
)}
|
||||
{containerStyles && (
|
||||
<VStack
|
||||
space={0.5}
|
||||
@@ -333,9 +382,9 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle'>, MenuPro
|
||||
|
||||
interface MenuItemProps {
|
||||
className?: string;
|
||||
item: DropdownItem;
|
||||
onSelect: (item: DropdownItem) => void;
|
||||
onFocus: (item: DropdownItem) => void;
|
||||
item: DropdownItemDefault;
|
||||
onSelect: (item: DropdownItemDefault) => void;
|
||||
onFocus: (item: DropdownItemDefault) => void;
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
@@ -359,7 +408,7 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
|
||||
[focused],
|
||||
);
|
||||
|
||||
if (item.type === 'separator') return <Separator className="my-1.5" />;
|
||||
const rightSlot = item.rightSlot ?? <HotKey action={item.hotkeyAction ?? null} />;
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -373,7 +422,7 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
|
||||
onClick={handleClick}
|
||||
justify="start"
|
||||
leftSlot={item.leftSlot && <div className="pr-2 flex justify-start">{item.leftSlot}</div>}
|
||||
rightSlot={item.rightSlot && <div className="ml-auto pl-3">{item.rightSlot}</div>}
|
||||
rightSlot={rightSlot && <div className="ml-auto pl-3">{rightSlot}</div>}
|
||||
className={classNames(
|
||||
className,
|
||||
'min-w-[8rem] outline-none px-2 mx-1.5 flex text-sm text-gray-700 whitespace-nowrap',
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
import classNames from 'classnames';
|
||||
import type { HotkeyAction } from '../../hooks/useHotkey';
|
||||
import { useFormattedHotkey } from '../../hooks/useHotkey';
|
||||
import { useOsInfo } from '../../hooks/useOsInfo';
|
||||
|
||||
interface Props {
|
||||
modifier: 'Meta' | 'Control' | 'Shift';
|
||||
keyName: string;
|
||||
action: HotkeyAction | null;
|
||||
}
|
||||
|
||||
const keys: Record<Props['modifier'], string> = {
|
||||
Control: '⌃',
|
||||
Meta: '⌘',
|
||||
Shift: '⇧',
|
||||
};
|
||||
export function HotKey({ action }: Props) {
|
||||
const osinfo = useOsInfo();
|
||||
const label = useFormattedHotkey(action);
|
||||
if (label === null || osinfo == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function HotKey({ modifier, keyName }: Props) {
|
||||
return (
|
||||
<span className={classNames('text-sm text-gray-600')}>
|
||||
{keys[modifier]}
|
||||
{keyName}
|
||||
</span>
|
||||
<span className={classNames('text-sm text-gray-1000 text-opacity-disabled')}>{label}</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,44 +1,4 @@
|
||||
import {
|
||||
ArchiveIcon,
|
||||
CameraIcon,
|
||||
CheckboxIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
ClockIcon,
|
||||
CodeIcon,
|
||||
ColorWheelIcon,
|
||||
CopyIcon,
|
||||
Cross2Icon,
|
||||
DividerHorizontalIcon,
|
||||
DotsHorizontalIcon,
|
||||
DotsVerticalIcon,
|
||||
DownloadIcon,
|
||||
DragHandleDots2Icon,
|
||||
EyeClosedIcon,
|
||||
EyeOpenIcon,
|
||||
GearIcon,
|
||||
HamburgerMenuIcon,
|
||||
HomeIcon,
|
||||
ListBulletIcon,
|
||||
MagicWandIcon,
|
||||
MagnifyingGlassIcon,
|
||||
MoonIcon,
|
||||
OpenInNewWindowIcon,
|
||||
PaperPlaneIcon,
|
||||
Pencil2Icon,
|
||||
PlusCircledIcon,
|
||||
PlusIcon,
|
||||
QuestionMarkIcon,
|
||||
RowsIcon,
|
||||
SunIcon,
|
||||
TrashIcon,
|
||||
TriangleDownIcon,
|
||||
TriangleLeftIcon,
|
||||
TriangleRightIcon,
|
||||
UpdateIcon,
|
||||
UploadIcon,
|
||||
} from '@radix-ui/react-icons';
|
||||
import * as ReactIcons from '@radix-ui/react-icons';
|
||||
import classNames from 'classnames';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { memo } from 'react';
|
||||
@@ -46,47 +6,49 @@ import { ReactComponent as LeftPanelHiddenIcon } from '../../assets/icons/LeftPa
|
||||
import { ReactComponent as LeftPanelVisibleIcon } from '../../assets/icons/LeftPanelVisibleIcon.svg';
|
||||
|
||||
const icons = {
|
||||
archive: ArchiveIcon,
|
||||
camera: CameraIcon,
|
||||
check: CheckIcon,
|
||||
checkbox: CheckboxIcon,
|
||||
clock: ClockIcon,
|
||||
chevronDown: ChevronDownIcon,
|
||||
chevronRight: ChevronRightIcon,
|
||||
code: CodeIcon,
|
||||
colorWheel: ColorWheelIcon,
|
||||
copy: CopyIcon,
|
||||
dividerH: DividerHorizontalIcon,
|
||||
dotsH: DotsHorizontalIcon,
|
||||
dotsV: DotsVerticalIcon,
|
||||
download: DownloadIcon,
|
||||
drag: DragHandleDots2Icon,
|
||||
eye: EyeOpenIcon,
|
||||
eyeClosed: EyeClosedIcon,
|
||||
gear: GearIcon,
|
||||
hamburger: HamburgerMenuIcon,
|
||||
home: HomeIcon,
|
||||
archive: ReactIcons.ArchiveIcon,
|
||||
camera: ReactIcons.CameraIcon,
|
||||
check: ReactIcons.CheckIcon,
|
||||
checkbox: ReactIcons.CheckboxIcon,
|
||||
clock: ReactIcons.ClockIcon,
|
||||
chevronDown: ReactIcons.ChevronDownIcon,
|
||||
chevronRight: ReactIcons.ChevronRightIcon,
|
||||
code: ReactIcons.CodeIcon,
|
||||
colorWheel: ReactIcons.ColorWheelIcon,
|
||||
copy: ReactIcons.CopyIcon,
|
||||
dividerH: ReactIcons.DividerHorizontalIcon,
|
||||
dotsH: ReactIcons.DotsHorizontalIcon,
|
||||
dotsV: ReactIcons.DotsVerticalIcon,
|
||||
download: ReactIcons.DownloadIcon,
|
||||
drag: ReactIcons.DragHandleDots2Icon,
|
||||
eye: ReactIcons.EyeOpenIcon,
|
||||
eyeClosed: ReactIcons.EyeClosedIcon,
|
||||
gear: ReactIcons.GearIcon,
|
||||
hamburger: ReactIcons.HamburgerMenuIcon,
|
||||
home: ReactIcons.HomeIcon,
|
||||
listBullet: ReactIcons.ListBulletIcon,
|
||||
magicWand: ReactIcons.MagicWandIcon,
|
||||
magnifyingGlass: ReactIcons.MagnifyingGlassIcon,
|
||||
moon: ReactIcons.MoonIcon,
|
||||
openNewWindow: ReactIcons.OpenInNewWindowIcon,
|
||||
paperPlane: ReactIcons.PaperPlaneIcon,
|
||||
pencil: ReactIcons.Pencil2Icon,
|
||||
plus: ReactIcons.PlusIcon,
|
||||
plusCircle: ReactIcons.PlusCircledIcon,
|
||||
question: ReactIcons.QuestionMarkIcon,
|
||||
rows: ReactIcons.RowsIcon,
|
||||
sun: ReactIcons.SunIcon,
|
||||
trash: ReactIcons.TrashIcon,
|
||||
triangleDown: ReactIcons.TriangleDownIcon,
|
||||
triangleLeft: ReactIcons.TriangleLeftIcon,
|
||||
triangleRight: ReactIcons.TriangleRightIcon,
|
||||
update: ReactIcons.UpdateIcon,
|
||||
upload: ReactIcons.UploadIcon,
|
||||
x: ReactIcons.Cross2Icon,
|
||||
|
||||
// Custom
|
||||
leftPanelHidden: LeftPanelHiddenIcon,
|
||||
leftPanelVisible: LeftPanelVisibleIcon,
|
||||
listBullet: ListBulletIcon,
|
||||
magicWand: MagicWandIcon,
|
||||
magnifyingGlass: MagnifyingGlassIcon,
|
||||
moon: MoonIcon,
|
||||
openNewWindow: OpenInNewWindowIcon,
|
||||
paperPlane: PaperPlaneIcon,
|
||||
pencil: Pencil2Icon,
|
||||
plus: PlusIcon,
|
||||
plusCircle: PlusCircledIcon,
|
||||
question: QuestionMarkIcon,
|
||||
rows: RowsIcon,
|
||||
sun: SunIcon,
|
||||
trash: TrashIcon,
|
||||
triangleDown: TriangleDownIcon,
|
||||
triangleLeft: TriangleLeftIcon,
|
||||
triangleRight: TriangleRightIcon,
|
||||
update: UpdateIcon,
|
||||
upload: UploadIcon,
|
||||
x: Cross2Icon,
|
||||
empty: (props: HTMLAttributes<HTMLSpanElement>) => <span {...props} />,
|
||||
};
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButt
|
||||
},
|
||||
[onClick, setConfirmed, showConfirm],
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
|
||||
Reference in New Issue
Block a user