Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 091123286b | |||
| 1db078a3dc | |||
| d4afd66268 | |||
| 849e6a0c86 | |||
| f03f5c948d | |||
| d2b698093c | |||
| 0f1720be61 | |||
| d0b33d35db | |||
| d6a5ef1936 | |||
| c7a11f5cd2 | |||
| 893c487add | |||
| 99fdffdab7 | |||
| faad8a65f1 | |||
| 9d04479f98 | |||
| 599a8f3c6f | |||
| 0982da38fe | |||
| 699897f71b | |||
| 328ff6c32d | |||
| 618442299f | |||
| 06b45e0cfc | |||
| 809d5ba17f | |||
| 40d53d9231 | |||
| 9803a85381 | |||
| 72784f9d29 | |||
| e3249f930c | |||
| cbe0d34f1a | |||
| bed8093e47 | |||
| 1255ca2e84 | |||
| 14d02dcaea | |||
| 9747223705 | |||
| 0f11f7da15 | |||
| 8420aecb34 |
+1
-1
@@ -1,2 +1,2 @@
|
||||
#!/bin/sh
|
||||
yarn lint-staged
|
||||
# yarn lint-staged
|
||||
|
||||
+3
-3
@@ -4692,9 +4692,9 @@ json-schema-traverse@^1.0.0:
|
||||
integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==
|
||||
|
||||
json5@^2.1.2, json5@^2.2.1:
|
||||
version "2.2.1"
|
||||
resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.1.tgz#655d50ed1e6f95ad1a3caababd2b0efda10b395c"
|
||||
integrity sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==
|
||||
version "2.2.3"
|
||||
resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283"
|
||||
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
|
||||
|
||||
jsonfile@^6.0.1:
|
||||
version "6.1.0"
|
||||
|
||||
@@ -26,7 +26,7 @@ export const actionUnbindText = register({
|
||||
name: "unbindText",
|
||||
contextItemLabel: "labels.unbindText",
|
||||
trackEvent: { category: "element" },
|
||||
contextItemPredicate: (elements, appState) => {
|
||||
predicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
return selectedElements.some((element) => hasBoundTextElement(element));
|
||||
},
|
||||
@@ -76,7 +76,7 @@ export const actionBindText = register({
|
||||
name: "bindText",
|
||||
contextItemLabel: "labels.bindText",
|
||||
trackEvent: { category: "element" },
|
||||
contextItemPredicate: (elements, appState) => {
|
||||
predicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
|
||||
if (selectedElements.length === 2) {
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { ColorPicker } from "../components/ColorPicker";
|
||||
import {
|
||||
eraser,
|
||||
MoonIcon,
|
||||
SunIcon,
|
||||
ZoomInIcon,
|
||||
ZoomOutIcon,
|
||||
} from "../components/icons";
|
||||
import { ZoomInIcon, ZoomOutIcon } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
|
||||
import { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
|
||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
@@ -16,19 +10,25 @@ import { getNormalizedZoom, getSelectedElements } from "../scene";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { getStateForZoom } from "../scene/zoom";
|
||||
import { AppState, NormalizedZoomValue } from "../types";
|
||||
import { getShortcutKey, updateActiveTool } from "../utils";
|
||||
import { getShortcutKey, setCursor, updateActiveTool } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { getDefaultAppState, isEraserActive } from "../appState";
|
||||
import ClearCanvas from "../components/ClearCanvas";
|
||||
import clsx from "clsx";
|
||||
import MenuItem from "../components/MenuItem";
|
||||
import { getShortcutFromShortcutName } from "./shortcuts";
|
||||
import {
|
||||
getDefaultAppState,
|
||||
isEraserActive,
|
||||
isHandToolActive,
|
||||
} from "../appState";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
name: "changeViewBackgroundColor",
|
||||
trackEvent: false,
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return (
|
||||
!!app.props.UIOptions.canvasActions.changeViewBackgroundColor &&
|
||||
!appState.viewModeEnabled
|
||||
);
|
||||
},
|
||||
perform: (_, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, ...value },
|
||||
@@ -36,6 +36,7 @@ export const actionChangeViewBackgroundColor = register({
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
// FIXME move me to src/components/mainMenu/DefaultItems.tsx
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<ColorPicker
|
||||
@@ -59,6 +60,12 @@ export const actionChangeViewBackgroundColor = register({
|
||||
export const actionClearCanvas = register({
|
||||
name: "clearCanvas",
|
||||
trackEvent: { category: "canvas" },
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return (
|
||||
!!app.props.UIOptions.canvasActions.clearCanvas &&
|
||||
!appState.viewModeEnabled
|
||||
);
|
||||
},
|
||||
perform: (elements, appState, _, app) => {
|
||||
app.imageCache.clear();
|
||||
return {
|
||||
@@ -84,8 +91,6 @@ export const actionClearCanvas = register({
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
|
||||
PanelComponent: ({ updateData }) => <ClearCanvas onConfirm={updateData} />,
|
||||
});
|
||||
|
||||
export const actionZoomIn = register({
|
||||
@@ -298,33 +303,21 @@ export const actionToggleTheme = register({
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<MenuItem
|
||||
label={
|
||||
appState.theme === "dark"
|
||||
? t("buttons.lightMode")
|
||||
: t("buttons.darkMode")
|
||||
}
|
||||
onClick={() => {
|
||||
updateData(appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT);
|
||||
}}
|
||||
icon={appState.theme === "dark" ? SunIcon : MoonIcon}
|
||||
dataTestId="toggle-dark-mode"
|
||||
shortcut={getShortcutFromShortcutName("toggleTheme")}
|
||||
/>
|
||||
),
|
||||
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return !!app.props.UIOptions.canvasActions.toggleTheme;
|
||||
},
|
||||
});
|
||||
|
||||
export const actionErase = register({
|
||||
name: "eraser",
|
||||
export const actionToggleEraserTool = register({
|
||||
name: "toggleEraserTool",
|
||||
trackEvent: { category: "toolbar" },
|
||||
perform: (elements, appState) => {
|
||||
let activeTool: AppState["activeTool"];
|
||||
|
||||
if (isEraserActive(appState)) {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
...(appState.activeTool.lastActiveToolBeforeEraser || {
|
||||
...(appState.activeTool.lastActiveTool || {
|
||||
type: "selection",
|
||||
}),
|
||||
lastActiveToolBeforeEraser: null,
|
||||
@@ -347,17 +340,38 @@ export const actionErase = register({
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.key === KEYS.E,
|
||||
PanelComponent: ({ elements, appState, updateData, data }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={eraser}
|
||||
className={clsx("eraser", { active: isEraserActive(appState) })}
|
||||
title={`${t("toolBar.eraser")}-${getShortcutKey("E")}`}
|
||||
aria-label={t("toolBar.eraser")}
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
size={data?.size || "medium"}
|
||||
></ToolButton>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionToggleHandTool = register({
|
||||
name: "toggleHandTool",
|
||||
trackEvent: { category: "toolbar" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
let activeTool: AppState["activeTool"];
|
||||
|
||||
if (isHandToolActive(appState)) {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
...(appState.activeTool.lastActiveTool || {
|
||||
type: "selection",
|
||||
}),
|
||||
lastActiveToolBeforeEraser: null,
|
||||
});
|
||||
} else {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
type: "hand",
|
||||
lastActiveToolBeforeEraser: appState.activeTool,
|
||||
});
|
||||
setCursor(app.canvas, CURSOR_TYPE.GRAB);
|
||||
}
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
activeTool,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.key === KEYS.H,
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ export const actionCopy = register({
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemPredicate: (elements, appState, appProps, app) => {
|
||||
predicate: (elements, appState, appProps, app) => {
|
||||
return app.device.isMobile && !!navigator.clipboard;
|
||||
},
|
||||
contextItemLabel: "labels.copy",
|
||||
@@ -41,7 +41,7 @@ export const actionPaste = register({
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemPredicate: (elements, appState, appProps, app) => {
|
||||
predicate: (elements, appState, appProps, app) => {
|
||||
return app.device.isMobile && !!navigator.clipboard;
|
||||
},
|
||||
contextItemLabel: "labels.paste",
|
||||
@@ -56,7 +56,7 @@ export const actionCut = register({
|
||||
actionCopy.perform(elements, appState, data, app);
|
||||
return actionDeleteSelected.perform(elements, appState);
|
||||
},
|
||||
contextItemPredicate: (elements, appState, appProps, app) => {
|
||||
predicate: (elements, appState, appProps, app) => {
|
||||
return app.device.isMobile && !!navigator.clipboard;
|
||||
},
|
||||
contextItemLabel: "labels.cut",
|
||||
@@ -101,7 +101,7 @@ export const actionCopyAsSvg = register({
|
||||
};
|
||||
}
|
||||
},
|
||||
contextItemPredicate: (elements) => {
|
||||
predicate: (elements) => {
|
||||
return probablySupportsClipboardWriteText && elements.length > 0;
|
||||
},
|
||||
contextItemLabel: "labels.copyAsSvg",
|
||||
@@ -158,7 +158,7 @@ export const actionCopyAsPng = register({
|
||||
};
|
||||
}
|
||||
},
|
||||
contextItemPredicate: (elements) => {
|
||||
predicate: (elements) => {
|
||||
return probablySupportsClipboardBlob && elements.length > 0;
|
||||
},
|
||||
contextItemLabel: "labels.copyAsPng",
|
||||
@@ -188,7 +188,7 @@ export const copyText = register({
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemPredicate: (elements, appState) => {
|
||||
predicate: (elements, appState) => {
|
||||
return (
|
||||
probablySupportsClipboardWriteText &&
|
||||
getSelectedElements(elements, appState, true).some(isTextElement)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { LoadIcon, questionCircle, saveAs } from "../components/icons";
|
||||
import { questionCircle, saveAs } from "../components/icons";
|
||||
import { ProjectName } from "../components/ProjectName";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import "../components/ToolIcon.scss";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||
import { loadFromJSON, saveAsJSON } from "../data";
|
||||
@@ -15,12 +14,11 @@ import { getExportSize } from "../scene/export";
|
||||
import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ActiveFile } from "../components/ActiveFile";
|
||||
import { isImageFileHandle } from "../data/blob";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
import { Theme } from "../element/types";
|
||||
import MenuItem from "../components/MenuItem";
|
||||
import { getShortcutFromShortcutName } from "./shortcuts";
|
||||
|
||||
import "../components/ToolIcon.scss";
|
||||
|
||||
export const actionChangeProjectName = register({
|
||||
name: "changeProjectName",
|
||||
@@ -133,6 +131,13 @@ export const actionChangeExportEmbedScene = register({
|
||||
export const actionSaveToActiveFile = register({
|
||||
name: "saveToActiveFile",
|
||||
trackEvent: { category: "export" },
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return (
|
||||
!!app.props.UIOptions.canvasActions.saveToActiveFile &&
|
||||
!!appState.fileHandle &&
|
||||
!appState.viewModeEnabled
|
||||
);
|
||||
},
|
||||
perform: async (elements, appState, value, app) => {
|
||||
const fileHandleExists = !!appState.fileHandle;
|
||||
|
||||
@@ -169,12 +174,6 @@ export const actionSaveToActiveFile = register({
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<ActiveFile
|
||||
onSave={() => updateData(null)}
|
||||
fileName={appState.fileHandle?.name}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionSaveFileToDisk = register({
|
||||
@@ -220,6 +219,11 @@ export const actionSaveFileToDisk = register({
|
||||
export const actionLoadScene = register({
|
||||
name: "loadScene",
|
||||
trackEvent: { category: "export" },
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return (
|
||||
!!app.props.UIOptions.canvasActions.loadScene && !appState.viewModeEnabled
|
||||
);
|
||||
},
|
||||
perform: async (elements, appState, _, app) => {
|
||||
try {
|
||||
const {
|
||||
@@ -247,15 +251,6 @@ export const actionLoadScene = register({
|
||||
}
|
||||
},
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<MenuItem
|
||||
label={t("buttons.load")}
|
||||
icon={LoadIcon}
|
||||
onClick={updateData}
|
||||
dataTestId="load-button"
|
||||
shortcut={getShortcutFromShortcutName("loadScene")}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionExportWithDarkMode = register({
|
||||
|
||||
@@ -145,7 +145,7 @@ export const actionFinalize = register({
|
||||
let activeTool: AppState["activeTool"];
|
||||
if (appState.activeTool.type === "eraser") {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
...(appState.activeTool.lastActiveToolBeforeEraser || {
|
||||
...(appState.activeTool.lastActiveTool || {
|
||||
type: "selection",
|
||||
}),
|
||||
lastActiveToolBeforeEraser: null,
|
||||
|
||||
@@ -50,7 +50,7 @@ export const actionFlipHorizontal = register({
|
||||
},
|
||||
keyTest: (event) => event.shiftKey && event.code === "KeyH",
|
||||
contextItemLabel: "labels.flipHorizontal",
|
||||
contextItemPredicate: (elements, appState) =>
|
||||
predicate: (elements, appState) =>
|
||||
enableActionFlipHorizontal(elements, appState),
|
||||
});
|
||||
|
||||
@@ -67,7 +67,7 @@ export const actionFlipVertical = register({
|
||||
keyTest: (event) =>
|
||||
event.shiftKey && event.code === "KeyV" && !event[KEYS.CTRL_OR_CMD],
|
||||
contextItemLabel: "labels.flipVertical",
|
||||
contextItemPredicate: (elements, appState) =>
|
||||
predicate: (elements, appState) =>
|
||||
enableActionFlipVertical(elements, appState),
|
||||
});
|
||||
|
||||
|
||||
@@ -129,8 +129,7 @@ export const actionGroup = register({
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.group",
|
||||
contextItemPredicate: (elements, appState) =>
|
||||
enableActionGroup(elements, appState),
|
||||
predicate: (elements, appState) => enableActionGroup(elements, appState),
|
||||
keyTest: (event) =>
|
||||
!event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
@@ -193,8 +192,7 @@ export const actionUngroup = register({
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
event.key === KEYS.G.toUpperCase(),
|
||||
contextItemLabel: "labels.ungroup",
|
||||
contextItemPredicate: (elements, appState) =>
|
||||
getSelectedGroupIds(appState).length > 0,
|
||||
predicate: (elements, appState) => getSelectedGroupIds(appState).length > 0,
|
||||
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
|
||||
@@ -5,10 +5,11 @@ import { t } from "../i18n";
|
||||
import History, { HistoryEntry } from "../history";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { isWindows, KEYS } from "../keys";
|
||||
import { KEYS } from "../keys";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { isWindows } from "../constants";
|
||||
|
||||
const writeData = (
|
||||
prevElements: readonly ExcalidrawElement[],
|
||||
|
||||
@@ -10,7 +10,7 @@ export const actionToggleLinearEditor = register({
|
||||
trackEvent: {
|
||||
category: "element",
|
||||
},
|
||||
contextItemPredicate: (elements, appState) => {
|
||||
predicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
|
||||
return true;
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { HamburgerMenuIcon, HelpIcon, palette } from "../components/icons";
|
||||
import { HamburgerMenuIcon, palette } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
|
||||
import { register } from "./register";
|
||||
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
|
||||
import { KEYS } from "../keys";
|
||||
import { HelpButton } from "../components/HelpButton";
|
||||
import MenuItem from "../components/MenuItem";
|
||||
|
||||
export const actionToggleCanvasMenu = register({
|
||||
name: "toggleCanvasMenu",
|
||||
@@ -88,17 +86,5 @@ export const actionShortcuts = register({
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData, isInHamburgerMenu }) =>
|
||||
isInHamburgerMenu ? (
|
||||
<MenuItem
|
||||
label={t("helpDialog.title")}
|
||||
dataTestId="help-menu-item"
|
||||
icon={HelpIcon}
|
||||
onClick={updateData}
|
||||
shortcut="?"
|
||||
/>
|
||||
) : (
|
||||
<HelpButton title={t("helpDialog.title")} onClick={updateData} />
|
||||
),
|
||||
keyTest: (event) => event.key === KEYS.QUESTION_MARK,
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ export const actionToggleGridMode = register({
|
||||
};
|
||||
},
|
||||
checked: (appState: AppState) => appState.gridSize !== null,
|
||||
contextItemPredicate: (element, appState, props) => {
|
||||
predicate: (element, appState, props) => {
|
||||
return typeof props.gridModeEnabled === "undefined";
|
||||
},
|
||||
contextItemLabel: "labels.showGrid",
|
||||
|
||||
@@ -18,7 +18,7 @@ export const actionToggleViewMode = register({
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.viewModeEnabled,
|
||||
contextItemPredicate: (elements, appState, appProps) => {
|
||||
predicate: (elements, appState, appProps) => {
|
||||
return typeof appProps.viewModeEnabled === "undefined";
|
||||
},
|
||||
contextItemLabel: "labels.viewMode",
|
||||
|
||||
@@ -18,7 +18,7 @@ export const actionToggleZenMode = register({
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.zenModeEnabled,
|
||||
contextItemPredicate: (elements, appState, appProps) => {
|
||||
predicate: (elements, appState, appProps) => {
|
||||
return typeof appProps.zenModeEnabled === "undefined";
|
||||
},
|
||||
contextItemLabel: "buttons.zenMode",
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
moveAllLeft,
|
||||
moveAllRight,
|
||||
} from "../zindex";
|
||||
import { KEYS, isDarwin, CODES } from "../keys";
|
||||
import { KEYS, CODES } from "../keys";
|
||||
import { t } from "../i18n";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
SendBackwardIcon,
|
||||
SendToBackIcon,
|
||||
} from "../components/icons";
|
||||
import { isDarwin } from "../constants";
|
||||
|
||||
export const actionSendBackward = register({
|
||||
name: "sendBackward",
|
||||
|
||||
+11
-6
@@ -131,11 +131,7 @@ export class ActionManager {
|
||||
/**
|
||||
* @param data additional data sent to the PanelComponent
|
||||
*/
|
||||
renderAction = (
|
||||
name: ActionName,
|
||||
data?: PanelComponentProps["data"],
|
||||
isInHamburgerMenu = false,
|
||||
) => {
|
||||
renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
|
||||
const canvasActions = this.app.props.UIOptions.canvasActions;
|
||||
|
||||
if (
|
||||
@@ -170,11 +166,20 @@ export class ActionManager {
|
||||
updateData={updateData}
|
||||
appProps={this.app.props}
|
||||
data={data}
|
||||
isInHamburgerMenu={isInHamburgerMenu}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
isActionEnabled = (action: Action) => {
|
||||
const elements = this.getElementsIncludingDeleted();
|
||||
const appState = this.getAppState();
|
||||
|
||||
return (
|
||||
!action.predicate ||
|
||||
action.predicate(elements, appState, this.app.props, this.app)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isDarwin } from "../constants";
|
||||
import { t } from "../i18n";
|
||||
import { isDarwin } from "../keys";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { ActionName } from "./types";
|
||||
|
||||
|
||||
+88
-83
@@ -34,85 +34,92 @@ type ActionFn = (
|
||||
export type UpdaterFn = (res: ActionResult) => void;
|
||||
export type ActionFilterFn = (action: Action) => void;
|
||||
|
||||
export type ActionName =
|
||||
| "copy"
|
||||
| "cut"
|
||||
| "paste"
|
||||
| "copyAsPng"
|
||||
| "copyAsSvg"
|
||||
| "copyText"
|
||||
| "sendBackward"
|
||||
| "bringForward"
|
||||
| "sendToBack"
|
||||
| "bringToFront"
|
||||
| "copyStyles"
|
||||
| "selectAll"
|
||||
| "pasteStyles"
|
||||
| "gridMode"
|
||||
| "zenMode"
|
||||
| "stats"
|
||||
| "changeStrokeColor"
|
||||
| "changeBackgroundColor"
|
||||
| "changeFillStyle"
|
||||
| "changeStrokeWidth"
|
||||
| "changeStrokeShape"
|
||||
| "changeSloppiness"
|
||||
| "changeStrokeStyle"
|
||||
| "changeArrowhead"
|
||||
| "changeOpacity"
|
||||
| "changeFontSize"
|
||||
| "toggleCanvasMenu"
|
||||
| "toggleEditMenu"
|
||||
| "undo"
|
||||
| "redo"
|
||||
| "finalize"
|
||||
| "changeProjectName"
|
||||
| "changeExportBackground"
|
||||
| "changeExportEmbedScene"
|
||||
| "changeExportScale"
|
||||
| "saveToActiveFile"
|
||||
| "saveFileToDisk"
|
||||
| "loadScene"
|
||||
| "duplicateSelection"
|
||||
| "deleteSelectedElements"
|
||||
| "changeViewBackgroundColor"
|
||||
| "clearCanvas"
|
||||
| "zoomIn"
|
||||
| "zoomOut"
|
||||
| "resetZoom"
|
||||
| "zoomToFit"
|
||||
| "zoomToSelection"
|
||||
| "changeFontFamily"
|
||||
| "changeTextAlign"
|
||||
| "changeVerticalAlign"
|
||||
| "toggleFullScreen"
|
||||
| "toggleShortcuts"
|
||||
| "group"
|
||||
| "ungroup"
|
||||
| "goToCollaborator"
|
||||
| "addToLibrary"
|
||||
| "changeRoundness"
|
||||
| "alignTop"
|
||||
| "alignBottom"
|
||||
| "alignLeft"
|
||||
| "alignRight"
|
||||
| "alignVerticallyCentered"
|
||||
| "alignHorizontallyCentered"
|
||||
| "distributeHorizontally"
|
||||
| "distributeVertically"
|
||||
| "flipHorizontal"
|
||||
| "flipVertical"
|
||||
| "viewMode"
|
||||
| "exportWithDarkMode"
|
||||
| "toggleTheme"
|
||||
| "increaseFontSize"
|
||||
| "decreaseFontSize"
|
||||
| "unbindText"
|
||||
| "hyperlink"
|
||||
| "eraser"
|
||||
| "bindText"
|
||||
| "toggleLock"
|
||||
| "toggleLinearEditor";
|
||||
const actionNames = [
|
||||
"copy",
|
||||
"cut",
|
||||
"paste",
|
||||
"copyAsPng",
|
||||
"copyAsSvg",
|
||||
"copyText",
|
||||
"sendBackward",
|
||||
"bringForward",
|
||||
"sendToBack",
|
||||
"bringToFront",
|
||||
"copyStyles",
|
||||
"selectAll",
|
||||
"pasteStyles",
|
||||
"gridMode",
|
||||
"zenMode",
|
||||
"stats",
|
||||
"changeStrokeColor",
|
||||
"changeBackgroundColor",
|
||||
"changeFillStyle",
|
||||
"changeStrokeWidth",
|
||||
"changeStrokeShape",
|
||||
"changeSloppiness",
|
||||
"changeStrokeStyle",
|
||||
"changeArrowhead",
|
||||
"changeOpacity",
|
||||
"changeFontSize",
|
||||
"toggleCanvasMenu",
|
||||
"toggleEditMenu",
|
||||
"undo",
|
||||
"redo",
|
||||
"finalize",
|
||||
"changeProjectName",
|
||||
"changeExportBackground",
|
||||
"changeExportEmbedScene",
|
||||
"changeExportScale",
|
||||
"saveToActiveFile",
|
||||
"saveFileToDisk",
|
||||
"loadScene",
|
||||
"duplicateSelection",
|
||||
"deleteSelectedElements",
|
||||
"changeViewBackgroundColor",
|
||||
"clearCanvas",
|
||||
"zoomIn",
|
||||
"zoomOut",
|
||||
"resetZoom",
|
||||
"zoomToFit",
|
||||
"zoomToSelection",
|
||||
"changeFontFamily",
|
||||
"changeTextAlign",
|
||||
"changeVerticalAlign",
|
||||
"toggleFullScreen",
|
||||
"toggleShortcuts",
|
||||
"group",
|
||||
"ungroup",
|
||||
"goToCollaborator",
|
||||
"addToLibrary",
|
||||
"changeRoundness",
|
||||
"alignTop",
|
||||
"alignBottom",
|
||||
"alignLeft",
|
||||
"alignRight",
|
||||
"alignVerticallyCentered",
|
||||
"alignHorizontallyCentered",
|
||||
"distributeHorizontally",
|
||||
"distributeVertically",
|
||||
"flipHorizontal",
|
||||
"flipVertical",
|
||||
"viewMode",
|
||||
"exportWithDarkMode",
|
||||
"toggleTheme",
|
||||
"increaseFontSize",
|
||||
"decreaseFontSize",
|
||||
"unbindText",
|
||||
"hyperlink",
|
||||
"bindText",
|
||||
"toggleLock",
|
||||
"toggleLinearEditor",
|
||||
"toggleEraserTool",
|
||||
"toggleHandTool",
|
||||
] as const;
|
||||
|
||||
// So we can have the `isActionName` type guard
|
||||
export type ActionName = typeof actionNames[number];
|
||||
export const isActionName = (n: any): n is ActionName =>
|
||||
actionNames.includes(n);
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
@@ -124,9 +131,7 @@ export type PanelComponentProps = {
|
||||
|
||||
export interface Action {
|
||||
name: ActionName;
|
||||
PanelComponent?: React.FC<
|
||||
PanelComponentProps & { isInHamburgerMenu: boolean }
|
||||
>;
|
||||
PanelComponent?: React.FC<PanelComponentProps>;
|
||||
perform: ActionFn;
|
||||
keyPriority?: number;
|
||||
keyTest?: (
|
||||
@@ -140,7 +145,7 @@ export interface Action {
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
) => string);
|
||||
contextItemPredicate?: (
|
||||
predicate?: (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
appProps: ExcalidrawProps,
|
||||
|
||||
+9
-1
@@ -45,7 +45,7 @@ export const getDefaultAppState = (): Omit<
|
||||
type: "selection",
|
||||
customType: null,
|
||||
locked: false,
|
||||
lastActiveToolBeforeEraser: null,
|
||||
lastActiveTool: null,
|
||||
},
|
||||
penMode: false,
|
||||
penDetected: false,
|
||||
@@ -228,3 +228,11 @@ export const isEraserActive = ({
|
||||
}: {
|
||||
activeTool: AppState["activeTool"];
|
||||
}) => activeTool.type === "eraser";
|
||||
|
||||
export const isHandToolActive = ({
|
||||
activeTool,
|
||||
}: {
|
||||
activeTool: AppState["activeTool"];
|
||||
}) => {
|
||||
return activeTool.type === "hand";
|
||||
};
|
||||
|
||||
+4
-5
@@ -180,16 +180,16 @@ export const parseClipboard = async (
|
||||
};
|
||||
|
||||
export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
|
||||
let promise;
|
||||
try {
|
||||
// in Safari so far we need to construct the ClipboardItem synchronously
|
||||
// (i.e. in the same tick) otherwise browser will complain for lack of
|
||||
// user intent. Using a Promise ClipboardItem constructor solves this.
|
||||
// https://bugs.webkit.org/show_bug.cgi?id=222262
|
||||
//
|
||||
// not await so that we can detect whether the thrown error likely relates
|
||||
// to a lack of support for the Promise ClipboardItem constructor
|
||||
promise = navigator.clipboard.write([
|
||||
// Note that Firefox (and potentially others) seems to support Promise
|
||||
// ClipboardItem constructor, but throws on an unrelated MIME type error.
|
||||
// So we need to await this and fallback to awaiting the blob if applicable.
|
||||
await navigator.clipboard.write([
|
||||
new window.ClipboardItem({
|
||||
[MIME_TYPES.png]: blob,
|
||||
}),
|
||||
@@ -207,7 +207,6 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await promise;
|
||||
};
|
||||
|
||||
export const copyTextToSystemClipboard = async (text: string | null) => {
|
||||
|
||||
@@ -219,9 +219,10 @@ export const ShapesSwitcher = ({
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
|
||||
const label = t(`toolBar.${value}`);
|
||||
const letter = key && (typeof key === "string" ? key : key[0]);
|
||||
const letter =
|
||||
key && capitalizeString(typeof key === "string" ? key : key[0]);
|
||||
const shortcut = letter
|
||||
? `${capitalizeString(letter)} ${t("helpDialog.or")} ${numericKey}`
|
||||
? `${letter} ${t("helpDialog.or")} ${numericKey}`
|
||||
: `${numericKey}`;
|
||||
return (
|
||||
<ToolButton
|
||||
@@ -232,7 +233,7 @@ export const ShapesSwitcher = ({
|
||||
checked={activeTool.type === value}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||
keyBindingLabel={numericKey}
|
||||
keyBindingLabel={numericKey || letter}
|
||||
aria-label={capitalizeString(label)}
|
||||
aria-keyshortcuts={shortcut}
|
||||
data-testid={`toolbar-${value}`}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { actionClearCanvas } from "../actions";
|
||||
import { t } from "../i18n";
|
||||
import { useExcalidrawActionManager } from "./App";
|
||||
import ConfirmDialog from "./ConfirmDialog";
|
||||
|
||||
export const activeConfirmDialogAtom = atom<"clearCanvas" | null>(null);
|
||||
|
||||
export const ActiveConfirmDialog = () => {
|
||||
const [activeConfirmDialog, setActiveConfirmDialog] = useAtom(
|
||||
activeConfirmDialogAtom,
|
||||
);
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
if (!activeConfirmDialog) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (activeConfirmDialog === "clearCanvas") {
|
||||
return (
|
||||
<ConfirmDialog
|
||||
onConfirm={() => {
|
||||
actionManager.executeAction(actionClearCanvas);
|
||||
setActiveConfirmDialog(null);
|
||||
}}
|
||||
onCancel={() => setActiveConfirmDialog(null)}
|
||||
title={t("clearCanvasDialog.title")}
|
||||
>
|
||||
<p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
// TODO barnabasmolnar/editor-redesign
|
||||
// this icon is not great
|
||||
import { getShortcutFromShortcutName } from "../actions/shortcuts";
|
||||
import { save } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import "./ActiveFile.scss";
|
||||
import MenuItem from "./MenuItem";
|
||||
|
||||
type ActiveFileProps = {
|
||||
fileName?: string;
|
||||
onSave: () => void;
|
||||
};
|
||||
|
||||
export const ActiveFile = ({ fileName, onSave }: ActiveFileProps) => (
|
||||
<MenuItem
|
||||
label={`${t("buttons.save")}`}
|
||||
shortcut={getShortcutFromShortcutName("saveScene")}
|
||||
dataTestId="save-button"
|
||||
onClick={onSave}
|
||||
icon={save}
|
||||
/>
|
||||
);
|
||||
+113
-83
@@ -41,7 +41,11 @@ import { ActionManager } from "../actions/manager";
|
||||
import { actions } from "../actions/register";
|
||||
import { ActionResult } from "../actions/types";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { getDefaultAppState, isEraserActive } from "../appState";
|
||||
import {
|
||||
getDefaultAppState,
|
||||
isEraserActive,
|
||||
isHandToolActive,
|
||||
} from "../appState";
|
||||
import { parseClipboard } from "../clipboard";
|
||||
import {
|
||||
APP_NAME,
|
||||
@@ -57,6 +61,7 @@ import {
|
||||
EVENT,
|
||||
GRID_SIZE,
|
||||
IMAGE_RENDER_TIMEOUT,
|
||||
isAndroid,
|
||||
LINE_CONFIRM_THRESHOLD,
|
||||
MAX_ALLOWED_FILE_BYTES,
|
||||
MIME_TYPES,
|
||||
@@ -166,7 +171,6 @@ import {
|
||||
shouldRotateWithDiscreteAngle,
|
||||
isArrowKey,
|
||||
KEYS,
|
||||
isAndroid,
|
||||
} from "../keys";
|
||||
import { distance2d, getGridPoint, isPathALoop } from "../math";
|
||||
import { renderScene } from "../renderer/renderScene";
|
||||
@@ -272,12 +276,9 @@ import {
|
||||
isLocalLink,
|
||||
} from "../element/Hyperlink";
|
||||
import { shouldShowBoundingBox } from "../element/transformHandles";
|
||||
import { atom } from "jotai";
|
||||
import { Fonts } from "../scene/Fonts";
|
||||
import { actionPaste } from "../actions/actionClipboard";
|
||||
|
||||
export const isMenuOpenAtom = atom(false);
|
||||
export const isDropdownOpenAtom = atom(false);
|
||||
import { actionToggleHandTool } from "../actions/actionCanvas";
|
||||
|
||||
const deviceContextInitialValue = {
|
||||
isSmScreen: false,
|
||||
@@ -287,15 +288,12 @@ const deviceContextInitialValue = {
|
||||
};
|
||||
const DeviceContext = React.createContext<Device>(deviceContextInitialValue);
|
||||
DeviceContext.displayName = "DeviceContext";
|
||||
export const useDevice = () => useContext<Device>(DeviceContext);
|
||||
|
||||
const ExcalidrawContainerContext = React.createContext<{
|
||||
export const ExcalidrawContainerContext = React.createContext<{
|
||||
container: HTMLDivElement | null;
|
||||
id: string | null;
|
||||
}>({ container: null, id: null });
|
||||
ExcalidrawContainerContext.displayName = "ExcalidrawContainerContext";
|
||||
export const useExcalidrawContainer = () =>
|
||||
useContext(ExcalidrawContainerContext);
|
||||
|
||||
const ExcalidrawElementsContext = React.createContext<
|
||||
readonly NonDeletedExcalidrawElement[]
|
||||
@@ -313,15 +311,27 @@ ExcalidrawAppStateContext.displayName = "ExcalidrawAppStateContext";
|
||||
|
||||
const ExcalidrawSetAppStateContext = React.createContext<
|
||||
React.Component<any, AppState>["setState"]
|
||||
>(() => {});
|
||||
>(() => {
|
||||
console.warn("unitialized ExcalidrawSetAppStateContext context!");
|
||||
});
|
||||
ExcalidrawSetAppStateContext.displayName = "ExcalidrawSetAppStateContext";
|
||||
|
||||
const ExcalidrawActionManagerContext = React.createContext<ActionManager>(
|
||||
null!,
|
||||
);
|
||||
ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext";
|
||||
|
||||
export const useDevice = () => useContext<Device>(DeviceContext);
|
||||
export const useExcalidrawContainer = () =>
|
||||
useContext(ExcalidrawContainerContext);
|
||||
export const useExcalidrawElements = () =>
|
||||
useContext(ExcalidrawElementsContext);
|
||||
export const useExcalidrawAppState = () =>
|
||||
useContext(ExcalidrawAppStateContext);
|
||||
export const useExcalidrawSetAppState = () =>
|
||||
useContext(ExcalidrawSetAppStateContext);
|
||||
export const useExcalidrawActionManager = () =>
|
||||
useContext(ExcalidrawActionManagerContext);
|
||||
|
||||
let didTapTwice: boolean = false;
|
||||
let tappedTwiceTimer = 0;
|
||||
@@ -534,8 +544,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scene.getNonDeletedElements(),
|
||||
this.state,
|
||||
);
|
||||
const { onCollabButtonClick, renderTopRightUI, renderCustomStats } =
|
||||
this.props;
|
||||
const { renderTopRightUI, renderCustomStats } = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -559,75 +568,81 @@ class App extends React.Component<AppProps, AppState> {
|
||||
<ExcalidrawElementsContext.Provider
|
||||
value={this.scene.getNonDeletedElements()}
|
||||
>
|
||||
<LayerUI
|
||||
canvas={this.canvas}
|
||||
appState={this.state}
|
||||
files={this.files}
|
||||
setAppState={this.setAppState}
|
||||
actionManager={this.actionManager}
|
||||
elements={this.scene.getNonDeletedElements()}
|
||||
onCollabButtonClick={onCollabButtonClick}
|
||||
onLockToggle={this.toggleLock}
|
||||
onPenModeToggle={this.togglePenMode}
|
||||
onInsertElements={(elements) =>
|
||||
this.addElementsFromPasteOrLibrary({
|
||||
elements,
|
||||
position: "center",
|
||||
files: null,
|
||||
})
|
||||
}
|
||||
langCode={getLanguage().code}
|
||||
isCollaborating={this.props.isCollaborating}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderCustomStats={renderCustomStats}
|
||||
renderCustomSidebar={this.props.renderSidebar}
|
||||
showExitZenModeBtn={
|
||||
typeof this.props?.zenModeEnabled === "undefined" &&
|
||||
this.state.zenModeEnabled
|
||||
}
|
||||
libraryReturnUrl={this.props.libraryReturnUrl}
|
||||
UIOptions={this.props.UIOptions}
|
||||
focusContainer={this.focusContainer}
|
||||
library={this.library}
|
||||
id={this.id}
|
||||
onImageAction={this.onImageAction}
|
||||
renderWelcomeScreen={
|
||||
this.state.showWelcomeScreen &&
|
||||
this.state.activeTool.type === "selection" &&
|
||||
!this.scene.getElementsIncludingDeleted().length
|
||||
}
|
||||
<ExcalidrawActionManagerContext.Provider
|
||||
value={this.actionManager}
|
||||
>
|
||||
{this.props.children}
|
||||
</LayerUI>
|
||||
<div className="excalidraw-textEditorContainer" />
|
||||
<div className="excalidraw-contextMenuContainer" />
|
||||
{selectedElement.length === 1 &&
|
||||
!this.state.contextMenu &&
|
||||
this.state.showHyperlinkPopup && (
|
||||
<Hyperlink
|
||||
key={selectedElement[0].id}
|
||||
element={selectedElement[0]}
|
||||
setAppState={this.setAppState}
|
||||
onLinkOpen={this.props.onLinkOpen}
|
||||
<LayerUI
|
||||
canvas={this.canvas}
|
||||
appState={this.state}
|
||||
files={this.files}
|
||||
setAppState={this.setAppState}
|
||||
actionManager={this.actionManager}
|
||||
elements={this.scene.getNonDeletedElements()}
|
||||
onLockToggle={this.toggleLock}
|
||||
onPenModeToggle={this.togglePenMode}
|
||||
onHandToolToggle={this.onHandToolToggle}
|
||||
onInsertElements={(elements) =>
|
||||
this.addElementsFromPasteOrLibrary({
|
||||
elements,
|
||||
position: "center",
|
||||
files: null,
|
||||
})
|
||||
}
|
||||
langCode={getLanguage().code}
|
||||
isCollaborating={this.props.isCollaborating}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderCustomStats={renderCustomStats}
|
||||
renderCustomSidebar={this.props.renderSidebar}
|
||||
showExitZenModeBtn={
|
||||
typeof this.props?.zenModeEnabled === "undefined" &&
|
||||
this.state.zenModeEnabled
|
||||
}
|
||||
libraryReturnUrl={this.props.libraryReturnUrl}
|
||||
UIOptions={this.props.UIOptions}
|
||||
focusContainer={this.focusContainer}
|
||||
library={this.library}
|
||||
id={this.id}
|
||||
onImageAction={this.onImageAction}
|
||||
renderWelcomeScreen={
|
||||
!this.state.isLoading &&
|
||||
this.props.UIOptions.welcomeScreen &&
|
||||
this.state.showWelcomeScreen &&
|
||||
this.state.activeTool.type === "selection" &&
|
||||
!this.scene.getElementsIncludingDeleted().length
|
||||
}
|
||||
>
|
||||
{this.props.children}
|
||||
</LayerUI>
|
||||
<div className="excalidraw-textEditorContainer" />
|
||||
<div className="excalidraw-contextMenuContainer" />
|
||||
{selectedElement.length === 1 &&
|
||||
!this.state.contextMenu &&
|
||||
this.state.showHyperlinkPopup && (
|
||||
<Hyperlink
|
||||
key={selectedElement[0].id}
|
||||
element={selectedElement[0]}
|
||||
setAppState={this.setAppState}
|
||||
onLinkOpen={this.props.onLinkOpen}
|
||||
/>
|
||||
)}
|
||||
{this.state.toast !== null && (
|
||||
<Toast
|
||||
message={this.state.toast.message}
|
||||
onClose={() => this.setToast(null)}
|
||||
duration={this.state.toast.duration}
|
||||
closable={this.state.toast.closable}
|
||||
/>
|
||||
)}
|
||||
{this.state.toast !== null && (
|
||||
<Toast
|
||||
message={this.state.toast.message}
|
||||
onClose={() => this.setToast(null)}
|
||||
duration={this.state.toast.duration}
|
||||
closable={this.state.toast.closable}
|
||||
/>
|
||||
)}
|
||||
{this.state.contextMenu && (
|
||||
<ContextMenu
|
||||
items={this.state.contextMenu.items}
|
||||
top={this.state.contextMenu.top}
|
||||
left={this.state.contextMenu.left}
|
||||
actionManager={this.actionManager}
|
||||
/>
|
||||
)}
|
||||
<main>{this.renderCanvas()}</main>
|
||||
{this.state.contextMenu && (
|
||||
<ContextMenu
|
||||
items={this.state.contextMenu.items}
|
||||
top={this.state.contextMenu.top}
|
||||
left={this.state.contextMenu.left}
|
||||
actionManager={this.actionManager}
|
||||
/>
|
||||
)}
|
||||
<main>{this.renderCanvas()}</main>
|
||||
</ExcalidrawActionManagerContext.Provider>
|
||||
</ExcalidrawElementsContext.Provider>{" "}
|
||||
</ExcalidrawAppStateContext.Provider>
|
||||
</ExcalidrawSetAppStateContext.Provider>
|
||||
@@ -1803,6 +1818,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
};
|
||||
|
||||
onHandToolToggle = () => {
|
||||
this.actionManager.executeAction(actionToggleHandTool);
|
||||
};
|
||||
|
||||
scrollToContent = (
|
||||
target:
|
||||
| ExcalidrawElement
|
||||
@@ -2220,11 +2239,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
private setActiveTool = (
|
||||
tool:
|
||||
| { type: typeof SHAPES[number]["value"] | "eraser" }
|
||||
| { type: typeof SHAPES[number]["value"] | "eraser" | "hand" }
|
||||
| { type: "custom"; customType: string },
|
||||
) => {
|
||||
const nextActiveTool = updateActiveTool(this.state, tool);
|
||||
if (!isHoldingSpace) {
|
||||
if (nextActiveTool.type === "hand") {
|
||||
setCursor(this.canvas, CURSOR_TYPE.GRAB);
|
||||
} else if (!isHoldingSpace) {
|
||||
setCursorForShape(this.canvas, this.state);
|
||||
}
|
||||
if (isToolIcon(document.activeElement)) {
|
||||
@@ -2895,7 +2916,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
null;
|
||||
}
|
||||
|
||||
if (isHoldingSpace || isPanning || isDraggingScrollBar) {
|
||||
if (
|
||||
isHoldingSpace ||
|
||||
isPanning ||
|
||||
isDraggingScrollBar ||
|
||||
isHandToolActive(this.state)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3487,7 +3513,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
);
|
||||
} else if (this.state.activeTool.type === "custom") {
|
||||
setCursor(this.canvas, CURSOR_TYPE.AUTO);
|
||||
} else if (this.state.activeTool.type !== "eraser") {
|
||||
} else if (
|
||||
this.state.activeTool.type !== "eraser" &&
|
||||
this.state.activeTool.type !== "hand"
|
||||
) {
|
||||
this.createGenericElementOnPointerDown(
|
||||
this.state.activeTool.type,
|
||||
pointerDownState,
|
||||
@@ -3598,6 +3627,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
gesture.pointers.size <= 1 &&
|
||||
(event.button === POINTER_BUTTON.WHEEL ||
|
||||
(event.button === POINTER_BUTTON.MAIN && isHoldingSpace) ||
|
||||
isHandToolActive(this.state) ||
|
||||
this.state.viewModeEnabled)
|
||||
) ||
|
||||
isTextElement(this.state.editingElement)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
@import "../css/theme";
|
||||
|
||||
.excalidraw {
|
||||
.excalidraw-button {
|
||||
@include outlineButtonStyles;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import "./Button.scss";
|
||||
|
||||
interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
|
||||
type?: "button" | "submit" | "reset";
|
||||
onSelect: () => any;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A generic button component that follows Excalidraw's design system.
|
||||
* Style can be customised using `className` or `style` prop.
|
||||
* Accepts all props that a regular `button` element accepts.
|
||||
*/
|
||||
export const Button = ({
|
||||
type = "button",
|
||||
onSelect,
|
||||
children,
|
||||
className = "",
|
||||
...rest
|
||||
}: ButtonProps) => {
|
||||
return (
|
||||
<button
|
||||
onClick={(event) => {
|
||||
onSelect();
|
||||
rest.onClick?.(event);
|
||||
}}
|
||||
type={type}
|
||||
className={`excalidraw-button ${className}`}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { TrashIcon } from "./icons";
|
||||
|
||||
import ConfirmDialog from "./ConfirmDialog";
|
||||
import MenuItem from "./MenuItem";
|
||||
|
||||
const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const toggleDialog = () => {
|
||||
setShowDialog(!showDialog);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuItem
|
||||
label={t("buttons.clearReset")}
|
||||
icon={TrashIcon}
|
||||
onClick={toggleDialog}
|
||||
dataTestId="clear-canvas-button"
|
||||
/>
|
||||
|
||||
{showDialog && (
|
||||
<ConfirmDialog
|
||||
onConfirm={() => {
|
||||
onConfirm();
|
||||
toggleDialog();
|
||||
}}
|
||||
onCancel={toggleDialog}
|
||||
title={t("clearCanvasDialog.title")}
|
||||
>
|
||||
<p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClearCanvas;
|
||||
@@ -1,49 +0,0 @@
|
||||
import { t } from "../i18n";
|
||||
import { UsersIcon } from "./icons";
|
||||
|
||||
import "./CollabButton.scss";
|
||||
import MenuItem from "./MenuItem";
|
||||
import clsx from "clsx";
|
||||
|
||||
const CollabButton = ({
|
||||
isCollaborating,
|
||||
collaboratorCount,
|
||||
onClick,
|
||||
isInHamburgerMenu = true,
|
||||
}: {
|
||||
isCollaborating: boolean;
|
||||
collaboratorCount: number;
|
||||
onClick: () => void;
|
||||
isInHamburgerMenu?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{isInHamburgerMenu ? (
|
||||
<MenuItem
|
||||
label={t("labels.liveCollaboration")}
|
||||
dataTestId="collab-button"
|
||||
icon={UsersIcon}
|
||||
onClick={onClick}
|
||||
isCollaborating={isCollaborating}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
className={clsx("collab-button", { active: isCollaborating })}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
style={{ position: "relative" }}
|
||||
title={t("labels.liveCollaboration")}
|
||||
>
|
||||
{UsersIcon}
|
||||
{collaboratorCount > 0 && (
|
||||
<div className="CollabButton-collaborators">
|
||||
{collaboratorCount}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollabButton;
|
||||
@@ -3,9 +3,9 @@ import { Dialog, DialogProps } from "./Dialog";
|
||||
|
||||
import "./ConfirmDialog.scss";
|
||||
import DialogActionButton from "./DialogActionButton";
|
||||
import { isMenuOpenAtom } from "./App";
|
||||
import { isDropdownOpenAtom } from "./App";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
|
||||
import { useExcalidrawSetAppState } from "./App";
|
||||
|
||||
interface Props extends Omit<DialogProps, "onCloseRequest"> {
|
||||
onConfirm: () => void;
|
||||
@@ -23,9 +23,8 @@ const ConfirmDialog = (props: Props) => {
|
||||
className = "",
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const setIsMenuOpen = useSetAtom(isMenuOpenAtom);
|
||||
const setIsDropdownOpen = useSetAtom(isDropdownOpenAtom);
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -39,16 +38,16 @@ const ConfirmDialog = (props: Props) => {
|
||||
<DialogActionButton
|
||||
label={cancelText}
|
||||
onClick={() => {
|
||||
setIsMenuOpen(false);
|
||||
setIsDropdownOpen(false);
|
||||
setAppState({ openMenu: null });
|
||||
setIsLibraryMenuOpen(false);
|
||||
onCancel();
|
||||
}}
|
||||
/>
|
||||
<DialogActionButton
|
||||
label={confirmText}
|
||||
onClick={() => {
|
||||
setIsMenuOpen(false);
|
||||
setIsDropdownOpen(false);
|
||||
setAppState({ openMenu: null });
|
||||
setIsLibraryMenuOpen(false);
|
||||
onConfirm();
|
||||
}}
|
||||
actionType="danger"
|
||||
|
||||
@@ -39,8 +39,8 @@ export const ContextMenu = React.memo(
|
||||
if (
|
||||
item &&
|
||||
(item === CONTEXT_MENU_SEPARATOR ||
|
||||
!item.contextItemPredicate ||
|
||||
item.contextItemPredicate(
|
||||
!item.predicate ||
|
||||
item.predicate(
|
||||
elements,
|
||||
appState,
|
||||
actionManager.app.props,
|
||||
|
||||
@@ -2,7 +2,11 @@ import clsx from "clsx";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||
import { t } from "../i18n";
|
||||
import { useExcalidrawContainer, useDevice } from "../components/App";
|
||||
import {
|
||||
useExcalidrawContainer,
|
||||
useDevice,
|
||||
useExcalidrawSetAppState,
|
||||
} from "../components/App";
|
||||
import { KEYS } from "../keys";
|
||||
import "./Dialog.scss";
|
||||
import { back, CloseIcon } from "./icons";
|
||||
@@ -10,8 +14,8 @@ import { Island } from "./Island";
|
||||
import { Modal } from "./Modal";
|
||||
import { AppState } from "../types";
|
||||
import { queryFocusableElements } from "../utils";
|
||||
import { isMenuOpenAtom, isDropdownOpenAtom } from "./App";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
|
||||
|
||||
export interface DialogProps {
|
||||
children: React.ReactNode;
|
||||
@@ -67,12 +71,12 @@ export const Dialog = (props: DialogProps) => {
|
||||
return () => islandNode.removeEventListener("keydown", handleKeyDown);
|
||||
}, [islandNode, props.autofocus]);
|
||||
|
||||
const setIsMenuOpen = useSetAtom(isMenuOpenAtom);
|
||||
const setIsDropdownOpen = useSetAtom(isDropdownOpenAtom);
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
|
||||
|
||||
const onClose = () => {
|
||||
setIsMenuOpen(false);
|
||||
setIsDropdownOpen(false);
|
||||
setAppState({ openMenu: null });
|
||||
setIsLibraryMenuOpen(false);
|
||||
(lastActiveElement as HTMLElement).focus();
|
||||
props.onCloseRequest();
|
||||
};
|
||||
|
||||
@@ -96,6 +96,10 @@
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
margin: 0 0.2em;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
border-radius: 1rem;
|
||||
background-color: var(--button-color);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.FixedSideContainer {
|
||||
position: absolute;
|
||||
@@ -9,10 +11,10 @@
|
||||
}
|
||||
|
||||
.FixedSideContainer_side_top {
|
||||
left: 1rem;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
left: var(--editor-container-padding);
|
||||
top: var(--editor-container-padding);
|
||||
right: var(--editor-container-padding);
|
||||
bottom: var(--editor-container-padding);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import "./ToolIcon.scss";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { handIcon } from "./icons";
|
||||
import { KEYS } from "../keys";
|
||||
|
||||
type LockIconProps = {
|
||||
title?: string;
|
||||
name?: string;
|
||||
checked: boolean;
|
||||
onChange?(): void;
|
||||
isMobile?: boolean;
|
||||
};
|
||||
|
||||
export const HandButton = (props: LockIconProps) => {
|
||||
return (
|
||||
<ToolButton
|
||||
className={clsx("Shape", { fillable: false })}
|
||||
type="radio"
|
||||
icon={handIcon}
|
||||
name="editor-current-shape"
|
||||
checked={props.checked}
|
||||
title={`${props.title} — H`}
|
||||
keyBindingLabel={!props.isMobile ? KEYS.H.toLocaleUpperCase() : undefined}
|
||||
aria-label={`${props.title} — H`}
|
||||
aria-keyshortcuts={KEYS.H}
|
||||
data-testid={`toolbar-hand`}
|
||||
onChange={() => props.onChange?.()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,12 @@
|
||||
import React from "react";
|
||||
import { t } from "../i18n";
|
||||
import { isDarwin, isWindows, KEYS } from "../keys";
|
||||
import { KEYS } from "../keys";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import "./HelpDialog.scss";
|
||||
import { ExternalLinkIcon } from "./icons";
|
||||
import { probablySupportsClipboardBlob } from "../clipboard";
|
||||
import { isDarwin, isFirefox, isWindows } from "../constants";
|
||||
|
||||
const Header = () => (
|
||||
<div className="HelpDialog__header">
|
||||
@@ -67,6 +69,10 @@ function* intersperse(as: JSX.Element[][], delim: string | null) {
|
||||
}
|
||||
}
|
||||
|
||||
const upperCaseSingleChars = (str: string) => {
|
||||
return str.replace(/\b[a-z]\b/, (c) => c.toUpperCase());
|
||||
};
|
||||
|
||||
const Shortcut = ({
|
||||
label,
|
||||
shortcuts,
|
||||
@@ -81,7 +87,9 @@ const Shortcut = ({
|
||||
? [...shortcut.slice(0, -2).split("+"), "+"]
|
||||
: shortcut.split("+");
|
||||
|
||||
return keys.map((key) => <ShortcutKey key={key}>{key}</ShortcutKey>);
|
||||
return keys.map((key) => (
|
||||
<ShortcutKey key={key}>{upperCaseSingleChars(key)}</ShortcutKey>
|
||||
));
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -118,6 +126,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
className="HelpDialog__island--tools"
|
||||
caption={t("helpDialog.tools")}
|
||||
>
|
||||
<Shortcut label={t("toolBar.hand")} shortcuts={[KEYS.H]} />
|
||||
<Shortcut
|
||||
label={t("toolBar.selection")}
|
||||
shortcuts={[KEYS.V, KEYS["1"]]}
|
||||
@@ -140,11 +149,11 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("toolBar.line")}
|
||||
shortcuts={[KEYS.P, KEYS["6"]]}
|
||||
shortcuts={[KEYS.L, KEYS["6"]]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("toolBar.freedraw")}
|
||||
shortcuts={["Shift + P", KEYS["7"]]}
|
||||
shortcuts={[KEYS.P, KEYS["7"]]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("toolBar.text")}
|
||||
@@ -304,10 +313,14 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
label={t("labels.pasteAsPlaintext")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+V")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.copyAsPng")}
|
||||
shortcuts={[getShortcutKey("Shift+Alt+C")]}
|
||||
/>
|
||||
{/* firefox supports clipboard API under a flag, so we'll
|
||||
show users what they can do in the error message */}
|
||||
{(probablySupportsClipboardBlob || isFirefox) && (
|
||||
<Shortcut
|
||||
label={t("labels.copyAsPng")}
|
||||
shortcuts={[getShortcutKey("Shift+Alt+C")]}
|
||||
/>
|
||||
)}
|
||||
<Shortcut
|
||||
label={t("labels.copyStyles")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Alt+C")]}
|
||||
|
||||
@@ -12,7 +12,7 @@ import Stack from "./Stack";
|
||||
import "./ExportDialog.scss";
|
||||
import OpenColor from "open-color";
|
||||
import { CheckboxItem } from "./CheckboxItem";
|
||||
import { DEFAULT_EXPORT_PADDING } from "../constants";
|
||||
import { DEFAULT_EXPORT_PADDING, isFirefox } from "../constants";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
|
||||
@@ -190,7 +190,9 @@ const ImageExportModal = ({
|
||||
>
|
||||
SVG
|
||||
</ExportButton>
|
||||
{probablySupportsClipboardBlob && (
|
||||
{/* firefox supports clipboard API under a flag,
|
||||
so let's throw and tell people what they can do */}
|
||||
{(probablySupportsClipboardBlob || isFirefox) && (
|
||||
<ExportButton
|
||||
title={t("buttons.copyPngToClipboard")}
|
||||
onClick={() => onExportToClipboard(exportedElements)}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useState } from "react";
|
||||
import React from "react";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { AppState, ExportOpts, BinaryFiles } from "../types";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { ExportIcon, exportToFileIcon, LinkIcon } from "./icons";
|
||||
import { exportToFileIcon, LinkIcon } from "./icons";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { actionSaveFileToDisk } from "../actions/actionExport";
|
||||
import { Card } from "./Card";
|
||||
@@ -14,7 +14,6 @@ import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { getFrame } from "../utils";
|
||||
import MenuItem from "./MenuItem";
|
||||
|
||||
export type ExportCB = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
@@ -94,6 +93,7 @@ export const JSONExportDialog = ({
|
||||
actionManager,
|
||||
exportOpts,
|
||||
canvas,
|
||||
setAppState,
|
||||
}: {
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
appState: AppState;
|
||||
@@ -101,24 +101,15 @@ export const JSONExportDialog = ({
|
||||
actionManager: ActionManager;
|
||||
exportOpts: ExportOpts;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
}) => {
|
||||
const [modalIsShown, setModalIsShown] = useState(false);
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
setModalIsShown(false);
|
||||
}, []);
|
||||
setAppState({ openDialog: null });
|
||||
}, [setAppState]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuItem
|
||||
icon={ExportIcon}
|
||||
label={t("buttons.export")}
|
||||
onClick={() => {
|
||||
setModalIsShown(true);
|
||||
}}
|
||||
dataTestId="json-export-button"
|
||||
/>
|
||||
{modalIsShown && (
|
||||
{appState.openDialog === "jsonExport" && (
|
||||
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
|
||||
<JSONExportModal
|
||||
elements={elements}
|
||||
|
||||
@@ -80,16 +80,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__wrapper__footer-center {
|
||||
pointer-events: none;
|
||||
& > * {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.layer-ui__wrapper__footer-left,
|
||||
.layer-ui__wrapper__footer-right,
|
||||
.disable-zen-mode--visible {
|
||||
|
||||
+113
-172
@@ -14,10 +14,10 @@ import {
|
||||
ExcalidrawProps,
|
||||
BinaryFiles,
|
||||
UIChildrenComponents,
|
||||
UIWelcomeScreenComponents,
|
||||
} from "../types";
|
||||
import { muteFSAbortError, ReactChildrenToObject } from "../utils";
|
||||
import { isShallowEqual, muteFSAbortError, getReactChildren } from "../utils";
|
||||
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||
import CollabButton from "./CollabButton";
|
||||
import { ErrorDialog } from "./ErrorDialog";
|
||||
import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
|
||||
import { FixedSideContainer } from "./FixedSideContainer";
|
||||
@@ -41,26 +41,18 @@ import "./LayerUI.scss";
|
||||
import "./Toolbar.scss";
|
||||
import { PenModeButton } from "./PenModeButton";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { isMenuOpenAtom, useDevice } from "../components/App";
|
||||
import { useDevice } from "../components/App";
|
||||
import { Stats } from "./Stats";
|
||||
import { actionToggleStats } from "../actions/actionToggleStats";
|
||||
import Footer from "./footer/Footer";
|
||||
import {
|
||||
ExportImageIcon,
|
||||
HamburgerMenuIcon,
|
||||
WelcomeScreenMenuArrow,
|
||||
WelcomeScreenTopToolbarArrow,
|
||||
} from "./icons";
|
||||
import { MenuLinks, Separator } from "./MenuUtils";
|
||||
import { useOutsideClickHook } from "../hooks/useOutsideClick";
|
||||
import WelcomeScreen from "./WelcomeScreen";
|
||||
import WelcomeScreen from "./welcome-screen/WelcomeScreen";
|
||||
import { hostSidebarCountersAtom } from "./Sidebar/Sidebar";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import { useAtom } from "jotai";
|
||||
import { LanguageList } from "../excalidraw-app/components/LanguageList";
|
||||
import WelcomeScreenDecor from "./WelcomeScreenDecor";
|
||||
import { getShortcutFromShortcutName } from "../actions/shortcuts";
|
||||
import MenuItem from "./MenuItem";
|
||||
import MainMenu from "./main-menu/MainMenu";
|
||||
import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
|
||||
import { HandButton } from "./HandButton";
|
||||
import { isHandToolActive } from "../appState";
|
||||
|
||||
interface LayerUIProps {
|
||||
actionManager: ActionManager;
|
||||
@@ -69,8 +61,8 @@ interface LayerUIProps {
|
||||
canvas: HTMLCanvasElement | null;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
onCollabButtonClick?: () => void;
|
||||
onLockToggle: () => void;
|
||||
onHandToolToggle: () => void;
|
||||
onPenModeToggle: () => void;
|
||||
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
||||
showExitZenModeBtn: boolean;
|
||||
@@ -96,14 +88,13 @@ const LayerUI = ({
|
||||
setAppState,
|
||||
elements,
|
||||
canvas,
|
||||
onCollabButtonClick,
|
||||
onLockToggle,
|
||||
onHandToolToggle,
|
||||
onPenModeToggle,
|
||||
onInsertElements,
|
||||
showExitZenModeBtn,
|
||||
isCollaborating,
|
||||
renderTopRightUI,
|
||||
|
||||
renderCustomStats,
|
||||
renderCustomSidebar,
|
||||
libraryReturnUrl,
|
||||
@@ -117,8 +108,27 @@ const LayerUI = ({
|
||||
}: LayerUIProps) => {
|
||||
const device = useDevice();
|
||||
|
||||
const childrenComponents =
|
||||
ReactChildrenToObject<UIChildrenComponents>(children);
|
||||
const [childrenComponents, restChildren] =
|
||||
getReactChildren<UIChildrenComponents>(children, {
|
||||
Menu: true,
|
||||
FooterCenter: true,
|
||||
WelcomeScreen: true,
|
||||
});
|
||||
|
||||
const [WelcomeScreenComponents] = getReactChildren<UIWelcomeScreenComponents>(
|
||||
renderWelcomeScreen
|
||||
? (
|
||||
childrenComponents?.WelcomeScreen ?? (
|
||||
<WelcomeScreen>
|
||||
<WelcomeScreen.Center />
|
||||
<WelcomeScreen.Hints.MenuHint />
|
||||
<WelcomeScreen.Hints.ToolbarHint />
|
||||
<WelcomeScreen.Hints.HelpHint />
|
||||
</WelcomeScreen>
|
||||
)
|
||||
)?.props?.children
|
||||
: null,
|
||||
);
|
||||
|
||||
const renderJSONExportDialog = () => {
|
||||
if (!UIOptions.canvasActions.export) {
|
||||
@@ -133,6 +143,7 @@ const LayerUI = ({
|
||||
actionManager={actionManager}
|
||||
exportOpts={UIOptions.canvasActions.export}
|
||||
canvas={canvas}
|
||||
setAppState={setAppState}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -186,100 +197,37 @@ const LayerUI = ({
|
||||
);
|
||||
};
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = useAtom(isMenuOpenAtom);
|
||||
const menuRef = useOutsideClickHook(() => setIsMenuOpen(false));
|
||||
|
||||
const renderMenu = () => {
|
||||
return (
|
||||
childrenComponents.Menu || (
|
||||
<MainMenu>
|
||||
<MainMenu.DefaultItems.LoadScene />
|
||||
<MainMenu.DefaultItems.SaveToActiveFile />
|
||||
{/* FIXME we should to test for this inside the item itself */}
|
||||
{UIOptions.canvasActions.export && <MainMenu.DefaultItems.Export />}
|
||||
{/* FIXME we should to test for this inside the item itself */}
|
||||
{UIOptions.canvasActions.saveAsImage && (
|
||||
<MainMenu.DefaultItems.SaveAsImage />
|
||||
)}
|
||||
<MainMenu.DefaultItems.Help />
|
||||
<MainMenu.DefaultItems.ClearCanvas />
|
||||
<MainMenu.Separator />
|
||||
<MainMenu.Group title="Excalidraw links">
|
||||
<MainMenu.DefaultItems.Socials />
|
||||
</MainMenu.Group>
|
||||
<MainMenu.Separator />
|
||||
<MainMenu.DefaultItems.ToggleTheme />
|
||||
<MainMenu.DefaultItems.ChangeCanvasBackground />
|
||||
</MainMenu>
|
||||
)
|
||||
);
|
||||
};
|
||||
const renderCanvasActions = () => (
|
||||
<div style={{ position: "relative" }}>
|
||||
<WelcomeScreenDecor
|
||||
shouldRender={renderWelcomeScreen && !appState.isLoading}
|
||||
>
|
||||
<div className="virgil WelcomeScreen-decor WelcomeScreen-decor--menu-pointer">
|
||||
{WelcomeScreenMenuArrow}
|
||||
<div>{t("welcomeScreen.menuHints")}</div>
|
||||
</div>
|
||||
</WelcomeScreenDecor>
|
||||
|
||||
<button
|
||||
data-prevent-outside-click
|
||||
className={clsx("menu-button", "zen-mode-transition", {
|
||||
"transition-left": appState.zenModeEnabled,
|
||||
})}
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
type="button"
|
||||
data-testid="menu-button"
|
||||
>
|
||||
{HamburgerMenuIcon}
|
||||
</button>
|
||||
|
||||
{isMenuOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{ position: "absolute", top: "100%", marginTop: ".25rem" }}
|
||||
>
|
||||
<Section heading="canvasActions">
|
||||
{/* the zIndex ensures this menu has higher stacking order,
|
||||
see https://github.com/excalidraw/excalidraw/pull/1445 */}
|
||||
<Island
|
||||
className="menu-container"
|
||||
padding={2}
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
{!appState.viewModeEnabled &&
|
||||
actionManager.renderAction("loadScene")}
|
||||
{/* // TODO barnabasmolnar/editor-redesign */}
|
||||
{/* is this fine here? */}
|
||||
{appState.fileHandle &&
|
||||
actionManager.renderAction("saveToActiveFile")}
|
||||
{renderJSONExportDialog()}
|
||||
{UIOptions.canvasActions.saveAsImage && (
|
||||
<MenuItem
|
||||
label={t("buttons.exportImage")}
|
||||
icon={ExportImageIcon}
|
||||
dataTestId="image-export-button"
|
||||
onClick={() => setAppState({ openDialog: "imageExport" })}
|
||||
shortcut={getShortcutFromShortcutName("imageExport")}
|
||||
/>
|
||||
)}
|
||||
{onCollabButtonClick && (
|
||||
<CollabButton
|
||||
isCollaborating={isCollaborating}
|
||||
collaboratorCount={appState.collaborators.size}
|
||||
onClick={onCollabButtonClick}
|
||||
/>
|
||||
)}
|
||||
{actionManager.renderAction("toggleShortcuts", undefined, true)}
|
||||
{!appState.viewModeEnabled &&
|
||||
actionManager.renderAction("clearCanvas")}
|
||||
<Separator />
|
||||
<MenuLinks />
|
||||
<Separator />
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
rowGap: ".5rem",
|
||||
}}
|
||||
>
|
||||
<div>{actionManager.renderAction("toggleTheme")}</div>
|
||||
<div style={{ padding: "0 0.625rem" }}>
|
||||
<LanguageList style={{ width: "100%" }} />
|
||||
</div>
|
||||
{!appState.viewModeEnabled && (
|
||||
<div>
|
||||
<div style={{ fontSize: ".75rem", marginBottom: ".5rem" }}>
|
||||
{t("labels.canvasBackground")}
|
||||
</div>
|
||||
<div style={{ padding: "0 0.625rem" }}>
|
||||
{actionManager.renderAction("changeViewBackgroundColor")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Island>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
{WelcomeScreenComponents.MenuHint}
|
||||
{/* wrapping to Fragment stops React from occasionally complaining
|
||||
about identical Keys */}
|
||||
<>{renderMenu()}</>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -316,9 +264,7 @@ const LayerUI = ({
|
||||
|
||||
return (
|
||||
<FixedSideContainer side="top">
|
||||
{renderWelcomeScreen && !appState.isLoading && (
|
||||
<WelcomeScreen appState={appState} actionManager={actionManager} />
|
||||
)}
|
||||
{WelcomeScreenComponents.Center}
|
||||
<div className="App-menu App-menu_top">
|
||||
<Stack.Col
|
||||
gap={6}
|
||||
@@ -333,17 +279,7 @@ const LayerUI = ({
|
||||
<Section heading="shapes" className="shapes-section">
|
||||
{(heading: React.ReactNode) => (
|
||||
<div style={{ position: "relative" }}>
|
||||
<WelcomeScreenDecor
|
||||
shouldRender={renderWelcomeScreen && !appState.isLoading}
|
||||
>
|
||||
<div className="virgil WelcomeScreen-decor WelcomeScreen-decor--top-toolbar-pointer">
|
||||
<div className="WelcomeScreen-decor--top-toolbar-pointer__label">
|
||||
{t("welcomeScreen.toolbarHints")}
|
||||
</div>
|
||||
{WelcomeScreenTopToolbarArrow}
|
||||
</div>
|
||||
</WelcomeScreenDecor>
|
||||
|
||||
{WelcomeScreenComponents.ToolbarHint}
|
||||
<Stack.Col gap={4} align="start">
|
||||
<Stack.Row
|
||||
gap={1}
|
||||
@@ -373,13 +309,20 @@ const LayerUI = ({
|
||||
penDetected={appState.penDetected}
|
||||
/>
|
||||
<LockButton
|
||||
zenModeEnabled={appState.zenModeEnabled}
|
||||
checked={appState.activeTool.locked}
|
||||
onChange={() => onLockToggle()}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
/>
|
||||
|
||||
<div className="App-toolbar__divider"></div>
|
||||
|
||||
<HandButton
|
||||
checked={isHandToolActive(appState)}
|
||||
onChange={() => onHandToolToggle()}
|
||||
title={t("toolBar.hand")}
|
||||
isMobile
|
||||
/>
|
||||
|
||||
<ShapesSwitcher
|
||||
appState={appState}
|
||||
canvas={canvas}
|
||||
@@ -391,9 +334,6 @@ const LayerUI = ({
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{/* {actionManager.renderAction("eraser", {
|
||||
// size: "small",
|
||||
})} */}
|
||||
</Stack.Row>
|
||||
</Island>
|
||||
</Stack.Row>
|
||||
@@ -410,18 +350,7 @@ const LayerUI = ({
|
||||
},
|
||||
)}
|
||||
>
|
||||
<UserList
|
||||
collaborators={appState.collaborators}
|
||||
actionManager={actionManager}
|
||||
/>
|
||||
{onCollabButtonClick && (
|
||||
<CollabButton
|
||||
isInHamburgerMenu={false}
|
||||
isCollaborating={isCollaborating}
|
||||
collaboratorCount={appState.collaborators.size}
|
||||
onClick={onCollabButtonClick}
|
||||
/>
|
||||
)}
|
||||
<UserList collaborators={appState.collaborators} />
|
||||
{renderTopRightUI?.(device.isMobile, appState)}
|
||||
{!appState.viewModeEnabled && (
|
||||
<LibraryButton appState={appState} setAppState={setAppState} />
|
||||
@@ -451,6 +380,7 @@ const LayerUI = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{restChildren}
|
||||
{appState.isLoading && <LoadingMessage delay={250} />}
|
||||
{appState.errorMessage && (
|
||||
<ErrorDialog
|
||||
@@ -465,7 +395,9 @@ const LayerUI = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ActiveConfirmDialog />
|
||||
{renderImageExportDialog()}
|
||||
{renderJSONExportDialog()}
|
||||
{appState.pasteDialog.shown && (
|
||||
<PasteChartDialog
|
||||
setAppState={setAppState}
|
||||
@@ -480,23 +412,23 @@ const LayerUI = ({
|
||||
)}
|
||||
{device.isMobile && (
|
||||
<MobileMenu
|
||||
renderWelcomeScreen={renderWelcomeScreen}
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
actionManager={actionManager}
|
||||
renderJSONExportDialog={renderJSONExportDialog}
|
||||
renderImageExportDialog={renderImageExportDialog}
|
||||
setAppState={setAppState}
|
||||
onCollabButtonClick={onCollabButtonClick}
|
||||
onLockToggle={() => onLockToggle()}
|
||||
onLockToggle={onLockToggle}
|
||||
onHandToolToggle={onHandToolToggle}
|
||||
onPenModeToggle={onPenModeToggle}
|
||||
canvas={canvas}
|
||||
isCollaborating={isCollaborating}
|
||||
onImageAction={onImageAction}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderCustomStats={renderCustomStats}
|
||||
renderSidebars={renderSidebars}
|
||||
device={device}
|
||||
renderMenu={renderMenu}
|
||||
welcomeScreenCenter={WelcomeScreenComponents.Center}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -521,14 +453,12 @@ const LayerUI = ({
|
||||
>
|
||||
{renderFixedSideContainer()}
|
||||
<Footer
|
||||
renderWelcomeScreen={renderWelcomeScreen}
|
||||
appState={appState}
|
||||
actionManager={actionManager}
|
||||
showExitZenModeBtn={showExitZenModeBtn}
|
||||
>
|
||||
{childrenComponents.FooterCenter}
|
||||
</Footer>
|
||||
|
||||
footerCenter={childrenComponents.FooterCenter}
|
||||
welcomeScreenHelp={WelcomeScreenComponents.HelpHint}
|
||||
/>
|
||||
{appState.showStats && (
|
||||
<Stats
|
||||
appState={appState}
|
||||
@@ -560,28 +490,39 @@ const LayerUI = ({
|
||||
);
|
||||
};
|
||||
|
||||
const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
|
||||
const getNecessaryObj = (appState: AppState): Partial<AppState> => {
|
||||
const {
|
||||
suggestedBindings,
|
||||
startBoundElement: boundElement,
|
||||
...ret
|
||||
} = appState;
|
||||
return ret;
|
||||
};
|
||||
const prevAppState = getNecessaryObj(prev.appState);
|
||||
const nextAppState = getNecessaryObj(next.appState);
|
||||
const stripIrrelevantAppStateProps = (
|
||||
appState: AppState,
|
||||
): Partial<AppState> => {
|
||||
const { suggestedBindings, startBoundElement, cursorButton, ...ret } =
|
||||
appState;
|
||||
return ret;
|
||||
};
|
||||
|
||||
const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
|
||||
const areEqual = (prevProps: LayerUIProps, nextProps: LayerUIProps) => {
|
||||
// short-circuit early
|
||||
if (prevProps.children !== nextProps.children) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const {
|
||||
canvas: _prevCanvas,
|
||||
// not stable, but shouldn't matter in our case
|
||||
onInsertElements: _prevOnInsertElements,
|
||||
appState: prevAppState,
|
||||
...prev
|
||||
} = prevProps;
|
||||
const {
|
||||
canvas: _nextCanvas,
|
||||
onInsertElements: _nextOnInsertElements,
|
||||
appState: nextAppState,
|
||||
...next
|
||||
} = nextProps;
|
||||
|
||||
return (
|
||||
prev.renderTopRightUI === next.renderTopRightUI &&
|
||||
prev.renderCustomStats === next.renderCustomStats &&
|
||||
prev.renderCustomSidebar === next.renderCustomSidebar &&
|
||||
prev.langCode === next.langCode &&
|
||||
prev.elements === next.elements &&
|
||||
prev.files === next.files &&
|
||||
keys.every((key) => prevAppState[key] === nextAppState[key])
|
||||
isShallowEqual(
|
||||
stripIrrelevantAppStateProps(prevAppState),
|
||||
stripIrrelevantAppStateProps(nextAppState),
|
||||
) && isShallowEqual(prev, next)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -129,4 +129,27 @@
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__sidebar__header .dropdown-menu {
|
||||
&.dropdown-menu--mobile {
|
||||
top: 100%;
|
||||
}
|
||||
.dropdown-menu-container {
|
||||
--gap: 0;
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
right: 0;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
width: 196px;
|
||||
box-shadow: var(--library-dropdown-shadow);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,14 +13,15 @@ import {
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { fileOpen } from "../data/filesystem";
|
||||
import { muteFSAbortError } from "../utils";
|
||||
import { useAtom } from "jotai";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import ConfirmDialog from "./ConfirmDialog";
|
||||
import PublishLibrary from "./PublishLibrary";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { useOutsideClickHook } from "../hooks/useOutsideClick";
|
||||
import MenuItem from "./MenuItem";
|
||||
import { isDropdownOpenAtom } from "./App";
|
||||
|
||||
import DropdownMenu from "./dropdownMenu/DropdownMenu";
|
||||
|
||||
export const isLibraryMenuOpenAtom = atom(false);
|
||||
|
||||
const getSelectedItems = (
|
||||
libraryItems: LibraryItems,
|
||||
@@ -45,7 +46,9 @@ export const LibraryMenuHeader: React.FC<{
|
||||
appState,
|
||||
}) => {
|
||||
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
||||
|
||||
const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom(
|
||||
isLibraryMenuOpenAtom,
|
||||
);
|
||||
const renderRemoveLibAlert = useCallback(() => {
|
||||
const content = selectedItems.length
|
||||
? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
|
||||
@@ -173,85 +176,87 @@ export const LibraryMenuHeader: React.FC<{
|
||||
});
|
||||
};
|
||||
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useAtom(isDropdownOpenAtom);
|
||||
const dropdownRef = useOutsideClickHook(() => setIsDropdownOpen(false));
|
||||
|
||||
const renderLibraryMenu = () => {
|
||||
return (
|
||||
<DropdownMenu open={isLibraryMenuOpen}>
|
||||
<DropdownMenu.Trigger
|
||||
className="Sidebar__dropdown-btn"
|
||||
onToggle={() => setIsLibraryMenuOpen(!isLibraryMenuOpen)}
|
||||
>
|
||||
{DotsIcon}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
onClickOutside={() => setIsLibraryMenuOpen(false)}
|
||||
onSelect={() => setIsLibraryMenuOpen(false)}
|
||||
className="library-menu"
|
||||
>
|
||||
{!itemsSelected && (
|
||||
<DropdownMenu.Item
|
||||
onSelect={onLibraryImport}
|
||||
icon={LoadIcon}
|
||||
data-testid="lib-dropdown--load"
|
||||
>
|
||||
{t("buttons.load")}
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
{!!items.length && (
|
||||
<DropdownMenu.Item
|
||||
onSelect={onLibraryExport}
|
||||
icon={ExportIcon}
|
||||
data-testid="lib-dropdown--export"
|
||||
>
|
||||
{t("buttons.export")}
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
{!!items.length && (
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => setShowRemoveLibAlert(true)}
|
||||
icon={TrashIcon}
|
||||
>
|
||||
{resetLabel}
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
{itemsSelected && (
|
||||
<DropdownMenu.Item
|
||||
icon={publishIcon}
|
||||
onSelect={() => setShowPublishLibraryDialog(true)}
|
||||
data-testid="lib-dropdown--remove"
|
||||
>
|
||||
{t("buttons.publishLibrary")}
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<button
|
||||
type="button"
|
||||
className="Sidebar__dropdown-btn"
|
||||
data-prevent-outside-click
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
>
|
||||
{DotsIcon}
|
||||
</button>
|
||||
|
||||
{renderLibraryMenu()}
|
||||
{selectedItems.length > 0 && (
|
||||
<div className="library-actions-counter">{selectedItems.length}</div>
|
||||
)}
|
||||
|
||||
{isDropdownOpen && (
|
||||
<div
|
||||
className="Sidebar__dropdown-content menu-container"
|
||||
ref={dropdownRef}
|
||||
>
|
||||
{!itemsSelected && (
|
||||
<MenuItem
|
||||
label={t("buttons.load")}
|
||||
icon={LoadIcon}
|
||||
dataTestId="lib-dropdown--load"
|
||||
onClick={onLibraryImport}
|
||||
/>
|
||||
{showRemoveLibAlert && renderRemoveLibAlert()}
|
||||
{showPublishLibraryDialog && (
|
||||
<PublishLibrary
|
||||
onClose={() => setShowPublishLibraryDialog(false)}
|
||||
libraryItems={getSelectedItems(
|
||||
libraryItemsData.libraryItems,
|
||||
selectedItems,
|
||||
)}
|
||||
{showRemoveLibAlert && renderRemoveLibAlert()}
|
||||
{showPublishLibraryDialog && (
|
||||
<PublishLibrary
|
||||
onClose={() => setShowPublishLibraryDialog(false)}
|
||||
libraryItems={getSelectedItems(
|
||||
libraryItemsData.libraryItems,
|
||||
selectedItems,
|
||||
)}
|
||||
appState={appState}
|
||||
onSuccess={(data) =>
|
||||
onPublishLibSuccess(data, libraryItemsData.libraryItems)
|
||||
}
|
||||
onError={(error) => window.alert(error)}
|
||||
updateItemsInStorage={() =>
|
||||
library.setLibrary(libraryItemsData.libraryItems)
|
||||
}
|
||||
onRemove={(id: string) =>
|
||||
onSelectItems(selectedItems.filter((_id) => _id !== id))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{publishLibSuccess && renderPublishSuccess()}
|
||||
{!!items.length && (
|
||||
<>
|
||||
<MenuItem
|
||||
label={t("buttons.export")}
|
||||
icon={ExportIcon}
|
||||
onClick={onLibraryExport}
|
||||
dataTestId="lib-dropdown--export"
|
||||
/>
|
||||
<MenuItem
|
||||
label={resetLabel}
|
||||
icon={TrashIcon}
|
||||
onClick={() => setShowRemoveLibAlert(true)}
|
||||
dataTestId="lib-dropdown--remove"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{itemsSelected && (
|
||||
<MenuItem
|
||||
label={t("buttons.publishLibrary")}
|
||||
icon={publishIcon}
|
||||
dataTestId="lib-dropdown--publish"
|
||||
onClick={() => setShowPublishLibraryDialog(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
appState={appState}
|
||||
onSuccess={(data) =>
|
||||
onPublishLibSuccess(data, libraryItemsData.libraryItems)
|
||||
}
|
||||
onError={(error) => window.alert(error)}
|
||||
updateItemsInStorage={() =>
|
||||
library.setLibrary(libraryItemsData.libraryItems)
|
||||
}
|
||||
onRemove={(id: string) =>
|
||||
onSelectItems(selectedItems.filter((_id) => _id !== id))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{publishLibSuccess && renderPublishSuccess()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,6 @@ type LockIconProps = {
|
||||
name?: string;
|
||||
checked: boolean;
|
||||
onChange?(): void;
|
||||
zenModeEnabled?: boolean;
|
||||
isMobile?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.menu-container {
|
||||
background-color: #fff !important;
|
||||
max-height: calc(100vh - 150px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.menu-button {
|
||||
@include outlineButtonStyles;
|
||||
background-color: var(--island-bg-color);
|
||||
width: var(--lg-button-size);
|
||||
height: var(--lg-button-size);
|
||||
|
||||
svg {
|
||||
width: var(--lg-icon-size);
|
||||
height: var(--lg-icon-size);
|
||||
}
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
align-items: center;
|
||||
padding: 0 0.625rem;
|
||||
height: 2rem;
|
||||
column-gap: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-gray-100);
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-md);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font-weight: normal;
|
||||
font-family: inherit;
|
||||
|
||||
@media screen and (min-width: 1921px) {
|
||||
height: 2.25rem;
|
||||
}
|
||||
|
||||
&__text {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__shortcut {
|
||||
margin-inline-start: auto;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-hover);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&.active-collab {
|
||||
background-color: #ecfdf5;
|
||||
color: #064e3c;
|
||||
}
|
||||
}
|
||||
|
||||
&.theme--dark {
|
||||
.menu-item {
|
||||
color: var(--color-gray-40);
|
||||
|
||||
&.active-collab {
|
||||
background-color: #064e3c;
|
||||
color: #ecfdf5;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-container {
|
||||
background-color: var(--color-gray-90) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import clsx from "clsx";
|
||||
import "./Menu.scss";
|
||||
|
||||
interface MenuProps {
|
||||
icon: JSX.Element;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
dataTestId: string;
|
||||
shortcut?: string;
|
||||
isCollaborating?: boolean;
|
||||
}
|
||||
|
||||
const MenuItem = ({
|
||||
icon,
|
||||
onClick,
|
||||
label,
|
||||
dataTestId,
|
||||
shortcut,
|
||||
isCollaborating,
|
||||
}: MenuProps) => {
|
||||
return (
|
||||
<button
|
||||
className={clsx("menu-item", { "active-collab": isCollaborating })}
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
data-testid={dataTestId}
|
||||
title={label}
|
||||
type="button"
|
||||
>
|
||||
<div className="menu-item__icon">{icon}</div>
|
||||
<div className="menu-item__text">{label}</div>
|
||||
{shortcut && <div className="menu-item__shortcut">{shortcut}</div>}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuItem;
|
||||
@@ -1,53 +0,0 @@
|
||||
import { GithubIcon, DiscordIcon, PlusPromoIcon, TwitterIcon } from "./icons";
|
||||
|
||||
export const MenuLinks = () => (
|
||||
<>
|
||||
<a
|
||||
href="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="menu-item"
|
||||
style={{ color: "var(--color-promo)" }}
|
||||
>
|
||||
<div className="menu-item__icon">{PlusPromoIcon}</div>
|
||||
<div className="menu-item__text">Excalidraw+</div>
|
||||
</a>
|
||||
<a
|
||||
className="menu-item"
|
||||
href="https://github.com/excalidraw/excalidraw"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="menu-item__icon">{GithubIcon}</div>
|
||||
<div className="menu-item__text">GitHub</div>
|
||||
</a>
|
||||
<a
|
||||
className="menu-item"
|
||||
target="_blank"
|
||||
href="https://discord.gg/UexuTaE"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="menu-item__icon">{DiscordIcon}</div>
|
||||
<div className="menu-item__text">Discord</div>
|
||||
</a>
|
||||
<a
|
||||
className="menu-item"
|
||||
target="_blank"
|
||||
href="https://twitter.com/excalidraw"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="menu-item__icon">{TwitterIcon}</div>
|
||||
<div className="menu-item__text">Twitter</div>
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
|
||||
export const Separator = () => (
|
||||
<div
|
||||
style={{
|
||||
height: "1px",
|
||||
backgroundColor: "var(--default-border-color)",
|
||||
margin: ".5rem 0",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
+39
-125
@@ -1,5 +1,10 @@
|
||||
import React from "react";
|
||||
import { AppState, Device, ExcalidrawProps } from "../types";
|
||||
import {
|
||||
AppState,
|
||||
Device,
|
||||
ExcalidrawProps,
|
||||
UIWelcomeScreenComponents,
|
||||
} from "../types";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { t } from "../i18n";
|
||||
import Stack from "./Stack";
|
||||
@@ -11,18 +16,14 @@ import { HintViewer } from "./HintViewer";
|
||||
import { calculateScrollCenter } from "../scene";
|
||||
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||
import { Section } from "./Section";
|
||||
import CollabButton from "./CollabButton";
|
||||
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
||||
import { LockButton } from "./LockButton";
|
||||
import { UserList } from "./UserList";
|
||||
import { LibraryButton } from "./LibraryButton";
|
||||
import { PenModeButton } from "./PenModeButton";
|
||||
import { Stats } from "./Stats";
|
||||
import { actionToggleStats } from "../actions";
|
||||
import { MenuLinks, Separator } from "./MenuUtils";
|
||||
import WelcomeScreen from "./WelcomeScreen";
|
||||
import MenuItem from "./MenuItem";
|
||||
import { ExportImageIcon } from "./icons";
|
||||
import { HandButton } from "./HandButton";
|
||||
import { isHandToolActive } from "../appState";
|
||||
|
||||
type MobileMenuProps = {
|
||||
appState: AppState;
|
||||
@@ -31,11 +32,10 @@ type MobileMenuProps = {
|
||||
renderImageExportDialog: () => React.ReactNode;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
onCollabButtonClick?: () => void;
|
||||
onLockToggle: () => void;
|
||||
onHandToolToggle: () => void;
|
||||
onPenModeToggle: () => void;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
isCollaborating: boolean;
|
||||
|
||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||
renderTopRightUI?: (
|
||||
@@ -45,34 +45,31 @@ type MobileMenuProps = {
|
||||
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
||||
renderSidebars: () => JSX.Element | null;
|
||||
device: Device;
|
||||
renderWelcomeScreen?: boolean;
|
||||
renderMenu: () => React.ReactNode;
|
||||
welcomeScreenCenter: UIWelcomeScreenComponents["Center"];
|
||||
};
|
||||
|
||||
export const MobileMenu = ({
|
||||
appState,
|
||||
elements,
|
||||
actionManager,
|
||||
renderJSONExportDialog,
|
||||
renderImageExportDialog,
|
||||
setAppState,
|
||||
onCollabButtonClick,
|
||||
onLockToggle,
|
||||
onHandToolToggle,
|
||||
onPenModeToggle,
|
||||
canvas,
|
||||
isCollaborating,
|
||||
onImageAction,
|
||||
renderTopRightUI,
|
||||
renderCustomStats,
|
||||
renderSidebars,
|
||||
device,
|
||||
renderWelcomeScreen,
|
||||
renderMenu,
|
||||
welcomeScreenCenter,
|
||||
}: MobileMenuProps) => {
|
||||
const renderToolbar = () => {
|
||||
return (
|
||||
<FixedSideContainer side="top" className="App-top-bar">
|
||||
{renderWelcomeScreen && !appState.isLoading && (
|
||||
<WelcomeScreen appState={appState} actionManager={actionManager} />
|
||||
)}
|
||||
{welcomeScreenCenter}
|
||||
<Section heading="shapes">
|
||||
{(heading: React.ReactNode) => (
|
||||
<Stack.Col gap={4} align="center">
|
||||
@@ -80,20 +77,6 @@ export const MobileMenu = ({
|
||||
<Island padding={1} className="App-toolbar App-toolbar--mobile">
|
||||
{heading}
|
||||
<Stack.Row gap={1}>
|
||||
{/* <PenModeButton
|
||||
checked={appState.penMode}
|
||||
onChange={onPenModeToggle}
|
||||
title={t("toolBar.penMode")}
|
||||
isMobile
|
||||
penDetected={appState.penDetected}
|
||||
/>
|
||||
<LockButton
|
||||
checked={appState.activeTool.locked}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
isMobile
|
||||
/>
|
||||
<div className="App-toolbar__divider"></div> */}
|
||||
<ShapesSwitcher
|
||||
appState={appState}
|
||||
canvas={canvas}
|
||||
@@ -109,20 +92,6 @@ export const MobileMenu = ({
|
||||
</Island>
|
||||
{renderTopRightUI && renderTopRightUI(true, appState)}
|
||||
<div className="mobile-misc-tools-container">
|
||||
<PenModeButton
|
||||
checked={appState.penMode}
|
||||
onChange={onPenModeToggle}
|
||||
title={t("toolBar.penMode")}
|
||||
isMobile
|
||||
penDetected={appState.penDetected}
|
||||
// penDetected={true}
|
||||
/>
|
||||
<LockButton
|
||||
checked={appState.activeTool.locked}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
isMobile
|
||||
/>
|
||||
{!appState.viewModeEnabled && (
|
||||
<LibraryButton
|
||||
appState={appState}
|
||||
@@ -130,6 +99,25 @@ export const MobileMenu = ({
|
||||
isMobile
|
||||
/>
|
||||
)}
|
||||
<PenModeButton
|
||||
checked={appState.penMode}
|
||||
onChange={onPenModeToggle}
|
||||
title={t("toolBar.penMode")}
|
||||
isMobile
|
||||
penDetected={appState.penDetected}
|
||||
/>
|
||||
<LockButton
|
||||
checked={appState.activeTool.locked}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
isMobile
|
||||
/>
|
||||
<HandButton
|
||||
checked={isHandToolActive(appState)}
|
||||
onChange={() => onHandToolToggle()}
|
||||
title={t("toolBar.hand")}
|
||||
isMobile
|
||||
/>
|
||||
</div>
|
||||
</Stack.Row>
|
||||
</Stack.Col>
|
||||
@@ -147,16 +135,12 @@ export const MobileMenu = ({
|
||||
|
||||
const renderAppToolbar = () => {
|
||||
if (appState.viewModeEnabled) {
|
||||
return (
|
||||
<div className="App-toolbar-content">
|
||||
{actionManager.renderAction("toggleCanvasMenu")}
|
||||
</div>
|
||||
);
|
||||
return <div className="App-toolbar-content">{renderMenu()}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="App-toolbar-content">
|
||||
{actionManager.renderAction("toggleCanvasMenu")}
|
||||
{renderMenu()}
|
||||
{actionManager.renderAction("toggleEditMenu")}
|
||||
{actionManager.renderAction("undo")}
|
||||
{actionManager.renderAction("redo")}
|
||||
@@ -168,58 +152,6 @@ export const MobileMenu = ({
|
||||
);
|
||||
};
|
||||
|
||||
const renderCanvasActions = () => {
|
||||
if (appState.viewModeEnabled) {
|
||||
return (
|
||||
<>
|
||||
{renderJSONExportDialog()}
|
||||
<MenuItem
|
||||
label={t("buttons.exportImage")}
|
||||
icon={ExportImageIcon}
|
||||
dataTestId="image-export-button"
|
||||
onClick={() => setAppState({ openDialog: "imageExport" })}
|
||||
/>
|
||||
{renderImageExportDialog()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{!appState.viewModeEnabled && actionManager.renderAction("loadScene")}
|
||||
{renderJSONExportDialog()}
|
||||
{renderImageExportDialog()}
|
||||
<MenuItem
|
||||
label={t("buttons.exportImage")}
|
||||
icon={ExportImageIcon}
|
||||
dataTestId="image-export-button"
|
||||
onClick={() => setAppState({ openDialog: "imageExport" })}
|
||||
/>
|
||||
{onCollabButtonClick && (
|
||||
<CollabButton
|
||||
isCollaborating={isCollaborating}
|
||||
collaboratorCount={appState.collaborators.size}
|
||||
onClick={onCollabButtonClick}
|
||||
/>
|
||||
)}
|
||||
{actionManager.renderAction("toggleShortcuts", undefined, true)}
|
||||
{!appState.viewModeEnabled && actionManager.renderAction("clearCanvas")}
|
||||
<Separator />
|
||||
<MenuLinks />
|
||||
<Separator />
|
||||
{!appState.viewModeEnabled && (
|
||||
<div style={{ marginBottom: ".5rem" }}>
|
||||
<div style={{ fontSize: ".75rem", marginBottom: ".5rem" }}>
|
||||
{t("labels.canvasBackground")}
|
||||
</div>
|
||||
<div style={{ padding: "0 0.625rem" }}>
|
||||
{actionManager.renderAction("changeViewBackgroundColor")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{actionManager.renderAction("toggleTheme")}
|
||||
</>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{renderSidebars()}
|
||||
@@ -244,27 +176,9 @@ export const MobileMenu = ({
|
||||
}}
|
||||
>
|
||||
<Island padding={0}>
|
||||
{appState.openMenu === "canvas" ? (
|
||||
<Section className="App-mobile-menu" heading="canvasActions">
|
||||
<div className="panelColumn">
|
||||
<Stack.Col gap={2}>
|
||||
{renderCanvasActions()}
|
||||
{appState.collaborators.size > 0 && (
|
||||
<fieldset>
|
||||
<legend>{t("labels.collaborators")}</legend>
|
||||
<UserList
|
||||
mobile
|
||||
collaborators={appState.collaborators}
|
||||
actionManager={actionManager}
|
||||
/>
|
||||
</fieldset>
|
||||
)}
|
||||
</Stack.Col>
|
||||
</div>
|
||||
</Section>
|
||||
) : appState.openMenu === "shape" &&
|
||||
!appState.viewModeEnabled &&
|
||||
showSelectedShapeActions(appState, elements) ? (
|
||||
{appState.openMenu === "shape" &&
|
||||
!appState.viewModeEnabled &&
|
||||
showSelectedShapeActions(appState, elements) ? (
|
||||
<Section className="App-mobile-menu" heading="selectedShapeActions">
|
||||
<SelectedShapeActions
|
||||
appState={appState}
|
||||
|
||||
@@ -3,24 +3,6 @@
|
||||
|
||||
.excalidraw {
|
||||
.Sidebar {
|
||||
&__dropdown-content {
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
right: 0;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
margin-top: 0.25rem;
|
||||
width: 180px;
|
||||
box-shadow: var(--library-dropdown-shadow);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
&__close-btn,
|
||||
&__pin-btn,
|
||||
&__dropdown-btn {
|
||||
|
||||
@@ -19,7 +19,7 @@ type ToolButtonBaseProps = {
|
||||
name?: string;
|
||||
id?: string;
|
||||
size?: ToolButtonSize;
|
||||
keyBindingLabel?: string;
|
||||
keyBindingLabel?: string | null;
|
||||
showAriaLabel?: boolean;
|
||||
hidden?: boolean;
|
||||
visible?: boolean;
|
||||
|
||||
@@ -4,16 +4,16 @@ import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { AppState, Collaborator } from "../types";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { useExcalidrawActionManager } from "./App";
|
||||
|
||||
export const UserList: React.FC<{
|
||||
className?: string;
|
||||
mobile?: boolean;
|
||||
collaborators: AppState["collaborators"];
|
||||
actionManager: ActionManager;
|
||||
}> = ({ className, mobile, collaborators, actionManager }) => {
|
||||
const uniqueCollaborators = new Map<string, Collaborator>();
|
||||
}> = ({ className, mobile, collaborators }) => {
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
const uniqueCollaborators = new Map<string, Collaborator>();
|
||||
collaborators.forEach((collaborator, socketId) => {
|
||||
uniqueCollaborators.set(
|
||||
// filter on user id, else fall back on unique socketId
|
||||
@@ -44,26 +44,6 @@ export const UserList: React.FC<{
|
||||
);
|
||||
});
|
||||
|
||||
// TODO barnabasmolnar/editor-redesign
|
||||
// probably remove before shipping :)
|
||||
// 20 fake collaborators; for easy, convenient debug purposes ˇˇ
|
||||
// const avatars = Array.from({ length: 20 }).map((_, index) => {
|
||||
// const avatarJSX = actionManager.renderAction("goToCollaborator", [
|
||||
// index.toString(),
|
||||
// {
|
||||
// username: `User ${index}`,
|
||||
// },
|
||||
// ]);
|
||||
|
||||
// return mobile ? (
|
||||
// <Tooltip label={`User ${index}`} key={index}>
|
||||
// {avatarJSX}
|
||||
// </Tooltip>
|
||||
// ) : (
|
||||
// <React.Fragment key={index}>{avatarJSX}</React.Fragment>
|
||||
// );
|
||||
// });
|
||||
|
||||
return (
|
||||
<div className={clsx("UserList", className, { UserList_mobile: mobile })}>
|
||||
{avatars}
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { useAtom } from "jotai";
|
||||
import { actionLoadScene, actionShortcuts } from "../actions";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { getShortcutFromShortcutName } from "../actions/shortcuts";
|
||||
import { isExcalidrawPlusSignedUser } from "../constants";
|
||||
import { collabDialogShownAtom } from "../excalidraw-app/collab/Collab";
|
||||
import { t } from "../i18n";
|
||||
import { AppState } from "../types";
|
||||
import {
|
||||
ExcalLogo,
|
||||
HelpIcon,
|
||||
LoadIcon,
|
||||
PlusPromoIcon,
|
||||
UsersIcon,
|
||||
} from "./icons";
|
||||
import "./WelcomeScreen.scss";
|
||||
|
||||
const WelcomeScreenItem = ({
|
||||
label,
|
||||
shortcut,
|
||||
onClick,
|
||||
icon,
|
||||
link,
|
||||
}: {
|
||||
label: string;
|
||||
shortcut: string | null;
|
||||
onClick?: () => void;
|
||||
icon: JSX.Element;
|
||||
link?: string;
|
||||
}) => {
|
||||
if (link) {
|
||||
return (
|
||||
<a
|
||||
className="WelcomeScreen-item"
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<div className="WelcomeScreen-item__label">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button className="WelcomeScreen-item" type="button" onClick={onClick}>
|
||||
<div className="WelcomeScreen-item__label">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
{shortcut && (
|
||||
<div className="WelcomeScreen-item__shortcut">{shortcut}</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const WelcomeScreen = ({
|
||||
appState,
|
||||
actionManager,
|
||||
}: {
|
||||
appState: AppState;
|
||||
actionManager: ActionManager;
|
||||
}) => {
|
||||
const [, setCollabDialogShown] = useAtom(collabDialogShownAtom);
|
||||
|
||||
let subheadingJSX;
|
||||
|
||||
if (isExcalidrawPlusSignedUser) {
|
||||
subheadingJSX = t("welcomeScreen.switchToPlusApp")
|
||||
.split(/(Excalidraw\+)/)
|
||||
.map((bit, idx) => {
|
||||
if (bit === "Excalidraw+") {
|
||||
return (
|
||||
<a
|
||||
style={{ pointerEvents: "all" }}
|
||||
href={`${process.env.REACT_APP_PLUS_APP}?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenSignedInUser`}
|
||||
key={idx}
|
||||
>
|
||||
Excalidraw+
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return bit;
|
||||
});
|
||||
} else {
|
||||
subheadingJSX = t("welcomeScreen.data");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="WelcomeScreen-container">
|
||||
<div className="WelcomeScreen-logo virgil WelcomeScreen-decor">
|
||||
{ExcalLogo} Excalidraw
|
||||
</div>
|
||||
<div className="virgil WelcomeScreen-decor WelcomeScreen-decor--subheading">
|
||||
{subheadingJSX}
|
||||
</div>
|
||||
<div className="WelcomeScreen-items">
|
||||
{!appState.viewModeEnabled && (
|
||||
<WelcomeScreenItem
|
||||
// TODO barnabasmolnar/editor-redesign
|
||||
// do we want the internationalized labels here that are currently
|
||||
// in use elsewhere or new ones?
|
||||
label={t("buttons.load")}
|
||||
onClick={() => actionManager.executeAction(actionLoadScene)}
|
||||
shortcut={getShortcutFromShortcutName("loadScene")}
|
||||
icon={LoadIcon}
|
||||
/>
|
||||
)}
|
||||
<WelcomeScreenItem
|
||||
label={t("labels.liveCollaboration")}
|
||||
shortcut={null}
|
||||
onClick={() => setCollabDialogShown(true)}
|
||||
icon={UsersIcon}
|
||||
/>
|
||||
<WelcomeScreenItem
|
||||
onClick={() => actionManager.executeAction(actionShortcuts)}
|
||||
label={t("helpDialog.title")}
|
||||
shortcut="?"
|
||||
icon={HelpIcon}
|
||||
/>
|
||||
{!isExcalidrawPlusSignedUser && (
|
||||
<WelcomeScreenItem
|
||||
link="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
|
||||
label="Try Excalidraw Plus!"
|
||||
shortcut={null}
|
||||
icon={PlusPromoIcon}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WelcomeScreen;
|
||||
@@ -1,11 +0,0 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
const WelcomeScreenDecor = ({
|
||||
children,
|
||||
shouldRender,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
shouldRender: boolean;
|
||||
}) => (shouldRender ? <>{children}</> : null);
|
||||
|
||||
export default WelcomeScreenDecor;
|
||||
@@ -0,0 +1,127 @@
|
||||
@import "../../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
margin-top: 0.25rem;
|
||||
|
||||
&--mobile {
|
||||
bottom: 55px;
|
||||
top: auto;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 0.75rem;
|
||||
|
||||
.dropdown-menu-container {
|
||||
padding: 8px 8px;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--island-bg-color);
|
||||
box-shadow: var(--shadow-island);
|
||||
border-radius: var(--border-radius-lg);
|
||||
position: relative;
|
||||
transition: box-shadow 0.5s ease-in-out;
|
||||
|
||||
&.zen-mode {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu-container {
|
||||
background-color: #fff !important;
|
||||
max-height: calc(100vh - 150px);
|
||||
overflow-y: auto;
|
||||
--gap: 2;
|
||||
}
|
||||
|
||||
.dropdown-menu-item-base {
|
||||
display: flex;
|
||||
padding: 0 0.625rem;
|
||||
column-gap: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-gray-100);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font-weight: normal;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.dropdown-menu-item {
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
align-items: center;
|
||||
height: 2rem;
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-md);
|
||||
|
||||
@media screen and (min-width: 1921px) {
|
||||
height: 2.25rem;
|
||||
}
|
||||
|
||||
&__text {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__shortcut {
|
||||
margin-inline-start: auto;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-hover-bg);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu-item-custom {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.dropdown-menu-group-title {
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
margin: 10px 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
&.theme--dark {
|
||||
.dropdown-menu-item {
|
||||
color: var(--color-gray-40);
|
||||
}
|
||||
|
||||
.dropdown-menu-container {
|
||||
background-color: var(--color-gray-90) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu-button {
|
||||
@include outlineButtonStyles;
|
||||
background-color: var(--island-bg-color);
|
||||
width: var(--lg-button-size);
|
||||
height: var(--lg-button-size);
|
||||
|
||||
svg {
|
||||
width: var(--lg-icon-size);
|
||||
height: var(--lg-icon-size);
|
||||
}
|
||||
|
||||
&--mobile {
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: var(--default-button-size);
|
||||
height: var(--default-button-size);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
import DropdownMenuTrigger from "./DropdownMenuTrigger";
|
||||
import DropdownMenuItem from "./DropdownMenuItem";
|
||||
import MenuSeparator from "./DropdownMenuSeparator";
|
||||
import DropdownMenuGroup from "./DropdownMenuGroup";
|
||||
import DropdownMenuContent from "./DropdownMenuContent";
|
||||
import DropdownMenuItemLink from "./DropdownMenuItemLink";
|
||||
import DropdownMenuItemCustom from "./DropdownMenuItemCustom";
|
||||
import {
|
||||
getMenuContentComponent,
|
||||
getMenuTriggerComponent,
|
||||
} from "./dropdownMenuUtils";
|
||||
|
||||
import "./DropdownMenu.scss";
|
||||
|
||||
const DropdownMenu = ({
|
||||
children,
|
||||
open,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
open: boolean;
|
||||
}) => {
|
||||
const MenuTriggerComp = getMenuTriggerComponent(children);
|
||||
const MenuContentComp = getMenuContentComponent(children);
|
||||
return (
|
||||
<>
|
||||
{MenuTriggerComp}
|
||||
{open && MenuContentComp}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
DropdownMenu.Trigger = DropdownMenuTrigger;
|
||||
DropdownMenu.Content = DropdownMenuContent;
|
||||
DropdownMenu.Item = DropdownMenuItem;
|
||||
DropdownMenu.ItemLink = DropdownMenuItemLink;
|
||||
DropdownMenu.ItemCustom = DropdownMenuItemCustom;
|
||||
DropdownMenu.Group = DropdownMenuGroup;
|
||||
DropdownMenu.Separator = MenuSeparator;
|
||||
|
||||
export default DropdownMenu;
|
||||
|
||||
DropdownMenu.displayName = "DropdownMenu";
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useOutsideClickHook } from "../../hooks/useOutsideClick";
|
||||
import { Island } from "../Island";
|
||||
|
||||
import { useDevice } from "../App";
|
||||
import clsx from "clsx";
|
||||
import Stack from "../Stack";
|
||||
import React from "react";
|
||||
import { DropdownMenuContentPropsContext } from "./common";
|
||||
|
||||
const MenuContent = ({
|
||||
children,
|
||||
onClickOutside,
|
||||
className = "",
|
||||
onSelect,
|
||||
style,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
onClickOutside?: () => void;
|
||||
className?: string;
|
||||
/**
|
||||
* Called when any menu item is selected (clicked on).
|
||||
*/
|
||||
onSelect?: (event: Event) => void;
|
||||
style?: React.CSSProperties;
|
||||
}) => {
|
||||
const device = useDevice();
|
||||
const menuRef = useOutsideClickHook(() => {
|
||||
onClickOutside?.();
|
||||
});
|
||||
|
||||
const classNames = clsx(`dropdown-menu ${className}`, {
|
||||
"dropdown-menu--mobile": device.isMobile,
|
||||
}).trim();
|
||||
|
||||
return (
|
||||
<DropdownMenuContentPropsContext.Provider value={{ onSelect }}>
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={classNames}
|
||||
style={style}
|
||||
data-testid="dropdown-menu"
|
||||
>
|
||||
{/* the zIndex ensures this menu has higher stacking order,
|
||||
see https://github.com/excalidraw/excalidraw/pull/1445 */}
|
||||
{device.isMobile ? (
|
||||
<Stack.Col className="dropdown-menu-container">{children}</Stack.Col>
|
||||
) : (
|
||||
<Island
|
||||
className="dropdown-menu-container"
|
||||
padding={2}
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
{children}
|
||||
</Island>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContentPropsContext.Provider>
|
||||
);
|
||||
};
|
||||
MenuContent.displayName = "DropdownMenuContent";
|
||||
|
||||
export default MenuContent;
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
|
||||
const MenuGroup = ({
|
||||
children,
|
||||
className = "",
|
||||
style,
|
||||
title,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
title?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className={`dropdown-menu-group ${className}`} style={style}>
|
||||
{title && <p className="dropdown-menu-group-title">{title}</p>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuGroup;
|
||||
MenuGroup.displayName = "DropdownMenuGroup";
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from "react";
|
||||
import {
|
||||
getDrodownMenuItemClassName,
|
||||
useHandleDropdownMenuItemClick,
|
||||
} from "./common";
|
||||
import MenuItemContent from "./DropdownMenuItemContent";
|
||||
|
||||
const DropdownMenuItem = ({
|
||||
icon,
|
||||
onSelect,
|
||||
children,
|
||||
shortcut,
|
||||
className,
|
||||
...rest
|
||||
}: {
|
||||
icon?: JSX.Element;
|
||||
onSelect: (event: Event) => void;
|
||||
children: React.ReactNode;
|
||||
shortcut?: string;
|
||||
className?: string;
|
||||
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
|
||||
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
|
||||
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
className={getDrodownMenuItemClassName(className)}
|
||||
title={rest.title ?? rest["aria-label"]}
|
||||
>
|
||||
<MenuItemContent icon={icon} shortcut={shortcut}>
|
||||
{children}
|
||||
</MenuItemContent>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownMenuItem;
|
||||
DropdownMenuItem.displayName = "DropdownMenuItem";
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useDevice } from "../App";
|
||||
|
||||
const MenuItemContent = ({
|
||||
icon,
|
||||
shortcut,
|
||||
children,
|
||||
}: {
|
||||
icon?: JSX.Element;
|
||||
shortcut?: string;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const device = useDevice();
|
||||
return (
|
||||
<>
|
||||
<div className="dropdown-menu-item__icon">{icon}</div>
|
||||
<div className="dropdown-menu-item__text">{children}</div>
|
||||
{shortcut && !device.isMobile && (
|
||||
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default MenuItemContent;
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
|
||||
const DropdownMenuItemCustom = ({
|
||||
children,
|
||||
className = "",
|
||||
...rest
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.HTMLAttributes<HTMLDivElement>) => {
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
className={`dropdown-menu-item-base dropdown-menu-item-custom ${className}`.trim()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownMenuItemCustom;
|
||||
@@ -0,0 +1,44 @@
|
||||
import MenuItemContent from "./DropdownMenuItemContent";
|
||||
import React from "react";
|
||||
import {
|
||||
getDrodownMenuItemClassName,
|
||||
useHandleDropdownMenuItemClick,
|
||||
} from "./common";
|
||||
|
||||
const DropdownMenuItemLink = ({
|
||||
icon,
|
||||
shortcut,
|
||||
href,
|
||||
children,
|
||||
onSelect,
|
||||
className = "",
|
||||
...rest
|
||||
}: {
|
||||
href: string;
|
||||
icon?: JSX.Element;
|
||||
children: React.ReactNode;
|
||||
shortcut?: string;
|
||||
className?: string;
|
||||
onSelect?: (event: Event) => void;
|
||||
} & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
|
||||
|
||||
return (
|
||||
<a
|
||||
{...rest}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={getDrodownMenuItemClassName(className)}
|
||||
title={rest.title ?? rest["aria-label"]}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<MenuItemContent icon={icon} shortcut={shortcut}>
|
||||
{children}
|
||||
</MenuItemContent>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownMenuItemLink;
|
||||
DropdownMenuItemLink.displayName = "DropdownMenuItemLink";
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
|
||||
const MenuSeparator = () => (
|
||||
<div
|
||||
style={{
|
||||
height: "1px",
|
||||
backgroundColor: "var(--default-border-color)",
|
||||
margin: ".5rem 0",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
export default MenuSeparator;
|
||||
MenuSeparator.displayName = "DropdownMenuSeparator";
|
||||
@@ -0,0 +1,37 @@
|
||||
import clsx from "clsx";
|
||||
import { useDevice, useExcalidrawAppState } from "../App";
|
||||
|
||||
const MenuTrigger = ({
|
||||
className = "",
|
||||
children,
|
||||
onToggle,
|
||||
}: {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
onToggle: () => void;
|
||||
}) => {
|
||||
const appState = useExcalidrawAppState();
|
||||
const device = useDevice();
|
||||
const classNames = clsx(
|
||||
`dropdown-menu-button ${className}`,
|
||||
"zen-mode-transition",
|
||||
{
|
||||
"transition-left": appState.zenModeEnabled,
|
||||
"dropdown-menu-button--mobile": device.isMobile,
|
||||
},
|
||||
).trim();
|
||||
return (
|
||||
<button
|
||||
data-prevent-outside-click
|
||||
className={classNames}
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
data-testid="dropdown-menu-button"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuTrigger;
|
||||
MenuTrigger.displayName = "DropdownMenuTrigger";
|
||||
@@ -0,0 +1,31 @@
|
||||
import React, { useContext } from "react";
|
||||
import { EVENT } from "../../constants";
|
||||
import { composeEventHandlers } from "../../utils";
|
||||
|
||||
export const DropdownMenuContentPropsContext = React.createContext<{
|
||||
onSelect?: (event: Event) => void;
|
||||
}>({});
|
||||
|
||||
export const getDrodownMenuItemClassName = (className = "") => {
|
||||
return `dropdown-menu-item dropdown-menu-item-base ${className}`.trim();
|
||||
};
|
||||
|
||||
export const useHandleDropdownMenuItemClick = (
|
||||
origOnClick:
|
||||
| React.MouseEventHandler<HTMLAnchorElement | HTMLButtonElement>
|
||||
| undefined,
|
||||
onSelect: ((event: Event) => void) | undefined,
|
||||
) => {
|
||||
const DropdownMenuContentProps = useContext(DropdownMenuContentPropsContext);
|
||||
|
||||
return composeEventHandlers(origOnClick, (event) => {
|
||||
const itemSelectEvent = new CustomEvent(EVENT.MENU_ITEM_SELECT, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
onSelect?.(itemSelectEvent);
|
||||
if (!itemSelectEvent.defaultPrevented) {
|
||||
DropdownMenuContentProps.onSelect?.(itemSelectEvent);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
|
||||
export const getMenuTriggerComponent = (children: React.ReactNode) => {
|
||||
const comp = React.Children.toArray(children).find(
|
||||
(child) =>
|
||||
React.isValidElement(child) &&
|
||||
typeof child.type !== "string" &&
|
||||
//@ts-ignore
|
||||
child?.type.displayName &&
|
||||
//@ts-ignore
|
||||
child.type.displayName === "DropdownMenuTrigger",
|
||||
);
|
||||
if (!comp) {
|
||||
return null;
|
||||
}
|
||||
//@ts-ignore
|
||||
return comp;
|
||||
};
|
||||
|
||||
export const getMenuContentComponent = (children: React.ReactNode) => {
|
||||
const comp = React.Children.toArray(children).find(
|
||||
(child) =>
|
||||
React.isValidElement(child) &&
|
||||
typeof child.type !== "string" &&
|
||||
//@ts-ignore
|
||||
child?.type.displayName &&
|
||||
//@ts-ignore
|
||||
child.type.displayName === "DropdownMenuContent",
|
||||
);
|
||||
if (!comp) {
|
||||
return null;
|
||||
}
|
||||
//@ts-ignore
|
||||
return comp;
|
||||
};
|
||||
@@ -1,7 +1,11 @@
|
||||
import clsx from "clsx";
|
||||
import { actionShortcuts } from "../../actions";
|
||||
import { ActionManager } from "../../actions/manager";
|
||||
import { t } from "../../i18n";
|
||||
import { AppState } from "../../types";
|
||||
import {
|
||||
AppState,
|
||||
UIChildrenComponents,
|
||||
UIWelcomeScreenComponents,
|
||||
} from "../../types";
|
||||
import {
|
||||
ExitZenModeAction,
|
||||
FinalizeAction,
|
||||
@@ -9,24 +13,22 @@ import {
|
||||
ZoomActions,
|
||||
} from "../Actions";
|
||||
import { useDevice } from "../App";
|
||||
import { WelcomeScreenHelpArrow } from "../icons";
|
||||
import { HelpButton } from "../HelpButton";
|
||||
import { Section } from "../Section";
|
||||
import Stack from "../Stack";
|
||||
import WelcomeScreenDecor from "../WelcomeScreenDecor";
|
||||
import FooterCenter from "./FooterCenter";
|
||||
|
||||
const Footer = ({
|
||||
appState,
|
||||
actionManager,
|
||||
showExitZenModeBtn,
|
||||
renderWelcomeScreen,
|
||||
children,
|
||||
footerCenter,
|
||||
welcomeScreenHelp,
|
||||
}: {
|
||||
appState: AppState;
|
||||
actionManager: ActionManager;
|
||||
showExitZenModeBtn: boolean;
|
||||
renderWelcomeScreen: boolean;
|
||||
children?: React.ReactNode;
|
||||
footerCenter: UIChildrenComponents["FooterCenter"];
|
||||
welcomeScreenHelp: UIWelcomeScreenComponents["HelpHint"];
|
||||
}) => {
|
||||
const device = useDevice();
|
||||
const showFinalize =
|
||||
@@ -71,23 +73,17 @@ const Footer = ({
|
||||
</Section>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
<FooterCenter>{children}</FooterCenter>
|
||||
{footerCenter}
|
||||
<div
|
||||
className={clsx("layer-ui__wrapper__footer-right zen-mode-transition", {
|
||||
"transition-right disable-pointerEvents": appState.zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
<div style={{ position: "relative" }}>
|
||||
<WelcomeScreenDecor
|
||||
shouldRender={renderWelcomeScreen && !appState.isLoading}
|
||||
>
|
||||
<div className="virgil WelcomeScreen-decor WelcomeScreen-decor--help-pointer">
|
||||
<div>{t("welcomeScreen.helpHints")}</div>
|
||||
{WelcomeScreenHelpArrow}
|
||||
</div>
|
||||
</WelcomeScreenDecor>
|
||||
|
||||
{actionManager.renderAction("toggleShortcuts")}
|
||||
{welcomeScreenHelp}
|
||||
<HelpButton
|
||||
onClick={() => actionManager.executeAction(actionShortcuts)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ExitZenModeAction
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
.footer-center {
|
||||
pointer-events: none;
|
||||
& > * {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import clsx from "clsx";
|
||||
import { useExcalidrawAppState } from "../App";
|
||||
import "./FooterCenter.scss";
|
||||
|
||||
const FooterCenter = ({ children }: { children?: React.ReactNode }) => {
|
||||
const appState = useExcalidrawAppState();
|
||||
return (
|
||||
<div
|
||||
className={clsx("layer-ui__wrapper__footer-center zen-mode-transition", {
|
||||
className={clsx("footer-center zen-mode-transition", {
|
||||
"layer-ui__wrapper__footer-left--transition-bottom":
|
||||
appState.zenModeEnabled,
|
||||
})}
|
||||
|
||||
@@ -883,7 +883,7 @@ export const CenterHorizontallyIcon = createIcon(
|
||||
modifiedTablerIconProps,
|
||||
);
|
||||
|
||||
export const UsersIcon = createIcon(
|
||||
export const usersIcon = createIcon(
|
||||
<g strokeWidth="1.5">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<circle cx="9" cy="7" r="4"></circle>
|
||||
@@ -1532,3 +1532,14 @@ export const publishIcon = createIcon(
|
||||
export const eraser = createIcon(
|
||||
<path d="M480 416C497.7 416 512 430.3 512 448C512 465.7 497.7 480 480 480H150.6C133.7 480 117.4 473.3 105.4 461.3L25.37 381.3C.3786 356.3 .3786 315.7 25.37 290.7L258.7 57.37C283.7 32.38 324.3 32.38 349.3 57.37L486.6 194.7C511.6 219.7 511.6 260.3 486.6 285.3L355.9 416H480zM265.4 416L332.7 348.7L195.3 211.3L70.63 336L150.6 416L265.4 416z" />,
|
||||
);
|
||||
|
||||
export const handIcon = createIcon(
|
||||
<g strokeWidth={1.25}>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M8 13v-7.5a1.5 1.5 0 0 1 3 0v6.5"></path>
|
||||
<path d="M11 5.5v-2a1.5 1.5 0 1 1 3 0v8.5"></path>
|
||||
<path d="M14 5.5a1.5 1.5 0 0 1 3 0v6.5"></path>
|
||||
<path d="M17 7.5a1.5 1.5 0 0 1 3 0v8.5a6 6 0 0 1 -6 6h-2h.208a6 6 0 0 1 -5.012 -2.7a69.74 69.74 0 0 1 -.196 -.3c-.312 -.479 -1.407 -2.388 -3.286 -5.728a1.5 1.5 0 0 1 .536 -2.022a1.867 1.867 0 0 1 2.28 .28l1.47 1.47"></path>
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
+14
-21
@@ -1,30 +1,23 @@
|
||||
@import "../css/variables.module";
|
||||
@import "../../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.collab-button {
|
||||
@include outlineButtonStyles;
|
||||
width: var(--lg-button-size);
|
||||
height: var(--lg-button-size);
|
||||
--button-bg: var(--color-primary);
|
||||
--button-color: white;
|
||||
--button-border: var(--color-primary);
|
||||
|
||||
--button-width: var(--lg-button-size);
|
||||
--button-height: var(--lg-button-size);
|
||||
|
||||
--button-hover-bg: var(--color-primary-darker);
|
||||
--button-hover-border: var(--color-primary-darker);
|
||||
|
||||
--button-active-bg: var(--color-primary-darker);
|
||||
|
||||
svg {
|
||||
width: var(--lg-icon-size);
|
||||
height: var(--lg-icon-size);
|
||||
}
|
||||
background-color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primary-darker);
|
||||
border-color: var(--color-primary-darker);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--color-primary-darker);
|
||||
}
|
||||
|
||||
&.active {
|
||||
// double .active to force specificity
|
||||
&.active.active {
|
||||
background-color: #0fb884;
|
||||
border-color: #0fb884;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { t } from "../../i18n";
|
||||
import { usersIcon } from "../icons";
|
||||
import { Button } from "../Button";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { useExcalidrawAppState } from "../App";
|
||||
|
||||
import "./LiveCollaborationTrigger.scss";
|
||||
|
||||
const LiveCollaborationTrigger = ({
|
||||
isCollaborating,
|
||||
onSelect,
|
||||
...rest
|
||||
}: {
|
||||
isCollaborating: boolean;
|
||||
onSelect: () => void;
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||
const appState = useExcalidrawAppState();
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...rest}
|
||||
className={clsx("collab-button", { active: isCollaborating })}
|
||||
type="button"
|
||||
onSelect={onSelect}
|
||||
style={{ position: "relative" }}
|
||||
title={t("labels.liveCollaboration")}
|
||||
>
|
||||
{usersIcon}
|
||||
{appState.collaborators.size > 0 && (
|
||||
<div className="CollabButton-collaborators">
|
||||
{appState.collaborators.size}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default LiveCollaborationTrigger;
|
||||
LiveCollaborationTrigger.displayName = "LiveCollaborationTrigger";
|
||||
@@ -0,0 +1,268 @@
|
||||
import { getShortcutFromShortcutName } from "../../actions/shortcuts";
|
||||
import { t } from "../../i18n";
|
||||
import {
|
||||
useExcalidrawAppState,
|
||||
useExcalidrawSetAppState,
|
||||
useExcalidrawActionManager,
|
||||
} from "../App";
|
||||
import {
|
||||
ExportIcon,
|
||||
ExportImageIcon,
|
||||
HelpIcon,
|
||||
LoadIcon,
|
||||
MoonIcon,
|
||||
save,
|
||||
SunIcon,
|
||||
TrashIcon,
|
||||
usersIcon,
|
||||
} from "../icons";
|
||||
import { GithubIcon, DiscordIcon, TwitterIcon } from "../icons";
|
||||
import DropdownMenuItem from "../dropdownMenu/DropdownMenuItem";
|
||||
import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
|
||||
import {
|
||||
actionClearCanvas,
|
||||
actionLoadScene,
|
||||
actionSaveToActiveFile,
|
||||
actionShortcuts,
|
||||
actionToggleTheme,
|
||||
} from "../../actions";
|
||||
|
||||
import "./DefaultItems.scss";
|
||||
import clsx from "clsx";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
||||
|
||||
export const LoadScene = () => {
|
||||
// FIXME Hack until we tie "t" to lang state
|
||||
// eslint-disable-next-line
|
||||
const appState = useExcalidrawAppState();
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
if (!actionManager.isActionEnabled(actionLoadScene)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
icon={LoadIcon}
|
||||
onSelect={() => actionManager.executeAction(actionLoadScene)}
|
||||
data-testid="load-button"
|
||||
shortcut={getShortcutFromShortcutName("loadScene")}
|
||||
aria-label={t("buttons.load")}
|
||||
>
|
||||
{t("buttons.load")}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
LoadScene.displayName = "LoadScene";
|
||||
|
||||
export const SaveToActiveFile = () => {
|
||||
// FIXME Hack until we tie "t" to lang state
|
||||
// eslint-disable-next-line
|
||||
const appState = useExcalidrawAppState();
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
if (!actionManager.isActionEnabled(actionSaveToActiveFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
shortcut={getShortcutFromShortcutName("saveScene")}
|
||||
data-testid="save-button"
|
||||
onSelect={() => actionManager.executeAction(actionSaveToActiveFile)}
|
||||
icon={save}
|
||||
aria-label={`${t("buttons.save")}`}
|
||||
>{`${t("buttons.save")}`}</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
SaveToActiveFile.displayName = "SaveToActiveFile";
|
||||
|
||||
export const SaveAsImage = () => {
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
// FIXME Hack until we tie "t" to lang state
|
||||
// eslint-disable-next-line
|
||||
const appState = useExcalidrawAppState();
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
icon={ExportImageIcon}
|
||||
data-testid="image-export-button"
|
||||
onSelect={() => setAppState({ openDialog: "imageExport" })}
|
||||
shortcut={getShortcutFromShortcutName("imageExport")}
|
||||
aria-label={t("buttons.exportImage")}
|
||||
>
|
||||
{t("buttons.exportImage")}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
SaveAsImage.displayName = "SaveAsImage";
|
||||
|
||||
export const Help = () => {
|
||||
// FIXME Hack until we tie "t" to lang state
|
||||
// eslint-disable-next-line
|
||||
const appState = useExcalidrawAppState();
|
||||
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
data-testid="help-menu-item"
|
||||
icon={HelpIcon}
|
||||
onSelect={() => actionManager.executeAction(actionShortcuts)}
|
||||
shortcut="?"
|
||||
aria-label={t("helpDialog.title")}
|
||||
>
|
||||
{t("helpDialog.title")}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
Help.displayName = "Help";
|
||||
|
||||
export const ClearCanvas = () => {
|
||||
// FIXME Hack until we tie "t" to lang state
|
||||
// eslint-disable-next-line
|
||||
const appState = useExcalidrawAppState();
|
||||
const setActiveConfirmDialog = useSetAtom(activeConfirmDialogAtom);
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
if (!actionManager.isActionEnabled(actionClearCanvas)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
icon={TrashIcon}
|
||||
onSelect={() => setActiveConfirmDialog("clearCanvas")}
|
||||
data-testid="clear-canvas-button"
|
||||
aria-label={t("buttons.clearReset")}
|
||||
>
|
||||
{t("buttons.clearReset")}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
ClearCanvas.displayName = "ClearCanvas";
|
||||
|
||||
export const ToggleTheme = () => {
|
||||
const appState = useExcalidrawAppState();
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
if (!actionManager.isActionEnabled(actionToggleTheme)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) => {
|
||||
// do not close the menu when changing theme
|
||||
event.preventDefault();
|
||||
return actionManager.executeAction(actionToggleTheme);
|
||||
}}
|
||||
icon={appState.theme === "dark" ? SunIcon : MoonIcon}
|
||||
data-testid="toggle-dark-mode"
|
||||
shortcut={getShortcutFromShortcutName("toggleTheme")}
|
||||
aria-label={
|
||||
appState.theme === "dark"
|
||||
? t("buttons.lightMode")
|
||||
: t("buttons.darkMode")
|
||||
}
|
||||
>
|
||||
{appState.theme === "dark"
|
||||
? t("buttons.lightMode")
|
||||
: t("buttons.darkMode")}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
ToggleTheme.displayName = "ToggleTheme";
|
||||
|
||||
export const ChangeCanvasBackground = () => {
|
||||
const appState = useExcalidrawAppState();
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
if (appState.viewModeEnabled) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div style={{ marginTop: "0.5rem" }}>
|
||||
<div style={{ fontSize: ".75rem", marginBottom: ".5rem" }}>
|
||||
{t("labels.canvasBackground")}
|
||||
</div>
|
||||
<div style={{ padding: "0 0.625rem" }}>
|
||||
{actionManager.renderAction("changeViewBackgroundColor")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
ChangeCanvasBackground.displayName = "ChangeCanvasBackground";
|
||||
|
||||
export const Export = () => {
|
||||
// FIXME Hack until we tie "t" to lang state
|
||||
// eslint-disable-next-line
|
||||
const appState = useExcalidrawAppState();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
icon={ExportIcon}
|
||||
onSelect={() => {
|
||||
setAppState({ openDialog: "jsonExport" });
|
||||
}}
|
||||
data-testid="json-export-button"
|
||||
aria-label={t("buttons.export")}
|
||||
>
|
||||
{t("buttons.export")}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
Export.displayName = "Export";
|
||||
|
||||
export const Socials = () => (
|
||||
<>
|
||||
<DropdownMenuItemLink
|
||||
icon={GithubIcon}
|
||||
href="https://github.com/excalidraw/excalidraw"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
GitHub
|
||||
</DropdownMenuItemLink>
|
||||
<DropdownMenuItemLink
|
||||
icon={DiscordIcon}
|
||||
href="https://discord.gg/UexuTaE"
|
||||
aria-label="Discord"
|
||||
>
|
||||
Discord
|
||||
</DropdownMenuItemLink>
|
||||
<DropdownMenuItemLink
|
||||
icon={TwitterIcon}
|
||||
href="https://twitter.com/excalidraw"
|
||||
aria-label="Twitter"
|
||||
>
|
||||
Twitter
|
||||
</DropdownMenuItemLink>
|
||||
</>
|
||||
);
|
||||
Socials.displayName = "Socials";
|
||||
|
||||
export const LiveCollaborationTrigger = ({
|
||||
onSelect,
|
||||
isCollaborating,
|
||||
}: {
|
||||
onSelect: () => void;
|
||||
isCollaborating: boolean;
|
||||
}) => {
|
||||
// FIXME Hack until we tie "t" to lang state
|
||||
// eslint-disable-next-line
|
||||
const appState = useExcalidrawAppState();
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
data-testid="collab-button"
|
||||
icon={usersIcon}
|
||||
className={clsx({
|
||||
"active-collab": isCollaborating,
|
||||
})}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
{t("labels.liveCollaboration")}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
LiveCollaborationTrigger.displayName = "LiveCollaborationTrigger";
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from "react";
|
||||
import {
|
||||
useDevice,
|
||||
useExcalidrawAppState,
|
||||
useExcalidrawSetAppState,
|
||||
} from "../App";
|
||||
import DropdownMenu from "../dropdownMenu/DropdownMenu";
|
||||
|
||||
import * as DefaultItems from "./DefaultItems";
|
||||
|
||||
import { UserList } from "../UserList";
|
||||
import { t } from "../../i18n";
|
||||
import { HamburgerMenuIcon } from "../icons";
|
||||
import { composeEventHandlers } from "../../utils";
|
||||
|
||||
const MainMenu = ({
|
||||
children,
|
||||
onSelect,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
/**
|
||||
* Called when any menu item is selected (clicked on).
|
||||
*/
|
||||
onSelect?: (event: Event) => void;
|
||||
}) => {
|
||||
const device = useDevice();
|
||||
const appState = useExcalidrawAppState();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const onClickOutside = device.isMobile
|
||||
? undefined
|
||||
: () => setAppState({ openMenu: null });
|
||||
|
||||
return (
|
||||
<DropdownMenu open={appState.openMenu === "canvas"}>
|
||||
<DropdownMenu.Trigger
|
||||
onToggle={() => {
|
||||
setAppState({
|
||||
openMenu: appState.openMenu === "canvas" ? null : "canvas",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{HamburgerMenuIcon}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
onClickOutside={onClickOutside}
|
||||
onSelect={composeEventHandlers(onSelect, () => {
|
||||
setAppState({ openMenu: null });
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
{device.isMobile && appState.collaborators.size > 0 && (
|
||||
<fieldset className="UserList-Wrapper">
|
||||
<legend>{t("labels.collaborators")}</legend>
|
||||
<UserList mobile={true} collaborators={appState.collaborators} />
|
||||
</fieldset>
|
||||
)}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
MainMenu.Trigger = DropdownMenu.Trigger;
|
||||
MainMenu.Item = DropdownMenu.Item;
|
||||
MainMenu.ItemLink = DropdownMenu.ItemLink;
|
||||
MainMenu.ItemCustom = DropdownMenu.ItemCustom;
|
||||
MainMenu.Group = DropdownMenu.Group;
|
||||
MainMenu.Separator = DropdownMenu.Separator;
|
||||
MainMenu.DefaultItems = DefaultItems;
|
||||
|
||||
export default MainMenu;
|
||||
|
||||
MainMenu.displayName = "Menu";
|
||||
@@ -0,0 +1,195 @@
|
||||
import { actionLoadScene, actionShortcuts } from "../../actions";
|
||||
import { getShortcutFromShortcutName } from "../../actions/shortcuts";
|
||||
import { t } from "../../i18n";
|
||||
import {
|
||||
useDevice,
|
||||
useExcalidrawActionManager,
|
||||
useExcalidrawAppState,
|
||||
} from "../App";
|
||||
import { ExcalLogo, HelpIcon, LoadIcon, usersIcon } from "../icons";
|
||||
|
||||
const WelcomeScreenMenuItemContent = ({
|
||||
icon,
|
||||
shortcut,
|
||||
children,
|
||||
}: {
|
||||
icon?: JSX.Element;
|
||||
shortcut?: string | null;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const device = useDevice();
|
||||
return (
|
||||
<>
|
||||
<div className="welcome-screen-menu-item__icon">{icon}</div>
|
||||
<div className="welcome-screen-menu-item__text">{children}</div>
|
||||
{shortcut && !device.isMobile && (
|
||||
<div className="welcome-screen-menu-item__shortcut">{shortcut}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
WelcomeScreenMenuItemContent.displayName = "WelcomeScreenMenuItemContent";
|
||||
|
||||
const WelcomeScreenMenuItem = ({
|
||||
onSelect,
|
||||
children,
|
||||
icon,
|
||||
shortcut,
|
||||
className = "",
|
||||
...props
|
||||
}: {
|
||||
onSelect: () => void;
|
||||
children: React.ReactNode;
|
||||
icon?: JSX.Element;
|
||||
shortcut?: string | null;
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
type="button"
|
||||
className={`welcome-screen-menu-item ${className}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<WelcomeScreenMenuItemContent icon={icon} shortcut={shortcut}>
|
||||
{children}
|
||||
</WelcomeScreenMenuItemContent>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
WelcomeScreenMenuItem.displayName = "WelcomeScreenMenuItem";
|
||||
|
||||
const WelcomeScreenMenuItemLink = ({
|
||||
children,
|
||||
href,
|
||||
icon,
|
||||
shortcut,
|
||||
className = "",
|
||||
...props
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
icon?: JSX.Element;
|
||||
shortcut?: string | null;
|
||||
} & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
className={`welcome-screen-menu-item ${className}`}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<WelcomeScreenMenuItemContent icon={icon} shortcut={shortcut}>
|
||||
{children}
|
||||
</WelcomeScreenMenuItemContent>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
WelcomeScreenMenuItemLink.displayName = "WelcomeScreenMenuItemLink";
|
||||
|
||||
const Center = ({ children }: { children?: React.ReactNode }) => {
|
||||
return (
|
||||
<div className="welcome-screen-center">
|
||||
{children || (
|
||||
<>
|
||||
<Logo />
|
||||
<Heading>{t("welcomeScreen.defaults.center_heading")}</Heading>
|
||||
<Menu>
|
||||
<MenuItemLoadScene />
|
||||
<MenuItemHelp />
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Center.displayName = "Center";
|
||||
|
||||
const Logo = ({ children }: { children?: React.ReactNode }) => {
|
||||
return (
|
||||
<div className="welcome-screen-center__logo virgil welcome-screen-decor">
|
||||
{children || <>{ExcalLogo} Excalidraw</>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Logo.displayName = "Logo";
|
||||
|
||||
const Heading = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<div className="welcome-screen-center__heading welcome-screen-decor virgil">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Heading.displayName = "Heading";
|
||||
|
||||
const Menu = ({ children }: { children?: React.ReactNode }) => {
|
||||
return <div className="welcome-screen-menu">{children}</div>;
|
||||
};
|
||||
Menu.displayName = "Menu";
|
||||
|
||||
const MenuItemHelp = () => {
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
return (
|
||||
<WelcomeScreenMenuItem
|
||||
onSelect={() => actionManager.executeAction(actionShortcuts)}
|
||||
shortcut="?"
|
||||
icon={HelpIcon}
|
||||
>
|
||||
{t("helpDialog.title")}
|
||||
</WelcomeScreenMenuItem>
|
||||
);
|
||||
};
|
||||
MenuItemHelp.displayName = "MenuItemHelp";
|
||||
|
||||
const MenuItemLoadScene = () => {
|
||||
const appState = useExcalidrawAppState();
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
if (appState.viewModeEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<WelcomeScreenMenuItem
|
||||
onSelect={() => actionManager.executeAction(actionLoadScene)}
|
||||
shortcut={getShortcutFromShortcutName("loadScene")}
|
||||
icon={LoadIcon}
|
||||
>
|
||||
{t("buttons.load")}
|
||||
</WelcomeScreenMenuItem>
|
||||
);
|
||||
};
|
||||
MenuItemLoadScene.displayName = "MenuItemLoadScene";
|
||||
|
||||
const MenuItemLiveCollaborationTrigger = ({
|
||||
onSelect,
|
||||
}: {
|
||||
onSelect: () => any;
|
||||
}) => {
|
||||
// FIXME when we tie t() to lang state
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const appState = useExcalidrawAppState();
|
||||
|
||||
return (
|
||||
<WelcomeScreenMenuItem shortcut={null} onSelect={onSelect} icon={usersIcon}>
|
||||
{t("labels.liveCollaboration")}
|
||||
</WelcomeScreenMenuItem>
|
||||
);
|
||||
};
|
||||
MenuItemLiveCollaborationTrigger.displayName =
|
||||
"MenuItemLiveCollaborationTrigger";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
Center.Logo = Logo;
|
||||
Center.Heading = Heading;
|
||||
Center.Menu = Menu;
|
||||
Center.MenuItem = WelcomeScreenMenuItem;
|
||||
Center.MenuItemLink = WelcomeScreenMenuItemLink;
|
||||
Center.MenuItemHelp = MenuItemHelp;
|
||||
Center.MenuItemLoadScene = MenuItemLoadScene;
|
||||
Center.MenuItemLiveCollaborationTrigger = MenuItemLiveCollaborationTrigger;
|
||||
|
||||
export { Center };
|
||||
@@ -0,0 +1,42 @@
|
||||
import { t } from "../../i18n";
|
||||
import {
|
||||
WelcomeScreenHelpArrow,
|
||||
WelcomeScreenMenuArrow,
|
||||
WelcomeScreenTopToolbarArrow,
|
||||
} from "../icons";
|
||||
|
||||
const MenuHint = ({ children }: { children?: React.ReactNode }) => {
|
||||
return (
|
||||
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--menu">
|
||||
{WelcomeScreenMenuArrow}
|
||||
<div className="welcome-screen-decor-hint__label">
|
||||
{children || t("welcomeScreen.defaults.menuHint")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
MenuHint.displayName = "MenuHint";
|
||||
|
||||
const ToolbarHint = ({ children }: { children?: React.ReactNode }) => {
|
||||
return (
|
||||
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--toolbar">
|
||||
<div className="welcome-screen-decor-hint__label">
|
||||
{children || t("welcomeScreen.defaults.toolbarHint")}
|
||||
</div>
|
||||
{WelcomeScreenTopToolbarArrow}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
ToolbarHint.displayName = "ToolbarHint";
|
||||
|
||||
const HelpHint = ({ children }: { children?: React.ReactNode }) => {
|
||||
return (
|
||||
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--help">
|
||||
<div>{children || t("welcomeScreen.defaults.helpHint")}</div>
|
||||
{WelcomeScreenHelpArrow}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
HelpHint.displayName = "HelpHint";
|
||||
|
||||
export { HelpHint, MenuHint, ToolbarHint };
|
||||
+89
-78
@@ -3,29 +3,39 @@
|
||||
font-family: "Virgil";
|
||||
}
|
||||
|
||||
.WelcomeScreen-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 0.75rem;
|
||||
font-size: 2.25rem;
|
||||
// WelcomeSreen common
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
svg {
|
||||
width: 1.625rem;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.WelcomeScreen-decor {
|
||||
.welcome-screen-decor {
|
||||
pointer-events: none;
|
||||
|
||||
color: var(--color-gray-40);
|
||||
}
|
||||
|
||||
&--subheading {
|
||||
font-size: 1.125rem;
|
||||
text-align: center;
|
||||
&.theme--dark {
|
||||
.welcome-screen-decor {
|
||||
color: var(--color-gray-60);
|
||||
}
|
||||
}
|
||||
|
||||
// WelcomeScreen.Hints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
.welcome-screen-decor-hint {
|
||||
@media (max-height: 599px) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
&--help-pointer {
|
||||
@media (max-width: 1024px), (max-width: 800px) {
|
||||
.welcome-screen-decor {
|
||||
&--help,
|
||||
&--menu {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--help {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
@@ -49,7 +59,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
&--top-toolbar-pointer {
|
||||
&--toolbar {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
@@ -58,7 +68,7 @@
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
&__label {
|
||||
.welcome-screen-decor-hint__label {
|
||||
width: 120px;
|
||||
position: relative;
|
||||
top: -0.5rem;
|
||||
@@ -74,7 +84,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
&--menu-pointer {
|
||||
&--menu {
|
||||
position: absolute;
|
||||
width: 320px;
|
||||
font-size: 1rem;
|
||||
@@ -95,10 +105,19 @@
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.welcome-screen-decor-hint__label {
|
||||
max-width: 160px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.WelcomeScreen-container {
|
||||
// WelcomeSreen.Center
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
.welcome-screen-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
@@ -112,7 +131,24 @@
|
||||
bottom: 1rem;
|
||||
}
|
||||
|
||||
.WelcomeScreen-items {
|
||||
.welcome-screen-center__logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 0.75rem;
|
||||
font-size: 2.25rem;
|
||||
|
||||
svg {
|
||||
width: 1.625rem;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-screen-center__heading {
|
||||
font-size: 1.125rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.welcome-screen-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
@@ -120,7 +156,7 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.WelcomeScreen-item {
|
||||
.welcome-screen-menu-item {
|
||||
box-sizing: border-box;
|
||||
|
||||
pointer-events: all;
|
||||
@@ -128,8 +164,10 @@
|
||||
color: var(--color-gray-50);
|
||||
font-size: 0.875rem;
|
||||
|
||||
width: 100%;
|
||||
min-width: 300px;
|
||||
display: flex;
|
||||
max-width: 400px;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -140,44 +178,49 @@
|
||||
|
||||
border-radius: var(--border-radius-md);
|
||||
|
||||
&__label {
|
||||
grid-template-columns: calc(var(--default-icon-size) + 0.5rem) 1fr 3rem;
|
||||
|
||||
&__text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: auto;
|
||||
text-align: left;
|
||||
column-gap: 0.5rem;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: var(--default-icon-size);
|
||||
height: var(--default-icon-size);
|
||||
}
|
||||
&__icon {
|
||||
width: var(--default-icon-size);
|
||||
height: var(--default-icon-size);
|
||||
}
|
||||
|
||||
&__shortcut {
|
||||
margin-left: auto;
|
||||
color: var(--color-gray-40);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:active) .WelcomeScreen-item:hover {
|
||||
&:not(:active) .welcome-screen-menu-item:hover {
|
||||
text-decoration: none;
|
||||
background: var(--color-gray-10);
|
||||
|
||||
.WelcomeScreen-item__shortcut {
|
||||
.welcome-screen-menu-item__shortcut {
|
||||
color: var(--color-gray-50);
|
||||
}
|
||||
|
||||
.WelcomeScreen-item__label {
|
||||
.welcome-screen-menu-item__text {
|
||||
color: var(--color-gray-100);
|
||||
}
|
||||
}
|
||||
|
||||
.WelcomeScreen-item:active {
|
||||
.welcome-screen-menu-item:active {
|
||||
background: var(--color-gray-20);
|
||||
|
||||
.WelcomeScreen-item__shortcut {
|
||||
.welcome-screen-menu-item__shortcut {
|
||||
color: var(--color-gray-50);
|
||||
}
|
||||
|
||||
.WelcomeScreen-item__label {
|
||||
.welcome-screen-menu-item__text {
|
||||
color: var(--color-gray-100);
|
||||
}
|
||||
|
||||
@@ -185,7 +228,7 @@
|
||||
color: var(--color-promo) !important;
|
||||
|
||||
&:hover {
|
||||
.WelcomeScreen-item__label {
|
||||
.welcome-screen-menu-item__text {
|
||||
color: var(--color-promo) !important;
|
||||
}
|
||||
}
|
||||
@@ -193,11 +236,7 @@
|
||||
}
|
||||
|
||||
&.theme--dark {
|
||||
.WelcomeScreen-decor {
|
||||
color: var(--color-gray-60);
|
||||
}
|
||||
|
||||
.WelcomeScreen-item {
|
||||
.welcome-screen-menu-item {
|
||||
color: var(--color-gray-60);
|
||||
|
||||
&__shortcut {
|
||||
@@ -205,69 +244,41 @@
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:active) .WelcomeScreen-item:hover {
|
||||
&:not(:active) .welcome-screen-menu-item:hover {
|
||||
background: var(--color-gray-85);
|
||||
|
||||
.WelcomeScreen-item__shortcut {
|
||||
.welcome-screen-menu-item__shortcut {
|
||||
color: var(--color-gray-50);
|
||||
}
|
||||
|
||||
.WelcomeScreen-item__label {
|
||||
.welcome-screen-menu-item__text {
|
||||
color: var(--color-gray-10);
|
||||
}
|
||||
}
|
||||
|
||||
.WelcomeScreen-item:active {
|
||||
.welcome-screen-menu-item:active {
|
||||
background-color: var(--color-gray-90);
|
||||
.WelcomeScreen-item__label {
|
||||
.welcome-screen-menu-item__text {
|
||||
color: var(--color-gray-10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Can tweak these values but for an initial effort, it looks OK to me
|
||||
@media (max-width: 1024px) {
|
||||
.WelcomeScreen-decor {
|
||||
&--help-pointer,
|
||||
&--menu-pointer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @media (max-height: 400px) {
|
||||
// .WelcomeScreen-container {
|
||||
// margin-top: 0;
|
||||
// }
|
||||
// }
|
||||
@media (max-height: 599px) {
|
||||
.WelcomeScreen-container {
|
||||
.welcome-screen-center {
|
||||
margin-top: 4rem;
|
||||
}
|
||||
}
|
||||
@media (min-height: 600px) and (max-height: 900px) {
|
||||
.WelcomeScreen-container {
|
||||
.welcome-screen-center {
|
||||
margin-top: 8rem;
|
||||
}
|
||||
}
|
||||
@media (max-height: 630px) {
|
||||
.WelcomeScreen-decor--top-toolbar-pointer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (max-height: 500px) {
|
||||
.WelcomeScreen-container {
|
||||
@media (max-height: 500px), (max-width: 320px) {
|
||||
.welcome-screen-center {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// @media (max-height: 740px) {
|
||||
// .WelcomeScreen-decor {
|
||||
// &--help-pointer,
|
||||
// &--top-toolbar-pointer,
|
||||
// &--menu-pointer {
|
||||
// display: none;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// ---------------------------------------------------------------------------
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Center } from "./WelcomeScreen.Center";
|
||||
import { MenuHint, ToolbarHint, HelpHint } from "./WelcomeScreen.Hints";
|
||||
|
||||
import "./WelcomeScreen.scss";
|
||||
|
||||
const WelcomeScreen = (props: { children: React.ReactNode }) => {
|
||||
// NOTE this component is used as a dummy wrapper to retrieve child props
|
||||
// from, and will never be rendered to DOM directly. As such, we can't
|
||||
// do anything here (use hooks and such)
|
||||
return null;
|
||||
};
|
||||
WelcomeScreen.displayName = "WelcomeScreen";
|
||||
|
||||
WelcomeScreen.Center = Center;
|
||||
WelcomeScreen.Hints = { MenuHint, ToolbarHint, HelpHint };
|
||||
|
||||
export default WelcomeScreen;
|
||||
+10
-8
@@ -2,6 +2,14 @@ import cssVariables from "./css/variables.module.scss";
|
||||
import { AppProps } from "./types";
|
||||
import { FontFamilyValues } from "./element/types";
|
||||
|
||||
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||
export const isWindows = /^Win/.test(navigator.platform);
|
||||
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
|
||||
export const isFirefox =
|
||||
"netscape" in window &&
|
||||
navigator.userAgent.indexOf("rv:") > 1 &&
|
||||
navigator.userAgent.indexOf("Gecko") > 1;
|
||||
|
||||
export const APP_NAME = "Excalidraw";
|
||||
|
||||
export const DRAGGING_THRESHOLD = 10; // px
|
||||
@@ -54,6 +62,7 @@ export enum EVENT {
|
||||
SCROLL = "scroll",
|
||||
// custom events
|
||||
EXCALIDRAW_LINK = "excalidraw-link",
|
||||
MENU_ITEM_SELECT = "menu.itemSelect",
|
||||
}
|
||||
|
||||
export const ENV = {
|
||||
@@ -150,6 +159,7 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
||||
toggleTheme: null,
|
||||
saveAsImage: true,
|
||||
},
|
||||
welcomeScreen: true,
|
||||
};
|
||||
|
||||
// breakpoints
|
||||
@@ -236,14 +246,6 @@ export const ROUNDNESS = {
|
||||
ADAPTIVE_RADIUS: 3,
|
||||
} as const;
|
||||
|
||||
export const COOKIES = {
|
||||
AUTH_STATE_COOKIE: "excplus-auth",
|
||||
} as const;
|
||||
|
||||
/** key containt id of precedeing elemnt id we use in reconciliation during
|
||||
* collaboration */
|
||||
export const PRECEDING_ELEMENT_KEY = "__precedingElement__";
|
||||
|
||||
export const isExcalidrawPlusSignedUser = document.cookie.includes(
|
||||
COOKIES.AUTH_STATE_COOKIE,
|
||||
);
|
||||
|
||||
+19
-4
@@ -408,7 +408,7 @@
|
||||
pointer-events: all;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-hover);
|
||||
background-color: var(--button-hover-bg);
|
||||
}
|
||||
|
||||
&:active {
|
||||
@@ -540,15 +540,16 @@
|
||||
}
|
||||
|
||||
.mobile-misc-tools-container {
|
||||
position: fixed;
|
||||
top: 5rem;
|
||||
right: 0;
|
||||
position: absolute;
|
||||
top: calc(5rem - var(--editor-container-padding));
|
||||
right: calc(var(--editor-container-padding) * -1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--sidebar-border-color);
|
||||
border-top-left-radius: var(--border-radius-lg);
|
||||
border-bottom-left-radius: var(--border-radius-lg);
|
||||
border-right: 0;
|
||||
overflow: hidden;
|
||||
|
||||
background-color: var(--island-bg-color);
|
||||
|
||||
@@ -569,6 +570,20 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.UserList-Wrapper {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
text-align: left;
|
||||
|
||||
legend {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
margin: 0 0 0.25rem;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ErrorSplash.excalidraw {
|
||||
|
||||
+3
-2
@@ -35,13 +35,14 @@
|
||||
--shadow-island: 0px 7px 14px rgba(0, 0, 0, 0.05),
|
||||
0px 0px 3.12708px rgba(0, 0, 0, 0.0798),
|
||||
0px 0px 0.931014px rgba(0, 0, 0, 0.1702);
|
||||
--button-hover: var(--color-gray-10);
|
||||
--button-hover-bg: var(--color-gray-10);
|
||||
--default-border-color: var(--color-gray-30);
|
||||
|
||||
--default-button-size: 2rem;
|
||||
--default-icon-size: 1rem;
|
||||
--lg-button-size: 2.25rem;
|
||||
--lg-icon-size: 1rem;
|
||||
--editor-container-padding: 1rem;
|
||||
|
||||
@media screen and (min-device-width: 1921px) {
|
||||
--lg-button-size: 2.5rem;
|
||||
@@ -135,7 +136,7 @@
|
||||
--popup-text-inverted-color: #2c2c2c;
|
||||
--select-highlight-color: #{$oc-blue-4};
|
||||
--text-primary-color: var(--color-gray-40);
|
||||
--button-hover: var(--color-gray-80);
|
||||
--button-hover-bg: var(--color-gray-80);
|
||||
--default-border-color: var(--color-gray-80);
|
||||
--shadow-island: 0px 13px 33px rgba(0, 0, 0, 0.07),
|
||||
0px 4.13px 9.94853px rgba(0, 0, 0, 0.0456112),
|
||||
|
||||
@@ -39,11 +39,11 @@
|
||||
|
||||
.ToolIcon__icon {
|
||||
&:hover {
|
||||
background: var(--button-hover);
|
||||
background: var(--button-hover-bg);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--button-hover);
|
||||
background: var(--button-hover-bg);
|
||||
border: 1px solid var(--color-primary-darkest);
|
||||
}
|
||||
}
|
||||
@@ -54,24 +54,30 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0.625rem;
|
||||
width: var(--default-button-size);
|
||||
height: var(--default-button-size);
|
||||
width: var(--button-width, var(--default-button-size));
|
||||
height: var(--button-height, var(--default-button-size));
|
||||
box-sizing: border-box;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: var(--default-border-color);
|
||||
border-color: var(--button-border, var(--default-border-color));
|
||||
border-radius: var(--border-radius-lg);
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
color: var(--text-primary-color);
|
||||
background-color: var(--button-bg, var(--island-bg-color));
|
||||
color: var(--button-color, var(--text-primary-color));
|
||||
|
||||
svg {
|
||||
width: var(--button-width, var(--lg-icon-size));
|
||||
height: var(--button-height, var(--lg-icon-size));
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-hover);
|
||||
background-color: var(--button-hover-bg, var(--island-bg-color));
|
||||
border-color: var(--button-hover-border, var(--default-border-color));
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--button-hover);
|
||||
border-color: var(--color-primary-darkest);
|
||||
background-color: var(--button-active-bg, var(--island-bg-color));
|
||||
border-color: var(--button-active-border, var(--color-primary-darkest));
|
||||
}
|
||||
|
||||
&.active {
|
||||
@@ -83,7 +89,7 @@
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--color-primary-darker);
|
||||
color: var(--button-color, var(--color-primary-darker));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -2,7 +2,7 @@ import {
|
||||
copyBlobToClipboardAsPng,
|
||||
copyTextToSystemClipboard,
|
||||
} from "../clipboard";
|
||||
import { DEFAULT_EXPORT_PADDING, MIME_TYPES } from "../constants";
|
||||
import { DEFAULT_EXPORT_PADDING, isFirefox, MIME_TYPES } from "../constants";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { exportToCanvas, exportToSvg } from "../scene/export";
|
||||
@@ -97,10 +97,21 @@ export const exportCanvas = async (
|
||||
const blob = canvasToBlob(tempCanvas);
|
||||
await copyBlobToClipboardAsPng(blob);
|
||||
} catch (error: any) {
|
||||
console.warn(error);
|
||||
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(t("alerts.couldNotCopyToClipboard"));
|
||||
// TypeError *probably* suggests ClipboardItem not defined, which
|
||||
// people on Firefox can enable through a flag, so let's tell them.
|
||||
if (isFirefox && error.name === "TypeError") {
|
||||
throw new Error(
|
||||
`${t("alerts.couldNotCopyToClipboard")}\n\n${t(
|
||||
"hints.firefox_clipboard_write",
|
||||
)}`,
|
||||
);
|
||||
} else {
|
||||
throw new Error(t("alerts.couldNotCopyToClipboard"));
|
||||
}
|
||||
} finally {
|
||||
tempCanvas.remove();
|
||||
}
|
||||
|
||||
+2
-1
@@ -55,6 +55,7 @@ export const AllowedExcalidrawActiveTools: Record<
|
||||
freedraw: true,
|
||||
eraser: false,
|
||||
custom: true,
|
||||
hand: true,
|
||||
};
|
||||
|
||||
export type RestoredDataState = {
|
||||
@@ -465,7 +466,7 @@ export const restoreAppState = (
|
||||
? nextAppState.activeTool
|
||||
: { type: "selection" },
|
||||
),
|
||||
lastActiveToolBeforeEraser: null,
|
||||
lastActiveTool: null,
|
||||
locked: nextAppState.activeTool.locked ?? false,
|
||||
},
|
||||
// Migrates from previous version where appState.zoom was a number
|
||||
|
||||
@@ -267,7 +267,7 @@ export const actionLink = register({
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K,
|
||||
contextItemLabel: (elements, appState) =>
|
||||
getContextMenuLabel(elements, appState),
|
||||
contextItemPredicate: (elements, appState) => {
|
||||
predicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
return selectedElements.length === 1;
|
||||
},
|
||||
|
||||
@@ -557,10 +557,10 @@ export const resizeSingleElement = (
|
||||
mutateElement(element, {
|
||||
scale: [
|
||||
// defaulting because scaleX/Y can be 0/-0
|
||||
(Math.sign(scaleX) || stateAtResizeStart.scale[0]) *
|
||||
stateAtResizeStart.scale[0],
|
||||
(Math.sign(scaleY) || stateAtResizeStart.scale[1]) *
|
||||
stateAtResizeStart.scale[1],
|
||||
(Math.sign(newBoundsX2 - stateAtResizeStart.x) ||
|
||||
stateAtResizeStart.scale[0]) * stateAtResizeStart.scale[0],
|
||||
(Math.sign(newBoundsY2 - stateAtResizeStart.y) ||
|
||||
stateAtResizeStart.scale[1]) * stateAtResizeStart.scale[1],
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export const showSelectedShapeActions = (
|
||||
appState.activeTool.type !== "custom" &&
|
||||
(appState.editingElement ||
|
||||
(appState.activeTool.type !== "selection" &&
|
||||
appState.activeTool.type !== "eraser"))) ||
|
||||
appState.activeTool.type !== "eraser" &&
|
||||
appState.activeTool.type !== "hand"))) ||
|
||||
getSelectedElements(elements, appState).length,
|
||||
);
|
||||
|
||||
@@ -38,3 +38,11 @@ export const STORAGE_KEYS = {
|
||||
VERSION_DATA_STATE: "version-dataState",
|
||||
VERSION_FILES: "version-files",
|
||||
} as const;
|
||||
|
||||
export const COOKIES = {
|
||||
AUTH_STATE_COOKIE: "excplus-auth",
|
||||
} as const;
|
||||
|
||||
export const isExcalidrawPlusSignedUser = document.cookie.includes(
|
||||
COOKIES.AUTH_STATE_COOKIE,
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isExcalidrawPlusSignedUser } from "../../constants";
|
||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||
|
||||
export const ExcalidrawPlusAppLink = () => {
|
||||
if (!isExcalidrawPlusSignedUser) {
|
||||
|
||||
@@ -8,23 +8,21 @@ export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
|
||||
const [langCode, setLangCode] = useAtom(langCodeAtom);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<select
|
||||
className="dropdown-select dropdown-select__language"
|
||||
onChange={({ target }) => setLangCode(target.value)}
|
||||
value={langCode}
|
||||
aria-label={i18n.t("buttons.selectLanguage")}
|
||||
style={style}
|
||||
>
|
||||
<option key={i18n.defaultLang.code} value={i18n.defaultLang.code}>
|
||||
{i18n.defaultLang.label}
|
||||
<select
|
||||
className="dropdown-select dropdown-select__language"
|
||||
onChange={({ target }) => setLangCode(target.value)}
|
||||
value={langCode}
|
||||
aria-label={i18n.t("buttons.selectLanguage")}
|
||||
style={style}
|
||||
>
|
||||
<option key={i18n.defaultLang.code} value={i18n.defaultLang.code}>
|
||||
{i18n.defaultLang.label}
|
||||
</option>
|
||||
{languages.map((lang) => (
|
||||
<option key={lang.code} value={lang.code}>
|
||||
{lang.label}
|
||||
</option>
|
||||
{languages.map((lang) => (
|
||||
<option key={lang.code} value={lang.code}>
|
||||
{lang.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
&.theme--dark {
|
||||
--color-primary-contrast-offset: #726dff; // to offset Chubb illusion
|
||||
}
|
||||
.layer-ui__wrapper .layer-ui__wrapper__footer-center {
|
||||
.footer-center {
|
||||
justify-content: flex-end;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
@@ -24,7 +24,29 @@
|
||||
height: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu-container {
|
||||
.dropdown-menu-item {
|
||||
&.active-collab {
|
||||
background-color: #ecfdf5;
|
||||
color: #064e3c;
|
||||
}
|
||||
&.ExcalidrawPlus {
|
||||
color: var(--color-promo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.theme--dark {
|
||||
.dropdown-menu-item {
|
||||
&.active-collab {
|
||||
background-color: #064e3c;
|
||||
color: #ecfdf5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.excalidraw-app.is-collaborating {
|
||||
[data-testid="clear-canvas-button"] {
|
||||
display: none;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import polyfill from "../polyfill";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { ErrorDialog } from "../components/ErrorDialog";
|
||||
@@ -21,7 +21,14 @@ import {
|
||||
} from "../element/types";
|
||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||
import { t } from "../i18n";
|
||||
import { Excalidraw, defaultLang, Footer } from "../packages/excalidraw/index";
|
||||
import {
|
||||
Excalidraw,
|
||||
defaultLang,
|
||||
Footer,
|
||||
MainMenu,
|
||||
LiveCollaborationTrigger,
|
||||
WelcomeScreen,
|
||||
} from "../packages/excalidraw/index";
|
||||
import {
|
||||
AppState,
|
||||
LibraryItems,
|
||||
@@ -40,6 +47,7 @@ import {
|
||||
} from "../utils";
|
||||
import {
|
||||
FIREBASE_STORAGE_PREFIXES,
|
||||
isExcalidrawPlusSignedUser,
|
||||
STORAGE_KEYS,
|
||||
SYNC_BROWSER_TABS_TIMEOUT,
|
||||
} from "./app_constants";
|
||||
@@ -79,8 +87,11 @@ import { reconcileElements } from "./collab/reconciliation";
|
||||
import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library";
|
||||
import { EncryptedIcon } from "./components/EncryptedIcon";
|
||||
import { ExcalidrawPlusAppLink } from "./components/ExcalidrawPlusAppLink";
|
||||
import { LanguageList } from "./components/LanguageList";
|
||||
import { PlusPromoIcon } from "../components/icons";
|
||||
|
||||
polyfill();
|
||||
|
||||
window.EXCALIDRAW_THROTTLE_RENDER = true;
|
||||
|
||||
const languageDetector = new LanguageDetector();
|
||||
@@ -229,7 +240,6 @@ export const langCodeAtom = atom(
|
||||
const ExcalidrawWrapper = () => {
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [langCode, setLangCode] = useAtom(langCodeAtom);
|
||||
|
||||
// initial state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -594,6 +604,96 @@ const ExcalidrawWrapper = () => {
|
||||
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
|
||||
};
|
||||
|
||||
const renderMenu = () => {
|
||||
return (
|
||||
<MainMenu>
|
||||
<MainMenu.DefaultItems.LoadScene />
|
||||
<MainMenu.DefaultItems.SaveToActiveFile />
|
||||
<MainMenu.DefaultItems.Export />
|
||||
<MainMenu.DefaultItems.SaveAsImage />
|
||||
<MainMenu.DefaultItems.LiveCollaborationTrigger
|
||||
isCollaborating={isCollaborating}
|
||||
onSelect={() => setCollabDialogShown(true)}
|
||||
/>
|
||||
|
||||
<MainMenu.DefaultItems.Help />
|
||||
<MainMenu.DefaultItems.ClearCanvas />
|
||||
<MainMenu.Separator />
|
||||
<MainMenu.ItemLink
|
||||
icon={PlusPromoIcon}
|
||||
href="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger"
|
||||
className="ExcalidrawPlus"
|
||||
>
|
||||
Excalidraw+
|
||||
</MainMenu.ItemLink>
|
||||
<MainMenu.DefaultItems.Socials />
|
||||
<MainMenu.Separator />
|
||||
<MainMenu.DefaultItems.ToggleTheme />
|
||||
<MainMenu.ItemCustom>
|
||||
<LanguageList style={{ width: "100%" }} />
|
||||
</MainMenu.ItemCustom>
|
||||
<MainMenu.DefaultItems.ChangeCanvasBackground />
|
||||
</MainMenu>
|
||||
);
|
||||
};
|
||||
|
||||
const welcomeScreenJSX = useMemo(() => {
|
||||
let headingContent;
|
||||
|
||||
if (isExcalidrawPlusSignedUser) {
|
||||
headingContent = t("welcomeScreen.app.center_heading_plus")
|
||||
.split(/(Excalidraw\+)/)
|
||||
.map((bit, idx) => {
|
||||
if (bit === "Excalidraw+") {
|
||||
return (
|
||||
<a
|
||||
style={{ pointerEvents: "all" }}
|
||||
href={`${process.env.REACT_APP_PLUS_APP}?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenSignedInUser`}
|
||||
key={idx}
|
||||
>
|
||||
Excalidraw+
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return bit;
|
||||
});
|
||||
} else {
|
||||
headingContent = t("welcomeScreen.app.center_heading");
|
||||
}
|
||||
|
||||
return (
|
||||
<WelcomeScreen>
|
||||
<WelcomeScreen.Hints.MenuHint>
|
||||
{t("welcomeScreen.app.menuHint")}
|
||||
</WelcomeScreen.Hints.MenuHint>
|
||||
<WelcomeScreen.Hints.ToolbarHint />
|
||||
<WelcomeScreen.Hints.HelpHint />
|
||||
<WelcomeScreen.Center>
|
||||
<WelcomeScreen.Center.Logo />
|
||||
<WelcomeScreen.Center.Heading>
|
||||
{headingContent}
|
||||
</WelcomeScreen.Center.Heading>
|
||||
<WelcomeScreen.Center.Menu>
|
||||
<WelcomeScreen.Center.MenuItemLoadScene />
|
||||
<WelcomeScreen.Center.MenuItemHelp />
|
||||
<WelcomeScreen.Center.MenuItemLiveCollaborationTrigger
|
||||
onSelect={() => setCollabDialogShown(true)}
|
||||
/>
|
||||
{!isExcalidrawPlusSignedUser && (
|
||||
<WelcomeScreen.Center.MenuItemLink
|
||||
href="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
|
||||
shortcut={null}
|
||||
icon={PlusPromoIcon}
|
||||
>
|
||||
Try Excalidraw Plus!
|
||||
</WelcomeScreen.Center.MenuItemLink>
|
||||
)}
|
||||
</WelcomeScreen.Center.Menu>
|
||||
</WelcomeScreen.Center>
|
||||
</WelcomeScreen>
|
||||
);
|
||||
}, [setCollabDialogShown]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ height: "100%" }}
|
||||
@@ -605,7 +705,6 @@ const ExcalidrawWrapper = () => {
|
||||
ref={excalidrawRefCallback}
|
||||
onChange={onChange}
|
||||
initialData={initialStatePromiseRef.current.promise}
|
||||
onCollabButtonClick={() => setCollabDialogShown(true)}
|
||||
isCollaborating={isCollaborating}
|
||||
onPointerUpdate={collabAPI?.onPointerUpdate}
|
||||
UIOptions={{
|
||||
@@ -639,13 +738,27 @@ const ExcalidrawWrapper = () => {
|
||||
onLibraryChange={onLibraryChange}
|
||||
autoFocus={true}
|
||||
theme={theme}
|
||||
renderTopRightUI={(isMobile) => {
|
||||
if (isMobile) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<LiveCollaborationTrigger
|
||||
isCollaborating={isCollaborating}
|
||||
onSelect={() => setCollabDialogShown(true)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{renderMenu()}
|
||||
|
||||
<Footer>
|
||||
<div style={{ display: "flex", gap: ".5rem", alignItems: "center" }}>
|
||||
<ExcalidrawPlusAppLink />
|
||||
<EncryptedIcon />
|
||||
</div>
|
||||
</Footer>
|
||||
{welcomeScreenJSX}
|
||||
</Excalidraw>
|
||||
{excalidrawAPI && <Collab excalidrawAPI={excalidrawAPI} />}
|
||||
{errorMessage && (
|
||||
|
||||
+1
-3
@@ -1,6 +1,4 @@
|
||||
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(window.navigator.platform);
|
||||
export const isWindows = /^Win/.test(window.navigator.platform);
|
||||
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
|
||||
import { isDarwin } from "./constants";
|
||||
|
||||
export const CODES = {
|
||||
EQUAL: "Equal",
|
||||
|
||||
+19
-11
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"labels": {
|
||||
"paste": "لصق",
|
||||
"pasteAsPlaintext": "",
|
||||
"pasteAsPlaintext": "اللصق كنص عادي",
|
||||
"pasteCharts": "لصق الرسوم البيانية",
|
||||
"selectAll": "تحديد الكل",
|
||||
"multiSelect": "إضافة عنصر للتحديد",
|
||||
@@ -66,13 +66,13 @@
|
||||
"cartoonist": "كرتوني",
|
||||
"fileTitle": "إسم الملف",
|
||||
"colorPicker": "منتقي اللون",
|
||||
"canvasColors": "",
|
||||
"canvasColors": "تستخدم على القماش",
|
||||
"canvasBackground": "خلفية اللوحة",
|
||||
"drawingCanvas": "لوحة الرسم",
|
||||
"layers": "الطبقات",
|
||||
"actions": "الإجراءات",
|
||||
"language": "اللغة",
|
||||
"liveCollaboration": "",
|
||||
"liveCollaboration": "التعاون المباشر...",
|
||||
"duplicateSelection": "تكرار",
|
||||
"untitled": "غير معنون",
|
||||
"name": "الاسم",
|
||||
@@ -108,7 +108,7 @@
|
||||
"excalidrawLib": "مكتبتنا",
|
||||
"decreaseFontSize": "تصغير حجم الخط",
|
||||
"increaseFontSize": "تكبير حجم الخط",
|
||||
"unbindText": "",
|
||||
"unbindText": "فك ربط النص",
|
||||
"bindText": "",
|
||||
"link": {
|
||||
"edit": "تعديل الرابط",
|
||||
@@ -145,7 +145,7 @@
|
||||
"scale": "مقاس",
|
||||
"save": "احفظ للملف الحالي",
|
||||
"saveAs": "حفظ كـ",
|
||||
"load": "",
|
||||
"load": "فتح",
|
||||
"getShareableLink": "احصل على رابط المشاركة",
|
||||
"close": "غلق",
|
||||
"selectLanguage": "اختر اللغة",
|
||||
@@ -201,7 +201,9 @@
|
||||
"svgImageInsertError": "تعذر إدراج صورة SVG. يبدو أن ترميز SVG غير صحيح.",
|
||||
"invalidSVGString": "SVG غير صالح.",
|
||||
"cannotResolveCollabServer": "",
|
||||
"importLibraryError": ""
|
||||
"importLibraryError": "",
|
||||
"collabSaveFailed": "",
|
||||
"collabSaveFailed_sizeExceeded": ""
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "تحديد",
|
||||
@@ -445,10 +447,16 @@
|
||||
"d9480f": "برتقالي 9"
|
||||
},
|
||||
"welcomeScreen": {
|
||||
"data": "",
|
||||
"switchToPlusApp": "",
|
||||
"menuHints": "",
|
||||
"toolbarHints": "",
|
||||
"helpHints": ""
|
||||
"app": {
|
||||
"center_heading": "",
|
||||
"center_heading_plus": "",
|
||||
"menuHint": ""
|
||||
},
|
||||
"defaults": {
|
||||
"menuHint": "",
|
||||
"center_heading": "",
|
||||
"toolbarHint": "",
|
||||
"helpHint": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-6
@@ -201,7 +201,9 @@
|
||||
"svgImageInsertError": "",
|
||||
"invalidSVGString": "",
|
||||
"cannotResolveCollabServer": "",
|
||||
"importLibraryError": ""
|
||||
"importLibraryError": "",
|
||||
"collabSaveFailed": "",
|
||||
"collabSaveFailed_sizeExceeded": ""
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "Селекция",
|
||||
@@ -445,10 +447,16 @@
|
||||
"d9480f": ""
|
||||
},
|
||||
"welcomeScreen": {
|
||||
"data": "",
|
||||
"switchToPlusApp": "",
|
||||
"menuHints": "",
|
||||
"toolbarHints": "",
|
||||
"helpHints": ""
|
||||
"app": {
|
||||
"center_heading": "",
|
||||
"center_heading_plus": "",
|
||||
"menuHint": ""
|
||||
},
|
||||
"defaults": {
|
||||
"menuHint": "",
|
||||
"center_heading": "",
|
||||
"toolbarHint": "",
|
||||
"helpHint": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-6
@@ -201,7 +201,9 @@
|
||||
"svgImageInsertError": "এসভীজী ছবি সন্নিবেশ করা যায়নি। এসভীজী মার্কআপটি অবৈধ মনে হচ্ছে৷",
|
||||
"invalidSVGString": "এসভীজী মার্কআপটি অবৈধ মনে হচ্ছে৷",
|
||||
"cannotResolveCollabServer": "কোল্যাব সার্ভারের সাথে সংযোগ করা যায়নি। পৃষ্ঠাটি পুনরায় লোড করে আবার চেষ্টা করুন।",
|
||||
"importLibraryError": "সংগ্রহ লোড করা যায়নি"
|
||||
"importLibraryError": "সংগ্রহ লোড করা যায়নি",
|
||||
"collabSaveFailed": "",
|
||||
"collabSaveFailed_sizeExceeded": ""
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "বাছাই",
|
||||
@@ -445,10 +447,16 @@
|
||||
"d9480f": ""
|
||||
},
|
||||
"welcomeScreen": {
|
||||
"data": "",
|
||||
"switchToPlusApp": "",
|
||||
"menuHints": "",
|
||||
"toolbarHints": "",
|
||||
"helpHints": ""
|
||||
"app": {
|
||||
"center_heading": "",
|
||||
"center_heading_plus": "",
|
||||
"menuHint": ""
|
||||
},
|
||||
"defaults": {
|
||||
"menuHint": "",
|
||||
"center_heading": "",
|
||||
"toolbarHint": "",
|
||||
"helpHint": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-6
@@ -201,7 +201,9 @@
|
||||
"svgImageInsertError": "No ha estat possible inserir la imatge SVG. Les marques SVG semblen invàlides.",
|
||||
"invalidSVGString": "SVG no vàlid.",
|
||||
"cannotResolveCollabServer": "No ha estat possible connectar amb el servidor collab. Si us plau recarregueu la pàgina i torneu a provar.",
|
||||
"importLibraryError": "No s'ha pogut carregar la biblioteca"
|
||||
"importLibraryError": "No s'ha pogut carregar la biblioteca",
|
||||
"collabSaveFailed": "",
|
||||
"collabSaveFailed_sizeExceeded": ""
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "Selecció",
|
||||
@@ -445,10 +447,16 @@
|
||||
"d9480f": "Taronja 9"
|
||||
},
|
||||
"welcomeScreen": {
|
||||
"data": "",
|
||||
"switchToPlusApp": "",
|
||||
"menuHints": "",
|
||||
"toolbarHints": "",
|
||||
"helpHints": ""
|
||||
"app": {
|
||||
"center_heading": "",
|
||||
"center_heading_plus": "",
|
||||
"menuHint": ""
|
||||
},
|
||||
"defaults": {
|
||||
"menuHint": "",
|
||||
"center_heading": "",
|
||||
"toolbarHint": "",
|
||||
"helpHint": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-6
@@ -201,7 +201,9 @@
|
||||
"svgImageInsertError": "",
|
||||
"invalidSVGString": "",
|
||||
"cannotResolveCollabServer": "",
|
||||
"importLibraryError": ""
|
||||
"importLibraryError": "",
|
||||
"collabSaveFailed": "",
|
||||
"collabSaveFailed_sizeExceeded": ""
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "Výběr",
|
||||
@@ -445,10 +447,16 @@
|
||||
"d9480f": "Oranzova"
|
||||
},
|
||||
"welcomeScreen": {
|
||||
"data": "",
|
||||
"switchToPlusApp": "",
|
||||
"menuHints": "",
|
||||
"toolbarHints": "",
|
||||
"helpHints": ""
|
||||
"app": {
|
||||
"center_heading": "",
|
||||
"center_heading_plus": "",
|
||||
"menuHint": ""
|
||||
},
|
||||
"defaults": {
|
||||
"menuHint": "",
|
||||
"center_heading": "",
|
||||
"toolbarHint": "",
|
||||
"helpHint": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-6
@@ -201,7 +201,9 @@
|
||||
"svgImageInsertError": "",
|
||||
"invalidSVGString": "",
|
||||
"cannotResolveCollabServer": "",
|
||||
"importLibraryError": ""
|
||||
"importLibraryError": "",
|
||||
"collabSaveFailed": "",
|
||||
"collabSaveFailed_sizeExceeded": ""
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "",
|
||||
@@ -445,10 +447,16 @@
|
||||
"d9480f": ""
|
||||
},
|
||||
"welcomeScreen": {
|
||||
"data": "",
|
||||
"switchToPlusApp": "",
|
||||
"menuHints": "",
|
||||
"toolbarHints": "",
|
||||
"helpHints": ""
|
||||
"app": {
|
||||
"center_heading": "",
|
||||
"center_heading_plus": "",
|
||||
"menuHint": ""
|
||||
},
|
||||
"defaults": {
|
||||
"menuHint": "",
|
||||
"center_heading": "",
|
||||
"toolbarHint": "",
|
||||
"helpHint": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-14
@@ -99,14 +99,14 @@
|
||||
"flipHorizontal": "Horizontal spiegeln",
|
||||
"flipVertical": "Vertikal spiegeln",
|
||||
"viewMode": "Ansichtsmodus",
|
||||
"toggleExportColorScheme": "Exportfarbschema umschalten",
|
||||
"toggleExportColorScheme": "Farbschema für Export umschalten",
|
||||
"share": "Teilen",
|
||||
"showStroke": "Auswahl für Strichfarbe anzeigen",
|
||||
"showBackground": "Hintergrundfarbe auswählen",
|
||||
"toggleTheme": "Thema umschalten",
|
||||
"toggleTheme": "Design umschalten",
|
||||
"personalLib": "Persönliche Bibliothek",
|
||||
"excalidrawLib": "Excalidraw-Bibliothek",
|
||||
"decreaseFontSize": "Schrift verkleinern",
|
||||
"excalidrawLib": "Excalidraw Bibliothek",
|
||||
"decreaseFontSize": "Schriftgröße verkleinern",
|
||||
"increaseFontSize": "Schrift vergrößern",
|
||||
"unbindText": "Text lösen",
|
||||
"bindText": "Text an Container binden",
|
||||
@@ -161,8 +161,8 @@
|
||||
"resetLibrary": "Bibliothek zurücksetzen",
|
||||
"createNewRoom": "Neuen Raum erstellen",
|
||||
"fullScreen": "Vollbildanzeige",
|
||||
"darkMode": "Dunkler Modus",
|
||||
"lightMode": "Heller Modus",
|
||||
"darkMode": "Dunkles Design",
|
||||
"lightMode": "Helles Design",
|
||||
"zenMode": "Zen-Modus",
|
||||
"exitZenMode": "Zen-Modus verlassen",
|
||||
"cancel": "Abbrechen",
|
||||
@@ -201,7 +201,9 @@
|
||||
"svgImageInsertError": "SVG-Bild konnte nicht eingefügt werden. Das SVG-Markup sieht ungültig aus.",
|
||||
"invalidSVGString": "Ungültige SVG.",
|
||||
"cannotResolveCollabServer": "Konnte keine Verbindung zum Collab-Server herstellen. Bitte lade die Seite neu und versuche es erneut.",
|
||||
"importLibraryError": "Bibliothek konnte nicht geladen werden"
|
||||
"importLibraryError": "Bibliothek konnte nicht geladen werden",
|
||||
"collabSaveFailed": "Keine Speicherung in der Backend-Datenbank möglich. Wenn die Probleme weiterhin bestehen, solltest Du Deine Datei lokal speichern, um sicherzustellen, dass Du Deine Arbeit nicht verlierst.",
|
||||
"collabSaveFailed_sizeExceeded": "Keine Speicherung in der Backend-Datenbank möglich, die Zeichenfläche scheint zu groß zu sein. Du solltest Deine Datei lokal speichern, um sicherzustellen, dass Du Deine Arbeit nicht verlierst."
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "Auswahl",
|
||||
@@ -312,8 +314,8 @@
|
||||
"zoomToFit": "Zoomen um alle Elemente einzupassen",
|
||||
"zoomToSelection": "Auf Auswahl zoomen",
|
||||
"toggleElementLock": "Auswahl sperren/entsperren",
|
||||
"movePageUpDown": "",
|
||||
"movePageLeftRight": ""
|
||||
"movePageUpDown": "Seite nach oben/unten verschieben",
|
||||
"movePageLeftRight": "Seite nach links/rechts verschieben"
|
||||
},
|
||||
"clearCanvasDialog": {
|
||||
"title": "Zeichenfläche löschen"
|
||||
@@ -445,10 +447,16 @@
|
||||
"d9480f": "Orange 9"
|
||||
},
|
||||
"welcomeScreen": {
|
||||
"data": "Alle Daten werden lokal in Deinem Browser gespeichert.",
|
||||
"switchToPlusApp": "Möchtest du stattdessen zu Excalidraw+ gehen?",
|
||||
"menuHints": "Exportieren, Einstellungen, Sprachen, ...",
|
||||
"toolbarHints": "Wähle ein Werkzeug & beginne zu zeichnen!",
|
||||
"helpHints": "Kurzbefehle & Hilfe"
|
||||
"app": {
|
||||
"center_heading": "Alle Daten werden lokal in Deinem Browser gespeichert.",
|
||||
"center_heading_plus": "Möchtest du stattdessen zu Excalidraw+ gehen?",
|
||||
"menuHint": "Exportieren, Einstellungen, Sprachen, ..."
|
||||
},
|
||||
"defaults": {
|
||||
"menuHint": "Exportieren, Einstellungen und mehr...",
|
||||
"center_heading": "Diagramme. Einfach. Gemacht.",
|
||||
"toolbarHint": "Wähle ein Werkzeug & beginne zu zeichnen!",
|
||||
"helpHint": "Kurzbefehle & Hilfe"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-18
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"labels": {
|
||||
"paste": "Επικόλληση",
|
||||
"pasteAsPlaintext": "",
|
||||
"pasteAsPlaintext": "Επικόλληση ως απλό κείμενο",
|
||||
"pasteCharts": "Επικόλληση γραφημάτων",
|
||||
"selectAll": "Επιλογή όλων",
|
||||
"multiSelect": "Προσθέστε το στοιχείο στην επιλογή",
|
||||
@@ -72,7 +72,7 @@
|
||||
"layers": "Στρώματα",
|
||||
"actions": "Ενέργειες",
|
||||
"language": "Γλώσσα",
|
||||
"liveCollaboration": "",
|
||||
"liveCollaboration": "Live συνεργασία...",
|
||||
"duplicateSelection": "Δημιουργία αντιγράφου",
|
||||
"untitled": "Χωρίς τίτλο",
|
||||
"name": "Όνομα",
|
||||
@@ -116,8 +116,8 @@
|
||||
"label": "Σύνδεσμος"
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
"edit": "Επεξεργασία γραμμής",
|
||||
"exit": "Έξοδος επεξεργαστή κειμένου"
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "Κλείδωμα",
|
||||
@@ -136,8 +136,8 @@
|
||||
"buttons": {
|
||||
"clearReset": "Επαναφορά του καμβά",
|
||||
"exportJSON": "Εξαγωγή σε αρχείο",
|
||||
"exportImage": "",
|
||||
"export": "",
|
||||
"exportImage": "Εξαγωγή εικόνας...",
|
||||
"export": "Αποθήκευση ως...",
|
||||
"exportToPng": "Εξαγωγή σε PNG",
|
||||
"exportToSvg": "Εξαγωγή σε SVG",
|
||||
"copyToClipboard": "Αντιγραφή στο πρόχειρο",
|
||||
@@ -145,7 +145,7 @@
|
||||
"scale": "Κλίμακα",
|
||||
"save": "Αποθήκευση στο τρέχον αρχείο",
|
||||
"saveAs": "Αποθήκευση ως",
|
||||
"load": "",
|
||||
"load": "Άνοιγμα",
|
||||
"getShareableLink": "Δημόσιος σύνδεσμος",
|
||||
"close": "Κλείσιμο",
|
||||
"selectLanguage": "Επιλογή γλώσσας",
|
||||
@@ -201,7 +201,9 @@
|
||||
"svgImageInsertError": "Αδυναμία εισαγωγής εικόνας SVG. Η σήμανση της SVG δεν φαίνεται έγκυρη.",
|
||||
"invalidSVGString": "Μη έγκυρο SVG.",
|
||||
"cannotResolveCollabServer": "Αδυναμία σύνδεσης με τον διακομιστή συνεργασίας. Παρακαλώ ανανεώστε τη σελίδα και προσπαθήστε ξανά.",
|
||||
"importLibraryError": "Αδυναμία φόρτωσης βιβλιοθήκης"
|
||||
"importLibraryError": "Αδυναμία φόρτωσης βιβλιοθήκης",
|
||||
"collabSaveFailed": "Η αποθήκευση στη βάση δεδομένων δεν ήταν δυνατή. Αν το προβλήματα παραμείνει, θα πρέπει να αποθηκεύσετε το αρχείο σας τοπικά για να βεβαιωθείτε ότι δεν χάνετε την εργασία σας.",
|
||||
"collabSaveFailed_sizeExceeded": "Η αποθήκευση στη βάση δεδομένων δεν ήταν δυνατή, ο καμβάς φαίνεται να είναι πολύ μεγάλος. Θα πρέπει να αποθηκεύσετε το αρχείο τοπικά για να βεβαιωθείτε ότι δεν θα χάσετε την εργασία σας."
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "Επιλογή",
|
||||
@@ -215,7 +217,7 @@
|
||||
"text": "Κείμενο",
|
||||
"library": "Βιβλιοθήκη",
|
||||
"lock": "Κράτησε επιλεγμένο το εργαλείο μετά το σχέδιο",
|
||||
"penMode": "",
|
||||
"penMode": "Λειτουργία μολυβιού - αποτροπή αφής",
|
||||
"link": "Προσθήκη/ Ενημέρωση συνδέσμου για ένα επιλεγμένο σχήμα",
|
||||
"eraser": "Γόμα"
|
||||
},
|
||||
@@ -236,7 +238,7 @@
|
||||
"resize": "Μπορείς να περιορίσεις τις αναλογίες κρατώντας το SHIFT ενώ αλλάζεις μέγεθος,\nκράτησε πατημένο το ALT για αλλαγή μεγέθους από το κέντρο",
|
||||
"resizeImage": "Μπορείτε να αλλάξετε το μέγεθος ελεύθερα κρατώντας πατημένο το SHIFT,\nκρατήστε πατημένο το ALT για να αλλάξετε το μέγεθος από το κέντρο",
|
||||
"rotate": "Μπορείς να περιορίσεις τις γωνίες κρατώντας πατημένο το πλήκτρο SHIFT κατά την περιστροφή",
|
||||
"lineEditor_info": "",
|
||||
"lineEditor_info": "Κρατήστε πατημένο Ctrl ή Cmd και πατήστε το πλήκτρο Ctrl ή Cmd + Enter για επεξεργασία σημείων",
|
||||
"lineEditor_pointSelected": "Πατήστε Διαγραφή για αφαίρεση σημείου(ων),\nCtrlOrCmd+D για αντιγραφή, ή σύρετε για μετακίνηση",
|
||||
"lineEditor_nothingSelected": "Επιλέξτε ένα σημείο για να επεξεργαστείτε (κρατήστε πατημένο το SHIFT για να επιλέξετε πολλαπλά),\nή κρατήστε πατημένο το Alt και κάντε κλικ για να προσθέσετε νέα σημεία",
|
||||
"placeImage": "Κάντε κλικ για να τοποθετήσετε την εικόνα ή κάντε κλικ και σύρετε για να ορίσετε το μέγεθός της χειροκίνητα",
|
||||
@@ -312,8 +314,8 @@
|
||||
"zoomToFit": "Zoom ώστε να χωρέσουν όλα τα στοιχεία",
|
||||
"zoomToSelection": "Ζουμ στην επιλογή",
|
||||
"toggleElementLock": "Κλείδωμα/Ξεκλείδωμα επιλογής",
|
||||
"movePageUpDown": "",
|
||||
"movePageLeftRight": ""
|
||||
"movePageUpDown": "Μετακίνηση σελίδας πάνω/κάτω",
|
||||
"movePageLeftRight": "Μετακίνηση σελίδας αριστερά/δεξιά"
|
||||
},
|
||||
"clearCanvasDialog": {
|
||||
"title": "Καθαρισμός καμβά"
|
||||
@@ -395,7 +397,7 @@
|
||||
"fileSavedToFilename": "Αποθηκεύτηκε στο {filename}",
|
||||
"canvas": "καμβάς",
|
||||
"selection": "επιλογή",
|
||||
"pasteAsSingleElement": ""
|
||||
"pasteAsSingleElement": "Χρησιμοποίησε το {{shortcut}} για να επικολλήσεις ως ένα μόνο στοιχείο,\nή να επικολλήσεις σε έναν υπάρχοντα επεξεργαστή κειμένου"
|
||||
},
|
||||
"colors": {
|
||||
"ffffff": "Λευκό",
|
||||
@@ -445,10 +447,16 @@
|
||||
"d9480f": "Πορτοκαλί 9"
|
||||
},
|
||||
"welcomeScreen": {
|
||||
"data": "",
|
||||
"switchToPlusApp": "",
|
||||
"menuHints": "",
|
||||
"toolbarHints": "",
|
||||
"helpHints": ""
|
||||
"app": {
|
||||
"center_heading": "Όλα τα δεδομένα σας αποθηκεύονται τοπικά στο πρόγραμμα περιήγησης.",
|
||||
"center_heading_plus": "Μήπως θέλατε να πάτε στο Excalidraw+;",
|
||||
"menuHint": "Εξαγωγή, προτιμήσεις, γλώσσες, ..."
|
||||
},
|
||||
"defaults": {
|
||||
"menuHint": "Εξαγωγή, προτιμήσεις και άλλες επιλογές...",
|
||||
"center_heading": "Διαγράμματα. Εύκολα. Γρήγορα.",
|
||||
"toolbarHint": "Επιλέξτε ένα εργαλείο και ξεκινήστε να σχεδιάζεται!",
|
||||
"helpHint": "Συντομεύσεις και βοήθεια"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user