mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-10-26 16:34:22 +01:00
Compare commits
30 Commits
export-deb
...
zsviczian-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13309a66c5 | ||
|
|
531829d95e | ||
|
|
d3cbceb7fa | ||
|
|
73111500d3 | ||
|
|
9e17b64e5e | ||
|
|
326da61573 | ||
|
|
994f2a3f1e | ||
|
|
5dbcf64353 | ||
|
|
eda2320dae | ||
|
|
b610c04481 | ||
|
|
d969849357 | ||
|
|
9a66fc6c05 | ||
|
|
158f169c43 | ||
|
|
ce27cb6159 | ||
|
|
2e04bcd485 | ||
|
|
7436f3926b | ||
|
|
e429b7048d | ||
|
|
e61b447413 | ||
|
|
73f0d854bf | ||
|
|
cec3cf8334 | ||
|
|
8640e75ccf | ||
|
|
ca7ce64fea | ||
|
|
e3a78fe5df | ||
|
|
554985f749 | ||
|
|
d3857fbb35 | ||
|
|
93c72cbb32 | ||
|
|
aeb4d39387 | ||
|
|
a0259360d6 | ||
|
|
243d8de7a8 | ||
|
|
81c927bab6 |
@@ -4,10 +4,9 @@ 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
|
||||
|
||||
# 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_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=
|
||||
|
||||
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"}'
|
||||
|
||||
43
README.md
43
README.md
@@ -128,41 +128,14 @@ For collaboration, you will need to set up [collab server](https://github.com/ex
|
||||
|
||||
#### Commands
|
||||
|
||||
##### 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
|
||||
```
|
||||
| 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 |
|
||||
|
||||
#### Docker Compose
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
rules_version = '2';
|
||||
service firebase.storage {
|
||||
match /b/{bucket}/o {
|
||||
match /{files}/rooms/{room}/{file} {
|
||||
allow get, write: if true;
|
||||
}
|
||||
match /{files}/shareLinks/{shareLink}/{file} {
|
||||
allow get, write: if true;
|
||||
match /{migrations} {
|
||||
match /{scenes}/{scene} {
|
||||
allow get, write: if true;
|
||||
// redundant, but let's be explicit'
|
||||
allow list: if false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
"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",
|
||||
@@ -68,7 +67,7 @@
|
||||
"eslint-plugin-prettier": "3.3.1",
|
||||
"husky": "7.0.4",
|
||||
"jest-canvas-mock": "2.3.1",
|
||||
"lint-staged": "12.3.7",
|
||||
"lint-staged": "12.3.3",
|
||||
"pepjs": "0.5.3",
|
||||
"prettier": "2.5.1",
|
||||
"rewire": "5.0.0"
|
||||
|
||||
@@ -124,6 +124,26 @@
|
||||
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;
|
||||
@@ -132,10 +152,8 @@
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1200px) {
|
||||
#root {
|
||||
@media screen and (min-width: 1200px) {
|
||||
-webkit-touch-callout: default;
|
||||
-webkit-user-select: auto;
|
||||
-khtml-user-select: auto;
|
||||
@@ -152,6 +170,10 @@
|
||||
<header>
|
||||
<h1 class="visually-hidden">Excalidraw</h1>
|
||||
</header>
|
||||
<div id="root"></div>
|
||||
<div id="root">
|
||||
<div class="LoadingMessage">
|
||||
<span>Loading scene...</span>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,7 +7,6 @@ import { t } from "../i18n";
|
||||
|
||||
export const actionAddToLibrary = register({
|
||||
name: "addToLibrary",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
|
||||
@@ -43,7 +43,6 @@ const alignSelectedElements = (
|
||||
|
||||
export const actionAlignTop = register({
|
||||
name: "alignTop",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
@@ -73,7 +72,6 @@ export const actionAlignTop = register({
|
||||
|
||||
export const actionAlignBottom = register({
|
||||
name: "alignBottom",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
@@ -103,7 +101,6 @@ export const actionAlignBottom = register({
|
||||
|
||||
export const actionAlignLeft = register({
|
||||
name: "alignLeft",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
@@ -133,8 +130,6 @@ export const actionAlignLeft = register({
|
||||
|
||||
export const actionAlignRight = register({
|
||||
name: "alignRight",
|
||||
trackEvent: { category: "element" },
|
||||
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
@@ -164,8 +159,6 @@ export const actionAlignRight = register({
|
||||
|
||||
export const actionAlignVerticallyCentered = register({
|
||||
name: "alignVerticallyCentered",
|
||||
trackEvent: { category: "element" },
|
||||
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
@@ -191,7 +184,6 @@ export const actionAlignVerticallyCentered = register({
|
||||
|
||||
export const actionAlignHorizontallyCentered = register({
|
||||
name: "alignHorizontallyCentered",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
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 { eraser, zoomIn, zoomOut } from "../components/icons";
|
||||
import { zoomIn, zoomOut } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||
import { THEME, ZOOM_STEP } from "../constants";
|
||||
@@ -15,13 +15,11 @@ import { getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { getDefaultAppState, isEraserActive } from "../appState";
|
||||
import { getDefaultAppState } 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 },
|
||||
@@ -51,7 +49,6 @@ export const actionChangeViewBackgroundColor = register({
|
||||
|
||||
export const actionClearCanvas = register({
|
||||
name: "clearCanvas",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
app.imageCache.clear();
|
||||
return {
|
||||
@@ -62,6 +59,7 @@ export const actionClearCanvas = register({
|
||||
...getDefaultAppState(),
|
||||
files: {},
|
||||
theme: appState.theme,
|
||||
elementLocked: appState.elementLocked,
|
||||
penMode: appState.penMode,
|
||||
penDetected: appState.penDetected,
|
||||
exportBackground: appState.exportBackground,
|
||||
@@ -69,10 +67,8 @@ export const actionClearCanvas = register({
|
||||
gridSize: appState.gridSize,
|
||||
showStats: appState.showStats,
|
||||
pasteDialog: appState.pasteDialog,
|
||||
activeTool:
|
||||
appState.activeTool.type === "image"
|
||||
? { ...appState.activeTool, type: "selection" }
|
||||
: appState.activeTool,
|
||||
elementType:
|
||||
appState.elementType === "image" ? "selection" : appState.elementType,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
@@ -83,7 +79,6 @@ export const actionClearCanvas = register({
|
||||
|
||||
export const actionZoomIn = register({
|
||||
name: "zoomIn",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
return {
|
||||
appState: {
|
||||
@@ -119,7 +114,6 @@ export const actionZoomIn = register({
|
||||
|
||||
export const actionZoomOut = register({
|
||||
name: "zoomOut",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
return {
|
||||
appState: {
|
||||
@@ -155,7 +149,6 @@ export const actionZoomOut = register({
|
||||
|
||||
export const actionResetZoom = register({
|
||||
name: "resetZoom",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
return {
|
||||
appState: {
|
||||
@@ -254,7 +247,6 @@ const zoomToFitElements = (
|
||||
|
||||
export const actionZoomToSelected = register({
|
||||
name: "zoomToSelection",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => zoomToFitElements(elements, appState, true),
|
||||
keyTest: (event) =>
|
||||
event.code === CODES.TWO &&
|
||||
@@ -265,7 +257,6 @@ 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 &&
|
||||
@@ -276,7 +267,6 @@ export const actionZoomToFit = register({
|
||||
|
||||
export const actionToggleTheme = register({
|
||||
name: "toggleTheme",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_, appState, value) => {
|
||||
return {
|
||||
appState: {
|
||||
@@ -299,42 +289,3 @@ 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,19 +1,14 @@
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import {
|
||||
copyTextToSystemClipboard,
|
||||
copyToClipboard,
|
||||
probablySupportsClipboardWriteText,
|
||||
} from "../clipboard";
|
||||
import { copyToClipboard } from "../clipboard";
|
||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||
import { getSelectedElements } from "../scene/selection";
|
||||
import { exportCanvas } from "../data/index";
|
||||
import { getNonDeletedElements, isTextElement } from "../element";
|
||||
import { getNonDeletedElements } 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);
|
||||
|
||||
@@ -28,7 +23,6 @@ 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);
|
||||
@@ -39,7 +33,6 @@ export const actionCut = register({
|
||||
|
||||
export const actionCopyAsSvg = register({
|
||||
name: "copyAsSvg",
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, _data, app) => {
|
||||
if (!app.canvas) {
|
||||
return {
|
||||
@@ -80,7 +73,6 @@ export const actionCopyAsSvg = register({
|
||||
|
||||
export const actionCopyAsPng = register({
|
||||
name: "copyAsPng",
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, _data, app) => {
|
||||
if (!app.canvas) {
|
||||
return {
|
||||
@@ -130,35 +122,3 @@ 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,7 +58,6 @@ const handleGroupEditingState = (
|
||||
|
||||
export const actionDeleteSelected = register({
|
||||
name: "deleteSelectedElements",
|
||||
trackEvent: { category: "element", action: "delete" },
|
||||
perform: (elements, appState) => {
|
||||
if (appState.editingLinearElement) {
|
||||
const {
|
||||
@@ -134,7 +133,7 @@ export const actionDeleteSelected = register({
|
||||
elements: nextElements,
|
||||
appState: {
|
||||
...nextAppState,
|
||||
activeTool: { ...appState.activeTool, type: "selection" },
|
||||
elementType: "selection",
|
||||
multiElement: null,
|
||||
},
|
||||
commitToHistory: isSomeElementSelected(
|
||||
|
||||
@@ -39,7 +39,6 @@ const distributeSelectedElements = (
|
||||
|
||||
export const distributeHorizontally = register({
|
||||
name: "distributeHorizontally",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
@@ -69,7 +68,6 @@ export const distributeHorizontally = register({
|
||||
|
||||
export const distributeVertically = register({
|
||||
name: "distributeVertically",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
|
||||
@@ -22,7 +22,6 @@ 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,3 +1,4 @@
|
||||
import { trackEvent } from "../analytics";
|
||||
import { load, questionCircle, saveAs } from "../components/icons";
|
||||
import { ProjectName } from "../components/ProjectName";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
@@ -7,7 +8,7 @@ import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||
import { loadFromJSON, saveAsJSON } from "../data";
|
||||
import { resaveAsImageWithScene } from "../data/resave";
|
||||
import { t } from "../i18n";
|
||||
import { useDeviceType } from "../components/App";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { CheckboxItem } from "../components/CheckboxItem";
|
||||
@@ -22,8 +23,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 }) => (
|
||||
@@ -40,7 +41,6 @@ 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,7 +89,6 @@ export const actionChangeExportScale = register({
|
||||
|
||||
export const actionChangeExportBackground = register({
|
||||
name: "changeExportBackground",
|
||||
trackEvent: { category: "export", action: "toggleBackground" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportBackground: value },
|
||||
@@ -108,7 +107,6 @@ export const actionChangeExportBackground = register({
|
||||
|
||||
export const actionChangeExportEmbedScene = register({
|
||||
name: "changeExportEmbedScene",
|
||||
trackEvent: { category: "export", action: "embedScene" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportEmbedScene: value },
|
||||
@@ -130,7 +128,6 @@ export const actionChangeExportEmbedScene = register({
|
||||
|
||||
export const actionSaveToActiveFile = register({
|
||||
name: "saveToActiveFile",
|
||||
trackEvent: { category: "export" },
|
||||
perform: async (elements, appState, value, app) => {
|
||||
const fileHandleExists = !!appState.fileHandle;
|
||||
|
||||
@@ -175,7 +172,6 @@ export const actionSaveToActiveFile = register({
|
||||
|
||||
export const actionSaveFileToDisk = register({
|
||||
name: "saveFileToDisk",
|
||||
trackEvent: { category: "export" },
|
||||
perform: async (elements, appState, value, app) => {
|
||||
try {
|
||||
const { fileHandle } = await saveAsJSON(
|
||||
@@ -204,7 +200,7 @@ export const actionSaveFileToDisk = register({
|
||||
icon={saveAs}
|
||||
title={t("buttons.saveAs")}
|
||||
aria-label={t("buttons.saveAs")}
|
||||
showAriaLabel={useDeviceType().isMobile}
|
||||
showAriaLabel={useIsMobile()}
|
||||
hidden={!nativeFileSystemSupported}
|
||||
onClick={() => updateData(null)}
|
||||
data-testid="save-as-button"
|
||||
@@ -214,7 +210,6 @@ export const actionSaveFileToDisk = register({
|
||||
|
||||
export const actionLoadScene = register({
|
||||
name: "loadScene",
|
||||
trackEvent: { category: "export" },
|
||||
perform: async (elements, appState, _, app) => {
|
||||
try {
|
||||
const {
|
||||
@@ -248,7 +243,7 @@ export const actionLoadScene = register({
|
||||
icon={load}
|
||||
title={t("buttons.load")}
|
||||
aria-label={t("buttons.load")}
|
||||
showAriaLabel={useDeviceType().isMobile}
|
||||
showAriaLabel={useIsMobile()}
|
||||
onClick={updateData}
|
||||
data-testid="load-button"
|
||||
/>
|
||||
@@ -257,7 +252,6 @@ 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,7 +17,6 @@ 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 } =
|
||||
@@ -39,7 +38,6 @@ export const actionFinalize = register({
|
||||
: undefined,
|
||||
appState: {
|
||||
...appState,
|
||||
cursorButton: "up",
|
||||
editingLinearElement: null,
|
||||
},
|
||||
commitToHistory: true,
|
||||
@@ -121,17 +119,13 @@ export const actionFinalize = register({
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!appState.activeTool.locked &&
|
||||
appState.activeTool.type !== "freedraw"
|
||||
) {
|
||||
if (!appState.elementLocked && appState.elementType !== "freedraw") {
|
||||
appState.selectedElementIds[multiPointElement.id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(!appState.activeTool.locked &&
|
||||
appState.activeTool.type !== "freedraw") ||
|
||||
(!appState.elementLocked && appState.elementType !== "freedraw") ||
|
||||
!multiPointElement
|
||||
) {
|
||||
resetCursor(canvas);
|
||||
@@ -141,20 +135,11 @@ export const actionFinalize = register({
|
||||
elements: newElements,
|
||||
appState: {
|
||||
...appState,
|
||||
cursorButton: "up",
|
||||
activeTool:
|
||||
(appState.activeTool.locked ||
|
||||
appState.activeTool.type === "freedraw") &&
|
||||
elementType:
|
||||
(appState.elementLocked || appState.elementType === "freedraw") &&
|
||||
multiPointElement
|
||||
? appState.activeTool
|
||||
: {
|
||||
...appState.activeTool,
|
||||
type:
|
||||
appState.activeTool.type === "eraser" &&
|
||||
appState.activeTool.lastActiveToolBeforeEraser
|
||||
? appState.activeTool.lastActiveToolBeforeEraser
|
||||
: "selection",
|
||||
},
|
||||
? appState.elementType
|
||||
: "selection",
|
||||
draggingElement: null,
|
||||
multiElement: null,
|
||||
editingElement: null,
|
||||
@@ -162,8 +147,8 @@ export const actionFinalize = register({
|
||||
suggestedBindings: [],
|
||||
selectedElementIds:
|
||||
multiPointElement &&
|
||||
!appState.activeTool.locked &&
|
||||
appState.activeTool.type !== "freedraw"
|
||||
!appState.elementLocked &&
|
||||
appState.elementType !== "freedraw"
|
||||
? {
|
||||
...appState.selectedElementIds,
|
||||
[multiPointElement.id]: true,
|
||||
@@ -171,7 +156,7 @@ export const actionFinalize = register({
|
||||
: appState.selectedElementIds,
|
||||
pendingImageElement: null,
|
||||
},
|
||||
commitToHistory: appState.activeTool.type === "freedraw",
|
||||
commitToHistory: appState.elementType === "freedraw",
|
||||
};
|
||||
},
|
||||
keyTest: (event, appState) =>
|
||||
@@ -180,7 +165,7 @@ export const actionFinalize = register({
|
||||
(!appState.draggingElement && appState.multiElement === null))) ||
|
||||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
||||
appState.multiElement !== null),
|
||||
PanelComponent: ({ appState, updateData, data }) => (
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={done}
|
||||
@@ -188,7 +173,6 @@ export const actionFinalize = register({
|
||||
aria-label={t("buttons.done")}
|
||||
onClick={updateData}
|
||||
visible={appState.multiElement != null}
|
||||
size={data?.size || "medium"}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -35,7 +35,6 @@ const enableActionFlipVertical = (
|
||||
|
||||
export const actionFlipHorizontal = register({
|
||||
name: "flipHorizontal",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: flipSelectedElements(elements, appState, "horizontal"),
|
||||
@@ -51,7 +50,6 @@ export const actionFlipHorizontal = register({
|
||||
|
||||
export const actionFlipVertical = register({
|
||||
name: "flipVertical",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: flipSelectedElements(elements, appState, "vertical"),
|
||||
|
||||
@@ -54,7 +54,6 @@ const enableActionGroup = (
|
||||
|
||||
export const actionGroup = register({
|
||||
name: "group",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
@@ -148,7 +147,6 @@ 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,7 +62,6 @@ 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) =>
|
||||
@@ -83,7 +82,6 @@ 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,7 +9,6 @@ import { HelpIcon } from "../components/HelpIcon";
|
||||
|
||||
export const actionToggleCanvasMenu = register({
|
||||
name: "toggleCanvasMenu",
|
||||
trackEvent: { category: "menu" },
|
||||
perform: (_, appState) => ({
|
||||
appState: {
|
||||
...appState,
|
||||
@@ -30,7 +29,6 @@ export const actionToggleCanvasMenu = register({
|
||||
|
||||
export const actionToggleEditMenu = register({
|
||||
name: "toggleEditMenu",
|
||||
trackEvent: { category: "menu" },
|
||||
perform: (_elements, appState) => ({
|
||||
appState: {
|
||||
...appState,
|
||||
@@ -55,7 +53,6 @@ export const actionToggleEditMenu = register({
|
||||
|
||||
export const actionFullScreen = register({
|
||||
name: "toggleFullScreen",
|
||||
trackEvent: { category: "canvas", predicate: (appState) => !isFullScreen() },
|
||||
perform: () => {
|
||||
if (!isFullScreen()) {
|
||||
allowFullScreen();
|
||||
@@ -72,7 +69,6 @@ export const actionFullScreen = register({
|
||||
|
||||
export const actionShortcuts = register({
|
||||
name: "toggleShortcuts",
|
||||
trackEvent: { category: "menu", action: "toggleHelpDialog" },
|
||||
perform: (_elements, appState, _, { focusContainer }) => {
|
||||
if (appState.showHelpDialog) {
|
||||
focusContainer();
|
||||
|
||||
@@ -6,7 +6,6 @@ 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,7 +166,11 @@ const changeFontSize = (
|
||||
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
|
||||
fontSize: newFontSize,
|
||||
});
|
||||
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
getContainerElement(oldElement),
|
||||
appState,
|
||||
);
|
||||
|
||||
newElement = offsetElementAfterFontResize(oldElement, newElement);
|
||||
|
||||
@@ -194,7 +198,6 @@ const changeFontSize = (
|
||||
|
||||
export const actionChangeStrokeColor = register({
|
||||
name: "changeStrokeColor",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
...(value.currentItemStrokeColor && {
|
||||
@@ -244,7 +247,6 @@ export const actionChangeStrokeColor = register({
|
||||
|
||||
export const actionChangeBackgroundColor = register({
|
||||
name: "changeBackgroundColor",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
...(value.currentItemBackgroundColor && {
|
||||
@@ -287,7 +289,6 @@ export const actionChangeBackgroundColor = register({
|
||||
|
||||
export const actionChangeFillStyle = register({
|
||||
name: "changeFillStyle",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
@@ -337,7 +338,6 @@ export const actionChangeFillStyle = register({
|
||||
|
||||
export const actionChangeStrokeWidth = register({
|
||||
name: "changeStrokeWidth",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
@@ -385,7 +385,6 @@ export const actionChangeStrokeWidth = register({
|
||||
|
||||
export const actionChangeSloppiness = register({
|
||||
name: "changeSloppiness",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
@@ -434,7 +433,6 @@ export const actionChangeSloppiness = register({
|
||||
|
||||
export const actionChangeStrokeStyle = register({
|
||||
name: "changeStrokeStyle",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
@@ -482,7 +480,6 @@ export const actionChangeStrokeStyle = register({
|
||||
|
||||
export const actionChangeOpacity = register({
|
||||
name: "changeOpacity",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
@@ -532,7 +529,6 @@ export const actionChangeOpacity = register({
|
||||
|
||||
export const actionChangeFontSize = register({
|
||||
name: "changeFontSize",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return changeFontSize(elements, appState, () => value, value);
|
||||
},
|
||||
@@ -590,7 +586,6 @@ export const actionChangeFontSize = register({
|
||||
|
||||
export const actionDecreaseFontSize = register({
|
||||
name: "decreaseFontSize",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return changeFontSize(elements, appState, (element) =>
|
||||
Math.round(
|
||||
@@ -612,7 +607,6 @@ 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)),
|
||||
@@ -630,7 +624,6 @@ export const actionIncreaseFontSize = register({
|
||||
|
||||
export const actionChangeFontFamily = register({
|
||||
name: "changeFontFamily",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(
|
||||
@@ -644,7 +637,11 @@ export const actionChangeFontFamily = register({
|
||||
fontFamily: value,
|
||||
},
|
||||
);
|
||||
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
getContainerElement(oldElement),
|
||||
appState,
|
||||
);
|
||||
return newElement;
|
||||
}
|
||||
|
||||
@@ -712,7 +709,6 @@ export const actionChangeFontFamily = register({
|
||||
|
||||
export const actionChangeTextAlign = register({
|
||||
name: "changeTextAlign",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(
|
||||
@@ -724,7 +720,11 @@ export const actionChangeTextAlign = register({
|
||||
oldElement,
|
||||
{ textAlign: value },
|
||||
);
|
||||
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
getContainerElement(oldElement),
|
||||
appState,
|
||||
);
|
||||
return newElement;
|
||||
}
|
||||
|
||||
@@ -785,7 +785,6 @@ export const actionChangeTextAlign = register({
|
||||
});
|
||||
export const actionChangeVerticalAlign = register({
|
||||
name: "changeVerticalAlign",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(
|
||||
@@ -798,7 +797,11 @@ export const actionChangeVerticalAlign = register({
|
||||
{ verticalAlign: value },
|
||||
);
|
||||
|
||||
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
getContainerElement(oldElement),
|
||||
appState,
|
||||
);
|
||||
return newElement;
|
||||
}
|
||||
|
||||
@@ -853,7 +856,6 @@ export const actionChangeVerticalAlign = register({
|
||||
|
||||
export const actionChangeSharpness = register({
|
||||
name: "changeSharpness",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
const targetElements = getTargetElements(
|
||||
getNonDeletedElements(elements),
|
||||
@@ -861,10 +863,10 @@ export const actionChangeSharpness = register({
|
||||
);
|
||||
const shouldUpdateForNonLinearElements = targetElements.length
|
||||
? targetElements.every((el) => !isLinearElement(el))
|
||||
: !isLinearElementType(appState.activeTool.type);
|
||||
: !isLinearElementType(appState.elementType);
|
||||
const shouldUpdateForLinearElements = targetElements.length
|
||||
? targetElements.every(isLinearElement)
|
||||
: isLinearElementType(appState.activeTool.type);
|
||||
: isLinearElementType(appState.elementType);
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
@@ -904,8 +906,8 @@ export const actionChangeSharpness = register({
|
||||
elements,
|
||||
appState,
|
||||
(element) => element.strokeSharpness,
|
||||
(canChangeSharpness(appState.activeTool.type) &&
|
||||
(isLinearElementType(appState.activeTool.type)
|
||||
(canChangeSharpness(appState.elementType) &&
|
||||
(isLinearElementType(appState.elementType)
|
||||
? appState.currentItemLinearStrokeSharpness
|
||||
: appState.currentItemStrokeSharpness)) ||
|
||||
null,
|
||||
@@ -918,7 +920,6 @@ export const actionChangeSharpness = register({
|
||||
|
||||
export const actionChangeArrowhead = register({
|
||||
name: "changeArrowhead",
|
||||
trackEvent: false,
|
||||
perform: (
|
||||
elements,
|
||||
appState,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { getNonDeletedElements, isTextElement } from "../element";
|
||||
|
||||
export const actionSelectAll = register({
|
||||
name: "selectAll",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => {
|
||||
if (appState.editingLinearElement) {
|
||||
return false;
|
||||
@@ -18,8 +17,7 @@ export const actionSelectAll = register({
|
||||
selectedElementIds: elements.reduce((map, element) => {
|
||||
if (
|
||||
!element.isDeleted &&
|
||||
!(isTextElement(element) && element.containerId) &&
|
||||
element.locked === false
|
||||
!(isTextElement(element) && element.containerId)
|
||||
) {
|
||||
map[element.id] = true;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ 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) {
|
||||
@@ -40,7 +39,6 @@ export const actionCopyStyles = register({
|
||||
|
||||
export const actionPasteStyles = register({
|
||||
name: "pasteStyles",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
const pastedElement = JSON.parse(copiedStyles);
|
||||
if (!isExcalidrawElement(pastedElement)) {
|
||||
@@ -65,7 +63,11 @@ export const actionPasteStyles = register({
|
||||
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
|
||||
});
|
||||
|
||||
redrawTextBoundingBox(newElement, getContainerElement(newElement));
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
getContainerElement(newElement),
|
||||
appState,
|
||||
);
|
||||
}
|
||||
return newElement;
|
||||
}
|
||||
|
||||
@@ -2,14 +2,12 @@ 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,
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
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,7 +3,6 @@ import { CODES, KEYS } from "../keys";
|
||||
|
||||
export const actionToggleStats = register({
|
||||
name: "stats",
|
||||
trackEvent: { category: "menu" },
|
||||
perform(elements, appState) {
|
||||
return {
|
||||
appState: {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
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,13 +1,12 @@
|
||||
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,
|
||||
|
||||
44
src/actions/actionUnbindText.tsx
Normal file
44
src/actions/actionUnbindText.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
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,7 +18,6 @@ import {
|
||||
|
||||
export const actionSendBackward = register({
|
||||
name: "sendBackward",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveOneLeft(elements, appState),
|
||||
@@ -46,7 +45,6 @@ export const actionSendBackward = register({
|
||||
|
||||
export const actionBringForward = register({
|
||||
name: "bringForward",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveOneRight(elements, appState),
|
||||
@@ -74,7 +72,6 @@ export const actionBringForward = register({
|
||||
|
||||
export const actionSendToBack = register({
|
||||
name: "sendToBack",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveAllLeft(elements, appState),
|
||||
@@ -109,8 +106,6 @@ export const actionSendToBack = register({
|
||||
|
||||
export const actionBringToFront = register({
|
||||
name: "bringToFront",
|
||||
trackEvent: { category: "element" },
|
||||
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveAllRight(elements, appState),
|
||||
|
||||
@@ -75,13 +75,11 @@ export {
|
||||
actionCut,
|
||||
actionCopyAsPng,
|
||||
actionCopyAsSvg,
|
||||
copyText,
|
||||
} from "./actionClipboard";
|
||||
|
||||
export { actionToggleGridMode } from "./actionToggleGridMode";
|
||||
export { actionToggleZenMode } from "./actionToggleZenMode";
|
||||
|
||||
export { actionToggleStats } from "./actionToggleStats";
|
||||
export { actionUnbindText, actionBindText } from "./actionBoundText";
|
||||
export { actionUnbindText } from "./actionUnbindText";
|
||||
export { actionLink } from "../element/Hyperlink";
|
||||
export { actionToggleLock } from "./actionToggleLock";
|
||||
|
||||
@@ -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,25 +14,21 @@ import { trackEvent } from "../analytics";
|
||||
|
||||
const trackAction = (
|
||||
action: Action,
|
||||
source: ActionSource,
|
||||
appState: Readonly<AppState>,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
app: AppClassProperties,
|
||||
source: "ui" | "keyboard" | "api",
|
||||
value: any,
|
||||
) => {
|
||||
if (action.trackEvent) {
|
||||
if (action.trackEvent !== false) {
|
||||
try {
|
||||
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"})`,
|
||||
);
|
||||
}
|
||||
if (action.trackEvent === true) {
|
||||
trackEvent(
|
||||
action.name,
|
||||
source,
|
||||
typeof value === "number" || typeof value === "string"
|
||||
? String(value)
|
||||
: undefined,
|
||||
);
|
||||
} else {
|
||||
action.trackEvent?.(action, source, value);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("error while logging action:", error);
|
||||
@@ -40,8 +36,8 @@ const trackAction = (
|
||||
}
|
||||
};
|
||||
|
||||
export class ActionManager {
|
||||
actions = {} as Record<ActionName, Action>;
|
||||
export class ActionManager implements ActionsManagerInterface {
|
||||
actions = {} as ActionsManagerInterface["actions"];
|
||||
|
||||
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
|
||||
|
||||
@@ -110,26 +106,30 @@ export class ActionManager {
|
||||
}
|
||||
}
|
||||
|
||||
const elements = this.getElementsIncludingDeleted();
|
||||
const appState = this.getAppState();
|
||||
const value = null;
|
||||
|
||||
trackAction(action, "keyboard", appState, elements, this.app, null);
|
||||
trackAction(action, "keyboard", null);
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.updater(data[0].perform(elements, appState, value, this.app));
|
||||
this.updater(
|
||||
data[0].perform(
|
||||
this.getElementsIncludingDeleted(),
|
||||
this.getAppState(),
|
||||
null,
|
||||
this.app,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
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));
|
||||
executeAction(action: Action) {
|
||||
this.updater(
|
||||
action.perform(
|
||||
this.getElementsIncludingDeleted(),
|
||||
this.getAppState(),
|
||||
null,
|
||||
this.app,
|
||||
),
|
||||
);
|
||||
trackAction(action, "api", null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,11 +147,7 @@ export class ActionManager {
|
||||
) {
|
||||
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(),
|
||||
@@ -160,6 +156,8 @@ export class ActionManager {
|
||||
this.app,
|
||||
),
|
||||
);
|
||||
|
||||
trackAction(action, "ui", formState);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -29,7 +29,6 @@ export type ShortcutName = SubtypeOf<
|
||||
| "flipHorizontal"
|
||||
| "flipVertical"
|
||||
| "hyperlink"
|
||||
| "toggleLock"
|
||||
>;
|
||||
|
||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
@@ -68,7 +67,6 @@ 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) => {
|
||||
|
||||
@@ -8,8 +8,6 @@ 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 =
|
||||
| {
|
||||
@@ -41,7 +39,6 @@ export type ActionName =
|
||||
| "paste"
|
||||
| "copyAsPng"
|
||||
| "copyAsSvg"
|
||||
| "copyText"
|
||||
| "sendBackward"
|
||||
| "bringForward"
|
||||
| "sendToBack"
|
||||
@@ -109,10 +106,7 @@ export type ActionName =
|
||||
| "increaseFontSize"
|
||||
| "decreaseFontSize"
|
||||
| "unbindText"
|
||||
| "hyperlink"
|
||||
| "eraser"
|
||||
| "bindText"
|
||||
| "toggleLock";
|
||||
| "hyperlink";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
@@ -143,23 +137,15 @@ export interface Action {
|
||||
appState: AppState,
|
||||
) => boolean;
|
||||
checked?: (appState: Readonly<AppState>) => boolean;
|
||||
trackEvent:
|
||||
| false
|
||||
| {
|
||||
category:
|
||||
| "toolbar"
|
||||
| "element"
|
||||
| "canvas"
|
||||
| "export"
|
||||
| "history"
|
||||
| "menu"
|
||||
| "collab"
|
||||
| "hyperlink";
|
||||
action?: string;
|
||||
predicate?: (
|
||||
appState: Readonly<AppState>,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
value: any,
|
||||
) => 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;
|
||||
}
|
||||
|
||||
@@ -4,19 +4,15 @@ export const trackEvent =
|
||||
typeof window !== "undefined" &&
|
||||
window.gtag
|
||||
? (category: string, action: string, label?: string, value?: number) => {
|
||||
try {
|
||||
window.gtag("event", action, {
|
||||
event_category: category,
|
||||
event_label: label,
|
||||
value,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("error logging to ga", error);
|
||||
}
|
||||
window.gtag("event", action, {
|
||||
event_category: category,
|
||||
event_label: label,
|
||||
value,
|
||||
});
|
||||
}
|
||||
: 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.log("Track Event", { category, action, label, value });
|
||||
// console.info("Track Event", category, action, label, value);
|
||||
};
|
||||
|
||||
@@ -41,11 +41,8 @@ export const getDefaultAppState = (): Omit<
|
||||
editingElement: null,
|
||||
editingGroupId: null,
|
||||
editingLinearElement: null,
|
||||
activeTool: {
|
||||
type: "selection",
|
||||
locked: false,
|
||||
lastActiveToolBeforeEraser: null,
|
||||
},
|
||||
elementLocked: false,
|
||||
elementType: "selection",
|
||||
penMode: false,
|
||||
penDetected: false,
|
||||
errorMessage: null,
|
||||
@@ -133,9 +130,10 @@ 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 },
|
||||
activeTool: { browser: true, export: false, server: false },
|
||||
penMode: { browser: true, export: false, server: false },
|
||||
penDetected: { browser: true, 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 },
|
||||
errorMessage: { browser: false, export: false, server: false },
|
||||
exportBackground: { browser: true, export: false, server: false },
|
||||
exportEmbedScene: { browser: true, export: false, server: false },
|
||||
@@ -215,9 +213,3 @@ 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,7 +167,6 @@ const commonProps = {
|
||||
strokeStyle: "solid",
|
||||
strokeWidth: 1,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
locked: false,
|
||||
} as const;
|
||||
|
||||
const getChartDimentions = (spreadsheet: Spreadsheet) => {
|
||||
|
||||
@@ -8,7 +8,6 @@ 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;
|
||||
@@ -167,35 +166,10 @@ export const parseClipboard = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
|
||||
let promise;
|
||||
try {
|
||||
// in Safari so far we need to construct the ClipboardItem synchronously
|
||||
// (i.e. in the same tick) otherwise browser will complain for lack of
|
||||
// user intent. Using a Promise ClipboardItem constructor solves this.
|
||||
// https://bugs.webkit.org/show_bug.cgi?id=222262
|
||||
//
|
||||
// not await so that we can detect whether the thrown error likely relates
|
||||
// to a lack of support for the Promise ClipboardItem constructor
|
||||
promise = navigator.clipboard.write([
|
||||
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 copyBlobToClipboardAsPng = async (blob: Blob) => {
|
||||
await navigator.clipboard.write([
|
||||
new window.ClipboardItem({ [MIME_TYPES.png]: blob }),
|
||||
]);
|
||||
};
|
||||
|
||||
export const copyTextToSystemClipboard = async (text: string | null) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ActionManager } from "../actions/manager";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement, PointerType } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { useDeviceType } from "../components/App";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import {
|
||||
canChangeSharpness,
|
||||
canHaveArrowheads,
|
||||
@@ -19,19 +19,18 @@ 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,
|
||||
activeTool,
|
||||
elementType,
|
||||
}: {
|
||||
appState: AppState;
|
||||
elements: readonly ExcalidrawElement[];
|
||||
renderAction: ActionManager["renderAction"];
|
||||
activeTool: AppState["activeTool"]["type"];
|
||||
elementType: ExcalidrawElement["type"];
|
||||
}) => {
|
||||
const targetElements = getTargetElements(
|
||||
getNonDeletedElements(elements),
|
||||
@@ -47,22 +46,19 @@ export const SelectedShapeActions = ({
|
||||
isSingleElementBoundContainer = true;
|
||||
}
|
||||
const isEditing = Boolean(appState.editingElement);
|
||||
const deviceType = useDeviceType();
|
||||
const isMobile = useIsMobile();
|
||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||
|
||||
const showFillIcons =
|
||||
hasBackground(activeTool) ||
|
||||
hasBackground(elementType) ||
|
||||
targetElements.some(
|
||||
(element) =>
|
||||
hasBackground(element.type) && !isTransparent(element.backgroundColor),
|
||||
);
|
||||
const showChangeBackgroundIcons =
|
||||
hasBackground(activeTool) ||
|
||||
hasBackground(elementType) ||
|
||||
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) {
|
||||
@@ -74,23 +70,23 @@ export const SelectedShapeActions = ({
|
||||
|
||||
return (
|
||||
<div className="panelColumn">
|
||||
{((hasStrokeColor(activeTool) &&
|
||||
activeTool !== "image" &&
|
||||
{((hasStrokeColor(elementType) &&
|
||||
elementType !== "image" &&
|
||||
commonSelectedType !== "image") ||
|
||||
targetElements.some((element) => hasStrokeColor(element.type))) &&
|
||||
renderAction("changeStrokeColor")}
|
||||
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
|
||||
{showFillIcons && renderAction("changeFillStyle")}
|
||||
|
||||
{(hasStrokeWidth(activeTool) ||
|
||||
{(hasStrokeWidth(elementType) ||
|
||||
targetElements.some((element) => hasStrokeWidth(element.type))) &&
|
||||
renderAction("changeStrokeWidth")}
|
||||
|
||||
{(activeTool === "freedraw" ||
|
||||
{(elementType === "freedraw" ||
|
||||
targetElements.some((element) => element.type === "freedraw")) &&
|
||||
renderAction("changeStrokeShape")}
|
||||
|
||||
{(hasStrokeStyle(activeTool) ||
|
||||
{(hasStrokeStyle(elementType) ||
|
||||
targetElements.some((element) => hasStrokeStyle(element.type))) && (
|
||||
<>
|
||||
{renderAction("changeStrokeStyle")}
|
||||
@@ -98,12 +94,12 @@ export const SelectedShapeActions = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{(canChangeSharpness(activeTool) ||
|
||||
{(canChangeSharpness(elementType) ||
|
||||
targetElements.some((element) => canChangeSharpness(element.type))) && (
|
||||
<>{renderAction("changeSharpness")}</>
|
||||
)}
|
||||
|
||||
{(hasText(activeTool) ||
|
||||
{(hasText(elementType) ||
|
||||
targetElements.some((element) => hasText(element.type))) && (
|
||||
<>
|
||||
{renderAction("changeFontSize")}
|
||||
@@ -118,7 +114,7 @@ export const SelectedShapeActions = ({
|
||||
(element) =>
|
||||
hasBoundTextElement(element) || isBoundToContainer(element),
|
||||
) && renderAction("changeVerticalAlign")}
|
||||
{(canHaveArrowheads(activeTool) ||
|
||||
{(canHaveArrowheads(elementType) ||
|
||||
targetElements.some((element) => canHaveArrowheads(element.type))) && (
|
||||
<>{renderAction("changeArrowhead")}</>
|
||||
)}
|
||||
@@ -172,11 +168,11 @@ export const SelectedShapeActions = ({
|
||||
<fieldset>
|
||||
<legend>{t("labels.actions")}</legend>
|
||||
<div className="buttonList">
|
||||
{!deviceType.isMobile && renderAction("duplicateSelection")}
|
||||
{!deviceType.isMobile && renderAction("deleteSelectedElements")}
|
||||
{!isMobile && renderAction("duplicateSelection")}
|
||||
{!isMobile && renderAction("deleteSelectedElements")}
|
||||
{renderAction("group")}
|
||||
{renderAction("ungroup")}
|
||||
{showLinkIcon && renderAction("hyperlink")}
|
||||
{targetElements.length === 1 && renderAction("hyperlink")}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
@@ -186,16 +182,14 @@ export const SelectedShapeActions = ({
|
||||
|
||||
export const ShapesSwitcher = ({
|
||||
canvas,
|
||||
activeTool,
|
||||
elementType,
|
||||
setAppState,
|
||||
onImageAction,
|
||||
appState,
|
||||
}: {
|
||||
canvas: HTMLCanvasElement | null;
|
||||
activeTool: AppState["activeTool"];
|
||||
elementType: ExcalidrawElement["type"];
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
onImageAction: (data: { pointerType: PointerType | null }) => void;
|
||||
appState: AppState;
|
||||
}) => (
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key }, index) => {
|
||||
@@ -210,35 +204,20 @@ export const ShapesSwitcher = ({
|
||||
key={value}
|
||||
type="radio"
|
||||
icon={icon}
|
||||
checked={activeTool.type === value}
|
||||
checked={elementType === 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({
|
||||
activeTool: nextActiveTool,
|
||||
elementType: value,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
setCursorForShape(canvas, {
|
||||
...appState,
|
||||
activeTool: nextActiveTool,
|
||||
});
|
||||
setCursorForShape(canvas, value);
|
||||
if (value === "image") {
|
||||
onImageAction({ pointerType });
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { useDeviceType } from "./App";
|
||||
import { useIsMobile } 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={useDeviceType().isMobile}
|
||||
showAriaLabel={useIsMobile()}
|
||||
onClick={toggleDialog}
|
||||
data-testid="clear-canvas-button"
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import clsx from "clsx";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { useDeviceType } from "../components/App";
|
||||
import { useIsMobile } 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={useDeviceType().isMobile}
|
||||
showAriaLabel={useIsMobile()}
|
||||
>
|
||||
{collaboratorCount > 0 && (
|
||||
<div className="CollabButton-collaborators">{collaboratorCount}</div>
|
||||
|
||||
@@ -70,9 +70,7 @@ const ContextMenu = ({
|
||||
dangerous: actionName === "deleteSelectedElements",
|
||||
checkmark: option.checked?.(appState),
|
||||
})}
|
||||
onClick={() =>
|
||||
actionManager.executeAction(option, "contextMenu")
|
||||
}
|
||||
onClick={() => actionManager.executeAction(option)}
|
||||
>
|
||||
<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, useDeviceType } from "../components/App";
|
||||
import { useExcalidrawContainer, useIsMobile } 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")}
|
||||
>
|
||||
{useDeviceType().isMobile ? back : close}
|
||||
{useIsMobile() ? 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.tools")}>
|
||||
<ShortcutIsland caption={t("helpDialog.shapes")}>
|
||||
<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={["O", "4"]} />
|
||||
<Shortcut label={t("toolBar.ellipse")} shortcuts={["E", "4"]} />
|
||||
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
|
||||
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
|
||||
<Shortcut
|
||||
@@ -159,10 +159,6 @@ 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={[
|
||||
@@ -363,10 +359,6 @@ 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,7 +11,6 @@ import {
|
||||
isTextElement,
|
||||
} from "../element/typeChecks";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { isEraserActive } from "../appState";
|
||||
|
||||
interface HintViewerProps {
|
||||
appState: AppState;
|
||||
@@ -20,32 +19,25 @@ interface HintViewerProps {
|
||||
}
|
||||
|
||||
const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
||||
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||
const { elementType, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||
const multiMode = appState.multiElement !== null;
|
||||
|
||||
if (appState.isLibraryOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isEraserActive(appState)) {
|
||||
return t("hints.eraserRevert");
|
||||
}
|
||||
if (activeTool.type === "arrow" || activeTool.type === "line") {
|
||||
if (elementType === "arrow" || elementType === "line") {
|
||||
if (!multiMode) {
|
||||
return t("hints.linearElement");
|
||||
}
|
||||
return t("hints.linearElementMulti");
|
||||
}
|
||||
|
||||
if (activeTool.type === "freedraw") {
|
||||
if (elementType === "freedraw") {
|
||||
return t("hints.freeDraw");
|
||||
}
|
||||
|
||||
if (activeTool.type === "text") {
|
||||
if (elementType === "text") {
|
||||
return t("hints.text");
|
||||
}
|
||||
|
||||
if (appState.activeTool.type === "image" && appState.pendingImageElement) {
|
||||
if (appState.elementType === "image" && appState.pendingImageElement) {
|
||||
return t("hints.placeImage");
|
||||
}
|
||||
|
||||
@@ -77,7 +69,7 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
||||
return t("hints.text_editing");
|
||||
}
|
||||
|
||||
if (activeTool.type === "selection") {
|
||||
if (elementType === "selection") {
|
||||
if (
|
||||
appState.draggingElement?.type === "selection" &&
|
||||
!appState.editingElement &&
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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 { useDeviceType } from "./App";
|
||||
import { useIsMobile } from "./App";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { exportToCanvas } from "../scene/export";
|
||||
import { AppState, BinaryFiles } from "../types";
|
||||
@@ -18,7 +19,6 @@ 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: ActionManager;
|
||||
actionManager: ActionsManagerInterface;
|
||||
onExportToPng: ExportCB;
|
||||
onExportToSvg: ExportCB;
|
||||
onExportToClipboard: ExportCB;
|
||||
@@ -229,7 +229,7 @@ export const ImageExportDialog = ({
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
files: BinaryFiles;
|
||||
exportPadding?: number;
|
||||
actionManager: ActionManager;
|
||||
actionManager: ActionsManagerInterface;
|
||||
onExportToPng: ExportCB;
|
||||
onExportToSvg: ExportCB;
|
||||
onExportToClipboard: ExportCB;
|
||||
@@ -250,7 +250,7 @@ export const ImageExportDialog = ({
|
||||
icon={exportImage}
|
||||
type="button"
|
||||
aria-label={t("buttons.exportImage")}
|
||||
showAriaLabel={useDeviceType().isMobile}
|
||||
showAriaLabel={useIsMobile()}
|
||||
title={t("buttons.exportImage")}
|
||||
/>
|
||||
{modalIsShown && (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState } from "react";
|
||||
import { ActionsManagerInterface } from "../actions/types";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { useDeviceType } from "./App";
|
||||
import { useIsMobile } from "./App";
|
||||
import { AppState, ExportOpts, BinaryFiles } from "../types";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { exportFile, exportToFileIcon, link } from "./icons";
|
||||
@@ -11,9 +12,6 @@ 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[],
|
||||
@@ -31,7 +29,7 @@ const JSONExportModal = ({
|
||||
appState: AppState;
|
||||
files: BinaryFiles;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
actionManager: ActionManager;
|
||||
actionManager: ActionsManagerInterface;
|
||||
onCloseRequest: () => void;
|
||||
exportOpts: ExportOpts;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
@@ -56,7 +54,7 @@ const JSONExportModal = ({
|
||||
aria-label={t("exportDialog.disk_button")}
|
||||
showAriaLabel={true}
|
||||
onClick={() => {
|
||||
actionManager.executeAction(actionSaveFileToDisk, "ui");
|
||||
actionManager.executeAction(actionSaveFileToDisk);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
@@ -72,10 +70,9 @@ const JSONExportModal = ({
|
||||
title={t("exportDialog.link_button")}
|
||||
aria-label={t("exportDialog.link_button")}
|
||||
showAriaLabel={true}
|
||||
onClick={() => {
|
||||
onExportToBackend(elements, appState, files, canvas);
|
||||
trackEvent("export", "link", `ui (${getFrame()})`);
|
||||
}}
|
||||
onClick={() =>
|
||||
onExportToBackend(elements, appState, files, canvas)
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
@@ -97,7 +94,7 @@ export const JSONExportDialog = ({
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
appState: AppState;
|
||||
files: BinaryFiles;
|
||||
actionManager: ActionManager;
|
||||
actionManager: ActionsManagerInterface;
|
||||
exportOpts: ExportOpts;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
}) => {
|
||||
@@ -117,7 +114,7 @@ export const JSONExportDialog = ({
|
||||
icon={exportFile}
|
||||
type="button"
|
||||
aria-label={t("buttons.export")}
|
||||
showAriaLabel={useDeviceType().isMobile}
|
||||
showAriaLabel={useIsMobile()}
|
||||
title={t("buttons.export")}
|
||||
/>
|
||||
{modalIsShown && (
|
||||
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
@@ -36,8 +37,6 @@ 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;
|
||||
@@ -96,7 +95,7 @@ const LayerUI = ({
|
||||
id,
|
||||
onImageAction,
|
||||
}: LayerUIProps) => {
|
||||
const deviceType = useDeviceType();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const renderJSONExportDialog = () => {
|
||||
if (!UIOptions.canvasActions.export) {
|
||||
@@ -123,7 +122,6 @@ const LayerUI = ({
|
||||
const createExporter =
|
||||
(type: ExportType): ExportCB =>
|
||||
async (exportedElements) => {
|
||||
trackEvent("export", type, "ui");
|
||||
const fileHandle = await exportCanvas(
|
||||
type,
|
||||
exportedElements,
|
||||
@@ -250,7 +248,7 @@ const LayerUI = ({
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
renderAction={actionManager.renderAction}
|
||||
activeTool={appState.activeTool.type}
|
||||
elementType={appState.elementType}
|
||||
/>
|
||||
</Island>
|
||||
</Section>
|
||||
@@ -327,8 +325,8 @@ const LayerUI = ({
|
||||
/>
|
||||
<LockButton
|
||||
zenModeEnabled={zenModeEnabled}
|
||||
checked={appState.activeTool.locked}
|
||||
onChange={() => onLockToggle()}
|
||||
checked={appState.elementLocked}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
/>
|
||||
<Island
|
||||
@@ -340,14 +338,13 @@ const LayerUI = ({
|
||||
<HintViewer
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
isMobile={deviceType.isMobile}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
{heading}
|
||||
<Stack.Row gap={1}>
|
||||
<ShapesSwitcher
|
||||
appState={appState}
|
||||
canvas={canvas}
|
||||
activeTool={appState.activeTool}
|
||||
elementType={appState.elementType}
|
||||
setAppState={setAppState}
|
||||
onImageAction={({ pointerType }) => {
|
||||
onImageAction({
|
||||
@@ -391,7 +388,7 @@ const LayerUI = ({
|
||||
</Tooltip>
|
||||
))}
|
||||
</UserList>
|
||||
{renderTopRightUI?.(deviceType.isMobile, appState)}
|
||||
{renderTopRightUI?.(isMobile, appState)}
|
||||
</div>
|
||||
</div>
|
||||
</FixedSideContainer>
|
||||
@@ -421,39 +418,16 @@ 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("eraser-buttons zen-mode-transition", {
|
||||
"layer-ui__wrapper__footer-left--transition-left":
|
||||
zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
{actionManager.renderAction("eraser", { 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>
|
||||
)}
|
||||
{!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>
|
||||
@@ -492,7 +466,7 @@ const LayerUI = ({
|
||||
|
||||
const dialogs = (
|
||||
<>
|
||||
{appState.isLoading && <LoadingMessage delay={250} />}
|
||||
{appState.isLoading && <LoadingMessage />}
|
||||
{appState.errorMessage && (
|
||||
<ErrorDialog
|
||||
message={appState.errorMessage}
|
||||
@@ -521,7 +495,7 @@ const LayerUI = ({
|
||||
</>
|
||||
);
|
||||
|
||||
return deviceType.isMobile ? (
|
||||
return isMobile ? (
|
||||
<>
|
||||
{dialogs}
|
||||
<MobileMenu
|
||||
@@ -533,7 +507,7 @@ const LayerUI = ({
|
||||
renderImageExportDialog={renderImageExportDialog}
|
||||
setAppState={setAppState}
|
||||
onCollabButtonClick={onCollabButtonClick}
|
||||
onLockToggle={() => onLockToggle()}
|
||||
onLockToggle={onLockToggle}
|
||||
onPenModeToggle={onPenModeToggle}
|
||||
canvas={canvas}
|
||||
isCollaborating={isCollaborating}
|
||||
|
||||
@@ -28,17 +28,8 @@
|
||||
}
|
||||
|
||||
.layer-ui__library-message {
|
||||
padding: 2em 4em;
|
||||
min-width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
.Spinner {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
span {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
padding: 10px 20px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.publish-library-success {
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import {
|
||||
useRef,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
RefObject,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
import Library, { libraryItemsAtom } from "../data/library";
|
||||
import { useRef, useState, useEffect, useCallback, RefObject } from "react";
|
||||
import Library from "../data/library";
|
||||
import { t } from "../i18n";
|
||||
import { randomId } from "../random";
|
||||
import {
|
||||
@@ -26,10 +19,6 @@ 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>,
|
||||
@@ -64,17 +53,6 @@ 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,
|
||||
@@ -124,6 +102,11 @@ 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);
|
||||
@@ -131,35 +114,55 @@ export const LibraryMenu = ({
|
||||
url: string;
|
||||
authorName: string;
|
||||
}>(null);
|
||||
const loadingTimerRef = useRef<number | null>(null);
|
||||
|
||||
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
||||
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 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 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 resetLibrary = useCallback(() => {
|
||||
library.resetLibrary();
|
||||
setLibraryItems([]);
|
||||
focusContainer();
|
||||
}, [library, focusContainer]);
|
||||
|
||||
const addToLibrary = useCallback(
|
||||
async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => {
|
||||
trackEvent("element", "addToLibrary", "ui");
|
||||
async (elements: LibraryItem["elements"]) => {
|
||||
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",
|
||||
@@ -167,12 +170,14 @@ export const LibraryMenu = ({
|
||||
id: randomId(),
|
||||
created: Date.now(),
|
||||
},
|
||||
...libraryItems,
|
||||
...items,
|
||||
];
|
||||
onAddToLibrary();
|
||||
library.saveLibrary(nextItems).catch(() => {
|
||||
library.saveLibrary(nextItems).catch((error) => {
|
||||
setLibraryItems(items);
|
||||
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
||||
});
|
||||
setLibraryItems(nextItems);
|
||||
},
|
||||
[onAddToLibrary, library, setAppState],
|
||||
);
|
||||
@@ -211,7 +216,7 @@ export const LibraryMenu = ({
|
||||
}, [setPublishLibSuccess, publishLibSuccess]);
|
||||
|
||||
const onPublishLibSuccess = useCallback(
|
||||
(data, libraryItems: LibraryItems) => {
|
||||
(data) => {
|
||||
setShowPublishLibraryDialog(false);
|
||||
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
|
||||
const nextLibItems = libraryItems.slice();
|
||||
@@ -221,109 +226,101 @@ export const LibraryMenu = ({
|
||||
}
|
||||
});
|
||||
library.saveLibrary(nextLibItems);
|
||||
setLibraryItems(nextLibItems);
|
||||
},
|
||||
[setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
|
||||
[
|
||||
setShowPublishLibraryDialog,
|
||||
setPublishLibSuccess,
|
||||
libraryItems,
|
||||
selectedItems,
|
||||
library,
|
||||
],
|
||||
);
|
||||
|
||||
const [lastSelectedItem, setLastSelectedItem] = useState<
|
||||
LibraryItem["id"] | null
|
||||
>(null);
|
||||
|
||||
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}>
|
||||
return loadingState === "preloading" ? null : (
|
||||
<Island padding={1} ref={ref} className="layer-ui__library">
|
||||
{showPublishLibraryDialog && (
|
||||
<PublishLibrary
|
||||
onClose={() => setShowPublishLibraryDialog(false)}
|
||||
libraryItems={getSelectedItems(
|
||||
libraryItemsData.libraryItems,
|
||||
selectedItems,
|
||||
)}
|
||||
libraryItems={getSelectedItems(libraryItems, selectedItems)}
|
||||
appState={appState}
|
||||
onSuccess={(data) =>
|
||||
onPublishLibSuccess(data, libraryItemsData.libraryItems)
|
||||
}
|
||||
onSuccess={onPublishLibSuccess}
|
||||
onError={(error) => window.alert(error)}
|
||||
updateItemsInStorage={() =>
|
||||
library.saveLibrary(libraryItemsData.libraryItems)
|
||||
}
|
||||
updateItemsInStorage={() => library.saveLibrary(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);
|
||||
|
||||
if (shouldSelect) {
|
||||
if (event.shiftKey && lastSelectedItem) {
|
||||
const rangeStart = libraryItemsData.libraryItems.findIndex(
|
||||
(item) => item.id === lastSelectedItem,
|
||||
);
|
||||
const rangeEnd = libraryItemsData.libraryItems.findIndex(
|
||||
(item) => item.id === 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 (rangeStart === -1 || rangeEnd === -1) {
|
||||
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 {
|
||||
setSelectedItems([...selectedItems, id]);
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
setLastSelectedItem(id);
|
||||
} else {
|
||||
setSelectedItems([...selectedItems, id]);
|
||||
setLastSelectedItem(null);
|
||||
setSelectedItems(selectedItems.filter((_id) => _id !== id));
|
||||
}
|
||||
setLastSelectedItem(id);
|
||||
} else {
|
||||
setLastSelectedItem(null);
|
||||
setSelectedItems(selectedItems.filter((_id) => _id !== id));
|
||||
}
|
||||
}}
|
||||
onPublish={() => setShowPublishLibraryDialog(true)}
|
||||
resetLibrary={resetLibrary}
|
||||
/>
|
||||
</LibraryMenuWrapper>
|
||||
}}
|
||||
onPublish={() => setShowPublishLibraryDialog(true)}
|
||||
resetLibrary={resetLibrary}
|
||||
/>
|
||||
)}
|
||||
</Island>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
LibraryItems,
|
||||
} from "../types";
|
||||
import { muteFSAbortError } from "../utils";
|
||||
import { useDeviceType } from "./App";
|
||||
import { useIsMobile } 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 = useDeviceType().isMobile;
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const renderLibraryActions = () => {
|
||||
const itemsSelected = !!selectedItems.length;
|
||||
@@ -106,6 +106,11 @@ 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 { useDeviceType } from "../components/App";
|
||||
import { useIsMobile } 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 = useDeviceType().isMobile;
|
||||
const isMobile = useIsMobile();
|
||||
const adder = isPending && (
|
||||
<div className="library-unit__adder">{PLUS_ICON}</div>
|
||||
);
|
||||
|
||||
@@ -1,30 +1,10 @@
|
||||
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">
|
||||
<div>
|
||||
<Spinner />
|
||||
</div>
|
||||
<div className="LoadingMessage-text">{t("labels.loadingScene")}</div>
|
||||
<span>{t("labels.loadingScene")}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { FixedSideContainer } from "./FixedSideContainer";
|
||||
import { Island } from "./Island";
|
||||
import { HintViewer } from "./HintViewer";
|
||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
||||
import { calculateScrollCenter } from "../scene";
|
||||
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||
import { Section } from "./Section";
|
||||
import CollabButton from "./CollabButton";
|
||||
@@ -72,9 +72,8 @@ export const MobileMenu = ({
|
||||
{heading}
|
||||
<Stack.Row gap={1}>
|
||||
<ShapesSwitcher
|
||||
appState={appState}
|
||||
canvas={canvas}
|
||||
activeTool={appState.activeTool}
|
||||
elementType={appState.elementType}
|
||||
setAppState={setAppState}
|
||||
onImageAction={({ pointerType }) => {
|
||||
onImageAction({
|
||||
@@ -86,7 +85,7 @@ export const MobileMenu = ({
|
||||
</Island>
|
||||
{renderTopRightUI && renderTopRightUI(true, appState)}
|
||||
<LockButton
|
||||
checked={appState.activeTool.locked}
|
||||
checked={appState.elementLocked}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
isMobile
|
||||
@@ -114,12 +113,6 @@ 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">
|
||||
@@ -127,16 +120,12 @@ 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",
|
||||
)}
|
||||
@@ -226,7 +215,7 @@ export const MobileMenu = ({
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
renderAction={actionManager.renderAction}
|
||||
activeTool={appState.activeTool.type}
|
||||
elementType={appState.elementType}
|
||||
/>
|
||||
</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, useDeviceType } from "./App";
|
||||
import { useExcalidrawContainer, useIsMobile } 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 deviceType = useDeviceType();
|
||||
const isMobileRef = useRef(deviceType.isMobile);
|
||||
isMobileRef.current = deviceType.isMobile;
|
||||
const isMobile = useIsMobile();
|
||||
const isMobileRef = useRef(isMobile);
|
||||
isMobileRef.current = isMobile;
|
||||
|
||||
const { container: excalidrawContainer } = useExcalidrawContainer();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (div) {
|
||||
div.classList.toggle("excalidraw--mobile", deviceType.isMobile);
|
||||
div.classList.toggle("excalidraw--mobile", isMobile);
|
||||
}
|
||||
}, [div, deviceType.isMobile]);
|
||||
}, [div, 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 { useDeviceType } from "../components/App";
|
||||
import { useIsMobile } 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 deviceType = useDeviceType();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const boundingBox = getCommonBounds(props.elements);
|
||||
const selectedElements = getTargetElements(props.elements, props.appState);
|
||||
const selectedBoundingBox = getCommonBounds(selectedElements);
|
||||
|
||||
if (deviceType.isMobile && props.appState.openMenu) {
|
||||
if (isMobile && props.appState.openMenu) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,6 @@ 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) => {
|
||||
@@ -150,7 +149,6 @@ 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: 2rem;
|
||||
height: 2em;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -934,7 +934,3 @@ 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" />,
|
||||
);
|
||||
|
||||
@@ -63,6 +63,8 @@ export const ENV = {
|
||||
|
||||
export const CLASSES = {
|
||||
SHAPE_ACTIONS_MENU: "App-menu__left",
|
||||
SHAPE_ACTIONS_MOBILE_MENU: "App-mobile-menu",
|
||||
MOBILE_TOOLBAR: "App-toolbar-content",
|
||||
};
|
||||
|
||||
// 1-based in case we ever do `if(element.fontFamily)`
|
||||
@@ -94,9 +96,7 @@ 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",
|
||||
@@ -190,5 +190,3 @@ export const VERTICAL_ALIGN = {
|
||||
MIDDLE: "middle",
|
||||
BOTTOM: "bottom",
|
||||
};
|
||||
|
||||
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
|
||||
|
||||
@@ -16,17 +16,15 @@
|
||||
left: 0;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
|
||||
.Spinner {
|
||||
font-size: 2.8em;
|
||||
}
|
||||
|
||||
.LoadingMessage-text {
|
||||
margin-top: 1em;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -290,16 +290,6 @@
|
||||
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 {
|
||||
@@ -477,17 +467,7 @@
|
||||
font-family: var(--ui-font);
|
||||
}
|
||||
|
||||
.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 {
|
||||
.undo-redo-buttons {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
gap: 0.4em;
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { cleanAppStateForExport } from "../appState";
|
||||
import { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
|
||||
import {
|
||||
ALLOWED_IMAGE_MIME_TYPES,
|
||||
EXPORT_DATA_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, LibraryItem } from "../types";
|
||||
import { AppState, DataURL } from "../types";
|
||||
import { bytesToHexString } from "../utils";
|
||||
import { FileSystemHandle } from "./filesystem";
|
||||
import { isValidExcalidrawData, isValidLibrary } from "./json";
|
||||
import { restore, restoreLibraryItems } from "./restore";
|
||||
import { isValidExcalidrawData } from "./json";
|
||||
import { restore } from "./restore";
|
||||
import { ImportedLibraryData } from "./types";
|
||||
|
||||
const parseFileContents = async (blob: Blob | File) => {
|
||||
@@ -159,17 +163,13 @@ export const loadFromBlob = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const loadLibraryFromBlob = async (
|
||||
blob: Blob,
|
||||
defaultStatus: LibraryItem["status"] = "unpublished",
|
||||
) => {
|
||||
export const loadLibraryFromBlob = async (blob: Blob) => {
|
||||
const contents = await parseFileContents(blob);
|
||||
const data: ImportedLibraryData | undefined = JSON.parse(contents);
|
||||
if (!isValidLibrary(data)) {
|
||||
throw new Error("Invalid library");
|
||||
const data: ImportedLibraryData = JSON.parse(contents);
|
||||
if (data.type !== EXPORT_DATA_TYPES.excalidrawLibrary) {
|
||||
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
||||
}
|
||||
const libraryItems = data.libraryItems || data.library;
|
||||
return restoreLibraryItems(libraryItems, defaultStatus);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const canvasToBlob = async (
|
||||
|
||||
@@ -13,9 +13,7 @@ type FILE_EXTENSION =
|
||||
| "gif"
|
||||
| "jpg"
|
||||
| "png"
|
||||
| "excalidraw.png"
|
||||
| "svg"
|
||||
| "excalidraw.svg"
|
||||
| "json"
|
||||
| "excalidraw"
|
||||
| "excalidrawlib";
|
||||
|
||||
@@ -105,9 +105,7 @@ 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 -->\s*(.+?)\s*<!-- payload-end -->/,
|
||||
);
|
||||
const match = svg.match(/<!-- payload-start -->(.+?)<!-- payload-end -->/);
|
||||
if (!match) {
|
||||
throw new Error("INVALID");
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export { loadFromBlob } from "./blob";
|
||||
export { loadFromJSON, saveAsJSON } from "./json";
|
||||
|
||||
export const exportCanvas = async (
|
||||
type: Omit<ExportType, "backend">,
|
||||
type: ExportType,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
@@ -56,7 +56,7 @@ export const exportCanvas = async (
|
||||
{
|
||||
description: "Export to SVG",
|
||||
name,
|
||||
extension: appState.exportEmbedScene ? "excalidraw.svg" : "svg",
|
||||
extension: "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,24 +89,17 @@ export const exportCanvas = async (
|
||||
return await fileSave(blob, {
|
||||
description: "Export to PNG",
|
||||
name,
|
||||
extension: appState.exportEmbedScene ? "excalidraw.png" : "png",
|
||||
extension: "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");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
ExportedDataState,
|
||||
ImportedDataState,
|
||||
ExportedLibraryData,
|
||||
ImportedLibraryData,
|
||||
} from "./types";
|
||||
import Library from "./library";
|
||||
|
||||
@@ -115,7 +114,7 @@ export const isValidExcalidrawData = (data?: {
|
||||
);
|
||||
};
|
||||
|
||||
export const isValidLibrary = (json: any): json is ImportedLibraryData => {
|
||||
export const isValidLibrary = (json: any) => {
|
||||
return (
|
||||
typeof json === "object" &&
|
||||
json &&
|
||||
@@ -124,18 +123,14 @@ export const isValidLibrary = (json: any): json is ImportedLibraryData => {
|
||||
);
|
||||
};
|
||||
|
||||
export const serializeLibraryAsJSON = (libraryItems: LibraryItems) => {
|
||||
export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
|
||||
const data: ExportedLibraryData = {
|
||||
type: EXPORT_DATA_TYPES.excalidrawLibrary,
|
||||
version: VERSIONS.excalidrawLibrary,
|
||||
source: EXPORT_SOURCE,
|
||||
libraryItems,
|
||||
};
|
||||
return JSON.stringify(data, null, 2);
|
||||
};
|
||||
|
||||
export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
|
||||
const serialized = serializeLibraryAsJSON(libraryItems);
|
||||
const serialized = JSON.stringify(data, null, 2);
|
||||
await fileSave(
|
||||
new Blob([serialized], {
|
||||
type: MIME_TYPES.excalidrawlib,
|
||||
|
||||
@@ -1,52 +1,11 @@
|
||||
import { loadLibraryFromBlob } from "./blob";
|
||||
import { LibraryItems, LibraryItem } from "../types";
|
||||
import { restoreLibraryItems } from "./restore";
|
||||
import { restoreElements, restoreLibraryItems } from "./restore";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
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 {
|
||||
/** cache for currently active promise when initializing/updating libaries
|
||||
asynchronously */
|
||||
private libraryItemsPromise: Promise<LibraryItems> | null = null;
|
||||
/** last resolved libraryItems */
|
||||
private lastLibraryItems: LibraryItems = [];
|
||||
|
||||
private libraryCache: LibraryItems | null = null;
|
||||
private app: App;
|
||||
|
||||
constructor(app: App) {
|
||||
@@ -54,92 +13,107 @@ class Library {
|
||||
}
|
||||
|
||||
resetLibrary = async () => {
|
||||
this.saveLibrary([]);
|
||||
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;
|
||||
};
|
||||
|
||||
/** imports library (currently merges, removing duplicates) */
|
||||
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);
|
||||
}
|
||||
async importLibrary(blob: Blob, defaultStatus = "unpublished") {
|
||||
const libraryFile = await loadLibraryFromBlob(blob);
|
||||
if (!libraryFile || !(libraryFile.libraryItems || libraryFile.library)) {
|
||||
return;
|
||||
}
|
||||
|
||||
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")));
|
||||
/**
|
||||
* 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
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
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 {
|
||||
resolve(
|
||||
cloneLibraryItems(
|
||||
await (this.libraryItemsPromise || this.lastLibraryItems),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
return resolve(this.lastLibraryItems);
|
||||
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([]);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
saveLibrary = async (items: LibraryItems | Promise<LibraryItems>) => {
|
||||
const prevLibraryItems = this.lastLibraryItems;
|
||||
saveLibrary = async (items: LibraryItems) => {
|
||||
const prevLibraryItems = this.libraryCache;
|
||||
try {
|
||||
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),
|
||||
);
|
||||
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);
|
||||
} catch (error: any) {
|
||||
this.lastLibraryItems = prevLibraryItems;
|
||||
this.libraryItemsPromise = null;
|
||||
jotaiStore.set(libraryItemsAtom, {
|
||||
status: "loaded",
|
||||
libraryItems: prevLibraryItems,
|
||||
});
|
||||
this.libraryCache = prevLibraryItems;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -10,11 +10,7 @@ import {
|
||||
NormalizedZoomValue,
|
||||
} from "../types";
|
||||
import { ImportedDataState } from "./types";
|
||||
import {
|
||||
getNonDeletedElements,
|
||||
getNormalizedDimensions,
|
||||
isInvisiblySmallElement,
|
||||
} from "../element";
|
||||
import { getNormalizedDimensions, isInvisiblySmallElement } from "../element";
|
||||
import { isLinearElementType } from "../element/typeChecks";
|
||||
import { randomId } from "../random";
|
||||
import {
|
||||
@@ -34,9 +30,9 @@ type RestoredAppState = Omit<
|
||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||
>;
|
||||
|
||||
export const AllowedExcalidrawActiveTools: Record<
|
||||
AppState["activeTool"]["type"],
|
||||
boolean
|
||||
export const AllowedExcalidrawElementTypes: Record<
|
||||
ExcalidrawElement["type"],
|
||||
true
|
||||
> = {
|
||||
selection: true,
|
||||
text: true,
|
||||
@@ -47,7 +43,6 @@ export const AllowedExcalidrawActiveTools: Record<
|
||||
image: true,
|
||||
arrow: true,
|
||||
freedraw: true,
|
||||
eraser: false,
|
||||
};
|
||||
|
||||
export type RestoredDataState = {
|
||||
@@ -111,7 +106,6 @@ const restoreElementWithProperties = <
|
||||
: element.boundElements ?? [],
|
||||
updated: element.updated ?? getUpdatedTimestamp(),
|
||||
link: element.link ?? null,
|
||||
locked: element.locked ?? false,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -240,8 +234,10 @@ 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,
|
||||
@@ -255,20 +251,12 @@ export const restoreAppState = (
|
||||
? localValue
|
||||
: defaultValue;
|
||||
}
|
||||
|
||||
return {
|
||||
...nextAppState,
|
||||
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",
|
||||
},
|
||||
elementType: AllowedExcalidrawElementTypes[nextAppState.elementType]
|
||||
? nextAppState.elementType
|
||||
: "selection",
|
||||
// Migrates from previous version where appState.zoom was a number
|
||||
zoom:
|
||||
typeof appState.zoom === "number"
|
||||
@@ -280,7 +268,7 @@ export const restoreAppState = (
|
||||
};
|
||||
|
||||
export const restore = (
|
||||
data: Pick<ImportedDataState, "appState" | "elements" | "files"> | null,
|
||||
data: ImportedDataState | null,
|
||||
/**
|
||||
* Local AppState (`this.state` or initial state from localStorage) so that we
|
||||
* don't overwrite local state with default values (when values not
|
||||
@@ -297,45 +285,28 @@ export const restore = (
|
||||
};
|
||||
};
|
||||
|
||||
const restoreLibraryItem = (libraryItem: LibraryItem) => {
|
||||
const elements = restoreElements(
|
||||
getNonDeletedElements(libraryItem.elements),
|
||||
null,
|
||||
);
|
||||
return elements.length ? { ...libraryItem, elements } : null;
|
||||
};
|
||||
|
||||
export const restoreLibraryItems = (
|
||||
libraryItems: ImportedDataState["libraryItems"] = [],
|
||||
libraryItems: NonOptional<ImportedDataState["libraryItems"]>,
|
||||
defaultStatus: LibraryItem["status"],
|
||||
) => {
|
||||
const restoredItems: LibraryItem[] = [];
|
||||
for (const item of libraryItems) {
|
||||
// migrate older libraries
|
||||
if (Array.isArray(item)) {
|
||||
const restoredItem = restoreLibraryItem({
|
||||
restoredItems.push({
|
||||
status: defaultStatus,
|
||||
elements: item,
|
||||
id: randomId(),
|
||||
created: Date.now(),
|
||||
});
|
||||
if (restoredItem) {
|
||||
restoredItems.push(restoredItem);
|
||||
}
|
||||
} else {
|
||||
const _item = item as MarkOptional<
|
||||
LibraryItem,
|
||||
"id" | "status" | "created"
|
||||
>;
|
||||
const restoredItem = restoreLibraryItem({
|
||||
const _item = item as MarkOptional<LibraryItem, "id" | "status">;
|
||||
restoredItems.push({
|
||||
..._item,
|
||||
id: _item.id || randomId(),
|
||||
status: _item.status || defaultStatus,
|
||||
created: _item.created || Date.now(),
|
||||
});
|
||||
if (restoredItem) {
|
||||
restoredItems.push(restoredItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
return restoredItems;
|
||||
|
||||
@@ -262,7 +262,9 @@ export const actionLink = register({
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
trackEvent: { category: "hyperlink", action: "click" },
|
||||
trackEvent: (action, source) => {
|
||||
trackEvent("hyperlink", "edit", source);
|
||||
},
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K,
|
||||
contextItemLabel: (elements, appState) =>
|
||||
getContextMenuLabel(elements, appState),
|
||||
@@ -335,9 +337,6 @@ 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 &&
|
||||
|
||||
@@ -255,8 +255,7 @@ export const getHoveredElementForBinding = (
|
||||
const hoveredElement = getElementAtPosition(
|
||||
scene.getElements(),
|
||||
(element) =>
|
||||
isBindableElement(element, false) &&
|
||||
bindingBorderTest(element, pointerCoords),
|
||||
isBindableElement(element) && bindingBorderTest(element, pointerCoords),
|
||||
);
|
||||
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
|
||||
};
|
||||
@@ -457,13 +456,13 @@ export const getEligibleElementsForBinding = (
|
||||
): SuggestedBinding[] => {
|
||||
const includedElementIds = new Set(elements.map(({ id }) => id));
|
||||
return elements.flatMap((element) =>
|
||||
isBindingElement(element, false)
|
||||
isBindingElement(element)
|
||||
? (getElligibleElementsForBindingElement(
|
||||
element as NonDeleted<ExcalidrawLinearElement>,
|
||||
).filter(
|
||||
(element) => !includedElementIds.has(element.id),
|
||||
) as SuggestedBinding[])
|
||||
: isBindableElement(element, false)
|
||||
: isBindableElement(element)
|
||||
? getElligibleElementsForBindableElementAndWhere(element).filter(
|
||||
(binding) => !includedElementIds.has(binding[0].id),
|
||||
)
|
||||
@@ -509,7 +508,7 @@ const getElligibleElementsForBindableElementAndWhere = (
|
||||
return Scene.getScene(bindableElement)!
|
||||
.getElements()
|
||||
.map((element) => {
|
||||
if (!isBindingElement(element, false)) {
|
||||
if (!isBindingElement(element)) {
|
||||
return null;
|
||||
}
|
||||
const canBindStart = isLinearElementEligibleForNewBindingByBindable(
|
||||
@@ -660,47 +659,28 @@ export const fixBindingsAfterDeletion = (
|
||||
const deletedElementIds = new Set(
|
||||
deletedElements.map((element) => element.id),
|
||||
);
|
||||
// non-deleted which bindings need to be updated
|
||||
const affectedElements: Set<ExcalidrawElement["id"]> = new Set();
|
||||
// Non deleted and need an update
|
||||
const boundElementIds: Set<ExcalidrawElement["id"]> = new Set();
|
||||
deletedElements.forEach((deletedElement) => {
|
||||
if (isBindableElement(deletedElement)) {
|
||||
deletedElement.boundElements?.forEach((element) => {
|
||||
if (!deletedElementIds.has(element.id)) {
|
||||
affectedElements.add(element.id);
|
||||
boundElementIds.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 }) => 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,
|
||||
),
|
||||
});
|
||||
}
|
||||
(
|
||||
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),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const newBindingAfterDeletion = (
|
||||
@@ -712,13 +692,3 @@ 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,3 +1,4 @@
|
||||
import { SHAPES } from "../shapes";
|
||||
import { updateBoundElements } from "./binding";
|
||||
import { getCommonBounds } from "./bounds";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
@@ -92,7 +93,7 @@ export const getDragOffsetXY = (
|
||||
|
||||
export const dragNewElement = (
|
||||
draggingElement: NonDeletedExcalidrawElement,
|
||||
elementType: AppState["activeTool"]["type"],
|
||||
elementType: typeof SHAPES[number]["value"],
|
||||
originX: number,
|
||||
originY: number,
|
||||
x: number,
|
||||
|
||||
@@ -106,20 +106,6 @@ 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, false)) {
|
||||
if (isBindingElement(element)) {
|
||||
const coords: { x: number; y: number }[] = [];
|
||||
|
||||
const firstSelectedIndex = selectedPointsIndices[0];
|
||||
|
||||
@@ -2,7 +2,11 @@ import { duplicateElement } from "./newElement";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { FONT_FAMILY } from "../constants";
|
||||
import { isPrimitive } from "../utils";
|
||||
|
||||
const isPrimitive = (val: any) => {
|
||||
const type = typeof val;
|
||||
return val == null || (type !== "object" && type !== "function");
|
||||
};
|
||||
|
||||
const assertCloneObjects = (source: any, clone: any) => {
|
||||
for (const key in clone) {
|
||||
|
||||
@@ -56,7 +56,6 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||
strokeSharpness,
|
||||
boundElements = null,
|
||||
link = null,
|
||||
locked,
|
||||
...rest
|
||||
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
|
||||
) => {
|
||||
@@ -84,7 +83,6 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||
boundElements,
|
||||
updated: getUpdatedTimestamp(),
|
||||
link,
|
||||
locked,
|
||||
};
|
||||
return element;
|
||||
};
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
ExcalidrawTextElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeleted,
|
||||
ExcalidrawElement,
|
||||
} from "./types";
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
@@ -187,7 +186,7 @@ const validateTwoPointElementNormalized = (
|
||||
};
|
||||
|
||||
const getPerfectElementSizeWithRotation = (
|
||||
elementType: ExcalidrawElement["type"],
|
||||
elementType: string,
|
||||
width: number,
|
||||
height: number,
|
||||
angle: number,
|
||||
|
||||
@@ -10,6 +10,5 @@ export const showSelectedShapeActions = (
|
||||
!appState.viewModeEnabled &&
|
||||
(appState.editingElement ||
|
||||
getSelectedElements(elements, appState).length ||
|
||||
(appState.activeTool.type !== "selection" &&
|
||||
appState.activeTool.type !== "eraser")),
|
||||
appState.elementType !== "selection"),
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ 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,
|
||||
@@ -17,7 +16,7 @@ export const isInvisiblySmallElement = (
|
||||
* Makes a perfect shape or diagonal/horizontal/vertical line
|
||||
*/
|
||||
export const getPerfectElementSize = (
|
||||
elementType: AppState["activeTool"]["type"],
|
||||
elementType: string,
|
||||
width: number,
|
||||
height: number,
|
||||
): { width: number; height: number } => {
|
||||
|
||||
@@ -10,11 +10,13 @@ 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
|
||||
@@ -33,12 +35,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) {
|
||||
@@ -53,12 +55,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 triggered", async () => {
|
||||
it("should unbind bound text when unbind action from context menu is triggred", async () => {
|
||||
expect(h.elements.length).toBe(1);
|
||||
expect(h.elements[0].id).toBe(rectangle.id);
|
||||
|
||||
@@ -745,47 +745,5 @@ 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,11 +102,9 @@ export const textWysiwyg = ({
|
||||
|
||||
const updateWysiwygStyle = () => {
|
||||
const appState = app.state;
|
||||
const updatedElement =
|
||||
Scene.getScene(element)?.getElement<ExcalidrawTextElement>(id);
|
||||
if (!updatedElement) {
|
||||
return;
|
||||
}
|
||||
const updatedElement = Scene.getScene(element)?.getElement(
|
||||
id,
|
||||
) as ExcalidrawTextElement;
|
||||
const { textAlign, verticalAlign } = updatedElement;
|
||||
|
||||
const approxLineHeight = getApproxLineHeight(getFontString(updatedElement));
|
||||
@@ -544,9 +542,29 @@ export const textWysiwyg = ({
|
||||
target.closest(".color-picker-input") &&
|
||||
isWritableElement(target);
|
||||
|
||||
const isShapeActionsPanel =
|
||||
(target instanceof HTMLElement || target instanceof SVGElement) &&
|
||||
(target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) ||
|
||||
target.closest(`.${CLASSES.SHAPE_ACTIONS_MOBILE_MENU}`) ||
|
||||
target.closest(`.${CLASSES.MOBILE_TOOLBAR}`));
|
||||
|
||||
setTimeout(() => {
|
||||
editable.onblur = handleSubmit;
|
||||
if (target && isTargetColorPicker) {
|
||||
editable.onblur = () => {
|
||||
app.setState({
|
||||
toastMessage:
|
||||
target instanceof HTMLElement
|
||||
? target.tagName ?? "no tagName"
|
||||
: "not an HTMLElement",
|
||||
});
|
||||
if (isShapeActionsPanel) {
|
||||
return;
|
||||
}
|
||||
app.setState({
|
||||
toastMessage: "debug: onblur",
|
||||
});
|
||||
handleSubmit();
|
||||
};
|
||||
if (target && (isTargetColorPicker || isShapeActionsPanel)) {
|
||||
target.onblur = () => {
|
||||
editable.focus();
|
||||
};
|
||||
@@ -564,13 +582,22 @@ export const textWysiwyg = ({
|
||||
event.target instanceof HTMLInputElement &&
|
||||
event.target.closest(".color-picker-input") &&
|
||||
isWritableElement(event.target);
|
||||
const isShapeActionsPanel =
|
||||
(event.target instanceof HTMLElement ||
|
||||
event.target instanceof SVGElement) &&
|
||||
(event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) ||
|
||||
event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MOBILE_MENU}`) ||
|
||||
event.target.closest(`.${CLASSES.MOBILE_TOOLBAR}`));
|
||||
if (
|
||||
((event.target instanceof HTMLElement ||
|
||||
event.target instanceof SVGElement) &&
|
||||
event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
|
||||
isShapeActionsPanel &&
|
||||
!isWritableElement(event.target)) ||
|
||||
isTargetColorPicker
|
||||
) {
|
||||
app.setState({
|
||||
toastMessage: "debug: onPointerDown",
|
||||
});
|
||||
editable.onblur = null;
|
||||
window.addEventListener("pointerup", bindBlurEvent);
|
||||
// handle edge-case where pointerup doesn't fire e.g. due to user
|
||||
|
||||
@@ -222,13 +222,6 @@ 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,4 +1,3 @@
|
||||
import { AppState } from "../types";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
@@ -61,7 +60,7 @@ export const isLinearElement = (
|
||||
};
|
||||
|
||||
export const isLinearElementType = (
|
||||
elementType: AppState["activeTool"]["type"],
|
||||
elementType: ExcalidrawElement["type"],
|
||||
): boolean => {
|
||||
return (
|
||||
elementType === "arrow" || elementType === "line" // || elementType === "freedraw"
|
||||
@@ -70,28 +69,21 @@ export const isLinearElementType = (
|
||||
|
||||
export const isBindingElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
includeLocked = true,
|
||||
): element is ExcalidrawLinearElement => {
|
||||
return (
|
||||
element != null &&
|
||||
(!element.locked || includeLocked === true) &&
|
||||
isBindingElementType(element.type)
|
||||
);
|
||||
return element != null && isBindingElementType(element.type);
|
||||
};
|
||||
|
||||
export const isBindingElementType = (
|
||||
elementType: AppState["activeTool"]["type"],
|
||||
elementType: ExcalidrawElement["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" ||
|
||||
@@ -102,11 +94,9 @@ 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,7 +55,6 @@ 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 WS_EVENTS = {
|
||||
export const BROADCAST = {
|
||||
SERVER_VOLATILE: "server-volatile-broadcast",
|
||||
SERVER: "server-broadcast",
|
||||
};
|
||||
|
||||
export enum WS_SCENE_EVENT_TYPES {
|
||||
export enum SCENE {
|
||||
INIT = "SCENE_INIT",
|
||||
UPDATE = "SCENE_UPDATE",
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
import { getSceneVersion } from "../../packages/excalidraw/index";
|
||||
import { Collaborator, Gesture } from "../../types";
|
||||
import {
|
||||
getFrame,
|
||||
preventUnload,
|
||||
resolvablePromise,
|
||||
withBatchedUpdates,
|
||||
@@ -22,7 +21,7 @@ import {
|
||||
FIREBASE_STORAGE_PREFIXES,
|
||||
INITIAL_SCENE_UPDATE_TIMEOUT,
|
||||
LOAD_IMAGES_TIMEOUT,
|
||||
WS_SCENE_EVENT_TYPES,
|
||||
SCENE,
|
||||
STORAGE_KEYS,
|
||||
SYNC_FULL_SCENE_INTERVAL_MS,
|
||||
} from "../app_constants";
|
||||
@@ -68,7 +67,6 @@ import {
|
||||
} from "./reconciliation";
|
||||
import { decryptData } from "../../data/encryption";
|
||||
import { resetBrowserStateVersions } from "../data/tabSync";
|
||||
import { LocalData } from "../data/LocalData";
|
||||
|
||||
interface CollabState {
|
||||
modalIsShown: boolean;
|
||||
@@ -88,7 +86,7 @@ export interface CollabAPI {
|
||||
onPointerUpdate: CollabInstance["onPointerUpdate"];
|
||||
initializeSocketClient: CollabInstance["initializeSocketClient"];
|
||||
onCollabButtonClick: CollabInstance["onCollabButtonClick"];
|
||||
syncElements: CollabInstance["syncElements"];
|
||||
broadcastElements: CollabInstance["broadcastElements"];
|
||||
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
|
||||
setUsername: (username: string) => void;
|
||||
}
|
||||
@@ -110,11 +108,10 @@ 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>();
|
||||
@@ -195,8 +192,6 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
}
|
||||
}
|
||||
|
||||
isCollaborating = () => this._isCollaborating;
|
||||
|
||||
private onUnload = () => {
|
||||
this.destroySocketClient({ isUnload: true });
|
||||
};
|
||||
@@ -207,7 +202,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
);
|
||||
|
||||
if (
|
||||
this._isCollaborating &&
|
||||
this.isCollaborating &&
|
||||
(this.fileManager.shouldPreventUnload(syncableElements) ||
|
||||
!isSavedToFirebase(this.portal, syncableElements))
|
||||
) {
|
||||
@@ -232,40 +227,27 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
});
|
||||
|
||||
saveCollabRoomToFirebase = async (
|
||||
syncableElements: readonly ExcalidrawElement[],
|
||||
syncableElements: readonly ExcalidrawElement[] = this.getSyncableElements(
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
),
|
||||
) => {
|
||||
try {
|
||||
const savedData = await saveToFirebase(
|
||||
this.portal,
|
||||
syncableElements,
|
||||
this.excalidrawAPI.getAppState(),
|
||||
);
|
||||
|
||||
if (this.isCollaborating() && savedData && savedData.reconciledElements) {
|
||||
this.handleRemoteSceneUpdate(
|
||||
this.reconcileElements(savedData.reconciledElements),
|
||||
);
|
||||
}
|
||||
await saveToFirebase(this.portal, syncableElements);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
openPortal = async () => {
|
||||
trackEvent("share", "room creation", `ui (${getFrame()})`);
|
||||
trackEvent("share", "room creation");
|
||||
return this.initializeSocketClient(null);
|
||||
};
|
||||
|
||||
closePortal = () => {
|
||||
this.queueBroadcastAllElements.cancel();
|
||||
this.queueSaveToFirebase.cancel();
|
||||
this.loadImageFiles.cancel();
|
||||
|
||||
this.saveCollabRoomToFirebase(
|
||||
this.getSyncableElements(
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
),
|
||||
);
|
||||
this.saveCollabRoomToFirebase();
|
||||
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
|
||||
@@ -302,8 +284,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
this.setState({
|
||||
activeRoomLink: "",
|
||||
});
|
||||
this._isCollaborating = false;
|
||||
LocalData.resumeSave("collaboration");
|
||||
this.isCollaborating = false;
|
||||
}
|
||||
this.lastBroadcastedOrReceivedSceneVersion = -1;
|
||||
this.portal.close();
|
||||
@@ -371,8 +352,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
|
||||
const scenePromise = resolvablePromise<ImportedDataState | null>();
|
||||
|
||||
this._isCollaborating = true;
|
||||
LocalData.pauseSave("collaboration");
|
||||
this.isCollaborating = true;
|
||||
|
||||
const { default: socketIOClient } = await import(
|
||||
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
|
||||
@@ -413,7 +393,10 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
commitToHistory: true,
|
||||
});
|
||||
|
||||
this.saveCollabRoomToFirebase(this.getSyncableElements(elements));
|
||||
this.broadcastElements(elements);
|
||||
|
||||
const syncableElements = this.getSyncableElements(elements);
|
||||
this.saveCollabRoomToFirebase(syncableElements);
|
||||
}
|
||||
|
||||
// fallback in case you're not alone in the room but still don't receive
|
||||
@@ -443,7 +426,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
switch (decryptedData.type) {
|
||||
case "INVALID_RESPONSE":
|
||||
return;
|
||||
case WS_SCENE_EVENT_TYPES.INIT: {
|
||||
case SCENE.INIT: {
|
||||
if (!this.portal.socketInitialized) {
|
||||
this.initializeRoom({ fetchScene: false });
|
||||
const remoteElements = decryptedData.payload.elements;
|
||||
@@ -459,7 +442,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case WS_SCENE_EVENT_TYPES.UPDATE:
|
||||
case SCENE.UPDATE:
|
||||
this.handleRemoteSceneUpdate(
|
||||
this.reconcileElements(decryptedData.payload.elements),
|
||||
);
|
||||
@@ -721,20 +704,15 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
getSceneVersion(elements) >
|
||||
this.getLastBroadcastedOrReceivedSceneVersion()
|
||||
) {
|
||||
this.portal.broadcastScene(WS_SCENE_EVENT_TYPES.UPDATE, elements, false);
|
||||
this.portal.broadcastScene(SCENE.UPDATE, elements, false);
|
||||
this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);
|
||||
this.queueBroadcastAllElements();
|
||||
}
|
||||
};
|
||||
|
||||
syncElements = (elements: readonly ExcalidrawElement[]) => {
|
||||
this.broadcastElements(elements);
|
||||
this.queueSaveToFirebase();
|
||||
};
|
||||
|
||||
queueBroadcastAllElements = throttle(() => {
|
||||
this.portal.broadcastScene(
|
||||
WS_SCENE_EVENT_TYPES.UPDATE,
|
||||
SCENE.UPDATE,
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
true,
|
||||
);
|
||||
@@ -746,16 +724,6 @@ 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 });
|
||||
};
|
||||
@@ -791,12 +759,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.syncElements = this.syncElements;
|
||||
this.contextValue.broadcastElements = this.broadcastElements;
|
||||
this.contextValue.fetchImageFilesFromFirebase =
|
||||
this.fetchImageFilesFromFirebase;
|
||||
this.contextValue.setUsername = this.setUsername;
|
||||
|
||||
@@ -3,11 +3,7 @@ import { SocketUpdateData, SocketUpdateDataSource } from "../data";
|
||||
import CollabWrapper from "./CollabWrapper";
|
||||
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import {
|
||||
WS_EVENTS,
|
||||
FILE_UPLOAD_TIMEOUT,
|
||||
WS_SCENE_EVENT_TYPES,
|
||||
} from "../app_constants";
|
||||
import { BROADCAST, FILE_UPLOAD_TIMEOUT, SCENE } from "../app_constants";
|
||||
import { UserIdleState } from "../../types";
|
||||
import { trackEvent } from "../../analytics";
|
||||
import { throttle } from "lodash";
|
||||
@@ -41,7 +37,7 @@ class Portal {
|
||||
});
|
||||
this.socket.on("new-user", async (_socketId: string) => {
|
||||
this.broadcastScene(
|
||||
WS_SCENE_EVENT_TYPES.INIT,
|
||||
SCENE.INIT,
|
||||
this.collab.getSceneElementsIncludingDeleted(),
|
||||
/* syncAll */ true,
|
||||
);
|
||||
@@ -85,7 +81,7 @@ class Portal {
|
||||
const { encryptedBuffer, iv } = await encryptData(this.roomKey!, encoded);
|
||||
|
||||
this.socket?.emit(
|
||||
volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER,
|
||||
volatile ? BROADCAST.SERVER_VOLATILE : BROADCAST.SERVER,
|
||||
this.roomId,
|
||||
encryptedBuffer,
|
||||
iv,
|
||||
@@ -125,11 +121,11 @@ class Portal {
|
||||
}, FILE_UPLOAD_TIMEOUT);
|
||||
|
||||
broadcastScene = async (
|
||||
updateType: WS_SCENE_EVENT_TYPES.INIT | WS_SCENE_EVENT_TYPES.UPDATE,
|
||||
sceneType: SCENE.INIT | SCENE.UPDATE,
|
||||
allElements: readonly ExcalidrawElement[],
|
||||
syncAll: boolean,
|
||||
) => {
|
||||
if (updateType === WS_SCENE_EVENT_TYPES.INIT && !syncAll) {
|
||||
if (sceneType === SCENE.INIT && !syncAll) {
|
||||
throw new Error("syncAll must be true when sending SCENE.INIT");
|
||||
}
|
||||
|
||||
@@ -156,8 +152,8 @@ class Portal {
|
||||
[] as BroadcastedExcalidrawElement[],
|
||||
);
|
||||
|
||||
const data: SocketUpdateDataSource[typeof updateType] = {
|
||||
type: updateType,
|
||||
const data: SocketUpdateDataSource[typeof sceneType] = {
|
||||
type: sceneType,
|
||||
payload: {
|
||||
elements: syncableElements,
|
||||
},
|
||||
@@ -170,9 +166,20 @@ class Portal {
|
||||
);
|
||||
}
|
||||
|
||||
const broadcastPromise = this._broadcastSocketData(
|
||||
data as SocketUpdateData,
|
||||
);
|
||||
|
||||
this.queueFileUpload();
|
||||
|
||||
await this._broadcastSocketData(data as SocketUpdateData);
|
||||
if (syncAll && this.collab.isCollaborating) {
|
||||
await Promise.all([
|
||||
broadcastPromise,
|
||||
this.collab.saveCollabRoomToFirebase(syncableElements),
|
||||
]);
|
||||
} else {
|
||||
await broadcastPromise;
|
||||
}
|
||||
};
|
||||
|
||||
broadcastIdleChange = (userState: UserIdleState) => {
|
||||
|
||||
@@ -78,14 +78,8 @@ export const reconcileElements = (
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mark duplicate for removal as it'll be replaced with the remote element
|
||||
if (local) {
|
||||
// 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;
|
||||
}
|
||||
// mark for removal since it'll be replaced with the remote element
|
||||
duplicates.set(local[0], true);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,6 @@ 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[],
|
||||
@@ -94,7 +92,6 @@ 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);
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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,17 +2,11 @@ import { ExcalidrawElement, FileId } from "../../element/types";
|
||||
import { getSceneVersion } from "../../element";
|
||||
import Portal from "../collab/Portal";
|
||||
import { restoreElements } from "../../data/restore";
|
||||
import {
|
||||
AppState,
|
||||
BinaryFileData,
|
||||
BinaryFileMetadata,
|
||||
DataURL,
|
||||
} from "../../types";
|
||||
import { 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
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -114,13 +108,11 @@ const encryptElements = async (
|
||||
};
|
||||
|
||||
const decryptElements = async (
|
||||
data: FirebaseStoredScene,
|
||||
roomKey: string,
|
||||
key: string,
|
||||
iv: Uint8Array,
|
||||
ciphertext: ArrayBuffer | Uint8Array,
|
||||
): Promise<readonly ExcalidrawElement[]> => {
|
||||
const ciphertext = data.ciphertext.toUint8Array();
|
||||
const iv = data.iv.toUint8Array();
|
||||
|
||||
const decrypted = await decryptData(iv, ciphertext, roomKey);
|
||||
const decrypted = await decryptData(iv, ciphertext, key);
|
||||
const decodedData = new TextDecoder("utf-8").decode(
|
||||
new Uint8Array(decrypted),
|
||||
);
|
||||
@@ -179,86 +171,57 @@ export const saveFilesToFirebase = async ({
|
||||
return { savedFiles, erroredFiles };
|
||||
};
|
||||
|
||||
const createFirebaseSceneDocument = async (
|
||||
firebase: ResolutionType<typeof loadFirestore>,
|
||||
export const saveToFirebase = async (
|
||||
portal: Portal,
|
||||
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);
|
||||
return {
|
||||
|
||||
const nextDocData = {
|
||||
sceneVersion,
|
||||
ciphertext: firebase.firestore.Blob.fromUint8Array(
|
||||
new Uint8Array(ciphertext),
|
||||
),
|
||||
iv: firebase.firestore.Blob.fromUint8Array(iv),
|
||||
} as FirebaseStoredScene;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 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 = snapshot.data() as FirebaseStoredScene;
|
||||
const prevElements = await decryptElements(prevDocData, roomKey);
|
||||
const prevDocData = doc.data() as FirebaseStoredScene;
|
||||
if (prevDocData.sceneVersion >= nextDocData.sceneVersion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const reconciledElements = reconcileElements(
|
||||
elements,
|
||||
prevElements,
|
||||
appState,
|
||||
);
|
||||
|
||||
const sceneDocument = await createFirebaseSceneDocument(
|
||||
firebase,
|
||||
reconciledElements,
|
||||
roomKey,
|
||||
);
|
||||
|
||||
transaction.update(docRef, sceneDocument);
|
||||
return {
|
||||
reconciledElements,
|
||||
sceneVersion: sceneDocument.sceneVersion,
|
||||
};
|
||||
transaction.update(docRef, nextDocData);
|
||||
return true;
|
||||
});
|
||||
|
||||
firebaseSceneVersionCache.set(socket, savedData.sceneVersion);
|
||||
if (didUpdate) {
|
||||
firebaseSceneVersionCache.set(socket, sceneVersion);
|
||||
}
|
||||
|
||||
return savedData;
|
||||
return didUpdate;
|
||||
};
|
||||
|
||||
export const loadFromFirebase = async (
|
||||
@@ -275,7 +238,10 @@ export const loadFromFirebase = async (
|
||||
return null;
|
||||
}
|
||||
const storedScene = doc.data() as FirebaseStoredScene;
|
||||
const elements = await decryptElements(storedScene, roomKey);
|
||||
const ciphertext = storedScene.ciphertext.toUint8Array();
|
||||
const iv = storedScene.iv.toUint8Array();
|
||||
|
||||
const elements = await decryptElements(roomKey, iv, ciphertext);
|
||||
|
||||
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,6 +34,26 @@ 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;
|
||||
@@ -103,13 +123,14 @@ export const getTotalStorageSize = () => {
|
||||
|
||||
export const getLibraryItemsFromStorage = () => {
|
||||
try {
|
||||
const libraryItems: ImportedDataState["libraryItems"] = JSON.parse(
|
||||
localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string,
|
||||
);
|
||||
const libraryItems =
|
||||
JSON.parse(
|
||||
localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string,
|
||||
) || [];
|
||||
|
||||
return libraryItems || [];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return libraryItems;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -26,9 +26,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.excalidraw-app.is-collaborating {
|
||||
[data-testid="clear-canvas-button"] {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
VERSION_TIMEOUT,
|
||||
} from "../constants";
|
||||
import { loadFromBlob } from "../data/blob";
|
||||
import { ImportedDataState } from "../data/types";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
@@ -19,8 +20,7 @@ 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,13 +28,12 @@ import {
|
||||
AppState,
|
||||
LibraryItems,
|
||||
ExcalidrawImperativeAPI,
|
||||
BinaryFileData,
|
||||
BinaryFiles,
|
||||
ExcalidrawInitialDataState,
|
||||
} from "../types";
|
||||
import {
|
||||
debounce,
|
||||
getVersion,
|
||||
getFrame,
|
||||
isTestEnv,
|
||||
preventUnload,
|
||||
ResolvablePromise,
|
||||
@@ -42,6 +41,7 @@ import {
|
||||
} from "../utils";
|
||||
import {
|
||||
FIREBASE_STORAGE_PREFIXES,
|
||||
SAVE_TO_LOCAL_STORAGE_TIMEOUT,
|
||||
STORAGE_KEYS,
|
||||
SYNC_BROWSER_TABS_TIMEOUT,
|
||||
} from "./app_constants";
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
getLibraryItemsFromStorage,
|
||||
importFromLocalStorage,
|
||||
importUsernameFromLocalStorage,
|
||||
saveToLocalStorage,
|
||||
} from "./data/localStorage";
|
||||
import CustomStats from "./CustomStats";
|
||||
import { restoreAppState, RestoredDataState } from "../data/restore";
|
||||
@@ -65,13 +66,72 @@ import { shield } from "../components/icons";
|
||||
import "./index.scss";
|
||||
import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus";
|
||||
|
||||
import { updateStaleImageStatuses } from "./data/FileManager";
|
||||
import { getMany, set, del, keys, createStore } from "idb-keyval";
|
||||
import { FileManager, updateStaleImageStatuses } from "./data/FileManager";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { isInitializedImageElement } from "../element/typeChecks";
|
||||
import { loadFilesFromFirebase } from "./data/firebase";
|
||||
import { LocalData } from "./data/LocalData";
|
||||
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
||||
import clsx from "clsx";
|
||||
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 };
|
||||
},
|
||||
});
|
||||
|
||||
const languageDetector = new LanguageDetector();
|
||||
languageDetector.init({
|
||||
@@ -82,10 +142,32 @@ 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: ExcalidrawInitialDataState | null } & (
|
||||
{ scene: ImportedDataState | null } & (
|
||||
| { isExternalScene: true; id: string; key: string }
|
||||
| { isExternalScene: false; id?: null; key?: null }
|
||||
)
|
||||
@@ -212,15 +294,14 @@ const ExcalidrawWrapper = () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const initialStatePromiseRef = useRef<{
|
||||
promise: ResolvablePromise<ExcalidrawInitialDataState | null>;
|
||||
promise: ResolvablePromise<ImportedDataState | null>;
|
||||
}>({ promise: null! });
|
||||
if (!initialStatePromiseRef.current.promise) {
|
||||
initialStatePromiseRef.current.promise =
|
||||
resolvablePromise<ExcalidrawInitialDataState | null>();
|
||||
resolvablePromise<ImportedDataState | null>();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
trackEvent("load", "frame", getFrame());
|
||||
// Delayed so that the app has a time to load the latest SW
|
||||
setTimeout(() => {
|
||||
trackEvent("load", "version", getVersion());
|
||||
@@ -283,7 +364,7 @@ const ExcalidrawWrapper = () => {
|
||||
});
|
||||
} else if (isInitialLoad) {
|
||||
if (fileIds.length) {
|
||||
LocalData.fileStorage
|
||||
localFileStorage
|
||||
.getFiles(fileIds)
|
||||
.then(({ loadedFiles, erroredFiles }) => {
|
||||
if (loadedFiles.length) {
|
||||
@@ -298,7 +379,7 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
// on fresh load, clear unused files from IDB (from previous
|
||||
// session)
|
||||
LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds });
|
||||
clearObsoleteFilesFromIndexedDB({ currentFileIds: fileIds });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,7 +456,7 @@ const ExcalidrawWrapper = () => {
|
||||
return acc;
|
||||
}, [] as FileId[]) || [];
|
||||
if (fileIds.length) {
|
||||
LocalData.fileStorage
|
||||
localFileStorage
|
||||
.getFiles(fileIds)
|
||||
.then(({ loadedFiles, erroredFiles }) => {
|
||||
if (loadedFiles.length) {
|
||||
@@ -392,50 +473,28 @@ 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, onUnload, false);
|
||||
window.addEventListener(EVENT.BLUR, visibilityChange, false);
|
||||
document.addEventListener(EVENT.VISIBILITY_CHANGE, visibilityChange, false);
|
||||
window.addEventListener(EVENT.FOCUS, visibilityChange, 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);
|
||||
return () => {
|
||||
window.removeEventListener(EVENT.HASHCHANGE, onHashChange, 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,
|
||||
);
|
||||
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);
|
||||
clearTimeout(titleTimeout);
|
||||
};
|
||||
}, [collabAPI, excalidrawAPI]);
|
||||
|
||||
useEffect(() => {
|
||||
const unloadHandler = (event: BeforeUnloadEvent) => {
|
||||
LocalData.flushSave();
|
||||
saveDebounced.flush();
|
||||
|
||||
if (
|
||||
excalidrawAPI &&
|
||||
LocalData.fileStorage.shouldPreventUnload(
|
||||
excalidrawAPI.getSceneElements(),
|
||||
)
|
||||
localFileStorage.shouldPreventUnload(excalidrawAPI.getSceneElements())
|
||||
) {
|
||||
preventUnload(event);
|
||||
}
|
||||
@@ -456,13 +515,9 @@ const ExcalidrawWrapper = () => {
|
||||
files: BinaryFiles,
|
||||
) => {
|
||||
if (collabAPI?.isCollaborating()) {
|
||||
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, () => {
|
||||
collabAPI.broadcastElements(elements);
|
||||
} else {
|
||||
saveDebounced(elements, appState, files, () => {
|
||||
if (excalidrawAPI) {
|
||||
let didChange = false;
|
||||
|
||||
@@ -470,9 +525,7 @@ const ExcalidrawWrapper = () => {
|
||||
const elements = excalidrawAPI
|
||||
.getSceneElementsIncludingDeleted()
|
||||
.map((element) => {
|
||||
if (
|
||||
LocalData.fileStorage.shouldUpdateImageElementStatus(element)
|
||||
) {
|
||||
if (localFileStorage.shouldUpdateImageElementStatus(element)) {
|
||||
didChange = true;
|
||||
const newEl = newElementWith(element, { status: "saved" });
|
||||
if (pendingImageElement === element) {
|
||||
@@ -632,16 +685,11 @@ const ExcalidrawWrapper = () => {
|
||||
};
|
||||
|
||||
const onRoomClose = useCallback(() => {
|
||||
LocalData.fileStorage.reset();
|
||||
localFileStorage.reset();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ height: "100%" }}
|
||||
className={clsx("excalidraw-app", {
|
||||
"is-collaborating": collabAPI?.isCollaborating(),
|
||||
})}
|
||||
>
|
||||
<>
|
||||
<Excalidraw
|
||||
ref={excalidrawRefCallback}
|
||||
onChange={onChange}
|
||||
@@ -693,7 +741,7 @@ const ExcalidrawWrapper = () => {
|
||||
onClose={() => setErrorMessage("")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
2
src/global.d.ts
vendored
2
src/global.d.ts
vendored
@@ -34,8 +34,6 @@ 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;
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import { unstable_createStore } from "jotai";
|
||||
|
||||
export const jotaiScope = Symbol();
|
||||
export const jotaiStore = unstable_createStore();
|
||||
@@ -9,7 +9,6 @@
|
||||
"copy": "نسخ",
|
||||
"copyAsPng": "نسخ إلى الحافظة بصيغة PNG",
|
||||
"copyAsSvg": "نسخ إلى الحافظة بصيغة SVG",
|
||||
"copyText": "نسخ إلى الحافظة كنص",
|
||||
"bringForward": "جلب للأمام",
|
||||
"sendToBack": "أرسل للخلف",
|
||||
"bringToFront": "أحضر للأمام",
|
||||
@@ -22,7 +21,7 @@
|
||||
"fill": "التعبئة",
|
||||
"strokeWidth": "سُمك الخط",
|
||||
"strokeStyle": "نمط الخط",
|
||||
"strokeStyle_solid": "متصل",
|
||||
"strokeStyle_solid": "كامل",
|
||||
"strokeStyle_dashed": "متقطع",
|
||||
"strokeStyle_dotted": "منقط",
|
||||
"sloppiness": "الإمالة",
|
||||
@@ -108,17 +107,10 @@
|
||||
"decreaseFontSize": "تصغير حجم الخط",
|
||||
"increaseFontSize": "تكبير حجم الخط",
|
||||
"unbindText": "",
|
||||
"bindText": "",
|
||||
"link": {
|
||||
"edit": "تعديل الرابط",
|
||||
"create": "إنشاء رابط",
|
||||
"label": "رابط"
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "",
|
||||
"unlock": "",
|
||||
"lockAll": "",
|
||||
"unlockAll": ""
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
@@ -167,7 +159,7 @@
|
||||
"couldNotLoadInvalidFile": "تعذر التحميل، الملف غير صالح",
|
||||
"importBackendFailed": "فشل الاستيراد من الخادوم.",
|
||||
"cannotExportEmptyCanvas": "لا يمكن تصدير لوحة فارغة.",
|
||||
"couldNotCopyToClipboard": "",
|
||||
"couldNotCopyToClipboard": "تعذر النسخ إلى الحافظة. حاول استخدام متصفح Chrome.",
|
||||
"decryptFailed": "تعذر فك تشفير البيانات.",
|
||||
"uploadedSecurly": "تم تأمين التحميل بتشفير النهاية إلى النهاية، مما يعني أن خادوم Excalidraw والأطراف الثالثة لا يمكنها قراءة المحتوى.",
|
||||
"loadSceneOverridePrompt": "تحميل الرسم الخارجي سيحل محل المحتوى الموجود لديك. هل ترغب في المتابعة؟",
|
||||
@@ -180,7 +172,7 @@
|
||||
"cannotRestoreFromImage": "تعذر استعادة المشهد من ملف الصورة",
|
||||
"invalidSceneUrl": "تعذر استيراد المشهد من عنوان URL المتوفر. إما أنها مشوهة، أو لا تحتوي على بيانات Excalidraw JSON صالحة.",
|
||||
"resetLibrary": "هذا سوف يمسح مكتبتك. هل أنت متأكد؟",
|
||||
"removeItemsFromsLibrary": "حذف {{count}} عنصر (عناصر) من المكتبة؟",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": "مفتاح التشفير يجب أن يكون من 22 حرفاً. التعاون المباشر معطل."
|
||||
},
|
||||
"errors": {
|
||||
@@ -204,8 +196,7 @@
|
||||
"library": "مكتبة",
|
||||
"lock": "الحفاظ على أداة التحديد نشطة بعد الرسم",
|
||||
"penMode": "",
|
||||
"link": "",
|
||||
"eraser": "ممحاة"
|
||||
"link": ""
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "إجراءات اللوحة",
|
||||
@@ -230,8 +221,7 @@
|
||||
"placeImage": "",
|
||||
"publishLibrary": "نشر مكتبتك",
|
||||
"bindTextToElement": "",
|
||||
"deepBoxSelect": "",
|
||||
"eraserRevert": ""
|
||||
"deepBoxSelect": ""
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "تعذر عرض المعاينة",
|
||||
@@ -291,15 +281,14 @@
|
||||
"howto": "اتبع التعليمات",
|
||||
"or": "أو",
|
||||
"preventBinding": "منع ارتبط السهم",
|
||||
"tools": "الأدوات",
|
||||
"shapes": "أشكال",
|
||||
"shortcuts": "اختصارات لوحة المفاتيح",
|
||||
"textFinish": "إنهاء التعديل (محرر النص)",
|
||||
"textNewLine": "أضف سطر جديد (محرر نص)",
|
||||
"title": "المساعدة",
|
||||
"view": "عرض",
|
||||
"zoomToFit": "تكبير للملائمة",
|
||||
"zoomToSelection": "تكبير للعنصر المحدد",
|
||||
"toggleElementLock": ""
|
||||
"zoomToSelection": "تكبير للعنصر المحدد"
|
||||
},
|
||||
"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