feat: Add separators on context menu (#2659)

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
Co-authored-by: Lipis <lipiridis@gmail.com>
This commit is contained in:
Kartik Prajapati 2021-01-28 00:41:17 +05:30 committed by GitHub
parent b5e26ba81f
commit 978e85a33b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 354 additions and 173 deletions

View file

@ -17,6 +17,5 @@ export const actionAddToLibrary = register({
});
return false;
},
contextMenuOrder: 6,
contextItemLabel: "labels.addToLibrary",
});

View file

@ -0,0 +1,108 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { copyToClipboard } from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected";
import { getSelectedElements } from "../scene/selection";
import { exportCanvas } from "../data/index";
import { getNonDeletedElements } from "../element";
export const actionCopy = register({
name: "copy",
perform: (elements, appState) => {
copyToClipboard(getNonDeletedElements(elements), appState);
return {
commitToHistory: false,
};
},
contextItemLabel: "labels.copy",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.C,
});
export const actionCut = register({
name: "cut",
perform: (elements, appState, data, app) => {
actionCopy.perform(elements, appState, data, app);
return actionDeleteSelected.perform(elements, appState, data, app);
},
contextItemLabel: "labels.cut",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.X,
});
export const actionCopyAsSvg = register({
name: "copyAsSvg",
perform: async (elements, appState, _data, app) => {
if (!app.canvas) {
return {
commitToHistory: false,
};
}
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
try {
await exportCanvas(
"clipboard-svg",
selectedElements.length
? selectedElements
: getNonDeletedElements(elements),
appState,
app.canvas,
appState,
);
return {
commitToHistory: false,
};
} catch (error) {
console.error(error);
return {
appState: {
...appState,
errorMessage: error.message,
},
commitToHistory: false,
};
}
},
contextItemLabel: "labels.copyAsSvg",
});
export const actionCopyAsPng = register({
name: "copyAsPng",
perform: async (elements, appState, _data, app) => {
if (!app.canvas) {
return {
commitToHistory: false,
};
}
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
try {
await exportCanvas(
"clipboard",
selectedElements.length
? selectedElements
: getNonDeletedElements(elements),
appState,
app.canvas,
appState,
);
return {
commitToHistory: false,
};
} catch (error) {
console.error(error);
return {
appState: {
...appState,
errorMessage: error.message,
},
commitToHistory: false,
};
}
},
contextItemLabel: "labels.copyAsPng",
});

View file

@ -136,7 +136,6 @@ export const actionDeleteSelected = register({
};
},
contextItemLabel: "labels.delete",
contextMenuOrder: 999999,
keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton

View file

@ -125,7 +125,6 @@ export const actionGroup = register({
commitToHistory: true,
};
},
contextMenuOrder: 4,
contextItemLabel: "labels.group",
contextItemPredicate: (elements, appState) =>
enableActionGroup(elements, appState),
@ -174,7 +173,6 @@ export const actionUngroup = register({
},
keyTest: (event) =>
event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
contextMenuOrder: 5,
contextItemLabel: "labels.ungroup",
contextItemPredicate: (elements, appState) =>
getSelectedGroupIds(appState).length > 0,

View file

@ -34,7 +34,6 @@ export const actionCopyStyles = register({
contextItemLabel: "labels.copyStyles",
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
contextMenuOrder: 0,
});
export const actionPasteStyles = register({
@ -74,5 +73,4 @@ export const actionPasteStyles = register({
contextItemLabel: "labels.pasteStyles",
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
contextMenuOrder: 1,
});

View file

@ -0,0 +1,21 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { GRID_SIZE } from "../constants";
export const actionToggleGridMode = register({
name: "gridMode",
perform(elements, appState) {
this.checked = !this.checked;
return {
appState: {
...appState,
gridSize: this.checked ? GRID_SIZE : null,
},
commitToHistory: false,
};
},
checked: false,
contextItemLabel: "labels.gridMode",
// Wrong event code
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
});

View file

@ -0,0 +1,17 @@
import { register } from "./register";
export const actionToggleStats = register({
name: "stats",
perform(elements, appState) {
this.checked = !this.checked;
return {
appState: {
...appState,
showStats: !appState.showStats,
},
commitToHistory: false,
};
},
checked: false,
contextItemLabel: "stats.title",
});

View file

@ -0,0 +1,20 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
export const actionToggleZenMode = register({
name: "zenMode",
perform(elements, appState) {
this.checked = !this.checked;
return {
appState: {
...appState,
zenModeEnabled: this.checked,
},
commitToHistory: false,
};
},
checked: false,
contextItemLabel: "buttons.zenMode",
// Wrong event code
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
});

View file

@ -65,3 +65,15 @@ export {
distributeHorizontally,
distributeVertically,
} from "./actionDistribute";
export {
actionCopy,
actionCut,
actionCopyAsPng,
actionCopyAsSvg,
} from "./actionClipboard";
export { actionToggleGridMode } from "./actionToggleGridMode";
export { actionToggleZenMode } from "./actionToggleZenMode";
export { actionToggleStats } from "./actionToggleStats";

View file

@ -3,14 +3,15 @@ import {
Action,
ActionsManagerInterface,
UpdaterFn,
ActionFilterFn,
ActionName,
ActionResult,
} from "./types";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { t } from "../i18n";
import { ShortcutName } from "./shortcuts";
// This is the <App> component, but for now we don't care about anything but its
// `canvas` state.
type App = { canvas: HTMLCanvasElement | null };
export class ActionManager implements ActionsManagerInterface {
actions = {} as ActionsManagerInterface["actions"];
@ -18,13 +19,14 @@ export class ActionManager implements ActionsManagerInterface {
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
getAppState: () => Readonly<AppState>;
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
app: App;
constructor(
updater: UpdaterFn,
getAppState: () => AppState,
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
app: App,
) {
this.updater = (actionResult) => {
if (actionResult && "then" in actionResult) {
@ -37,6 +39,7 @@ export class ActionManager implements ActionsManagerInterface {
};
this.getAppState = getAppState;
this.getElementsIncludingDeleted = getElementsIncludingDeleted;
this.app = app;
}
registerAction(action: Action) {
@ -70,6 +73,7 @@ export class ActionManager implements ActionsManagerInterface {
this.getElementsIncludingDeleted(),
this.getAppState(),
null,
this.app,
),
);
return true;
@ -81,43 +85,11 @@ export class ActionManager implements ActionsManagerInterface {
this.getElementsIncludingDeleted(),
this.getAppState(),
null,
this.app,
),
);
}
getContextMenuItems(actionFilter: ActionFilterFn = (action) => action) {
return Object.values(this.actions)
.filter(actionFilter)
.filter((action) => "contextItemLabel" in action)
.filter((action) =>
action.contextItemPredicate
? action.contextItemPredicate(
this.getElementsIncludingDeleted(),
this.getAppState(),
)
: true,
)
.sort(
(a, b) =>
(a.contextMenuOrder !== undefined ? a.contextMenuOrder : 999) -
(b.contextMenuOrder !== undefined ? b.contextMenuOrder : 999),
)
.map((action) => ({
// take last bit of the label "labels.<shortcutName>"
shortcutName: action.contextItemLabel?.split(".").pop() as ShortcutName,
label: action.contextItemLabel ? t(action.contextItemLabel) : "",
action: () => {
this.updater(
action.perform(
this.getElementsIncludingDeleted(),
this.getAppState(),
null,
),
);
},
}));
}
// Id is an attribute that we can use to pass in data like keys.
// This is needed for dynamically generated action components
// like the user list. We can use this key to extract more
@ -132,6 +104,7 @@ export class ActionManager implements ActionsManagerInterface {
this.getElementsIncludingDeleted(),
this.getAppState(),
formState,
this.app,
),
);
};

View file

@ -9,7 +9,7 @@ export type ShortcutName =
| "copyStyles"
| "pasteStyles"
| "selectAll"
| "delete"
| "deleteSelectedElements"
| "duplicateSelection"
| "sendBackward"
| "bringForward"
@ -31,7 +31,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")],
pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")],
selectAll: [getShortcutKey("CtrlOrCmd+A")],
delete: [getShortcutKey("Del")],
deleteSelectedElements: [getShortcutKey("Del")],
duplicateSelection: [
getShortcutKey("CtrlOrCmd+D"),
getShortcutKey(`Alt+${t("helpDialog.drag")}`),

View file

@ -16,12 +16,18 @@ type ActionFn = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
formData: any,
app: { canvas: HTMLCanvasElement | null },
) => ActionResult | Promise<ActionResult>;
export type UpdaterFn = (res: ActionResult) => void;
export type ActionFilterFn = (action: Action) => void;
export type ActionName =
| "copy"
| "cut"
| "paste"
| "copyAsPng"
| "copyAsSvg"
| "sendBackward"
| "bringForward"
| "sendToBack"
@ -29,6 +35,9 @@ export type ActionName =
| "copyStyles"
| "selectAll"
| "pasteStyles"
| "gridMode"
| "zenMode"
| "stats"
| "changeStrokeColor"
| "changeBackgroundColor"
| "changeFillStyle"
@ -93,19 +102,16 @@ export interface Action {
elements: readonly ExcalidrawElement[],
) => boolean;
contextItemLabel?: string;
contextMenuOrder?: number;
contextItemPredicate?: (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => boolean;
checked?: boolean;
}
export interface ActionsManagerInterface {
actions: Record<ActionName, Action>;
registerAction: (action: Action) => void;
handleKeyDown: (event: KeyboardEvent) => boolean;
getContextMenuItems: (
actionFilter: ActionFilterFn,
) => { label: string; action: () => void }[];
renderAction: (name: ActionName) => React.ReactElement | null;
}