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:
parent
b5e26ba81f
commit
978e85a33b
@ -17,6 +17,5 @@ export const actionAddToLibrary = register({
|
|||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
contextMenuOrder: 6,
|
|
||||||
contextItemLabel: "labels.addToLibrary",
|
contextItemLabel: "labels.addToLibrary",
|
||||||
});
|
});
|
||||||
|
108
src/actions/actionClipboard.tsx
Normal file
108
src/actions/actionClipboard.tsx
Normal 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",
|
||||||
|
});
|
@ -136,7 +136,6 @@ export const actionDeleteSelected = register({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.delete",
|
contextItemLabel: "labels.delete",
|
||||||
contextMenuOrder: 999999,
|
|
||||||
keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
|
keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
|
@ -125,7 +125,6 @@ export const actionGroup = register({
|
|||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextMenuOrder: 4,
|
|
||||||
contextItemLabel: "labels.group",
|
contextItemLabel: "labels.group",
|
||||||
contextItemPredicate: (elements, appState) =>
|
contextItemPredicate: (elements, appState) =>
|
||||||
enableActionGroup(elements, appState),
|
enableActionGroup(elements, appState),
|
||||||
@ -174,7 +173,6 @@ export const actionUngroup = register({
|
|||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
|
event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
|
||||||
contextMenuOrder: 5,
|
|
||||||
contextItemLabel: "labels.ungroup",
|
contextItemLabel: "labels.ungroup",
|
||||||
contextItemPredicate: (elements, appState) =>
|
contextItemPredicate: (elements, appState) =>
|
||||||
getSelectedGroupIds(appState).length > 0,
|
getSelectedGroupIds(appState).length > 0,
|
||||||
|
@ -34,7 +34,6 @@ export const actionCopyStyles = register({
|
|||||||
contextItemLabel: "labels.copyStyles",
|
contextItemLabel: "labels.copyStyles",
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
|
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
|
||||||
contextMenuOrder: 0,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionPasteStyles = register({
|
export const actionPasteStyles = register({
|
||||||
@ -74,5 +73,4 @@ export const actionPasteStyles = register({
|
|||||||
contextItemLabel: "labels.pasteStyles",
|
contextItemLabel: "labels.pasteStyles",
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
|
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
|
||||||
contextMenuOrder: 1,
|
|
||||||
});
|
});
|
||||||
|
21
src/actions/actionToggleGridMode.tsx
Normal file
21
src/actions/actionToggleGridMode.tsx
Normal 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,
|
||||||
|
});
|
17
src/actions/actionToggleStats.tsx
Normal file
17
src/actions/actionToggleStats.tsx
Normal 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",
|
||||||
|
});
|
20
src/actions/actionToggleZenMode.tsx
Normal file
20
src/actions/actionToggleZenMode.tsx
Normal 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,
|
||||||
|
});
|
@ -65,3 +65,15 @@ export {
|
|||||||
distributeHorizontally,
|
distributeHorizontally,
|
||||||
distributeVertically,
|
distributeVertically,
|
||||||
} from "./actionDistribute";
|
} from "./actionDistribute";
|
||||||
|
|
||||||
|
export {
|
||||||
|
actionCopy,
|
||||||
|
actionCut,
|
||||||
|
actionCopyAsPng,
|
||||||
|
actionCopyAsSvg,
|
||||||
|
} from "./actionClipboard";
|
||||||
|
|
||||||
|
export { actionToggleGridMode } from "./actionToggleGridMode";
|
||||||
|
export { actionToggleZenMode } from "./actionToggleZenMode";
|
||||||
|
|
||||||
|
export { actionToggleStats } from "./actionToggleStats";
|
||||||
|
@ -3,14 +3,15 @@ import {
|
|||||||
Action,
|
Action,
|
||||||
ActionsManagerInterface,
|
ActionsManagerInterface,
|
||||||
UpdaterFn,
|
UpdaterFn,
|
||||||
ActionFilterFn,
|
|
||||||
ActionName,
|
ActionName,
|
||||||
ActionResult,
|
ActionResult,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState } from "../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 {
|
export class ActionManager implements ActionsManagerInterface {
|
||||||
actions = {} as ActionsManagerInterface["actions"];
|
actions = {} as ActionsManagerInterface["actions"];
|
||||||
@ -18,13 +19,14 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
|
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
|
||||||
|
|
||||||
getAppState: () => Readonly<AppState>;
|
getAppState: () => Readonly<AppState>;
|
||||||
|
|
||||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
|
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
|
||||||
|
app: App;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
updater: UpdaterFn,
|
updater: UpdaterFn,
|
||||||
getAppState: () => AppState,
|
getAppState: () => AppState,
|
||||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
|
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
|
||||||
|
app: App,
|
||||||
) {
|
) {
|
||||||
this.updater = (actionResult) => {
|
this.updater = (actionResult) => {
|
||||||
if (actionResult && "then" in actionResult) {
|
if (actionResult && "then" in actionResult) {
|
||||||
@ -37,6 +39,7 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
};
|
};
|
||||||
this.getAppState = getAppState;
|
this.getAppState = getAppState;
|
||||||
this.getElementsIncludingDeleted = getElementsIncludingDeleted;
|
this.getElementsIncludingDeleted = getElementsIncludingDeleted;
|
||||||
|
this.app = app;
|
||||||
}
|
}
|
||||||
|
|
||||||
registerAction(action: Action) {
|
registerAction(action: Action) {
|
||||||
@ -70,6 +73,7 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
this.getElementsIncludingDeleted(),
|
this.getElementsIncludingDeleted(),
|
||||||
this.getAppState(),
|
this.getAppState(),
|
||||||
null,
|
null,
|
||||||
|
this.app,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
@ -81,43 +85,11 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
this.getElementsIncludingDeleted(),
|
this.getElementsIncludingDeleted(),
|
||||||
this.getAppState(),
|
this.getAppState(),
|
||||||
null,
|
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.
|
// Id is an attribute that we can use to pass in data like keys.
|
||||||
// This is needed for dynamically generated action components
|
// This is needed for dynamically generated action components
|
||||||
// like the user list. We can use this key to extract more
|
// like the user list. We can use this key to extract more
|
||||||
@ -132,6 +104,7 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
this.getElementsIncludingDeleted(),
|
this.getElementsIncludingDeleted(),
|
||||||
this.getAppState(),
|
this.getAppState(),
|
||||||
formState,
|
formState,
|
||||||
|
this.app,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -9,7 +9,7 @@ export type ShortcutName =
|
|||||||
| "copyStyles"
|
| "copyStyles"
|
||||||
| "pasteStyles"
|
| "pasteStyles"
|
||||||
| "selectAll"
|
| "selectAll"
|
||||||
| "delete"
|
| "deleteSelectedElements"
|
||||||
| "duplicateSelection"
|
| "duplicateSelection"
|
||||||
| "sendBackward"
|
| "sendBackward"
|
||||||
| "bringForward"
|
| "bringForward"
|
||||||
@ -31,7 +31,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
|||||||
copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")],
|
copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")],
|
||||||
pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")],
|
pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")],
|
||||||
selectAll: [getShortcutKey("CtrlOrCmd+A")],
|
selectAll: [getShortcutKey("CtrlOrCmd+A")],
|
||||||
delete: [getShortcutKey("Del")],
|
deleteSelectedElements: [getShortcutKey("Del")],
|
||||||
duplicateSelection: [
|
duplicateSelection: [
|
||||||
getShortcutKey("CtrlOrCmd+D"),
|
getShortcutKey("CtrlOrCmd+D"),
|
||||||
getShortcutKey(`Alt+${t("helpDialog.drag")}`),
|
getShortcutKey(`Alt+${t("helpDialog.drag")}`),
|
||||||
|
@ -16,12 +16,18 @@ type ActionFn = (
|
|||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: Readonly<AppState>,
|
appState: Readonly<AppState>,
|
||||||
formData: any,
|
formData: any,
|
||||||
|
app: { canvas: HTMLCanvasElement | null },
|
||||||
) => ActionResult | Promise<ActionResult>;
|
) => ActionResult | Promise<ActionResult>;
|
||||||
|
|
||||||
export type UpdaterFn = (res: ActionResult) => void;
|
export type UpdaterFn = (res: ActionResult) => void;
|
||||||
export type ActionFilterFn = (action: Action) => void;
|
export type ActionFilterFn = (action: Action) => void;
|
||||||
|
|
||||||
export type ActionName =
|
export type ActionName =
|
||||||
|
| "copy"
|
||||||
|
| "cut"
|
||||||
|
| "paste"
|
||||||
|
| "copyAsPng"
|
||||||
|
| "copyAsSvg"
|
||||||
| "sendBackward"
|
| "sendBackward"
|
||||||
| "bringForward"
|
| "bringForward"
|
||||||
| "sendToBack"
|
| "sendToBack"
|
||||||
@ -29,6 +35,9 @@ export type ActionName =
|
|||||||
| "copyStyles"
|
| "copyStyles"
|
||||||
| "selectAll"
|
| "selectAll"
|
||||||
| "pasteStyles"
|
| "pasteStyles"
|
||||||
|
| "gridMode"
|
||||||
|
| "zenMode"
|
||||||
|
| "stats"
|
||||||
| "changeStrokeColor"
|
| "changeStrokeColor"
|
||||||
| "changeBackgroundColor"
|
| "changeBackgroundColor"
|
||||||
| "changeFillStyle"
|
| "changeFillStyle"
|
||||||
@ -93,19 +102,16 @@ export interface Action {
|
|||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
) => boolean;
|
) => boolean;
|
||||||
contextItemLabel?: string;
|
contextItemLabel?: string;
|
||||||
contextMenuOrder?: number;
|
|
||||||
contextItemPredicate?: (
|
contextItemPredicate?: (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
) => boolean;
|
) => boolean;
|
||||||
|
checked?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActionsManagerInterface {
|
export interface ActionsManagerInterface {
|
||||||
actions: Record<ActionName, Action>;
|
actions: Record<ActionName, Action>;
|
||||||
registerAction: (action: Action) => void;
|
registerAction: (action: Action) => void;
|
||||||
handleKeyDown: (event: KeyboardEvent) => boolean;
|
handleKeyDown: (event: KeyboardEvent) => boolean;
|
||||||
getContextMenuItems: (
|
|
||||||
actionFilter: ActionFilterFn,
|
|
||||||
) => { label: string; action: () => void }[];
|
|
||||||
renderAction: (name: ActionName) => React.ReactElement | null;
|
renderAction: (name: ActionName) => React.ReactElement | null;
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,28 @@ import React from "react";
|
|||||||
import { RoughCanvas } from "roughjs/bin/canvas";
|
import { RoughCanvas } from "roughjs/bin/canvas";
|
||||||
import rough from "roughjs/bin/rough";
|
import rough from "roughjs/bin/rough";
|
||||||
import "../actions";
|
import "../actions";
|
||||||
import { actionDeleteSelected, actionFinalize } from "../actions";
|
import {
|
||||||
|
actionAddToLibrary,
|
||||||
|
actionBringForward,
|
||||||
|
actionBringToFront,
|
||||||
|
actionCopy,
|
||||||
|
actionCopyAsPng,
|
||||||
|
actionCopyAsSvg,
|
||||||
|
actionCopyStyles,
|
||||||
|
actionCut,
|
||||||
|
actionDeleteSelected,
|
||||||
|
actionDuplicateSelection,
|
||||||
|
actionFinalize,
|
||||||
|
actionGroup,
|
||||||
|
actionPasteStyles,
|
||||||
|
actionSelectAll,
|
||||||
|
actionSendBackward,
|
||||||
|
actionSendToBack,
|
||||||
|
actionToggleGridMode,
|
||||||
|
actionToggleStats,
|
||||||
|
actionToggleZenMode,
|
||||||
|
actionUngroup,
|
||||||
|
} from "../actions";
|
||||||
import { createRedoAction, createUndoAction } from "../actions/actionHistory";
|
import { createRedoAction, createUndoAction } from "../actions/actionHistory";
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import { actions } from "../actions/register";
|
import { actions } from "../actions/register";
|
||||||
@ -18,7 +39,6 @@ import {
|
|||||||
} from "../clipboard";
|
} from "../clipboard";
|
||||||
import {
|
import {
|
||||||
APP_NAME,
|
APP_NAME,
|
||||||
CANVAS_ONLY_ACTIONS,
|
|
||||||
CURSOR_TYPE,
|
CURSOR_TYPE,
|
||||||
DEFAULT_VERTICAL_ALIGN,
|
DEFAULT_VERTICAL_ALIGN,
|
||||||
DRAGGING_THRESHOLD,
|
DRAGGING_THRESHOLD,
|
||||||
@ -26,7 +46,6 @@ import {
|
|||||||
ELEMENT_TRANSLATE_AMOUNT,
|
ELEMENT_TRANSLATE_AMOUNT,
|
||||||
ENV,
|
ENV,
|
||||||
EVENT,
|
EVENT,
|
||||||
GRID_SIZE,
|
|
||||||
LINE_CONFIRM_THRESHOLD,
|
LINE_CONFIRM_THRESHOLD,
|
||||||
MIME_TYPES,
|
MIME_TYPES,
|
||||||
POINTER_BUTTON,
|
POINTER_BUTTON,
|
||||||
@ -314,6 +333,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
this.syncActionResult,
|
this.syncActionResult,
|
||||||
() => this.state,
|
() => this.state,
|
||||||
() => this.scene.getElementsIncludingDeleted(),
|
() => this.scene.getElementsIncludingDeleted(),
|
||||||
|
this,
|
||||||
);
|
);
|
||||||
this.actionManager.registerAll(actions);
|
this.actionManager.registerAll(actions);
|
||||||
|
|
||||||
@ -927,25 +947,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private copyToClipboardAsSvg = async () => {
|
|
||||||
const selectedElements = getSelectedElements(
|
|
||||||
this.scene.getElements(),
|
|
||||||
this.state,
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await exportCanvas(
|
|
||||||
"clipboard-svg",
|
|
||||||
selectedElements.length ? selectedElements : this.scene.getElements(),
|
|
||||||
this.state,
|
|
||||||
this.canvas!,
|
|
||||||
this.state,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
this.setState({ errorMessage: error.message });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private static resetTapTwice() {
|
private static resetTapTwice() {
|
||||||
didTapTwice = false;
|
didTapTwice = false;
|
||||||
}
|
}
|
||||||
@ -1148,15 +1149,11 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
toggleZenMode = () => {
|
toggleZenMode = () => {
|
||||||
this.setState({
|
this.actionManager.executeAction(actionToggleZenMode);
|
||||||
zenModeEnabled: !this.state.zenModeEnabled,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
toggleGridMode = () => {
|
toggleGridMode = () => {
|
||||||
this.setState({
|
this.actionManager.executeAction(actionToggleGridMode);
|
||||||
gridSize: this.state.gridSize ? null : GRID_SIZE,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
toggleStats = () => {
|
toggleStats = () => {
|
||||||
@ -3618,52 +3615,52 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
this.state,
|
this.state,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const maybeGroupAction = actionGroup.contextItemPredicate!(
|
||||||
|
this.actionManager.getElementsIncludingDeleted(),
|
||||||
|
this.actionManager.getAppState(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const maybeUngroupAction = actionUngroup.contextItemPredicate!(
|
||||||
|
this.actionManager.getElementsIncludingDeleted(),
|
||||||
|
this.actionManager.getAppState(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const separator = "separator";
|
||||||
|
|
||||||
const elements = this.scene.getElements();
|
const elements = this.scene.getElements();
|
||||||
const element = this.getElementAtPosition(x, y);
|
const element = this.getElementAtPosition(x, y);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
ContextMenu.push({
|
ContextMenu.push({
|
||||||
options: [
|
options: [
|
||||||
navigator.clipboard && {
|
navigator.clipboard && {
|
||||||
shortcutName: "paste",
|
name: "paste",
|
||||||
label: t("labels.paste"),
|
perform: (elements, appStates) => {
|
||||||
action: () => this.pasteFromClipboard(null),
|
this.pasteFromClipboard(null);
|
||||||
|
return {
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
contextItemLabel: "labels.paste",
|
||||||
},
|
},
|
||||||
|
separator,
|
||||||
probablySupportsClipboardBlob &&
|
probablySupportsClipboardBlob &&
|
||||||
elements.length > 0 && {
|
elements.length > 0 &&
|
||||||
shortcutName: "copyAsPng",
|
actionCopyAsPng,
|
||||||
label: t("labels.copyAsPng"),
|
|
||||||
action: this.copyToClipboardAsPng,
|
|
||||||
},
|
|
||||||
probablySupportsClipboardWriteText &&
|
probablySupportsClipboardWriteText &&
|
||||||
elements.length > 0 && {
|
elements.length > 0 &&
|
||||||
shortcutName: "copyAsSvg",
|
actionCopyAsSvg,
|
||||||
label: t("labels.copyAsSvg"),
|
((probablySupportsClipboardBlob && elements.length > 0) ||
|
||||||
action: this.copyToClipboardAsSvg,
|
(probablySupportsClipboardWriteText && elements.length > 0)) &&
|
||||||
},
|
separator,
|
||||||
...this.actionManager.getContextMenuItems((action) =>
|
actionSelectAll,
|
||||||
CANVAS_ONLY_ACTIONS.includes(action.name),
|
separator,
|
||||||
),
|
actionToggleGridMode,
|
||||||
{
|
actionToggleZenMode,
|
||||||
checked: this.state.gridSize !== null,
|
actionToggleStats,
|
||||||
shortcutName: "gridMode",
|
|
||||||
label: t("labels.gridMode"),
|
|
||||||
action: this.toggleGridMode,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
checked: this.state.zenModeEnabled,
|
|
||||||
shortcutName: "zenMode",
|
|
||||||
label: t("buttons.zenMode"),
|
|
||||||
action: this.toggleZenMode,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
checked: this.state.showStats,
|
|
||||||
shortcutName: "stats",
|
|
||||||
label: t("stats.title"),
|
|
||||||
action: this.toggleStats,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
top: clientY,
|
top: clientY,
|
||||||
left: clientX,
|
left: clientX,
|
||||||
|
actionManager: this.actionManager,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -3674,37 +3671,41 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
|
|
||||||
ContextMenu.push({
|
ContextMenu.push({
|
||||||
options: [
|
options: [
|
||||||
{
|
actionCut,
|
||||||
shortcutName: "cut",
|
navigator.clipboard && actionCopy,
|
||||||
label: t("labels.cut"),
|
|
||||||
action: this.cutAll,
|
|
||||||
},
|
|
||||||
navigator.clipboard && {
|
navigator.clipboard && {
|
||||||
shortcutName: "copy",
|
name: "paste",
|
||||||
label: t("labels.copy"),
|
perform: (elements, appStates) => {
|
||||||
action: this.copyAll,
|
this.pasteFromClipboard(null);
|
||||||
|
return {
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
contextItemLabel: "labels.paste",
|
||||||
},
|
},
|
||||||
navigator.clipboard && {
|
separator,
|
||||||
shortcutName: "paste",
|
probablySupportsClipboardBlob && actionCopyAsPng,
|
||||||
label: t("labels.paste"),
|
probablySupportsClipboardWriteText && actionCopyAsSvg,
|
||||||
action: () => this.pasteFromClipboard(null),
|
separator,
|
||||||
},
|
actionCopyStyles,
|
||||||
probablySupportsClipboardBlob && {
|
actionPasteStyles,
|
||||||
shortcutName: "copyAsPng",
|
separator,
|
||||||
label: t("labels.copyAsPng"),
|
maybeGroupAction && actionGroup,
|
||||||
action: this.copyToClipboardAsPng,
|
maybeUngroupAction && actionUngroup,
|
||||||
},
|
(maybeGroupAction || maybeUngroupAction) && separator,
|
||||||
probablySupportsClipboardWriteText && {
|
actionAddToLibrary,
|
||||||
shortcutName: "copyAsSvg",
|
separator,
|
||||||
label: t("labels.copyAsSvg"),
|
actionSendBackward,
|
||||||
action: this.copyToClipboardAsSvg,
|
actionBringForward,
|
||||||
},
|
actionSendToBack,
|
||||||
...this.actionManager.getContextMenuItems(
|
actionBringToFront,
|
||||||
(action) => !CANVAS_ONLY_ACTIONS.includes(action.name),
|
separator,
|
||||||
),
|
actionDuplicateSelection,
|
||||||
|
actionDeleteSelected,
|
||||||
],
|
],
|
||||||
top: clientY,
|
top: clientY,
|
||||||
left: clientX,
|
left: clientX,
|
||||||
|
actionManager: this.actionManager,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -9,9 +9,10 @@
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
margin: -0.25rem 0 0 0.125rem;
|
margin: -0.25rem 0 0 0.125rem;
|
||||||
padding: 0.25rem 0;
|
padding: 0.5rem 0;
|
||||||
background-color: var(--popup-secondary-background-color);
|
background-color: var(--popup-secondary-background-color);
|
||||||
border: 1px solid var(--button-gray-3);
|
border: 1px solid var(--button-gray-3);
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-menu button {
|
.context-menu button {
|
||||||
@ -88,4 +89,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.context-menu-option-separator {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid $oc-gray-5;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,31 +2,37 @@ import React from "react";
|
|||||||
import { render, unmountComponentAtNode } from "react-dom";
|
import { render, unmountComponentAtNode } from "react-dom";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Popover } from "./Popover";
|
import { Popover } from "./Popover";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
import "./ContextMenu.scss";
|
import "./ContextMenu.scss";
|
||||||
import {
|
import {
|
||||||
getShortcutFromShortcutName,
|
getShortcutFromShortcutName,
|
||||||
ShortcutName,
|
ShortcutName,
|
||||||
} from "../actions/shortcuts";
|
} from "../actions/shortcuts";
|
||||||
|
import { Action } from "../actions/types";
|
||||||
|
import { ActionManager } from "../actions/manager";
|
||||||
|
|
||||||
type ContextMenuOption = {
|
type ContextMenuOption = "separator" | Action;
|
||||||
checked?: boolean;
|
|
||||||
shortcutName: ShortcutName;
|
|
||||||
label: string;
|
|
||||||
action(): void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
type ContextMenuProps = {
|
||||||
options: ContextMenuOption[];
|
options: ContextMenuOption[];
|
||||||
onCloseRequest?(): void;
|
onCloseRequest?(): void;
|
||||||
top: number;
|
top: number;
|
||||||
left: number;
|
left: number;
|
||||||
|
actionManager: ActionManager;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => {
|
const ContextMenu = ({
|
||||||
|
options,
|
||||||
|
onCloseRequest,
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
actionManager,
|
||||||
|
}: ContextMenuProps) => {
|
||||||
const isDarkTheme = !!document
|
const isDarkTheme = !!document
|
||||||
.querySelector(".excalidraw")
|
.querySelector(".excalidraw")
|
||||||
?.classList.contains("Appearance_dark");
|
?.classList.contains("Appearance_dark");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx("excalidraw", {
|
className={clsx("excalidraw", {
|
||||||
@ -43,23 +49,33 @@ const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => {
|
|||||||
className="context-menu"
|
className="context-menu"
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
{options.map(({ action, checked, shortcutName, label }, idx) => (
|
{options.map((option, idx) => {
|
||||||
<li data-testid={shortcutName} key={idx} onClick={onCloseRequest}>
|
if (option === "separator") {
|
||||||
<button
|
return <hr key={idx} className="context-menu-option-separator" />;
|
||||||
className={`context-menu-option
|
}
|
||||||
${shortcutName === "delete" ? "dangerous" : ""}
|
|
||||||
${checked ? "checkmark" : ""}`}
|
const actionName = option.name;
|
||||||
onClick={action}
|
const label = option.contextItemLabel
|
||||||
>
|
? t(option.contextItemLabel)
|
||||||
<div className="context-menu-option__label">{label}</div>
|
: "";
|
||||||
<kbd className="context-menu-option__shortcut">
|
return (
|
||||||
{shortcutName
|
<li key={idx} data-testid={actionName} onClick={onCloseRequest}>
|
||||||
? getShortcutFromShortcutName(shortcutName)
|
<button
|
||||||
: ""}
|
className={`context-menu-option
|
||||||
</kbd>
|
${actionName === "deleteSelectedElements" ? "dangerous" : ""}
|
||||||
</button>
|
${option.checked ? "checkmark" : ""}`}
|
||||||
</li>
|
onClick={() => actionManager.executeAction(option)}
|
||||||
))}
|
>
|
||||||
|
<div className="context-menu-option__label">{label}</div>
|
||||||
|
<kbd className="context-menu-option__shortcut">
|
||||||
|
{actionName
|
||||||
|
? getShortcutFromShortcutName(actionName as ShortcutName)
|
||||||
|
: ""}
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
@ -78,8 +94,9 @@ const getContextMenuNode = (): HTMLDivElement => {
|
|||||||
|
|
||||||
type ContextMenuParams = {
|
type ContextMenuParams = {
|
||||||
options: (ContextMenuOption | false | null | undefined)[];
|
options: (ContextMenuOption | false | null | undefined)[];
|
||||||
top: number;
|
top: ContextMenuProps["top"];
|
||||||
left: number;
|
left: ContextMenuProps["left"];
|
||||||
|
actionManager: ContextMenuProps["actionManager"];
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
@ -101,6 +118,7 @@ export default {
|
|||||||
left={params.left}
|
left={params.left}
|
||||||
options={options}
|
options={options}
|
||||||
onCloseRequest={handleClose}
|
onCloseRequest={handleClose}
|
||||||
|
actionManager={params.actionManager}
|
||||||
/>,
|
/>,
|
||||||
getContextMenuNode(),
|
getContextMenuNode(),
|
||||||
);
|
);
|
||||||
|
@ -12,7 +12,7 @@ export const IsMobileProvider = ({
|
|||||||
query.current = window.matchMedia
|
query.current = window.matchMedia
|
||||||
? window.matchMedia(
|
? window.matchMedia(
|
||||||
// keep up to date with _variables.scss
|
// keep up to date with _variables.scss
|
||||||
"(max-width: 640px), (max-height: 500px) and (max-width: 1000px)",
|
"(max-width: 600px), (max-height: 500px) and (max-width: 1000px)",
|
||||||
)
|
)
|
||||||
: (({
|
: (({
|
||||||
matches: false,
|
matches: false,
|
||||||
|
@ -19,6 +19,7 @@ export const CODES = {
|
|||||||
F: "KeyF",
|
F: "KeyF",
|
||||||
H: "KeyH",
|
H: "KeyH",
|
||||||
V: "KeyV",
|
V: "KeyV",
|
||||||
|
X: "KeyX",
|
||||||
Z: "KeyZ",
|
Z: "KeyZ",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
@ -618,6 +618,7 @@ describe("regression tests", () => {
|
|||||||
clientY: 1,
|
clientY: 1,
|
||||||
});
|
});
|
||||||
const contextMenu = document.querySelector(".context-menu");
|
const contextMenu = document.querySelector(".context-menu");
|
||||||
|
const contextMenuOptions = document.querySelectorAll(".context-menu li");
|
||||||
const expectedShortcutNames: ShortcutName[] = [
|
const expectedShortcutNames: ShortcutName[] = [
|
||||||
"selectAll",
|
"selectAll",
|
||||||
"gridMode",
|
"gridMode",
|
||||||
@ -626,7 +627,7 @@ describe("regression tests", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
expect(contextMenu).not.toBeNull();
|
expect(contextMenu).not.toBeNull();
|
||||||
expect(contextMenu?.children.length).toBe(expectedShortcutNames.length);
|
expect(contextMenuOptions.length).toBe(expectedShortcutNames.length);
|
||||||
expectedShortcutNames.forEach((shortcutName) => {
|
expectedShortcutNames.forEach((shortcutName) => {
|
||||||
expect(
|
expect(
|
||||||
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
|
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
|
||||||
@ -645,11 +646,12 @@ describe("regression tests", () => {
|
|||||||
clientY: 1,
|
clientY: 1,
|
||||||
});
|
});
|
||||||
const contextMenu = document.querySelector(".context-menu");
|
const contextMenu = document.querySelector(".context-menu");
|
||||||
|
const contextMenuOptions = document.querySelectorAll(".context-menu li");
|
||||||
const expectedShortcutNames: ShortcutName[] = [
|
const expectedShortcutNames: ShortcutName[] = [
|
||||||
"cut",
|
"cut",
|
||||||
"copyStyles",
|
"copyStyles",
|
||||||
"pasteStyles",
|
"pasteStyles",
|
||||||
"delete",
|
"deleteSelectedElements",
|
||||||
"addToLibrary",
|
"addToLibrary",
|
||||||
"sendBackward",
|
"sendBackward",
|
||||||
"bringForward",
|
"bringForward",
|
||||||
@ -659,7 +661,7 @@ describe("regression tests", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
expect(contextMenu).not.toBeNull();
|
expect(contextMenu).not.toBeNull();
|
||||||
expect(contextMenu?.children.length).toBe(expectedShortcutNames.length);
|
expect(contextMenuOptions.length).toBe(expectedShortcutNames.length);
|
||||||
expectedShortcutNames.forEach((shortcutName) => {
|
expectedShortcutNames.forEach((shortcutName) => {
|
||||||
expect(
|
expect(
|
||||||
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
|
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
|
||||||
@ -689,11 +691,12 @@ describe("regression tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const contextMenu = document.querySelector(".context-menu");
|
const contextMenu = document.querySelector(".context-menu");
|
||||||
|
const contextMenuOptions = document.querySelectorAll(".context-menu li");
|
||||||
const expectedShortcutNames: ShortcutName[] = [
|
const expectedShortcutNames: ShortcutName[] = [
|
||||||
"cut",
|
"cut",
|
||||||
"copyStyles",
|
"copyStyles",
|
||||||
"pasteStyles",
|
"pasteStyles",
|
||||||
"delete",
|
"deleteSelectedElements",
|
||||||
"group",
|
"group",
|
||||||
"addToLibrary",
|
"addToLibrary",
|
||||||
"sendBackward",
|
"sendBackward",
|
||||||
@ -704,7 +707,7 @@ describe("regression tests", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
expect(contextMenu).not.toBeNull();
|
expect(contextMenu).not.toBeNull();
|
||||||
expect(contextMenu?.children.length).toBe(expectedShortcutNames.length);
|
expect(contextMenuOptions.length).toBe(expectedShortcutNames.length);
|
||||||
expectedShortcutNames.forEach((shortcutName) => {
|
expectedShortcutNames.forEach((shortcutName) => {
|
||||||
expect(
|
expect(
|
||||||
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
|
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
|
||||||
@ -738,11 +741,12 @@ describe("regression tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const contextMenu = document.querySelector(".context-menu");
|
const contextMenu = document.querySelector(".context-menu");
|
||||||
|
const contextMenuOptions = document.querySelectorAll(".context-menu li");
|
||||||
const expectedShortcutNames: ShortcutName[] = [
|
const expectedShortcutNames: ShortcutName[] = [
|
||||||
"cut",
|
"cut",
|
||||||
"copyStyles",
|
"copyStyles",
|
||||||
"pasteStyles",
|
"pasteStyles",
|
||||||
"delete",
|
"deleteSelectedElements",
|
||||||
"ungroup",
|
"ungroup",
|
||||||
"addToLibrary",
|
"addToLibrary",
|
||||||
"sendBackward",
|
"sendBackward",
|
||||||
@ -753,7 +757,7 @@ describe("regression tests", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
expect(contextMenu).not.toBeNull();
|
expect(contextMenu).not.toBeNull();
|
||||||
expect(contextMenu?.children.length).toBe(expectedShortcutNames.length);
|
expect(contextMenuOptions.length).toBe(expectedShortcutNames.length);
|
||||||
expectedShortcutNames.forEach((shortcutName) => {
|
expectedShortcutNames.forEach((shortcutName) => {
|
||||||
expect(
|
expect(
|
||||||
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
|
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
|
||||||
|
Loading…
x
Reference in New Issue
Block a user