add Preferences default menu item

This commit is contained in:
dwelle
2025-08-31 23:30:26 +02:00
parent 40c5c743b1
commit ecbaeb1701
17 changed files with 306 additions and 56 deletions

View File

@@ -37,6 +37,7 @@ export const AppMainMenu: React.FC<{
)}
<MainMenu.DefaultItems.CommandPalette className="highlighted" />
<MainMenu.DefaultItems.SearchMenu />
<MainMenu.DefaultItems.Preferences />
<MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator />

View File

@@ -54,7 +54,8 @@ export type ShortcutName =
| "saveScene"
| "imageExport"
| "commandPalette"
| "searchMenu";
| "searchMenu"
| "toolLock";
const shortcutMap: Record<ShortcutName, string[]> = {
toggleTheme: [getShortcutKey("Shift+Alt+D")],
@@ -116,6 +117,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
toggleShortcuts: [getShortcutKey("?")],
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
wrapSelectionInFrame: [],
toolLock: [getShortcutKey("Q")],
};
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {

View File

@@ -451,10 +451,10 @@ export const ShapesSwitcher = ({
<DropdownMenu.Item
onSelect={() => app.onMagicframeToolSelect()}
icon={MagicIcon}
badge={<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>}
data-testid="toolbar-magicframe"
>
{t("toolBar.magicframe")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item>
</>
)}

View File

@@ -25,10 +25,6 @@ import { PropertiesPopover } from "../PropertiesPopover";
import { QuickSearch } from "../QuickSearch";
import { ScrollableList } from "../ScrollableList";
import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup";
import DropdownMenuItem, {
DropDownMenuItemBadgeType,
DropDownMenuItemBadge,
} from "../dropdownMenu/DropdownMenuItem";
import {
FontFamilyCodeIcon,
FontFamilyHeadingIcon,
@@ -36,8 +32,15 @@ import {
FreedrawIcon,
} from "../icons";
import { Ellipsify } from "../Ellipsify";
import { fontPickerKeyHandler } from "./keyboardNavHandlers";
import {
FontPickerListItem,
FontPickerListItemBadgeType,
} from "./FontPickerListItem";
import type { JSX } from "react";
export interface FontDescriptor {
@@ -46,7 +49,7 @@ export interface FontDescriptor {
text: string;
deprecated?: true;
badge?: {
type: ValueOf<typeof DropDownMenuItemBadgeType>;
type: ValueOf<typeof FontPickerListItemBadgeType>;
placeholder: string;
};
}
@@ -112,7 +115,7 @@ export const FontPickerList = React.memo(
Object.assign(fontDescriptor, {
deprecated: metadata.deprecated,
badge: {
type: DropDownMenuItemBadgeType.RED,
type: FontPickerListItemBadgeType.RED,
placeholder: t("fontList.badge.old"),
},
});
@@ -227,7 +230,7 @@ export const FontPickerList = React.memo(
);
const renderFont = (font: FontDescriptor, index: number) => (
<DropdownMenuItem
<FontPickerListItem
key={font.value}
icon={font.icon}
value={font.value}
@@ -239,8 +242,8 @@ export const FontPickerList = React.memo(
selected={font.value === selectedFontFamily}
// allow to tab between search and selected font
tabIndex={font.value === selectedFontFamily ? 0 : -1}
onClick={(e) => {
onSelect(Number(e.currentTarget.value));
onSelect={() => {
onSelect(font.value);
}}
onMouseMove={() => {
if (hoveredFont?.value !== font.value) {
@@ -248,13 +251,13 @@ export const FontPickerList = React.memo(
}
}}
>
{font.text}
<Ellipsify>{font.text}</Ellipsify>
{font.badge && (
<DropDownMenuItemBadge type={font.badge.type}>
<FontPickerListItem.Badge type={font.badge.type}>
{font.badge.placeholder}
</DropDownMenuItemBadge>
</FontPickerListItem.Badge>
)}
</DropdownMenuItem>
</FontPickerListItem>
);
const groups = [];

View File

@@ -0,0 +1,151 @@
import React, { useEffect, useRef } from "react";
import { THEME } from "@excalidraw/common";
import type { ValueOf } from "@excalidraw/common/utility-types";
import { Button } from "../Button";
import { useExcalidrawAppState } from "../App";
import { useDevice } from "../App";
import { getDropdownMenuItemClassName } from "../dropdownMenu/common";
import type { JSX } from "react";
const MenuItemContent = ({
textStyle,
icon,
shortcut,
children,
}: {
icon?: React.ReactNode;
shortcut?: string;
textStyle?: React.CSSProperties;
children: React.ReactNode;
}) => {
const device = useDevice();
return (
<>
{icon && <div className="dropdown-menu-item__icon">{icon}</div>}
<div style={textStyle} className="dropdown-menu-item__text">
{children}
</div>
{shortcut && !device.editor.isMobile && (
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
)}
</>
);
};
export const FontPickerListItem = ({
icon,
value,
order,
children,
shortcut,
className,
hovered,
selected,
textStyle,
onSelect,
onClick,
...rest
}: {
icon?: JSX.Element;
value?: string | number | undefined;
order?: number;
onSelect: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
children: React.ReactNode;
shortcut?: string;
hovered?: boolean;
selected?: boolean;
textStyle?: React.CSSProperties;
className?: string;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
const ref = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (hovered) {
if (order === 0) {
// scroll into the first item differently, so it's visible what is above (i.e. group title)
ref.current?.scrollIntoView({ block: "end" });
} else {
ref.current?.scrollIntoView({ block: "nearest" });
}
}
}, [hovered, order]);
return (
<div className="radix-menu-item">
<Button
{...rest}
ref={ref}
onSelect={onSelect}
className={getDropdownMenuItemClassName(className, selected, hovered)}
title={rest.title ?? rest["aria-label"]}
>
<MenuItemContent textStyle={textStyle} icon={icon} shortcut={shortcut}>
{children}
</MenuItemContent>
</Button>
</div>
);
};
FontPickerListItem.displayName = "FontPickerListItem";
export const FontPickerListItemBadgeType = {
GREEN: "green",
RED: "red",
BLUE: "blue",
} as const;
export const FontPickerListItemBadge = ({
type = FontPickerListItemBadgeType.BLUE,
children,
}: {
type?: ValueOf<typeof FontPickerListItemBadgeType>;
children: React.ReactNode;
}) => {
const { theme } = useExcalidrawAppState();
const style = {
display: "inline-flex",
marginLeft: "auto",
padding: "2px 4px",
borderRadius: 6,
fontSize: 9,
fontFamily: "Cascadia, monospace",
border: theme === THEME.LIGHT ? "1.5px solid white" : "none",
};
switch (type) {
case FontPickerListItemBadgeType.GREEN:
Object.assign(style, {
backgroundColor: "var(--background-color-badge)",
color: "var(--color-badge)",
});
break;
case FontPickerListItemBadgeType.RED:
Object.assign(style, {
backgroundColor: "pink",
color: "darkred",
});
break;
case FontPickerListItemBadgeType.BLUE:
default:
Object.assign(style, {
background: "var(--color-promo)",
color: "var(--color-surface-lowest)",
});
}
return (
<div className="DropDownMenuItemBadge" style={style}>
{children}
</div>
);
};
FontPickerListItemBadge.displayName = "DropdownMenuItemBadge";
FontPickerListItem.Badge = FontPickerListItemBadge;

View File

@@ -238,7 +238,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
shortcuts={[getShortcutKey("Enter"), getShortcutKey("Escape")]}
isOr={true}
/>
<Shortcut label={t("toolBar.lock")} shortcuts={[KEYS.Q]} />
<Shortcut
label={t("toolBar.lock")}
shortcuts={[getShortcutFromShortcutName("toolLock")]}
/>
<Shortcut
label={t("helpDialog.preventBinding")}
shortcuts={[getShortcutKey("CtrlOrCmd")]}

View File

@@ -26,9 +26,9 @@ export const TTDDialogTrigger = ({
setAppState({ openDialog: { name: "ttd", tab: "text-to-diagram" } });
}}
icon={icon ?? brainIcon}
badge={<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>}
>
{children ?? t("labels.textToDiagram")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item>
</TTDDialogTriggerTunnel.In>
);

View File

@@ -7,6 +7,7 @@
.dropdown-menu {
max-width: 16rem;
margin-top: 0.25rem;
&__submenu-trigger {
&[aria-expanded="true"] {
@@ -60,6 +61,7 @@
.dropdown-menu-item-base {
display: flex;
padding: 0 0.625rem;
column-gap: 0.625rem;
font-size: 0.875rem;
color: var(--color-on-surface);
@@ -125,7 +127,7 @@
border: 1px solid transparent;
align-items: center;
cursor: pointer;
border-radius: var(--border-radius-sm);
border-radius: var(--border-radius-md);
flex: 1 0 auto;
@media screen and (min-width: 1921px) {

View File

@@ -1,5 +1,7 @@
import React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import DropdownMenuContent from "./DropdownMenuContent";
import DropdownMenuGroup from "./DropdownMenuGroup";
import DropdownMenuItem from "./DropdownMenuItem";
@@ -14,8 +16,6 @@ import {
import "./DropdownMenu.scss";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
const DropdownMenu = ({
children,
open,

View File

@@ -3,6 +3,8 @@ import React, { useEffect, useRef } from "react";
import { EVENT, KEYS } from "@excalidraw/common";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { useOutsideClick } from "../../hooks/useOutsideClick";
import { useStable } from "../../hooks/useStable";
import { useDevice } from "../App";
@@ -11,8 +13,6 @@ import Stack from "../Stack";
import { DropdownMenuContentPropsContext } from "./common";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
const MenuContent = ({
children,
onClickOutside,
@@ -89,7 +89,7 @@ const MenuContent = ({
) : (
<Island
className="dropdown-menu-container"
padding={1}
padding={2}
style={{ zIndex: 2 }}
>
{children}

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef } from "react";
import React, { useRef } from "react";
import { THEME } from "@excalidraw/common";
@@ -22,53 +22,41 @@ import type { JSX } from "react";
const DropdownMenuItem = ({
icon,
value,
badge,
order,
children,
shortcut,
className,
hovered,
selected,
textStyle,
onSelect,
onClick,
...rest
}: {
icon?: JSX.Element;
badge?: React.ReactNode;
value?: string | number | undefined;
order?: number;
onSelect?: (event: Event) => void;
children: React.ReactNode;
shortcut?: string;
hovered?: boolean;
selected?: boolean;
textStyle?: React.CSSProperties;
className?: string;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
const handleClick = useHandleDropdownMenuItemClick(onClick, onSelect);
const ref = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (hovered) {
if (order === 0) {
// scroll into the first item differently, so it's visible what is above (i.e. group title)
ref.current?.scrollIntoView({ block: "end" });
} else {
ref.current?.scrollIntoView({ block: "nearest" });
}
}
}, [hovered, order]);
return (
<DropdownMenuPrimitive.Item className="radix-menu-item">
<Button
{...rest}
ref={ref}
// onClick={handleClick}
onSelect={handleClick}
className={getDropdownMenuItemClassName(className, selected, hovered)}
className={getDropdownMenuItemClassName(className)}
title={rest.title ?? rest["aria-label"]}
>
<MenuItemContent textStyle={textStyle} icon={icon} shortcut={shortcut}>
<MenuItemContent icon={icon} shortcut={shortcut} badge={badge}>
{children}
</MenuItemContent>
</Button>

View File

@@ -2,25 +2,24 @@ import { useDevice } from "../App";
import { Ellipsify } from "../Ellipsify";
import type { JSX } from "react";
const MenuItemContent = ({
textStyle,
icon,
badge,
shortcut,
children,
}: {
icon?: JSX.Element;
icon?: React.ReactNode;
shortcut?: string;
textStyle?: React.CSSProperties;
children: React.ReactNode;
badge?: React.ReactNode;
}) => {
const device = useDevice();
return (
<>
{icon && <div className="dropdown-menu-item__icon">{icon}</div>}
<div style={textStyle} className="dropdown-menu-item__text">
<div className="dropdown-menu-item__text">
<Ellipsify>{children}</Ellipsify>
{badge}
</div>
{shortcut && !device.editor.isMobile && (
<div className="dropdown-menu-item__shortcut">{shortcut}</div>

View File

@@ -8,8 +8,6 @@ import {
useHandleDropdownMenuItemClick,
} from "./common";
import type { JSX } from "react";
const DropdownMenuSubItem = ({
icon,
onSelect,
@@ -18,7 +16,7 @@ const DropdownMenuSubItem = ({
className,
...rest
}: {
icon?: JSX.Element;
icon?: React.ReactNode;
onSelect: (event: Event) => void;
children: React.ReactNode;
shortcut?: string;
@@ -30,8 +28,7 @@ const DropdownMenuSubItem = ({
<DropdownMenuPrimitive.Item className="radix-menu-item">
<Button
{...rest}
onClick={handleClick}
onSelect={() => {}}
onSelect={handleClick}
type="button"
className={getDropdownMenuItemClassName(className)}
title={rest.title ?? rest["aria-label"]}

View File

@@ -1,8 +1,9 @@
import clsx from "clsx";
import { useDevice } from "../App";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { useDevice } from "../App";
const MenuTrigger = ({
className = "",
children,

View File

@@ -2278,3 +2278,21 @@ export const elementLinkIcon = createIcon(
</g>,
tablerIconProps,
);
export const settingsIcon = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M14 6m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M4 6l8 0" />
<path d="M16 6l4 0" />
<path d="M8 12m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M4 12l2 0" />
<path d="M10 12l10 0" />
<path d="M17 18m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M4 18l11 0" />
<path d="M19 18l1 0" />
</g>,
tablerIconProps,
);
export const emptyIcon = <div style={{ width: "1rem", height: "1rem" }} />;

View File

@@ -9,8 +9,11 @@ import {
actionLoadScene,
actionSaveToActiveFile,
actionShortcuts,
actionToggleGridMode,
actionToggleObjectsSnapMode,
actionToggleSearchMenu,
actionToggleTheme,
actionToggleZenMode,
} from "../../actions";
import { getShortcutFromShortcutName } from "../../actions/shortcuts";
import { trackEvent } from "../../analytics";
@@ -23,13 +26,23 @@ import {
useExcalidrawActionManager,
useExcalidrawElements,
useAppProps,
useApp,
} from "../App";
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
import Trans from "../Trans";
import DropdownMenuItem from "../dropdownMenu/DropdownMenuItem";
import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio";
import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
import { GithubIcon, DiscordIcon, XBrandIcon } from "../icons";
import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub";
import { actionToggleViewMode } from "../../actions/actionToggleViewMode";
import {
GithubIcon,
DiscordIcon,
XBrandIcon,
settingsIcon,
checkIcon,
emptyIcon,
} from "../icons";
import {
boltIcon,
DeviceDesktopIcon,
@@ -396,3 +409,73 @@ export const LiveCollaborationTrigger = ({
};
LiveCollaborationTrigger.displayName = "LiveCollaborationTrigger";
export const Preferences = ({ children }: { children?: React.ReactNode }) => {
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
const appState = useUIAppState();
const app = useApp();
return (
<DropdownMenuSub>
<DropdownMenuSub.Trigger icon={settingsIcon}>
{t("labels.preferences")}
</DropdownMenuSub.Trigger>
<DropdownMenuSub.Content className="excalidraw-main-menu-preferences-submenu">
<DropdownMenuSub.Item
icon={appState.activeTool.locked ? checkIcon : emptyIcon}
shortcut={getShortcutFromShortcutName("toolLock")}
onSelect={(event) => {
app.toggleLock();
event.preventDefault();
}}
>
{t("labels.preferences_toolLock")}
</DropdownMenuSub.Item>
<DropdownMenuSub.Item
icon={appState.objectsSnapModeEnabled ? checkIcon : emptyIcon}
shortcut={getShortcutFromShortcutName("objectsSnapMode")}
onSelect={(event) => {
actionManager.executeAction(actionToggleObjectsSnapMode);
event.preventDefault();
}}
>
{t("buttons.objectsSnapMode")}
</DropdownMenuSub.Item>
<DropdownMenuSub.Item
icon={appState.gridModeEnabled ? checkIcon : emptyIcon}
shortcut={getShortcutFromShortcutName("gridMode")}
onSelect={(event) => {
actionManager.executeAction(actionToggleGridMode);
event.preventDefault();
}}
>
{t("labels.toggleGrid")}
</DropdownMenuSub.Item>
<DropdownMenuSub.Item
icon={appState.zenModeEnabled ? checkIcon : emptyIcon}
shortcut={getShortcutFromShortcutName("zenMode")}
onSelect={(event) => {
actionManager.executeAction(actionToggleZenMode);
event.preventDefault();
}}
>
{t("buttons.zenMode")}
</DropdownMenuSub.Item>
<DropdownMenuSub.Item
icon={appState.viewModeEnabled ? checkIcon : emptyIcon}
shortcut={getShortcutFromShortcutName("viewMode")}
onSelect={(event) => {
actionManager.executeAction(actionToggleViewMode);
event.preventDefault();
}}
>
{t("labels.viewMode")}
</DropdownMenuSub.Item>
{children}
</DropdownMenuSub.Content>
</DropdownMenuSub>
);
};
Preferences.displayName = "Preferences";

View File

@@ -171,7 +171,9 @@
"linkToElement": "Link to object",
"wrapSelectionInFrame": "Wrap selection in frame",
"tab": "Tab",
"shapeSwitch": "Switch shape"
"shapeSwitch": "Switch shape",
"preferences": "Preferences",
"preferences_toolLock": "Tool lock"
},
"elementLink": {
"title": "Link to object",