feat: command palette (#7804)

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Ryan Di 2024-03-29 00:16:32 +08:00 committed by GitHub
parent 6b523563d8
commit 550a388b2b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 5226 additions and 317 deletions

View file

@ -1,6 +1,7 @@
import { useState } from "react";
import { ActionManager } from "../actions/manager";
import {
ExcalidrawElement,
ExcalidrawElementType,
NonDeletedElementsMap,
NonDeletedSceneElementsMap,
@ -45,6 +46,40 @@ import {
import { KEYS } from "../keys";
import { useTunnels } from "../context/tunnels";
export const canChangeStrokeColor = (
appState: UIAppState,
targetElements: ExcalidrawElement[],
) => {
let commonSelectedType: ExcalidrawElementType | null =
targetElements[0]?.type || null;
for (const element of targetElements) {
if (element.type !== commonSelectedType) {
commonSelectedType = null;
break;
}
}
return (
(hasStrokeColor(appState.activeTool.type) &&
appState.activeTool.type !== "image" &&
commonSelectedType !== "image" &&
commonSelectedType !== "frame" &&
commonSelectedType !== "magicframe") ||
targetElements.some((element) => hasStrokeColor(element.type))
);
};
export const canChangeBackgroundColor = (
appState: UIAppState,
targetElements: ExcalidrawElement[],
) => {
return (
hasBackground(appState.activeTool.type) ||
targetElements.some((element) => hasBackground(element.type))
);
};
export const SelectedShapeActions = ({
appState,
elementsMap,
@ -75,35 +110,17 @@ export const SelectedShapeActions = ({
(element) =>
hasBackground(element.type) && !isTransparent(element.backgroundColor),
);
const showChangeBackgroundIcons =
hasBackground(appState.activeTool.type) ||
targetElements.some((element) => hasBackground(element.type));
const showLinkIcon =
targetElements.length === 1 || isSingleElementBoundContainer;
let commonSelectedType: ExcalidrawElementType | null =
targetElements[0]?.type || null;
for (const element of targetElements) {
if (element.type !== commonSelectedType) {
commonSelectedType = null;
break;
}
}
return (
<div className="panelColumn">
<div>
{((hasStrokeColor(appState.activeTool.type) &&
appState.activeTool.type !== "image" &&
commonSelectedType !== "image" &&
commonSelectedType !== "frame" &&
commonSelectedType !== "magicframe") ||
targetElements.some((element) => hasStrokeColor(element.type))) &&
{canChangeStrokeColor(appState, targetElements) &&
renderAction("changeStrokeColor")}
</div>
{showChangeBackgroundIcons && (
{canChangeBackgroundColor(appState, targetElements) && (
<div>{renderAction("changeBackgroundColor")}</div>
)}
{showFillIcons && renderAction("changeFillStyle")}

View file

@ -413,6 +413,7 @@ import {
isPointHittingLink,
isPointHittingLinkIcon,
} from "./hyperlink/helpers";
import { getShortcutFromShortcutName } from "../actions/shortcuts";
const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
@ -3746,6 +3747,22 @@ class App extends React.Component<AppProps, AppState> {
});
}
if (
event[KEYS.CTRL_OR_CMD] &&
event.key === KEYS.P &&
!event.shiftKey &&
!event.altKey
) {
this.setToast({
message: t("commandPalette.shortcutHint", {
shortcutOne: getShortcutFromShortcutName("commandPalette"),
shortcutTwo: getShortcutFromShortcutName("commandPalette", 1),
}),
});
event.preventDefault();
return;
}
if (event[KEYS.CTRL_OR_CMD] && event.key.toLowerCase() === KEYS.V) {
IS_PLAIN_PASTE = event.shiftKey;
clearTimeout(IS_PLAIN_PASTE_TIMER);
@ -4604,11 +4621,6 @@ class App extends React.Component<AppProps, AppState> {
editingLinearElement: new LinearElementEditor(selectedElements[0]),
});
return;
} else if (
this.state.editingLinearElement &&
this.state.editingLinearElement.elementId === selectedElements[0].id
) {
return;
}
}
@ -4781,7 +4793,11 @@ class App extends React.Component<AppProps, AppState> {
}
if (!customEvent?.defaultPrevented) {
const target = isLocalLink(url) ? "_self" : "_blank";
const newWindow = window.open(undefined, target);
const newWindow = window.open(
undefined,
target,
"noopener noreferrer",
);
// https://mathiasbynens.github.io/rel-noopener/
if (newWindow) {
newWindow.opener = null;

View file

@ -0,0 +1,137 @@
@import "../../css/variables.module.scss";
$verticalBreakpoint: 861px;
.excalidraw {
.command-palette-dialog {
user-select: none;
.Modal__content {
height: auto;
max-height: 100%;
@media screen and (min-width: $verticalBreakpoint) {
max-height: 750px;
height: 100%;
}
.Island {
height: 100%;
padding: 1.5rem;
}
.Dialog__content {
height: 100%;
display: flex;
flex-direction: column;
}
}
.shortcuts-wrapper {
display: flex;
justify-content: center;
align-items: center;
margin-top: 12px;
gap: 1.5rem;
}
.shortcut {
display: flex;
justify-content: center;
align-items: center;
height: 16px;
font-size: 10px;
gap: 0.25rem;
.shortcut-wrapper {
display: flex;
}
.shortcut-plus {
margin: 0px 4px;
}
.shortcut-key {
padding: 0px 4px;
height: 16px;
border-radius: 4px;
display: flex;
justify-content: center;
align-items: center;
background-color: var(--color-primary-light);
}
.shortcut-desc {
margin-left: 4px;
color: var(--color-gray-50);
}
}
.commands {
overflow-y: auto;
box-sizing: border-box;
margin-top: 12px;
color: var(--popup-text-color);
user-select: none;
.command-category {
display: flex;
flex-direction: column;
padding: 12px 0px;
margin-right: 0.25rem;
}
.command-category-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 6px;
display: flex;
align-items: center;
}
.command-item {
color: var(--popup-text-color);
height: 2.5rem;
display: flex;
justify-content: space-between;
align-items: center;
box-sizing: border-box;
padding: 0 0.5rem;
border-radius: var(--border-radius-lg);
cursor: pointer;
&:active {
background-color: var(--color-surface-low);
}
.name {
display: flex;
align-items: center;
gap: 0.25rem;
}
}
.item-selected {
background-color: var(--color-surface-mid);
}
.item-disabled {
opacity: 0.3;
cursor: not-allowed;
}
.no-match {
display: flex;
justify-content: center;
align-items: center;
margin-top: 36px;
}
}
.icon {
width: 16px;
height: 16px;
margin-right: 6px;
}
}
}

View file

@ -0,0 +1,915 @@
import { useEffect, useRef, useState } from "react";
import {
useApp,
useAppProps,
useExcalidrawActionManager,
useExcalidrawSetAppState,
} from "../App";
import { KEYS } from "../../keys";
import { Dialog } from "../Dialog";
import { TextField } from "../TextField";
import clsx from "clsx";
import { getSelectedElements } from "../../scene";
import { Action } from "../../actions/types";
import { TranslationKeys, t } from "../../i18n";
import {
ShortcutName,
getShortcutFromShortcutName,
} from "../../actions/shortcuts";
import { DEFAULT_SIDEBAR, EVENT } from "../../constants";
import {
LockedIcon,
UnlockedIcon,
clockIcon,
searchIcon,
boltIcon,
bucketFillIcon,
ExportImageIcon,
mermaidLogoIcon,
brainIconThin,
LibraryIcon,
} from "../icons";
import fuzzy from "fuzzy";
import { useUIAppState } from "../../context/ui-appState";
import { AppProps, AppState, UIAppState } from "../../types";
import {
capitalizeString,
getShortcutKey,
isWritableElement,
} from "../../utils";
import { atom, useAtom } from "jotai";
import { deburr } from "../../deburr";
import { MarkRequired } from "../../utility-types";
import { InlineIcon } from "../InlineIcon";
import { SHAPES } from "../../shapes";
import { canChangeBackgroundColor, canChangeStrokeColor } from "../Actions";
import { useStableCallback } from "../../hooks/useStableCallback";
import { actionClearCanvas, actionLink } from "../../actions";
import { jotaiStore } from "../../jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import { CommandPaletteItem } from "./types";
import * as defaultItems from "./defaultCommandPaletteItems";
import "./CommandPalette.scss";
const lastUsedPaletteItem = atom<CommandPaletteItem | null>(null);
export const DEFAULT_CATEGORIES = {
app: "App",
export: "Export",
tools: "Tools",
editor: "Editor",
elements: "Elements",
links: "Links",
};
const getCategoryOrder = (category: string) => {
switch (category) {
case DEFAULT_CATEGORIES.app:
return 1;
case DEFAULT_CATEGORIES.export:
return 2;
case DEFAULT_CATEGORIES.editor:
return 3;
case DEFAULT_CATEGORIES.tools:
return 4;
case DEFAULT_CATEGORIES.elements:
return 5;
case DEFAULT_CATEGORIES.links:
return 6;
default:
return 10;
}
};
const CommandShortcutHint = ({
shortcut,
className,
children,
}: {
shortcut: string;
className?: string;
children?: React.ReactNode;
}) => {
const shortcuts = shortcut.split(/(?<!\+)(?:\+)/g);
return (
<div className={clsx("shortcut", className)}>
{shortcuts.map((item) => {
return (
<div className="shortcut-wrapper" key={item}>
<div className="shortcut-key">{item}</div>
</div>
);
})}
<div className="shortcut-desc">{children}</div>
</div>
);
};
const isCommandPaletteToggleShortcut = (event: KeyboardEvent) => {
return (
!event.altKey &&
event[KEYS.CTRL_OR_CMD] &&
((event.shiftKey && event.key.toLowerCase() === KEYS.P) ||
event.key === KEYS.SLASH)
);
};
type CommandPaletteProps = {
customCommandPaletteItems?: CommandPaletteItem[];
};
export const CommandPalette = Object.assign(
(props: CommandPaletteProps) => {
const uiAppState = useUIAppState();
const setAppState = useExcalidrawSetAppState();
useEffect(() => {
const commandPaletteShortcut = (event: KeyboardEvent) => {
if (isCommandPaletteToggleShortcut(event)) {
event.preventDefault();
event.stopPropagation();
setAppState((appState) => ({
openDialog:
appState.openDialog?.name === "commandPalette"
? null
: { name: "commandPalette" },
}));
}
};
window.addEventListener(EVENT.KEYDOWN, commandPaletteShortcut, {
capture: true,
});
return () =>
window.removeEventListener(EVENT.KEYDOWN, commandPaletteShortcut, {
capture: true,
});
}, [setAppState]);
if (uiAppState.openDialog?.name !== "commandPalette") {
return null;
}
return <CommandPaletteInner {...props} />;
},
{
defaultItems,
},
);
function CommandPaletteInner({
customCommandPaletteItems,
}: CommandPaletteProps) {
const app = useApp();
const uiAppState = useUIAppState();
const setAppState = useExcalidrawSetAppState();
const appProps = useAppProps();
const actionManager = useExcalidrawActionManager();
const [lastUsed, setLastUsed] = useAtom(lastUsedPaletteItem);
const [allCommands, setAllCommands] = useState<
MarkRequired<CommandPaletteItem, "haystack" | "order">[] | null
>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!uiAppState || !app.scene || !actionManager) {
return;
}
const getActionLabel = (action: Action) => {
let label = "";
if (action.label) {
if (typeof action.label === "function") {
label = t(
action.label(
app.scene.getNonDeletedElements(),
uiAppState as AppState,
app,
) as unknown as TranslationKeys,
);
} else {
label = t(action.label as unknown as TranslationKeys);
}
}
return label;
};
const getActionIcon = (action: Action) => {
if (typeof action.icon === "function") {
return action.icon(uiAppState, app.scene.getNonDeletedElements());
}
return action.icon;
};
let commandsFromActions: CommandPaletteItem[] = [];
const actionToCommand = (
action: Action,
category: string,
transformer?: (
command: CommandPaletteItem,
action: Action,
) => CommandPaletteItem,
): CommandPaletteItem => {
const command: CommandPaletteItem = {
label: getActionLabel(action),
icon: getActionIcon(action),
category,
shortcut: getShortcutFromShortcutName(action.name as ShortcutName),
keywords: action.keywords,
predicate: action.predicate,
viewMode: action.viewMode,
perform: () => {
actionManager.executeAction(action, "commandPalette");
},
};
return transformer ? transformer(command, action) : command;
};
if (uiAppState && app.scene && actionManager) {
const elementsCommands: CommandPaletteItem[] = [
actionManager.actions.group,
actionManager.actions.ungroup,
actionManager.actions.cut,
actionManager.actions.copy,
actionManager.actions.deleteSelectedElements,
actionManager.actions.copyStyles,
actionManager.actions.pasteStyles,
actionManager.actions.sendBackward,
actionManager.actions.sendToBack,
actionManager.actions.bringForward,
actionManager.actions.bringToFront,
actionManager.actions.alignTop,
actionManager.actions.alignBottom,
actionManager.actions.alignLeft,
actionManager.actions.alignRight,
actionManager.actions.alignVerticallyCentered,
actionManager.actions.alignHorizontallyCentered,
actionManager.actions.duplicateSelection,
actionManager.actions.flipHorizontal,
actionManager.actions.flipVertical,
actionManager.actions.zoomToFitSelection,
actionManager.actions.zoomToFitSelectionInViewport,
actionManager.actions.increaseFontSize,
actionManager.actions.decreaseFontSize,
actionManager.actions.toggleLinearEditor,
actionLink,
].map((action: Action) =>
actionToCommand(
action,
DEFAULT_CATEGORIES.elements,
(command, action) => ({
...command,
predicate: action.predicate
? action.predicate
: (elements, appState, appProps, app) => {
const selectedElements = getSelectedElements(
elements,
appState,
);
return selectedElements.length > 0;
},
}),
),
);
const toolCommands: CommandPaletteItem[] = [
actionManager.actions.toggleHandTool,
actionManager.actions.setFrameAsActiveTool,
].map((action) => actionToCommand(action, DEFAULT_CATEGORIES.tools));
const editorCommands: CommandPaletteItem[] = [
actionManager.actions.undo,
actionManager.actions.redo,
actionManager.actions.zoomIn,
actionManager.actions.zoomOut,
actionManager.actions.resetZoom,
actionManager.actions.zoomToFit,
actionManager.actions.zenMode,
actionManager.actions.viewMode,
actionManager.actions.objectsSnapMode,
actionManager.actions.toggleShortcuts,
actionManager.actions.selectAll,
actionManager.actions.toggleElementLock,
actionManager.actions.unlockAllElements,
actionManager.actions.stats,
].map((action) => actionToCommand(action, DEFAULT_CATEGORIES.editor));
const exportCommands: CommandPaletteItem[] = [
actionManager.actions.saveToActiveFile,
actionManager.actions.saveFileToDisk,
actionManager.actions.copyAsPng,
actionManager.actions.copyAsSvg,
].map((action) => actionToCommand(action, DEFAULT_CATEGORIES.export));
commandsFromActions = [
...elementsCommands,
...editorCommands,
{
label: getActionLabel(actionClearCanvas),
icon: getActionIcon(actionClearCanvas),
shortcut: getShortcutFromShortcutName(
actionClearCanvas.name as ShortcutName,
),
category: DEFAULT_CATEGORIES.editor,
keywords: ["delete", "destroy"],
viewMode: false,
perform: () => {
jotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
},
},
{
label: t("buttons.exportImage"),
category: DEFAULT_CATEGORIES.export,
icon: ExportImageIcon,
shortcut: getShortcutFromShortcutName("imageExport"),
keywords: [
"export",
"image",
"png",
"jpeg",
"svg",
"clipboard",
"picture",
],
perform: () => {
setAppState({ openDialog: { name: "imageExport" } });
},
},
...exportCommands,
];
const additionalCommands: CommandPaletteItem[] = [
{
label: t("toolBar.library"),
category: DEFAULT_CATEGORIES.app,
icon: LibraryIcon,
viewMode: false,
perform: () => {
if (uiAppState.openSidebar) {
setAppState({
openSidebar: null,
});
} else {
setAppState({
openSidebar: {
name: DEFAULT_SIDEBAR.name,
tab: DEFAULT_SIDEBAR.defaultTab,
},
});
}
},
},
{
label: t("labels.changeStroke"),
keywords: ["color", "outline"],
category: DEFAULT_CATEGORIES.elements,
icon: bucketFillIcon,
viewMode: false,
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
return (
selectedElements.length > 0 &&
canChangeStrokeColor(appState, selectedElements)
);
},
perform: () => {
setAppState((prevState) => ({
openMenu: prevState.openMenu === "shape" ? null : "shape",
openPopup: "elementStroke",
}));
},
},
{
label: t("labels.changeBackground"),
keywords: ["color", "fill"],
icon: bucketFillIcon,
category: DEFAULT_CATEGORIES.elements,
viewMode: false,
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
return (
selectedElements.length > 0 &&
canChangeBackgroundColor(appState, selectedElements)
);
},
perform: () => {
setAppState((prevState) => ({
openMenu: prevState.openMenu === "shape" ? null : "shape",
openPopup: "elementBackground",
}));
},
},
{
label: t("labels.canvasBackground"),
keywords: ["color"],
icon: bucketFillIcon,
category: DEFAULT_CATEGORIES.editor,
viewMode: false,
perform: () => {
setAppState((prevState) => ({
openMenu: prevState.openMenu === "canvas" ? null : "canvas",
openPopup: "canvasBackground",
}));
},
},
...SHAPES.reduce((acc: CommandPaletteItem[], shape) => {
const { value, icon, key, numericKey } = shape;
if (
appProps.UIOptions.tools?.[
value as Extract<
typeof value,
keyof AppProps["UIOptions"]["tools"]
>
] === false
) {
return acc;
}
const letter =
key && capitalizeString(typeof key === "string" ? key : key[0]);
const shortcut = letter || numericKey;
const command: CommandPaletteItem = {
label: t(`toolBar.${value}`),
category: DEFAULT_CATEGORIES.tools,
shortcut,
icon,
keywords: ["toolbar"],
viewMode: false,
perform: ({ event }) => {
if (value === "image") {
app.setActiveTool({
type: value,
insertOnCanvasDirectly: event.type === EVENT.KEYDOWN,
});
} else {
app.setActiveTool({ type: value });
}
},
};
acc.push(command);
return acc;
}, []),
...toolCommands,
{
label: t("toolBar.lock"),
category: DEFAULT_CATEGORIES.tools,
icon: uiAppState.activeTool.locked ? LockedIcon : UnlockedIcon,
shortcut: KEYS.Q.toLocaleUpperCase(),
viewMode: false,
perform: () => {
app.toggleLock();
},
},
{
label: `${t("labels.textToDiagram")}...`,
category: DEFAULT_CATEGORIES.tools,
icon: brainIconThin,
viewMode: false,
predicate: appProps.aiEnabled,
perform: () => {
setAppState((state) => ({
...state,
openDialog: {
name: "ttd",
tab: "text-to-diagram",
},
}));
},
},
{
label: `${t("toolBar.mermaidToExcalidraw")}...`,
category: DEFAULT_CATEGORIES.tools,
icon: mermaidLogoIcon,
viewMode: false,
predicate: appProps.aiEnabled,
perform: () => {
setAppState((state) => ({
...state,
openDialog: {
name: "ttd",
tab: "mermaid",
},
}));
},
},
// {
// label: `${t("toolBar.magicframe")}...`,
// category: DEFAULT_CATEGORIES.tools,
// icon: MagicIconThin,
// viewMode: false,
// predicate: appProps.aiEnabled,
// perform: () => {
// app.onMagicframeToolSelect();
// },
// },
];
const allCommands = [
...commandsFromActions,
...additionalCommands,
...(customCommandPaletteItems || []),
].map((command) => {
return {
...command,
icon: command.icon || boltIcon,
order: command.order ?? getCategoryOrder(command.category),
haystack: `${deburr(command.label)} ${
command.keywords?.join(" ") || ""
}`,
};
});
setAllCommands(allCommands);
setLastUsed(
allCommands.find((command) => command.label === lastUsed?.label) ??
null,
);
}
}, [
app,
appProps,
uiAppState,
actionManager,
setAllCommands,
lastUsed?.label,
setLastUsed,
setAppState,
customCommandPaletteItems,
]);
const [commandSearch, setCommandSearch] = useState("");
const [currentCommand, setCurrentCommand] =
useState<CommandPaletteItem | null>(null);
const [commandsByCategory, setCommandsByCategory] = useState<
Record<string, CommandPaletteItem[]>
>({});
const closeCommandPalette = (cb?: () => void) => {
setAppState(
{
openDialog: null,
},
cb,
);
setCommandSearch("");
};
const executeCommand = (
command: CommandPaletteItem,
event: React.MouseEvent | React.KeyboardEvent | KeyboardEvent,
) => {
if (uiAppState.openDialog?.name === "commandPalette") {
event.stopPropagation();
event.preventDefault();
document.body.classList.add("excalidraw-animations-disabled");
closeCommandPalette(() => {
command.perform({ actionManager, event });
setLastUsed(command);
requestAnimationFrame(() => {
document.body.classList.remove("excalidraw-animations-disabled");
});
});
}
};
const isCommandAvailable = useStableCallback(
(command: CommandPaletteItem) => {
if (command.viewMode === false && uiAppState.viewModeEnabled) {
return false;
}
return typeof command.predicate === "function"
? command.predicate(
app.scene.getNonDeletedElements(),
uiAppState as AppState,
appProps,
app,
)
: command.predicate === undefined || command.predicate;
},
);
const handleKeyDown = useStableCallback((event: KeyboardEvent) => {
const ignoreAlphanumerics =
isWritableElement(event.target) ||
isCommandPaletteToggleShortcut(event) ||
event.key === KEYS.ESCAPE;
if (
ignoreAlphanumerics &&
event.key !== KEYS.ARROW_UP &&
event.key !== KEYS.ARROW_DOWN &&
event.key !== KEYS.ENTER
) {
return;
}
const matchingCommands = Object.values(commandsByCategory).flat();
const shouldConsiderLastUsed =
lastUsed && !commandSearch && isCommandAvailable(lastUsed);
if (event.key === KEYS.ARROW_UP) {
event.preventDefault();
const index = matchingCommands.findIndex(
(item) => item.label === currentCommand?.label,
);
if (shouldConsiderLastUsed) {
if (index === 0) {
setCurrentCommand(lastUsed);
return;
}
if (currentCommand === lastUsed) {
const nextItem = matchingCommands[matchingCommands.length - 1];
if (nextItem) {
setCurrentCommand(nextItem);
}
return;
}
}
let nextIndex;
if (index === -1) {
nextIndex = matchingCommands.length - 1;
} else {
nextIndex =
index === 0
? matchingCommands.length - 1
: (index - 1) % matchingCommands.length;
}
const nextItem = matchingCommands[nextIndex];
if (nextItem) {
setCurrentCommand(nextItem);
}
return;
}
if (event.key === KEYS.ARROW_DOWN) {
event.preventDefault();
const index = matchingCommands.findIndex(
(item) => item.label === currentCommand?.label,
);
if (shouldConsiderLastUsed) {
if (!currentCommand || index === matchingCommands.length - 1) {
setCurrentCommand(lastUsed);
return;
}
if (currentCommand === lastUsed) {
const nextItem = matchingCommands[0];
if (nextItem) {
setCurrentCommand(nextItem);
}
return;
}
}
const nextIndex = (index + 1) % matchingCommands.length;
const nextItem = matchingCommands[nextIndex];
if (nextItem) {
setCurrentCommand(nextItem);
}
return;
}
if (event.key === KEYS.ENTER) {
if (currentCommand) {
setTimeout(() => {
executeCommand(currentCommand, event);
});
}
}
if (ignoreAlphanumerics) {
return;
}
// prevent regular editor shortcuts
event.stopPropagation();
// if alphanumeric keypress and we're not inside the input, focus it
if (/^[a-zA-Z0-9]$/.test(event.key)) {
inputRef?.current?.focus();
return;
}
event.preventDefault();
});
useEffect(() => {
window.addEventListener(EVENT.KEYDOWN, handleKeyDown, {
capture: true,
});
return () =>
window.removeEventListener(EVENT.KEYDOWN, handleKeyDown, {
capture: true,
});
}, [handleKeyDown]);
useEffect(() => {
if (!allCommands) {
return;
}
const getNextCommandsByCategory = (commands: CommandPaletteItem[]) => {
const nextCommandsByCategory: Record<string, CommandPaletteItem[]> = {};
for (const command of commands) {
if (nextCommandsByCategory[command.category]) {
nextCommandsByCategory[command.category].push(command);
} else {
nextCommandsByCategory[command.category] = [command];
}
}
return nextCommandsByCategory;
};
let matchingCommands = allCommands
.filter(isCommandAvailable)
.sort((a, b) => a.order - b.order);
const showLastUsed =
!commandSearch && lastUsed && isCommandAvailable(lastUsed);
if (!commandSearch) {
setCommandsByCategory(
getNextCommandsByCategory(
showLastUsed
? matchingCommands.filter(
(command) => command.label !== lastUsed?.label,
)
: matchingCommands,
),
);
setCurrentCommand(showLastUsed ? lastUsed : matchingCommands[0] || null);
return;
}
const _query = deburr(commandSearch.replace(/[<>-_| ]/g, ""));
matchingCommands = fuzzy
.filter(_query, matchingCommands, {
extract: (command) => command.haystack,
})
.sort((a, b) => b.score - a.score)
.map((item) => item.original);
setCommandsByCategory(getNextCommandsByCategory(matchingCommands));
setCurrentCommand(matchingCommands[0] ?? null);
}, [commandSearch, allCommands, isCommandAvailable, lastUsed]);
return (
<Dialog
onCloseRequest={() => closeCommandPalette()}
closeOnClickOutside
title={false}
size={720}
autofocus
className="command-palette-dialog"
>
<TextField
value={commandSearch}
placeholder={t("commandPalette.search.placeholder")}
onChange={(value) => {
setCommandSearch(value);
}}
selectOnRender
ref={inputRef}
/>
{!app.device.viewport.isMobile && (
<div className="shortcuts-wrapper">
<CommandShortcutHint shortcut="↑↓">
{t("commandPalette.shortcuts.select")}
</CommandShortcutHint>
<CommandShortcutHint shortcut="↵">
{t("commandPalette.shortcuts.confirm")}
</CommandShortcutHint>
<CommandShortcutHint shortcut={getShortcutKey("Esc")}>
{t("commandPalette.shortcuts.close")}
</CommandShortcutHint>
</div>
)}
<div className="commands">
{lastUsed && !commandSearch && (
<div className="command-category">
<div className="command-category-title">
{t("commandPalette.recents")}
<div
className="icon"
style={{
marginLeft: "6px",
}}
>
{clockIcon}
</div>
</div>
<CommandItem
command={lastUsed}
isSelected={lastUsed.label === currentCommand?.label}
onClick={(event) => executeCommand(lastUsed, event)}
disabled={!isCommandAvailable(lastUsed)}
onMouseMove={() => setCurrentCommand(lastUsed)}
showShortcut={!app.device.viewport.isMobile}
appState={uiAppState}
/>
</div>
)}
{Object.keys(commandsByCategory).length > 0 ? (
Object.keys(commandsByCategory).map((category, idx) => {
return (
<div className="command-category" key={category}>
<div className="command-category-title">{category}</div>
{commandsByCategory[category].map((command) => (
<CommandItem
key={command.label}
command={command}
isSelected={command.label === currentCommand?.label}
onClick={(event) => executeCommand(command, event)}
onMouseMove={() => setCurrentCommand(command)}
showShortcut={!app.device.viewport.isMobile}
appState={uiAppState}
/>
))}
</div>
);
})
) : allCommands ? (
<div className="no-match">
<div className="icon">{searchIcon}</div>{" "}
{t("commandPalette.search.noMatch")}
</div>
) : null}
</div>
</Dialog>
);
}
const CommandItem = ({
command,
isSelected,
disabled,
onMouseMove,
onClick,
showShortcut,
appState,
}: {
command: CommandPaletteItem;
isSelected: boolean;
disabled?: boolean;
onMouseMove: () => void;
onClick: (event: React.MouseEvent) => void;
showShortcut: boolean;
appState: UIAppState;
}) => {
const noop = () => {};
return (
<div
className={clsx("command-item", {
"item-selected": isSelected,
"item-disabled": disabled,
})}
ref={(ref) => {
if (isSelected && !disabled) {
ref?.scrollIntoView?.({
block: "nearest",
});
}
}}
onClick={disabled ? noop : onClick}
onMouseMove={disabled ? noop : onMouseMove}
title={disabled ? t("commandPalette.itemNotAvailable") : ""}
>
<div className="name">
{command.icon && (
<InlineIcon
icon={
typeof command.icon === "function"
? command.icon(appState)
: command.icon
}
/>
)}
{command.label}
</div>
{showShortcut && command.shortcut && (
<CommandShortcutHint shortcut={command.shortcut} />
)}
</div>
);
};

View file

@ -0,0 +1,11 @@
import { actionToggleTheme } from "../../actions";
import { CommandPaletteItem } from "./types";
export const toggleTheme: CommandPaletteItem = {
...actionToggleTheme,
category: "App",
label: "Toggle theme",
perform: ({ actionManager }) => {
actionManager.executeAction(actionToggleTheme, "commandPalette");
},
};

View file

@ -0,0 +1,26 @@
import { ActionManager } from "../../actions/manager";
import { Action } from "../../actions/types";
import { UIAppState } from "../../types";
export type CommandPaletteItem = {
label: string;
/** additional keywords to match against
* (appended to haystack, not displayed) */
keywords?: string[];
/**
* string we should match against when searching
* (deburred name + keywords)
*/
haystack?: string;
icon?: React.ReactNode | ((appState: UIAppState) => React.ReactNode);
category: string;
order?: number;
predicate?: boolean | Action["predicate"];
shortcut?: string;
/** if false, command will not show while in view mode */
viewMode?: boolean;
perform: (data: {
actionManager: ActionManager;
event: React.MouseEvent | React.KeyboardEvent | KeyboardEvent;
}) => void;
};

View file

@ -78,17 +78,17 @@ export const ContextMenu = React.memo(
const actionName = item.name;
let label = "";
if (item.contextItemLabel) {
if (typeof item.contextItemLabel === "function") {
if (item.label) {
if (typeof item.label === "function") {
label = t(
item.contextItemLabel(
item.label(
elements,
appState,
actionManager.app,
) as unknown as TranslationKeys,
);
} else {
label = t(item.contextItemLabel as unknown as TranslationKeys);
label = t(item.label as unknown as TranslationKeys);
}
}

View file

@ -37,6 +37,12 @@
width: 1.5rem;
height: 1.5rem;
}
& + .Dialog__content {
--offset: 28px;
height: calc(100% - var(--offset)) !important;
margin-top: var(--offset) !important;
}
}
.Dialog--fullscreen {

View file

@ -1,7 +1,6 @@
import clsx from "clsx";
import React, { useEffect, useState } from "react";
import { useCallbackRefState } from "../hooks/useCallbackRefState";
import { t } from "../i18n";
import {
useExcalidrawContainer,
useDevice,
@ -9,13 +8,14 @@ import {
} from "./App";
import { KEYS } from "../keys";
import "./Dialog.scss";
import { back, CloseIcon } from "./icons";
import { Island } from "./Island";
import { Modal } from "./Modal";
import { queryFocusableElements } from "../utils";
import { useSetAtom } from "jotai";
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
import { jotaiScope } from "../jotai";
import { t } from "../i18n";
import { CloseIcon } from "./icons";
export type DialogSize = number | "small" | "regular" | "wide" | undefined;
@ -58,10 +58,12 @@ export const Dialog = (props: DialogProps) => {
const focusableElements = queryFocusableElements(islandNode);
if (focusableElements.length > 0 && props.autofocus !== false) {
// If there's an element other than close, focus it.
(focusableElements[1] || focusableElements[0]).focus();
}
setTimeout(() => {
if (focusableElements.length > 0 && props.autofocus !== false) {
// If there's an element other than close, focus it.
(focusableElements[1] || focusableElements[0]).focus();
}
});
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === KEYS.TAB) {
@ -115,14 +117,16 @@ export const Dialog = (props: DialogProps) => {
<span className="Dialog__titleContent">{props.title}</span>
</h2>
)}
<button
className="Dialog__close"
onClick={onClose}
title={t("buttons.close")}
aria-label={t("buttons.close")}
>
{isFullscreen ? back : CloseIcon}
</button>
{isFullscreen && (
<button
className="Dialog__close"
onClick={onClose}
title={t("buttons.close")}
aria-label={t("buttons.close")}
>
{CloseIcon}
</button>
)}
<div className="Dialog__content">{props.children}</div>
</Island>
</Modal>

View file

@ -10,6 +10,10 @@
background-color: var(--back-color);
border-color: var(--border-color);
&:hover {
transition: all 150ms ease-out;
}
.Spinner {
--spinner-color: var(--color-surface-lowest);
position: absolute;
@ -203,8 +207,6 @@
user-select: none;
transition: all 150ms ease-out;
&--size-large {
font-weight: 600;
font-size: 0.875rem;

View file

@ -7,6 +7,7 @@ import "./HelpDialog.scss";
import { ExternalLinkIcon } from "./icons";
import { probablySupportsClipboardBlob } from "../clipboard";
import { isDarwin, isFirefox, isWindows } from "../constants";
import { getShortcutFromShortcutName } from "../actions/shortcuts";
const Header = () => (
<div className="HelpDialog__header">
@ -278,6 +279,13 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("stats.title")}
shortcuts={[getShortcutKey("Alt+/")]}
/>
<Shortcut
label={t("commandPalette.title")}
shortcuts={[
getShortcutFromShortcutName("commandPalette"),
getShortcutFromShortcutName("commandPalette", 1),
]}
/>
</ShortcutIsland>
<ShortcutIsland
className="HelpDialog__island--editor"

View file

@ -1,4 +1,4 @@
export const InlineIcon = ({ icon }: { icon: JSX.Element }) => {
export const InlineIcon = ({ icon }: { icon: React.ReactNode }) => {
return (
<span
style={{

View file

@ -23,6 +23,20 @@
.Island {
padding: 2.5rem;
border: 0;
box-shadow: none;
border-radius: 0;
}
&.animations-disabled {
.Modal__background {
animation: none;
}
.Modal__content {
animation: none;
opacity: 1;
}
}
}
@ -35,7 +49,7 @@
z-index: 1;
background-color: rgba(#121212, 0.2);
animation: Modal__background__fade-in 0.125s linear forwards;
animation: Modal__background__fade-in 0.1s linear forwards;
}
.Modal__content {
@ -47,7 +61,8 @@
opacity: 0;
transform: translateY(10px);
animation: Modal__content_fade-in 0.1s ease-out 0.05s forwards;
animation: Modal__content_fade-in 0.025s ease-out 0s forwards;
position: relative;
overflow-y: auto;
@ -56,7 +71,7 @@
border: 1px solid var(--dialog-border-color);
box-shadow: var(--modal-shadow);
border-radius: 6px;
border-radius: 0.75rem;
box-sizing: border-box;
&:focus {

View file

@ -5,6 +5,7 @@ import clsx from "clsx";
import { KEYS } from "../keys";
import { AppState } from "../types";
import { useCreatePortalContainer } from "../hooks/useCreatePortalContainer";
import { useRef } from "react";
export const Modal: React.FC<{
className?: string;
@ -20,6 +21,10 @@ export const Modal: React.FC<{
className: "excalidraw-modal-container",
});
const animationsDisabledRef = useRef(
document.body.classList.contains("excalidraw-animations-disabled"),
);
if (!modalRoot) {
return null;
}
@ -34,7 +39,9 @@ export const Modal: React.FC<{
return createPortal(
<div
className={clsx("Modal", props.className)}
className={clsx("Modal", props.className, {
"animations-disabled": animationsDisabledRef.current,
})}
role="dialog"
aria-modal="true"
onKeyDown={handleKeydown}

View file

@ -42,13 +42,15 @@ const MenuContent = ({
}
};
document.addEventListener(EVENT.KEYDOWN, onKeyDown, {
const option = {
// so that we can stop propagation of the event before it reaches
// event handlers that were bound before this one
capture: true,
});
};
document.addEventListener(EVENT.KEYDOWN, onKeyDown, option);
return () => {
document.removeEventListener(EVENT.KEYDOWN, onKeyDown);
document.removeEventListener(EVENT.KEYDOWN, onKeyDown, option);
};
}, [callbacksRef]);

View file

@ -1,4 +1,4 @@
import { AppState, ExcalidrawProps, Point } from "../../types";
import { AppState, ExcalidrawProps, Point, UIAppState } from "../../types";
import {
sceneCoordsToViewportCoords,
viewportCoordsToSceneCoords,
@ -332,10 +332,10 @@ const getCoordsForPopover = (
export const getContextMenuLabel = (
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
appState: UIAppState,
) => {
const selectedElements = getSelectedElements(elements, appState);
const label = selectedElements[0]!.link
const label = selectedElements[0]?.link
? isEmbeddableElement(selectedElements[0])
? "labels.link.editEmbed"
: "labels.link.edit"

View file

@ -85,7 +85,7 @@ export const PlusPromoIcon = createIcon(
// tabler-icons: book
export const LibraryIcon = createIcon(
<g strokeWidth="1.5">
<g strokeWidth="1.25">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M3 19a9 9 0 0 1 9 0a9 9 0 0 1 9 0" />
<path d="M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0" />
@ -386,6 +386,16 @@ export const ZoomOutIcon = createIcon(
modifiedTablerIconProps,
);
export const ZoomResetIcon = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M21 21l-6 -6" />
<path d="M3.268 12.043a7.017 7.017 0 0 0 6.634 4.957a7.012 7.012 0 0 0 7.043 -6.131a7 7 0 0 0 -5.314 -7.672a7.021 7.021 0 0 0 -8.241 4.403" />
<path d="M3 4v4h4" />
</g>,
tablerIconProps,
);
export const TrashIcon = createIcon(
<path
strokeWidth="1.25"
@ -462,6 +472,16 @@ export const HelpIcon = createIcon(
tablerIconProps,
);
export const HelpIconThin = createIcon(
<g strokeWidth="1.25">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<circle cx="12" cy="12" r="9"></circle>
<line x1="12" y1="17" x2="12" y2="17.01"></line>
<path d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"></path>
</g>,
tablerIconProps,
);
export const ExternalLinkIcon = createIcon(
<path
strokeWidth="1.25"
@ -539,6 +559,16 @@ export const palette = createIcon(
"M204.3 5C104.9 24.4 24.8 104.3 5.2 203.4c-37 187 131.7 326.4 258.8 306.7 41.2-6.4 61.4-54.6 42.5-91.7-23.1-45.4 9.9-98.4 60.9-98.4h79.7c35.8 0 64.8-29.6 64.9-65.3C511.5 97.1 368.1-26.9 204.3 5zM96 320c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm32-128c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128-64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128 64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32z",
);
export const bucketFillIcon = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M5 16l1.465 1.638a2 2 0 1 1 -3.015 .099l1.55 -1.737z" />
<path d="M13.737 9.737c2.299 -2.3 3.23 -5.095 2.081 -6.245c-1.15 -1.15 -3.945 -.217 -6.244 2.082c-2.3 2.299 -3.231 5.095 -2.082 6.244c1.15 1.15 3.946 .218 6.245 -2.081z" />
<path d="M7.492 11.818c.362 .362 .768 .676 1.208 .934l6.895 4.047c1.078 .557 2.255 -.075 3.692 -1.512c1.437 -1.437 2.07 -2.614 1.512 -3.692c-.372 -.718 -1.72 -3.017 -4.047 -6.895a6.015 6.015 0 0 0 -.934 -1.208" />
</g>,
tablerIconProps,
);
export const ExportImageIcon = createIcon(
<g strokeWidth="1.25">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
@ -613,6 +643,16 @@ export const shareIOS = createIcon(
{ width: 24, height: 24 },
);
export const exportToPlus = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M8 9h-1a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-8a2 2 0 0 0 -2 -2h-1" />
<path d="M12 14v-11" />
<path d="M9 6l3 -3l3 3" />
</g>,
tablerIconProps,
);
export const shareWindows = createIcon(
<>
<path
@ -934,11 +974,6 @@ export const CloseIcon = createIcon(
modifiedTablerIconProps,
);
export const back = createIcon(
"M34.52 239.03L228.87 44.69c9.37-9.37 24.57-9.37 33.94 0l22.67 22.67c9.36 9.36 9.37 24.52.04 33.9L131.49 256l154.02 154.75c9.34 9.38 9.32 24.54-.04 33.9l-22.67 22.67c-9.37 9.37-24.57 9.37-33.94 0L34.52 272.97c-9.37-9.37-9.37-24.57 0-33.94z",
{ width: 320, height: 512, style: { marginLeft: "-0.2rem" }, mirror: true },
);
export const clone = createIcon(
"M464 0c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48H176c-26.51 0-48-21.49-48-48V48c0-26.51 21.49-48 48-48h288M176 416c-44.112 0-80-35.888-80-80V128H48c-26.51 0-48 21.49-48 48v288c0 26.51 21.49 48 48 48h288c26.51 0 48-21.49 48-48v-48H176z",
{ mirror: true },
@ -1472,6 +1507,19 @@ export const FontSizeExtraLargeIcon = createIcon(
modifiedTablerIconProps,
);
export const fontSizeIcon = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M3 7v-2h13v2" />
<path d="M10 5v14" />
<path d="M12 19h-4" />
<path d="M15 13v-1h6v1" />
<path d="M18 12v7" />
<path d="M17 19h2" />
</g>,
tablerIconProps,
);
export const FontFamilyNormalIcon = createIcon(
<>
<g
@ -1649,6 +1697,17 @@ export const copyIcon = createIcon(
tablerIconProps,
);
export const cutIcon = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M7 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" />
<path d="M17 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" />
<path d="M9.15 14.85l8.85 -10.85" />
<path d="M6 4l8.85 10.85" />
</g>,
tablerIconProps,
);
export const helpIcon = createIcon(
<>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
@ -1773,6 +1832,17 @@ export const MagicIcon = createIcon(
tablerIconProps,
);
export const MagicIconThin = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" />
<path d="M6 21l15 -15l-3 -3l-15 15l3 3" />
<path d="M15 6l3 3" />
<path d="M9 3a2 2 0 0 0 2 2a2 2 0 0 0 -2 2a2 2 0 0 0 -2 -2a2 2 0 0 0 2 -2" />
<path d="M19 13a2 2 0 0 0 2 2a2 2 0 0 0 -2 2a2 2 0 0 0 -2 -2a2 2 0 0 0 2 -2" />
</g>,
tablerIconProps,
);
export const OpenAIIcon = createIcon(
<g stroke="currentColor" fill="none">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
@ -1829,6 +1899,19 @@ export const brainIcon = createIcon(
tablerIconProps,
);
export const brainIconThin = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M15.5 13a3.5 3.5 0 0 0 -3.5 3.5v1a3.5 3.5 0 0 0 7 0v-1.8" />
<path d="M8.5 13a3.5 3.5 0 0 1 3.5 3.5v1a3.5 3.5 0 0 1 -7 0v-1.8" />
<path d="M17.5 16a3.5 3.5 0 0 0 0 -7h-.5" />
<path d="M19 9.3v-2.8a3.5 3.5 0 0 0 -7 0" />
<path d="M6.5 16a3.5 3.5 0 0 1 0 -7h.5" />
<path d="M5 9.3v-2.8a3.5 3.5 0 0 1 7 0v10" />
</g>,
tablerIconProps,
);
export const searchIcon = createIcon(
<g strokeWidth={1.5}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
@ -1838,6 +1921,16 @@ export const searchIcon = createIcon(
tablerIconProps,
);
export const clockIcon = createIcon(
<g strokeWidth={1.5}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M20.984 12.53a9 9 0 1 0 -7.552 8.355" />
<path d="M12 7v5l3 3" />
<path d="M19 16l-2 3h4l-2 3" />
</g>,
tablerIconProps,
);
export const microphoneIcon = createIcon(
<g strokeWidth={1.5}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
@ -1860,3 +1953,142 @@ export const microphoneMutedIcon = createIcon(
</g>,
tablerIconProps,
);
export const boltIcon = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M13 3l0 7l6 0l-8 11l0 -7l-6 0l8 -11" />
</g>,
tablerIconProps,
);
export const selectAllIcon = createIcon(
<g>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M8 8m0 1a1 1 0 0 1 1 -1h6a1 1 0 0 1 1 1v6a1 1 0 0 1 -1 1h-6a1 1 0 0 1 -1 -1z" />
<path d="M12 20v.01" />
<path d="M16 20v.01" />
<path d="M8 20v.01" />
<path d="M4 20v.01" />
<path d="M4 16v.01" />
<path d="M4 12v.01" />
<path d="M4 8v.01" />
<path d="M4 4v.01" />
<path d="M8 4v.01" />
<path d="M12 4v.01" />
<path d="M16 4v.01" />
<path d="M20 4v.01" />
<path d="M20 8v.01" />
<path d="M20 12v.01" />
<path d="M20 16v.01" />
<path d="M20 20v.01" />
</g>,
tablerIconProps,
);
export const abacusIcon = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M5 3v18" />
<path d="M19 21v-18" />
<path d="M5 7h14" />
<path d="M5 15h14" />
<path d="M8 13v4" />
<path d="M11 13v4" />
<path d="M16 13v4" />
<path d="M14 5v4" />
<path d="M11 5v4" />
<path d="M8 5v4" />
<path d="M3 21h18" />
</g>,
tablerIconProps,
);
export const flipVertical = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M3 12l18 0" />
<path d="M7 16l10 0l-10 5l0 -5" />
<path d="M7 8l10 0l-10 -5l0 5" />
</g>,
tablerIconProps,
);
export const flipHorizontal = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 3l0 18" />
<path d="M16 7l0 10l5 0l-5 -10" />
<path d="M8 7l0 10l-5 0l5 -10" />
</g>,
tablerIconProps,
);
export const paintIcon = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M5 3m0 2a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v2a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z" />
<path d="M19 6h1a2 2 0 0 1 2 2a5 5 0 0 1 -5 5l-5 0v2" />
<path d="M10 15m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z" />
</g>,
tablerIconProps,
);
export const zoomAreaIcon = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M15 15m-5 0a5 5 0 1 0 10 0a5 5 0 1 0 -10 0" />
<path d="M22 22l-3 -3" />
<path d="M6 18h-1a2 2 0 0 1 -2 -2v-1" />
<path d="M3 11v-1" />
<path d="M3 6v-1a2 2 0 0 1 2 -2h1" />
<path d="M10 3h1" />
<path d="M15 3h1a2 2 0 0 1 2 2v1" />
</g>,
tablerIconProps,
);
export const svgIcon = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
<path d="M5 12v-7a2 2 0 0 1 2 -2h7l5 5v4" />
<path d="M4 20.25c0 .414 .336 .75 .75 .75h1.25a1 1 0 0 0 1 -1v-1a1 1 0 0 0 -1 -1h-1a1 1 0 0 1 -1 -1v-1a1 1 0 0 1 1 -1h1.25a.75 .75 0 0 1 .75 .75" />
<path d="M10 15l2 6l2 -6" />
<path d="M20 15h-1a2 2 0 0 0 -2 2v2a2 2 0 0 0 2 2h1v-3" />
</g>,
tablerIconProps,
);
export const pngIcon = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
<path d="M5 12v-7a2 2 0 0 1 2 -2h7l5 5v4" />
<path d="M20 15h-1a2 2 0 0 0 -2 2v2a2 2 0 0 0 2 2h1v-3" />
<path d="M5 18h1.5a1.5 1.5 0 0 0 0 -3h-1.5v6" />
<path d="M11 21v-6l3 6v-6" />
</g>,
tablerIconProps,
);
export const magnetIcon = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4 13v-8a2 2 0 0 1 2 -2h1a2 2 0 0 1 2 2v8a2 2 0 0 0 6 0v-8a2 2 0 0 1 2 -2h1a2 2 0 0 1 2 2v8a8 8 0 0 1 -16 0" />
<path d="M4 8l5 0" />
<path d="M15 8l4 0" />
</g>,
tablerIconProps,
);
export const coffeeIcon = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M3 14c.83 .642 2.077 1.017 3.5 1c1.423 .017 2.67 -.358 3.5 -1c.83 -.642 2.077 -1.017 3.5 -1c1.423 -.017 2.67 .358 3.5 1" />
<path d="M8 3a2.4 2.4 0 0 0 -1 2a2.4 2.4 0 0 0 1 2" />
<path d="M12 3a2.4 2.4 0 0 0 -1 2a2.4 2.4 0 0 0 1 2" />
<path d="M3 10h14v5a6 6 0 0 1 -6 6h-2a6 6 0 0 1 -6 -6v-5z" />
<path d="M16.746 16.726a3 3 0 1 0 .252 -5.555" />
</g>,
tablerIconProps,
);

View file

@ -7,6 +7,7 @@ import {
useAppProps,
} from "../App";
import {
boltIcon,
ExportIcon,
ExportImageIcon,
HelpIcon,
@ -27,8 +28,6 @@ import {
actionShortcuts,
actionToggleTheme,
} from "../../actions";
import "./DefaultItems.scss";
import clsx from "clsx";
import { useSetAtom } from "jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
@ -37,6 +36,8 @@ import { useUIAppState } from "../../context/ui-appState";
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
import Trans from "../Trans";
import "./DefaultItems.scss";
export const LoadScene = () => {
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
@ -117,6 +118,24 @@ export const SaveAsImage = () => {
};
SaveAsImage.displayName = "SaveAsImage";
export const CommandPalette = () => {
const setAppState = useExcalidrawSetAppState();
const { t } = useI18n();
return (
<DropdownMenuItem
icon={boltIcon}
data-testid="command-palette-button"
onSelect={() => setAppState({ openDialog: { name: "commandPalette" } })}
shortcut={getShortcutFromShortcutName("commandPalette")}
aria-label={t("commandPalette.title")}
>
{t("commandPalette.title")}
</DropdownMenuItem>
);
};
CommandPalette.displayName = "CommandPalette";
export const Help = () => {
const { t } = useI18n();