Compare commits

..

1 Commits

Author SHA1 Message Date
Zsolt Viczian 09ae07ed7f Added font color picker 2022-03-14 20:56:12 +01:00
204 changed files with 2764 additions and 6999 deletions
+4 -5
View File
@@ -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"}'
+8 -35
View File
@@ -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
+6 -5
View File
@@ -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;
}
}
}
}
+3 -4
View File
@@ -29,16 +29,15 @@
"@types/react": "17.0.39",
"@types/react-dom": "17.0.11",
"@types/socket.io-client": "1.4.36",
"browser-fs-access": "0.29.1",
"browser-fs-access": "0.24.1",
"clsx": "1.1.1",
"fake-indexeddb": "3.1.7",
"firebase": "8.3.3",
"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.3.3",
"nanoid": "3.1.32",
"open-color": "1.9.1",
"pako": "1.0.11",
"perfect-freehand": "1.0.16",
@@ -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"
+26 -4
View File
@@ -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>
+2 -3
View File
@@ -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),
@@ -25,9 +24,9 @@ export const actionAddToLibrary = register({
}
return app.library
.getLatestLibrary()
.loadLibrary()
.then((items) => {
return app.library.setLibrary([
return app.library.saveLibrary([
{
id: randomId(),
status: "unpublished",
-8
View File
@@ -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,
-136
View File
@@ -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,
};
},
});
+5 -46
View File
@@ -21,7 +21,6 @@ import clsx from "clsx";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
trackEvent: false,
perform: (_, appState, value) => {
return {
appState: { ...appState, ...value },
@@ -51,7 +50,6 @@ export const actionChangeViewBackgroundColor = register({
export const actionClearCanvas = register({
name: "clearCanvas",
trackEvent: { category: "canvas" },
perform: (elements, appState, _, app) => {
app.imageCache.clear();
return {
@@ -62,6 +60,7 @@ export const actionClearCanvas = register({
...getDefaultAppState(),
files: {},
theme: appState.theme,
elementLocked: appState.elementLocked,
penMode: appState.penMode,
penDetected: appState.penDetected,
exportBackground: appState.exportBackground,
@@ -69,10 +68,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 +80,6 @@ export const actionClearCanvas = register({
export const actionZoomIn = register({
name: "zoomIn",
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
return {
appState: {
@@ -119,7 +115,6 @@ export const actionZoomIn = register({
export const actionZoomOut = register({
name: "zoomOut",
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
return {
appState: {
@@ -155,7 +150,6 @@ export const actionZoomOut = register({
export const actionResetZoom = register({
name: "resetZoom",
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
return {
appState: {
@@ -254,7 +248,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 +258,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 +268,6 @@ export const actionZoomToFit = register({
export const actionToggleTheme = register({
name: "toggleTheme",
trackEvent: { category: "canvas" },
perform: (_, appState, value) => {
return {
appState: {
@@ -302,55 +293,23 @@ export const actionToggleTheme = register({
export const actionErase = register({
name: "eraser",
trackEvent: { category: "toolbar" },
perform: (elements, appState) => {
const activeTool: any = { ...appState.activeTool };
if (appState.activeTool.type !== "eraser") {
if (appState.activeTool.type === "custom") {
activeTool.lastActiveToolBeforeEraser = {
type: "custom",
customType: appState.activeTool.customType,
};
} else {
activeTool.lastActiveToolBeforeEraser = appState.activeTool.type;
}
}
if (isEraserActive(appState)) {
if (appState.activeTool.lastActiveToolBeforeEraser) {
if (
typeof appState.activeTool.lastActiveToolBeforeEraser === "object" &&
appState.activeTool.lastActiveToolBeforeEraser?.type === "custom"
) {
activeTool.type = "custom";
activeTool.customType =
appState.activeTool.lastActiveToolBeforeEraser.customType;
} else {
activeTool.type = appState.activeTool.lastActiveToolBeforeEraser;
}
} else {
activeTool.type = "selection";
}
} else {
activeTool.type = "eraser";
}
return {
appState: {
...appState,
selectedElementIds: {},
selectedGroupIds: {},
activeTool,
elementType: isEraserActive(appState) ? "selection" : "eraser",
},
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")}`}
title={t("toolBar.eraser")}
aria-label={t("toolBar.eraser")}
onClick={() => {
updateData(null);
+3 -45
View File
@@ -1,23 +1,16 @@
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) => {
const selectedElements = getSelectedElements(elements, appState, true);
copyToClipboard(selectedElements, appState, app.files);
copyToClipboard(getNonDeletedElements(elements), appState, app.files);
return {
commitToHistory: false,
@@ -30,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);
@@ -41,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 {
@@ -82,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 {
@@ -132,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",
});
+1 -2
View File
@@ -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(
-2
View File
@@ -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,
-1
View File
@@ -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) {
+5 -11
View File
@@ -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 },
+11 -34
View File
@@ -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,47 +119,27 @@ 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);
}
const activeTool: any = { ...appState.activeTool };
if (appState.activeTool.lastActiveToolBeforeEraser) {
if (
typeof appState.activeTool.lastActiveToolBeforeEraser === "object" &&
appState.activeTool.lastActiveToolBeforeEraser.type === "custom"
) {
activeTool.type = appState.activeTool.lastActiveToolBeforeEraser.type;
activeTool.customType =
appState.activeTool.lastActiveToolBeforeEraser.customType;
} else {
activeTool.type = appState.activeTool.lastActiveToolBeforeEraser;
}
} else {
activeTool.type = "selection";
}
return {
elements: newElements,
appState: {
...appState,
cursorButton: "up",
activeTool:
(appState.activeTool.locked ||
appState.activeTool.type === "freedraw") &&
elementType:
(appState.elementLocked || appState.elementType === "freedraw") &&
multiPointElement
? appState.activeTool
: activeTool,
? appState.elementType
: "selection",
draggingElement: null,
multiElement: null,
editingElement: null,
@@ -169,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,
@@ -178,7 +156,7 @@ export const actionFinalize = register({
: appState.selectedElementIds,
pendingImageElement: null,
},
commitToHistory: appState.activeTool.type === "freedraw",
commitToHistory: appState.elementType === "freedraw",
};
},
keyTest: (event, appState) =>
@@ -187,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}
@@ -195,7 +173,6 @@ export const actionFinalize = register({
aria-label={t("buttons.done")}
onClick={updateData}
visible={appState.multiElement != null}
size={data?.size || "medium"}
/>
),
});
-2
View File
@@ -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"),
-2
View File
@@ -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) {
-2
View File
@@ -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) =>
-4
View File
@@ -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();
+5 -5
View File
@@ -1,4 +1,4 @@
import { getClientColors } from "../clients";
import { getClientColors, getClientInitials } from "../clients";
import { Avatar } from "../components/Avatar";
import { centerScrollOn } from "../scene/scroll";
import { Collaborator } from "../types";
@@ -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) {
@@ -43,15 +42,16 @@ export const actionGoToCollaborator = register({
}
const { background, stroke } = getClientColors(clientId, appState);
const shortName = getClientInitials(collaborator.username);
return (
<Avatar
color={background}
border={stroke}
onClick={() => updateData(collaborator.pointer)}
name={collaborator.username || ""}
src={collaborator.src}
/>
>
{shortName}
</Avatar>
);
},
});
+143 -46
View File
@@ -51,6 +51,7 @@ import {
getContainerElement,
} from "../element/textElement";
import {
hasBoundTextElement,
isBoundToContainer,
isLinearElement,
isLinearElementType,
@@ -106,6 +107,7 @@ const getFormValue = function <T>(
appState: AppState,
getAttribute: (element: ExcalidrawElement) => T,
defaultValue?: T,
onlyBoundTextElements: boolean = false,
): T | null {
const editingElement = appState.editingElement;
const nonDeletedElements = getNonDeletedElements(elements);
@@ -116,6 +118,7 @@ const getFormValue = function <T>(
nonDeletedElements,
appState,
getAttribute,
onlyBoundTextElements,
)
: defaultValue) ??
null
@@ -166,7 +169,11 @@ const changeFontSize = (
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
fontSize: newFontSize,
});
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
redrawTextBoundingBox(
newElement,
getContainerElement(oldElement),
appState,
);
newElement = offsetElementAfterFontResize(oldElement, newElement);
@@ -192,9 +199,8 @@ const changeFontSize = (
// -----------------------------------------------------------------------------
export const actionChangeStrokeColor = register({
name: "changeStrokeColor",
trackEvent: false,
export const actionChangeFontColor = register({
name: "changeFontColor",
perform: (elements, appState, value) => {
return {
...(value.currentItemStrokeColor && {
@@ -202,7 +208,7 @@ export const actionChangeStrokeColor = register({
elements,
appState,
(el) => {
return hasStrokeColor(el.type)
return isTextElement(el)
? newElementWith(el, {
strokeColor: value.currentItemStrokeColor,
})
@@ -218,33 +224,111 @@ export const actionChangeStrokeColor = register({
commitToHistory: !!value.currentItemStrokeColor,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
<>
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
<ColorPicker
type="elementStroke"
label={t("labels.stroke")}
color={getFormValue(
PanelComponent: ({ elements, appState, updateData }) => {
return (
<>
<h3 aria-hidden="true">{t("labels.fontColor")}</h3>
<ColorPicker
type="elementFontColor"
label={t("labels.fontColor")}
color={getFormValue(
elements,
appState,
(element) => element.strokeColor,
appState.currentItemStrokeColor,
true,
)}
onChange={(color) => updateData({ currentItemStrokeColor: color })}
isActive={appState.openPopup === "fontColorPicker"}
setActive={(active) =>
updateData({ openPopup: active ? "fontColorPicker" : null })
}
elements={elements}
appState={appState}
/>
</>
);
},
});
export const actionChangeStrokeColor = register({
name: "changeStrokeColor",
perform: (elements, appState, value) => {
const targetElements = getTargetElements(
getNonDeletedElements(elements),
appState,
);
const hasOnlyContainersWithBoundText =
targetElements.length > 1 &&
targetElements.every(
(element) =>
hasBoundTextElement(element) || isBoundToContainer(element),
);
return {
...(value.currentItemStrokeColor && {
elements: changeProperty(
elements,
appState,
(element) => element.strokeColor,
appState.currentItemStrokeColor,
)}
onChange={(color) => updateData({ currentItemStrokeColor: color })}
isActive={appState.openPopup === "strokeColorPicker"}
setActive={(active) =>
updateData({ openPopup: active ? "strokeColorPicker" : null })
}
elements={elements}
appState={appState}
/>
</>
),
(el) => {
return (hasStrokeColor(el.type) &&
!hasOnlyContainersWithBoundText) ||
!isBoundToContainer(el)
? newElementWith(el, {
strokeColor: value.currentItemStrokeColor,
})
: el;
},
true,
),
}),
appState: {
...appState,
...value,
},
commitToHistory: !!value.currentItemStrokeColor,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
const targetElements = getTargetElements(
getNonDeletedElements(elements),
appState,
);
const hasOnlyContainersWithBoundText = targetElements.every(
(element) => hasBoundTextElement(element) || isBoundToContainer(element),
);
return (
<>
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
<ColorPicker
type="elementStroke"
label={t("labels.stroke")}
color={getFormValue(
hasOnlyContainersWithBoundText
? elements.filter((element) => !isTextElement(element))
: elements,
appState,
(element) => element.strokeColor,
appState.currentItemStrokeColor,
)}
onChange={(color) => updateData({ currentItemStrokeColor: color })}
isActive={appState.openPopup === "strokeColorPicker"}
setActive={(active) =>
updateData({ openPopup: active ? "strokeColorPicker" : null })
}
elements={elements}
appState={appState}
/>
</>
);
},
});
export const actionChangeBackgroundColor = register({
name: "changeBackgroundColor",
trackEvent: false,
perform: (elements, appState, value) => {
return {
...(value.currentItemBackgroundColor && {
@@ -287,7 +371,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 +420,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 +467,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 +515,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 +562,6 @@ export const actionChangeStrokeStyle = register({
export const actionChangeOpacity = register({
name: "changeOpacity",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) =>
@@ -503,6 +582,20 @@ export const actionChangeOpacity = register({
max="100"
step="10"
onChange={(event) => updateData(+event.target.value)}
onWheel={(event) => {
event.stopPropagation();
const target = event.target as HTMLInputElement;
const STEP = 10;
const MAX = 100;
const MIN = 0;
const value = +target.value;
if (event.deltaY < 0 && value < MAX) {
updateData(value + STEP);
} else if (event.deltaY > 0 && value > MIN) {
updateData(value - STEP);
}
}}
value={
getFormValue(
elements,
@@ -518,7 +611,6 @@ export const actionChangeOpacity = register({
export const actionChangeFontSize = register({
name: "changeFontSize",
trackEvent: false,
perform: (elements, appState, value) => {
return changeFontSize(elements, appState, () => value, value);
},
@@ -576,7 +668,6 @@ export const actionChangeFontSize = register({
export const actionDecreaseFontSize = register({
name: "decreaseFontSize",
trackEvent: false,
perform: (elements, appState, value) => {
return changeFontSize(elements, appState, (element) =>
Math.round(
@@ -598,7 +689,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)),
@@ -616,7 +706,6 @@ export const actionIncreaseFontSize = register({
export const actionChangeFontFamily = register({
name: "changeFontFamily",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(
@@ -630,7 +719,11 @@ export const actionChangeFontFamily = register({
fontFamily: value,
},
);
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
redrawTextBoundingBox(
newElement,
getContainerElement(oldElement),
appState,
);
return newElement;
}
@@ -698,7 +791,6 @@ export const actionChangeFontFamily = register({
export const actionChangeTextAlign = register({
name: "changeTextAlign",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(
@@ -710,7 +802,11 @@ export const actionChangeTextAlign = register({
oldElement,
{ textAlign: value },
);
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
redrawTextBoundingBox(
newElement,
getContainerElement(oldElement),
appState,
);
return newElement;
}
@@ -771,7 +867,6 @@ export const actionChangeTextAlign = register({
});
export const actionChangeVerticalAlign = register({
name: "changeVerticalAlign",
trackEvent: { category: "element" },
perform: (elements, appState, value) => {
return {
elements: changeProperty(
@@ -784,7 +879,11 @@ export const actionChangeVerticalAlign = register({
{ verticalAlign: value },
);
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
redrawTextBoundingBox(
newElement,
getContainerElement(oldElement),
appState,
);
return newElement;
}
@@ -839,7 +938,6 @@ export const actionChangeVerticalAlign = register({
export const actionChangeSharpness = register({
name: "changeSharpness",
trackEvent: false,
perform: (elements, appState, value) => {
const targetElements = getTargetElements(
getNonDeletedElements(elements),
@@ -847,10 +945,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, {
@@ -890,8 +988,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,
@@ -904,7 +1002,6 @@ export const actionChangeSharpness = register({
export const actionChangeArrowhead = register({
name: "changeArrowhead",
trackEvent: false,
perform: (
elements,
appState,
+1 -3
View File
@@ -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;
}
+5 -3
View File
@@ -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 -4
View File
@@ -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,
-63
View File
@@ -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");
-1
View File
@@ -3,7 +3,6 @@ import { CODES, KEYS } from "../keys";
export const actionToggleStats = register({
name: "stats",
trackEvent: { category: "menu" },
perform(elements, appState) {
return {
appState: {
+2 -4
View File
@@ -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,
+3 -4
View File
@@ -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
View 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,
};
},
});
-5
View File
@@ -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),
+2 -3
View File
@@ -8,6 +8,7 @@ export {
export { actionSelectAll } from "./actionSelectAll";
export { actionDuplicateSelection } from "./actionDuplicateSelection";
export {
actionChangeFontColor,
actionChangeStrokeColor,
actionChangeBackgroundColor,
actionChangeStrokeWidth,
@@ -75,13 +76,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";
+36 -38
View File
@@ -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 (
-2
View File
@@ -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) => {
+13 -25
View File
@@ -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"
@@ -52,6 +49,7 @@ export type ActionName =
| "gridMode"
| "zenMode"
| "stats"
| "changeFontColor"
| "changeStrokeColor"
| "changeBackgroundColor"
| "changeFillStyle"
@@ -110,9 +108,7 @@ export type ActionName =
| "decreaseFontSize"
| "unbindText"
| "hyperlink"
| "eraser"
| "bindText"
| "toggleLock";
| "eraser";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];
@@ -143,23 +139,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;
}
+6 -10
View File
@@ -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);
};
+9 -11
View File
@@ -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 },
@@ -217,7 +215,7 @@ export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
};
export const isEraserActive = ({
activeTool,
elementType,
}: {
activeTool: AppState["activeTool"];
}) => activeTool.type === "eraser";
elementType: AppState["elementType"];
}) => elementType === "eraser";
-1
View File
@@ -167,7 +167,6 @@ const commonProps = {
strokeStyle: "solid",
strokeWidth: 1,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
locked: false,
} as const;
const getChartDimentions = (spreadsheet: Spreadsheet) => {
+15 -41
View File
@@ -2,16 +2,16 @@ import {
ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "./element/types";
import { getSelectedElements } from "./scene";
import { AppState, BinaryFiles } from "./types";
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;
elements: readonly NonDeletedExcalidrawElement[];
elements: ExcalidrawElement[];
files: BinaryFiles | undefined;
};
@@ -56,20 +56,19 @@ const clipboardContainsElements = (
export const copyToClipboard = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
files: BinaryFiles | null,
files: BinaryFiles,
) => {
// select binded text elements when copying
const selectedElements = getSelectedElements(elements, appState, true);
const contents: ElementsClipboard = {
type: EXPORT_DATA_TYPES.excalidrawClipboard,
elements,
files: files
? elements.reduce((acc, element) => {
if (isInitializedImageElement(element) && files[element.fileId]) {
acc[element.fileId] = files[element.fileId];
}
return acc;
}, {} as BinaryFiles)
: undefined,
elements: selectedElements,
files: selectedElements.reduce((acc, element) => {
if (isInitializedImageElement(element) && files[element.fileId]) {
acc[element.fileId] = files[element.fileId];
}
return acc;
}, {} as BinaryFiles),
};
const json = JSON.stringify(contents);
CLIPBOARD = json;
@@ -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) => {
+29 -43
View File
@@ -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: AppState["elementType"];
}) => {
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) {
@@ -72,25 +68,32 @@ export const SelectedShapeActions = ({
}
}
const hasOnlyContainersWithBoundText =
targetElements.length > 1 &&
targetElements.every(
(element) => hasBoundTextElement(element) || isBoundToContainer(element),
);
return (
<div className="panelColumn">
{((hasStrokeColor(activeTool) &&
activeTool !== "image" &&
{hasOnlyContainersWithBoundText && renderAction("changeFontColor")}
{((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 +101,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 +121,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 +175,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 +189,14 @@ export const SelectedShapeActions = ({
export const ShapesSwitcher = ({
canvas,
activeTool,
elementType,
setAppState,
onImageAction,
appState,
}: {
canvas: HTMLCanvasElement | null;
activeTool: AppState["activeTool"];
elementType: AppState["elementType"];
setAppState: React.Component<any, AppState>["setState"];
onImageAction: (data: { pointerType: PointerType | null }) => void;
appState: AppState;
}) => (
<>
{SHAPES.map(({ value, icon, key }, index) => {
@@ -210,35 +211,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 });
}
+220 -506
View File
File diff suppressed because it is too large Load Diff
-6
View File
@@ -12,11 +12,5 @@
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
&-img {
width: 100%;
height: 100%;
border-radius: 100%;
}
}
}
+10 -18
View File
@@ -1,28 +1,20 @@
import "./Avatar.scss";
import React from "react";
import { getClientInitials } from "../clients";
type AvatarProps = {
children: string;
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
color: string;
border: string;
name: string;
src?: string;
};
export const Avatar = ({ color, border, onClick, name, src }: AvatarProps) => {
const shortName = getClientInitials(name);
const style = src
? undefined
: { background: color, border: `1px solid ${border}` };
return (
<div className="Avatar" style={style} onClick={onClick}>
{src ? (
<img className="Avatar-img" src={src} alt={shortName} />
) : (
shortName
)}
</div>
);
};
export const Avatar = ({ children, color, border, onClick }: AvatarProps) => (
<div
className="Avatar"
style={{ background: color, border: `1px solid ${border}` }}
onClick={onClick}
>
{children}
</div>
);
+2 -2
View File
@@ -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"
/>
+2 -2
View File
@@ -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>
+2 -1
View File
@@ -255,7 +255,8 @@
color: #aaa;
}
.color-picker-type-elementStroke .color-picker-keybinding {
.color-picker-type-elementStroke .color-picker-keybinding,
.color-picker-type-elementFontColor .color-picker-keybinding {
color: #d4d4d4;
}
+15 -6
View File
@@ -101,19 +101,24 @@ const Picker = ({
onClose: () => void;
label: string;
showInput: boolean;
type: "canvasBackground" | "elementBackground" | "elementStroke";
type:
| "canvasBackground"
| "elementBackground"
| "elementStroke"
| "elementFontColor";
elements: readonly ExcalidrawElement[];
}) => {
const firstItem = React.useRef<HTMLButtonElement>();
const activeItem = React.useRef<HTMLButtonElement>();
const gallery = React.useRef<HTMLDivElement>();
const colorInput = React.useRef<HTMLInputElement>();
const colorType = type === "elementFontColor" ? "elementStroke" : type;
const [customColors] = React.useState(() => {
if (type === "canvasBackground") {
if (colorType === "canvasBackground") {
return [];
}
return getCustomColors(elements, type);
return getCustomColors(elements, colorType);
});
React.useEffect(() => {
@@ -356,7 +361,11 @@ export const ColorPicker = ({
elements,
appState,
}: {
type: "canvasBackground" | "elementBackground" | "elementStroke";
type:
| "canvasBackground"
| "elementBackground"
| "elementStroke"
| "elementFontColor";
color: string | null;
onChange: (color: string) => void;
label: string;
@@ -366,7 +375,7 @@ export const ColorPicker = ({
appState: AppState;
}) => {
const pickerButton = React.useRef<HTMLButtonElement>(null);
const colorType = type === "elementFontColor" ? "elementStroke" : type;
return (
<div>
<div className="color-picker-control-container">
@@ -393,7 +402,7 @@ export const ColorPicker = ({
}
>
<Picker
colors={colors[type]}
colors={colors[colorType]}
color={color || null}
onChange={(changedColor) => {
onChange(changedColor);
+1 -3
View File
@@ -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 -2
View File
@@ -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>
+2 -10
View File
@@ -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")]}
+6 -10
View File
@@ -20,32 +20,28 @@ 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 +73,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 &&
+5 -5
View File
@@ -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 && (
+9 -12
View File
@@ -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 && (
+28 -50
View File
@@ -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;
@@ -68,7 +67,6 @@ interface LayerUIProps {
library: Library;
id: string;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderCustomElementWidget?: (appState: AppState) => void;
}
const LayerUI = ({
@@ -96,9 +94,8 @@ const LayerUI = ({
library,
id,
onImageAction,
renderCustomElementWidget,
}: LayerUIProps) => {
const deviceType = useDeviceType();
const isMobile = useIsMobile();
const renderJSONExportDialog = () => {
if (!UIOptions.canvasActions.export) {
@@ -125,7 +122,6 @@ const LayerUI = ({
const createExporter =
(type: ExportType): ExportCB =>
async (exportedElements) => {
trackEvent("export", type, "ui");
const fileHandle = await exportCanvas(
type,
exportedElements,
@@ -252,7 +248,7 @@ const LayerUI = ({
appState={appState}
elements={elements}
renderAction={actionManager.renderAction}
activeTool={appState.activeTool.type}
elementType={appState.elementType}
/>
</Island>
</Section>
@@ -329,8 +325,8 @@ const LayerUI = ({
/>
<LockButton
zenModeEnabled={zenModeEnabled}
checked={appState.activeTool.locked}
onChange={() => onLockToggle()}
checked={appState.elementLocked}
onChange={onLockToggle}
title={t("toolBar.lock")}
/>
<Island
@@ -342,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({
@@ -393,7 +388,7 @@ const LayerUI = ({
</Tooltip>
))}
</UserList>
{renderTopRightUI?.(deviceType.isMobile, appState)}
{renderTopRightUI?.(isMobile, appState)}
</div>
</div>
</FixedSideContainer>
@@ -423,41 +418,24 @@ 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" })}
{renderCustomElementWidget &&
renderCustomElementWidget(appState)}
</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>
)}
<div
className={clsx("eraser-buttons zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-left":
zenModeEnabled,
})}
>
{actionManager.renderAction("eraser", { size: "small" })}
</div>
</Section>
</Stack.Col>
</div>
@@ -496,7 +474,7 @@ const LayerUI = ({
const dialogs = (
<>
{appState.isLoading && <LoadingMessage delay={250} />}
{appState.isLoading && <LoadingMessage />}
{appState.errorMessage && (
<ErrorDialog
message={appState.errorMessage}
@@ -525,7 +503,7 @@ const LayerUI = ({
</>
);
return deviceType.isMobile ? (
return isMobile ? (
<>
{dialogs}
<MobileMenu
@@ -537,7 +515,7 @@ const LayerUI = ({
renderImageExportDialog={renderImageExportDialog}
setAppState={setAppState}
onCollabButtonClick={onCollabButtonClick}
onLockToggle={() => onLockToggle()}
onLockToggle={onLockToggle}
onPenModeToggle={onPenModeToggle}
canvas={canvas}
isCollaborating={isCollaborating}
+2 -15
View File
@@ -13,10 +13,6 @@
width: 100%;
margin: 2px 0;
.Spinner {
margin-right: 1rem;
}
button {
// 2px from the left to account for focus border of left-most button
margin: 0 2px;
@@ -32,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 {
+120 -127
View File
@@ -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.setLibrary(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.setLibrary(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();
@@ -220,114 +225,102 @@ export const LibraryMenu = ({
libItem.status = "published";
}
});
library.setLibrary(nextLibItems);
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" &&
!libraryItemsData.isInitialized
) {
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.setLibrary(libraryItemsData.libraryItems)
}
updateItemsInStorage={() => library.saveLibrary(libraryItems)}
onRemove={(id: string) =>
setSelectedItems(selectedItems.filter((_id) => _id !== id))
}
/>
)}
{publishLibSuccess && renderPublishSuccess()}
<LibraryMenuItems
isLoading={libraryItemsData.status === "loading"}
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>
);
};
+19 -22
View File
@@ -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";
@@ -22,10 +22,8 @@ import { Tooltip } from "./Tooltip";
import "./LibraryMenuItems.scss";
import { VERSIONS } from "../constants";
import Spinner from "./Spinner";
const LibraryMenuItems = ({
isLoading,
libraryItems,
onRemoveFromLibrary,
onAddToLibrary,
@@ -42,7 +40,6 @@ const LibraryMenuItems = ({
onPublish,
resetLibrary,
}: {
isLoading: boolean;
libraryItems: LibraryItems;
pendingElements: LibraryItem["elements"];
onRemoveFromLibrary: () => void;
@@ -88,7 +85,7 @@ const LibraryMenuItems = ({
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
const isMobile = useDeviceType().isMobile;
const isMobile = useIsMobile();
const renderLibraryActions = () => {
const itemsSelected = !!selectedItems.length;
@@ -109,10 +106,14 @@ 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) => {
console.error(error);
setAppState({ errorMessage: t("errors.importLibraryError") });
setAppState({ errorMessage: error.message });
});
}}
className="library-actions--load"
@@ -129,7 +130,7 @@ const LibraryMenuItems = ({
onClick={async () => {
const libraryItems = itemsSelected
? items
: await library.getLatestLibrary();
: await library.loadLibrary();
saveLibraryAsJSON(libraryItems)
.catch(muteFSAbortError)
.catch((error) => {
@@ -288,20 +289,16 @@ const LibraryMenuItems = ({
{showRemoveLibAlert && renderRemoveLibAlert()}
<div className="layer-ui__library-header" key="library-header">
{renderLibraryActions()}
{isLoading ? (
<Spinner />
) : (
<a
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
window.name || "_blank"
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
VERSIONS.excalidrawLibrary
}`}
target="_excalidraw_libraries"
>
{t("labels.libraries")}
</a>
)}
<a
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
window.name || "_blank"
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
VERSIONS.excalidrawLibrary
}`}
target="_excalidraw_libraries"
>
{t("labels.libraries")}
</a>
</div>
<Stack.Col
className="library-menu-items-container__items"
+2 -2
View File
@@ -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>
);
+3 -23
View File
@@ -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>
);
};
+3 -4
View File
@@ -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
@@ -226,7 +225,7 @@ export const MobileMenu = ({
appState={appState}
elements={elements}
renderAction={actionManager.renderAction}
activeTool={appState.activeTool.type}
elementType={appState.elementType}
/>
</Section>
) : null}
+6 -6
View File
@@ -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 =
+3 -3
View File
@@ -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;
}
-2
View File
@@ -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(() => {
+1 -1
View File
@@ -155,7 +155,7 @@
}
width: 2rem;
height: 2rem;
height: 2em;
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
// container in body where the actual tooltip is appended to
.excalidraw-tooltip {
position: fixed;
position: absolute;
z-index: 1000;
padding: 8px;
+2 -18
View File
@@ -1,5 +1,5 @@
import cssVariables from "./css/variables.module.scss";
import { AppProps, CustomElementConfig } from "./types";
import { AppProps } from "./types";
import { FontFamilyValues } from "./element/types";
export const APP_NAME = "Excalidraw";
@@ -94,9 +94,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",
@@ -108,8 +106,7 @@ export const EXPORT_DATA_TYPES = {
excalidrawLibrary: "excalidrawlib",
} as const;
export const EXPORT_SOURCE =
window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin;
export const EXPORT_SOURCE = window.location.origin;
// time in milliseconds
export const IMAGE_RENDER_TIMEOUT = 500;
@@ -155,17 +152,6 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
},
};
export const DEFAULT_CUSTOM_ELEMENT_CONFIG: Required<CustomElementConfig> = {
type: "custom",
customType: "custom",
transformHandles: true,
displayData: { content: "", type: "svg" },
width: 40,
height: 40,
stackedOnTop: false,
onCreate: () => {},
disableContextMenu: false,
};
export const MQ_MAX_WIDTH_PORTRAIT = 730;
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
@@ -202,5 +188,3 @@ export const VERTICAL_ALIGN = {
MIDDLE: "middle",
BOTTOM: "bottom",
};
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
+8 -10
View File
@@ -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;
}
-9
View File
@@ -477,15 +477,6 @@
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 {
display: grid;
+13 -13
View File
@@ -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 (
-2
View File
@@ -13,9 +13,7 @@ type FILE_EXTENSION =
| "gif"
| "jpg"
| "png"
| "excalidraw.png"
| "svg"
| "excalidraw.svg"
| "json"
| "excalidraw"
| "excalidrawlib";
+1 -3
View File
@@ -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");
}
+5 -12
View File
@@ -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");
}
};
+3 -8
View File
@@ -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,
+92 -176
View File
@@ -1,205 +1,121 @@
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";
export const libraryItemsAtom = atom<{
status: "loading" | "loaded";
isInitialized: boolean;
libraryItems: LibraryItems;
}>({ status: "loaded", isInitialized: true, 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
);
});
});
};
/** Merges otherItems into localItems. Unique items in otherItems array are
sorted first. */
export const mergeLibraryItems = (
localItems: LibraryItems,
otherItems: LibraryItems,
): LibraryItems => {
const newItems = [];
for (const item of otherItems) {
if (isUniqueItem(localItems, item)) {
newItems.push(item);
}
}
return [...newItems, ...localItems];
};
class Library {
/** latest libraryItems */
private lastLibraryItems: LibraryItems = [];
/** indicates whether library is initialized with library items (has gone
* though at least one update) */
private isInitialized = false;
private libraryCache: LibraryItems | null = null;
private app: App;
constructor(app: App) {
this.app = app;
}
private updateQueue: Promise<LibraryItems>[] = [];
private getLastUpdateTask = (): Promise<LibraryItems> | undefined => {
return this.updateQueue[this.updateQueue.length - 1];
resetLibrary = async () => {
await this.app.props.onLibraryChange?.([]);
this.libraryCache = [];
};
private notifyListeners = () => {
if (this.updateQueue.length > 0) {
jotaiStore.set(libraryItemsAtom, {
status: "loading",
libraryItems: this.lastLibraryItems,
isInitialized: this.isInitialized,
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(blob: Blob, defaultStatus = "unpublished") {
const libraryFile = await loadLibraryFromBlob(blob);
if (!libraryFile || !(libraryFile.libraryItems || libraryFile.library)) {
return;
}
/**
* 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
);
});
});
} else {
this.isInitialized = true;
jotaiStore.set(libraryItemsAtom, {
status: "loaded",
libraryItems: this.lastLibraryItems,
isInitialized: this.isInitialized,
});
try {
this.app.props.onLibraryChange?.(
cloneLibraryItems(this.lastLibraryItems),
);
} catch (error) {
console.error(error);
};
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);
}
}
};
resetLibrary = () => {
return this.setLibrary([]);
};
/**
* imports library (from blob or libraryItems), merging with current library
* (attempting to remove duplicates)
*/
importLibrary(
library:
| Blob
| Required<ImportedDataState>["libraryItems"]
| Promise<Required<ImportedDataState>["libraryItems"]>,
defaultStatus: LibraryItem["status"] = "unpublished",
): Promise<LibraryItems> {
return this.setLibrary(
() =>
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);
}
resolve(mergeLibraryItems(this.lastLibraryItems, libraryItems));
} catch (error) {
reject(error);
}
}),
);
await this.saveLibrary([...filteredItems, ...existingLibraryItems]);
}
/**
* @returns latest cloned libraryItems. Awaits all in-progress updates first.
*/
getLatestLibrary = (): Promise<LibraryItems> => {
loadLibrary = (): Promise<LibraryItems> => {
return new Promise(async (resolve) => {
if (this.libraryCache) {
return resolve(JSON.parse(JSON.stringify(this.libraryCache)));
}
try {
const libraryItems = await (this.getLastUpdateTask() ||
this.lastLibraryItems);
if (this.updateQueue.length > 0) {
resolve(this.getLatestLibrary());
} else {
resolve(cloneLibraryItems(libraryItems));
const libraryItems = this.app.libraryItemsFromStorage;
if (!libraryItems) {
return resolve([]);
}
} catch (error) {
return resolve(this.lastLibraryItems);
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([]);
}
});
};
setLibrary = (
/**
* LibraryItems that will replace current items. Can be a function which
* will be invoked after all previous tasks are resolved
* (this is the prefered way to update the library to avoid race conditions,
* but you'll want to manually merge the library items in the callback
* - which is what we're doing in Library.importLibrary()).
*
* If supplied promise is rejected with AbortError, we swallow it and
* do not update the library.
*/
libraryItems:
| LibraryItems
| Promise<LibraryItems>
| ((
latestLibraryItems: LibraryItems,
) => LibraryItems | Promise<LibraryItems>),
): Promise<LibraryItems> => {
const task = new Promise<LibraryItems>(async (resolve, reject) => {
try {
await this.getLastUpdateTask();
if (typeof libraryItems === "function") {
libraryItems = libraryItems(this.lastLibraryItems);
}
this.lastLibraryItems = cloneLibraryItems(await libraryItems);
resolve(this.lastLibraryItems);
} catch (error: any) {
reject(error);
}
})
.catch((error) => {
if (error.name === "AbortError") {
console.warn("Library update aborted by user");
return this.lastLibraryItems;
}
throw error;
})
.finally(() => {
this.updateQueue = this.updateQueue.filter((_task) => _task !== task);
this.notifyListeners();
});
this.updateQueue.push(task);
this.notifyListeners();
return task;
saveLibrary = async (items: LibraryItems) => {
const prevLibraryItems = this.libraryCache;
try {
const serializedItems = JSON.stringify(items);
// cache optimistically so that the app has access to the latest
// immediately
this.libraryCache = JSON.parse(serializedItems);
await this.app.props.onLibraryChange?.(items);
} catch (error: any) {
this.libraryCache = prevLibraryItems;
throw error;
}
};
}
+14 -54
View File
@@ -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,8 +30,8 @@ type RestoredAppState = Omit<
"offsetTop" | "offsetLeft" | "width" | "height"
>;
export const AllowedExcalidrawActiveTools: Record<
AppState["activeTool"]["type"],
export const AllowedExcalidrawElementTypes: Record<
AppState["elementType"],
boolean
> = {
selection: true,
@@ -48,7 +44,6 @@ export const AllowedExcalidrawActiveTools: Record<
arrow: true,
freedraw: true,
eraser: false,
custom: true,
};
export type RestoredDataState = {
@@ -112,7 +107,6 @@ const restoreElementWithProperties = <
: element.boundElements ?? [],
updated: element.updated ?? getUpdatedTimestamp(),
link: element.link ?? null,
locked: element.locked ?? false,
};
return {
@@ -199,10 +193,6 @@ const restoreElement = (
y,
});
}
case "custom":
return restoreElementWithProperties(element, {
customType: element.customType || "custom",
});
// generic elements
case "ellipse":
return restoreElementWithProperties(element, {});
@@ -245,8 +235,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,
@@ -260,27 +252,12 @@ export const restoreAppState = (
? localValue
: defaultValue;
}
const activeTool: any = {
lastActiveToolBeforeEraser: null,
locked: nextAppState.activeTool.locked ?? false,
type: "selection",
};
if (AllowedExcalidrawActiveTools[nextAppState.activeTool.type]) {
if (nextAppState.activeTool.type === "custom") {
activeTool.type = "custom";
activeTool.customType = nextAppState.activeTool.customType ?? "custom";
} else {
activeTool.type = nextAppState.activeTool.type;
}
}
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,
elementType: AllowedExcalidrawElementTypes[nextAppState.elementType]
? nextAppState.elementType
: "selection",
// Migrates from previous version where appState.zoom was a number
zoom:
typeof appState.zoom === "number"
@@ -292,7 +269,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
@@ -309,45 +286,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;
+3 -4
View File
@@ -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 &&
+17 -47
View File
@@ -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));
};
+4 -21
View File
@@ -25,7 +25,6 @@ import {
ExcalidrawFreeDrawElement,
ExcalidrawImageElement,
ExcalidrawLinearElement,
ExcalidrawCustomElement,
} from "./types";
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
@@ -33,20 +32,13 @@ import { Point } from "../types";
import { Drawable } from "roughjs/bin/core";
import { AppState } from "../types";
import { getShapeForElement } from "../renderer/renderElement";
import {
hasBoundTextElement,
isCustomElement,
isImageElement,
} from "./typeChecks";
import { hasBoundTextElement, isImageElement } from "./typeChecks";
import { isTextElement } from ".";
import { isTransparent } from "../utils";
const isElementDraggableFromInside = (
element: NonDeletedExcalidrawElement,
): boolean => {
if (isCustomElement(element)) {
return true;
}
if (element.type === "arrow") {
return false;
}
@@ -174,7 +166,6 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
case "text":
case "diamond":
case "ellipse":
case "custom":
const distance = distanceToBindableElement(args.element, args.point);
return args.check(distance, args.threshold);
case "freedraw": {
@@ -208,7 +199,6 @@ export const distanceToBindableElement = (
case "rectangle":
case "image":
case "text":
case "custom":
return distanceToRectangle(element, point);
case "diamond":
return distanceToDiamond(element, point);
@@ -238,8 +228,7 @@ const distanceToRectangle = (
| ExcalidrawRectangleElement
| ExcalidrawTextElement
| ExcalidrawFreeDrawElement
| ExcalidrawImageElement
| ExcalidrawCustomElement,
| ExcalidrawImageElement,
point: Point,
): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
@@ -515,7 +504,6 @@ export const determineFocusDistance = (
case "rectangle":
case "image":
case "text":
case "custom":
return c / (hwidth * (nabs + q * mabs));
case "diamond":
return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
@@ -548,7 +536,6 @@ export const determineFocusPoint = (
case "image":
case "text":
case "diamond":
case "custom":
point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
break;
case "ellipse":
@@ -599,7 +586,6 @@ const getSortedElementLineIntersections = (
case "image":
case "text":
case "diamond":
case "custom":
const corners = getCorners(element);
intersections = corners
.flatMap((point, i) => {
@@ -633,8 +619,7 @@ const getCorners = (
| ExcalidrawRectangleElement
| ExcalidrawImageElement
| ExcalidrawDiamondElement
| ExcalidrawTextElement
| ExcalidrawCustomElement,
| ExcalidrawTextElement,
scale: number = 1,
): GA.Point[] => {
const hx = (scale * element.width) / 2;
@@ -643,7 +628,6 @@ const getCorners = (
case "rectangle":
case "image":
case "text":
case "custom":
return [
GA.point(hx, hy),
GA.point(hx, -hy),
@@ -786,8 +770,7 @@ export const findFocusPointForRectangulars = (
| ExcalidrawRectangleElement
| ExcalidrawImageElement
| ExcalidrawDiamondElement
| ExcalidrawTextElement
| ExcalidrawCustomElement,
| ExcalidrawTextElement,
// Between -1 and 1 for how far away should the focus point be relative
// to the size of the element. Sign determines orientation.
relativeDistance: number,
+1 -1
View File
@@ -92,7 +92,7 @@ export const getDragOffsetXY = (
export const dragNewElement = (
draggingElement: NonDeletedExcalidrawElement,
elementType: AppState["activeTool"]["type"],
elementType: AppState["elementType"],
originX: number,
originY: number,
x: number,
-14
View File
@@ -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;
}
};
+1 -1
View File
@@ -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];
+5 -1
View File
@@ -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) {
-14
View File
@@ -12,7 +12,6 @@ import {
ExcalidrawFreeDrawElement,
FontFamilyValues,
ExcalidrawRectangleElement,
ExcalidrawCustomElement,
} from "../element/types";
import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils";
import { randomInteger, randomId } from "../random";
@@ -57,7 +56,6 @@ const _newElementBase = <T extends ExcalidrawElement>(
strokeSharpness,
boundElements = null,
link = null,
locked,
...rest
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
) => {
@@ -85,7 +83,6 @@ const _newElementBase = <T extends ExcalidrawElement>(
boundElements,
updated: getUpdatedTimestamp(),
link,
locked,
};
return element;
};
@@ -321,17 +318,6 @@ export const newImageElement = (
};
};
export const newCustomElement = (
customType: string,
opts: {
type: ExcalidrawCustomElement["type"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawCustomElement> => {
return {
..._newElementBase<ExcalidrawCustomElement>("custom", opts),
customType,
};
};
// Simplified deep clone for the purpose of cloning ExcalidrawElement only
// (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.)
//
+1 -2
View File
@@ -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,
+2 -2
View File
@@ -10,6 +10,6 @@ export const showSelectedShapeActions = (
!appState.viewModeEnabled &&
(appState.editingElement ||
getSelectedElements(elements, appState).length ||
(appState.activeTool.type !== "selection" &&
appState.activeTool.type !== "eraser")),
(appState.elementType !== "selection" &&
appState.elementType !== "eraser")),
);
+1 -2
View File
@@ -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 } => {
+5 -3
View File
@@ -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,
});
};
+1 -66
View File
@@ -544,29 +544,6 @@ describe("textWysiwyg", () => {
expect((h.elements[1] as ExcalidrawTextElement).containerId).toBe(null);
});
it("should'nt bind text to container when not double clicked on center", async () => {
expect(h.elements.length).toBe(1);
expect(h.elements[0].id).toBe(rectangle.id);
// clicking somewhere on top left
mouse.doubleClickAt(rectangle.x + 20, rectangle.y + 20);
expect(h.elements.length).toBe(2);
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
expect(text.containerId).toBe(null);
mouse.down();
const editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { target: { value: "Hello World!" } });
await new Promise((r) => setTimeout(r, 0));
editor.blur();
expect(rectangle.boundElements).toBe(null);
});
it("should update font family correctly on undo/redo by selecting bounded text when font family was updated", async () => {
expect(h.elements.length).toBe(1);
@@ -727,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);
@@ -768,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);
});
});
});
+7 -13
View File
@@ -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));
@@ -283,14 +281,7 @@ export const textWysiwyg = ({
// using scrollHeight here since we need to calculate
// number of lines so cannot use editable.style.height
// as that gets updated below
// Rounding here so that the lines calculated is more accurate in all browsers.
// The scrollHeight and approxLineHeight differs in diff browsers
// eg it gives 1.05 in firefox for handewritten small font due to which
// height gets updated as lines > 1 and leads to jumping text for first line in bound container
// hence rounding here to avoid that
const lines = Math.round(
editable.scrollHeight / getApproxLineHeight(font),
);
const lines = editable.scrollHeight / getApproxLineHeight(font);
// auto increase height only when lines > 1 so its
// measured correctly and vertically aligns for
// first line as well as setting height to "auto"
@@ -305,6 +296,7 @@ export const textWysiwyg = ({
font,
container!.width,
).split("\n").length;
// This is browser behaviour when setting height to "auto"
// It sets the height needed for 2 lines even if actual
// line count is 1 as mentioned above as well
@@ -322,6 +314,8 @@ export const textWysiwyg = ({
}
editable.onkeydown = (event) => {
event.stopPropagation();
if (!event.shiftKey && actionZoomIn.keyTest(event)) {
event.preventDefault();
app.actionManager.executeAction(actionZoomIn);
-7
View File
@@ -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" ||
+3 -17
View File
@@ -10,7 +10,6 @@ import {
ExcalidrawImageElement,
ExcalidrawTextElementWithContainer,
ExcalidrawTextContainer,
ExcalidrawCustomElement,
} from "./types";
export const isGenericElement = (
@@ -62,7 +61,7 @@ export const isLinearElement = (
};
export const isLinearElementType = (
elementType: AppState["activeTool"]["type"],
elementType: AppState["elementType"],
): boolean => {
return (
elementType === "arrow" || elementType === "line" // || elementType === "freedraw"
@@ -71,28 +70,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: AppState["elementType"],
): 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" ||
@@ -103,11 +95,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" ||
@@ -143,7 +133,3 @@ export const isBoundToContainer = (
element !== null && isTextElement(element) && element.containerId !== null
);
};
export const isCustomElement = (
element: ExcalidrawElement,
): element is ExcalidrawCustomElement => element && element.type === "custom";
+2 -8
View File
@@ -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 & {
@@ -84,9 +83,6 @@ export type ExcalidrawImageElement = _ExcalidrawElementBase &
scale: [number, number];
}>;
export type ExcalidrawCustomElement = _ExcalidrawElementBase &
Readonly<{ type: "custom"; customType: string }>;
export type InitializedExcalidrawImageElement = MarkNonNullable<
ExcalidrawImageElement,
"fileId"
@@ -111,8 +107,7 @@ export type ExcalidrawElement =
| ExcalidrawTextElement
| ExcalidrawLinearElement
| ExcalidrawFreeDrawElement
| ExcalidrawImageElement
| ExcalidrawCustomElement;
| ExcalidrawImageElement;
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
isDeleted: boolean;
@@ -138,8 +133,7 @@ export type ExcalidrawBindableElement =
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement
| ExcalidrawTextElement
| ExcalidrawImageElement
| ExcalidrawCustomElement;
| ExcalidrawImageElement;
export type ExcalidrawTextContainer =
| ExcalidrawRectangleElement
+2 -2
View File
@@ -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",
}
+22 -54
View File
@@ -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;
+19 -12
View File
@@ -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) => {
+1 -7
View File
@@ -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);
-154
View File
@@ -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 };
},
});
}
-18
View File
@@ -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;
}
}
+43 -77
View File
@@ -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));
+28 -7
View File
@@ -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 [];
}
};

Some files were not shown because too many files have changed in this diff Show More