Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef7330243f | |||
| e6a9ff1b96 | |||
| 832b88249c | |||
| 9902092fd1 | |||
| 8f0863d335 | |||
| 9423ac3263 | |||
| a66cfe2627 | |||
| 86cf28f2b4 | |||
| b5a46dd671 | |||
| cd942c3e3b | |||
| 55ccd5b79b | |||
| 4348c55c31 | |||
| a3fbe40b26 | |||
| 7431ca81d1 | |||
| 4d13dbf625 | |||
| 3840e2f4e6 | |||
| 52d10bb41e | |||
| 96c87f920a | |||
| 7d4189c624 | |||
| f3e17c90d3 | |||
| 70b3a9de49 | |||
| bf6d0eeef7 | |||
| 5359e4fec9 | |||
| 58fe639b8d | |||
| 327ed0e2d1 | |||
| c2fce6d8c4 | |||
| cb6b7559b4 | |||
| 77d789ed8e | |||
| 89471094ce | |||
| 670ceafc84 | |||
| 873afdacd3 | |||
| 880e4feede | |||
| 9ba7ca3845 | |||
| 734bb4d2ed | |||
| f2d2f97546 | |||
| 2fa69ddc32 | |||
| 1331cffe93 | |||
| f242721f3b | |||
| e940aeb1a3 | |||
| 580e719580 | |||
| 127af9db23 | |||
| 2209e2c1e8 | |||
| ed31980f84 | |||
| db28595302 | |||
| cded1cd63d | |||
| 8e447b4c32 | |||
| e29d3fc5e6 | |||
| 9da56e46f0 | |||
| 625ecc64ed | |||
| ceb43ed8fb | |||
| 8c0a0415de | |||
| 192debd829 | |||
| 1cfb4dfd8b | |||
| fb32886355 | |||
| 065df495ba | |||
| 558227f744 | |||
| 6d45430344 | |||
| 3aa0c5ebc0 | |||
| e940993e0e | |||
| 8f90aeb8d5 | |||
| e92d133973 | |||
| b682d88167 | |||
| 7daf1a7944 |
+5
-4
@@ -4,9 +4,10 @@ REACT_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
|
||||
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
||||
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
||||
|
||||
REACT_APP_PORTAL_URL=http://localhost:3002
|
||||
# Fill to set socket server URL used for collaboration.
|
||||
# Meant for forks only: excalidraw.com uses custom REACT_APP_PORTAL_URL flow
|
||||
REACT_APP_WS_SERVER_URL=
|
||||
# collaboration WebSocket server (https://github.com/excalidraw/excalidraw-room)
|
||||
REACT_APP_WS_SERVER_URL=http://localhost:3002
|
||||
|
||||
# set this only if using the collaboration workflow we use on excalidraw.com
|
||||
REACT_APP_PORTAL_URL=
|
||||
|
||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
|
||||
|
||||
@@ -128,14 +128,41 @@ For collaboration, you will need to set up [collab server](https://github.com/ex
|
||||
|
||||
#### Commands
|
||||
|
||||
| Command | Description |
|
||||
| ------------------ | --------------------------------- |
|
||||
| `yarn` | Install the dependencies |
|
||||
| `yarn start` | Run the project |
|
||||
| `yarn fix` | Reformat all files with Prettier |
|
||||
| `yarn test` | Run tests |
|
||||
| `yarn test:update` | Update test snapshots |
|
||||
| `yarn test:code` | Test for formatting with Prettier |
|
||||
##### Install the dependencies
|
||||
|
||||
```
|
||||
yarn
|
||||
```
|
||||
|
||||
##### Run the project
|
||||
|
||||
```
|
||||
yarn start
|
||||
```
|
||||
|
||||
##### Reformat all files with Prettier
|
||||
|
||||
```
|
||||
yarn fix
|
||||
```
|
||||
|
||||
##### Run tests
|
||||
|
||||
```
|
||||
yarn test
|
||||
```
|
||||
|
||||
##### Update test snapshots
|
||||
|
||||
```
|
||||
yarn test:update
|
||||
```
|
||||
|
||||
##### Test for formatting with Prettier
|
||||
|
||||
```
|
||||
yarn test:code
|
||||
```
|
||||
|
||||
#### Docker Compose
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
rules_version = '2';
|
||||
service firebase.storage {
|
||||
match /b/{bucket}/o {
|
||||
match /{migrations} {
|
||||
match /{scenes}/{scene} {
|
||||
allow get, write: if true;
|
||||
// redundant, but let's be explicit'
|
||||
allow list: if false;
|
||||
}
|
||||
match /{files}/rooms/{room}/{file} {
|
||||
allow get, write: if true;
|
||||
}
|
||||
match /{files}/shareLinks/{shareLink}/{file} {
|
||||
allow get, write: if true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -36,6 +36,7 @@
|
||||
"i18next-browser-languagedetector": "6.1.2",
|
||||
"idb-keyval": "6.0.3",
|
||||
"image-blob-reduce": "3.0.1",
|
||||
"jotai": "1.6.4",
|
||||
"lodash.throttle": "4.1.1",
|
||||
"nanoid": "3.1.32",
|
||||
"open-color": "1.9.1",
|
||||
@@ -67,7 +68,7 @@
|
||||
"eslint-plugin-prettier": "3.3.1",
|
||||
"husky": "7.0.4",
|
||||
"jest-canvas-mock": "2.3.1",
|
||||
"lint-staged": "12.3.3",
|
||||
"lint-staged": "12.3.7",
|
||||
"pepjs": "0.5.3",
|
||||
"prettier": "2.5.1",
|
||||
"rewire": "5.0.0"
|
||||
|
||||
+4
-26
@@ -124,26 +124,6 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.LoadingMessage {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.LoadingMessage span {
|
||||
background-color: var(--button-gray-1);
|
||||
border-radius: 5px;
|
||||
padding: 0.8em 1.2em;
|
||||
color: var(--popup-text-color);
|
||||
font-size: 1.3em;
|
||||
}
|
||||
#root {
|
||||
height: 100%;
|
||||
-webkit-touch-callout: none;
|
||||
@@ -152,8 +132,10 @@
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1200px) {
|
||||
@media screen and (min-width: 1200px) {
|
||||
#root {
|
||||
-webkit-touch-callout: default;
|
||||
-webkit-user-select: auto;
|
||||
-khtml-user-select: auto;
|
||||
@@ -170,10 +152,6 @@
|
||||
<header>
|
||||
<h1 class="visually-hidden">Excalidraw</h1>
|
||||
</header>
|
||||
<div id="root">
|
||||
<div class="LoadingMessage">
|
||||
<span>Loading scene...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { t } from "../i18n";
|
||||
|
||||
export const actionAddToLibrary = register({
|
||||
name: "addToLibrary",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
|
||||
@@ -43,6 +43,7 @@ const alignSelectedElements = (
|
||||
|
||||
export const actionAlignTop = register({
|
||||
name: "alignTop",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
@@ -72,6 +73,7 @@ export const actionAlignTop = register({
|
||||
|
||||
export const actionAlignBottom = register({
|
||||
name: "alignBottom",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
@@ -101,6 +103,7 @@ export const actionAlignBottom = register({
|
||||
|
||||
export const actionAlignLeft = register({
|
||||
name: "alignLeft",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
@@ -130,6 +133,8 @@ export const actionAlignLeft = register({
|
||||
|
||||
export const actionAlignRight = register({
|
||||
name: "alignRight",
|
||||
trackEvent: { category: "element" },
|
||||
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
@@ -159,6 +164,8 @@ export const actionAlignRight = register({
|
||||
|
||||
export const actionAlignVerticallyCentered = register({
|
||||
name: "alignVerticallyCentered",
|
||||
trackEvent: { category: "element" },
|
||||
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
@@ -184,6 +191,7 @@ export const actionAlignVerticallyCentered = register({
|
||||
|
||||
export const actionAlignHorizontallyCentered = register({
|
||||
name: "alignHorizontallyCentered",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { VERTICAL_ALIGN } from "../constants";
|
||||
import { getNonDeletedElements, isTextElement } from "../element";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
measureText,
|
||||
redrawTextBoundingBox,
|
||||
} from "../element/textElement";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isTextBindableContainer,
|
||||
} from "../element/typeChecks";
|
||||
import {
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawTextElement,
|
||||
} from "../element/types";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getFontString } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionUnbindText = register({
|
||||
name: "unbindText",
|
||||
contextItemLabel: "labels.unbindText",
|
||||
trackEvent: { category: "element" },
|
||||
contextItemPredicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
return selectedElements.some((element) => hasBoundTextElement(element));
|
||||
},
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
selectedElements.forEach((element) => {
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
const { width, height, baseline } = measureText(
|
||||
boundTextElement.originalText,
|
||||
getFontString(boundTextElement),
|
||||
);
|
||||
mutateElement(boundTextElement as ExcalidrawTextElement, {
|
||||
containerId: null,
|
||||
width,
|
||||
height,
|
||||
baseline,
|
||||
text: boundTextElement.originalText,
|
||||
});
|
||||
mutateElement(element, {
|
||||
boundElements: element.boundElements?.filter(
|
||||
(ele) => ele.id !== boundTextElement.id,
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
return {
|
||||
elements,
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const actionBindText = register({
|
||||
name: "bindText",
|
||||
contextItemLabel: "labels.bindText",
|
||||
trackEvent: { category: "element" },
|
||||
contextItemPredicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
|
||||
if (selectedElements.length === 2) {
|
||||
const textElement =
|
||||
isTextElement(selectedElements[0]) ||
|
||||
isTextElement(selectedElements[1]);
|
||||
|
||||
let bindingContainer;
|
||||
if (isTextBindableContainer(selectedElements[0])) {
|
||||
bindingContainer = selectedElements[0];
|
||||
} else if (isTextBindableContainer(selectedElements[1])) {
|
||||
bindingContainer = selectedElements[1];
|
||||
}
|
||||
if (
|
||||
textElement &&
|
||||
bindingContainer &&
|
||||
getBoundTextElement(bindingContainer) === null
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
|
||||
let textElement: ExcalidrawTextElement;
|
||||
let container: ExcalidrawTextContainer;
|
||||
|
||||
if (
|
||||
isTextElement(selectedElements[0]) &&
|
||||
isTextBindableContainer(selectedElements[1])
|
||||
) {
|
||||
textElement = selectedElements[0];
|
||||
container = selectedElements[1];
|
||||
} else {
|
||||
textElement = selectedElements[1] as ExcalidrawTextElement;
|
||||
container = selectedElements[0] as ExcalidrawTextContainer;
|
||||
}
|
||||
mutateElement(textElement, {
|
||||
containerId: container.id,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
});
|
||||
mutateElement(container, {
|
||||
boundElements: (container.boundElements || []).concat({
|
||||
type: "text",
|
||||
id: textElement.id,
|
||||
}),
|
||||
});
|
||||
redrawTextBoundingBox(textElement, container);
|
||||
const updatedElements = elements.slice();
|
||||
const textElementIndex = updatedElements.findIndex(
|
||||
(ele) => ele.id === textElement.id,
|
||||
);
|
||||
updatedElements.splice(textElementIndex, 1);
|
||||
const containerIndex = updatedElements.findIndex(
|
||||
(ele) => ele.id === container.id,
|
||||
);
|
||||
updatedElements.splice(containerIndex + 1, 0, textElement);
|
||||
return {
|
||||
elements: updatedElements,
|
||||
appState: { ...appState, selectedElementIds: { [container.id]: true } },
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ColorPicker } from "../components/ColorPicker";
|
||||
import { zoomIn, zoomOut } from "../components/icons";
|
||||
import { eraser, zoomIn, zoomOut } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||
import { THEME, ZOOM_STEP } from "../constants";
|
||||
@@ -15,11 +15,13 @@ import { getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { getDefaultAppState, isEraserActive } from "../appState";
|
||||
import ClearCanvas from "../components/ClearCanvas";
|
||||
import clsx from "clsx";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
name: "changeViewBackgroundColor",
|
||||
trackEvent: false,
|
||||
perform: (_, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, ...value },
|
||||
@@ -49,6 +51,7 @@ export const actionChangeViewBackgroundColor = register({
|
||||
|
||||
export const actionClearCanvas = register({
|
||||
name: "clearCanvas",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
app.imageCache.clear();
|
||||
return {
|
||||
@@ -59,7 +62,6 @@ export const actionClearCanvas = register({
|
||||
...getDefaultAppState(),
|
||||
files: {},
|
||||
theme: appState.theme,
|
||||
elementLocked: appState.elementLocked,
|
||||
penMode: appState.penMode,
|
||||
penDetected: appState.penDetected,
|
||||
exportBackground: appState.exportBackground,
|
||||
@@ -67,8 +69,10 @@ export const actionClearCanvas = register({
|
||||
gridSize: appState.gridSize,
|
||||
showStats: appState.showStats,
|
||||
pasteDialog: appState.pasteDialog,
|
||||
elementType:
|
||||
appState.elementType === "image" ? "selection" : appState.elementType,
|
||||
activeTool:
|
||||
appState.activeTool.type === "image"
|
||||
? { ...appState.activeTool, type: "selection" }
|
||||
: appState.activeTool,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
@@ -79,6 +83,7 @@ export const actionClearCanvas = register({
|
||||
|
||||
export const actionZoomIn = register({
|
||||
name: "zoomIn",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
return {
|
||||
appState: {
|
||||
@@ -114,6 +119,7 @@ export const actionZoomIn = register({
|
||||
|
||||
export const actionZoomOut = register({
|
||||
name: "zoomOut",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
return {
|
||||
appState: {
|
||||
@@ -149,6 +155,7 @@ export const actionZoomOut = register({
|
||||
|
||||
export const actionResetZoom = register({
|
||||
name: "resetZoom",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
return {
|
||||
appState: {
|
||||
@@ -247,6 +254,7 @@ const zoomToFitElements = (
|
||||
|
||||
export const actionZoomToSelected = register({
|
||||
name: "zoomToSelection",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => zoomToFitElements(elements, appState, true),
|
||||
keyTest: (event) =>
|
||||
event.code === CODES.TWO &&
|
||||
@@ -257,6 +265,7 @@ export const actionZoomToSelected = register({
|
||||
|
||||
export const actionZoomToFit = register({
|
||||
name: "zoomToFit",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => zoomToFitElements(elements, appState, false),
|
||||
keyTest: (event) =>
|
||||
event.code === CODES.ONE &&
|
||||
@@ -267,6 +276,7 @@ export const actionZoomToFit = register({
|
||||
|
||||
export const actionToggleTheme = register({
|
||||
name: "toggleTheme",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_, appState, value) => {
|
||||
return {
|
||||
appState: {
|
||||
@@ -289,3 +299,42 @@ export const actionToggleTheme = register({
|
||||
),
|
||||
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
|
||||
});
|
||||
|
||||
export const actionErase = register({
|
||||
name: "eraser",
|
||||
trackEvent: { category: "toolbar" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
activeTool: {
|
||||
...appState.activeTool,
|
||||
type: isEraserActive(appState)
|
||||
? appState.activeTool.lastActiveToolBeforeEraser ?? "selection"
|
||||
: "eraser",
|
||||
lastActiveToolBeforeEraser:
|
||||
appState.activeTool.type === "eraser" //node throws incorrect type error when using isEraserActive()
|
||||
? null
|
||||
: appState.activeTool.type,
|
||||
},
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
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>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { copyToClipboard } from "../clipboard";
|
||||
import {
|
||||
copyTextToSystemClipboard,
|
||||
copyToClipboard,
|
||||
probablySupportsClipboardWriteText,
|
||||
} from "../clipboard";
|
||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||
import { getSelectedElements } from "../scene/selection";
|
||||
import { exportCanvas } from "../data/index";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { getNonDeletedElements, isTextElement } from "../element";
|
||||
import { t } from "../i18n";
|
||||
|
||||
export const actionCopy = register({
|
||||
name: "copy",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
copyToClipboard(getNonDeletedElements(elements), appState, app.files);
|
||||
|
||||
@@ -23,6 +28,7 @@ export const actionCopy = register({
|
||||
|
||||
export const actionCut = register({
|
||||
name: "cut",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, data, app) => {
|
||||
actionCopy.perform(elements, appState, data, app);
|
||||
return actionDeleteSelected.perform(elements, appState);
|
||||
@@ -33,6 +39,7 @@ export const actionCut = register({
|
||||
|
||||
export const actionCopyAsSvg = register({
|
||||
name: "copyAsSvg",
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, _data, app) => {
|
||||
if (!app.canvas) {
|
||||
return {
|
||||
@@ -73,6 +80,7 @@ export const actionCopyAsSvg = register({
|
||||
|
||||
export const actionCopyAsPng = register({
|
||||
name: "copyAsPng",
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, _data, app) => {
|
||||
if (!app.canvas) {
|
||||
return {
|
||||
@@ -122,3 +130,35 @@ export const actionCopyAsPng = register({
|
||||
contextItemLabel: "labels.copyAsPng",
|
||||
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
|
||||
});
|
||||
|
||||
export const copyText = register({
|
||||
name: "copyText",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
);
|
||||
|
||||
const text = selectedElements
|
||||
.reduce((acc: string[], element) => {
|
||||
if (isTextElement(element)) {
|
||||
acc.push(element.text);
|
||||
}
|
||||
return acc;
|
||||
}, [])
|
||||
.join("\n\n");
|
||||
copyTextToSystemClipboard(text);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemPredicate: (elements, appState) => {
|
||||
return (
|
||||
probablySupportsClipboardWriteText &&
|
||||
getSelectedElements(elements, appState, true).some(isTextElement)
|
||||
);
|
||||
},
|
||||
contextItemLabel: "labels.copyText",
|
||||
});
|
||||
|
||||
@@ -58,6 +58,7 @@ const handleGroupEditingState = (
|
||||
|
||||
export const actionDeleteSelected = register({
|
||||
name: "deleteSelectedElements",
|
||||
trackEvent: { category: "element", action: "delete" },
|
||||
perform: (elements, appState) => {
|
||||
if (appState.editingLinearElement) {
|
||||
const {
|
||||
@@ -133,7 +134,7 @@ export const actionDeleteSelected = register({
|
||||
elements: nextElements,
|
||||
appState: {
|
||||
...nextAppState,
|
||||
elementType: "selection",
|
||||
activeTool: { ...appState.activeTool, type: "selection" },
|
||||
multiElement: null,
|
||||
},
|
||||
commitToHistory: isSomeElementSelected(
|
||||
|
||||
@@ -39,6 +39,7 @@ const distributeSelectedElements = (
|
||||
|
||||
export const distributeHorizontally = register({
|
||||
name: "distributeHorizontally",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
@@ -68,6 +69,7 @@ export const distributeHorizontally = register({
|
||||
|
||||
export const distributeVertically = register({
|
||||
name: "distributeVertically",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
|
||||
@@ -22,6 +22,7 @@ import { isBoundToContainer } from "../element/typeChecks";
|
||||
|
||||
export const actionDuplicateSelection = register({
|
||||
name: "duplicateSelection",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
// duplicate selected point(s) if editing a line
|
||||
if (appState.editingLinearElement) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { trackEvent } from "../analytics";
|
||||
import { load, questionCircle, saveAs } from "../components/icons";
|
||||
import { ProjectName } from "../components/ProjectName";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
@@ -8,7 +7,7 @@ import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||
import { loadFromJSON, saveAsJSON } from "../data";
|
||||
import { resaveAsImageWithScene } from "../data/resave";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { useDeviceType } from "../components/App";
|
||||
import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { CheckboxItem } from "../components/CheckboxItem";
|
||||
@@ -23,8 +22,8 @@ import { Theme } from "../element/types";
|
||||
|
||||
export const actionChangeProjectName = register({
|
||||
name: "changeProjectName",
|
||||
trackEvent: false,
|
||||
perform: (_elements, appState, value) => {
|
||||
trackEvent("change", "title");
|
||||
return { appState: { ...appState, name: value }, commitToHistory: false };
|
||||
},
|
||||
PanelComponent: ({ appState, updateData, appProps }) => (
|
||||
@@ -41,6 +40,7 @@ export const actionChangeProjectName = register({
|
||||
|
||||
export const actionChangeExportScale = register({
|
||||
name: "changeExportScale",
|
||||
trackEvent: { category: "export", action: "scale" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportScale: value },
|
||||
@@ -89,6 +89,7 @@ export const actionChangeExportScale = register({
|
||||
|
||||
export const actionChangeExportBackground = register({
|
||||
name: "changeExportBackground",
|
||||
trackEvent: { category: "export", action: "toggleBackground" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportBackground: value },
|
||||
@@ -107,6 +108,7 @@ export const actionChangeExportBackground = register({
|
||||
|
||||
export const actionChangeExportEmbedScene = register({
|
||||
name: "changeExportEmbedScene",
|
||||
trackEvent: { category: "export", action: "embedScene" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportEmbedScene: value },
|
||||
@@ -128,6 +130,7 @@ export const actionChangeExportEmbedScene = register({
|
||||
|
||||
export const actionSaveToActiveFile = register({
|
||||
name: "saveToActiveFile",
|
||||
trackEvent: { category: "export" },
|
||||
perform: async (elements, appState, value, app) => {
|
||||
const fileHandleExists = !!appState.fileHandle;
|
||||
|
||||
@@ -172,6 +175,7 @@ export const actionSaveToActiveFile = register({
|
||||
|
||||
export const actionSaveFileToDisk = register({
|
||||
name: "saveFileToDisk",
|
||||
trackEvent: { category: "export" },
|
||||
perform: async (elements, appState, value, app) => {
|
||||
try {
|
||||
const { fileHandle } = await saveAsJSON(
|
||||
@@ -200,7 +204,7 @@ export const actionSaveFileToDisk = register({
|
||||
icon={saveAs}
|
||||
title={t("buttons.saveAs")}
|
||||
aria-label={t("buttons.saveAs")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
showAriaLabel={useDeviceType().isMobile}
|
||||
hidden={!nativeFileSystemSupported}
|
||||
onClick={() => updateData(null)}
|
||||
data-testid="save-as-button"
|
||||
@@ -210,6 +214,7 @@ export const actionSaveFileToDisk = register({
|
||||
|
||||
export const actionLoadScene = register({
|
||||
name: "loadScene",
|
||||
trackEvent: { category: "export" },
|
||||
perform: async (elements, appState, _, app) => {
|
||||
try {
|
||||
const {
|
||||
@@ -243,7 +248,7 @@ export const actionLoadScene = register({
|
||||
icon={load}
|
||||
title={t("buttons.load")}
|
||||
aria-label={t("buttons.load")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
showAriaLabel={useDeviceType().isMobile}
|
||||
onClick={updateData}
|
||||
data-testid="load-button"
|
||||
/>
|
||||
@@ -252,6 +257,7 @@ export const actionLoadScene = register({
|
||||
|
||||
export const actionExportWithDarkMode = register({
|
||||
name: "exportWithDarkMode",
|
||||
trackEvent: { category: "export", action: "toggleTheme" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportWithDarkMode: value },
|
||||
|
||||
@@ -17,6 +17,7 @@ import { isBindingElement } from "../element/typeChecks";
|
||||
|
||||
export const actionFinalize = register({
|
||||
name: "finalize",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, _, { canvas, focusContainer }) => {
|
||||
if (appState.editingLinearElement) {
|
||||
const { elementId, startBindingElement, endBindingElement } =
|
||||
@@ -38,6 +39,7 @@ export const actionFinalize = register({
|
||||
: undefined,
|
||||
appState: {
|
||||
...appState,
|
||||
cursorButton: "up",
|
||||
editingLinearElement: null,
|
||||
},
|
||||
commitToHistory: true,
|
||||
@@ -119,13 +121,17 @@ export const actionFinalize = register({
|
||||
);
|
||||
}
|
||||
|
||||
if (!appState.elementLocked && appState.elementType !== "freedraw") {
|
||||
if (
|
||||
!appState.activeTool.locked &&
|
||||
appState.activeTool.type !== "freedraw"
|
||||
) {
|
||||
appState.selectedElementIds[multiPointElement.id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(!appState.elementLocked && appState.elementType !== "freedraw") ||
|
||||
(!appState.activeTool.locked &&
|
||||
appState.activeTool.type !== "freedraw") ||
|
||||
!multiPointElement
|
||||
) {
|
||||
resetCursor(canvas);
|
||||
@@ -135,11 +141,20 @@ export const actionFinalize = register({
|
||||
elements: newElements,
|
||||
appState: {
|
||||
...appState,
|
||||
elementType:
|
||||
(appState.elementLocked || appState.elementType === "freedraw") &&
|
||||
cursorButton: "up",
|
||||
activeTool:
|
||||
(appState.activeTool.locked ||
|
||||
appState.activeTool.type === "freedraw") &&
|
||||
multiPointElement
|
||||
? appState.elementType
|
||||
: "selection",
|
||||
? appState.activeTool
|
||||
: {
|
||||
...appState.activeTool,
|
||||
type:
|
||||
appState.activeTool.type === "eraser" &&
|
||||
appState.activeTool.lastActiveToolBeforeEraser
|
||||
? appState.activeTool.lastActiveToolBeforeEraser
|
||||
: "selection",
|
||||
},
|
||||
draggingElement: null,
|
||||
multiElement: null,
|
||||
editingElement: null,
|
||||
@@ -147,8 +162,8 @@ export const actionFinalize = register({
|
||||
suggestedBindings: [],
|
||||
selectedElementIds:
|
||||
multiPointElement &&
|
||||
!appState.elementLocked &&
|
||||
appState.elementType !== "freedraw"
|
||||
!appState.activeTool.locked &&
|
||||
appState.activeTool.type !== "freedraw"
|
||||
? {
|
||||
...appState.selectedElementIds,
|
||||
[multiPointElement.id]: true,
|
||||
@@ -156,7 +171,7 @@ export const actionFinalize = register({
|
||||
: appState.selectedElementIds,
|
||||
pendingImageElement: null,
|
||||
},
|
||||
commitToHistory: appState.elementType === "freedraw",
|
||||
commitToHistory: appState.activeTool.type === "freedraw",
|
||||
};
|
||||
},
|
||||
keyTest: (event, appState) =>
|
||||
@@ -165,7 +180,7 @@ export const actionFinalize = register({
|
||||
(!appState.draggingElement && appState.multiElement === null))) ||
|
||||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
||||
appState.multiElement !== null),
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
PanelComponent: ({ appState, updateData, data }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={done}
|
||||
@@ -173,6 +188,7 @@ export const actionFinalize = register({
|
||||
aria-label={t("buttons.done")}
|
||||
onClick={updateData}
|
||||
visible={appState.multiElement != null}
|
||||
size={data?.size || "medium"}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ const enableActionFlipVertical = (
|
||||
|
||||
export const actionFlipHorizontal = register({
|
||||
name: "flipHorizontal",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: flipSelectedElements(elements, appState, "horizontal"),
|
||||
@@ -50,6 +51,7 @@ export const actionFlipHorizontal = register({
|
||||
|
||||
export const actionFlipVertical = register({
|
||||
name: "flipVertical",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: flipSelectedElements(elements, appState, "vertical"),
|
||||
|
||||
@@ -54,6 +54,7 @@ const enableActionGroup = (
|
||||
|
||||
export const actionGroup = register({
|
||||
name: "group",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
@@ -147,6 +148,7 @@ export const actionGroup = register({
|
||||
|
||||
export const actionUngroup = register({
|
||||
name: "ungroup",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
const groupIds = getSelectedGroupIds(appState);
|
||||
if (groupIds.length === 0) {
|
||||
|
||||
@@ -62,6 +62,7 @@ type ActionCreator = (history: History) => Action;
|
||||
|
||||
export const createUndoAction: ActionCreator = (history) => ({
|
||||
name: "undo",
|
||||
trackEvent: { category: "history" },
|
||||
perform: (elements, appState) =>
|
||||
writeData(elements, appState, () => history.undoOnce()),
|
||||
keyTest: (event) =>
|
||||
@@ -82,6 +83,7 @@ export const createUndoAction: ActionCreator = (history) => ({
|
||||
|
||||
export const createRedoAction: ActionCreator = (history) => ({
|
||||
name: "redo",
|
||||
trackEvent: { category: "history" },
|
||||
perform: (elements, appState) =>
|
||||
writeData(elements, appState, () => history.redoOnce()),
|
||||
keyTest: (event) =>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { HelpIcon } from "../components/HelpIcon";
|
||||
|
||||
export const actionToggleCanvasMenu = register({
|
||||
name: "toggleCanvasMenu",
|
||||
trackEvent: { category: "menu" },
|
||||
perform: (_, appState) => ({
|
||||
appState: {
|
||||
...appState,
|
||||
@@ -29,6 +30,7 @@ export const actionToggleCanvasMenu = register({
|
||||
|
||||
export const actionToggleEditMenu = register({
|
||||
name: "toggleEditMenu",
|
||||
trackEvent: { category: "menu" },
|
||||
perform: (_elements, appState) => ({
|
||||
appState: {
|
||||
...appState,
|
||||
@@ -53,6 +55,7 @@ export const actionToggleEditMenu = register({
|
||||
|
||||
export const actionFullScreen = register({
|
||||
name: "toggleFullScreen",
|
||||
trackEvent: { category: "canvas", predicate: (appState) => !isFullScreen() },
|
||||
perform: () => {
|
||||
if (!isFullScreen()) {
|
||||
allowFullScreen();
|
||||
@@ -69,6 +72,7 @@ export const actionFullScreen = register({
|
||||
|
||||
export const actionShortcuts = register({
|
||||
name: "toggleShortcuts",
|
||||
trackEvent: { category: "menu", action: "toggleHelpDialog" },
|
||||
perform: (_elements, appState, _, { focusContainer }) => {
|
||||
if (appState.showHelpDialog) {
|
||||
focusContainer();
|
||||
|
||||
@@ -6,6 +6,7 @@ import { register } from "./register";
|
||||
|
||||
export const actionGoToCollaborator = register({
|
||||
name: "goToCollaborator",
|
||||
trackEvent: { category: "collab" },
|
||||
perform: (_elements, appState, value) => {
|
||||
const point = value as Collaborator["pointer"];
|
||||
if (!point) {
|
||||
|
||||
@@ -166,11 +166,7 @@ const changeFontSize = (
|
||||
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
|
||||
fontSize: newFontSize,
|
||||
});
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
getContainerElement(oldElement),
|
||||
appState,
|
||||
);
|
||||
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
||||
|
||||
newElement = offsetElementAfterFontResize(oldElement, newElement);
|
||||
|
||||
@@ -198,6 +194,7 @@ const changeFontSize = (
|
||||
|
||||
export const actionChangeStrokeColor = register({
|
||||
name: "changeStrokeColor",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
...(value.currentItemStrokeColor && {
|
||||
@@ -247,6 +244,7 @@ export const actionChangeStrokeColor = register({
|
||||
|
||||
export const actionChangeBackgroundColor = register({
|
||||
name: "changeBackgroundColor",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
...(value.currentItemBackgroundColor && {
|
||||
@@ -289,6 +287,7 @@ export const actionChangeBackgroundColor = register({
|
||||
|
||||
export const actionChangeFillStyle = register({
|
||||
name: "changeFillStyle",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
@@ -338,6 +337,7 @@ export const actionChangeFillStyle = register({
|
||||
|
||||
export const actionChangeStrokeWidth = register({
|
||||
name: "changeStrokeWidth",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
@@ -385,6 +385,7 @@ export const actionChangeStrokeWidth = register({
|
||||
|
||||
export const actionChangeSloppiness = register({
|
||||
name: "changeSloppiness",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
@@ -433,6 +434,7 @@ export const actionChangeSloppiness = register({
|
||||
|
||||
export const actionChangeStrokeStyle = register({
|
||||
name: "changeStrokeStyle",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
@@ -480,6 +482,7 @@ export const actionChangeStrokeStyle = register({
|
||||
|
||||
export const actionChangeOpacity = register({
|
||||
name: "changeOpacity",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
@@ -529,6 +532,7 @@ export const actionChangeOpacity = register({
|
||||
|
||||
export const actionChangeFontSize = register({
|
||||
name: "changeFontSize",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return changeFontSize(elements, appState, () => value, value);
|
||||
},
|
||||
@@ -586,6 +590,7 @@ export const actionChangeFontSize = register({
|
||||
|
||||
export const actionDecreaseFontSize = register({
|
||||
name: "decreaseFontSize",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return changeFontSize(elements, appState, (element) =>
|
||||
Math.round(
|
||||
@@ -607,6 +612,7 @@ export const actionDecreaseFontSize = register({
|
||||
|
||||
export const actionIncreaseFontSize = register({
|
||||
name: "increaseFontSize",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return changeFontSize(elements, appState, (element) =>
|
||||
Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
|
||||
@@ -624,6 +630,7 @@ export const actionIncreaseFontSize = register({
|
||||
|
||||
export const actionChangeFontFamily = register({
|
||||
name: "changeFontFamily",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(
|
||||
@@ -637,11 +644,7 @@ export const actionChangeFontFamily = register({
|
||||
fontFamily: value,
|
||||
},
|
||||
);
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
getContainerElement(oldElement),
|
||||
appState,
|
||||
);
|
||||
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
||||
return newElement;
|
||||
}
|
||||
|
||||
@@ -709,6 +712,7 @@ export const actionChangeFontFamily = register({
|
||||
|
||||
export const actionChangeTextAlign = register({
|
||||
name: "changeTextAlign",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(
|
||||
@@ -720,11 +724,7 @@ export const actionChangeTextAlign = register({
|
||||
oldElement,
|
||||
{ textAlign: value },
|
||||
);
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
getContainerElement(oldElement),
|
||||
appState,
|
||||
);
|
||||
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
||||
return newElement;
|
||||
}
|
||||
|
||||
@@ -785,6 +785,7 @@ export const actionChangeTextAlign = register({
|
||||
});
|
||||
export const actionChangeVerticalAlign = register({
|
||||
name: "changeVerticalAlign",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(
|
||||
@@ -797,11 +798,7 @@ export const actionChangeVerticalAlign = register({
|
||||
{ verticalAlign: value },
|
||||
);
|
||||
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
getContainerElement(oldElement),
|
||||
appState,
|
||||
);
|
||||
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
||||
return newElement;
|
||||
}
|
||||
|
||||
@@ -856,6 +853,7 @@ export const actionChangeVerticalAlign = register({
|
||||
|
||||
export const actionChangeSharpness = register({
|
||||
name: "changeSharpness",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
const targetElements = getTargetElements(
|
||||
getNonDeletedElements(elements),
|
||||
@@ -863,10 +861,10 @@ export const actionChangeSharpness = register({
|
||||
);
|
||||
const shouldUpdateForNonLinearElements = targetElements.length
|
||||
? targetElements.every((el) => !isLinearElement(el))
|
||||
: !isLinearElementType(appState.elementType);
|
||||
: !isLinearElementType(appState.activeTool.type);
|
||||
const shouldUpdateForLinearElements = targetElements.length
|
||||
? targetElements.every(isLinearElement)
|
||||
: isLinearElementType(appState.elementType);
|
||||
: isLinearElementType(appState.activeTool.type);
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
@@ -906,8 +904,8 @@ export const actionChangeSharpness = register({
|
||||
elements,
|
||||
appState,
|
||||
(element) => element.strokeSharpness,
|
||||
(canChangeSharpness(appState.elementType) &&
|
||||
(isLinearElementType(appState.elementType)
|
||||
(canChangeSharpness(appState.activeTool.type) &&
|
||||
(isLinearElementType(appState.activeTool.type)
|
||||
? appState.currentItemLinearStrokeSharpness
|
||||
: appState.currentItemStrokeSharpness)) ||
|
||||
null,
|
||||
@@ -920,6 +918,7 @@ export const actionChangeSharpness = register({
|
||||
|
||||
export const actionChangeArrowhead = register({
|
||||
name: "changeArrowhead",
|
||||
trackEvent: false,
|
||||
perform: (
|
||||
elements,
|
||||
appState,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getNonDeletedElements, isTextElement } from "../element";
|
||||
|
||||
export const actionSelectAll = register({
|
||||
name: "selectAll",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => {
|
||||
if (appState.editingLinearElement) {
|
||||
return false;
|
||||
@@ -17,7 +18,8 @@ export const actionSelectAll = register({
|
||||
selectedElementIds: elements.reduce((map, element) => {
|
||||
if (
|
||||
!element.isDeleted &&
|
||||
!(isTextElement(element) && element.containerId)
|
||||
!(isTextElement(element) && element.containerId) &&
|
||||
element.locked === false
|
||||
) {
|
||||
map[element.id] = true;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export let copiedStyles: string = "{}";
|
||||
|
||||
export const actionCopyStyles = register({
|
||||
name: "copyStyles",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
const element = elements.find((el) => appState.selectedElementIds[el.id]);
|
||||
if (element) {
|
||||
@@ -39,6 +40,7 @@ export const actionCopyStyles = register({
|
||||
|
||||
export const actionPasteStyles = register({
|
||||
name: "pasteStyles",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
const pastedElement = JSON.parse(copiedStyles);
|
||||
if (!isExcalidrawElement(pastedElement)) {
|
||||
@@ -63,11 +65,7 @@ export const actionPasteStyles = register({
|
||||
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
|
||||
});
|
||||
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
getContainerElement(newElement),
|
||||
appState,
|
||||
);
|
||||
redrawTextBoundingBox(newElement, getContainerElement(newElement));
|
||||
}
|
||||
return newElement;
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { GRID_SIZE } from "../constants";
|
||||
import { AppState } from "../types";
|
||||
import { trackEvent } from "../analytics";
|
||||
|
||||
export const actionToggleGridMode = register({
|
||||
name: "gridMode",
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => !appState.gridSize,
|
||||
},
|
||||
perform(elements, appState) {
|
||||
trackEvent("view", "mode", "grid");
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { KEYS } from "../keys";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleLock = register({
|
||||
name: "toggleLock",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState, true);
|
||||
|
||||
if (!selectedElements.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const operation = getOperation(selectedElements);
|
||||
const selectedElementsMap = arrayToMap(selectedElements);
|
||||
|
||||
return {
|
||||
elements: elements.map((element) => {
|
||||
if (!selectedElementsMap.has(element.id)) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return newElementWith(element, { locked: operation === "lock" });
|
||||
}),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
contextItemLabel: (elements, appState) => {
|
||||
const selected = getSelectedElements(elements, appState, false);
|
||||
if (selected.length === 1) {
|
||||
return selected[0].locked
|
||||
? "labels.elementLock.unlock"
|
||||
: "labels.elementLock.lock";
|
||||
}
|
||||
|
||||
if (selected.length > 1) {
|
||||
return getOperation(selected) === "lock"
|
||||
? "labels.elementLock.lockAll"
|
||||
: "labels.elementLock.unlockAll";
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Unexpected zero elements to lock/unlock. This should never happen.",
|
||||
);
|
||||
},
|
||||
keyTest: (event, appState, elements) => {
|
||||
return (
|
||||
event.key.toLocaleLowerCase() === KEYS.L &&
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
event.shiftKey &&
|
||||
getSelectedElements(elements, appState, false).length > 0
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const getOperation = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): "lock" | "unlock" => (elements.some((el) => !el.locked) ? "lock" : "unlock");
|
||||
@@ -3,6 +3,7 @@ import { CODES, KEYS } from "../keys";
|
||||
|
||||
export const actionToggleStats = register({
|
||||
name: "stats",
|
||||
trackEvent: { category: "menu" },
|
||||
perform(elements, appState) {
|
||||
return {
|
||||
appState: {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { trackEvent } from "../analytics";
|
||||
|
||||
export const actionToggleViewMode = register({
|
||||
name: "viewMode",
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => !appState.viewModeEnabled,
|
||||
},
|
||||
perform(elements, appState) {
|
||||
trackEvent("view", "mode", "view");
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { trackEvent } from "../analytics";
|
||||
|
||||
export const actionToggleZenMode = register({
|
||||
name: "zenMode",
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => !appState.zenModeEnabled,
|
||||
},
|
||||
perform(elements, appState) {
|
||||
trackEvent("view", "mode", "zen");
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import { getBoundTextElement, measureText } from "../element/textElement";
|
||||
import { ExcalidrawTextElement } from "../element/types";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getFontString } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionUnbindText = register({
|
||||
name: "unbindText",
|
||||
contextItemLabel: "labels.unbindText",
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
selectedElements.forEach((element) => {
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
const { width, height, baseline } = measureText(
|
||||
boundTextElement.originalText,
|
||||
getFontString(boundTextElement),
|
||||
);
|
||||
mutateElement(boundTextElement as ExcalidrawTextElement, {
|
||||
containerId: null,
|
||||
width,
|
||||
height,
|
||||
baseline,
|
||||
text: boundTextElement.originalText,
|
||||
});
|
||||
mutateElement(element, {
|
||||
boundElements: element.boundElements?.filter(
|
||||
(ele) => ele.id !== boundTextElement.id,
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
return {
|
||||
elements,
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
|
||||
export const actionSendBackward = register({
|
||||
name: "sendBackward",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveOneLeft(elements, appState),
|
||||
@@ -45,6 +46,7 @@ export const actionSendBackward = register({
|
||||
|
||||
export const actionBringForward = register({
|
||||
name: "bringForward",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveOneRight(elements, appState),
|
||||
@@ -72,6 +74,7 @@ export const actionBringForward = register({
|
||||
|
||||
export const actionSendToBack = register({
|
||||
name: "sendToBack",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveAllLeft(elements, appState),
|
||||
@@ -106,6 +109,8 @@ export const actionSendToBack = register({
|
||||
|
||||
export const actionBringToFront = register({
|
||||
name: "bringToFront",
|
||||
trackEvent: { category: "element" },
|
||||
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveAllRight(elements, appState),
|
||||
|
||||
@@ -75,11 +75,13 @@ export {
|
||||
actionCut,
|
||||
actionCopyAsPng,
|
||||
actionCopyAsSvg,
|
||||
copyText,
|
||||
} from "./actionClipboard";
|
||||
|
||||
export { actionToggleGridMode } from "./actionToggleGridMode";
|
||||
export { actionToggleZenMode } from "./actionToggleZenMode";
|
||||
|
||||
export { actionToggleStats } from "./actionToggleStats";
|
||||
export { actionUnbindText } from "./actionUnbindText";
|
||||
export { actionUnbindText, actionBindText } from "./actionBoundText";
|
||||
export { actionLink } from "../element/Hyperlink";
|
||||
export { actionToggleLock } from "./actionToggleLock";
|
||||
|
||||
+38
-36
@@ -1,11 +1,11 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Action,
|
||||
ActionsManagerInterface,
|
||||
UpdaterFn,
|
||||
ActionName,
|
||||
ActionResult,
|
||||
PanelComponentProps,
|
||||
ActionSource,
|
||||
} from "./types";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
@@ -14,21 +14,25 @@ import { trackEvent } from "../analytics";
|
||||
|
||||
const trackAction = (
|
||||
action: Action,
|
||||
source: "ui" | "keyboard" | "api",
|
||||
source: ActionSource,
|
||||
appState: Readonly<AppState>,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
app: AppClassProperties,
|
||||
value: any,
|
||||
) => {
|
||||
if (action.trackEvent !== false) {
|
||||
if (action.trackEvent) {
|
||||
try {
|
||||
if (action.trackEvent === true) {
|
||||
trackEvent(
|
||||
action.name,
|
||||
source,
|
||||
typeof value === "number" || typeof value === "string"
|
||||
? String(value)
|
||||
: undefined,
|
||||
);
|
||||
} else {
|
||||
action.trackEvent?.(action, source, value);
|
||||
if (typeof action.trackEvent === "object") {
|
||||
const shouldTrack = action.trackEvent.predicate
|
||||
? action.trackEvent.predicate(appState, elements, value)
|
||||
: true;
|
||||
if (shouldTrack) {
|
||||
trackEvent(
|
||||
action.trackEvent.category,
|
||||
action.trackEvent.action || action.name,
|
||||
`${source} (${app.deviceType.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("error while logging action:", error);
|
||||
@@ -36,8 +40,8 @@ const trackAction = (
|
||||
}
|
||||
};
|
||||
|
||||
export class ActionManager implements ActionsManagerInterface {
|
||||
actions = {} as ActionsManagerInterface["actions"];
|
||||
export class ActionManager {
|
||||
actions = {} as Record<ActionName, Action>;
|
||||
|
||||
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
|
||||
|
||||
@@ -106,30 +110,26 @@ export class ActionManager implements ActionsManagerInterface {
|
||||
}
|
||||
}
|
||||
|
||||
trackAction(action, "keyboard", null);
|
||||
const elements = this.getElementsIncludingDeleted();
|
||||
const appState = this.getAppState();
|
||||
const value = null;
|
||||
|
||||
trackAction(action, "keyboard", appState, elements, this.app, null);
|
||||
|
||||
event.preventDefault();
|
||||
this.updater(
|
||||
data[0].perform(
|
||||
this.getElementsIncludingDeleted(),
|
||||
this.getAppState(),
|
||||
null,
|
||||
this.app,
|
||||
),
|
||||
);
|
||||
event.stopPropagation();
|
||||
this.updater(data[0].perform(elements, appState, value, this.app));
|
||||
return true;
|
||||
}
|
||||
|
||||
executeAction(action: Action) {
|
||||
this.updater(
|
||||
action.perform(
|
||||
this.getElementsIncludingDeleted(),
|
||||
this.getAppState(),
|
||||
null,
|
||||
this.app,
|
||||
),
|
||||
);
|
||||
trackAction(action, "api", null);
|
||||
executeAction(action: Action, source: ActionSource = "api") {
|
||||
const elements = this.getElementsIncludingDeleted();
|
||||
const appState = this.getAppState();
|
||||
const value = null;
|
||||
|
||||
trackAction(action, source, appState, elements, this.app, value);
|
||||
|
||||
this.updater(action.perform(elements, appState, value, this.app));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,7 +147,11 @@ export class ActionManager implements ActionsManagerInterface {
|
||||
) {
|
||||
const action = this.actions[name];
|
||||
const PanelComponent = action.PanelComponent!;
|
||||
const elements = this.getElementsIncludingDeleted();
|
||||
const appState = this.getAppState();
|
||||
const updateData = (formState?: any) => {
|
||||
trackAction(action, "ui", appState, elements, this.app, formState);
|
||||
|
||||
this.updater(
|
||||
action.perform(
|
||||
this.getElementsIncludingDeleted(),
|
||||
@@ -156,8 +160,6 @@ export class ActionManager implements ActionsManagerInterface {
|
||||
this.app,
|
||||
),
|
||||
);
|
||||
|
||||
trackAction(action, "ui", formState);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -29,6 +29,7 @@ export type ShortcutName = SubtypeOf<
|
||||
| "flipHorizontal"
|
||||
| "flipVertical"
|
||||
| "hyperlink"
|
||||
| "toggleLock"
|
||||
>;
|
||||
|
||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
@@ -67,6 +68,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
flipVertical: [getShortcutKey("Shift+V")],
|
||||
viewMode: [getShortcutKey("Alt+R")],
|
||||
hyperlink: [getShortcutKey("CtrlOrCmd+K")],
|
||||
toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
|
||||
};
|
||||
|
||||
export const getShortcutFromShortcutName = (name: ShortcutName) => {
|
||||
|
||||
+26
-12
@@ -8,6 +8,8 @@ import {
|
||||
} from "../types";
|
||||
import { ToolButtonSize } from "../components/ToolButton";
|
||||
|
||||
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
|
||||
|
||||
/** if false, the action should be prevented */
|
||||
export type ActionResult =
|
||||
| {
|
||||
@@ -39,6 +41,7 @@ export type ActionName =
|
||||
| "paste"
|
||||
| "copyAsPng"
|
||||
| "copyAsSvg"
|
||||
| "copyText"
|
||||
| "sendBackward"
|
||||
| "bringForward"
|
||||
| "sendToBack"
|
||||
@@ -106,7 +109,10 @@ export type ActionName =
|
||||
| "increaseFontSize"
|
||||
| "decreaseFontSize"
|
||||
| "unbindText"
|
||||
| "hyperlink";
|
||||
| "hyperlink"
|
||||
| "eraser"
|
||||
| "bindText"
|
||||
| "toggleLock";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
@@ -137,15 +143,23 @@ export interface Action {
|
||||
appState: AppState,
|
||||
) => boolean;
|
||||
checked?: (appState: Readonly<AppState>) => boolean;
|
||||
trackEvent?:
|
||||
| boolean
|
||||
| ((action: Action, type: "ui" | "keyboard" | "api", value: any) => void);
|
||||
}
|
||||
|
||||
export interface ActionsManagerInterface {
|
||||
actions: Record<ActionName, Action>;
|
||||
registerAction: (action: Action) => void;
|
||||
handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean;
|
||||
renderAction: (name: ActionName) => React.ReactElement | null;
|
||||
executeAction: (action: Action) => void;
|
||||
trackEvent:
|
||||
| false
|
||||
| {
|
||||
category:
|
||||
| "toolbar"
|
||||
| "element"
|
||||
| "canvas"
|
||||
| "export"
|
||||
| "history"
|
||||
| "menu"
|
||||
| "collab"
|
||||
| "hyperlink";
|
||||
action?: string;
|
||||
predicate?: (
|
||||
appState: Readonly<AppState>,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
value: any,
|
||||
) => boolean;
|
||||
};
|
||||
}
|
||||
|
||||
+10
-6
@@ -4,15 +4,19 @@ export const trackEvent =
|
||||
typeof window !== "undefined" &&
|
||||
window.gtag
|
||||
? (category: string, action: string, label?: string, value?: number) => {
|
||||
window.gtag("event", action, {
|
||||
event_category: category,
|
||||
event_label: label,
|
||||
value,
|
||||
});
|
||||
try {
|
||||
window.gtag("event", action, {
|
||||
event_category: category,
|
||||
event_label: label,
|
||||
value,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("error logging to ga", error);
|
||||
}
|
||||
}
|
||||
: typeof process !== "undefined" && process.env?.JEST_WORKER_ID
|
||||
? (category: string, action: string, label?: string, value?: number) => {}
|
||||
: (category: string, action: string, label?: string, value?: number) => {
|
||||
// Uncomment the next line to track locally
|
||||
// console.info("Track Event", category, action, label, value);
|
||||
// console.log("Track Event", { category, action, label, value });
|
||||
};
|
||||
|
||||
+14
-6
@@ -41,8 +41,11 @@ export const getDefaultAppState = (): Omit<
|
||||
editingElement: null,
|
||||
editingGroupId: null,
|
||||
editingLinearElement: null,
|
||||
elementLocked: false,
|
||||
elementType: "selection",
|
||||
activeTool: {
|
||||
type: "selection",
|
||||
locked: false,
|
||||
lastActiveToolBeforeEraser: null,
|
||||
},
|
||||
penMode: false,
|
||||
penDetected: false,
|
||||
errorMessage: null,
|
||||
@@ -130,10 +133,9 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
editingElement: { browser: false, export: false, server: false },
|
||||
editingGroupId: { browser: true, export: false, server: false },
|
||||
editingLinearElement: { browser: false, export: false, server: false },
|
||||
elementLocked: { browser: true, export: false, server: false },
|
||||
elementType: { browser: true, export: false, server: false },
|
||||
penMode: { browser: false, export: false, server: false },
|
||||
penDetected: { browser: false, export: false, server: false },
|
||||
activeTool: { browser: true, export: false, server: false },
|
||||
penMode: { browser: true, export: false, server: false },
|
||||
penDetected: { browser: true, export: false, server: false },
|
||||
errorMessage: { browser: false, export: false, server: false },
|
||||
exportBackground: { browser: true, export: false, server: false },
|
||||
exportEmbedScene: { browser: true, export: false, server: false },
|
||||
@@ -213,3 +215,9 @@ export const cleanAppStateForExport = (appState: Partial<AppState>) => {
|
||||
export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
|
||||
return _clearAppStateForStorage(appState, "server");
|
||||
};
|
||||
|
||||
export const isEraserActive = ({
|
||||
activeTool,
|
||||
}: {
|
||||
activeTool: AppState["activeTool"];
|
||||
}) => activeTool.type === "eraser";
|
||||
|
||||
@@ -167,6 +167,7 @@ const commonProps = {
|
||||
strokeStyle: "solid",
|
||||
strokeWidth: 1,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
locked: false,
|
||||
} as const;
|
||||
|
||||
const getChartDimentions = (spreadsheet: Spreadsheet) => {
|
||||
|
||||
+30
-4
@@ -8,6 +8,7 @@ import { SVG_EXPORT_TAG } from "./scene/export";
|
||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
|
||||
import { isInitializedImageElement } from "./element/typeChecks";
|
||||
import { isPromiseLike } from "./utils";
|
||||
|
||||
type ElementsClipboard = {
|
||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
||||
@@ -166,10 +167,35 @@ export const parseClipboard = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
|
||||
await navigator.clipboard.write([
|
||||
new window.ClipboardItem({ [MIME_TYPES.png]: blob }),
|
||||
]);
|
||||
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([
|
||||
new window.ClipboardItem({
|
||||
[MIME_TYPES.png]: blob,
|
||||
}),
|
||||
]);
|
||||
} catch (error: any) {
|
||||
// if we're using a Promise ClipboardItem, let's try constructing
|
||||
// with resolution value instead
|
||||
if (isPromiseLike(blob)) {
|
||||
await navigator.clipboard.write([
|
||||
new window.ClipboardItem({
|
||||
[MIME_TYPES.png]: await blob,
|
||||
}),
|
||||
]);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await promise;
|
||||
};
|
||||
|
||||
export const copyTextToSystemClipboard = async (text: string | null) => {
|
||||
|
||||
+43
-22
@@ -3,7 +3,7 @@ import { ActionManager } from "../actions/manager";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement, PointerType } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { useDeviceType } from "../components/App";
|
||||
import {
|
||||
canChangeSharpness,
|
||||
canHaveArrowheads,
|
||||
@@ -19,18 +19,19 @@ import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
|
||||
import Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { hasStrokeColor } from "../scene/comparisons";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
|
||||
|
||||
export const SelectedShapeActions = ({
|
||||
appState,
|
||||
elements,
|
||||
renderAction,
|
||||
elementType,
|
||||
activeTool,
|
||||
}: {
|
||||
appState: AppState;
|
||||
elements: readonly ExcalidrawElement[];
|
||||
renderAction: ActionManager["renderAction"];
|
||||
elementType: ExcalidrawElement["type"];
|
||||
activeTool: AppState["activeTool"]["type"];
|
||||
}) => {
|
||||
const targetElements = getTargetElements(
|
||||
getNonDeletedElements(elements),
|
||||
@@ -46,19 +47,22 @@ export const SelectedShapeActions = ({
|
||||
isSingleElementBoundContainer = true;
|
||||
}
|
||||
const isEditing = Boolean(appState.editingElement);
|
||||
const isMobile = useIsMobile();
|
||||
const deviceType = useDeviceType();
|
||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||
|
||||
const showFillIcons =
|
||||
hasBackground(elementType) ||
|
||||
hasBackground(activeTool) ||
|
||||
targetElements.some(
|
||||
(element) =>
|
||||
hasBackground(element.type) && !isTransparent(element.backgroundColor),
|
||||
);
|
||||
const showChangeBackgroundIcons =
|
||||
hasBackground(elementType) ||
|
||||
hasBackground(activeTool) ||
|
||||
targetElements.some((element) => hasBackground(element.type));
|
||||
|
||||
const showLinkIcon =
|
||||
targetElements.length === 1 || isSingleElementBoundContainer;
|
||||
|
||||
let commonSelectedType: string | null = targetElements[0]?.type || null;
|
||||
|
||||
for (const element of targetElements) {
|
||||
@@ -70,23 +74,23 @@ export const SelectedShapeActions = ({
|
||||
|
||||
return (
|
||||
<div className="panelColumn">
|
||||
{((hasStrokeColor(elementType) &&
|
||||
elementType !== "image" &&
|
||||
{((hasStrokeColor(activeTool) &&
|
||||
activeTool !== "image" &&
|
||||
commonSelectedType !== "image") ||
|
||||
targetElements.some((element) => hasStrokeColor(element.type))) &&
|
||||
renderAction("changeStrokeColor")}
|
||||
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
|
||||
{showFillIcons && renderAction("changeFillStyle")}
|
||||
|
||||
{(hasStrokeWidth(elementType) ||
|
||||
{(hasStrokeWidth(activeTool) ||
|
||||
targetElements.some((element) => hasStrokeWidth(element.type))) &&
|
||||
renderAction("changeStrokeWidth")}
|
||||
|
||||
{(elementType === "freedraw" ||
|
||||
{(activeTool === "freedraw" ||
|
||||
targetElements.some((element) => element.type === "freedraw")) &&
|
||||
renderAction("changeStrokeShape")}
|
||||
|
||||
{(hasStrokeStyle(elementType) ||
|
||||
{(hasStrokeStyle(activeTool) ||
|
||||
targetElements.some((element) => hasStrokeStyle(element.type))) && (
|
||||
<>
|
||||
{renderAction("changeStrokeStyle")}
|
||||
@@ -94,12 +98,12 @@ export const SelectedShapeActions = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{(canChangeSharpness(elementType) ||
|
||||
{(canChangeSharpness(activeTool) ||
|
||||
targetElements.some((element) => canChangeSharpness(element.type))) && (
|
||||
<>{renderAction("changeSharpness")}</>
|
||||
)}
|
||||
|
||||
{(hasText(elementType) ||
|
||||
{(hasText(activeTool) ||
|
||||
targetElements.some((element) => hasText(element.type))) && (
|
||||
<>
|
||||
{renderAction("changeFontSize")}
|
||||
@@ -114,7 +118,7 @@ export const SelectedShapeActions = ({
|
||||
(element) =>
|
||||
hasBoundTextElement(element) || isBoundToContainer(element),
|
||||
) && renderAction("changeVerticalAlign")}
|
||||
{(canHaveArrowheads(elementType) ||
|
||||
{(canHaveArrowheads(activeTool) ||
|
||||
targetElements.some((element) => canHaveArrowheads(element.type))) && (
|
||||
<>{renderAction("changeArrowhead")}</>
|
||||
)}
|
||||
@@ -168,11 +172,11 @@ export const SelectedShapeActions = ({
|
||||
<fieldset>
|
||||
<legend>{t("labels.actions")}</legend>
|
||||
<div className="buttonList">
|
||||
{!isMobile && renderAction("duplicateSelection")}
|
||||
{!isMobile && renderAction("deleteSelectedElements")}
|
||||
{!deviceType.isMobile && renderAction("duplicateSelection")}
|
||||
{!deviceType.isMobile && renderAction("deleteSelectedElements")}
|
||||
{renderAction("group")}
|
||||
{renderAction("ungroup")}
|
||||
{targetElements.length === 1 && renderAction("hyperlink")}
|
||||
{showLinkIcon && renderAction("hyperlink")}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
@@ -182,14 +186,16 @@ export const SelectedShapeActions = ({
|
||||
|
||||
export const ShapesSwitcher = ({
|
||||
canvas,
|
||||
elementType,
|
||||
activeTool,
|
||||
setAppState,
|
||||
onImageAction,
|
||||
appState,
|
||||
}: {
|
||||
canvas: HTMLCanvasElement | null;
|
||||
elementType: ExcalidrawElement["type"];
|
||||
activeTool: AppState["activeTool"];
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
onImageAction: (data: { pointerType: PointerType | null }) => void;
|
||||
appState: AppState;
|
||||
}) => (
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key }, index) => {
|
||||
@@ -204,20 +210,35 @@ export const ShapesSwitcher = ({
|
||||
key={value}
|
||||
type="radio"
|
||||
icon={icon}
|
||||
checked={elementType === value}
|
||||
checked={activeTool.type === value}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||
keyBindingLabel={`${index + 1}`}
|
||||
aria-label={capitalizeString(label)}
|
||||
aria-keyshortcuts={shortcut}
|
||||
data-testid={value}
|
||||
onPointerDown={({ pointerType }) => {
|
||||
if (!appState.penDetected && pointerType === "pen") {
|
||||
setAppState({
|
||||
penDetected: true,
|
||||
penMode: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
if (appState.activeTool.type !== value) {
|
||||
trackEvent("toolbar", value, "ui");
|
||||
}
|
||||
const nextActiveTool = { ...activeTool, type: value };
|
||||
setAppState({
|
||||
elementType: value,
|
||||
activeTool: nextActiveTool,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
setCursorForShape(canvas, value);
|
||||
setCursorForShape(canvas, {
|
||||
...appState,
|
||||
activeTool: nextActiveTool,
|
||||
});
|
||||
if (value === "image") {
|
||||
onImageAction({ pointerType });
|
||||
}
|
||||
|
||||
+475
-170
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "./App";
|
||||
import { useDeviceType } from "./App";
|
||||
import { trash } from "./icons";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
|
||||
@@ -19,7 +19,7 @@ const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
|
||||
icon={trash}
|
||||
title={t("buttons.clearReset")}
|
||||
aria-label={t("buttons.clearReset")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
showAriaLabel={useDeviceType().isMobile}
|
||||
onClick={toggleDialog}
|
||||
data-testid="clear-canvas-button"
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import clsx from "clsx";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { useDeviceType } from "../components/App";
|
||||
import { users } from "./icons";
|
||||
|
||||
import "./CollabButton.scss";
|
||||
@@ -26,7 +26,7 @@ const CollabButton = ({
|
||||
type="button"
|
||||
title={t("labels.liveCollaboration")}
|
||||
aria-label={t("labels.liveCollaboration")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
showAriaLabel={useDeviceType().isMobile}
|
||||
>
|
||||
{collaboratorCount > 0 && (
|
||||
<div className="CollabButton-collaborators">{collaboratorCount}</div>
|
||||
|
||||
@@ -70,7 +70,9 @@ const ContextMenu = ({
|
||||
dangerous: actionName === "deleteSelectedElements",
|
||||
checkmark: option.checked?.(appState),
|
||||
})}
|
||||
onClick={() => actionManager.executeAction(option)}
|
||||
onClick={() =>
|
||||
actionManager.executeAction(option, "contextMenu")
|
||||
}
|
||||
>
|
||||
<div className="context-menu-option__label">{label}</div>
|
||||
<kbd className="context-menu-option__shortcut">
|
||||
|
||||
@@ -2,7 +2,7 @@ import clsx from "clsx";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||
import { t } from "../i18n";
|
||||
import { useExcalidrawContainer, useIsMobile } from "../components/App";
|
||||
import { useExcalidrawContainer, useDeviceType } from "../components/App";
|
||||
import { KEYS } from "../keys";
|
||||
import "./Dialog.scss";
|
||||
import { back, close } from "./icons";
|
||||
@@ -94,7 +94,7 @@ export const Dialog = (props: DialogProps) => {
|
||||
onClick={onClose}
|
||||
aria-label={t("buttons.close")}
|
||||
>
|
||||
{useIsMobile() ? back : close}
|
||||
{useDeviceType().isMobile ? back : close}
|
||||
</button>
|
||||
</h2>
|
||||
<div className="Dialog__content">{props.children}</div>
|
||||
|
||||
@@ -139,7 +139,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
<Section title={t("helpDialog.shortcuts")}>
|
||||
<Columns>
|
||||
<Column>
|
||||
<ShortcutIsland caption={t("helpDialog.shapes")}>
|
||||
<ShortcutIsland caption={t("helpDialog.tools")}>
|
||||
<Shortcut
|
||||
label={t("toolBar.selection")}
|
||||
shortcuts={["V", "1"]}
|
||||
@@ -149,7 +149,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
shortcuts={["R", "2"]}
|
||||
/>
|
||||
<Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} />
|
||||
<Shortcut label={t("toolBar.ellipse")} shortcuts={["E", "4"]} />
|
||||
<Shortcut label={t("toolBar.ellipse")} shortcuts={["O", "4"]} />
|
||||
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
|
||||
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
|
||||
<Shortcut
|
||||
@@ -159,6 +159,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
|
||||
<Shortcut label={t("toolBar.image")} shortcuts={["9"]} />
|
||||
<Shortcut label={t("toolBar.library")} shortcuts={["0"]} />
|
||||
<Shortcut
|
||||
label={t("toolBar.eraser")}
|
||||
shortcuts={[getShortcutKey("E")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.editSelectedShape")}
|
||||
shortcuts={[
|
||||
@@ -359,6 +363,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
getShortcutKey(`Alt+${t("helpDialog.drag")}`),
|
||||
]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.toggleElementLock")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+L")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("buttons.undo")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Z")]}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
isTextElement,
|
||||
} from "../element/typeChecks";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { isEraserActive } from "../appState";
|
||||
|
||||
interface HintViewerProps {
|
||||
appState: AppState;
|
||||
@@ -19,25 +20,32 @@ interface HintViewerProps {
|
||||
}
|
||||
|
||||
const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
||||
const { elementType, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||
const multiMode = appState.multiElement !== null;
|
||||
|
||||
if (elementType === "arrow" || elementType === "line") {
|
||||
if (appState.isLibraryOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isEraserActive(appState)) {
|
||||
return t("hints.eraserRevert");
|
||||
}
|
||||
if (activeTool.type === "arrow" || activeTool.type === "line") {
|
||||
if (!multiMode) {
|
||||
return t("hints.linearElement");
|
||||
}
|
||||
return t("hints.linearElementMulti");
|
||||
}
|
||||
|
||||
if (elementType === "freedraw") {
|
||||
if (activeTool.type === "freedraw") {
|
||||
return t("hints.freeDraw");
|
||||
}
|
||||
|
||||
if (elementType === "text") {
|
||||
if (activeTool.type === "text") {
|
||||
return t("hints.text");
|
||||
}
|
||||
|
||||
if (appState.elementType === "image" && appState.pendingImageElement) {
|
||||
if (appState.activeTool.type === "image" && appState.pendingImageElement) {
|
||||
return t("hints.placeImage");
|
||||
}
|
||||
|
||||
@@ -69,7 +77,7 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
||||
return t("hints.text_editing");
|
||||
}
|
||||
|
||||
if (elementType === "selection") {
|
||||
if (activeTool.type === "selection") {
|
||||
if (
|
||||
appState.draggingElement?.type === "selection" &&
|
||||
!appState.editingElement &&
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { render, unmountComponentAtNode } from "react-dom";
|
||||
import { ActionsManagerInterface } from "../actions/types";
|
||||
import { probablySupportsClipboardBlob } from "../clipboard";
|
||||
import { canvasToBlob } from "../data/blob";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { CanvasError } from "../errors";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "./App";
|
||||
import { useDeviceType } from "./App";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { exportToCanvas } from "../scene/export";
|
||||
import { AppState, BinaryFiles } from "../types";
|
||||
@@ -19,6 +18,7 @@ import OpenColor from "open-color";
|
||||
import { CheckboxItem } from "./CheckboxItem";
|
||||
import { DEFAULT_EXPORT_PADDING } from "../constants";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
|
||||
const supportsContextFilters =
|
||||
"filter" in document.createElement("canvas").getContext("2d")!;
|
||||
@@ -90,7 +90,7 @@ const ImageExportModal = ({
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
files: BinaryFiles;
|
||||
exportPadding?: number;
|
||||
actionManager: ActionsManagerInterface;
|
||||
actionManager: ActionManager;
|
||||
onExportToPng: ExportCB;
|
||||
onExportToSvg: ExportCB;
|
||||
onExportToClipboard: ExportCB;
|
||||
@@ -229,7 +229,7 @@ export const ImageExportDialog = ({
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
files: BinaryFiles;
|
||||
exportPadding?: number;
|
||||
actionManager: ActionsManagerInterface;
|
||||
actionManager: ActionManager;
|
||||
onExportToPng: ExportCB;
|
||||
onExportToSvg: ExportCB;
|
||||
onExportToClipboard: ExportCB;
|
||||
@@ -250,7 +250,7 @@ export const ImageExportDialog = ({
|
||||
icon={exportImage}
|
||||
type="button"
|
||||
aria-label={t("buttons.exportImage")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
showAriaLabel={useDeviceType().isMobile}
|
||||
title={t("buttons.exportImage")}
|
||||
/>
|
||||
{modalIsShown && (
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, { useState } from "react";
|
||||
import { ActionsManagerInterface } from "../actions/types";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "./App";
|
||||
import { useDeviceType } from "./App";
|
||||
import { AppState, ExportOpts, BinaryFiles } from "../types";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { exportFile, exportToFileIcon, link } from "./icons";
|
||||
@@ -12,6 +11,9 @@ import { Card } from "./Card";
|
||||
|
||||
import "./ExportDialog.scss";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { getFrame } from "../utils";
|
||||
|
||||
export type ExportCB = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
@@ -29,7 +31,7 @@ const JSONExportModal = ({
|
||||
appState: AppState;
|
||||
files: BinaryFiles;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
actionManager: ActionsManagerInterface;
|
||||
actionManager: ActionManager;
|
||||
onCloseRequest: () => void;
|
||||
exportOpts: ExportOpts;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
@@ -54,7 +56,7 @@ const JSONExportModal = ({
|
||||
aria-label={t("exportDialog.disk_button")}
|
||||
showAriaLabel={true}
|
||||
onClick={() => {
|
||||
actionManager.executeAction(actionSaveFileToDisk);
|
||||
actionManager.executeAction(actionSaveFileToDisk, "ui");
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
@@ -70,9 +72,10 @@ const JSONExportModal = ({
|
||||
title={t("exportDialog.link_button")}
|
||||
aria-label={t("exportDialog.link_button")}
|
||||
showAriaLabel={true}
|
||||
onClick={() =>
|
||||
onExportToBackend(elements, appState, files, canvas)
|
||||
}
|
||||
onClick={() => {
|
||||
onExportToBackend(elements, appState, files, canvas);
|
||||
trackEvent("export", "link", `ui (${getFrame()})`);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
@@ -94,7 +97,7 @@ export const JSONExportDialog = ({
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
appState: AppState;
|
||||
files: BinaryFiles;
|
||||
actionManager: ActionsManagerInterface;
|
||||
actionManager: ActionManager;
|
||||
exportOpts: ExportOpts;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
}) => {
|
||||
@@ -114,7 +117,7 @@ export const JSONExportDialog = ({
|
||||
icon={exportFile}
|
||||
type="button"
|
||||
aria-label={t("buttons.export")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
showAriaLabel={useDeviceType().isMobile}
|
||||
title={t("buttons.export")}
|
||||
/>
|
||||
{modalIsShown && (
|
||||
|
||||
+46
-20
@@ -6,7 +6,6 @@ import { exportCanvas } from "../data";
|
||||
import { isTextElement, showSelectedShapeActions } from "../element";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { Language, t } from "../i18n";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
||||
import { ExportType } from "../scene/types";
|
||||
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
|
||||
@@ -37,6 +36,8 @@ import { LibraryMenu } from "./LibraryMenu";
|
||||
import "./LayerUI.scss";
|
||||
import "./Toolbar.scss";
|
||||
import { PenModeButton } from "./PenModeButton";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { useDeviceType } from "../components/App";
|
||||
|
||||
interface LayerUIProps {
|
||||
actionManager: ActionManager;
|
||||
@@ -95,7 +96,7 @@ const LayerUI = ({
|
||||
id,
|
||||
onImageAction,
|
||||
}: LayerUIProps) => {
|
||||
const isMobile = useIsMobile();
|
||||
const deviceType = useDeviceType();
|
||||
|
||||
const renderJSONExportDialog = () => {
|
||||
if (!UIOptions.canvasActions.export) {
|
||||
@@ -122,6 +123,7 @@ const LayerUI = ({
|
||||
const createExporter =
|
||||
(type: ExportType): ExportCB =>
|
||||
async (exportedElements) => {
|
||||
trackEvent("export", type, "ui");
|
||||
const fileHandle = await exportCanvas(
|
||||
type,
|
||||
exportedElements,
|
||||
@@ -248,7 +250,7 @@ const LayerUI = ({
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
renderAction={actionManager.renderAction}
|
||||
elementType={appState.elementType}
|
||||
activeTool={appState.activeTool.type}
|
||||
/>
|
||||
</Island>
|
||||
</Section>
|
||||
@@ -325,8 +327,8 @@ const LayerUI = ({
|
||||
/>
|
||||
<LockButton
|
||||
zenModeEnabled={zenModeEnabled}
|
||||
checked={appState.elementLocked}
|
||||
onChange={onLockToggle}
|
||||
checked={appState.activeTool.locked}
|
||||
onChange={() => onLockToggle()}
|
||||
title={t("toolBar.lock")}
|
||||
/>
|
||||
<Island
|
||||
@@ -338,13 +340,14 @@ const LayerUI = ({
|
||||
<HintViewer
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
isMobile={isMobile}
|
||||
isMobile={deviceType.isMobile}
|
||||
/>
|
||||
{heading}
|
||||
<Stack.Row gap={1}>
|
||||
<ShapesSwitcher
|
||||
appState={appState}
|
||||
canvas={canvas}
|
||||
elementType={appState.elementType}
|
||||
activeTool={appState.activeTool}
|
||||
setAppState={setAppState}
|
||||
onImageAction={({ pointerType }) => {
|
||||
onImageAction({
|
||||
@@ -388,7 +391,7 @@ const LayerUI = ({
|
||||
</Tooltip>
|
||||
))}
|
||||
</UserList>
|
||||
{renderTopRightUI?.(isMobile, appState)}
|
||||
{renderTopRightUI?.(deviceType.isMobile, appState)}
|
||||
</div>
|
||||
</div>
|
||||
</FixedSideContainer>
|
||||
@@ -418,16 +421,39 @@ const LayerUI = ({
|
||||
/>
|
||||
</Island>
|
||||
{!viewModeEnabled && (
|
||||
<div
|
||||
className={clsx("undo-redo-buttons zen-mode-transition", {
|
||||
"layer-ui__wrapper__footer-left--transition-bottom":
|
||||
zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
{actionManager.renderAction("undo", { size: "small" })}
|
||||
{actionManager.renderAction("redo", { size: "small" })}
|
||||
</div>
|
||||
<>
|
||||
<div
|
||||
className={clsx("undo-redo-buttons zen-mode-transition", {
|
||||
"layer-ui__wrapper__footer-left--transition-bottom":
|
||||
zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
{actionManager.renderAction("undo", { size: "small" })}
|
||||
{actionManager.renderAction("redo", { size: "small" })}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx("eraser-buttons zen-mode-transition", {
|
||||
"layer-ui__wrapper__footer-left--transition-left":
|
||||
zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
{actionManager.renderAction("eraser", { size: "small" })}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!viewModeEnabled &&
|
||||
appState.multiElement &&
|
||||
deviceType.isTouchScreen && (
|
||||
<div
|
||||
className={clsx("finalize-button zen-mode-transition", {
|
||||
"layer-ui__wrapper__footer-left--transition-left":
|
||||
zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
{actionManager.renderAction("finalize", { size: "small" })}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
@@ -466,7 +492,7 @@ const LayerUI = ({
|
||||
|
||||
const dialogs = (
|
||||
<>
|
||||
{appState.isLoading && <LoadingMessage />}
|
||||
{appState.isLoading && <LoadingMessage delay={250} />}
|
||||
{appState.errorMessage && (
|
||||
<ErrorDialog
|
||||
message={appState.errorMessage}
|
||||
@@ -495,7 +521,7 @@ const LayerUI = ({
|
||||
</>
|
||||
);
|
||||
|
||||
return isMobile ? (
|
||||
return deviceType.isMobile ? (
|
||||
<>
|
||||
{dialogs}
|
||||
<MobileMenu
|
||||
@@ -507,7 +533,7 @@ const LayerUI = ({
|
||||
renderImageExportDialog={renderImageExportDialog}
|
||||
setAppState={setAppState}
|
||||
onCollabButtonClick={onCollabButtonClick}
|
||||
onLockToggle={onLockToggle}
|
||||
onLockToggle={() => onLockToggle()}
|
||||
onPenModeToggle={onPenModeToggle}
|
||||
canvas={canvas}
|
||||
isCollaborating={isCollaborating}
|
||||
|
||||
@@ -28,8 +28,17 @@
|
||||
}
|
||||
|
||||
.layer-ui__library-message {
|
||||
padding: 10px 20px;
|
||||
max-width: 200px;
|
||||
padding: 2em 4em;
|
||||
min-width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
.Spinner {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
span {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
||||
|
||||
.publish-library-success {
|
||||
|
||||
+122
-119
@@ -1,5 +1,12 @@
|
||||
import { useRef, useState, useEffect, useCallback, RefObject } from "react";
|
||||
import Library from "../data/library";
|
||||
import {
|
||||
useRef,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
RefObject,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
import Library, { libraryItemsAtom } from "../data/library";
|
||||
import { t } from "../i18n";
|
||||
import { randomId } from "../random";
|
||||
import {
|
||||
@@ -19,6 +26,10 @@ import LibraryMenuItems from "./LibraryMenuItems";
|
||||
import { EVENT } from "../constants";
|
||||
import { KEYS } from "../keys";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { useAtom } from "jotai";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import Spinner from "./Spinner";
|
||||
|
||||
const useOnClickOutside = (
|
||||
ref: RefObject<HTMLElement>,
|
||||
@@ -53,6 +64,17 @@ const getSelectedItems = (
|
||||
selectedItems: LibraryItem["id"][],
|
||||
) => libraryItems.filter((item) => selectedItems.includes(item.id));
|
||||
|
||||
const LibraryMenuWrapper = forwardRef<
|
||||
HTMLDivElement,
|
||||
{ children: React.ReactNode }
|
||||
>(({ children }, ref) => {
|
||||
return (
|
||||
<Island padding={1} ref={ref} className="layer-ui__library">
|
||||
{children}
|
||||
</Island>
|
||||
);
|
||||
});
|
||||
|
||||
export const LibraryMenu = ({
|
||||
onClose,
|
||||
onInsertShape,
|
||||
@@ -102,11 +124,6 @@ export const LibraryMenu = ({
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
|
||||
|
||||
const [loadingState, setIsLoading] = useState<
|
||||
"preloading" | "loading" | "ready"
|
||||
>("preloading");
|
||||
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
||||
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
|
||||
useState(false);
|
||||
@@ -114,55 +131,35 @@ export const LibraryMenu = ({
|
||||
url: string;
|
||||
authorName: string;
|
||||
}>(null);
|
||||
const loadingTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.race([
|
||||
new Promise((resolve) => {
|
||||
loadingTimerRef.current = window.setTimeout(() => {
|
||||
resolve("loading");
|
||||
}, 100);
|
||||
}),
|
||||
library.loadLibrary().then((items) => {
|
||||
setLibraryItems(items);
|
||||
setIsLoading("ready");
|
||||
}),
|
||||
]).then((data) => {
|
||||
if (data === "loading") {
|
||||
setIsLoading("loading");
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
clearTimeout(loadingTimerRef.current!);
|
||||
};
|
||||
}, [library]);
|
||||
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
||||
|
||||
const removeFromLibrary = useCallback(async () => {
|
||||
const items = await library.loadLibrary();
|
||||
|
||||
const nextItems = items.filter((item) => !selectedItems.includes(item.id));
|
||||
library.saveLibrary(nextItems).catch((error) => {
|
||||
setLibraryItems(items);
|
||||
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
||||
});
|
||||
setSelectedItems([]);
|
||||
setLibraryItems(nextItems);
|
||||
}, [library, setAppState, selectedItems, setSelectedItems]);
|
||||
const removeFromLibrary = useCallback(
|
||||
async (libraryItems: LibraryItems) => {
|
||||
const nextItems = libraryItems.filter(
|
||||
(item) => !selectedItems.includes(item.id),
|
||||
);
|
||||
library.saveLibrary(nextItems).catch(() => {
|
||||
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
||||
});
|
||||
setSelectedItems([]);
|
||||
},
|
||||
[library, setAppState, selectedItems, setSelectedItems],
|
||||
);
|
||||
|
||||
const resetLibrary = useCallback(() => {
|
||||
library.resetLibrary();
|
||||
setLibraryItems([]);
|
||||
focusContainer();
|
||||
}, [library, focusContainer]);
|
||||
|
||||
const addToLibrary = useCallback(
|
||||
async (elements: LibraryItem["elements"]) => {
|
||||
async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => {
|
||||
trackEvent("element", "addToLibrary", "ui");
|
||||
if (elements.some((element) => element.type === "image")) {
|
||||
return setAppState({
|
||||
errorMessage: "Support for adding images to the library coming soon!",
|
||||
});
|
||||
}
|
||||
const items = await library.loadLibrary();
|
||||
const nextItems: LibraryItems = [
|
||||
{
|
||||
status: "unpublished",
|
||||
@@ -170,14 +167,12 @@ export const LibraryMenu = ({
|
||||
id: randomId(),
|
||||
created: Date.now(),
|
||||
},
|
||||
...items,
|
||||
...libraryItems,
|
||||
];
|
||||
onAddToLibrary();
|
||||
library.saveLibrary(nextItems).catch((error) => {
|
||||
setLibraryItems(items);
|
||||
library.saveLibrary(nextItems).catch(() => {
|
||||
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
||||
});
|
||||
setLibraryItems(nextItems);
|
||||
},
|
||||
[onAddToLibrary, library, setAppState],
|
||||
);
|
||||
@@ -216,7 +211,7 @@ export const LibraryMenu = ({
|
||||
}, [setPublishLibSuccess, publishLibSuccess]);
|
||||
|
||||
const onPublishLibSuccess = useCallback(
|
||||
(data) => {
|
||||
(data, libraryItems: LibraryItems) => {
|
||||
setShowPublishLibraryDialog(false);
|
||||
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
|
||||
const nextLibItems = libraryItems.slice();
|
||||
@@ -226,101 +221,109 @@ export const LibraryMenu = ({
|
||||
}
|
||||
});
|
||||
library.saveLibrary(nextLibItems);
|
||||
setLibraryItems(nextLibItems);
|
||||
},
|
||||
[
|
||||
setShowPublishLibraryDialog,
|
||||
setPublishLibSuccess,
|
||||
libraryItems,
|
||||
selectedItems,
|
||||
library,
|
||||
],
|
||||
[setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
|
||||
);
|
||||
|
||||
const [lastSelectedItem, setLastSelectedItem] = useState<
|
||||
LibraryItem["id"] | null
|
||||
>(null);
|
||||
|
||||
return loadingState === "preloading" ? null : (
|
||||
<Island padding={1} ref={ref} className="layer-ui__library">
|
||||
if (libraryItemsData.status === "loading") {
|
||||
return (
|
||||
<LibraryMenuWrapper ref={ref}>
|
||||
<div className="layer-ui__library-message">
|
||||
<Spinner size="2em" />
|
||||
<span>{t("labels.libraryLoadingMessage")}</span>
|
||||
</div>
|
||||
</LibraryMenuWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LibraryMenuWrapper ref={ref}>
|
||||
{showPublishLibraryDialog && (
|
||||
<PublishLibrary
|
||||
onClose={() => setShowPublishLibraryDialog(false)}
|
||||
libraryItems={getSelectedItems(libraryItems, selectedItems)}
|
||||
libraryItems={getSelectedItems(
|
||||
libraryItemsData.libraryItems,
|
||||
selectedItems,
|
||||
)}
|
||||
appState={appState}
|
||||
onSuccess={onPublishLibSuccess}
|
||||
onSuccess={(data) =>
|
||||
onPublishLibSuccess(data, libraryItemsData.libraryItems)
|
||||
}
|
||||
onError={(error) => window.alert(error)}
|
||||
updateItemsInStorage={() => library.saveLibrary(libraryItems)}
|
||||
updateItemsInStorage={() =>
|
||||
library.saveLibrary(libraryItemsData.libraryItems)
|
||||
}
|
||||
onRemove={(id: string) =>
|
||||
setSelectedItems(selectedItems.filter((_id) => _id !== id))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{publishLibSuccess && renderPublishSuccess()}
|
||||
<LibraryMenuItems
|
||||
libraryItems={libraryItemsData.libraryItems}
|
||||
onRemoveFromLibrary={() =>
|
||||
removeFromLibrary(libraryItemsData.libraryItems)
|
||||
}
|
||||
onAddToLibrary={(elements) =>
|
||||
addToLibrary(elements, libraryItemsData.libraryItems)
|
||||
}
|
||||
onInsertShape={onInsertShape}
|
||||
pendingElements={pendingElements}
|
||||
setAppState={setAppState}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
library={library}
|
||||
theme={theme}
|
||||
files={files}
|
||||
id={id}
|
||||
selectedItems={selectedItems}
|
||||
onToggle={(id, event) => {
|
||||
const shouldSelect = !selectedItems.includes(id);
|
||||
|
||||
{loadingState === "loading" ? (
|
||||
<div className="layer-ui__library-message">
|
||||
{t("labels.libraryLoadingMessage")}
|
||||
</div>
|
||||
) : (
|
||||
<LibraryMenuItems
|
||||
libraryItems={libraryItems}
|
||||
onRemoveFromLibrary={removeFromLibrary}
|
||||
onAddToLibrary={addToLibrary}
|
||||
onInsertShape={onInsertShape}
|
||||
pendingElements={pendingElements}
|
||||
setAppState={setAppState}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
library={library}
|
||||
theme={theme}
|
||||
files={files}
|
||||
id={id}
|
||||
selectedItems={selectedItems}
|
||||
onToggle={(id, event) => {
|
||||
const shouldSelect = !selectedItems.includes(id);
|
||||
if (shouldSelect) {
|
||||
if (event.shiftKey && lastSelectedItem) {
|
||||
const rangeStart = libraryItemsData.libraryItems.findIndex(
|
||||
(item) => item.id === lastSelectedItem,
|
||||
);
|
||||
const rangeEnd = libraryItemsData.libraryItems.findIndex(
|
||||
(item) => item.id === id,
|
||||
);
|
||||
|
||||
if (shouldSelect) {
|
||||
if (event.shiftKey && lastSelectedItem) {
|
||||
const rangeStart = libraryItems.findIndex(
|
||||
(item) => item.id === lastSelectedItem,
|
||||
);
|
||||
const rangeEnd = libraryItems.findIndex(
|
||||
(item) => item.id === id,
|
||||
);
|
||||
|
||||
if (rangeStart === -1 || rangeEnd === -1) {
|
||||
setSelectedItems([...selectedItems, id]);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedItemsMap = arrayToMap(selectedItems);
|
||||
const nextSelectedIds = libraryItems.reduce(
|
||||
(acc: LibraryItem["id"][], item, idx) => {
|
||||
if (
|
||||
(idx >= rangeStart && idx <= rangeEnd) ||
|
||||
selectedItemsMap.has(item.id)
|
||||
) {
|
||||
acc.push(item.id);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
setSelectedItems(nextSelectedIds);
|
||||
} else {
|
||||
if (rangeStart === -1 || rangeEnd === -1) {
|
||||
setSelectedItems([...selectedItems, id]);
|
||||
return;
|
||||
}
|
||||
setLastSelectedItem(id);
|
||||
|
||||
const selectedItemsMap = arrayToMap(selectedItems);
|
||||
const nextSelectedIds = libraryItemsData.libraryItems.reduce(
|
||||
(acc: LibraryItem["id"][], item, idx) => {
|
||||
if (
|
||||
(idx >= rangeStart && idx <= rangeEnd) ||
|
||||
selectedItemsMap.has(item.id)
|
||||
) {
|
||||
acc.push(item.id);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
setSelectedItems(nextSelectedIds);
|
||||
} else {
|
||||
setLastSelectedItem(null);
|
||||
setSelectedItems(selectedItems.filter((_id) => _id !== id));
|
||||
setSelectedItems([...selectedItems, id]);
|
||||
}
|
||||
}}
|
||||
onPublish={() => setShowPublishLibraryDialog(true)}
|
||||
resetLibrary={resetLibrary}
|
||||
/>
|
||||
)}
|
||||
</Island>
|
||||
setLastSelectedItem(id);
|
||||
} else {
|
||||
setLastSelectedItem(null);
|
||||
setSelectedItems(selectedItems.filter((_id) => _id !== id));
|
||||
}
|
||||
}}
|
||||
onPublish={() => setShowPublishLibraryDialog(true)}
|
||||
resetLibrary={resetLibrary}
|
||||
/>
|
||||
</LibraryMenuWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
LibraryItems,
|
||||
} from "../types";
|
||||
import { muteFSAbortError } from "../utils";
|
||||
import { useIsMobile } from "./App";
|
||||
import { useDeviceType } from "./App";
|
||||
import ConfirmDialog from "./ConfirmDialog";
|
||||
import { exportToFileIcon, load, publishIcon, trash } from "./icons";
|
||||
import { LibraryUnit } from "./LibraryUnit";
|
||||
@@ -85,7 +85,7 @@ const LibraryMenuItems = ({
|
||||
|
||||
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const isMobile = useDeviceType().isMobile;
|
||||
|
||||
const renderLibraryActions = () => {
|
||||
const itemsSelected = !!selectedItems.length;
|
||||
@@ -106,11 +106,6 @@ const LibraryMenuItems = ({
|
||||
icon={load}
|
||||
onClick={() => {
|
||||
importLibraryFromJSON(library)
|
||||
.then(() => {
|
||||
// Close and then open to get the libraries updated
|
||||
setAppState({ isLibraryOpen: false });
|
||||
setAppState({ isLibraryOpen: true });
|
||||
})
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
setAppState({ errorMessage: error.message });
|
||||
|
||||
@@ -2,7 +2,7 @@ import clsx from "clsx";
|
||||
import oc from "open-color";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { MIME_TYPES } from "../constants";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { useDeviceType } from "../components/App";
|
||||
import { exportToSvg } from "../scene/export";
|
||||
import { BinaryFiles, LibraryItem } from "../types";
|
||||
import "./LibraryUnit.scss";
|
||||
@@ -66,7 +66,7 @@ export const LibraryUnit = ({
|
||||
}, [elements, files]);
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const isMobile = useDeviceType().isMobile;
|
||||
const adder = isPending && (
|
||||
<div className="library-unit__adder">{PLUS_ICON}</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
import { t } from "../i18n";
|
||||
import { useState, useEffect } from "react";
|
||||
import Spinner from "./Spinner";
|
||||
|
||||
export const LoadingMessage: React.FC<{ delay?: number }> = ({ delay }) => {
|
||||
const [isWaiting, setIsWaiting] = useState(!!delay);
|
||||
|
||||
useEffect(() => {
|
||||
if (!delay) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
setIsWaiting(false);
|
||||
}, delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [delay]);
|
||||
|
||||
if (isWaiting) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export const LoadingMessage = () => {
|
||||
// !! KEEP THIS IN SYNC WITH index.html !!
|
||||
return (
|
||||
<div className="LoadingMessage">
|
||||
<span>{t("labels.loadingScene")}</span>
|
||||
<div>
|
||||
<Spinner />
|
||||
</div>
|
||||
<div className="LoadingMessage-text">{t("labels.loadingScene")}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { FixedSideContainer } from "./FixedSideContainer";
|
||||
import { Island } from "./Island";
|
||||
import { HintViewer } from "./HintViewer";
|
||||
import { calculateScrollCenter } from "../scene";
|
||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
||||
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||
import { Section } from "./Section";
|
||||
import CollabButton from "./CollabButton";
|
||||
@@ -72,8 +72,9 @@ export const MobileMenu = ({
|
||||
{heading}
|
||||
<Stack.Row gap={1}>
|
||||
<ShapesSwitcher
|
||||
appState={appState}
|
||||
canvas={canvas}
|
||||
elementType={appState.elementType}
|
||||
activeTool={appState.activeTool}
|
||||
setAppState={setAppState}
|
||||
onImageAction={({ pointerType }) => {
|
||||
onImageAction({
|
||||
@@ -85,7 +86,7 @@ export const MobileMenu = ({
|
||||
</Island>
|
||||
{renderTopRightUI && renderTopRightUI(true, appState)}
|
||||
<LockButton
|
||||
checked={appState.elementLocked}
|
||||
checked={appState.activeTool.locked}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
isMobile
|
||||
@@ -113,6 +114,12 @@ export const MobileMenu = ({
|
||||
};
|
||||
|
||||
const renderAppToolbar = () => {
|
||||
// Render eraser conditionally in mobile
|
||||
const showEraser =
|
||||
!appState.viewModeEnabled &&
|
||||
!appState.editingElement &&
|
||||
getSelectedElements(elements, appState).length === 0;
|
||||
|
||||
if (viewModeEnabled) {
|
||||
return (
|
||||
<div className="App-toolbar-content">
|
||||
@@ -120,12 +127,16 @@ export const MobileMenu = ({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="App-toolbar-content">
|
||||
{actionManager.renderAction("toggleCanvasMenu")}
|
||||
{actionManager.renderAction("toggleEditMenu")}
|
||||
|
||||
{actionManager.renderAction("undo")}
|
||||
{actionManager.renderAction("redo")}
|
||||
{showEraser && actionManager.renderAction("eraser")}
|
||||
|
||||
{actionManager.renderAction(
|
||||
appState.multiElement ? "finalize" : "duplicateSelection",
|
||||
)}
|
||||
@@ -215,7 +226,7 @@ export const MobileMenu = ({
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
renderAction={actionManager.renderAction}
|
||||
elementType={appState.elementType}
|
||||
activeTool={appState.activeTool.type}
|
||||
/>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { useState, useLayoutEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import clsx from "clsx";
|
||||
import { KEYS } from "../keys";
|
||||
import { useExcalidrawContainer, useIsMobile } from "./App";
|
||||
import { useExcalidrawContainer, useDeviceType } from "./App";
|
||||
import { AppState } from "../types";
|
||||
import { THEME } from "../constants";
|
||||
|
||||
@@ -59,17 +59,17 @@ export const Modal = (props: {
|
||||
const useBodyRoot = (theme: AppState["theme"]) => {
|
||||
const [div, setDiv] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const isMobileRef = useRef(isMobile);
|
||||
isMobileRef.current = isMobile;
|
||||
const deviceType = useDeviceType();
|
||||
const isMobileRef = useRef(deviceType.isMobile);
|
||||
isMobileRef.current = deviceType.isMobile;
|
||||
|
||||
const { container: excalidrawContainer } = useExcalidrawContainer();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (div) {
|
||||
div.classList.toggle("excalidraw--mobile", isMobile);
|
||||
div.classList.toggle("excalidraw--mobile", deviceType.isMobile);
|
||||
}
|
||||
}, [div, isMobile]);
|
||||
}, [div, deviceType.isMobile]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const isDarkTheme =
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from "react";
|
||||
import { getCommonBounds } from "../element/bounds";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { useDeviceType } from "../components/App";
|
||||
import { getTargetElements } from "../scene";
|
||||
import { AppState, ExcalidrawProps } from "../types";
|
||||
import { close } from "./icons";
|
||||
@@ -16,13 +16,13 @@ export const Stats = (props: {
|
||||
onClose: () => void;
|
||||
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
||||
}) => {
|
||||
const isMobile = useIsMobile();
|
||||
const deviceType = useDeviceType();
|
||||
|
||||
const boundingBox = getCommonBounds(props.elements);
|
||||
const selectedElements = getTargetElements(props.elements, props.appState);
|
||||
const selectedBoundingBox = getCommonBounds(selectedElements);
|
||||
|
||||
if (isMobile && props.appState.openMenu) {
|
||||
if (deviceType.isMobile && props.appState.openMenu) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ type ToolButtonProps =
|
||||
type: "radio";
|
||||
checked: boolean;
|
||||
onChange?(data: { pointerType: PointerType | null }): void;
|
||||
onPointerDown?(data: { pointerType: PointerType }): void;
|
||||
});
|
||||
|
||||
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
@@ -149,6 +150,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
title={props.title}
|
||||
onPointerDown={(event) => {
|
||||
lastPointerTypeRef.current = event.pointerType || null;
|
||||
props.onPointerDown?.({ pointerType: event.pointerType || null });
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
requestAnimationFrame(() => {
|
||||
|
||||
@@ -155,7 +155,7 @@
|
||||
}
|
||||
|
||||
width: 2rem;
|
||||
height: 2em;
|
||||
height: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -934,3 +934,7 @@ export const editIcon = createIcon(
|
||||
></path>,
|
||||
{ width: 640, height: 512 },
|
||||
);
|
||||
|
||||
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" />,
|
||||
);
|
||||
|
||||
@@ -94,7 +94,9 @@ export const MIME_TYPES = {
|
||||
excalidrawlib: "application/vnd.excalidrawlib+json",
|
||||
json: "application/json",
|
||||
svg: "image/svg+xml",
|
||||
"excalidraw.svg": "image/svg+xml",
|
||||
png: "image/png",
|
||||
"excalidraw.png": "image/png",
|
||||
jpg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
binary: "application/octet-stream",
|
||||
@@ -188,3 +190,5 @@ export const VERTICAL_ALIGN = {
|
||||
MIDDLE: "middle",
|
||||
BOTTOM: "bottom",
|
||||
};
|
||||
|
||||
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
|
||||
|
||||
+9
-7
@@ -16,15 +16,17 @@
|
||||
left: 0;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.LoadingMessage span {
|
||||
background-color: var(--button-gray-1);
|
||||
border-radius: 5px;
|
||||
padding: 0.8em 1.2em;
|
||||
color: var(--popup-text-color);
|
||||
font-size: 1.3em;
|
||||
.Spinner {
|
||||
font-size: 2.8em;
|
||||
}
|
||||
|
||||
.LoadingMessage-text {
|
||||
margin-top: 1em;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
||||
|
||||
+21
-1
@@ -290,6 +290,16 @@
|
||||
width: 100%;
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
.eraser {
|
||||
&.ToolIcon:hover {
|
||||
--icon-fill-color: #fff;
|
||||
--keybinding-color: #fff;
|
||||
}
|
||||
&.active {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.App-toolbar-content {
|
||||
@@ -467,7 +477,17 @@
|
||||
font-family: var(--ui-font);
|
||||
}
|
||||
|
||||
.undo-redo-buttons {
|
||||
.finalize-button {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
gap: 0.4em;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
margin-inline-start: 0.6em;
|
||||
}
|
||||
|
||||
.undo-redo-buttons,
|
||||
.eraser-buttons {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
gap: 0.4em;
|
||||
|
||||
+13
-13
@@ -1,20 +1,16 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { cleanAppStateForExport } from "../appState";
|
||||
import {
|
||||
ALLOWED_IMAGE_MIME_TYPES,
|
||||
EXPORT_DATA_TYPES,
|
||||
MIME_TYPES,
|
||||
} from "../constants";
|
||||
import { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
|
||||
import { clearElementsForExport } from "../element";
|
||||
import { ExcalidrawElement, FileId } from "../element/types";
|
||||
import { CanvasError } from "../errors";
|
||||
import { t } from "../i18n";
|
||||
import { calculateScrollCenter } from "../scene";
|
||||
import { AppState, DataURL } from "../types";
|
||||
import { AppState, DataURL, LibraryItem } from "../types";
|
||||
import { bytesToHexString } from "../utils";
|
||||
import { FileSystemHandle } from "./filesystem";
|
||||
import { isValidExcalidrawData } from "./json";
|
||||
import { restore } from "./restore";
|
||||
import { isValidExcalidrawData, isValidLibrary } from "./json";
|
||||
import { restore, restoreLibraryItems } from "./restore";
|
||||
import { ImportedLibraryData } from "./types";
|
||||
|
||||
const parseFileContents = async (blob: Blob | File) => {
|
||||
@@ -163,13 +159,17 @@ export const loadFromBlob = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const loadLibraryFromBlob = async (blob: Blob) => {
|
||||
export const loadLibraryFromBlob = async (
|
||||
blob: Blob,
|
||||
defaultStatus: LibraryItem["status"] = "unpublished",
|
||||
) => {
|
||||
const contents = await parseFileContents(blob);
|
||||
const data: ImportedLibraryData = JSON.parse(contents);
|
||||
if (data.type !== EXPORT_DATA_TYPES.excalidrawLibrary) {
|
||||
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
||||
const data: ImportedLibraryData | undefined = JSON.parse(contents);
|
||||
if (!isValidLibrary(data)) {
|
||||
throw new Error("Invalid library");
|
||||
}
|
||||
return data;
|
||||
const libraryItems = data.libraryItems || data.library;
|
||||
return restoreLibraryItems(libraryItems, defaultStatus);
|
||||
};
|
||||
|
||||
export const canvasToBlob = async (
|
||||
|
||||
@@ -13,7 +13,9 @@ type FILE_EXTENSION =
|
||||
| "gif"
|
||||
| "jpg"
|
||||
| "png"
|
||||
| "excalidraw.png"
|
||||
| "svg"
|
||||
| "excalidraw.svg"
|
||||
| "json"
|
||||
| "excalidraw"
|
||||
| "excalidrawlib";
|
||||
|
||||
+3
-1
@@ -105,7 +105,9 @@ export const encodeSvgMetadata = async ({ text }: { text: string }) => {
|
||||
|
||||
export const decodeSvgMetadata = async ({ svg }: { svg: string }) => {
|
||||
if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) {
|
||||
const match = svg.match(/<!-- payload-start -->(.+?)<!-- payload-end -->/);
|
||||
const match = svg.match(
|
||||
/<!-- payload-start -->\s*(.+?)\s*<!-- payload-end -->/,
|
||||
);
|
||||
if (!match) {
|
||||
throw new Error("INVALID");
|
||||
}
|
||||
|
||||
+12
-5
@@ -16,7 +16,7 @@ export { loadFromBlob } from "./blob";
|
||||
export { loadFromJSON, saveAsJSON } from "./json";
|
||||
|
||||
export const exportCanvas = async (
|
||||
type: ExportType,
|
||||
type: Omit<ExportType, "backend">,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
@@ -56,7 +56,7 @@ export const exportCanvas = async (
|
||||
{
|
||||
description: "Export to SVG",
|
||||
name,
|
||||
extension: "svg",
|
||||
extension: appState.exportEmbedScene ? "excalidraw.svg" : "svg",
|
||||
fileHandle,
|
||||
},
|
||||
);
|
||||
@@ -73,10 +73,10 @@ export const exportCanvas = async (
|
||||
});
|
||||
tempCanvas.style.display = "none";
|
||||
document.body.appendChild(tempCanvas);
|
||||
let blob = await canvasToBlob(tempCanvas);
|
||||
tempCanvas.remove();
|
||||
|
||||
if (type === "png") {
|
||||
let blob = await canvasToBlob(tempCanvas);
|
||||
tempCanvas.remove();
|
||||
if (appState.exportEmbedScene) {
|
||||
blob = await (
|
||||
await import(/* webpackChunkName: "image" */ "./image")
|
||||
@@ -89,17 +89,24 @@ export const exportCanvas = async (
|
||||
return await fileSave(blob, {
|
||||
description: "Export to PNG",
|
||||
name,
|
||||
extension: "png",
|
||||
extension: appState.exportEmbedScene ? "excalidraw.png" : "png",
|
||||
fileHandle,
|
||||
});
|
||||
} else if (type === "clipboard") {
|
||||
try {
|
||||
const blob = canvasToBlob(tempCanvas);
|
||||
await copyBlobToClipboardAsPng(blob);
|
||||
} catch (error: any) {
|
||||
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(t("alerts.couldNotCopyToClipboard"));
|
||||
} finally {
|
||||
tempCanvas.remove();
|
||||
}
|
||||
} else {
|
||||
tempCanvas.remove();
|
||||
// shouldn't happen
|
||||
throw new Error("Unsupported export type");
|
||||
}
|
||||
};
|
||||
|
||||
+8
-3
@@ -15,6 +15,7 @@ import {
|
||||
ExportedDataState,
|
||||
ImportedDataState,
|
||||
ExportedLibraryData,
|
||||
ImportedLibraryData,
|
||||
} from "./types";
|
||||
import Library from "./library";
|
||||
|
||||
@@ -114,7 +115,7 @@ export const isValidExcalidrawData = (data?: {
|
||||
);
|
||||
};
|
||||
|
||||
export const isValidLibrary = (json: any) => {
|
||||
export const isValidLibrary = (json: any): json is ImportedLibraryData => {
|
||||
return (
|
||||
typeof json === "object" &&
|
||||
json &&
|
||||
@@ -123,14 +124,18 @@ export const isValidLibrary = (json: any) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
|
||||
export const serializeLibraryAsJSON = (libraryItems: LibraryItems) => {
|
||||
const data: ExportedLibraryData = {
|
||||
type: EXPORT_DATA_TYPES.excalidrawLibrary,
|
||||
version: VERSIONS.excalidrawLibrary,
|
||||
source: EXPORT_SOURCE,
|
||||
libraryItems,
|
||||
};
|
||||
const serialized = JSON.stringify(data, null, 2);
|
||||
return JSON.stringify(data, null, 2);
|
||||
};
|
||||
|
||||
export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
|
||||
const serialized = serializeLibraryAsJSON(libraryItems);
|
||||
await fileSave(
|
||||
new Blob([serialized], {
|
||||
type: MIME_TYPES.excalidrawlib,
|
||||
|
||||
+113
-87
@@ -1,11 +1,52 @@
|
||||
import { loadLibraryFromBlob } from "./blob";
|
||||
import { LibraryItems, LibraryItem } from "../types";
|
||||
import { restoreElements, restoreLibraryItems } from "./restore";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { restoreLibraryItems } from "./restore";
|
||||
import type App from "../components/App";
|
||||
import { ImportedDataState } from "./types";
|
||||
import { atom } from "jotai";
|
||||
import { jotaiStore } from "../jotai";
|
||||
import { isPromiseLike } from "../utils";
|
||||
import { t } from "../i18n";
|
||||
|
||||
export const libraryItemsAtom = atom<
|
||||
| { status: "loading"; libraryItems: null; promise: Promise<LibraryItems> }
|
||||
| { status: "loaded"; libraryItems: LibraryItems }
|
||||
>({ status: "loaded", libraryItems: [] });
|
||||
|
||||
const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
|
||||
JSON.parse(JSON.stringify(libraryItems));
|
||||
|
||||
/**
|
||||
* checks if library item does not exist already in current library
|
||||
*/
|
||||
const isUniqueItem = (
|
||||
existingLibraryItems: LibraryItems,
|
||||
targetLibraryItem: LibraryItem,
|
||||
) => {
|
||||
return !existingLibraryItems.find((libraryItem) => {
|
||||
if (libraryItem.elements.length !== targetLibraryItem.elements.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// detect z-index difference by checking the excalidraw elements
|
||||
// are in order
|
||||
return libraryItem.elements.every((libItemExcalidrawItem, idx) => {
|
||||
return (
|
||||
libItemExcalidrawItem.id === targetLibraryItem.elements[idx].id &&
|
||||
libItemExcalidrawItem.versionNonce ===
|
||||
targetLibraryItem.elements[idx].versionNonce
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
class Library {
|
||||
private libraryCache: LibraryItems | null = null;
|
||||
/** cache for currently active promise when initializing/updating libaries
|
||||
asynchronously */
|
||||
private libraryItemsPromise: Promise<LibraryItems> | null = null;
|
||||
/** last resolved libraryItems */
|
||||
private lastLibraryItems: LibraryItems = [];
|
||||
|
||||
private app: App;
|
||||
|
||||
constructor(app: App) {
|
||||
@@ -13,107 +54,92 @@ class Library {
|
||||
}
|
||||
|
||||
resetLibrary = async () => {
|
||||
await this.app.props.onLibraryChange?.([]);
|
||||
this.libraryCache = [];
|
||||
};
|
||||
|
||||
restoreLibraryItem = (libraryItem: LibraryItem): LibraryItem | null => {
|
||||
const elements = getNonDeletedElements(
|
||||
restoreElements(libraryItem.elements, null),
|
||||
);
|
||||
return elements.length ? { ...libraryItem, elements } : null;
|
||||
this.saveLibrary([]);
|
||||
};
|
||||
|
||||
/** imports library (currently merges, removing duplicates) */
|
||||
async importLibrary(blob: Blob, defaultStatus = "unpublished") {
|
||||
const libraryFile = await loadLibraryFromBlob(blob);
|
||||
if (!libraryFile || !(libraryFile.libraryItems || libraryFile.library)) {
|
||||
return;
|
||||
}
|
||||
async importLibrary(
|
||||
library:
|
||||
| Blob
|
||||
| Required<ImportedDataState>["libraryItems"]
|
||||
| Promise<Required<ImportedDataState>["libraryItems"]>,
|
||||
defaultStatus: LibraryItem["status"] = "unpublished",
|
||||
) {
|
||||
return this.saveLibrary(
|
||||
new Promise<LibraryItems>(async (resolve, reject) => {
|
||||
try {
|
||||
let libraryItems: LibraryItems;
|
||||
if (library instanceof Blob) {
|
||||
libraryItems = await loadLibraryFromBlob(library, defaultStatus);
|
||||
} else {
|
||||
libraryItems = restoreLibraryItems(await library, defaultStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* checks if library item does not exist already in current library
|
||||
*/
|
||||
const isUniqueitem = (
|
||||
existingLibraryItems: LibraryItems,
|
||||
targetLibraryItem: LibraryItem,
|
||||
) => {
|
||||
return !existingLibraryItems.find((libraryItem) => {
|
||||
if (libraryItem.elements.length !== targetLibraryItem.elements.length) {
|
||||
return false;
|
||||
const existingLibraryItems = this.lastLibraryItems;
|
||||
|
||||
const filteredItems = [];
|
||||
for (const item of libraryItems) {
|
||||
if (isUniqueItem(existingLibraryItems, item)) {
|
||||
filteredItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
resolve([...filteredItems, ...existingLibraryItems]);
|
||||
} catch (error) {
|
||||
reject(new Error(t("errors.importLibraryError")));
|
||||
}
|
||||
|
||||
// detect z-index difference by checking the excalidraw elements
|
||||
// are in order
|
||||
return libraryItem.elements.every((libItemExcalidrawItem, idx) => {
|
||||
return (
|
||||
libItemExcalidrawItem.id === targetLibraryItem.elements[idx].id &&
|
||||
libItemExcalidrawItem.versionNonce ===
|
||||
targetLibraryItem.elements[idx].versionNonce
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const existingLibraryItems = await this.loadLibrary();
|
||||
|
||||
const library = libraryFile.libraryItems || libraryFile.library || [];
|
||||
const restoredLibItems = restoreLibraryItems(
|
||||
library,
|
||||
defaultStatus as "published" | "unpublished",
|
||||
}),
|
||||
);
|
||||
const filteredItems = [];
|
||||
for (const item of restoredLibItems) {
|
||||
const restoredItem = this.restoreLibraryItem(item as LibraryItem);
|
||||
if (restoredItem && isUniqueitem(existingLibraryItems, restoredItem)) {
|
||||
filteredItems.push(restoredItem);
|
||||
}
|
||||
}
|
||||
|
||||
await this.saveLibrary([...filteredItems, ...existingLibraryItems]);
|
||||
}
|
||||
|
||||
loadLibrary = (): Promise<LibraryItems> => {
|
||||
return new Promise(async (resolve) => {
|
||||
if (this.libraryCache) {
|
||||
return resolve(JSON.parse(JSON.stringify(this.libraryCache)));
|
||||
}
|
||||
|
||||
try {
|
||||
const libraryItems = this.app.libraryItemsFromStorage;
|
||||
if (!libraryItems) {
|
||||
return resolve([]);
|
||||
}
|
||||
|
||||
const items = libraryItems.reduce((acc, item) => {
|
||||
const restoredItem = this.restoreLibraryItem(item);
|
||||
if (restoredItem) {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
}, [] as Mutable<LibraryItems>);
|
||||
|
||||
// clone to ensure we don't mutate the cached library elements in the app
|
||||
this.libraryCache = JSON.parse(JSON.stringify(items));
|
||||
|
||||
resolve(items);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
resolve([]);
|
||||
resolve(
|
||||
cloneLibraryItems(
|
||||
await (this.libraryItemsPromise || this.lastLibraryItems),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
return resolve(this.lastLibraryItems);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
saveLibrary = async (items: LibraryItems) => {
|
||||
const prevLibraryItems = this.libraryCache;
|
||||
saveLibrary = async (items: LibraryItems | Promise<LibraryItems>) => {
|
||||
const prevLibraryItems = this.lastLibraryItems;
|
||||
try {
|
||||
const serializedItems = JSON.stringify(items);
|
||||
// cache optimistically so that the app has access to the latest
|
||||
// immediately
|
||||
this.libraryCache = JSON.parse(serializedItems);
|
||||
await this.app.props.onLibraryChange?.(items);
|
||||
let nextLibraryItems;
|
||||
if (isPromiseLike(items)) {
|
||||
const promise = items.then((items) => cloneLibraryItems(items));
|
||||
this.libraryItemsPromise = promise;
|
||||
jotaiStore.set(libraryItemsAtom, {
|
||||
status: "loading",
|
||||
promise,
|
||||
libraryItems: null,
|
||||
});
|
||||
nextLibraryItems = await promise;
|
||||
} else {
|
||||
nextLibraryItems = cloneLibraryItems(items);
|
||||
}
|
||||
|
||||
this.lastLibraryItems = nextLibraryItems;
|
||||
this.libraryItemsPromise = null;
|
||||
|
||||
jotaiStore.set(libraryItemsAtom, {
|
||||
status: "loaded",
|
||||
libraryItems: nextLibraryItems,
|
||||
});
|
||||
await this.app.props.onLibraryChange?.(
|
||||
cloneLibraryItems(nextLibraryItems),
|
||||
);
|
||||
} catch (error: any) {
|
||||
this.libraryCache = prevLibraryItems;
|
||||
this.lastLibraryItems = prevLibraryItems;
|
||||
this.libraryItemsPromise = null;
|
||||
jotaiStore.set(libraryItemsAtom, {
|
||||
status: "loaded",
|
||||
libraryItems: prevLibraryItems,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
+44
-15
@@ -10,7 +10,11 @@ import {
|
||||
NormalizedZoomValue,
|
||||
} from "../types";
|
||||
import { ImportedDataState } from "./types";
|
||||
import { getNormalizedDimensions, isInvisiblySmallElement } from "../element";
|
||||
import {
|
||||
getNonDeletedElements,
|
||||
getNormalizedDimensions,
|
||||
isInvisiblySmallElement,
|
||||
} from "../element";
|
||||
import { isLinearElementType } from "../element/typeChecks";
|
||||
import { randomId } from "../random";
|
||||
import {
|
||||
@@ -30,9 +34,9 @@ type RestoredAppState = Omit<
|
||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||
>;
|
||||
|
||||
export const AllowedExcalidrawElementTypes: Record<
|
||||
ExcalidrawElement["type"],
|
||||
true
|
||||
export const AllowedExcalidrawActiveTools: Record<
|
||||
AppState["activeTool"]["type"],
|
||||
boolean
|
||||
> = {
|
||||
selection: true,
|
||||
text: true,
|
||||
@@ -43,6 +47,7 @@ export const AllowedExcalidrawElementTypes: Record<
|
||||
image: true,
|
||||
arrow: true,
|
||||
freedraw: true,
|
||||
eraser: false,
|
||||
};
|
||||
|
||||
export type RestoredDataState = {
|
||||
@@ -106,6 +111,7 @@ const restoreElementWithProperties = <
|
||||
: element.boundElements ?? [],
|
||||
updated: element.updated ?? getUpdatedTimestamp(),
|
||||
link: element.link ?? null,
|
||||
locked: element.locked ?? false,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -234,10 +240,8 @@ export const restoreAppState = (
|
||||
localAppState: Partial<AppState> | null | undefined,
|
||||
): RestoredAppState => {
|
||||
appState = appState || {};
|
||||
|
||||
const defaultAppState = getDefaultAppState();
|
||||
const nextAppState = {} as typeof defaultAppState;
|
||||
|
||||
for (const [key, defaultValue] of Object.entries(defaultAppState) as [
|
||||
keyof typeof defaultAppState,
|
||||
any,
|
||||
@@ -251,12 +255,20 @@ export const restoreAppState = (
|
||||
? localValue
|
||||
: defaultValue;
|
||||
}
|
||||
|
||||
return {
|
||||
...nextAppState,
|
||||
elementType: AllowedExcalidrawElementTypes[nextAppState.elementType]
|
||||
? nextAppState.elementType
|
||||
: "selection",
|
||||
cursorButton: localAppState?.cursorButton || "up",
|
||||
// reset on fresh restore so as to hide the UI button if penMode not active
|
||||
penDetected:
|
||||
localAppState?.penDetected ??
|
||||
(appState.penMode ? appState.penDetected ?? false : false),
|
||||
activeTool: {
|
||||
lastActiveToolBeforeEraser: null,
|
||||
locked: nextAppState.activeTool.locked ?? false,
|
||||
type: AllowedExcalidrawActiveTools[nextAppState.activeTool.type]
|
||||
? nextAppState.activeTool.type ?? "selection"
|
||||
: "selection",
|
||||
},
|
||||
// Migrates from previous version where appState.zoom was a number
|
||||
zoom:
|
||||
typeof appState.zoom === "number"
|
||||
@@ -268,7 +280,7 @@ export const restoreAppState = (
|
||||
};
|
||||
|
||||
export const restore = (
|
||||
data: ImportedDataState | null,
|
||||
data: Pick<ImportedDataState, "appState" | "elements" | "files"> | null,
|
||||
/**
|
||||
* Local AppState (`this.state` or initial state from localStorage) so that we
|
||||
* don't overwrite local state with default values (when values not
|
||||
@@ -285,28 +297,45 @@ export const restore = (
|
||||
};
|
||||
};
|
||||
|
||||
const restoreLibraryItem = (libraryItem: LibraryItem) => {
|
||||
const elements = restoreElements(
|
||||
getNonDeletedElements(libraryItem.elements),
|
||||
null,
|
||||
);
|
||||
return elements.length ? { ...libraryItem, elements } : null;
|
||||
};
|
||||
|
||||
export const restoreLibraryItems = (
|
||||
libraryItems: NonOptional<ImportedDataState["libraryItems"]>,
|
||||
libraryItems: ImportedDataState["libraryItems"] = [],
|
||||
defaultStatus: LibraryItem["status"],
|
||||
) => {
|
||||
const restoredItems: LibraryItem[] = [];
|
||||
for (const item of libraryItems) {
|
||||
// migrate older libraries
|
||||
if (Array.isArray(item)) {
|
||||
restoredItems.push({
|
||||
const restoredItem = restoreLibraryItem({
|
||||
status: defaultStatus,
|
||||
elements: item,
|
||||
id: randomId(),
|
||||
created: Date.now(),
|
||||
});
|
||||
if (restoredItem) {
|
||||
restoredItems.push(restoredItem);
|
||||
}
|
||||
} else {
|
||||
const _item = item as MarkOptional<LibraryItem, "id" | "status">;
|
||||
restoredItems.push({
|
||||
const _item = item as MarkOptional<
|
||||
LibraryItem,
|
||||
"id" | "status" | "created"
|
||||
>;
|
||||
const restoredItem = restoreLibraryItem({
|
||||
..._item,
|
||||
id: _item.id || randomId(),
|
||||
status: _item.status || defaultStatus,
|
||||
created: _item.created || Date.now(),
|
||||
});
|
||||
if (restoredItem) {
|
||||
restoredItems.push(restoredItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
return restoredItems;
|
||||
|
||||
@@ -262,9 +262,7 @@ export const actionLink = register({
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
trackEvent: (action, source) => {
|
||||
trackEvent("hyperlink", "edit", source);
|
||||
},
|
||||
trackEvent: { category: "hyperlink", action: "click" },
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K,
|
||||
contextItemLabel: (elements, appState) =>
|
||||
getContextMenuLabel(elements, appState),
|
||||
@@ -337,6 +335,9 @@ export const isPointHittingLinkIcon = (
|
||||
[x, y]: Point,
|
||||
isMobile: boolean,
|
||||
) => {
|
||||
if (!element.link || appState.selectedElementIds[element.id]) {
|
||||
return false;
|
||||
}
|
||||
const threshold = 4 / appState.zoom.value;
|
||||
if (
|
||||
!isMobile &&
|
||||
|
||||
+47
-17
@@ -255,7 +255,8 @@ export const getHoveredElementForBinding = (
|
||||
const hoveredElement = getElementAtPosition(
|
||||
scene.getElements(),
|
||||
(element) =>
|
||||
isBindableElement(element) && bindingBorderTest(element, pointerCoords),
|
||||
isBindableElement(element, false) &&
|
||||
bindingBorderTest(element, pointerCoords),
|
||||
);
|
||||
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
|
||||
};
|
||||
@@ -456,13 +457,13 @@ export const getEligibleElementsForBinding = (
|
||||
): SuggestedBinding[] => {
|
||||
const includedElementIds = new Set(elements.map(({ id }) => id));
|
||||
return elements.flatMap((element) =>
|
||||
isBindingElement(element)
|
||||
isBindingElement(element, false)
|
||||
? (getElligibleElementsForBindingElement(
|
||||
element as NonDeleted<ExcalidrawLinearElement>,
|
||||
).filter(
|
||||
(element) => !includedElementIds.has(element.id),
|
||||
) as SuggestedBinding[])
|
||||
: isBindableElement(element)
|
||||
: isBindableElement(element, false)
|
||||
? getElligibleElementsForBindableElementAndWhere(element).filter(
|
||||
(binding) => !includedElementIds.has(binding[0].id),
|
||||
)
|
||||
@@ -508,7 +509,7 @@ const getElligibleElementsForBindableElementAndWhere = (
|
||||
return Scene.getScene(bindableElement)!
|
||||
.getElements()
|
||||
.map((element) => {
|
||||
if (!isBindingElement(element)) {
|
||||
if (!isBindingElement(element, false)) {
|
||||
return null;
|
||||
}
|
||||
const canBindStart = isLinearElementEligibleForNewBindingByBindable(
|
||||
@@ -659,28 +660,47 @@ export const fixBindingsAfterDeletion = (
|
||||
const deletedElementIds = new Set(
|
||||
deletedElements.map((element) => element.id),
|
||||
);
|
||||
// Non deleted and need an update
|
||||
const boundElementIds: Set<ExcalidrawElement["id"]> = new Set();
|
||||
// non-deleted which bindings need to be updated
|
||||
const affectedElements: Set<ExcalidrawElement["id"]> = new Set();
|
||||
deletedElements.forEach((deletedElement) => {
|
||||
if (isBindableElement(deletedElement)) {
|
||||
deletedElement.boundElements?.forEach((element) => {
|
||||
if (!deletedElementIds.has(element.id)) {
|
||||
boundElementIds.add(element.id);
|
||||
affectedElements.add(element.id);
|
||||
}
|
||||
});
|
||||
} else if (isBindingElement(deletedElement)) {
|
||||
if (deletedElement.startBinding) {
|
||||
affectedElements.add(deletedElement.startBinding.elementId);
|
||||
}
|
||||
if (deletedElement.endBinding) {
|
||||
affectedElements.add(deletedElement.endBinding.elementId);
|
||||
}
|
||||
}
|
||||
});
|
||||
(
|
||||
sceneElements.filter(({ id }) =>
|
||||
boundElementIds.has(id),
|
||||
) as ExcalidrawLinearElement[]
|
||||
).forEach((element: ExcalidrawLinearElement) => {
|
||||
const { startBinding, endBinding } = element;
|
||||
mutateElement(element, {
|
||||
startBinding: newBindingAfterDeletion(startBinding, deletedElementIds),
|
||||
endBinding: newBindingAfterDeletion(endBinding, deletedElementIds),
|
||||
sceneElements
|
||||
.filter(({ id }) => affectedElements.has(id))
|
||||
.forEach((element) => {
|
||||
if (isBindableElement(element)) {
|
||||
mutateElement(element, {
|
||||
boundElements: newBoundElementsAfterDeletion(
|
||||
element.boundElements,
|
||||
deletedElementIds,
|
||||
),
|
||||
});
|
||||
} else if (isBindingElement(element)) {
|
||||
mutateElement(element, {
|
||||
startBinding: newBindingAfterDeletion(
|
||||
element.startBinding,
|
||||
deletedElementIds,
|
||||
),
|
||||
endBinding: newBindingAfterDeletion(
|
||||
element.endBinding,
|
||||
deletedElementIds,
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const newBindingAfterDeletion = (
|
||||
@@ -692,3 +712,13 @@ const newBindingAfterDeletion = (
|
||||
}
|
||||
return binding;
|
||||
};
|
||||
|
||||
const newBoundElementsAfterDeletion = (
|
||||
boundElements: ExcalidrawElement["boundElements"],
|
||||
deletedElementIds: Set<ExcalidrawElement["id"]>,
|
||||
) => {
|
||||
if (!boundElements) {
|
||||
return null;
|
||||
}
|
||||
return boundElements.filter((ele) => !deletedElementIds.has(ele.id));
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { SHAPES } from "../shapes";
|
||||
import { updateBoundElements } from "./binding";
|
||||
import { getCommonBounds } from "./bounds";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
@@ -93,7 +92,7 @@ export const getDragOffsetXY = (
|
||||
|
||||
export const dragNewElement = (
|
||||
draggingElement: NonDeletedExcalidrawElement,
|
||||
elementType: typeof SHAPES[number]["value"],
|
||||
elementType: AppState["activeTool"]["type"],
|
||||
originX: number,
|
||||
originY: number,
|
||||
x: number,
|
||||
|
||||
@@ -106,6 +106,20 @@ export const normalizeSVG = async (SVGString: string) => {
|
||||
svg.setAttribute("xmlns", SVG_NS);
|
||||
}
|
||||
|
||||
if (!svg.hasAttribute("width") || !svg.hasAttribute("height")) {
|
||||
const viewBox = svg.getAttribute("viewBox");
|
||||
let width = svg.getAttribute("width") || "50";
|
||||
let height = svg.getAttribute("height") || "50";
|
||||
if (viewBox) {
|
||||
const match = viewBox.match(/\d+ +\d+ +(\d+) +(\d+)/);
|
||||
if (match) {
|
||||
[, width, height] = match;
|
||||
}
|
||||
}
|
||||
svg.setAttribute("width", width);
|
||||
svg.setAttribute("height", height);
|
||||
}
|
||||
|
||||
return svg.outerHTML;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -205,7 +205,7 @@ export class LinearElementEditor {
|
||||
);
|
||||
|
||||
// suggest bindings for first and last point if selected
|
||||
if (isBindingElement(element)) {
|
||||
if (isBindingElement(element, false)) {
|
||||
const coords: { x: number; y: number }[] = [];
|
||||
|
||||
const firstSelectedIndex = selectedPointsIndices[0];
|
||||
|
||||
@@ -2,11 +2,7 @@ import { duplicateElement } from "./newElement";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { FONT_FAMILY } from "../constants";
|
||||
|
||||
const isPrimitive = (val: any) => {
|
||||
const type = typeof val;
|
||||
return val == null || (type !== "object" && type !== "function");
|
||||
};
|
||||
import { isPrimitive } from "../utils";
|
||||
|
||||
const assertCloneObjects = (source: any, clone: any) => {
|
||||
for (const key in clone) {
|
||||
|
||||
@@ -56,6 +56,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||
strokeSharpness,
|
||||
boundElements = null,
|
||||
link = null,
|
||||
locked,
|
||||
...rest
|
||||
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
|
||||
) => {
|
||||
@@ -83,6 +84,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||
boundElements,
|
||||
updated: getUpdatedTimestamp(),
|
||||
link,
|
||||
locked,
|
||||
};
|
||||
return element;
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ExcalidrawTextElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeleted,
|
||||
ExcalidrawElement,
|
||||
} from "./types";
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
@@ -186,7 +187,7 @@ const validateTwoPointElementNormalized = (
|
||||
};
|
||||
|
||||
const getPerfectElementSizeWithRotation = (
|
||||
elementType: string,
|
||||
elementType: ExcalidrawElement["type"],
|
||||
width: number,
|
||||
height: number,
|
||||
angle: number,
|
||||
|
||||
@@ -10,5 +10,6 @@ export const showSelectedShapeActions = (
|
||||
!appState.viewModeEnabled &&
|
||||
(appState.editingElement ||
|
||||
getSelectedElements(elements, appState).length ||
|
||||
appState.elementType !== "selection"),
|
||||
(appState.activeTool.type !== "selection" &&
|
||||
appState.activeTool.type !== "eraser")),
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ExcalidrawElement } from "./types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
|
||||
import { SHIFT_LOCKING_ANGLE } from "../constants";
|
||||
import { AppState } from "../types";
|
||||
|
||||
export const isInvisiblySmallElement = (
|
||||
element: ExcalidrawElement,
|
||||
@@ -16,7 +17,7 @@ export const isInvisiblySmallElement = (
|
||||
* Makes a perfect shape or diagonal/horizontal/vertical line
|
||||
*/
|
||||
export const getPerfectElementSize = (
|
||||
elementType: string,
|
||||
elementType: AppState["activeTool"]["type"],
|
||||
width: number,
|
||||
height: number,
|
||||
): { width: number; height: number } => {
|
||||
|
||||
@@ -10,13 +10,11 @@ import { mutateElement } from "./mutateElement";
|
||||
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
|
||||
import { MaybeTransformHandleType } from "./transformHandles";
|
||||
import Scene from "../scene/Scene";
|
||||
import { AppState } from "../types";
|
||||
import { isTextElement } from ".";
|
||||
|
||||
export const redrawTextBoundingBox = (
|
||||
element: ExcalidrawTextElement,
|
||||
container: ExcalidrawElement | null,
|
||||
appState: AppState,
|
||||
) => {
|
||||
const maxWidth = container
|
||||
? container.width - BOUND_TEXT_PADDING * 2
|
||||
@@ -35,12 +33,12 @@ export const redrawTextBoundingBox = (
|
||||
getFontString(element),
|
||||
maxWidth,
|
||||
);
|
||||
|
||||
let coordY = element.y;
|
||||
let coordX = element.x;
|
||||
// Resize container and vertically center align the text
|
||||
if (container) {
|
||||
let nextHeight = container.height;
|
||||
|
||||
coordX = container.x + BOUND_TEXT_PADDING;
|
||||
if (element.verticalAlign === VERTICAL_ALIGN.TOP) {
|
||||
coordY = container.y + BOUND_TEXT_PADDING;
|
||||
} else if (element.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
@@ -55,12 +53,12 @@ export const redrawTextBoundingBox = (
|
||||
}
|
||||
mutateElement(container, { height: nextHeight });
|
||||
}
|
||||
|
||||
mutateElement(element, {
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
baseline: metrics.baseline,
|
||||
y: coordY,
|
||||
x: coordX,
|
||||
text,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -704,7 +704,7 @@ describe("textWysiwyg", () => {
|
||||
expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2);
|
||||
});
|
||||
|
||||
it("should unbind bound text when unbind action from context menu is triggred", async () => {
|
||||
it("should unbind bound text when unbind action from context menu is triggered", async () => {
|
||||
expect(h.elements.length).toBe(1);
|
||||
expect(h.elements[0].id).toBe(rectangle.id);
|
||||
|
||||
@@ -745,5 +745,47 @@ describe("textWysiwyg", () => {
|
||||
null,
|
||||
);
|
||||
});
|
||||
it("shouldn't bind to container if container has bound text", async () => {
|
||||
expect(h.elements.length).toBe(1);
|
||||
|
||||
Keyboard.withModifierKeys({}, () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
});
|
||||
|
||||
expect(h.elements.length).toBe(2);
|
||||
|
||||
// Bind first text
|
||||
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
let editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: text.id, type: "text" },
|
||||
]);
|
||||
|
||||
// Attempt to bind another text
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(
|
||||
rectangle.x + rectangle.width / 2,
|
||||
rectangle.y + rectangle.height / 2,
|
||||
);
|
||||
mouse.down();
|
||||
expect(h.elements.length).toBe(3);
|
||||
text = h.elements[2] as ExcalidrawTextElementWithContainer;
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Whats up?" } });
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: h.elements[1].id, type: "text" },
|
||||
]);
|
||||
expect(text.containerId).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,9 +102,11 @@ export const textWysiwyg = ({
|
||||
|
||||
const updateWysiwygStyle = () => {
|
||||
const appState = app.state;
|
||||
const updatedElement = Scene.getScene(element)?.getElement(
|
||||
id,
|
||||
) as ExcalidrawTextElement;
|
||||
const updatedElement =
|
||||
Scene.getScene(element)?.getElement<ExcalidrawTextElement>(id);
|
||||
if (!updatedElement) {
|
||||
return;
|
||||
}
|
||||
const { textAlign, verticalAlign } = updatedElement;
|
||||
|
||||
const approxLineHeight = getApproxLineHeight(getFontString(updatedElement));
|
||||
|
||||
@@ -222,6 +222,13 @@ export const getTransformHandles = (
|
||||
zoom: Zoom,
|
||||
pointerType: PointerType = "mouse",
|
||||
): TransformHandles => {
|
||||
// so that when locked element is selected (especially when you toggle lock
|
||||
// via keyboard) the locked element is visually distinct, indicating
|
||||
// you can't move/resize
|
||||
if (element.locked) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let omitSides: { [T in TransformHandleType]?: boolean } = {};
|
||||
if (
|
||||
element.type === "arrow" ||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AppState } from "../types";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
@@ -60,7 +61,7 @@ export const isLinearElement = (
|
||||
};
|
||||
|
||||
export const isLinearElementType = (
|
||||
elementType: ExcalidrawElement["type"],
|
||||
elementType: AppState["activeTool"]["type"],
|
||||
): boolean => {
|
||||
return (
|
||||
elementType === "arrow" || elementType === "line" // || elementType === "freedraw"
|
||||
@@ -69,21 +70,28 @@ export const isLinearElementType = (
|
||||
|
||||
export const isBindingElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
includeLocked = true,
|
||||
): element is ExcalidrawLinearElement => {
|
||||
return element != null && isBindingElementType(element.type);
|
||||
return (
|
||||
element != null &&
|
||||
(!element.locked || includeLocked === true) &&
|
||||
isBindingElementType(element.type)
|
||||
);
|
||||
};
|
||||
|
||||
export const isBindingElementType = (
|
||||
elementType: ExcalidrawElement["type"],
|
||||
elementType: AppState["activeTool"]["type"],
|
||||
): boolean => {
|
||||
return elementType === "arrow";
|
||||
};
|
||||
|
||||
export const isBindableElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
includeLocked = true,
|
||||
): element is ExcalidrawBindableElement => {
|
||||
return (
|
||||
element != null &&
|
||||
(!element.locked || includeLocked === true) &&
|
||||
(element.type === "rectangle" ||
|
||||
element.type === "diamond" ||
|
||||
element.type === "ellipse" ||
|
||||
@@ -94,9 +102,11 @@ export const isBindableElement = (
|
||||
|
||||
export const isTextBindableContainer = (
|
||||
element: ExcalidrawElement | null,
|
||||
includeLocked = true,
|
||||
): element is ExcalidrawTextContainer => {
|
||||
return (
|
||||
element != null &&
|
||||
(!element.locked || includeLocked === true) &&
|
||||
(element.type === "rectangle" ||
|
||||
element.type === "diamond" ||
|
||||
element.type === "ellipse" ||
|
||||
|
||||
@@ -55,6 +55,7 @@ type _ExcalidrawElementBase = Readonly<{
|
||||
/** epoch (ms) timestamp of last element update */
|
||||
updated: number;
|
||||
link: string | null;
|
||||
locked: boolean;
|
||||
}>;
|
||||
|
||||
export type ExcalidrawSelectionElement = _ExcalidrawElementBase & {
|
||||
|
||||
@@ -11,12 +11,12 @@ export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
|
||||
// 1 year (https://stackoverflow.com/a/25201898/927631)
|
||||
export const FILE_CACHE_MAX_AGE_SEC = 31536000;
|
||||
|
||||
export const BROADCAST = {
|
||||
export const WS_EVENTS = {
|
||||
SERVER_VOLATILE: "server-volatile-broadcast",
|
||||
SERVER: "server-broadcast",
|
||||
};
|
||||
|
||||
export enum SCENE {
|
||||
export enum WS_SCENE_EVENT_TYPES {
|
||||
INIT = "SCENE_INIT",
|
||||
UPDATE = "SCENE_UPDATE",
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { getSceneVersion } from "../../packages/excalidraw/index";
|
||||
import { Collaborator, Gesture } from "../../types";
|
||||
import {
|
||||
getFrame,
|
||||
preventUnload,
|
||||
resolvablePromise,
|
||||
withBatchedUpdates,
|
||||
@@ -21,7 +22,7 @@ import {
|
||||
FIREBASE_STORAGE_PREFIXES,
|
||||
INITIAL_SCENE_UPDATE_TIMEOUT,
|
||||
LOAD_IMAGES_TIMEOUT,
|
||||
SCENE,
|
||||
WS_SCENE_EVENT_TYPES,
|
||||
STORAGE_KEYS,
|
||||
SYNC_FULL_SCENE_INTERVAL_MS,
|
||||
} from "../app_constants";
|
||||
@@ -67,6 +68,7 @@ import {
|
||||
} from "./reconciliation";
|
||||
import { decryptData } from "../../data/encryption";
|
||||
import { resetBrowserStateVersions } from "../data/tabSync";
|
||||
import { LocalData } from "../data/LocalData";
|
||||
|
||||
interface CollabState {
|
||||
modalIsShown: boolean;
|
||||
@@ -86,7 +88,7 @@ export interface CollabAPI {
|
||||
onPointerUpdate: CollabInstance["onPointerUpdate"];
|
||||
initializeSocketClient: CollabInstance["initializeSocketClient"];
|
||||
onCollabButtonClick: CollabInstance["onCollabButtonClick"];
|
||||
broadcastElements: CollabInstance["broadcastElements"];
|
||||
syncElements: CollabInstance["syncElements"];
|
||||
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
|
||||
setUsername: (username: string) => void;
|
||||
}
|
||||
@@ -108,10 +110,11 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
portal: Portal;
|
||||
fileManager: FileManager;
|
||||
excalidrawAPI: Props["excalidrawAPI"];
|
||||
isCollaborating: boolean = false;
|
||||
activeIntervalId: number | null;
|
||||
idleTimeoutId: number | null;
|
||||
|
||||
// marked as private to ensure we don't change it outside this class
|
||||
private _isCollaborating: boolean = false;
|
||||
private socketInitializationTimer?: number;
|
||||
private lastBroadcastedOrReceivedSceneVersion: number = -1;
|
||||
private collaborators = new Map<string, Collaborator>();
|
||||
@@ -192,6 +195,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
}
|
||||
}
|
||||
|
||||
isCollaborating = () => this._isCollaborating;
|
||||
|
||||
private onUnload = () => {
|
||||
this.destroySocketClient({ isUnload: true });
|
||||
};
|
||||
@@ -202,7 +207,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
);
|
||||
|
||||
if (
|
||||
this.isCollaborating &&
|
||||
this._isCollaborating &&
|
||||
(this.fileManager.shouldPreventUnload(syncableElements) ||
|
||||
!isSavedToFirebase(this.portal, syncableElements))
|
||||
) {
|
||||
@@ -227,27 +232,40 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
});
|
||||
|
||||
saveCollabRoomToFirebase = async (
|
||||
syncableElements: readonly ExcalidrawElement[] = this.getSyncableElements(
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
),
|
||||
syncableElements: readonly ExcalidrawElement[],
|
||||
) => {
|
||||
try {
|
||||
await saveToFirebase(this.portal, syncableElements);
|
||||
const savedData = await saveToFirebase(
|
||||
this.portal,
|
||||
syncableElements,
|
||||
this.excalidrawAPI.getAppState(),
|
||||
);
|
||||
|
||||
if (this.isCollaborating() && savedData && savedData.reconciledElements) {
|
||||
this.handleRemoteSceneUpdate(
|
||||
this.reconcileElements(savedData.reconciledElements),
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
openPortal = async () => {
|
||||
trackEvent("share", "room creation");
|
||||
trackEvent("share", "room creation", `ui (${getFrame()})`);
|
||||
return this.initializeSocketClient(null);
|
||||
};
|
||||
|
||||
closePortal = () => {
|
||||
this.queueBroadcastAllElements.cancel();
|
||||
this.queueSaveToFirebase.cancel();
|
||||
this.loadImageFiles.cancel();
|
||||
|
||||
this.saveCollabRoomToFirebase();
|
||||
this.saveCollabRoomToFirebase(
|
||||
this.getSyncableElements(
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
),
|
||||
);
|
||||
if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
|
||||
// hack to ensure that we prefer we disregard any new browser state
|
||||
// that could have been saved in other tabs while we were collaborating
|
||||
@@ -284,7 +302,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
this.setState({
|
||||
activeRoomLink: "",
|
||||
});
|
||||
this.isCollaborating = false;
|
||||
this._isCollaborating = false;
|
||||
LocalData.resumeSave("collaboration");
|
||||
}
|
||||
this.lastBroadcastedOrReceivedSceneVersion = -1;
|
||||
this.portal.close();
|
||||
@@ -352,7 +371,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
|
||||
const scenePromise = resolvablePromise<ImportedDataState | null>();
|
||||
|
||||
this.isCollaborating = true;
|
||||
this._isCollaborating = true;
|
||||
LocalData.pauseSave("collaboration");
|
||||
|
||||
const { default: socketIOClient } = await import(
|
||||
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
|
||||
@@ -393,10 +413,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
commitToHistory: true,
|
||||
});
|
||||
|
||||
this.broadcastElements(elements);
|
||||
|
||||
const syncableElements = this.getSyncableElements(elements);
|
||||
this.saveCollabRoomToFirebase(syncableElements);
|
||||
this.saveCollabRoomToFirebase(this.getSyncableElements(elements));
|
||||
}
|
||||
|
||||
// fallback in case you're not alone in the room but still don't receive
|
||||
@@ -426,7 +443,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
switch (decryptedData.type) {
|
||||
case "INVALID_RESPONSE":
|
||||
return;
|
||||
case SCENE.INIT: {
|
||||
case WS_SCENE_EVENT_TYPES.INIT: {
|
||||
if (!this.portal.socketInitialized) {
|
||||
this.initializeRoom({ fetchScene: false });
|
||||
const remoteElements = decryptedData.payload.elements;
|
||||
@@ -442,7 +459,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SCENE.UPDATE:
|
||||
case WS_SCENE_EVENT_TYPES.UPDATE:
|
||||
this.handleRemoteSceneUpdate(
|
||||
this.reconcileElements(decryptedData.payload.elements),
|
||||
);
|
||||
@@ -704,15 +721,20 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
getSceneVersion(elements) >
|
||||
this.getLastBroadcastedOrReceivedSceneVersion()
|
||||
) {
|
||||
this.portal.broadcastScene(SCENE.UPDATE, elements, false);
|
||||
this.portal.broadcastScene(WS_SCENE_EVENT_TYPES.UPDATE, elements, false);
|
||||
this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);
|
||||
this.queueBroadcastAllElements();
|
||||
}
|
||||
};
|
||||
|
||||
syncElements = (elements: readonly ExcalidrawElement[]) => {
|
||||
this.broadcastElements(elements);
|
||||
this.queueSaveToFirebase();
|
||||
};
|
||||
|
||||
queueBroadcastAllElements = throttle(() => {
|
||||
this.portal.broadcastScene(
|
||||
SCENE.UPDATE,
|
||||
WS_SCENE_EVENT_TYPES.UPDATE,
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
true,
|
||||
);
|
||||
@@ -724,6 +746,16 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
this.setLastBroadcastedOrReceivedSceneVersion(newVersion);
|
||||
}, SYNC_FULL_SCENE_INTERVAL_MS);
|
||||
|
||||
queueSaveToFirebase = throttle(() => {
|
||||
if (this.portal.socketInitialized) {
|
||||
this.saveCollabRoomToFirebase(
|
||||
this.getSyncableElements(
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}, SYNC_FULL_SCENE_INTERVAL_MS);
|
||||
|
||||
handleClose = () => {
|
||||
this.setState({ modalIsShown: false });
|
||||
};
|
||||
@@ -759,12 +791,12 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
this.contextValue = {} as CollabAPI;
|
||||
}
|
||||
|
||||
this.contextValue.isCollaborating = () => this.isCollaborating;
|
||||
this.contextValue.isCollaborating = this.isCollaborating;
|
||||
this.contextValue.username = this.state.username;
|
||||
this.contextValue.onPointerUpdate = this.onPointerUpdate;
|
||||
this.contextValue.initializeSocketClient = this.initializeSocketClient;
|
||||
this.contextValue.onCollabButtonClick = this.onCollabButtonClick;
|
||||
this.contextValue.broadcastElements = this.broadcastElements;
|
||||
this.contextValue.syncElements = this.syncElements;
|
||||
this.contextValue.fetchImageFilesFromFirebase =
|
||||
this.fetchImageFilesFromFirebase;
|
||||
this.contextValue.setUsername = this.setUsername;
|
||||
|
||||
@@ -3,7 +3,11 @@ import { SocketUpdateData, SocketUpdateDataSource } from "../data";
|
||||
import CollabWrapper from "./CollabWrapper";
|
||||
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import { BROADCAST, FILE_UPLOAD_TIMEOUT, SCENE } from "../app_constants";
|
||||
import {
|
||||
WS_EVENTS,
|
||||
FILE_UPLOAD_TIMEOUT,
|
||||
WS_SCENE_EVENT_TYPES,
|
||||
} from "../app_constants";
|
||||
import { UserIdleState } from "../../types";
|
||||
import { trackEvent } from "../../analytics";
|
||||
import { throttle } from "lodash";
|
||||
@@ -37,7 +41,7 @@ class Portal {
|
||||
});
|
||||
this.socket.on("new-user", async (_socketId: string) => {
|
||||
this.broadcastScene(
|
||||
SCENE.INIT,
|
||||
WS_SCENE_EVENT_TYPES.INIT,
|
||||
this.collab.getSceneElementsIncludingDeleted(),
|
||||
/* syncAll */ true,
|
||||
);
|
||||
@@ -81,7 +85,7 @@ class Portal {
|
||||
const { encryptedBuffer, iv } = await encryptData(this.roomKey!, encoded);
|
||||
|
||||
this.socket?.emit(
|
||||
volatile ? BROADCAST.SERVER_VOLATILE : BROADCAST.SERVER,
|
||||
volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER,
|
||||
this.roomId,
|
||||
encryptedBuffer,
|
||||
iv,
|
||||
@@ -121,11 +125,11 @@ class Portal {
|
||||
}, FILE_UPLOAD_TIMEOUT);
|
||||
|
||||
broadcastScene = async (
|
||||
sceneType: SCENE.INIT | SCENE.UPDATE,
|
||||
updateType: WS_SCENE_EVENT_TYPES.INIT | WS_SCENE_EVENT_TYPES.UPDATE,
|
||||
allElements: readonly ExcalidrawElement[],
|
||||
syncAll: boolean,
|
||||
) => {
|
||||
if (sceneType === SCENE.INIT && !syncAll) {
|
||||
if (updateType === WS_SCENE_EVENT_TYPES.INIT && !syncAll) {
|
||||
throw new Error("syncAll must be true when sending SCENE.INIT");
|
||||
}
|
||||
|
||||
@@ -152,8 +156,8 @@ class Portal {
|
||||
[] as BroadcastedExcalidrawElement[],
|
||||
);
|
||||
|
||||
const data: SocketUpdateDataSource[typeof sceneType] = {
|
||||
type: sceneType,
|
||||
const data: SocketUpdateDataSource[typeof updateType] = {
|
||||
type: updateType,
|
||||
payload: {
|
||||
elements: syncableElements,
|
||||
},
|
||||
@@ -166,20 +170,9 @@ class Portal {
|
||||
);
|
||||
}
|
||||
|
||||
const broadcastPromise = this._broadcastSocketData(
|
||||
data as SocketUpdateData,
|
||||
);
|
||||
|
||||
this.queueFileUpload();
|
||||
|
||||
if (syncAll && this.collab.isCollaborating) {
|
||||
await Promise.all([
|
||||
broadcastPromise,
|
||||
this.collab.saveCollabRoomToFirebase(syncableElements),
|
||||
]);
|
||||
} else {
|
||||
await broadcastPromise;
|
||||
}
|
||||
await this._broadcastSocketData(data as SocketUpdateData);
|
||||
};
|
||||
|
||||
broadcastIdleChange = (userState: UserIdleState) => {
|
||||
|
||||
@@ -78,8 +78,14 @@ export const reconcileElements = (
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mark duplicate for removal as it'll be replaced with the remote element
|
||||
if (local) {
|
||||
// mark for removal since it'll be replaced with the remote element
|
||||
// Unless the ramote and local elements are the same element in which case
|
||||
// we need to keep it as we'd otherwise discard it from the resulting
|
||||
// array.
|
||||
if (local[0] === remoteElement) {
|
||||
continue;
|
||||
}
|
||||
duplicates.set(local[0], true);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ import { isInitializedImageElement } from "../../element/typeChecks";
|
||||
import { FILE_UPLOAD_MAX_BYTES } from "../app_constants";
|
||||
import { encodeFilesForUpload } from "../data/FileManager";
|
||||
import { MIME_TYPES } from "../../constants";
|
||||
import { trackEvent } from "../../analytics";
|
||||
import { getFrame } from "../../utils";
|
||||
|
||||
const exportToExcalidrawPlus = async (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
@@ -92,6 +94,7 @@ export const ExportToExcalidrawPlus: React.FC<{
|
||||
showAriaLabel={true}
|
||||
onClick={async () => {
|
||||
try {
|
||||
trackEvent("export", "eplus", `ui (${getFrame()})`);
|
||||
await exportToExcalidrawPlus(elements, appState, files);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* This file deals with saving data state (appState, elements, images, ...)
|
||||
* locally to the browser.
|
||||
*
|
||||
* Notes:
|
||||
*
|
||||
* - DataState refers to full state of the app: appState, elements, images,
|
||||
* though some state is saved separately (collab username, library) for one
|
||||
* reason or another. We also save different data to different sotrage
|
||||
* (localStorage, indexedDB).
|
||||
*/
|
||||
|
||||
import { createStore, keys, del, getMany, set } from "idb-keyval";
|
||||
import { clearAppStateForLocalStorage } from "../../appState";
|
||||
import { clearElementsForLocalStorage } from "../../element";
|
||||
import { ExcalidrawElement, FileId } from "../../element/types";
|
||||
import { AppState, BinaryFileData, BinaryFiles } from "../../types";
|
||||
import { debounce } from "../../utils";
|
||||
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
|
||||
import { FileManager } from "./FileManager";
|
||||
import { Locker } from "./Locker";
|
||||
import { updateBrowserStateVersion } from "./tabSync";
|
||||
|
||||
const filesStore = createStore("files-db", "files-store");
|
||||
|
||||
class LocalFileManager extends FileManager {
|
||||
clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
|
||||
const allIds = await keys(filesStore);
|
||||
for (const id of allIds) {
|
||||
if (!opts.currentFileIds.includes(id as FileId)) {
|
||||
del(id, filesStore);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const saveDataStateToLocalStorage = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||
JSON.stringify(clearElementsForLocalStorage(elements)),
|
||||
);
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
|
||||
JSON.stringify(clearAppStateForLocalStorage(appState)),
|
||||
);
|
||||
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
|
||||
} catch (error: any) {
|
||||
// Unable to access window.localStorage
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
type SavingLockTypes = "collaboration";
|
||||
|
||||
export class LocalData {
|
||||
private static _save = debounce(
|
||||
async (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
onFilesSaved: () => void,
|
||||
) => {
|
||||
saveDataStateToLocalStorage(elements, appState);
|
||||
|
||||
await this.fileStorage.saveFiles({
|
||||
elements,
|
||||
files,
|
||||
});
|
||||
onFilesSaved();
|
||||
},
|
||||
SAVE_TO_LOCAL_STORAGE_TIMEOUT,
|
||||
);
|
||||
|
||||
/** Saves DataState, including files. Bails if saving is paused */
|
||||
static save = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
onFilesSaved: () => void,
|
||||
) => {
|
||||
// we need to make the `isSavePaused` check synchronously (undebounced)
|
||||
if (!this.isSavePaused()) {
|
||||
this._save(elements, appState, files, onFilesSaved);
|
||||
}
|
||||
};
|
||||
|
||||
static flushSave = () => {
|
||||
this._save.flush();
|
||||
};
|
||||
|
||||
private static locker = new Locker<SavingLockTypes>();
|
||||
|
||||
static pauseSave = (lockType: SavingLockTypes) => {
|
||||
this.locker.lock(lockType);
|
||||
};
|
||||
|
||||
static resumeSave = (lockType: SavingLockTypes) => {
|
||||
this.locker.unlock(lockType);
|
||||
};
|
||||
|
||||
static isSavePaused = () => {
|
||||
return document.hidden || this.locker.isLocked();
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static fileStorage = new LocalFileManager({
|
||||
getFiles(ids) {
|
||||
return getMany(ids, filesStore).then(
|
||||
(filesData: (BinaryFileData | undefined)[]) => {
|
||||
const loadedFiles: BinaryFileData[] = [];
|
||||
const erroredFiles = new Map<FileId, true>();
|
||||
filesData.forEach((data, index) => {
|
||||
const id = ids[index];
|
||||
if (data) {
|
||||
loadedFiles.push(data);
|
||||
} else {
|
||||
erroredFiles.set(id, true);
|
||||
}
|
||||
});
|
||||
|
||||
return { loadedFiles, erroredFiles };
|
||||
},
|
||||
);
|
||||
},
|
||||
async saveFiles({ addedFiles }) {
|
||||
const savedFiles = new Map<FileId, true>();
|
||||
const erroredFiles = new Map<FileId, true>();
|
||||
|
||||
// before we use `storage` event synchronization, let's update the flag
|
||||
// optimistically. Hopefully nothing fails, and an IDB read executed
|
||||
// before an IDB write finishes will read the latest value.
|
||||
updateBrowserStateVersion(STORAGE_KEYS.VERSION_FILES);
|
||||
|
||||
await Promise.all(
|
||||
[...addedFiles].map(async ([id, fileData]) => {
|
||||
try {
|
||||
await set(id, fileData, filesStore);
|
||||
savedFiles.set(id, true);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
erroredFiles.set(id, true);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return { savedFiles, erroredFiles };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export class Locker<T extends string> {
|
||||
private locks = new Map<T, true>();
|
||||
|
||||
lock = (lockType: T) => {
|
||||
this.locks.set(lockType, true);
|
||||
};
|
||||
|
||||
/** @returns whether no locks remaining */
|
||||
unlock = (lockType: T) => {
|
||||
this.locks.delete(lockType);
|
||||
return !this.isLocked();
|
||||
};
|
||||
|
||||
/** @returns whether some (or specific) locks are present */
|
||||
isLocked(lockType?: T) {
|
||||
return lockType ? this.locks.has(lockType) : !!this.locks.size;
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,17 @@ import { ExcalidrawElement, FileId } from "../../element/types";
|
||||
import { getSceneVersion } from "../../element";
|
||||
import Portal from "../collab/Portal";
|
||||
import { restoreElements } from "../../data/restore";
|
||||
import { BinaryFileData, BinaryFileMetadata, DataURL } from "../../types";
|
||||
import {
|
||||
AppState,
|
||||
BinaryFileData,
|
||||
BinaryFileMetadata,
|
||||
DataURL,
|
||||
} from "../../types";
|
||||
import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
|
||||
import { decompressData } from "../../data/encode";
|
||||
import { encryptData, decryptData } from "../../data/encryption";
|
||||
import { MIME_TYPES } from "../../constants";
|
||||
import { reconcileElements } from "../collab/reconciliation";
|
||||
|
||||
// private
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -108,11 +114,13 @@ const encryptElements = async (
|
||||
};
|
||||
|
||||
const decryptElements = async (
|
||||
key: string,
|
||||
iv: Uint8Array,
|
||||
ciphertext: ArrayBuffer | Uint8Array,
|
||||
data: FirebaseStoredScene,
|
||||
roomKey: string,
|
||||
): Promise<readonly ExcalidrawElement[]> => {
|
||||
const decrypted = await decryptData(iv, ciphertext, key);
|
||||
const ciphertext = data.ciphertext.toUint8Array();
|
||||
const iv = data.iv.toUint8Array();
|
||||
|
||||
const decrypted = await decryptData(iv, ciphertext, roomKey);
|
||||
const decodedData = new TextDecoder("utf-8").decode(
|
||||
new Uint8Array(decrypted),
|
||||
);
|
||||
@@ -171,57 +179,86 @@ export const saveFilesToFirebase = async ({
|
||||
return { savedFiles, erroredFiles };
|
||||
};
|
||||
|
||||
export const saveToFirebase = async (
|
||||
portal: Portal,
|
||||
const createFirebaseSceneDocument = async (
|
||||
firebase: ResolutionType<typeof loadFirestore>,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
roomKey: string,
|
||||
) => {
|
||||
const { roomId, roomKey, socket } = portal;
|
||||
if (
|
||||
// if no room exists, consider the room saved because there's nothing we can
|
||||
// do at this point
|
||||
!roomId ||
|
||||
!roomKey ||
|
||||
!socket ||
|
||||
isSavedToFirebase(portal, elements)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const firebase = await loadFirestore();
|
||||
const sceneVersion = getSceneVersion(elements);
|
||||
const { ciphertext, iv } = await encryptElements(roomKey, elements);
|
||||
|
||||
const nextDocData = {
|
||||
return {
|
||||
sceneVersion,
|
||||
ciphertext: firebase.firestore.Blob.fromUint8Array(
|
||||
new Uint8Array(ciphertext),
|
||||
),
|
||||
iv: firebase.firestore.Blob.fromUint8Array(iv),
|
||||
} as FirebaseStoredScene;
|
||||
};
|
||||
|
||||
const db = firebase.firestore();
|
||||
const docRef = db.collection("scenes").doc(roomId);
|
||||
const didUpdate = await db.runTransaction(async (transaction) => {
|
||||
const doc = await transaction.get(docRef);
|
||||
if (!doc.exists) {
|
||||
transaction.set(docRef, nextDocData);
|
||||
return true;
|
||||
}
|
||||
|
||||
const prevDocData = doc.data() as FirebaseStoredScene;
|
||||
if (prevDocData.sceneVersion >= nextDocData.sceneVersion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
transaction.update(docRef, nextDocData);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (didUpdate) {
|
||||
firebaseSceneVersionCache.set(socket, sceneVersion);
|
||||
export const saveToFirebase = async (
|
||||
portal: Portal,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
const { roomId, roomKey, socket } = portal;
|
||||
if (
|
||||
// bail if no room exists as there's nothing we can do at this point
|
||||
!roomId ||
|
||||
!roomKey ||
|
||||
!socket ||
|
||||
isSavedToFirebase(portal, elements)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return didUpdate;
|
||||
const firebase = await loadFirestore();
|
||||
const firestore = firebase.firestore();
|
||||
|
||||
const docRef = firestore.collection("scenes").doc(roomId);
|
||||
|
||||
const savedData = await firestore.runTransaction(async (transaction) => {
|
||||
const snapshot = await transaction.get(docRef);
|
||||
|
||||
if (!snapshot.exists) {
|
||||
const sceneDocument = await createFirebaseSceneDocument(
|
||||
firebase,
|
||||
elements,
|
||||
roomKey,
|
||||
);
|
||||
|
||||
transaction.set(docRef, sceneDocument);
|
||||
|
||||
return {
|
||||
sceneVersion: sceneDocument.sceneVersion,
|
||||
reconciledElements: null,
|
||||
};
|
||||
}
|
||||
|
||||
const prevDocData = snapshot.data() as FirebaseStoredScene;
|
||||
const prevElements = await decryptElements(prevDocData, roomKey);
|
||||
|
||||
const reconciledElements = reconcileElements(
|
||||
elements,
|
||||
prevElements,
|
||||
appState,
|
||||
);
|
||||
|
||||
const sceneDocument = await createFirebaseSceneDocument(
|
||||
firebase,
|
||||
reconciledElements,
|
||||
roomKey,
|
||||
);
|
||||
|
||||
transaction.update(docRef, sceneDocument);
|
||||
return {
|
||||
reconciledElements,
|
||||
sceneVersion: sceneDocument.sceneVersion,
|
||||
};
|
||||
});
|
||||
|
||||
firebaseSceneVersionCache.set(socket, savedData.sceneVersion);
|
||||
|
||||
return savedData;
|
||||
};
|
||||
|
||||
export const loadFromFirebase = async (
|
||||
@@ -238,10 +275,7 @@ export const loadFromFirebase = async (
|
||||
return null;
|
||||
}
|
||||
const storedScene = doc.data() as FirebaseStoredScene;
|
||||
const ciphertext = storedScene.ciphertext.toUint8Array();
|
||||
const iv = storedScene.iv.toUint8Array();
|
||||
|
||||
const elements = await decryptElements(roomKey, iv, ciphertext);
|
||||
const elements = await decryptElements(storedScene, roomKey);
|
||||
|
||||
if (socket) {
|
||||
firebaseSceneVersionCache.set(socket, getSceneVersion(elements));
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
getDefaultAppState,
|
||||
} from "../../appState";
|
||||
import { clearElementsForLocalStorage } from "../../element";
|
||||
import { updateBrowserStateVersion } from "./tabSync";
|
||||
import { STORAGE_KEYS } from "../app_constants";
|
||||
import { ImportedDataState } from "../../data/types";
|
||||
|
||||
export const saveUsernameToLocalStorage = (username: string) => {
|
||||
try {
|
||||
@@ -34,26 +34,6 @@ export const importUsernameFromLocalStorage = (): string | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export const saveToLocalStorage = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||
JSON.stringify(clearElementsForLocalStorage(elements)),
|
||||
);
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
|
||||
JSON.stringify(clearAppStateForLocalStorage(appState)),
|
||||
);
|
||||
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
|
||||
} catch (error: any) {
|
||||
// Unable to access window.localStorage
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const importFromLocalStorage = () => {
|
||||
let savedElements = null;
|
||||
let savedState = null;
|
||||
@@ -123,14 +103,13 @@ export const getTotalStorageSize = () => {
|
||||
|
||||
export const getLibraryItemsFromStorage = () => {
|
||||
try {
|
||||
const libraryItems =
|
||||
JSON.parse(
|
||||
localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string,
|
||||
) || [];
|
||||
const libraryItems: ImportedDataState["libraryItems"] = JSON.parse(
|
||||
localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string,
|
||||
);
|
||||
|
||||
return libraryItems;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return libraryItems || [];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -26,3 +26,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.excalidraw-app.is-collaborating {
|
||||
[data-testid="clear-canvas-button"] {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
+65
-113
@@ -12,7 +12,6 @@ import {
|
||||
VERSION_TIMEOUT,
|
||||
} from "../constants";
|
||||
import { loadFromBlob } from "../data/blob";
|
||||
import { ImportedDataState } from "../data/types";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
@@ -20,7 +19,8 @@ import {
|
||||
} from "../element/types";
|
||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||
import { Language, t } from "../i18n";
|
||||
import Excalidraw, {
|
||||
import {
|
||||
Excalidraw,
|
||||
defaultLang,
|
||||
languages,
|
||||
} from "../packages/excalidraw/index";
|
||||
@@ -28,12 +28,13 @@ import {
|
||||
AppState,
|
||||
LibraryItems,
|
||||
ExcalidrawImperativeAPI,
|
||||
BinaryFileData,
|
||||
BinaryFiles,
|
||||
ExcalidrawInitialDataState,
|
||||
} from "../types";
|
||||
import {
|
||||
debounce,
|
||||
getVersion,
|
||||
getFrame,
|
||||
isTestEnv,
|
||||
preventUnload,
|
||||
ResolvablePromise,
|
||||
@@ -41,7 +42,6 @@ import {
|
||||
} from "../utils";
|
||||
import {
|
||||
FIREBASE_STORAGE_PREFIXES,
|
||||
SAVE_TO_LOCAL_STORAGE_TIMEOUT,
|
||||
STORAGE_KEYS,
|
||||
SYNC_BROWSER_TABS_TIMEOUT,
|
||||
} from "./app_constants";
|
||||
@@ -56,7 +56,6 @@ import {
|
||||
getLibraryItemsFromStorage,
|
||||
importFromLocalStorage,
|
||||
importUsernameFromLocalStorage,
|
||||
saveToLocalStorage,
|
||||
} from "./data/localStorage";
|
||||
import CustomStats from "./CustomStats";
|
||||
import { restoreAppState, RestoredDataState } from "../data/restore";
|
||||
@@ -66,72 +65,13 @@ import { shield } from "../components/icons";
|
||||
import "./index.scss";
|
||||
import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus";
|
||||
|
||||
import { getMany, set, del, keys, createStore } from "idb-keyval";
|
||||
import { FileManager, updateStaleImageStatuses } from "./data/FileManager";
|
||||
import { updateStaleImageStatuses } from "./data/FileManager";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { isInitializedImageElement } from "../element/typeChecks";
|
||||
import { loadFilesFromFirebase } from "./data/firebase";
|
||||
import {
|
||||
isBrowserStorageStateNewer,
|
||||
updateBrowserStateVersion,
|
||||
} from "./data/tabSync";
|
||||
|
||||
const filesStore = createStore("files-db", "files-store");
|
||||
|
||||
const clearObsoleteFilesFromIndexedDB = async (opts: {
|
||||
currentFileIds: FileId[];
|
||||
}) => {
|
||||
const allIds = await keys(filesStore);
|
||||
for (const id of allIds) {
|
||||
if (!opts.currentFileIds.includes(id as FileId)) {
|
||||
del(id, filesStore);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const localFileStorage = new FileManager({
|
||||
getFiles(ids) {
|
||||
return getMany(ids, filesStore).then(
|
||||
(filesData: (BinaryFileData | undefined)[]) => {
|
||||
const loadedFiles: BinaryFileData[] = [];
|
||||
const erroredFiles = new Map<FileId, true>();
|
||||
filesData.forEach((data, index) => {
|
||||
const id = ids[index];
|
||||
if (data) {
|
||||
loadedFiles.push(data);
|
||||
} else {
|
||||
erroredFiles.set(id, true);
|
||||
}
|
||||
});
|
||||
|
||||
return { loadedFiles, erroredFiles };
|
||||
},
|
||||
);
|
||||
},
|
||||
async saveFiles({ addedFiles }) {
|
||||
const savedFiles = new Map<FileId, true>();
|
||||
const erroredFiles = new Map<FileId, true>();
|
||||
|
||||
// before we use `storage` event synchronization, let's update the flag
|
||||
// optimistically. Hopefully nothing fails, and an IDB read executed
|
||||
// before an IDB write finishes will read the latest value.
|
||||
updateBrowserStateVersion(STORAGE_KEYS.VERSION_FILES);
|
||||
|
||||
await Promise.all(
|
||||
[...addedFiles].map(async ([id, fileData]) => {
|
||||
try {
|
||||
await set(id, fileData, filesStore);
|
||||
savedFiles.set(id, true);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
erroredFiles.set(id, true);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return { savedFiles, erroredFiles };
|
||||
},
|
||||
});
|
||||
import { LocalData } from "./data/LocalData";
|
||||
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
||||
import clsx from "clsx";
|
||||
|
||||
const languageDetector = new LanguageDetector();
|
||||
languageDetector.init({
|
||||
@@ -142,32 +82,10 @@ languageDetector.init({
|
||||
checkWhitelist: false,
|
||||
});
|
||||
|
||||
const saveDebounced = debounce(
|
||||
async (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
onFilesSaved: () => void,
|
||||
) => {
|
||||
saveToLocalStorage(elements, appState);
|
||||
|
||||
await localFileStorage.saveFiles({
|
||||
elements,
|
||||
files,
|
||||
});
|
||||
onFilesSaved();
|
||||
},
|
||||
SAVE_TO_LOCAL_STORAGE_TIMEOUT,
|
||||
);
|
||||
|
||||
const onBlur = () => {
|
||||
saveDebounced.flush();
|
||||
};
|
||||
|
||||
const initializeScene = async (opts: {
|
||||
collabAPI: CollabAPI;
|
||||
}): Promise<
|
||||
{ scene: ImportedDataState | null } & (
|
||||
{ scene: ExcalidrawInitialDataState | null } & (
|
||||
| { isExternalScene: true; id: string; key: string }
|
||||
| { isExternalScene: false; id?: null; key?: null }
|
||||
)
|
||||
@@ -294,14 +212,15 @@ const ExcalidrawWrapper = () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const initialStatePromiseRef = useRef<{
|
||||
promise: ResolvablePromise<ImportedDataState | null>;
|
||||
promise: ResolvablePromise<ExcalidrawInitialDataState | null>;
|
||||
}>({ promise: null! });
|
||||
if (!initialStatePromiseRef.current.promise) {
|
||||
initialStatePromiseRef.current.promise =
|
||||
resolvablePromise<ImportedDataState | null>();
|
||||
resolvablePromise<ExcalidrawInitialDataState | null>();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
trackEvent("load", "frame", getFrame());
|
||||
// Delayed so that the app has a time to load the latest SW
|
||||
setTimeout(() => {
|
||||
trackEvent("load", "version", getVersion());
|
||||
@@ -364,7 +283,7 @@ const ExcalidrawWrapper = () => {
|
||||
});
|
||||
} else if (isInitialLoad) {
|
||||
if (fileIds.length) {
|
||||
localFileStorage
|
||||
LocalData.fileStorage
|
||||
.getFiles(fileIds)
|
||||
.then(({ loadedFiles, erroredFiles }) => {
|
||||
if (loadedFiles.length) {
|
||||
@@ -379,7 +298,7 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
// on fresh load, clear unused files from IDB (from previous
|
||||
// session)
|
||||
clearObsoleteFilesFromIndexedDB({ currentFileIds: fileIds });
|
||||
LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,7 +375,7 @@ const ExcalidrawWrapper = () => {
|
||||
return acc;
|
||||
}, [] as FileId[]) || [];
|
||||
if (fileIds.length) {
|
||||
localFileStorage
|
||||
LocalData.fileStorage
|
||||
.getFiles(fileIds)
|
||||
.then(({ loadedFiles, erroredFiles }) => {
|
||||
if (loadedFiles.length) {
|
||||
@@ -473,28 +392,50 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
}, SYNC_BROWSER_TABS_TIMEOUT);
|
||||
|
||||
const onUnload = () => {
|
||||
LocalData.flushSave();
|
||||
};
|
||||
|
||||
const visibilityChange = (event: FocusEvent | Event) => {
|
||||
if (event.type === EVENT.BLUR || document.hidden) {
|
||||
LocalData.flushSave();
|
||||
}
|
||||
if (
|
||||
event.type === EVENT.VISIBILITY_CHANGE ||
|
||||
event.type === EVENT.FOCUS
|
||||
) {
|
||||
syncData();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(EVENT.HASHCHANGE, onHashChange, false);
|
||||
window.addEventListener(EVENT.UNLOAD, onBlur, false);
|
||||
window.addEventListener(EVENT.BLUR, onBlur, false);
|
||||
document.addEventListener(EVENT.VISIBILITY_CHANGE, syncData, false);
|
||||
window.addEventListener(EVENT.FOCUS, syncData, false);
|
||||
window.addEventListener(EVENT.UNLOAD, onUnload, false);
|
||||
window.addEventListener(EVENT.BLUR, visibilityChange, false);
|
||||
document.addEventListener(EVENT.VISIBILITY_CHANGE, visibilityChange, false);
|
||||
window.addEventListener(EVENT.FOCUS, visibilityChange, false);
|
||||
return () => {
|
||||
window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false);
|
||||
window.removeEventListener(EVENT.UNLOAD, onBlur, false);
|
||||
window.removeEventListener(EVENT.BLUR, onBlur, false);
|
||||
window.removeEventListener(EVENT.FOCUS, syncData, false);
|
||||
document.removeEventListener(EVENT.VISIBILITY_CHANGE, syncData, false);
|
||||
window.removeEventListener(EVENT.UNLOAD, onUnload, false);
|
||||
window.removeEventListener(EVENT.BLUR, visibilityChange, false);
|
||||
window.removeEventListener(EVENT.FOCUS, visibilityChange, false);
|
||||
document.removeEventListener(
|
||||
EVENT.VISIBILITY_CHANGE,
|
||||
visibilityChange,
|
||||
false,
|
||||
);
|
||||
clearTimeout(titleTimeout);
|
||||
};
|
||||
}, [collabAPI, excalidrawAPI]);
|
||||
|
||||
useEffect(() => {
|
||||
const unloadHandler = (event: BeforeUnloadEvent) => {
|
||||
saveDebounced.flush();
|
||||
LocalData.flushSave();
|
||||
|
||||
if (
|
||||
excalidrawAPI &&
|
||||
localFileStorage.shouldPreventUnload(excalidrawAPI.getSceneElements())
|
||||
LocalData.fileStorage.shouldPreventUnload(
|
||||
excalidrawAPI.getSceneElements(),
|
||||
)
|
||||
) {
|
||||
preventUnload(event);
|
||||
}
|
||||
@@ -515,9 +456,13 @@ const ExcalidrawWrapper = () => {
|
||||
files: BinaryFiles,
|
||||
) => {
|
||||
if (collabAPI?.isCollaborating()) {
|
||||
collabAPI.broadcastElements(elements);
|
||||
} else {
|
||||
saveDebounced(elements, appState, files, () => {
|
||||
collabAPI.syncElements(elements);
|
||||
}
|
||||
|
||||
// this check is redundant, but since this is a hot path, it's best
|
||||
// not to evaludate the nested expression every time
|
||||
if (!LocalData.isSavePaused()) {
|
||||
LocalData.save(elements, appState, files, () => {
|
||||
if (excalidrawAPI) {
|
||||
let didChange = false;
|
||||
|
||||
@@ -525,7 +470,9 @@ const ExcalidrawWrapper = () => {
|
||||
const elements = excalidrawAPI
|
||||
.getSceneElementsIncludingDeleted()
|
||||
.map((element) => {
|
||||
if (localFileStorage.shouldUpdateImageElementStatus(element)) {
|
||||
if (
|
||||
LocalData.fileStorage.shouldUpdateImageElementStatus(element)
|
||||
) {
|
||||
didChange = true;
|
||||
const newEl = newElementWith(element, { status: "saved" });
|
||||
if (pendingImageElement === element) {
|
||||
@@ -685,11 +632,16 @@ const ExcalidrawWrapper = () => {
|
||||
};
|
||||
|
||||
const onRoomClose = useCallback(() => {
|
||||
localFileStorage.reset();
|
||||
LocalData.fileStorage.reset();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{ height: "100%" }}
|
||||
className={clsx("excalidraw-app", {
|
||||
"is-collaborating": collabAPI?.isCollaborating(),
|
||||
})}
|
||||
>
|
||||
<Excalidraw
|
||||
ref={excalidrawRefCallback}
|
||||
onChange={onChange}
|
||||
@@ -741,7 +693,7 @@ const ExcalidrawWrapper = () => {
|
||||
onClose={() => setErrorMessage("")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Vendored
+2
@@ -34,6 +34,8 @@ type Mutable<T> = {
|
||||
-readonly [P in keyof T]: T[P];
|
||||
};
|
||||
|
||||
type Merge<M, N> = Omit<M, keyof N> & N;
|
||||
|
||||
/** utility type to assert that the second type is a subtype of the first type.
|
||||
* Returns the subtype. */
|
||||
type SubtypeOf<Supertype, Subtype extends Supertype> = Subtype;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { unstable_createStore } from "jotai";
|
||||
|
||||
export const jotaiScope = Symbol();
|
||||
export const jotaiStore = unstable_createStore();
|
||||
+18
-7
@@ -9,6 +9,7 @@
|
||||
"copy": "نسخ",
|
||||
"copyAsPng": "نسخ إلى الحافظة بصيغة PNG",
|
||||
"copyAsSvg": "نسخ إلى الحافظة بصيغة SVG",
|
||||
"copyText": "نسخ إلى الحافظة كنص",
|
||||
"bringForward": "جلب للأمام",
|
||||
"sendToBack": "أرسل للخلف",
|
||||
"bringToFront": "أحضر للأمام",
|
||||
@@ -21,7 +22,7 @@
|
||||
"fill": "التعبئة",
|
||||
"strokeWidth": "سُمك الخط",
|
||||
"strokeStyle": "نمط الخط",
|
||||
"strokeStyle_solid": "كامل",
|
||||
"strokeStyle_solid": "متصل",
|
||||
"strokeStyle_dashed": "متقطع",
|
||||
"strokeStyle_dotted": "منقط",
|
||||
"sloppiness": "الإمالة",
|
||||
@@ -107,10 +108,17 @@
|
||||
"decreaseFontSize": "تصغير حجم الخط",
|
||||
"increaseFontSize": "تكبير حجم الخط",
|
||||
"unbindText": "",
|
||||
"bindText": "",
|
||||
"link": {
|
||||
"edit": "تعديل الرابط",
|
||||
"create": "إنشاء رابط",
|
||||
"label": "رابط"
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "",
|
||||
"unlock": "",
|
||||
"lockAll": "",
|
||||
"unlockAll": ""
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -159,7 +167,7 @@
|
||||
"couldNotLoadInvalidFile": "تعذر التحميل، الملف غير صالح",
|
||||
"importBackendFailed": "فشل الاستيراد من الخادوم.",
|
||||
"cannotExportEmptyCanvas": "لا يمكن تصدير لوحة فارغة.",
|
||||
"couldNotCopyToClipboard": "تعذر النسخ إلى الحافظة. حاول استخدام متصفح Chrome.",
|
||||
"couldNotCopyToClipboard": "",
|
||||
"decryptFailed": "تعذر فك تشفير البيانات.",
|
||||
"uploadedSecurly": "تم تأمين التحميل بتشفير النهاية إلى النهاية، مما يعني أن خادوم Excalidraw والأطراف الثالثة لا يمكنها قراءة المحتوى.",
|
||||
"loadSceneOverridePrompt": "تحميل الرسم الخارجي سيحل محل المحتوى الموجود لديك. هل ترغب في المتابعة؟",
|
||||
@@ -172,7 +180,7 @@
|
||||
"cannotRestoreFromImage": "تعذر استعادة المشهد من ملف الصورة",
|
||||
"invalidSceneUrl": "تعذر استيراد المشهد من عنوان URL المتوفر. إما أنها مشوهة، أو لا تحتوي على بيانات Excalidraw JSON صالحة.",
|
||||
"resetLibrary": "هذا سوف يمسح مكتبتك. هل أنت متأكد؟",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"removeItemsFromsLibrary": "حذف {{count}} عنصر (عناصر) من المكتبة؟",
|
||||
"invalidEncryptionKey": "مفتاح التشفير يجب أن يكون من 22 حرفاً. التعاون المباشر معطل."
|
||||
},
|
||||
"errors": {
|
||||
@@ -196,7 +204,8 @@
|
||||
"library": "مكتبة",
|
||||
"lock": "الحفاظ على أداة التحديد نشطة بعد الرسم",
|
||||
"penMode": "",
|
||||
"link": ""
|
||||
"link": "",
|
||||
"eraser": "ممحاة"
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "إجراءات اللوحة",
|
||||
@@ -221,7 +230,8 @@
|
||||
"placeImage": "",
|
||||
"publishLibrary": "نشر مكتبتك",
|
||||
"bindTextToElement": "",
|
||||
"deepBoxSelect": ""
|
||||
"deepBoxSelect": "",
|
||||
"eraserRevert": ""
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "تعذر عرض المعاينة",
|
||||
@@ -281,14 +291,15 @@
|
||||
"howto": "اتبع التعليمات",
|
||||
"or": "أو",
|
||||
"preventBinding": "منع ارتبط السهم",
|
||||
"shapes": "أشكال",
|
||||
"tools": "الأدوات",
|
||||
"shortcuts": "اختصارات لوحة المفاتيح",
|
||||
"textFinish": "إنهاء التعديل (محرر النص)",
|
||||
"textNewLine": "أضف سطر جديد (محرر نص)",
|
||||
"title": "المساعدة",
|
||||
"view": "عرض",
|
||||
"zoomToFit": "تكبير للملائمة",
|
||||
"zoomToSelection": "تكبير للعنصر المحدد"
|
||||
"zoomToSelection": "تكبير للعنصر المحدد",
|
||||
"toggleElementLock": ""
|
||||
},
|
||||
"clearCanvasDialog": {
|
||||
"title": "مسح اللوحة"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user