mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-11-03 20:34:40 +01:00 
			
		
		
		
	Compare commits
	
		
			19 Commits
		
	
	
		
			aakansha-f
			...
			zsviczian-
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					3b37ffbf6e | ||
| 
						 | 
					38b58ea1da | ||
| 
						 | 
					89a0dbafde | ||
| 
						 | 
					01789c3375 | ||
| 
						 | 
					563caa3f07 | ||
| 
						 | 
					bb04943564 | ||
| 
						 | 
					7bcc1f2a41 | ||
| 
						 | 
					fb449b6758 | ||
| 
						 | 
					8d60f22ff7 | ||
| 
						 | 
					93bd035d03 | ||
| 
						 | 
					4dec449516 | ||
| 
						 | 
					c45433c8db | ||
| 
						 | 
					22cd6f5115 | ||
| 
						 | 
					53ba9dffd9 | ||
| 
						 | 
					7e7864ca3d | ||
| 
						 | 
					15d88d0fe0 | ||
| 
						 | 
					24d7380333 | ||
| 
						 | 
					0ecb53e2f2 | ||
| 
						 | 
					cf8024bdc0 | 
@@ -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"}'
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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"
 | 
			
		||||
 
 | 
			
		||||
@@ -124,6 +124,26 @@
 | 
			
		||||
        user-select: none;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .LoadingMessage {
 | 
			
		||||
        position: absolute;
 | 
			
		||||
        top: 0;
 | 
			
		||||
        right: 0;
 | 
			
		||||
        bottom: 0;
 | 
			
		||||
        left: 0;
 | 
			
		||||
        z-index: 999;
 | 
			
		||||
        display: flex;
 | 
			
		||||
        align-items: center;
 | 
			
		||||
        justify-content: center;
 | 
			
		||||
        pointer-events: none;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      .LoadingMessage span {
 | 
			
		||||
        background-color: var(--button-gray-1);
 | 
			
		||||
        border-radius: 5px;
 | 
			
		||||
        padding: 0.8em 1.2em;
 | 
			
		||||
        color: var(--popup-text-color);
 | 
			
		||||
        font-size: 1.3em;
 | 
			
		||||
      }
 | 
			
		||||
      #root {
 | 
			
		||||
        height: 100%;
 | 
			
		||||
        -webkit-touch-callout: none;
 | 
			
		||||
@@ -132,10 +152,8 @@
 | 
			
		||||
        -moz-user-select: none;
 | 
			
		||||
        -ms-user-select: none;
 | 
			
		||||
        user-select: none;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      @media screen and (min-width: 1200px) {
 | 
			
		||||
        #root {
 | 
			
		||||
        @media screen and (min-width: 1200px) {
 | 
			
		||||
          -webkit-touch-callout: default;
 | 
			
		||||
          -webkit-user-select: auto;
 | 
			
		||||
          -khtml-user-select: auto;
 | 
			
		||||
@@ -152,6 +170,10 @@
 | 
			
		||||
    <header>
 | 
			
		||||
      <h1 class="visually-hidden">Excalidraw</h1>
 | 
			
		||||
    </header>
 | 
			
		||||
    <div id="root"></div>
 | 
			
		||||
    <div id="root">
 | 
			
		||||
      <div class="LoadingMessage">
 | 
			
		||||
        <span>Loading scene...</span>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </body>
 | 
			
		||||
</html>
 | 
			
		||||
 
 | 
			
		||||
@@ -7,7 +7,6 @@ import { t } from "../i18n";
 | 
			
		||||
 | 
			
		||||
export const actionAddToLibrary = register({
 | 
			
		||||
  name: "addToLibrary",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState, _, app) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
@@ -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",
 | 
			
		||||
 
 | 
			
		||||
@@ -43,7 +43,6 @@ const alignSelectedElements = (
 | 
			
		||||
 | 
			
		||||
export const actionAlignTop = register({
 | 
			
		||||
  name: "alignTop",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
@@ -73,7 +72,6 @@ export const actionAlignTop = register({
 | 
			
		||||
 | 
			
		||||
export const actionAlignBottom = register({
 | 
			
		||||
  name: "alignBottom",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
@@ -103,7 +101,6 @@ export const actionAlignBottom = register({
 | 
			
		||||
 | 
			
		||||
export const actionAlignLeft = register({
 | 
			
		||||
  name: "alignLeft",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
@@ -133,8 +130,6 @@ export const actionAlignLeft = register({
 | 
			
		||||
 | 
			
		||||
export const actionAlignRight = register({
 | 
			
		||||
  name: "alignRight",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
@@ -164,8 +159,6 @@ export const actionAlignRight = register({
 | 
			
		||||
 | 
			
		||||
export const actionAlignVerticallyCentered = register({
 | 
			
		||||
  name: "alignVerticallyCentered",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
@@ -191,7 +184,6 @@ export const actionAlignVerticallyCentered = register({
 | 
			
		||||
 | 
			
		||||
export const actionAlignHorizontallyCentered = register({
 | 
			
		||||
  name: "alignHorizontallyCentered",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,136 +0,0 @@
 | 
			
		||||
import { VERTICAL_ALIGN } from "../constants";
 | 
			
		||||
import { getNonDeletedElements, isTextElement } from "../element";
 | 
			
		||||
import { mutateElement } from "../element/mutateElement";
 | 
			
		||||
import {
 | 
			
		||||
  getBoundTextElement,
 | 
			
		||||
  measureText,
 | 
			
		||||
  redrawTextBoundingBox,
 | 
			
		||||
} from "../element/textElement";
 | 
			
		||||
import {
 | 
			
		||||
  hasBoundTextElement,
 | 
			
		||||
  isTextBindableContainer,
 | 
			
		||||
} from "../element/typeChecks";
 | 
			
		||||
import {
 | 
			
		||||
  ExcalidrawTextContainer,
 | 
			
		||||
  ExcalidrawTextElement,
 | 
			
		||||
} from "../element/types";
 | 
			
		||||
import { getSelectedElements } from "../scene";
 | 
			
		||||
import { getFontString } from "../utils";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
 | 
			
		||||
export const actionUnbindText = register({
 | 
			
		||||
  name: "unbindText",
 | 
			
		||||
  contextItemLabel: "labels.unbindText",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  contextItemPredicate: (elements, appState) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(elements, appState);
 | 
			
		||||
    return selectedElements.some((element) => hasBoundTextElement(element));
 | 
			
		||||
  },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
      appState,
 | 
			
		||||
    );
 | 
			
		||||
    selectedElements.forEach((element) => {
 | 
			
		||||
      const boundTextElement = getBoundTextElement(element);
 | 
			
		||||
      if (boundTextElement) {
 | 
			
		||||
        const { width, height, baseline } = measureText(
 | 
			
		||||
          boundTextElement.originalText,
 | 
			
		||||
          getFontString(boundTextElement),
 | 
			
		||||
        );
 | 
			
		||||
        mutateElement(boundTextElement as ExcalidrawTextElement, {
 | 
			
		||||
          containerId: null,
 | 
			
		||||
          width,
 | 
			
		||||
          height,
 | 
			
		||||
          baseline,
 | 
			
		||||
          text: boundTextElement.originalText,
 | 
			
		||||
        });
 | 
			
		||||
        mutateElement(element, {
 | 
			
		||||
          boundElements: element.boundElements?.filter(
 | 
			
		||||
            (ele) => ele.id !== boundTextElement.id,
 | 
			
		||||
          ),
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    return {
 | 
			
		||||
      elements,
 | 
			
		||||
      appState,
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const actionBindText = register({
 | 
			
		||||
  name: "bindText",
 | 
			
		||||
  contextItemLabel: "labels.bindText",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  contextItemPredicate: (elements, appState) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(elements, appState);
 | 
			
		||||
 | 
			
		||||
    if (selectedElements.length === 2) {
 | 
			
		||||
      const textElement =
 | 
			
		||||
        isTextElement(selectedElements[0]) ||
 | 
			
		||||
        isTextElement(selectedElements[1]);
 | 
			
		||||
 | 
			
		||||
      let bindingContainer;
 | 
			
		||||
      if (isTextBindableContainer(selectedElements[0])) {
 | 
			
		||||
        bindingContainer = selectedElements[0];
 | 
			
		||||
      } else if (isTextBindableContainer(selectedElements[1])) {
 | 
			
		||||
        bindingContainer = selectedElements[1];
 | 
			
		||||
      }
 | 
			
		||||
      if (
 | 
			
		||||
        textElement &&
 | 
			
		||||
        bindingContainer &&
 | 
			
		||||
        getBoundTextElement(bindingContainer) === null
 | 
			
		||||
      ) {
 | 
			
		||||
        return true;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return false;
 | 
			
		||||
  },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
      appState,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    let textElement: ExcalidrawTextElement;
 | 
			
		||||
    let container: ExcalidrawTextContainer;
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
      isTextElement(selectedElements[0]) &&
 | 
			
		||||
      isTextBindableContainer(selectedElements[1])
 | 
			
		||||
    ) {
 | 
			
		||||
      textElement = selectedElements[0];
 | 
			
		||||
      container = selectedElements[1];
 | 
			
		||||
    } else {
 | 
			
		||||
      textElement = selectedElements[1] as ExcalidrawTextElement;
 | 
			
		||||
      container = selectedElements[0] as ExcalidrawTextContainer;
 | 
			
		||||
    }
 | 
			
		||||
    mutateElement(textElement, {
 | 
			
		||||
      containerId: container.id,
 | 
			
		||||
      verticalAlign: VERTICAL_ALIGN.MIDDLE,
 | 
			
		||||
    });
 | 
			
		||||
    mutateElement(container, {
 | 
			
		||||
      boundElements: (container.boundElements || []).concat({
 | 
			
		||||
        type: "text",
 | 
			
		||||
        id: textElement.id,
 | 
			
		||||
      }),
 | 
			
		||||
    });
 | 
			
		||||
    redrawTextBoundingBox(textElement, container);
 | 
			
		||||
    const updatedElements = elements.slice();
 | 
			
		||||
    const textElementIndex = updatedElements.findIndex(
 | 
			
		||||
      (ele) => ele.id === textElement.id,
 | 
			
		||||
    );
 | 
			
		||||
    updatedElements.splice(textElementIndex, 1);
 | 
			
		||||
    const containerIndex = updatedElements.findIndex(
 | 
			
		||||
      (ele) => ele.id === container.id,
 | 
			
		||||
    );
 | 
			
		||||
    updatedElements.splice(containerIndex + 1, 0, textElement);
 | 
			
		||||
    return {
 | 
			
		||||
      elements: updatedElements,
 | 
			
		||||
      appState: { ...appState, selectedElementIds: { [container.id]: true } },
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
@@ -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,17 +60,15 @@ export const actionClearCanvas = register({
 | 
			
		||||
        ...getDefaultAppState(),
 | 
			
		||||
        files: {},
 | 
			
		||||
        theme: appState.theme,
 | 
			
		||||
        elementLocked: appState.elementLocked,
 | 
			
		||||
        penMode: appState.penMode,
 | 
			
		||||
        penDetected: appState.penDetected,
 | 
			
		||||
        exportBackground: appState.exportBackground,
 | 
			
		||||
        exportEmbedScene: appState.exportEmbedScene,
 | 
			
		||||
        gridSize: appState.gridSize,
 | 
			
		||||
        showStats: appState.showStats,
 | 
			
		||||
        pasteDialog: appState.pasteDialog,
 | 
			
		||||
        activeTool:
 | 
			
		||||
          appState.activeTool.type === "image"
 | 
			
		||||
            ? { ...appState.activeTool, type: "selection" }
 | 
			
		||||
            : appState.activeTool,
 | 
			
		||||
        elementType:
 | 
			
		||||
          appState.elementType === "image" ? "selection" : appState.elementType,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
@@ -83,7 +79,6 @@ export const actionClearCanvas = register({
 | 
			
		||||
 | 
			
		||||
export const actionZoomIn = register({
 | 
			
		||||
  name: "zoomIn",
 | 
			
		||||
  trackEvent: { category: "canvas" },
 | 
			
		||||
  perform: (_elements, appState, _, app) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
@@ -119,7 +114,6 @@ export const actionZoomIn = register({
 | 
			
		||||
 | 
			
		||||
export const actionZoomOut = register({
 | 
			
		||||
  name: "zoomOut",
 | 
			
		||||
  trackEvent: { category: "canvas" },
 | 
			
		||||
  perform: (_elements, appState, _, app) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
@@ -155,7 +149,6 @@ export const actionZoomOut = register({
 | 
			
		||||
 | 
			
		||||
export const actionResetZoom = register({
 | 
			
		||||
  name: "resetZoom",
 | 
			
		||||
  trackEvent: { category: "canvas" },
 | 
			
		||||
  perform: (_elements, appState, _, app) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
@@ -254,7 +247,6 @@ const zoomToFitElements = (
 | 
			
		||||
 | 
			
		||||
export const actionZoomToSelected = register({
 | 
			
		||||
  name: "zoomToSelection",
 | 
			
		||||
  trackEvent: { category: "canvas" },
 | 
			
		||||
  perform: (elements, appState) => zoomToFitElements(elements, appState, true),
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
    event.code === CODES.TWO &&
 | 
			
		||||
@@ -265,7 +257,6 @@ export const actionZoomToSelected = register({
 | 
			
		||||
 | 
			
		||||
export const actionZoomToFit = register({
 | 
			
		||||
  name: "zoomToFit",
 | 
			
		||||
  trackEvent: { category: "canvas" },
 | 
			
		||||
  perform: (elements, appState) => zoomToFitElements(elements, appState, false),
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
    event.code === CODES.ONE &&
 | 
			
		||||
@@ -276,7 +267,6 @@ export const actionZoomToFit = register({
 | 
			
		||||
 | 
			
		||||
export const actionToggleTheme = register({
 | 
			
		||||
  name: "toggleTheme",
 | 
			
		||||
  trackEvent: { category: "canvas" },
 | 
			
		||||
  perform: (_, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
@@ -302,23 +292,13 @@ export const actionToggleTheme = register({
 | 
			
		||||
 | 
			
		||||
export const actionErase = register({
 | 
			
		||||
  name: "eraser",
 | 
			
		||||
  trackEvent: { category: "toolbar" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        selectedElementIds: {},
 | 
			
		||||
        selectedGroupIds: {},
 | 
			
		||||
        activeTool: {
 | 
			
		||||
          ...appState.activeTool,
 | 
			
		||||
          type: isEraserActive(appState)
 | 
			
		||||
            ? appState.activeTool.lastActiveToolBeforeEraser ?? "selection"
 | 
			
		||||
            : "eraser",
 | 
			
		||||
          lastActiveToolBeforeEraser:
 | 
			
		||||
            appState.activeTool.type === "eraser" //node throws incorrect type error when using isEraserActive()
 | 
			
		||||
              ? null
 | 
			
		||||
              : appState.activeTool.type,
 | 
			
		||||
        },
 | 
			
		||||
        elementType: isEraserActive(appState) ? "selection" : "eraser",
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
 
 | 
			
		||||
@@ -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",
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -58,7 +58,6 @@ const handleGroupEditingState = (
 | 
			
		||||
 | 
			
		||||
export const actionDeleteSelected = register({
 | 
			
		||||
  name: "deleteSelectedElements",
 | 
			
		||||
  trackEvent: { category: "element", action: "delete" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    if (appState.editingLinearElement) {
 | 
			
		||||
      const {
 | 
			
		||||
@@ -134,7 +133,7 @@ export const actionDeleteSelected = register({
 | 
			
		||||
      elements: nextElements,
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...nextAppState,
 | 
			
		||||
        activeTool: { ...appState.activeTool, type: "selection" },
 | 
			
		||||
        elementType: "selection",
 | 
			
		||||
        multiElement: null,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: isSomeElementSelected(
 | 
			
		||||
 
 | 
			
		||||
@@ -39,7 +39,6 @@ const distributeSelectedElements = (
 | 
			
		||||
 | 
			
		||||
export const distributeHorizontally = register({
 | 
			
		||||
  name: "distributeHorizontally",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
@@ -69,7 +68,6 @@ export const distributeHorizontally = register({
 | 
			
		||||
 | 
			
		||||
export const distributeVertically = register({
 | 
			
		||||
  name: "distributeVertically",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState,
 | 
			
		||||
 
 | 
			
		||||
@@ -22,7 +22,6 @@ import { isBoundToContainer } from "../element/typeChecks";
 | 
			
		||||
 | 
			
		||||
export const actionDuplicateSelection = register({
 | 
			
		||||
  name: "duplicateSelection",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    // duplicate selected point(s) if editing a line
 | 
			
		||||
    if (appState.editingLinearElement) {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
import { trackEvent } from "../analytics";
 | 
			
		||||
import { load, questionCircle, saveAs } from "../components/icons";
 | 
			
		||||
import { ProjectName } from "../components/ProjectName";
 | 
			
		||||
import { ToolButton } from "../components/ToolButton";
 | 
			
		||||
@@ -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(
 | 
			
		||||
@@ -214,7 +210,6 @@ export const actionSaveFileToDisk = register({
 | 
			
		||||
 | 
			
		||||
export const actionLoadScene = register({
 | 
			
		||||
  name: "loadScene",
 | 
			
		||||
  trackEvent: { category: "export" },
 | 
			
		||||
  perform: async (elements, appState, _, app) => {
 | 
			
		||||
    try {
 | 
			
		||||
      const {
 | 
			
		||||
@@ -257,7 +252,6 @@ export const actionLoadScene = register({
 | 
			
		||||
 | 
			
		||||
export const actionExportWithDarkMode = register({
 | 
			
		||||
  name: "exportWithDarkMode",
 | 
			
		||||
  trackEvent: { category: "export", action: "toggleTheme" },
 | 
			
		||||
  perform: (_elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: { ...appState, exportWithDarkMode: value },
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,6 @@ import { isBindingElement } from "../element/typeChecks";
 | 
			
		||||
 | 
			
		||||
export const actionFinalize = register({
 | 
			
		||||
  name: "finalize",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, _, { canvas, focusContainer }) => {
 | 
			
		||||
    if (appState.editingLinearElement) {
 | 
			
		||||
      const { elementId, startBindingElement, endBindingElement } =
 | 
			
		||||
@@ -39,7 +38,6 @@ export const actionFinalize = register({
 | 
			
		||||
              : undefined,
 | 
			
		||||
          appState: {
 | 
			
		||||
            ...appState,
 | 
			
		||||
            cursorButton: "up",
 | 
			
		||||
            editingLinearElement: null,
 | 
			
		||||
          },
 | 
			
		||||
          commitToHistory: true,
 | 
			
		||||
@@ -121,17 +119,13 @@ export const actionFinalize = register({
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (
 | 
			
		||||
        !appState.activeTool.locked &&
 | 
			
		||||
        appState.activeTool.type !== "freedraw"
 | 
			
		||||
      ) {
 | 
			
		||||
      if (!appState.elementLocked && appState.elementType !== "freedraw") {
 | 
			
		||||
        appState.selectedElementIds[multiPointElement.id] = true;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
      (!appState.activeTool.locked &&
 | 
			
		||||
        appState.activeTool.type !== "freedraw") ||
 | 
			
		||||
      (!appState.elementLocked && appState.elementType !== "freedraw") ||
 | 
			
		||||
      !multiPointElement
 | 
			
		||||
    ) {
 | 
			
		||||
      resetCursor(canvas);
 | 
			
		||||
@@ -141,20 +135,11 @@ export const actionFinalize = register({
 | 
			
		||||
      elements: newElements,
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        cursorButton: "up",
 | 
			
		||||
        activeTool:
 | 
			
		||||
          (appState.activeTool.locked ||
 | 
			
		||||
            appState.activeTool.type === "freedraw") &&
 | 
			
		||||
        elementType:
 | 
			
		||||
          (appState.elementLocked || appState.elementType === "freedraw") &&
 | 
			
		||||
          multiPointElement
 | 
			
		||||
            ? appState.activeTool
 | 
			
		||||
            : {
 | 
			
		||||
                ...appState.activeTool,
 | 
			
		||||
                type:
 | 
			
		||||
                  appState.activeTool.type === "eraser" &&
 | 
			
		||||
                  appState.activeTool.lastActiveToolBeforeEraser
 | 
			
		||||
                    ? appState.activeTool.lastActiveToolBeforeEraser
 | 
			
		||||
                    : "selection",
 | 
			
		||||
              },
 | 
			
		||||
            ? appState.elementType
 | 
			
		||||
            : "selection",
 | 
			
		||||
        draggingElement: null,
 | 
			
		||||
        multiElement: null,
 | 
			
		||||
        editingElement: null,
 | 
			
		||||
@@ -162,8 +147,8 @@ export const actionFinalize = register({
 | 
			
		||||
        suggestedBindings: [],
 | 
			
		||||
        selectedElementIds:
 | 
			
		||||
          multiPointElement &&
 | 
			
		||||
          !appState.activeTool.locked &&
 | 
			
		||||
          appState.activeTool.type !== "freedraw"
 | 
			
		||||
          !appState.elementLocked &&
 | 
			
		||||
          appState.elementType !== "freedraw"
 | 
			
		||||
            ? {
 | 
			
		||||
                ...appState.selectedElementIds,
 | 
			
		||||
                [multiPointElement.id]: true,
 | 
			
		||||
@@ -171,7 +156,7 @@ export const actionFinalize = register({
 | 
			
		||||
            : appState.selectedElementIds,
 | 
			
		||||
        pendingImageElement: null,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: appState.activeTool.type === "freedraw",
 | 
			
		||||
      commitToHistory: appState.elementType === "freedraw",
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  keyTest: (event, appState) =>
 | 
			
		||||
 
 | 
			
		||||
@@ -35,7 +35,6 @@ const enableActionFlipVertical = (
 | 
			
		||||
 | 
			
		||||
export const actionFlipHorizontal = register({
 | 
			
		||||
  name: "flipHorizontal",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: flipSelectedElements(elements, appState, "horizontal"),
 | 
			
		||||
@@ -51,7 +50,6 @@ export const actionFlipHorizontal = register({
 | 
			
		||||
 | 
			
		||||
export const actionFlipVertical = register({
 | 
			
		||||
  name: "flipVertical",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: flipSelectedElements(elements, appState, "vertical"),
 | 
			
		||||
 
 | 
			
		||||
@@ -54,7 +54,6 @@ const enableActionGroup = (
 | 
			
		||||
 | 
			
		||||
export const actionGroup = register({
 | 
			
		||||
  name: "group",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
@@ -148,7 +147,6 @@ export const actionGroup = register({
 | 
			
		||||
 | 
			
		||||
export const actionUngroup = register({
 | 
			
		||||
  name: "ungroup",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    const groupIds = getSelectedGroupIds(appState);
 | 
			
		||||
    if (groupIds.length === 0) {
 | 
			
		||||
 
 | 
			
		||||
@@ -62,7 +62,6 @@ type ActionCreator = (history: History) => Action;
 | 
			
		||||
 | 
			
		||||
export const createUndoAction: ActionCreator = (history) => ({
 | 
			
		||||
  name: "undo",
 | 
			
		||||
  trackEvent: { category: "history" },
 | 
			
		||||
  perform: (elements, appState) =>
 | 
			
		||||
    writeData(elements, appState, () => history.undoOnce()),
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
@@ -83,7 +82,6 @@ export const createUndoAction: ActionCreator = (history) => ({
 | 
			
		||||
 | 
			
		||||
export const createRedoAction: ActionCreator = (history) => ({
 | 
			
		||||
  name: "redo",
 | 
			
		||||
  trackEvent: { category: "history" },
 | 
			
		||||
  perform: (elements, appState) =>
 | 
			
		||||
    writeData(elements, appState, () => history.redoOnce()),
 | 
			
		||||
  keyTest: (event) =>
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,6 @@ import { HelpIcon } from "../components/HelpIcon";
 | 
			
		||||
 | 
			
		||||
export const actionToggleCanvasMenu = register({
 | 
			
		||||
  name: "toggleCanvasMenu",
 | 
			
		||||
  trackEvent: { category: "menu" },
 | 
			
		||||
  perform: (_, appState) => ({
 | 
			
		||||
    appState: {
 | 
			
		||||
      ...appState,
 | 
			
		||||
@@ -30,7 +29,6 @@ export const actionToggleCanvasMenu = register({
 | 
			
		||||
 | 
			
		||||
export const actionToggleEditMenu = register({
 | 
			
		||||
  name: "toggleEditMenu",
 | 
			
		||||
  trackEvent: { category: "menu" },
 | 
			
		||||
  perform: (_elements, appState) => ({
 | 
			
		||||
    appState: {
 | 
			
		||||
      ...appState,
 | 
			
		||||
@@ -55,7 +53,6 @@ export const actionToggleEditMenu = register({
 | 
			
		||||
 | 
			
		||||
export const actionFullScreen = register({
 | 
			
		||||
  name: "toggleFullScreen",
 | 
			
		||||
  trackEvent: { category: "canvas", predicate: (appState) => !isFullScreen() },
 | 
			
		||||
  perform: () => {
 | 
			
		||||
    if (!isFullScreen()) {
 | 
			
		||||
      allowFullScreen();
 | 
			
		||||
@@ -72,7 +69,6 @@ export const actionFullScreen = register({
 | 
			
		||||
 | 
			
		||||
export const actionShortcuts = register({
 | 
			
		||||
  name: "toggleShortcuts",
 | 
			
		||||
  trackEvent: { category: "menu", action: "toggleHelpDialog" },
 | 
			
		||||
  perform: (_elements, appState, _, { focusContainer }) => {
 | 
			
		||||
    if (appState.showHelpDialog) {
 | 
			
		||||
      focusContainer();
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -166,7 +166,11 @@ const changeFontSize = (
 | 
			
		||||
          let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
 | 
			
		||||
            fontSize: newFontSize,
 | 
			
		||||
          });
 | 
			
		||||
          redrawTextBoundingBox(newElement, getContainerElement(oldElement));
 | 
			
		||||
          redrawTextBoundingBox(
 | 
			
		||||
            newElement,
 | 
			
		||||
            getContainerElement(oldElement),
 | 
			
		||||
            appState,
 | 
			
		||||
          );
 | 
			
		||||
 | 
			
		||||
          newElement = offsetElementAfterFontResize(oldElement, newElement);
 | 
			
		||||
 | 
			
		||||
@@ -194,7 +198,6 @@ const changeFontSize = (
 | 
			
		||||
 | 
			
		||||
export const actionChangeStrokeColor = register({
 | 
			
		||||
  name: "changeStrokeColor",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      ...(value.currentItemStrokeColor && {
 | 
			
		||||
@@ -244,7 +247,6 @@ export const actionChangeStrokeColor = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeBackgroundColor = register({
 | 
			
		||||
  name: "changeBackgroundColor",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      ...(value.currentItemBackgroundColor && {
 | 
			
		||||
@@ -287,7 +289,6 @@ export const actionChangeBackgroundColor = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeFillStyle = register({
 | 
			
		||||
  name: "changeFillStyle",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
@@ -337,7 +338,6 @@ export const actionChangeFillStyle = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeStrokeWidth = register({
 | 
			
		||||
  name: "changeStrokeWidth",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
@@ -385,7 +385,6 @@ export const actionChangeStrokeWidth = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeSloppiness = register({
 | 
			
		||||
  name: "changeSloppiness",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
@@ -434,7 +433,6 @@ export const actionChangeSloppiness = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeStrokeStyle = register({
 | 
			
		||||
  name: "changeStrokeStyle",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
@@ -482,7 +480,6 @@ export const actionChangeStrokeStyle = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeOpacity = register({
 | 
			
		||||
  name: "changeOpacity",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
@@ -503,6 +500,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 +529,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 +586,6 @@ export const actionChangeFontSize = register({
 | 
			
		||||
 | 
			
		||||
export const actionDecreaseFontSize = register({
 | 
			
		||||
  name: "decreaseFontSize",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return changeFontSize(elements, appState, (element) =>
 | 
			
		||||
      Math.round(
 | 
			
		||||
@@ -598,7 +607,6 @@ export const actionDecreaseFontSize = register({
 | 
			
		||||
 | 
			
		||||
export const actionIncreaseFontSize = register({
 | 
			
		||||
  name: "increaseFontSize",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return changeFontSize(elements, appState, (element) =>
 | 
			
		||||
      Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
 | 
			
		||||
@@ -616,7 +624,6 @@ export const actionIncreaseFontSize = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeFontFamily = register({
 | 
			
		||||
  name: "changeFontFamily",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(
 | 
			
		||||
@@ -630,7 +637,11 @@ export const actionChangeFontFamily = register({
 | 
			
		||||
                fontFamily: value,
 | 
			
		||||
              },
 | 
			
		||||
            );
 | 
			
		||||
            redrawTextBoundingBox(newElement, getContainerElement(oldElement));
 | 
			
		||||
            redrawTextBoundingBox(
 | 
			
		||||
              newElement,
 | 
			
		||||
              getContainerElement(oldElement),
 | 
			
		||||
              appState,
 | 
			
		||||
            );
 | 
			
		||||
            return newElement;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
@@ -698,7 +709,6 @@ export const actionChangeFontFamily = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeTextAlign = register({
 | 
			
		||||
  name: "changeTextAlign",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(
 | 
			
		||||
@@ -710,7 +720,11 @@ export const actionChangeTextAlign = register({
 | 
			
		||||
              oldElement,
 | 
			
		||||
              { textAlign: value },
 | 
			
		||||
            );
 | 
			
		||||
            redrawTextBoundingBox(newElement, getContainerElement(oldElement));
 | 
			
		||||
            redrawTextBoundingBox(
 | 
			
		||||
              newElement,
 | 
			
		||||
              getContainerElement(oldElement),
 | 
			
		||||
              appState,
 | 
			
		||||
            );
 | 
			
		||||
            return newElement;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
@@ -771,7 +785,6 @@ export const actionChangeTextAlign = register({
 | 
			
		||||
});
 | 
			
		||||
export const actionChangeVerticalAlign = register({
 | 
			
		||||
  name: "changeVerticalAlign",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(
 | 
			
		||||
@@ -784,7 +797,11 @@ export const actionChangeVerticalAlign = register({
 | 
			
		||||
              { verticalAlign: value },
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
            redrawTextBoundingBox(newElement, getContainerElement(oldElement));
 | 
			
		||||
            redrawTextBoundingBox(
 | 
			
		||||
              newElement,
 | 
			
		||||
              getContainerElement(oldElement),
 | 
			
		||||
              appState,
 | 
			
		||||
            );
 | 
			
		||||
            return newElement;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
@@ -839,7 +856,6 @@ export const actionChangeVerticalAlign = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeSharpness = register({
 | 
			
		||||
  name: "changeSharpness",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (elements, appState, value) => {
 | 
			
		||||
    const targetElements = getTargetElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
@@ -847,10 +863,10 @@ export const actionChangeSharpness = register({
 | 
			
		||||
    );
 | 
			
		||||
    const shouldUpdateForNonLinearElements = targetElements.length
 | 
			
		||||
      ? targetElements.every((el) => !isLinearElement(el))
 | 
			
		||||
      : !isLinearElementType(appState.activeTool.type);
 | 
			
		||||
      : !isLinearElementType(appState.elementType);
 | 
			
		||||
    const shouldUpdateForLinearElements = targetElements.length
 | 
			
		||||
      ? targetElements.every(isLinearElement)
 | 
			
		||||
      : isLinearElementType(appState.activeTool.type);
 | 
			
		||||
      : isLinearElementType(appState.elementType);
 | 
			
		||||
    return {
 | 
			
		||||
      elements: changeProperty(elements, appState, (el) =>
 | 
			
		||||
        newElementWith(el, {
 | 
			
		||||
@@ -890,8 +906,8 @@ export const actionChangeSharpness = register({
 | 
			
		||||
          elements,
 | 
			
		||||
          appState,
 | 
			
		||||
          (element) => element.strokeSharpness,
 | 
			
		||||
          (canChangeSharpness(appState.activeTool.type) &&
 | 
			
		||||
            (isLinearElementType(appState.activeTool.type)
 | 
			
		||||
          (canChangeSharpness(appState.elementType) &&
 | 
			
		||||
            (isLinearElementType(appState.elementType)
 | 
			
		||||
              ? appState.currentItemLinearStrokeSharpness
 | 
			
		||||
              : appState.currentItemStrokeSharpness)) ||
 | 
			
		||||
            null,
 | 
			
		||||
@@ -904,7 +920,6 @@ export const actionChangeSharpness = register({
 | 
			
		||||
 | 
			
		||||
export const actionChangeArrowhead = register({
 | 
			
		||||
  name: "changeArrowhead",
 | 
			
		||||
  trackEvent: false,
 | 
			
		||||
  perform: (
 | 
			
		||||
    elements,
 | 
			
		||||
    appState,
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,6 @@ import { getNonDeletedElements, isTextElement } from "../element";
 | 
			
		||||
 | 
			
		||||
export const actionSelectAll = register({
 | 
			
		||||
  name: "selectAll",
 | 
			
		||||
  trackEvent: { category: "canvas" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    if (appState.editingLinearElement) {
 | 
			
		||||
      return false;
 | 
			
		||||
@@ -18,8 +17,7 @@ export const actionSelectAll = register({
 | 
			
		||||
          selectedElementIds: elements.reduce((map, element) => {
 | 
			
		||||
            if (
 | 
			
		||||
              !element.isDeleted &&
 | 
			
		||||
              !(isTextElement(element) && element.containerId) &&
 | 
			
		||||
              element.locked === false
 | 
			
		||||
              !(isTextElement(element) && element.containerId)
 | 
			
		||||
            ) {
 | 
			
		||||
              map[element.id] = true;
 | 
			
		||||
            }
 | 
			
		||||
 
 | 
			
		||||
@@ -19,7 +19,6 @@ export let copiedStyles: string = "{}";
 | 
			
		||||
 | 
			
		||||
export const actionCopyStyles = register({
 | 
			
		||||
  name: "copyStyles",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    const element = elements.find((el) => appState.selectedElementIds[el.id]);
 | 
			
		||||
    if (element) {
 | 
			
		||||
@@ -40,7 +39,6 @@ export const actionCopyStyles = register({
 | 
			
		||||
 | 
			
		||||
export const actionPasteStyles = register({
 | 
			
		||||
  name: "pasteStyles",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    const pastedElement = JSON.parse(copiedStyles);
 | 
			
		||||
    if (!isExcalidrawElement(pastedElement)) {
 | 
			
		||||
@@ -65,7 +63,11 @@ export const actionPasteStyles = register({
 | 
			
		||||
              textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            redrawTextBoundingBox(newElement, getContainerElement(newElement));
 | 
			
		||||
            redrawTextBoundingBox(
 | 
			
		||||
              newElement,
 | 
			
		||||
              getContainerElement(newElement),
 | 
			
		||||
              appState,
 | 
			
		||||
            );
 | 
			
		||||
          }
 | 
			
		||||
          return newElement;
 | 
			
		||||
        }
 | 
			
		||||
 
 | 
			
		||||
@@ -2,14 +2,12 @@ import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { GRID_SIZE } from "../constants";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { trackEvent } from "../analytics";
 | 
			
		||||
 | 
			
		||||
export const actionToggleGridMode = register({
 | 
			
		||||
  name: "gridMode",
 | 
			
		||||
  trackEvent: {
 | 
			
		||||
    category: "canvas",
 | 
			
		||||
    predicate: (appState) => !appState.gridSize,
 | 
			
		||||
  },
 | 
			
		||||
  perform(elements, appState) {
 | 
			
		||||
    trackEvent("view", "mode", "grid");
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,63 +0,0 @@
 | 
			
		||||
import { newElementWith } from "../element/mutateElement";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { KEYS } from "../keys";
 | 
			
		||||
import { getSelectedElements } from "../scene";
 | 
			
		||||
import { arrayToMap } from "../utils";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
 | 
			
		||||
export const actionToggleLock = register({
 | 
			
		||||
  name: "toggleLock",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(elements, appState, true);
 | 
			
		||||
 | 
			
		||||
    if (!selectedElements.length) {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const operation = getOperation(selectedElements);
 | 
			
		||||
    const selectedElementsMap = arrayToMap(selectedElements);
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      elements: elements.map((element) => {
 | 
			
		||||
        if (!selectedElementsMap.has(element.id)) {
 | 
			
		||||
          return element;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return newElementWith(element, { locked: operation === "lock" });
 | 
			
		||||
      }),
 | 
			
		||||
      appState,
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  contextItemLabel: (elements, appState) => {
 | 
			
		||||
    const selected = getSelectedElements(elements, appState, false);
 | 
			
		||||
    if (selected.length === 1) {
 | 
			
		||||
      return selected[0].locked
 | 
			
		||||
        ? "labels.elementLock.unlock"
 | 
			
		||||
        : "labels.elementLock.lock";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (selected.length > 1) {
 | 
			
		||||
      return getOperation(selected) === "lock"
 | 
			
		||||
        ? "labels.elementLock.lockAll"
 | 
			
		||||
        : "labels.elementLock.unlockAll";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    throw new Error(
 | 
			
		||||
      "Unexpected zero elements to lock/unlock. This should never happen.",
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
  keyTest: (event, appState, elements) => {
 | 
			
		||||
    return (
 | 
			
		||||
      event.key.toLocaleLowerCase() === KEYS.L &&
 | 
			
		||||
      event[KEYS.CTRL_OR_CMD] &&
 | 
			
		||||
      event.shiftKey &&
 | 
			
		||||
      getSelectedElements(elements, appState, false).length > 0
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const getOperation = (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
): "lock" | "unlock" => (elements.some((el) => !el.locked) ? "lock" : "unlock");
 | 
			
		||||
@@ -3,7 +3,6 @@ import { CODES, KEYS } from "../keys";
 | 
			
		||||
 | 
			
		||||
export const actionToggleStats = register({
 | 
			
		||||
  name: "stats",
 | 
			
		||||
  trackEvent: { category: "menu" },
 | 
			
		||||
  perform(elements, appState) {
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,11 @@
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { trackEvent } from "../analytics";
 | 
			
		||||
 | 
			
		||||
export const actionToggleViewMode = register({
 | 
			
		||||
  name: "viewMode",
 | 
			
		||||
  trackEvent: {
 | 
			
		||||
    category: "canvas",
 | 
			
		||||
    predicate: (appState) => !appState.viewModeEnabled,
 | 
			
		||||
  },
 | 
			
		||||
  perform(elements, appState) {
 | 
			
		||||
    trackEvent("view", "mode", "view");
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,12 @@
 | 
			
		||||
import { CODES, KEYS } from "../keys";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
import { trackEvent } from "../analytics";
 | 
			
		||||
 | 
			
		||||
export const actionToggleZenMode = register({
 | 
			
		||||
  name: "zenMode",
 | 
			
		||||
  trackEvent: {
 | 
			
		||||
    category: "canvas",
 | 
			
		||||
    predicate: (appState) => !appState.zenModeEnabled,
 | 
			
		||||
  },
 | 
			
		||||
  perform(elements, appState) {
 | 
			
		||||
    trackEvent("view", "mode", "zen");
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										44
									
								
								src/actions/actionUnbindText.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								src/actions/actionUnbindText.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,44 @@
 | 
			
		||||
import { getNonDeletedElements } from "../element";
 | 
			
		||||
import { mutateElement } from "../element/mutateElement";
 | 
			
		||||
import { getBoundTextElement, measureText } from "../element/textElement";
 | 
			
		||||
import { ExcalidrawTextElement } from "../element/types";
 | 
			
		||||
import { getSelectedElements } from "../scene";
 | 
			
		||||
import { getFontString } from "../utils";
 | 
			
		||||
import { register } from "./register";
 | 
			
		||||
 | 
			
		||||
export const actionUnbindText = register({
 | 
			
		||||
  name: "unbindText",
 | 
			
		||||
  contextItemLabel: "labels.unbindText",
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    const selectedElements = getSelectedElements(
 | 
			
		||||
      getNonDeletedElements(elements),
 | 
			
		||||
      appState,
 | 
			
		||||
    );
 | 
			
		||||
    selectedElements.forEach((element) => {
 | 
			
		||||
      const boundTextElement = getBoundTextElement(element);
 | 
			
		||||
      if (boundTextElement) {
 | 
			
		||||
        const { width, height, baseline } = measureText(
 | 
			
		||||
          boundTextElement.originalText,
 | 
			
		||||
          getFontString(boundTextElement),
 | 
			
		||||
        );
 | 
			
		||||
        mutateElement(boundTextElement as ExcalidrawTextElement, {
 | 
			
		||||
          containerId: null,
 | 
			
		||||
          width,
 | 
			
		||||
          height,
 | 
			
		||||
          baseline,
 | 
			
		||||
          text: boundTextElement.originalText,
 | 
			
		||||
        });
 | 
			
		||||
        mutateElement(element, {
 | 
			
		||||
          boundElements: element.boundElements?.filter(
 | 
			
		||||
            (ele) => ele.id !== boundTextElement.id,
 | 
			
		||||
          ),
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    return {
 | 
			
		||||
      elements,
 | 
			
		||||
      appState,
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
@@ -18,7 +18,6 @@ import {
 | 
			
		||||
 | 
			
		||||
export const actionSendBackward = register({
 | 
			
		||||
  name: "sendBackward",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: moveOneLeft(elements, appState),
 | 
			
		||||
@@ -46,7 +45,6 @@ export const actionSendBackward = register({
 | 
			
		||||
 | 
			
		||||
export const actionBringForward = register({
 | 
			
		||||
  name: "bringForward",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: moveOneRight(elements, appState),
 | 
			
		||||
@@ -74,7 +72,6 @@ export const actionBringForward = register({
 | 
			
		||||
 | 
			
		||||
export const actionSendToBack = register({
 | 
			
		||||
  name: "sendToBack",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: moveAllLeft(elements, appState),
 | 
			
		||||
@@ -109,8 +106,6 @@ export const actionSendToBack = register({
 | 
			
		||||
 | 
			
		||||
export const actionBringToFront = register({
 | 
			
		||||
  name: "bringToFront",
 | 
			
		||||
  trackEvent: { category: "element" },
 | 
			
		||||
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    return {
 | 
			
		||||
      elements: moveAllRight(elements, appState),
 | 
			
		||||
 
 | 
			
		||||
@@ -75,13 +75,11 @@ export {
 | 
			
		||||
  actionCut,
 | 
			
		||||
  actionCopyAsPng,
 | 
			
		||||
  actionCopyAsSvg,
 | 
			
		||||
  copyText,
 | 
			
		||||
} from "./actionClipboard";
 | 
			
		||||
 | 
			
		||||
export { actionToggleGridMode } from "./actionToggleGridMode";
 | 
			
		||||
export { actionToggleZenMode } from "./actionToggleZenMode";
 | 
			
		||||
 | 
			
		||||
export { actionToggleStats } from "./actionToggleStats";
 | 
			
		||||
export { actionUnbindText, actionBindText } from "./actionBoundText";
 | 
			
		||||
export { actionUnbindText } from "./actionUnbindText";
 | 
			
		||||
export { actionLink } from "../element/Hyperlink";
 | 
			
		||||
export { actionToggleLock } from "./actionToggleLock";
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,11 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import {
 | 
			
		||||
  Action,
 | 
			
		||||
  ActionsManagerInterface,
 | 
			
		||||
  UpdaterFn,
 | 
			
		||||
  ActionName,
 | 
			
		||||
  ActionResult,
 | 
			
		||||
  PanelComponentProps,
 | 
			
		||||
  ActionSource,
 | 
			
		||||
} from "./types";
 | 
			
		||||
import { ExcalidrawElement } from "../element/types";
 | 
			
		||||
import { AppClassProperties, AppState } from "../types";
 | 
			
		||||
@@ -14,25 +14,21 @@ import { trackEvent } from "../analytics";
 | 
			
		||||
 | 
			
		||||
const trackAction = (
 | 
			
		||||
  action: Action,
 | 
			
		||||
  source: ActionSource,
 | 
			
		||||
  appState: Readonly<AppState>,
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
  app: AppClassProperties,
 | 
			
		||||
  source: "ui" | "keyboard" | "api",
 | 
			
		||||
  value: any,
 | 
			
		||||
) => {
 | 
			
		||||
  if (action.trackEvent) {
 | 
			
		||||
  if (action.trackEvent !== false) {
 | 
			
		||||
    try {
 | 
			
		||||
      if (typeof action.trackEvent === "object") {
 | 
			
		||||
        const shouldTrack = action.trackEvent.predicate
 | 
			
		||||
          ? action.trackEvent.predicate(appState, elements, value)
 | 
			
		||||
          : true;
 | 
			
		||||
        if (shouldTrack) {
 | 
			
		||||
          trackEvent(
 | 
			
		||||
            action.trackEvent.category,
 | 
			
		||||
            action.trackEvent.action || action.name,
 | 
			
		||||
            `${source} (${app.deviceType.isMobile ? "mobile" : "desktop"})`,
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
      if (action.trackEvent === true) {
 | 
			
		||||
        trackEvent(
 | 
			
		||||
          action.name,
 | 
			
		||||
          source,
 | 
			
		||||
          typeof value === "number" || typeof value === "string"
 | 
			
		||||
            ? String(value)
 | 
			
		||||
            : undefined,
 | 
			
		||||
        );
 | 
			
		||||
      } else {
 | 
			
		||||
        action.trackEvent?.(action, source, value);
 | 
			
		||||
      }
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error("error while logging action:", error);
 | 
			
		||||
@@ -40,8 +36,8 @@ const trackAction = (
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export class ActionManager {
 | 
			
		||||
  actions = {} as Record<ActionName, Action>;
 | 
			
		||||
export class ActionManager implements ActionsManagerInterface {
 | 
			
		||||
  actions = {} as ActionsManagerInterface["actions"];
 | 
			
		||||
 | 
			
		||||
  updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
 | 
			
		||||
 | 
			
		||||
@@ -110,26 +106,30 @@ export class ActionManager {
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const elements = this.getElementsIncludingDeleted();
 | 
			
		||||
    const appState = this.getAppState();
 | 
			
		||||
    const value = null;
 | 
			
		||||
 | 
			
		||||
    trackAction(action, "keyboard", appState, elements, this.app, null);
 | 
			
		||||
    trackAction(action, "keyboard", null);
 | 
			
		||||
 | 
			
		||||
    event.preventDefault();
 | 
			
		||||
    event.stopPropagation();
 | 
			
		||||
    this.updater(data[0].perform(elements, appState, value, this.app));
 | 
			
		||||
    this.updater(
 | 
			
		||||
      data[0].perform(
 | 
			
		||||
        this.getElementsIncludingDeleted(),
 | 
			
		||||
        this.getAppState(),
 | 
			
		||||
        null,
 | 
			
		||||
        this.app,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  executeAction(action: Action, source: ActionSource = "api") {
 | 
			
		||||
    const elements = this.getElementsIncludingDeleted();
 | 
			
		||||
    const appState = this.getAppState();
 | 
			
		||||
    const value = null;
 | 
			
		||||
 | 
			
		||||
    trackAction(action, source, appState, elements, this.app, value);
 | 
			
		||||
 | 
			
		||||
    this.updater(action.perform(elements, appState, value, this.app));
 | 
			
		||||
  executeAction(action: Action) {
 | 
			
		||||
    this.updater(
 | 
			
		||||
      action.perform(
 | 
			
		||||
        this.getElementsIncludingDeleted(),
 | 
			
		||||
        this.getAppState(),
 | 
			
		||||
        null,
 | 
			
		||||
        this.app,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
    trackAction(action, "api", null);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
@@ -147,11 +147,7 @@ export class ActionManager {
 | 
			
		||||
    ) {
 | 
			
		||||
      const action = this.actions[name];
 | 
			
		||||
      const PanelComponent = action.PanelComponent!;
 | 
			
		||||
      const elements = this.getElementsIncludingDeleted();
 | 
			
		||||
      const appState = this.getAppState();
 | 
			
		||||
      const updateData = (formState?: any) => {
 | 
			
		||||
        trackAction(action, "ui", appState, elements, this.app, formState);
 | 
			
		||||
 | 
			
		||||
        this.updater(
 | 
			
		||||
          action.perform(
 | 
			
		||||
            this.getElementsIncludingDeleted(),
 | 
			
		||||
@@ -160,6 +156,8 @@ export class ActionManager {
 | 
			
		||||
            this.app,
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        trackAction(action, "ui", formState);
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      return (
 | 
			
		||||
 
 | 
			
		||||
@@ -29,7 +29,6 @@ export type ShortcutName = SubtypeOf<
 | 
			
		||||
  | "flipHorizontal"
 | 
			
		||||
  | "flipVertical"
 | 
			
		||||
  | "hyperlink"
 | 
			
		||||
  | "toggleLock"
 | 
			
		||||
>;
 | 
			
		||||
 | 
			
		||||
const shortcutMap: Record<ShortcutName, string[]> = {
 | 
			
		||||
@@ -68,7 +67,6 @@ const shortcutMap: Record<ShortcutName, string[]> = {
 | 
			
		||||
  flipVertical: [getShortcutKey("Shift+V")],
 | 
			
		||||
  viewMode: [getShortcutKey("Alt+R")],
 | 
			
		||||
  hyperlink: [getShortcutKey("CtrlOrCmd+K")],
 | 
			
		||||
  toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getShortcutFromShortcutName = (name: ShortcutName) => {
 | 
			
		||||
 
 | 
			
		||||
@@ -8,8 +8,6 @@ import {
 | 
			
		||||
} from "../types";
 | 
			
		||||
import { ToolButtonSize } from "../components/ToolButton";
 | 
			
		||||
 | 
			
		||||
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
 | 
			
		||||
 | 
			
		||||
/** if false, the action should be prevented */
 | 
			
		||||
export type ActionResult =
 | 
			
		||||
  | {
 | 
			
		||||
@@ -41,7 +39,6 @@ export type ActionName =
 | 
			
		||||
  | "paste"
 | 
			
		||||
  | "copyAsPng"
 | 
			
		||||
  | "copyAsSvg"
 | 
			
		||||
  | "copyText"
 | 
			
		||||
  | "sendBackward"
 | 
			
		||||
  | "bringForward"
 | 
			
		||||
  | "sendToBack"
 | 
			
		||||
@@ -110,9 +107,7 @@ export type ActionName =
 | 
			
		||||
  | "decreaseFontSize"
 | 
			
		||||
  | "unbindText"
 | 
			
		||||
  | "hyperlink"
 | 
			
		||||
  | "eraser"
 | 
			
		||||
  | "bindText"
 | 
			
		||||
  | "toggleLock";
 | 
			
		||||
  | "eraser";
 | 
			
		||||
 | 
			
		||||
export type PanelComponentProps = {
 | 
			
		||||
  elements: readonly ExcalidrawElement[];
 | 
			
		||||
@@ -143,23 +138,15 @@ export interface Action {
 | 
			
		||||
    appState: AppState,
 | 
			
		||||
  ) => boolean;
 | 
			
		||||
  checked?: (appState: Readonly<AppState>) => boolean;
 | 
			
		||||
  trackEvent:
 | 
			
		||||
    | false
 | 
			
		||||
    | {
 | 
			
		||||
        category:
 | 
			
		||||
          | "toolbar"
 | 
			
		||||
          | "element"
 | 
			
		||||
          | "canvas"
 | 
			
		||||
          | "export"
 | 
			
		||||
          | "history"
 | 
			
		||||
          | "menu"
 | 
			
		||||
          | "collab"
 | 
			
		||||
          | "hyperlink";
 | 
			
		||||
        action?: string;
 | 
			
		||||
        predicate?: (
 | 
			
		||||
          appState: Readonly<AppState>,
 | 
			
		||||
          elements: readonly ExcalidrawElement[],
 | 
			
		||||
          value: any,
 | 
			
		||||
        ) => boolean;
 | 
			
		||||
      };
 | 
			
		||||
  trackEvent?:
 | 
			
		||||
    | boolean
 | 
			
		||||
    | ((action: Action, type: "ui" | "keyboard" | "api", value: any) => void);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface ActionsManagerInterface {
 | 
			
		||||
  actions: Record<ActionName, Action>;
 | 
			
		||||
  registerAction: (action: Action) => void;
 | 
			
		||||
  handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean;
 | 
			
		||||
  renderAction: (name: ActionName) => React.ReactElement | null;
 | 
			
		||||
  executeAction: (action: Action) => void;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -4,19 +4,15 @@ export const trackEvent =
 | 
			
		||||
  typeof window !== "undefined" &&
 | 
			
		||||
  window.gtag
 | 
			
		||||
    ? (category: string, action: string, label?: string, value?: number) => {
 | 
			
		||||
        try {
 | 
			
		||||
          window.gtag("event", action, {
 | 
			
		||||
            event_category: category,
 | 
			
		||||
            event_label: label,
 | 
			
		||||
            value,
 | 
			
		||||
          });
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
          console.error("error logging to ga", error);
 | 
			
		||||
        }
 | 
			
		||||
        window.gtag("event", action, {
 | 
			
		||||
          event_category: category,
 | 
			
		||||
          event_label: label,
 | 
			
		||||
          value,
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    : typeof process !== "undefined" && process.env?.JEST_WORKER_ID
 | 
			
		||||
    ? (category: string, action: string, label?: string, value?: number) => {}
 | 
			
		||||
    : (category: string, action: string, label?: string, value?: number) => {
 | 
			
		||||
        // Uncomment the next line to track locally
 | 
			
		||||
        // console.log("Track Event", { category, action, label, value });
 | 
			
		||||
        // console.info("Track Event", category, action, label, value);
 | 
			
		||||
      };
 | 
			
		||||
 
 | 
			
		||||
@@ -41,13 +41,9 @@ 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,
 | 
			
		||||
    exportBackground: true,
 | 
			
		||||
    exportScale: defaultExportScale,
 | 
			
		||||
@@ -133,9 +129,9 @@ const APP_STATE_STORAGE_CONF = (<
 | 
			
		||||
  editingElement: { browser: false, export: false, server: false },
 | 
			
		||||
  editingGroupId: { browser: true, export: false, server: false },
 | 
			
		||||
  editingLinearElement: { browser: false, export: false, server: false },
 | 
			
		||||
  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 },
 | 
			
		||||
  errorMessage: { browser: false, export: false, server: false },
 | 
			
		||||
  exportBackground: { browser: true, export: false, server: false },
 | 
			
		||||
  exportEmbedScene: { browser: true, export: false, server: false },
 | 
			
		||||
@@ -217,7 +213,7 @@ export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const isEraserActive = ({
 | 
			
		||||
  activeTool,
 | 
			
		||||
  elementType,
 | 
			
		||||
}: {
 | 
			
		||||
  activeTool: AppState["activeTool"];
 | 
			
		||||
}) => activeTool.type === "eraser";
 | 
			
		||||
  elementType: AppState["elementType"];
 | 
			
		||||
}) => elementType === "eraser";
 | 
			
		||||
 
 | 
			
		||||
@@ -167,7 +167,6 @@ const commonProps = {
 | 
			
		||||
  strokeStyle: "solid",
 | 
			
		||||
  strokeWidth: 1,
 | 
			
		||||
  verticalAlign: VERTICAL_ALIGN.MIDDLE,
 | 
			
		||||
  locked: false,
 | 
			
		||||
} as const;
 | 
			
		||||
 | 
			
		||||
const getChartDimentions = (spreadsheet: Spreadsheet) => {
 | 
			
		||||
 
 | 
			
		||||
@@ -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) => {
 | 
			
		||||
 
 | 
			
		||||
@@ -14,24 +14,23 @@ import {
 | 
			
		||||
  hasText,
 | 
			
		||||
} from "../scene";
 | 
			
		||||
import { SHAPES } from "../shapes";
 | 
			
		||||
import { AppState, Zoom } from "../types";
 | 
			
		||||
import { AppState, DeviceType, Zoom } from "../types";
 | 
			
		||||
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),
 | 
			
		||||
@@ -51,18 +50,15 @@ export const SelectedShapeActions = ({
 | 
			
		||||
  const isRTL = document.documentElement.getAttribute("dir") === "rtl";
 | 
			
		||||
 | 
			
		||||
  const showFillIcons =
 | 
			
		||||
    hasBackground(activeTool) ||
 | 
			
		||||
    hasBackground(elementType) ||
 | 
			
		||||
    targetElements.some(
 | 
			
		||||
      (element) =>
 | 
			
		||||
        hasBackground(element.type) && !isTransparent(element.backgroundColor),
 | 
			
		||||
    );
 | 
			
		||||
  const showChangeBackgroundIcons =
 | 
			
		||||
    hasBackground(activeTool) ||
 | 
			
		||||
    hasBackground(elementType) ||
 | 
			
		||||
    targetElements.some((element) => hasBackground(element.type));
 | 
			
		||||
 | 
			
		||||
  const showLinkIcon =
 | 
			
		||||
    targetElements.length === 1 || isSingleElementBoundContainer;
 | 
			
		||||
 | 
			
		||||
  let commonSelectedType: string | null = targetElements[0]?.type || null;
 | 
			
		||||
 | 
			
		||||
  for (const element of targetElements) {
 | 
			
		||||
@@ -74,23 +70,23 @@ export const SelectedShapeActions = ({
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="panelColumn">
 | 
			
		||||
      {((hasStrokeColor(activeTool) &&
 | 
			
		||||
        activeTool !== "image" &&
 | 
			
		||||
      {((hasStrokeColor(elementType) &&
 | 
			
		||||
        elementType !== "image" &&
 | 
			
		||||
        commonSelectedType !== "image") ||
 | 
			
		||||
        targetElements.some((element) => hasStrokeColor(element.type))) &&
 | 
			
		||||
        renderAction("changeStrokeColor")}
 | 
			
		||||
      {showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
 | 
			
		||||
      {showFillIcons && renderAction("changeFillStyle")}
 | 
			
		||||
 | 
			
		||||
      {(hasStrokeWidth(activeTool) ||
 | 
			
		||||
      {(hasStrokeWidth(elementType) ||
 | 
			
		||||
        targetElements.some((element) => hasStrokeWidth(element.type))) &&
 | 
			
		||||
        renderAction("changeStrokeWidth")}
 | 
			
		||||
 | 
			
		||||
      {(activeTool === "freedraw" ||
 | 
			
		||||
      {(elementType === "freedraw" ||
 | 
			
		||||
        targetElements.some((element) => element.type === "freedraw")) &&
 | 
			
		||||
        renderAction("changeStrokeShape")}
 | 
			
		||||
 | 
			
		||||
      {(hasStrokeStyle(activeTool) ||
 | 
			
		||||
      {(hasStrokeStyle(elementType) ||
 | 
			
		||||
        targetElements.some((element) => hasStrokeStyle(element.type))) && (
 | 
			
		||||
        <>
 | 
			
		||||
          {renderAction("changeStrokeStyle")}
 | 
			
		||||
@@ -98,12 +94,12 @@ export const SelectedShapeActions = ({
 | 
			
		||||
        </>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {(canChangeSharpness(activeTool) ||
 | 
			
		||||
      {(canChangeSharpness(elementType) ||
 | 
			
		||||
        targetElements.some((element) => canChangeSharpness(element.type))) && (
 | 
			
		||||
        <>{renderAction("changeSharpness")}</>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {(hasText(activeTool) ||
 | 
			
		||||
      {(hasText(elementType) ||
 | 
			
		||||
        targetElements.some((element) => hasText(element.type))) && (
 | 
			
		||||
        <>
 | 
			
		||||
          {renderAction("changeFontSize")}
 | 
			
		||||
@@ -118,7 +114,7 @@ export const SelectedShapeActions = ({
 | 
			
		||||
        (element) =>
 | 
			
		||||
          hasBoundTextElement(element) || isBoundToContainer(element),
 | 
			
		||||
      ) && renderAction("changeVerticalAlign")}
 | 
			
		||||
      {(canHaveArrowheads(activeTool) ||
 | 
			
		||||
      {(canHaveArrowheads(elementType) ||
 | 
			
		||||
        targetElements.some((element) => canHaveArrowheads(element.type))) && (
 | 
			
		||||
        <>{renderAction("changeArrowhead")}</>
 | 
			
		||||
      )}
 | 
			
		||||
@@ -176,7 +172,7 @@ export const SelectedShapeActions = ({
 | 
			
		||||
            {!deviceType.isMobile && renderAction("deleteSelectedElements")}
 | 
			
		||||
            {renderAction("group")}
 | 
			
		||||
            {renderAction("ungroup")}
 | 
			
		||||
            {showLinkIcon && renderAction("hyperlink")}
 | 
			
		||||
            {targetElements.length === 1 && renderAction("hyperlink")}
 | 
			
		||||
          </div>
 | 
			
		||||
        </fieldset>
 | 
			
		||||
      )}
 | 
			
		||||
@@ -186,68 +182,64 @@ export const SelectedShapeActions = ({
 | 
			
		||||
 | 
			
		||||
export const ShapesSwitcher = ({
 | 
			
		||||
  canvas,
 | 
			
		||||
  activeTool,
 | 
			
		||||
  elementType,
 | 
			
		||||
  setAppState,
 | 
			
		||||
  onImageAction,
 | 
			
		||||
  appState,
 | 
			
		||||
  setDeviceType,
 | 
			
		||||
}: {
 | 
			
		||||
  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) => {
 | 
			
		||||
      const label = t(`toolBar.${value}`);
 | 
			
		||||
      const letter = key && (typeof key === "string" ? key : key[0]);
 | 
			
		||||
      const shortcut = letter
 | 
			
		||||
        ? `${capitalizeString(letter)} ${t("helpDialog.or")} ${index + 1}`
 | 
			
		||||
        : `${index + 1}`;
 | 
			
		||||
      return (
 | 
			
		||||
        <ToolButton
 | 
			
		||||
          className="Shape"
 | 
			
		||||
          key={value}
 | 
			
		||||
          type="radio"
 | 
			
		||||
          icon={icon}
 | 
			
		||||
          checked={activeTool.type === value}
 | 
			
		||||
          name="editor-current-shape"
 | 
			
		||||
          title={`${capitalizeString(label)} — ${shortcut}`}
 | 
			
		||||
          keyBindingLabel={`${index + 1}`}
 | 
			
		||||
          aria-label={capitalizeString(label)}
 | 
			
		||||
          aria-keyshortcuts={shortcut}
 | 
			
		||||
          data-testid={value}
 | 
			
		||||
          onPointerDown={({ pointerType }) => {
 | 
			
		||||
            if (!appState.penDetected && pointerType === "pen") {
 | 
			
		||||
  setDeviceType: (obj: Partial<DeviceType>) => void;
 | 
			
		||||
}) => {
 | 
			
		||||
  const penDetected = useDeviceType().penDetected;
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      {SHAPES.map(({ value, icon, key }, index) => {
 | 
			
		||||
        const label = t(`toolBar.${value}`);
 | 
			
		||||
        const letter = key && (typeof key === "string" ? key : key[0]);
 | 
			
		||||
        const shortcut = letter
 | 
			
		||||
          ? `${capitalizeString(letter)} ${t("helpDialog.or")} ${index + 1}`
 | 
			
		||||
          : `${index + 1}`;
 | 
			
		||||
        return (
 | 
			
		||||
          <ToolButton
 | 
			
		||||
            className="Shape"
 | 
			
		||||
            key={value}
 | 
			
		||||
            type="radio"
 | 
			
		||||
            icon={icon}
 | 
			
		||||
            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 (!penDetected && pointerType === "pen") {
 | 
			
		||||
                setAppState({ penMode: true });
 | 
			
		||||
                setDeviceType({ penDetected: true });
 | 
			
		||||
              }
 | 
			
		||||
            }}
 | 
			
		||||
            onChange={({ pointerType }) => {
 | 
			
		||||
              setAppState({
 | 
			
		||||
                penDetected: true,
 | 
			
		||||
                penMode: true,
 | 
			
		||||
                elementType: value,
 | 
			
		||||
                multiElement: null,
 | 
			
		||||
                selectedElementIds: {},
 | 
			
		||||
              });
 | 
			
		||||
            }
 | 
			
		||||
          }}
 | 
			
		||||
          onChange={({ pointerType }) => {
 | 
			
		||||
            if (appState.activeTool.type !== value) {
 | 
			
		||||
              trackEvent("toolbar", value, "ui");
 | 
			
		||||
            }
 | 
			
		||||
            const nextActiveTool = { ...activeTool, type: value };
 | 
			
		||||
            setAppState({
 | 
			
		||||
              activeTool: nextActiveTool,
 | 
			
		||||
              multiElement: null,
 | 
			
		||||
              selectedElementIds: {},
 | 
			
		||||
            });
 | 
			
		||||
            setCursorForShape(canvas, {
 | 
			
		||||
              ...appState,
 | 
			
		||||
              activeTool: nextActiveTool,
 | 
			
		||||
            });
 | 
			
		||||
            if (value === "image") {
 | 
			
		||||
              onImageAction({ pointerType });
 | 
			
		||||
            }
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      );
 | 
			
		||||
    })}
 | 
			
		||||
  </>
 | 
			
		||||
);
 | 
			
		||||
              setCursorForShape(canvas, { ...appState, elementType: value });
 | 
			
		||||
              if (value === "image") {
 | 
			
		||||
                onImageAction({ pointerType });
 | 
			
		||||
              }
 | 
			
		||||
            }}
 | 
			
		||||
          />
 | 
			
		||||
        );
 | 
			
		||||
      })}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const ZoomActions = ({
 | 
			
		||||
  renderAction,
 | 
			
		||||
 
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -12,11 +12,5 @@
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
    font-size: 0.8rem;
 | 
			
		||||
    font-weight: 500;
 | 
			
		||||
 | 
			
		||||
    &-img {
 | 
			
		||||
      width: 100%;
 | 
			
		||||
      height: 100%;
 | 
			
		||||
      border-radius: 100%;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
);
 | 
			
		||||
 
 | 
			
		||||
@@ -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">
 | 
			
		||||
 
 | 
			
		||||
@@ -363,10 +363,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")]}
 | 
			
		||||
 
 | 
			
		||||
@@ -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 &&
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
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";
 | 
			
		||||
@@ -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;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,5 @@
 | 
			
		||||
import React, { useState } from "react";
 | 
			
		||||
import { ActionsManagerInterface } from "../actions/types";
 | 
			
		||||
import { NonDeletedExcalidrawElement } from "../element/types";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { useDeviceType } from "./App";
 | 
			
		||||
@@ -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;
 | 
			
		||||
}) => {
 | 
			
		||||
 
 | 
			
		||||
@@ -8,7 +8,13 @@ import { NonDeletedExcalidrawElement } from "../element/types";
 | 
			
		||||
import { Language, t } from "../i18n";
 | 
			
		||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
 | 
			
		||||
import { ExportType } from "../scene/types";
 | 
			
		||||
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
 | 
			
		||||
import {
 | 
			
		||||
  AppProps,
 | 
			
		||||
  AppState,
 | 
			
		||||
  ExcalidrawProps,
 | 
			
		||||
  BinaryFiles,
 | 
			
		||||
  DeviceType,
 | 
			
		||||
} from "../types";
 | 
			
		||||
import { muteFSAbortError } from "../utils";
 | 
			
		||||
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
 | 
			
		||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
 | 
			
		||||
@@ -36,7 +42,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 {
 | 
			
		||||
@@ -45,6 +50,7 @@ interface LayerUIProps {
 | 
			
		||||
  files: BinaryFiles;
 | 
			
		||||
  canvas: HTMLCanvasElement | null;
 | 
			
		||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
			
		||||
  setDeviceType: (obj: Partial<DeviceType>) => void;
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
  onCollabButtonClick?: () => void;
 | 
			
		||||
  onLockToggle: () => void;
 | 
			
		||||
@@ -75,6 +81,7 @@ const LayerUI = ({
 | 
			
		||||
  appState,
 | 
			
		||||
  files,
 | 
			
		||||
  setAppState,
 | 
			
		||||
  setDeviceType,
 | 
			
		||||
  canvas,
 | 
			
		||||
  elements,
 | 
			
		||||
  onCollabButtonClick,
 | 
			
		||||
@@ -123,7 +130,6 @@ const LayerUI = ({
 | 
			
		||||
    const createExporter =
 | 
			
		||||
      (type: ExportType): ExportCB =>
 | 
			
		||||
      async (exportedElements) => {
 | 
			
		||||
        trackEvent("export", type, "ui");
 | 
			
		||||
        const fileHandle = await exportCanvas(
 | 
			
		||||
          type,
 | 
			
		||||
          exportedElements,
 | 
			
		||||
@@ -250,7 +256,7 @@ const LayerUI = ({
 | 
			
		||||
          appState={appState}
 | 
			
		||||
          elements={elements}
 | 
			
		||||
          renderAction={actionManager.renderAction}
 | 
			
		||||
          activeTool={appState.activeTool.type}
 | 
			
		||||
          elementType={appState.elementType}
 | 
			
		||||
        />
 | 
			
		||||
      </Island>
 | 
			
		||||
    </Section>
 | 
			
		||||
@@ -323,12 +329,12 @@ const LayerUI = ({
 | 
			
		||||
                      checked={appState.penMode}
 | 
			
		||||
                      onChange={onPenModeToggle}
 | 
			
		||||
                      title={t("toolBar.penMode")}
 | 
			
		||||
                      penDetected={appState.penDetected}
 | 
			
		||||
                      penDetected={deviceType.penDetected}
 | 
			
		||||
                    />
 | 
			
		||||
                    <LockButton
 | 
			
		||||
                      zenModeEnabled={zenModeEnabled}
 | 
			
		||||
                      checked={appState.activeTool.locked}
 | 
			
		||||
                      onChange={() => onLockToggle()}
 | 
			
		||||
                      checked={appState.elementLocked}
 | 
			
		||||
                      onChange={onLockToggle}
 | 
			
		||||
                      title={t("toolBar.lock")}
 | 
			
		||||
                    />
 | 
			
		||||
                    <Island
 | 
			
		||||
@@ -347,13 +353,14 @@ const LayerUI = ({
 | 
			
		||||
                        <ShapesSwitcher
 | 
			
		||||
                          appState={appState}
 | 
			
		||||
                          canvas={canvas}
 | 
			
		||||
                          activeTool={appState.activeTool}
 | 
			
		||||
                          elementType={appState.elementType}
 | 
			
		||||
                          setAppState={setAppState}
 | 
			
		||||
                          onImageAction={({ pointerType }) => {
 | 
			
		||||
                            onImageAction({
 | 
			
		||||
                              insertOnCanvasDirectly: pointerType !== "mouse",
 | 
			
		||||
                            });
 | 
			
		||||
                          }}
 | 
			
		||||
                          setDeviceType={setDeviceType}
 | 
			
		||||
                        />
 | 
			
		||||
                      </Stack.Row>
 | 
			
		||||
                    </Island>
 | 
			
		||||
@@ -492,7 +499,7 @@ const LayerUI = ({
 | 
			
		||||
 | 
			
		||||
  const dialogs = (
 | 
			
		||||
    <>
 | 
			
		||||
      {appState.isLoading && <LoadingMessage delay={250} />}
 | 
			
		||||
      {appState.isLoading && <LoadingMessage />}
 | 
			
		||||
      {appState.errorMessage && (
 | 
			
		||||
        <ErrorDialog
 | 
			
		||||
          message={appState.errorMessage}
 | 
			
		||||
@@ -532,8 +539,9 @@ const LayerUI = ({
 | 
			
		||||
        renderJSONExportDialog={renderJSONExportDialog}
 | 
			
		||||
        renderImageExportDialog={renderImageExportDialog}
 | 
			
		||||
        setAppState={setAppState}
 | 
			
		||||
        setDeviceType={setDeviceType}
 | 
			
		||||
        onCollabButtonClick={onCollabButtonClick}
 | 
			
		||||
        onLockToggle={() => onLockToggle()}
 | 
			
		||||
        onLockToggle={onLockToggle}
 | 
			
		||||
        onPenModeToggle={onPenModeToggle}
 | 
			
		||||
        canvas={canvas}
 | 
			
		||||
        isCollaborating={isCollaborating}
 | 
			
		||||
 
 | 
			
		||||
@@ -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 {
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
@@ -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"
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { AppState, DeviceType } from "../types";
 | 
			
		||||
import { ActionManager } from "../actions/manager";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import Stack from "./Stack";
 | 
			
		||||
@@ -18,6 +18,7 @@ import { UserList } from "./UserList";
 | 
			
		||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
 | 
			
		||||
import { LibraryButton } from "./LibraryButton";
 | 
			
		||||
import { PenModeButton } from "./PenModeButton";
 | 
			
		||||
import { useDeviceType } from "./App";
 | 
			
		||||
 | 
			
		||||
type MobileMenuProps = {
 | 
			
		||||
  appState: AppState;
 | 
			
		||||
@@ -25,6 +26,7 @@ type MobileMenuProps = {
 | 
			
		||||
  renderJSONExportDialog: () => React.ReactNode;
 | 
			
		||||
  renderImageExportDialog: () => React.ReactNode;
 | 
			
		||||
  setAppState: React.Component<any, AppState>["setState"];
 | 
			
		||||
  setDeviceType: (obj: Partial<DeviceType>) => void;
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[];
 | 
			
		||||
  libraryMenu: JSX.Element | null;
 | 
			
		||||
  onCollabButtonClick?: () => void;
 | 
			
		||||
@@ -50,6 +52,7 @@ export const MobileMenu = ({
 | 
			
		||||
  renderJSONExportDialog,
 | 
			
		||||
  renderImageExportDialog,
 | 
			
		||||
  setAppState,
 | 
			
		||||
  setDeviceType,
 | 
			
		||||
  onCollabButtonClick,
 | 
			
		||||
  onLockToggle,
 | 
			
		||||
  onPenModeToggle,
 | 
			
		||||
@@ -61,6 +64,7 @@ export const MobileMenu = ({
 | 
			
		||||
  onImageAction,
 | 
			
		||||
  renderTopRightUI,
 | 
			
		||||
}: MobileMenuProps) => {
 | 
			
		||||
  const deviceType = useDeviceType();
 | 
			
		||||
  const renderToolbar = () => {
 | 
			
		||||
    return (
 | 
			
		||||
      <FixedSideContainer side="top" className="App-top-bar">
 | 
			
		||||
@@ -74,19 +78,20 @@ export const MobileMenu = ({
 | 
			
		||||
                    <ShapesSwitcher
 | 
			
		||||
                      appState={appState}
 | 
			
		||||
                      canvas={canvas}
 | 
			
		||||
                      activeTool={appState.activeTool}
 | 
			
		||||
                      elementType={appState.elementType}
 | 
			
		||||
                      setAppState={setAppState}
 | 
			
		||||
                      onImageAction={({ pointerType }) => {
 | 
			
		||||
                        onImageAction({
 | 
			
		||||
                          insertOnCanvasDirectly: pointerType !== "mouse",
 | 
			
		||||
                        });
 | 
			
		||||
                      }}
 | 
			
		||||
                      setDeviceType={setDeviceType}
 | 
			
		||||
                    />
 | 
			
		||||
                  </Stack.Row>
 | 
			
		||||
                </Island>
 | 
			
		||||
                {renderTopRightUI && renderTopRightUI(true, appState)}
 | 
			
		||||
                <LockButton
 | 
			
		||||
                  checked={appState.activeTool.locked}
 | 
			
		||||
                  checked={appState.elementLocked}
 | 
			
		||||
                  onChange={onLockToggle}
 | 
			
		||||
                  title={t("toolBar.lock")}
 | 
			
		||||
                  isMobile
 | 
			
		||||
@@ -101,7 +106,7 @@ export const MobileMenu = ({
 | 
			
		||||
                  onChange={onPenModeToggle}
 | 
			
		||||
                  title={t("toolBar.penMode")}
 | 
			
		||||
                  isMobile
 | 
			
		||||
                  penDetected={appState.penDetected}
 | 
			
		||||
                  penDetected={deviceType.penDetected}
 | 
			
		||||
                />
 | 
			
		||||
              </Stack.Row>
 | 
			
		||||
              {libraryMenu}
 | 
			
		||||
@@ -226,7 +231,7 @@ export const MobileMenu = ({
 | 
			
		||||
                appState={appState}
 | 
			
		||||
                elements={elements}
 | 
			
		||||
                renderAction={actionManager.renderAction}
 | 
			
		||||
                activeTool={appState.activeTool.type}
 | 
			
		||||
                elementType={appState.elementType}
 | 
			
		||||
              />
 | 
			
		||||
            </Section>
 | 
			
		||||
          ) : null}
 | 
			
		||||
 
 | 
			
		||||
@@ -155,7 +155,7 @@
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      width: 2rem;
 | 
			
		||||
      height: 2rem;
 | 
			
		||||
      height: 2em;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
@@ -191,5 +188,3 @@ export const VERTICAL_ALIGN = {
 | 
			
		||||
  MIDDLE: "middle",
 | 
			
		||||
  BOTTOM: "bottom",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
 | 
			
		||||
 
 | 
			
		||||
@@ -16,17 +16,15 @@
 | 
			
		||||
  left: 0;
 | 
			
		||||
  z-index: 999;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
  pointer-events: none;
 | 
			
		||||
 | 
			
		||||
  .Spinner {
 | 
			
		||||
    font-size: 2.8em;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .LoadingMessage-text {
 | 
			
		||||
    margin-top: 1em;
 | 
			
		||||
    font-size: 0.8em;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.LoadingMessage span {
 | 
			
		||||
  background-color: var(--button-gray-1);
 | 
			
		||||
  border-radius: 5px;
 | 
			
		||||
  padding: 0.8em 1.2em;
 | 
			
		||||
  color: var(--popup-text-color);
 | 
			
		||||
  font-size: 1.3em;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,16 +1,20 @@
 | 
			
		||||
import { nanoid } from "nanoid";
 | 
			
		||||
import { cleanAppStateForExport } from "../appState";
 | 
			
		||||
import { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
 | 
			
		||||
import {
 | 
			
		||||
  ALLOWED_IMAGE_MIME_TYPES,
 | 
			
		||||
  EXPORT_DATA_TYPES,
 | 
			
		||||
  MIME_TYPES,
 | 
			
		||||
} from "../constants";
 | 
			
		||||
import { clearElementsForExport } from "../element";
 | 
			
		||||
import { ExcalidrawElement, FileId } from "../element/types";
 | 
			
		||||
import { CanvasError } from "../errors";
 | 
			
		||||
import { t } from "../i18n";
 | 
			
		||||
import { calculateScrollCenter } from "../scene";
 | 
			
		||||
import { AppState, DataURL, LibraryItem } from "../types";
 | 
			
		||||
import { AppState, DataURL } from "../types";
 | 
			
		||||
import { bytesToHexString } from "../utils";
 | 
			
		||||
import { FileSystemHandle } from "./filesystem";
 | 
			
		||||
import { isValidExcalidrawData, isValidLibrary } from "./json";
 | 
			
		||||
import { restore, restoreLibraryItems } from "./restore";
 | 
			
		||||
import { isValidExcalidrawData } from "./json";
 | 
			
		||||
import { restore } from "./restore";
 | 
			
		||||
import { ImportedLibraryData } from "./types";
 | 
			
		||||
 | 
			
		||||
const parseFileContents = async (blob: Blob | File) => {
 | 
			
		||||
@@ -159,17 +163,13 @@ export const loadFromBlob = async (
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const loadLibraryFromBlob = async (
 | 
			
		||||
  blob: Blob,
 | 
			
		||||
  defaultStatus: LibraryItem["status"] = "unpublished",
 | 
			
		||||
) => {
 | 
			
		||||
export const loadLibraryFromBlob = async (blob: Blob) => {
 | 
			
		||||
  const contents = await parseFileContents(blob);
 | 
			
		||||
  const data: ImportedLibraryData | undefined = JSON.parse(contents);
 | 
			
		||||
  if (!isValidLibrary(data)) {
 | 
			
		||||
    throw new Error("Invalid library");
 | 
			
		||||
  const data: ImportedLibraryData = JSON.parse(contents);
 | 
			
		||||
  if (data.type !== EXPORT_DATA_TYPES.excalidrawLibrary) {
 | 
			
		||||
    throw new Error(t("alerts.couldNotLoadInvalidFile"));
 | 
			
		||||
  }
 | 
			
		||||
  const libraryItems = data.libraryItems || data.library;
 | 
			
		||||
  return restoreLibraryItems(libraryItems, defaultStatus);
 | 
			
		||||
  return data;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const canvasToBlob = async (
 | 
			
		||||
 
 | 
			
		||||
@@ -13,9 +13,7 @@ type FILE_EXTENSION =
 | 
			
		||||
  | "gif"
 | 
			
		||||
  | "jpg"
 | 
			
		||||
  | "png"
 | 
			
		||||
  | "excalidraw.png"
 | 
			
		||||
  | "svg"
 | 
			
		||||
  | "excalidraw.svg"
 | 
			
		||||
  | "json"
 | 
			
		||||
  | "excalidraw"
 | 
			
		||||
  | "excalidrawlib";
 | 
			
		||||
 
 | 
			
		||||
@@ -105,9 +105,7 @@ export const encodeSvgMetadata = async ({ text }: { text: string }) => {
 | 
			
		||||
 | 
			
		||||
export const decodeSvgMetadata = async ({ svg }: { svg: string }) => {
 | 
			
		||||
  if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) {
 | 
			
		||||
    const match = svg.match(
 | 
			
		||||
      /<!-- payload-start -->\s*(.+?)\s*<!-- payload-end -->/,
 | 
			
		||||
    );
 | 
			
		||||
    const match = svg.match(/<!-- payload-start -->(.+?)<!-- payload-end -->/);
 | 
			
		||||
    if (!match) {
 | 
			
		||||
      throw new Error("INVALID");
 | 
			
		||||
    }
 | 
			
		||||
 
 | 
			
		||||
@@ -16,7 +16,7 @@ export { loadFromBlob } from "./blob";
 | 
			
		||||
export { loadFromJSON, saveAsJSON } from "./json";
 | 
			
		||||
 | 
			
		||||
export const exportCanvas = async (
 | 
			
		||||
  type: Omit<ExportType, "backend">,
 | 
			
		||||
  type: ExportType,
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[],
 | 
			
		||||
  appState: AppState,
 | 
			
		||||
  files: BinaryFiles,
 | 
			
		||||
@@ -56,7 +56,7 @@ export const exportCanvas = async (
 | 
			
		||||
        {
 | 
			
		||||
          description: "Export to SVG",
 | 
			
		||||
          name,
 | 
			
		||||
          extension: appState.exportEmbedScene ? "excalidraw.svg" : "svg",
 | 
			
		||||
          extension: "svg",
 | 
			
		||||
          fileHandle,
 | 
			
		||||
        },
 | 
			
		||||
      );
 | 
			
		||||
@@ -73,10 +73,10 @@ export const exportCanvas = async (
 | 
			
		||||
  });
 | 
			
		||||
  tempCanvas.style.display = "none";
 | 
			
		||||
  document.body.appendChild(tempCanvas);
 | 
			
		||||
  let blob = await canvasToBlob(tempCanvas);
 | 
			
		||||
  tempCanvas.remove();
 | 
			
		||||
 | 
			
		||||
  if (type === "png") {
 | 
			
		||||
    let blob = await canvasToBlob(tempCanvas);
 | 
			
		||||
    tempCanvas.remove();
 | 
			
		||||
    if (appState.exportEmbedScene) {
 | 
			
		||||
      blob = await (
 | 
			
		||||
        await import(/* webpackChunkName: "image" */ "./image")
 | 
			
		||||
@@ -89,24 +89,17 @@ export const exportCanvas = async (
 | 
			
		||||
    return await fileSave(blob, {
 | 
			
		||||
      description: "Export to PNG",
 | 
			
		||||
      name,
 | 
			
		||||
      extension: appState.exportEmbedScene ? "excalidraw.png" : "png",
 | 
			
		||||
      extension: "png",
 | 
			
		||||
      fileHandle,
 | 
			
		||||
    });
 | 
			
		||||
  } else if (type === "clipboard") {
 | 
			
		||||
    try {
 | 
			
		||||
      const blob = canvasToBlob(tempCanvas);
 | 
			
		||||
      await copyBlobToClipboardAsPng(blob);
 | 
			
		||||
    } catch (error: any) {
 | 
			
		||||
      if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
 | 
			
		||||
        throw error;
 | 
			
		||||
      }
 | 
			
		||||
      throw new Error(t("alerts.couldNotCopyToClipboard"));
 | 
			
		||||
    } finally {
 | 
			
		||||
      tempCanvas.remove();
 | 
			
		||||
    }
 | 
			
		||||
  } else {
 | 
			
		||||
    tempCanvas.remove();
 | 
			
		||||
    // shouldn't happen
 | 
			
		||||
    throw new Error("Unsupported export type");
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -15,7 +15,6 @@ import {
 | 
			
		||||
  ExportedDataState,
 | 
			
		||||
  ImportedDataState,
 | 
			
		||||
  ExportedLibraryData,
 | 
			
		||||
  ImportedLibraryData,
 | 
			
		||||
} from "./types";
 | 
			
		||||
import Library from "./library";
 | 
			
		||||
 | 
			
		||||
@@ -115,7 +114,7 @@ export const isValidExcalidrawData = (data?: {
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const isValidLibrary = (json: any): json is ImportedLibraryData => {
 | 
			
		||||
export const isValidLibrary = (json: any) => {
 | 
			
		||||
  return (
 | 
			
		||||
    typeof json === "object" &&
 | 
			
		||||
    json &&
 | 
			
		||||
@@ -124,18 +123,14 @@ export const isValidLibrary = (json: any): json is ImportedLibraryData => {
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const serializeLibraryAsJSON = (libraryItems: LibraryItems) => {
 | 
			
		||||
export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
 | 
			
		||||
  const data: ExportedLibraryData = {
 | 
			
		||||
    type: EXPORT_DATA_TYPES.excalidrawLibrary,
 | 
			
		||||
    version: VERSIONS.excalidrawLibrary,
 | 
			
		||||
    source: EXPORT_SOURCE,
 | 
			
		||||
    libraryItems,
 | 
			
		||||
  };
 | 
			
		||||
  return JSON.stringify(data, null, 2);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
 | 
			
		||||
  const serialized = serializeLibraryAsJSON(libraryItems);
 | 
			
		||||
  const serialized = JSON.stringify(data, null, 2);
 | 
			
		||||
  await fileSave(
 | 
			
		||||
    new Blob([serialized], {
 | 
			
		||||
      type: MIME_TYPES.excalidrawlib,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,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;
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
@@ -111,7 +107,6 @@ const restoreElementWithProperties = <
 | 
			
		||||
      : element.boundElements ?? [],
 | 
			
		||||
    updated: element.updated ?? getUpdatedTimestamp(),
 | 
			
		||||
    link: element.link ?? null,
 | 
			
		||||
    locked: element.locked ?? false,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
@@ -240,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,
 | 
			
		||||
@@ -255,20 +252,12 @@ export const restoreAppState = (
 | 
			
		||||
        ? localValue
 | 
			
		||||
        : defaultValue;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    ...nextAppState,
 | 
			
		||||
    cursorButton: localAppState?.cursorButton || "up",
 | 
			
		||||
    // reset on fresh restore so as to hide the UI button if penMode not active
 | 
			
		||||
    penDetected:
 | 
			
		||||
      localAppState?.penDetected ??
 | 
			
		||||
      (appState.penMode ? appState.penDetected ?? false : false),
 | 
			
		||||
    activeTool: {
 | 
			
		||||
      lastActiveToolBeforeEraser: null,
 | 
			
		||||
      locked: nextAppState.activeTool.locked ?? false,
 | 
			
		||||
      type: AllowedExcalidrawActiveTools[nextAppState.activeTool.type]
 | 
			
		||||
        ? nextAppState.activeTool.type ?? "selection"
 | 
			
		||||
        : "selection",
 | 
			
		||||
    },
 | 
			
		||||
    elementType: AllowedExcalidrawElementTypes[nextAppState.elementType]
 | 
			
		||||
      ? nextAppState.elementType
 | 
			
		||||
      : "selection",
 | 
			
		||||
    // Migrates from previous version where appState.zoom was a number
 | 
			
		||||
    zoom:
 | 
			
		||||
      typeof appState.zoom === "number"
 | 
			
		||||
@@ -280,7 +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
 | 
			
		||||
@@ -297,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;
 | 
			
		||||
 
 | 
			
		||||
@@ -262,7 +262,9 @@ export const actionLink = register({
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  trackEvent: { category: "hyperlink", action: "click" },
 | 
			
		||||
  trackEvent: (action, source) => {
 | 
			
		||||
    trackEvent("hyperlink", "edit", source);
 | 
			
		||||
  },
 | 
			
		||||
  keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K,
 | 
			
		||||
  contextItemLabel: (elements, appState) =>
 | 
			
		||||
    getContextMenuLabel(elements, appState),
 | 
			
		||||
@@ -335,9 +337,6 @@ export const isPointHittingLinkIcon = (
 | 
			
		||||
  [x, y]: Point,
 | 
			
		||||
  isMobile: boolean,
 | 
			
		||||
) => {
 | 
			
		||||
  if (!element.link || appState.selectedElementIds[element.id]) {
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
  const threshold = 4 / appState.zoom.value;
 | 
			
		||||
  if (
 | 
			
		||||
    !isMobile &&
 | 
			
		||||
 
 | 
			
		||||
@@ -255,8 +255,7 @@ export const getHoveredElementForBinding = (
 | 
			
		||||
  const hoveredElement = getElementAtPosition(
 | 
			
		||||
    scene.getElements(),
 | 
			
		||||
    (element) =>
 | 
			
		||||
      isBindableElement(element, false) &&
 | 
			
		||||
      bindingBorderTest(element, pointerCoords),
 | 
			
		||||
      isBindableElement(element) && bindingBorderTest(element, pointerCoords),
 | 
			
		||||
  );
 | 
			
		||||
  return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
 | 
			
		||||
};
 | 
			
		||||
@@ -457,13 +456,13 @@ export const getEligibleElementsForBinding = (
 | 
			
		||||
): SuggestedBinding[] => {
 | 
			
		||||
  const includedElementIds = new Set(elements.map(({ id }) => id));
 | 
			
		||||
  return elements.flatMap((element) =>
 | 
			
		||||
    isBindingElement(element, false)
 | 
			
		||||
    isBindingElement(element)
 | 
			
		||||
      ? (getElligibleElementsForBindingElement(
 | 
			
		||||
          element as NonDeleted<ExcalidrawLinearElement>,
 | 
			
		||||
        ).filter(
 | 
			
		||||
          (element) => !includedElementIds.has(element.id),
 | 
			
		||||
        ) as SuggestedBinding[])
 | 
			
		||||
      : isBindableElement(element, false)
 | 
			
		||||
      : isBindableElement(element)
 | 
			
		||||
      ? getElligibleElementsForBindableElementAndWhere(element).filter(
 | 
			
		||||
          (binding) => !includedElementIds.has(binding[0].id),
 | 
			
		||||
        )
 | 
			
		||||
@@ -509,7 +508,7 @@ const getElligibleElementsForBindableElementAndWhere = (
 | 
			
		||||
  return Scene.getScene(bindableElement)!
 | 
			
		||||
    .getElements()
 | 
			
		||||
    .map((element) => {
 | 
			
		||||
      if (!isBindingElement(element, false)) {
 | 
			
		||||
      if (!isBindingElement(element)) {
 | 
			
		||||
        return null;
 | 
			
		||||
      }
 | 
			
		||||
      const canBindStart = isLinearElementEligibleForNewBindingByBindable(
 | 
			
		||||
@@ -660,47 +659,28 @@ export const fixBindingsAfterDeletion = (
 | 
			
		||||
  const deletedElementIds = new Set(
 | 
			
		||||
    deletedElements.map((element) => element.id),
 | 
			
		||||
  );
 | 
			
		||||
  // non-deleted which bindings need to be updated
 | 
			
		||||
  const affectedElements: Set<ExcalidrawElement["id"]> = new Set();
 | 
			
		||||
  // Non deleted and need an update
 | 
			
		||||
  const boundElementIds: Set<ExcalidrawElement["id"]> = new Set();
 | 
			
		||||
  deletedElements.forEach((deletedElement) => {
 | 
			
		||||
    if (isBindableElement(deletedElement)) {
 | 
			
		||||
      deletedElement.boundElements?.forEach((element) => {
 | 
			
		||||
        if (!deletedElementIds.has(element.id)) {
 | 
			
		||||
          affectedElements.add(element.id);
 | 
			
		||||
          boundElementIds.add(element.id);
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    } else if (isBindingElement(deletedElement)) {
 | 
			
		||||
      if (deletedElement.startBinding) {
 | 
			
		||||
        affectedElements.add(deletedElement.startBinding.elementId);
 | 
			
		||||
      }
 | 
			
		||||
      if (deletedElement.endBinding) {
 | 
			
		||||
        affectedElements.add(deletedElement.endBinding.elementId);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
  sceneElements
 | 
			
		||||
    .filter(({ id }) => affectedElements.has(id))
 | 
			
		||||
    .forEach((element) => {
 | 
			
		||||
      if (isBindableElement(element)) {
 | 
			
		||||
        mutateElement(element, {
 | 
			
		||||
          boundElements: newBoundElementsAfterDeletion(
 | 
			
		||||
            element.boundElements,
 | 
			
		||||
            deletedElementIds,
 | 
			
		||||
          ),
 | 
			
		||||
        });
 | 
			
		||||
      } else if (isBindingElement(element)) {
 | 
			
		||||
        mutateElement(element, {
 | 
			
		||||
          startBinding: newBindingAfterDeletion(
 | 
			
		||||
            element.startBinding,
 | 
			
		||||
            deletedElementIds,
 | 
			
		||||
          ),
 | 
			
		||||
          endBinding: newBindingAfterDeletion(
 | 
			
		||||
            element.endBinding,
 | 
			
		||||
            deletedElementIds,
 | 
			
		||||
          ),
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
  (
 | 
			
		||||
    sceneElements.filter(({ id }) =>
 | 
			
		||||
      boundElementIds.has(id),
 | 
			
		||||
    ) as ExcalidrawLinearElement[]
 | 
			
		||||
  ).forEach((element: ExcalidrawLinearElement) => {
 | 
			
		||||
    const { startBinding, endBinding } = element;
 | 
			
		||||
    mutateElement(element, {
 | 
			
		||||
      startBinding: newBindingAfterDeletion(startBinding, deletedElementIds),
 | 
			
		||||
      endBinding: newBindingAfterDeletion(endBinding, deletedElementIds),
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const newBindingAfterDeletion = (
 | 
			
		||||
@@ -712,13 +692,3 @@ const newBindingAfterDeletion = (
 | 
			
		||||
  }
 | 
			
		||||
  return binding;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const newBoundElementsAfterDeletion = (
 | 
			
		||||
  boundElements: ExcalidrawElement["boundElements"],
 | 
			
		||||
  deletedElementIds: Set<ExcalidrawElement["id"]>,
 | 
			
		||||
) => {
 | 
			
		||||
  if (!boundElements) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
  return boundElements.filter((ele) => !deletedElementIds.has(ele.id));
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
 
 | 
			
		||||
@@ -106,20 +106,6 @@ export const normalizeSVG = async (SVGString: string) => {
 | 
			
		||||
      svg.setAttribute("xmlns", SVG_NS);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!svg.hasAttribute("width") || !svg.hasAttribute("height")) {
 | 
			
		||||
      const viewBox = svg.getAttribute("viewBox");
 | 
			
		||||
      let width = svg.getAttribute("width") || "50";
 | 
			
		||||
      let height = svg.getAttribute("height") || "50";
 | 
			
		||||
      if (viewBox) {
 | 
			
		||||
        const match = viewBox.match(/\d+ +\d+ +(\d+) +(\d+)/);
 | 
			
		||||
        if (match) {
 | 
			
		||||
          [, width, height] = match;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      svg.setAttribute("width", width);
 | 
			
		||||
      svg.setAttribute("height", height);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return svg.outerHTML;
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -205,7 +205,7 @@ export class LinearElementEditor {
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      // suggest bindings for first and last point if selected
 | 
			
		||||
      if (isBindingElement(element, false)) {
 | 
			
		||||
      if (isBindingElement(element)) {
 | 
			
		||||
        const coords: { x: number; y: number }[] = [];
 | 
			
		||||
 | 
			
		||||
        const firstSelectedIndex = selectedPointsIndices[0];
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,11 @@ import { duplicateElement } from "./newElement";
 | 
			
		||||
import { mutateElement } from "./mutateElement";
 | 
			
		||||
import { API } from "../tests/helpers/api";
 | 
			
		||||
import { FONT_FAMILY } from "../constants";
 | 
			
		||||
import { isPrimitive } from "../utils";
 | 
			
		||||
 | 
			
		||||
const isPrimitive = (val: any) => {
 | 
			
		||||
  const type = typeof val;
 | 
			
		||||
  return val == null || (type !== "object" && type !== "function");
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const assertCloneObjects = (source: any, clone: any) => {
 | 
			
		||||
  for (const key in clone) {
 | 
			
		||||
 
 | 
			
		||||
@@ -56,7 +56,6 @@ const _newElementBase = <T extends ExcalidrawElement>(
 | 
			
		||||
    strokeSharpness,
 | 
			
		||||
    boundElements = null,
 | 
			
		||||
    link = null,
 | 
			
		||||
    locked,
 | 
			
		||||
    ...rest
 | 
			
		||||
  }: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
 | 
			
		||||
) => {
 | 
			
		||||
@@ -84,7 +83,6 @@ const _newElementBase = <T extends ExcalidrawElement>(
 | 
			
		||||
    boundElements,
 | 
			
		||||
    updated: getUpdatedTimestamp(),
 | 
			
		||||
    link,
 | 
			
		||||
    locked,
 | 
			
		||||
  };
 | 
			
		||||
  return element;
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -12,7 +12,6 @@ import {
 | 
			
		||||
  ExcalidrawTextElement,
 | 
			
		||||
  NonDeletedExcalidrawElement,
 | 
			
		||||
  NonDeleted,
 | 
			
		||||
  ExcalidrawElement,
 | 
			
		||||
} from "./types";
 | 
			
		||||
import {
 | 
			
		||||
  getElementAbsoluteCoords,
 | 
			
		||||
@@ -187,7 +186,7 @@ const validateTwoPointElementNormalized = (
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const getPerfectElementSizeWithRotation = (
 | 
			
		||||
  elementType: ExcalidrawElement["type"],
 | 
			
		||||
  elementType: string,
 | 
			
		||||
  width: number,
 | 
			
		||||
  height: number,
 | 
			
		||||
  angle: number,
 | 
			
		||||
 
 | 
			
		||||
@@ -10,6 +10,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")),
 | 
			
		||||
  );
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,6 @@ import { ExcalidrawElement } from "./types";
 | 
			
		||||
import { mutateElement } from "./mutateElement";
 | 
			
		||||
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
 | 
			
		||||
import { SHIFT_LOCKING_ANGLE } from "../constants";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
 | 
			
		||||
export const isInvisiblySmallElement = (
 | 
			
		||||
  element: ExcalidrawElement,
 | 
			
		||||
@@ -17,7 +16,7 @@ export const isInvisiblySmallElement = (
 | 
			
		||||
 * Makes a perfect shape or diagonal/horizontal/vertical line
 | 
			
		||||
 */
 | 
			
		||||
export const getPerfectElementSize = (
 | 
			
		||||
  elementType: AppState["activeTool"]["type"],
 | 
			
		||||
  elementType: string,
 | 
			
		||||
  width: number,
 | 
			
		||||
  height: number,
 | 
			
		||||
): { width: number; height: number } => {
 | 
			
		||||
 
 | 
			
		||||
@@ -10,11 +10,13 @@ import { mutateElement } from "./mutateElement";
 | 
			
		||||
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
 | 
			
		||||
import { MaybeTransformHandleType } from "./transformHandles";
 | 
			
		||||
import Scene from "../scene/Scene";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { isTextElement } from ".";
 | 
			
		||||
 | 
			
		||||
export const redrawTextBoundingBox = (
 | 
			
		||||
  element: ExcalidrawTextElement,
 | 
			
		||||
  container: ExcalidrawElement | null,
 | 
			
		||||
  appState: AppState,
 | 
			
		||||
) => {
 | 
			
		||||
  const maxWidth = container
 | 
			
		||||
    ? container.width - BOUND_TEXT_PADDING * 2
 | 
			
		||||
@@ -33,12 +35,12 @@ export const redrawTextBoundingBox = (
 | 
			
		||||
    getFontString(element),
 | 
			
		||||
    maxWidth,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  let coordY = element.y;
 | 
			
		||||
  let coordX = element.x;
 | 
			
		||||
  // Resize container and vertically center align the text
 | 
			
		||||
  if (container) {
 | 
			
		||||
    let nextHeight = container.height;
 | 
			
		||||
    coordX = container.x + BOUND_TEXT_PADDING;
 | 
			
		||||
 | 
			
		||||
    if (element.verticalAlign === VERTICAL_ALIGN.TOP) {
 | 
			
		||||
      coordY = container.y + BOUND_TEXT_PADDING;
 | 
			
		||||
    } else if (element.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
 | 
			
		||||
@@ -53,12 +55,12 @@ export const redrawTextBoundingBox = (
 | 
			
		||||
    }
 | 
			
		||||
    mutateElement(container, { height: nextHeight });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  mutateElement(element, {
 | 
			
		||||
    width: metrics.width,
 | 
			
		||||
    height: metrics.height,
 | 
			
		||||
    baseline: metrics.baseline,
 | 
			
		||||
    y: coordY,
 | 
			
		||||
    x: coordX,
 | 
			
		||||
    text,
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -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);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -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));
 | 
			
		||||
@@ -316,6 +314,8 @@ export const textWysiwyg = ({
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  editable.onkeydown = (event) => {
 | 
			
		||||
    event.stopPropagation();
 | 
			
		||||
 | 
			
		||||
    if (!event.shiftKey && actionZoomIn.keyTest(event)) {
 | 
			
		||||
      event.preventDefault();
 | 
			
		||||
      app.actionManager.executeAction(actionZoomIn);
 | 
			
		||||
 
 | 
			
		||||
@@ -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" ||
 | 
			
		||||
 
 | 
			
		||||
@@ -61,7 +61,7 @@ export const isLinearElement = (
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const isLinearElementType = (
 | 
			
		||||
  elementType: AppState["activeTool"]["type"],
 | 
			
		||||
  elementType: AppState["elementType"],
 | 
			
		||||
): boolean => {
 | 
			
		||||
  return (
 | 
			
		||||
    elementType === "arrow" || elementType === "line" // || elementType === "freedraw"
 | 
			
		||||
@@ -70,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" ||
 | 
			
		||||
@@ -102,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" ||
 | 
			
		||||
 
 | 
			
		||||
@@ -55,7 +55,6 @@ type _ExcalidrawElementBase = Readonly<{
 | 
			
		||||
  /** epoch (ms) timestamp of last element update */
 | 
			
		||||
  updated: number;
 | 
			
		||||
  link: string | null;
 | 
			
		||||
  locked: boolean;
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
export type ExcalidrawSelectionElement = _ExcalidrawElementBase & {
 | 
			
		||||
 
 | 
			
		||||
@@ -11,12 +11,12 @@ export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
 | 
			
		||||
// 1 year (https://stackoverflow.com/a/25201898/927631)
 | 
			
		||||
export const FILE_CACHE_MAX_AGE_SEC = 31536000;
 | 
			
		||||
 | 
			
		||||
export const WS_EVENTS = {
 | 
			
		||||
export const BROADCAST = {
 | 
			
		||||
  SERVER_VOLATILE: "server-volatile-broadcast",
 | 
			
		||||
  SERVER: "server-broadcast",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export enum WS_SCENE_EVENT_TYPES {
 | 
			
		||||
export enum SCENE {
 | 
			
		||||
  INIT = "SCENE_INIT",
 | 
			
		||||
  UPDATE = "SCENE_UPDATE",
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -11,7 +11,6 @@ import {
 | 
			
		||||
import { getSceneVersion } from "../../packages/excalidraw/index";
 | 
			
		||||
import { Collaborator, Gesture } from "../../types";
 | 
			
		||||
import {
 | 
			
		||||
  getFrame,
 | 
			
		||||
  preventUnload,
 | 
			
		||||
  resolvablePromise,
 | 
			
		||||
  withBatchedUpdates,
 | 
			
		||||
@@ -22,7 +21,7 @@ import {
 | 
			
		||||
  FIREBASE_STORAGE_PREFIXES,
 | 
			
		||||
  INITIAL_SCENE_UPDATE_TIMEOUT,
 | 
			
		||||
  LOAD_IMAGES_TIMEOUT,
 | 
			
		||||
  WS_SCENE_EVENT_TYPES,
 | 
			
		||||
  SCENE,
 | 
			
		||||
  STORAGE_KEYS,
 | 
			
		||||
  SYNC_FULL_SCENE_INTERVAL_MS,
 | 
			
		||||
} from "../app_constants";
 | 
			
		||||
@@ -68,7 +67,6 @@ import {
 | 
			
		||||
} from "./reconciliation";
 | 
			
		||||
import { decryptData } from "../../data/encryption";
 | 
			
		||||
import { resetBrowserStateVersions } from "../data/tabSync";
 | 
			
		||||
import { LocalData } from "../data/LocalData";
 | 
			
		||||
 | 
			
		||||
interface CollabState {
 | 
			
		||||
  modalIsShown: boolean;
 | 
			
		||||
@@ -88,7 +86,7 @@ export interface CollabAPI {
 | 
			
		||||
  onPointerUpdate: CollabInstance["onPointerUpdate"];
 | 
			
		||||
  initializeSocketClient: CollabInstance["initializeSocketClient"];
 | 
			
		||||
  onCollabButtonClick: CollabInstance["onCollabButtonClick"];
 | 
			
		||||
  syncElements: CollabInstance["syncElements"];
 | 
			
		||||
  broadcastElements: CollabInstance["broadcastElements"];
 | 
			
		||||
  fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
 | 
			
		||||
  setUsername: (username: string) => void;
 | 
			
		||||
}
 | 
			
		||||
@@ -110,11 +108,10 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
 | 
			
		||||
  portal: Portal;
 | 
			
		||||
  fileManager: FileManager;
 | 
			
		||||
  excalidrawAPI: Props["excalidrawAPI"];
 | 
			
		||||
  isCollaborating: boolean = false;
 | 
			
		||||
  activeIntervalId: number | null;
 | 
			
		||||
  idleTimeoutId: number | null;
 | 
			
		||||
 | 
			
		||||
  // marked as private to ensure we don't change it outside this class
 | 
			
		||||
  private _isCollaborating: boolean = false;
 | 
			
		||||
  private socketInitializationTimer?: number;
 | 
			
		||||
  private lastBroadcastedOrReceivedSceneVersion: number = -1;
 | 
			
		||||
  private collaborators = new Map<string, Collaborator>();
 | 
			
		||||
@@ -195,8 +192,6 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  isCollaborating = () => this._isCollaborating;
 | 
			
		||||
 | 
			
		||||
  private onUnload = () => {
 | 
			
		||||
    this.destroySocketClient({ isUnload: true });
 | 
			
		||||
  };
 | 
			
		||||
@@ -207,7 +202,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
      this._isCollaborating &&
 | 
			
		||||
      this.isCollaborating &&
 | 
			
		||||
      (this.fileManager.shouldPreventUnload(syncableElements) ||
 | 
			
		||||
        !isSavedToFirebase(this.portal, syncableElements))
 | 
			
		||||
    ) {
 | 
			
		||||
@@ -232,40 +227,27 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  saveCollabRoomToFirebase = async (
 | 
			
		||||
    syncableElements: readonly ExcalidrawElement[],
 | 
			
		||||
    syncableElements: readonly ExcalidrawElement[] = this.getSyncableElements(
 | 
			
		||||
      this.excalidrawAPI.getSceneElementsIncludingDeleted(),
 | 
			
		||||
    ),
 | 
			
		||||
  ) => {
 | 
			
		||||
    try {
 | 
			
		||||
      const savedData = await saveToFirebase(
 | 
			
		||||
        this.portal,
 | 
			
		||||
        syncableElements,
 | 
			
		||||
        this.excalidrawAPI.getAppState(),
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      if (this.isCollaborating() && savedData && savedData.reconciledElements) {
 | 
			
		||||
        this.handleRemoteSceneUpdate(
 | 
			
		||||
          this.reconcileElements(savedData.reconciledElements),
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
      await saveToFirebase(this.portal, syncableElements);
 | 
			
		||||
    } catch (error: any) {
 | 
			
		||||
      console.error(error);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  openPortal = async () => {
 | 
			
		||||
    trackEvent("share", "room creation", `ui (${getFrame()})`);
 | 
			
		||||
    trackEvent("share", "room creation");
 | 
			
		||||
    return this.initializeSocketClient(null);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  closePortal = () => {
 | 
			
		||||
    this.queueBroadcastAllElements.cancel();
 | 
			
		||||
    this.queueSaveToFirebase.cancel();
 | 
			
		||||
    this.loadImageFiles.cancel();
 | 
			
		||||
 | 
			
		||||
    this.saveCollabRoomToFirebase(
 | 
			
		||||
      this.getSyncableElements(
 | 
			
		||||
        this.excalidrawAPI.getSceneElementsIncludingDeleted(),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
    this.saveCollabRoomToFirebase();
 | 
			
		||||
    if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
 | 
			
		||||
      // hack to ensure that we prefer we disregard any new browser state
 | 
			
		||||
      // that could have been saved in other tabs while we were collaborating
 | 
			
		||||
@@ -302,8 +284,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
 | 
			
		||||
      this.setState({
 | 
			
		||||
        activeRoomLink: "",
 | 
			
		||||
      });
 | 
			
		||||
      this._isCollaborating = false;
 | 
			
		||||
      LocalData.resumeSave("collaboration");
 | 
			
		||||
      this.isCollaborating = false;
 | 
			
		||||
    }
 | 
			
		||||
    this.lastBroadcastedOrReceivedSceneVersion = -1;
 | 
			
		||||
    this.portal.close();
 | 
			
		||||
@@ -371,8 +352,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
 | 
			
		||||
 | 
			
		||||
    const scenePromise = resolvablePromise<ImportedDataState | null>();
 | 
			
		||||
 | 
			
		||||
    this._isCollaborating = true;
 | 
			
		||||
    LocalData.pauseSave("collaboration");
 | 
			
		||||
    this.isCollaborating = true;
 | 
			
		||||
 | 
			
		||||
    const { default: socketIOClient } = await import(
 | 
			
		||||
      /* webpackChunkName: "socketIoClient" */ "socket.io-client"
 | 
			
		||||
@@ -413,7 +393,10 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
 | 
			
		||||
        commitToHistory: true,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      this.saveCollabRoomToFirebase(this.getSyncableElements(elements));
 | 
			
		||||
      this.broadcastElements(elements);
 | 
			
		||||
 | 
			
		||||
      const syncableElements = this.getSyncableElements(elements);
 | 
			
		||||
      this.saveCollabRoomToFirebase(syncableElements);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // fallback in case you're not alone in the room but still don't receive
 | 
			
		||||
@@ -443,7 +426,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
 | 
			
		||||
        switch (decryptedData.type) {
 | 
			
		||||
          case "INVALID_RESPONSE":
 | 
			
		||||
            return;
 | 
			
		||||
          case WS_SCENE_EVENT_TYPES.INIT: {
 | 
			
		||||
          case SCENE.INIT: {
 | 
			
		||||
            if (!this.portal.socketInitialized) {
 | 
			
		||||
              this.initializeRoom({ fetchScene: false });
 | 
			
		||||
              const remoteElements = decryptedData.payload.elements;
 | 
			
		||||
@@ -459,7 +442,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
 | 
			
		||||
            }
 | 
			
		||||
            break;
 | 
			
		||||
          }
 | 
			
		||||
          case WS_SCENE_EVENT_TYPES.UPDATE:
 | 
			
		||||
          case SCENE.UPDATE:
 | 
			
		||||
            this.handleRemoteSceneUpdate(
 | 
			
		||||
              this.reconcileElements(decryptedData.payload.elements),
 | 
			
		||||
            );
 | 
			
		||||
@@ -721,20 +704,15 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
 | 
			
		||||
      getSceneVersion(elements) >
 | 
			
		||||
      this.getLastBroadcastedOrReceivedSceneVersion()
 | 
			
		||||
    ) {
 | 
			
		||||
      this.portal.broadcastScene(WS_SCENE_EVENT_TYPES.UPDATE, elements, false);
 | 
			
		||||
      this.portal.broadcastScene(SCENE.UPDATE, elements, false);
 | 
			
		||||
      this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);
 | 
			
		||||
      this.queueBroadcastAllElements();
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  syncElements = (elements: readonly ExcalidrawElement[]) => {
 | 
			
		||||
    this.broadcastElements(elements);
 | 
			
		||||
    this.queueSaveToFirebase();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  queueBroadcastAllElements = throttle(() => {
 | 
			
		||||
    this.portal.broadcastScene(
 | 
			
		||||
      WS_SCENE_EVENT_TYPES.UPDATE,
 | 
			
		||||
      SCENE.UPDATE,
 | 
			
		||||
      this.excalidrawAPI.getSceneElementsIncludingDeleted(),
 | 
			
		||||
      true,
 | 
			
		||||
    );
 | 
			
		||||
@@ -746,16 +724,6 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
 | 
			
		||||
    this.setLastBroadcastedOrReceivedSceneVersion(newVersion);
 | 
			
		||||
  }, SYNC_FULL_SCENE_INTERVAL_MS);
 | 
			
		||||
 | 
			
		||||
  queueSaveToFirebase = throttle(() => {
 | 
			
		||||
    if (this.portal.socketInitialized) {
 | 
			
		||||
      this.saveCollabRoomToFirebase(
 | 
			
		||||
        this.getSyncableElements(
 | 
			
		||||
          this.excalidrawAPI.getSceneElementsIncludingDeleted(),
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
  }, SYNC_FULL_SCENE_INTERVAL_MS);
 | 
			
		||||
 | 
			
		||||
  handleClose = () => {
 | 
			
		||||
    this.setState({ modalIsShown: false });
 | 
			
		||||
  };
 | 
			
		||||
@@ -791,12 +759,12 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
 | 
			
		||||
      this.contextValue = {} as CollabAPI;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.contextValue.isCollaborating = this.isCollaborating;
 | 
			
		||||
    this.contextValue.isCollaborating = () => this.isCollaborating;
 | 
			
		||||
    this.contextValue.username = this.state.username;
 | 
			
		||||
    this.contextValue.onPointerUpdate = this.onPointerUpdate;
 | 
			
		||||
    this.contextValue.initializeSocketClient = this.initializeSocketClient;
 | 
			
		||||
    this.contextValue.onCollabButtonClick = this.onCollabButtonClick;
 | 
			
		||||
    this.contextValue.syncElements = this.syncElements;
 | 
			
		||||
    this.contextValue.broadcastElements = this.broadcastElements;
 | 
			
		||||
    this.contextValue.fetchImageFilesFromFirebase =
 | 
			
		||||
      this.fetchImageFilesFromFirebase;
 | 
			
		||||
    this.contextValue.setUsername = this.setUsername;
 | 
			
		||||
 
 | 
			
		||||
@@ -3,11 +3,7 @@ import { SocketUpdateData, SocketUpdateDataSource } from "../data";
 | 
			
		||||
import CollabWrapper from "./CollabWrapper";
 | 
			
		||||
 | 
			
		||||
import { ExcalidrawElement } from "../../element/types";
 | 
			
		||||
import {
 | 
			
		||||
  WS_EVENTS,
 | 
			
		||||
  FILE_UPLOAD_TIMEOUT,
 | 
			
		||||
  WS_SCENE_EVENT_TYPES,
 | 
			
		||||
} from "../app_constants";
 | 
			
		||||
import { BROADCAST, FILE_UPLOAD_TIMEOUT, SCENE } from "../app_constants";
 | 
			
		||||
import { UserIdleState } from "../../types";
 | 
			
		||||
import { trackEvent } from "../../analytics";
 | 
			
		||||
import { throttle } from "lodash";
 | 
			
		||||
@@ -41,7 +37,7 @@ class Portal {
 | 
			
		||||
    });
 | 
			
		||||
    this.socket.on("new-user", async (_socketId: string) => {
 | 
			
		||||
      this.broadcastScene(
 | 
			
		||||
        WS_SCENE_EVENT_TYPES.INIT,
 | 
			
		||||
        SCENE.INIT,
 | 
			
		||||
        this.collab.getSceneElementsIncludingDeleted(),
 | 
			
		||||
        /* syncAll */ true,
 | 
			
		||||
      );
 | 
			
		||||
@@ -85,7 +81,7 @@ class Portal {
 | 
			
		||||
      const { encryptedBuffer, iv } = await encryptData(this.roomKey!, encoded);
 | 
			
		||||
 | 
			
		||||
      this.socket?.emit(
 | 
			
		||||
        volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER,
 | 
			
		||||
        volatile ? BROADCAST.SERVER_VOLATILE : BROADCAST.SERVER,
 | 
			
		||||
        this.roomId,
 | 
			
		||||
        encryptedBuffer,
 | 
			
		||||
        iv,
 | 
			
		||||
@@ -125,11 +121,11 @@ class Portal {
 | 
			
		||||
  }, FILE_UPLOAD_TIMEOUT);
 | 
			
		||||
 | 
			
		||||
  broadcastScene = async (
 | 
			
		||||
    updateType: WS_SCENE_EVENT_TYPES.INIT | WS_SCENE_EVENT_TYPES.UPDATE,
 | 
			
		||||
    sceneType: SCENE.INIT | SCENE.UPDATE,
 | 
			
		||||
    allElements: readonly ExcalidrawElement[],
 | 
			
		||||
    syncAll: boolean,
 | 
			
		||||
  ) => {
 | 
			
		||||
    if (updateType === WS_SCENE_EVENT_TYPES.INIT && !syncAll) {
 | 
			
		||||
    if (sceneType === SCENE.INIT && !syncAll) {
 | 
			
		||||
      throw new Error("syncAll must be true when sending SCENE.INIT");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -156,8 +152,8 @@ class Portal {
 | 
			
		||||
      [] as BroadcastedExcalidrawElement[],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const data: SocketUpdateDataSource[typeof updateType] = {
 | 
			
		||||
      type: updateType,
 | 
			
		||||
    const data: SocketUpdateDataSource[typeof sceneType] = {
 | 
			
		||||
      type: sceneType,
 | 
			
		||||
      payload: {
 | 
			
		||||
        elements: syncableElements,
 | 
			
		||||
      },
 | 
			
		||||
@@ -170,9 +166,20 @@ class Portal {
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const broadcastPromise = this._broadcastSocketData(
 | 
			
		||||
      data as SocketUpdateData,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    this.queueFileUpload();
 | 
			
		||||
 | 
			
		||||
    await this._broadcastSocketData(data as SocketUpdateData);
 | 
			
		||||
    if (syncAll && this.collab.isCollaborating) {
 | 
			
		||||
      await Promise.all([
 | 
			
		||||
        broadcastPromise,
 | 
			
		||||
        this.collab.saveCollabRoomToFirebase(syncableElements),
 | 
			
		||||
      ]);
 | 
			
		||||
    } else {
 | 
			
		||||
      await broadcastPromise;
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  broadcastIdleChange = (userState: UserIdleState) => {
 | 
			
		||||
 
 | 
			
		||||
@@ -78,14 +78,8 @@ export const reconcileElements = (
 | 
			
		||||
      continue;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Mark duplicate for removal as it'll be replaced with the remote element
 | 
			
		||||
    if (local) {
 | 
			
		||||
      // Unless the ramote and local elements are the same element in which case
 | 
			
		||||
      // we need to keep it as we'd otherwise discard it from the resulting
 | 
			
		||||
      // array.
 | 
			
		||||
      if (local[0] === remoteElement) {
 | 
			
		||||
        continue;
 | 
			
		||||
      }
 | 
			
		||||
      // mark for removal since it'll be replaced with the remote element
 | 
			
		||||
      duplicates.set(local[0], true);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -13,8 +13,6 @@ import { isInitializedImageElement } from "../../element/typeChecks";
 | 
			
		||||
import { FILE_UPLOAD_MAX_BYTES } from "../app_constants";
 | 
			
		||||
import { encodeFilesForUpload } from "../data/FileManager";
 | 
			
		||||
import { MIME_TYPES } from "../../constants";
 | 
			
		||||
import { trackEvent } from "../../analytics";
 | 
			
		||||
import { getFrame } from "../../utils";
 | 
			
		||||
 | 
			
		||||
const exportToExcalidrawPlus = async (
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[],
 | 
			
		||||
@@ -94,7 +92,6 @@ export const ExportToExcalidrawPlus: React.FC<{
 | 
			
		||||
        showAriaLabel={true}
 | 
			
		||||
        onClick={async () => {
 | 
			
		||||
          try {
 | 
			
		||||
            trackEvent("export", "eplus", `ui (${getFrame()})`);
 | 
			
		||||
            await exportToExcalidrawPlus(elements, appState, files);
 | 
			
		||||
          } catch (error: any) {
 | 
			
		||||
            console.error(error);
 | 
			
		||||
 
 | 
			
		||||
@@ -1,154 +0,0 @@
 | 
			
		||||
/**
 | 
			
		||||
 * This file deals with saving data state (appState, elements, images, ...)
 | 
			
		||||
 * locally to the browser.
 | 
			
		||||
 *
 | 
			
		||||
 * Notes:
 | 
			
		||||
 *
 | 
			
		||||
 * - DataState refers to full state of the app: appState, elements, images,
 | 
			
		||||
 *   though some state is saved separately (collab username, library) for one
 | 
			
		||||
 *   reason or another. We also save different data to different sotrage
 | 
			
		||||
 *   (localStorage, indexedDB).
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
import { createStore, keys, del, getMany, set } from "idb-keyval";
 | 
			
		||||
import { clearAppStateForLocalStorage } from "../../appState";
 | 
			
		||||
import { clearElementsForLocalStorage } from "../../element";
 | 
			
		||||
import { ExcalidrawElement, FileId } from "../../element/types";
 | 
			
		||||
import { AppState, BinaryFileData, BinaryFiles } from "../../types";
 | 
			
		||||
import { debounce } from "../../utils";
 | 
			
		||||
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
 | 
			
		||||
import { FileManager } from "./FileManager";
 | 
			
		||||
import { Locker } from "./Locker";
 | 
			
		||||
import { updateBrowserStateVersion } from "./tabSync";
 | 
			
		||||
 | 
			
		||||
const filesStore = createStore("files-db", "files-store");
 | 
			
		||||
 | 
			
		||||
class LocalFileManager extends FileManager {
 | 
			
		||||
  clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
 | 
			
		||||
    const allIds = await keys(filesStore);
 | 
			
		||||
    for (const id of allIds) {
 | 
			
		||||
      if (!opts.currentFileIds.includes(id as FileId)) {
 | 
			
		||||
        del(id, filesStore);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const saveDataStateToLocalStorage = (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
  appState: AppState,
 | 
			
		||||
) => {
 | 
			
		||||
  try {
 | 
			
		||||
    localStorage.setItem(
 | 
			
		||||
      STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
 | 
			
		||||
      JSON.stringify(clearElementsForLocalStorage(elements)),
 | 
			
		||||
    );
 | 
			
		||||
    localStorage.setItem(
 | 
			
		||||
      STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
 | 
			
		||||
      JSON.stringify(clearAppStateForLocalStorage(appState)),
 | 
			
		||||
    );
 | 
			
		||||
    updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
 | 
			
		||||
  } catch (error: any) {
 | 
			
		||||
    // Unable to access window.localStorage
 | 
			
		||||
    console.error(error);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type SavingLockTypes = "collaboration";
 | 
			
		||||
 | 
			
		||||
export class LocalData {
 | 
			
		||||
  private static _save = debounce(
 | 
			
		||||
    async (
 | 
			
		||||
      elements: readonly ExcalidrawElement[],
 | 
			
		||||
      appState: AppState,
 | 
			
		||||
      files: BinaryFiles,
 | 
			
		||||
      onFilesSaved: () => void,
 | 
			
		||||
    ) => {
 | 
			
		||||
      saveDataStateToLocalStorage(elements, appState);
 | 
			
		||||
 | 
			
		||||
      await this.fileStorage.saveFiles({
 | 
			
		||||
        elements,
 | 
			
		||||
        files,
 | 
			
		||||
      });
 | 
			
		||||
      onFilesSaved();
 | 
			
		||||
    },
 | 
			
		||||
    SAVE_TO_LOCAL_STORAGE_TIMEOUT,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  /** Saves DataState, including files. Bails if saving is paused */
 | 
			
		||||
  static save = (
 | 
			
		||||
    elements: readonly ExcalidrawElement[],
 | 
			
		||||
    appState: AppState,
 | 
			
		||||
    files: BinaryFiles,
 | 
			
		||||
    onFilesSaved: () => void,
 | 
			
		||||
  ) => {
 | 
			
		||||
    // we need to make the `isSavePaused` check synchronously (undebounced)
 | 
			
		||||
    if (!this.isSavePaused()) {
 | 
			
		||||
      this._save(elements, appState, files, onFilesSaved);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  static flushSave = () => {
 | 
			
		||||
    this._save.flush();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private static locker = new Locker<SavingLockTypes>();
 | 
			
		||||
 | 
			
		||||
  static pauseSave = (lockType: SavingLockTypes) => {
 | 
			
		||||
    this.locker.lock(lockType);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  static resumeSave = (lockType: SavingLockTypes) => {
 | 
			
		||||
    this.locker.unlock(lockType);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  static isSavePaused = () => {
 | 
			
		||||
    return document.hidden || this.locker.isLocked();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  // ---------------------------------------------------------------------------
 | 
			
		||||
 | 
			
		||||
  static fileStorage = new LocalFileManager({
 | 
			
		||||
    getFiles(ids) {
 | 
			
		||||
      return getMany(ids, filesStore).then(
 | 
			
		||||
        (filesData: (BinaryFileData | undefined)[]) => {
 | 
			
		||||
          const loadedFiles: BinaryFileData[] = [];
 | 
			
		||||
          const erroredFiles = new Map<FileId, true>();
 | 
			
		||||
          filesData.forEach((data, index) => {
 | 
			
		||||
            const id = ids[index];
 | 
			
		||||
            if (data) {
 | 
			
		||||
              loadedFiles.push(data);
 | 
			
		||||
            } else {
 | 
			
		||||
              erroredFiles.set(id, true);
 | 
			
		||||
            }
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
          return { loadedFiles, erroredFiles };
 | 
			
		||||
        },
 | 
			
		||||
      );
 | 
			
		||||
    },
 | 
			
		||||
    async saveFiles({ addedFiles }) {
 | 
			
		||||
      const savedFiles = new Map<FileId, true>();
 | 
			
		||||
      const erroredFiles = new Map<FileId, true>();
 | 
			
		||||
 | 
			
		||||
      // before we use `storage` event synchronization, let's update the flag
 | 
			
		||||
      // optimistically. Hopefully nothing fails, and an IDB read executed
 | 
			
		||||
      // before an IDB write finishes will read the latest value.
 | 
			
		||||
      updateBrowserStateVersion(STORAGE_KEYS.VERSION_FILES);
 | 
			
		||||
 | 
			
		||||
      await Promise.all(
 | 
			
		||||
        [...addedFiles].map(async ([id, fileData]) => {
 | 
			
		||||
          try {
 | 
			
		||||
            await set(id, fileData, filesStore);
 | 
			
		||||
            savedFiles.set(id, true);
 | 
			
		||||
          } catch (error: any) {
 | 
			
		||||
            console.error(error);
 | 
			
		||||
            erroredFiles.set(id, true);
 | 
			
		||||
          }
 | 
			
		||||
        }),
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      return { savedFiles, erroredFiles };
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
@@ -1,18 +0,0 @@
 | 
			
		||||
export class Locker<T extends string> {
 | 
			
		||||
  private locks = new Map<T, true>();
 | 
			
		||||
 | 
			
		||||
  lock = (lockType: T) => {
 | 
			
		||||
    this.locks.set(lockType, true);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  /** @returns whether no locks remaining */
 | 
			
		||||
  unlock = (lockType: T) => {
 | 
			
		||||
    this.locks.delete(lockType);
 | 
			
		||||
    return !this.isLocked();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  /** @returns whether some (or specific) locks are present */
 | 
			
		||||
  isLocked(lockType?: T) {
 | 
			
		||||
    return lockType ? this.locks.has(lockType) : !!this.locks.size;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -2,17 +2,11 @@ import { ExcalidrawElement, FileId } from "../../element/types";
 | 
			
		||||
import { getSceneVersion } from "../../element";
 | 
			
		||||
import Portal from "../collab/Portal";
 | 
			
		||||
import { restoreElements } from "../../data/restore";
 | 
			
		||||
import {
 | 
			
		||||
  AppState,
 | 
			
		||||
  BinaryFileData,
 | 
			
		||||
  BinaryFileMetadata,
 | 
			
		||||
  DataURL,
 | 
			
		||||
} from "../../types";
 | 
			
		||||
import { BinaryFileData, BinaryFileMetadata, DataURL } from "../../types";
 | 
			
		||||
import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
 | 
			
		||||
import { decompressData } from "../../data/encode";
 | 
			
		||||
import { encryptData, decryptData } from "../../data/encryption";
 | 
			
		||||
import { MIME_TYPES } from "../../constants";
 | 
			
		||||
import { reconcileElements } from "../collab/reconciliation";
 | 
			
		||||
 | 
			
		||||
// private
 | 
			
		||||
// -----------------------------------------------------------------------------
 | 
			
		||||
@@ -114,13 +108,11 @@ const encryptElements = async (
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const decryptElements = async (
 | 
			
		||||
  data: FirebaseStoredScene,
 | 
			
		||||
  roomKey: string,
 | 
			
		||||
  key: string,
 | 
			
		||||
  iv: Uint8Array,
 | 
			
		||||
  ciphertext: ArrayBuffer | Uint8Array,
 | 
			
		||||
): Promise<readonly ExcalidrawElement[]> => {
 | 
			
		||||
  const ciphertext = data.ciphertext.toUint8Array();
 | 
			
		||||
  const iv = data.iv.toUint8Array();
 | 
			
		||||
 | 
			
		||||
  const decrypted = await decryptData(iv, ciphertext, roomKey);
 | 
			
		||||
  const decrypted = await decryptData(iv, ciphertext, key);
 | 
			
		||||
  const decodedData = new TextDecoder("utf-8").decode(
 | 
			
		||||
    new Uint8Array(decrypted),
 | 
			
		||||
  );
 | 
			
		||||
@@ -179,86 +171,57 @@ export const saveFilesToFirebase = async ({
 | 
			
		||||
  return { savedFiles, erroredFiles };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const createFirebaseSceneDocument = async (
 | 
			
		||||
  firebase: ResolutionType<typeof loadFirestore>,
 | 
			
		||||
export const saveToFirebase = async (
 | 
			
		||||
  portal: Portal,
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
  roomKey: string,
 | 
			
		||||
) => {
 | 
			
		||||
  const { roomId, roomKey, socket } = portal;
 | 
			
		||||
  if (
 | 
			
		||||
    // if no room exists, consider the room saved because there's nothing we can
 | 
			
		||||
    // do at this point
 | 
			
		||||
    !roomId ||
 | 
			
		||||
    !roomKey ||
 | 
			
		||||
    !socket ||
 | 
			
		||||
    isSavedToFirebase(portal, elements)
 | 
			
		||||
  ) {
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const firebase = await loadFirestore();
 | 
			
		||||
  const sceneVersion = getSceneVersion(elements);
 | 
			
		||||
  const { ciphertext, iv } = await encryptElements(roomKey, elements);
 | 
			
		||||
  return {
 | 
			
		||||
 | 
			
		||||
  const nextDocData = {
 | 
			
		||||
    sceneVersion,
 | 
			
		||||
    ciphertext: firebase.firestore.Blob.fromUint8Array(
 | 
			
		||||
      new Uint8Array(ciphertext),
 | 
			
		||||
    ),
 | 
			
		||||
    iv: firebase.firestore.Blob.fromUint8Array(iv),
 | 
			
		||||
  } as FirebaseStoredScene;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const saveToFirebase = async (
 | 
			
		||||
  portal: Portal,
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
  appState: AppState,
 | 
			
		||||
) => {
 | 
			
		||||
  const { roomId, roomKey, socket } = portal;
 | 
			
		||||
  if (
 | 
			
		||||
    // bail if no room exists as there's nothing we can do at this point
 | 
			
		||||
    !roomId ||
 | 
			
		||||
    !roomKey ||
 | 
			
		||||
    !socket ||
 | 
			
		||||
    isSavedToFirebase(portal, elements)
 | 
			
		||||
  ) {
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const firebase = await loadFirestore();
 | 
			
		||||
  const firestore = firebase.firestore();
 | 
			
		||||
 | 
			
		||||
  const docRef = firestore.collection("scenes").doc(roomId);
 | 
			
		||||
 | 
			
		||||
  const savedData = await firestore.runTransaction(async (transaction) => {
 | 
			
		||||
    const snapshot = await transaction.get(docRef);
 | 
			
		||||
 | 
			
		||||
    if (!snapshot.exists) {
 | 
			
		||||
      const sceneDocument = await createFirebaseSceneDocument(
 | 
			
		||||
        firebase,
 | 
			
		||||
        elements,
 | 
			
		||||
        roomKey,
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      transaction.set(docRef, sceneDocument);
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        sceneVersion: sceneDocument.sceneVersion,
 | 
			
		||||
        reconciledElements: null,
 | 
			
		||||
      };
 | 
			
		||||
  const db = firebase.firestore();
 | 
			
		||||
  const docRef = db.collection("scenes").doc(roomId);
 | 
			
		||||
  const didUpdate = await db.runTransaction(async (transaction) => {
 | 
			
		||||
    const doc = await transaction.get(docRef);
 | 
			
		||||
    if (!doc.exists) {
 | 
			
		||||
      transaction.set(docRef, nextDocData);
 | 
			
		||||
      return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const prevDocData = snapshot.data() as FirebaseStoredScene;
 | 
			
		||||
    const prevElements = await decryptElements(prevDocData, roomKey);
 | 
			
		||||
    const prevDocData = doc.data() as FirebaseStoredScene;
 | 
			
		||||
    if (prevDocData.sceneVersion >= nextDocData.sceneVersion) {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const reconciledElements = reconcileElements(
 | 
			
		||||
      elements,
 | 
			
		||||
      prevElements,
 | 
			
		||||
      appState,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const sceneDocument = await createFirebaseSceneDocument(
 | 
			
		||||
      firebase,
 | 
			
		||||
      reconciledElements,
 | 
			
		||||
      roomKey,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    transaction.update(docRef, sceneDocument);
 | 
			
		||||
    return {
 | 
			
		||||
      reconciledElements,
 | 
			
		||||
      sceneVersion: sceneDocument.sceneVersion,
 | 
			
		||||
    };
 | 
			
		||||
    transaction.update(docRef, nextDocData);
 | 
			
		||||
    return true;
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  firebaseSceneVersionCache.set(socket, savedData.sceneVersion);
 | 
			
		||||
  if (didUpdate) {
 | 
			
		||||
    firebaseSceneVersionCache.set(socket, sceneVersion);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return savedData;
 | 
			
		||||
  return didUpdate;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const loadFromFirebase = async (
 | 
			
		||||
@@ -275,7 +238,10 @@ export const loadFromFirebase = async (
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
  const storedScene = doc.data() as FirebaseStoredScene;
 | 
			
		||||
  const elements = await decryptElements(storedScene, roomKey);
 | 
			
		||||
  const ciphertext = storedScene.ciphertext.toUint8Array();
 | 
			
		||||
  const iv = storedScene.iv.toUint8Array();
 | 
			
		||||
 | 
			
		||||
  const elements = await decryptElements(roomKey, iv, ciphertext);
 | 
			
		||||
 | 
			
		||||
  if (socket) {
 | 
			
		||||
    firebaseSceneVersionCache.set(socket, getSceneVersion(elements));
 | 
			
		||||
 
 | 
			
		||||
@@ -5,8 +5,8 @@ import {
 | 
			
		||||
  getDefaultAppState,
 | 
			
		||||
} from "../../appState";
 | 
			
		||||
import { clearElementsForLocalStorage } from "../../element";
 | 
			
		||||
import { updateBrowserStateVersion } from "./tabSync";
 | 
			
		||||
import { STORAGE_KEYS } from "../app_constants";
 | 
			
		||||
import { ImportedDataState } from "../../data/types";
 | 
			
		||||
 | 
			
		||||
export const saveUsernameToLocalStorage = (username: string) => {
 | 
			
		||||
  try {
 | 
			
		||||
@@ -34,6 +34,26 @@ export const importUsernameFromLocalStorage = (): string | null => {
 | 
			
		||||
  return null;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const saveToLocalStorage = (
 | 
			
		||||
  elements: readonly ExcalidrawElement[],
 | 
			
		||||
  appState: AppState,
 | 
			
		||||
) => {
 | 
			
		||||
  try {
 | 
			
		||||
    localStorage.setItem(
 | 
			
		||||
      STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
 | 
			
		||||
      JSON.stringify(clearElementsForLocalStorage(elements)),
 | 
			
		||||
    );
 | 
			
		||||
    localStorage.setItem(
 | 
			
		||||
      STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
 | 
			
		||||
      JSON.stringify(clearAppStateForLocalStorage(appState)),
 | 
			
		||||
    );
 | 
			
		||||
    updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
 | 
			
		||||
  } catch (error: any) {
 | 
			
		||||
    // Unable to access window.localStorage
 | 
			
		||||
    console.error(error);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const importFromLocalStorage = () => {
 | 
			
		||||
  let savedElements = null;
 | 
			
		||||
  let savedState = null;
 | 
			
		||||
@@ -103,13 +123,14 @@ export const getTotalStorageSize = () => {
 | 
			
		||||
 | 
			
		||||
export const getLibraryItemsFromStorage = () => {
 | 
			
		||||
  try {
 | 
			
		||||
    const libraryItems: ImportedDataState["libraryItems"] = JSON.parse(
 | 
			
		||||
      localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string,
 | 
			
		||||
    );
 | 
			
		||||
    const libraryItems =
 | 
			
		||||
      JSON.parse(
 | 
			
		||||
        localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string,
 | 
			
		||||
      ) || [];
 | 
			
		||||
 | 
			
		||||
    return libraryItems || [];
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error(error);
 | 
			
		||||
    return libraryItems;
 | 
			
		||||
  } catch (e) {
 | 
			
		||||
    console.error(e);
 | 
			
		||||
    return [];
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -26,9 +26,3 @@
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
.excalidraw-app.is-collaborating {
 | 
			
		||||
  [data-testid="clear-canvas-button"] {
 | 
			
		||||
    visibility: hidden;
 | 
			
		||||
    pointer-events: none;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -12,6 +12,7 @@ import {
 | 
			
		||||
  VERSION_TIMEOUT,
 | 
			
		||||
} from "../constants";
 | 
			
		||||
import { loadFromBlob } from "../data/blob";
 | 
			
		||||
import { ImportedDataState } from "../data/types";
 | 
			
		||||
import {
 | 
			
		||||
  ExcalidrawElement,
 | 
			
		||||
  FileId,
 | 
			
		||||
@@ -19,8 +20,7 @@ import {
 | 
			
		||||
} from "../element/types";
 | 
			
		||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
 | 
			
		||||
import { Language, t } from "../i18n";
 | 
			
		||||
import {
 | 
			
		||||
  Excalidraw,
 | 
			
		||||
import Excalidraw, {
 | 
			
		||||
  defaultLang,
 | 
			
		||||
  languages,
 | 
			
		||||
} from "../packages/excalidraw/index";
 | 
			
		||||
@@ -28,13 +28,12 @@ import {
 | 
			
		||||
  AppState,
 | 
			
		||||
  LibraryItems,
 | 
			
		||||
  ExcalidrawImperativeAPI,
 | 
			
		||||
  BinaryFileData,
 | 
			
		||||
  BinaryFiles,
 | 
			
		||||
  ExcalidrawInitialDataState,
 | 
			
		||||
} from "../types";
 | 
			
		||||
import {
 | 
			
		||||
  debounce,
 | 
			
		||||
  getVersion,
 | 
			
		||||
  getFrame,
 | 
			
		||||
  isTestEnv,
 | 
			
		||||
  preventUnload,
 | 
			
		||||
  ResolvablePromise,
 | 
			
		||||
@@ -42,6 +41,7 @@ import {
 | 
			
		||||
} from "../utils";
 | 
			
		||||
import {
 | 
			
		||||
  FIREBASE_STORAGE_PREFIXES,
 | 
			
		||||
  SAVE_TO_LOCAL_STORAGE_TIMEOUT,
 | 
			
		||||
  STORAGE_KEYS,
 | 
			
		||||
  SYNC_BROWSER_TABS_TIMEOUT,
 | 
			
		||||
} from "./app_constants";
 | 
			
		||||
@@ -56,6 +56,7 @@ import {
 | 
			
		||||
  getLibraryItemsFromStorage,
 | 
			
		||||
  importFromLocalStorage,
 | 
			
		||||
  importUsernameFromLocalStorage,
 | 
			
		||||
  saveToLocalStorage,
 | 
			
		||||
} from "./data/localStorage";
 | 
			
		||||
import CustomStats from "./CustomStats";
 | 
			
		||||
import { restoreAppState, RestoredDataState } from "../data/restore";
 | 
			
		||||
@@ -65,13 +66,72 @@ import { shield } from "../components/icons";
 | 
			
		||||
import "./index.scss";
 | 
			
		||||
import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus";
 | 
			
		||||
 | 
			
		||||
import { updateStaleImageStatuses } from "./data/FileManager";
 | 
			
		||||
import { getMany, set, del, keys, createStore } from "idb-keyval";
 | 
			
		||||
import { FileManager, updateStaleImageStatuses } from "./data/FileManager";
 | 
			
		||||
import { newElementWith } from "../element/mutateElement";
 | 
			
		||||
import { isInitializedImageElement } from "../element/typeChecks";
 | 
			
		||||
import { loadFilesFromFirebase } from "./data/firebase";
 | 
			
		||||
import { LocalData } from "./data/LocalData";
 | 
			
		||||
import { isBrowserStorageStateNewer } from "./data/tabSync";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import {
 | 
			
		||||
  isBrowserStorageStateNewer,
 | 
			
		||||
  updateBrowserStateVersion,
 | 
			
		||||
} from "./data/tabSync";
 | 
			
		||||
 | 
			
		||||
const filesStore = createStore("files-db", "files-store");
 | 
			
		||||
 | 
			
		||||
const clearObsoleteFilesFromIndexedDB = async (opts: {
 | 
			
		||||
  currentFileIds: FileId[];
 | 
			
		||||
}) => {
 | 
			
		||||
  const allIds = await keys(filesStore);
 | 
			
		||||
  for (const id of allIds) {
 | 
			
		||||
    if (!opts.currentFileIds.includes(id as FileId)) {
 | 
			
		||||
      del(id, filesStore);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const localFileStorage = new FileManager({
 | 
			
		||||
  getFiles(ids) {
 | 
			
		||||
    return getMany(ids, filesStore).then(
 | 
			
		||||
      (filesData: (BinaryFileData | undefined)[]) => {
 | 
			
		||||
        const loadedFiles: BinaryFileData[] = [];
 | 
			
		||||
        const erroredFiles = new Map<FileId, true>();
 | 
			
		||||
        filesData.forEach((data, index) => {
 | 
			
		||||
          const id = ids[index];
 | 
			
		||||
          if (data) {
 | 
			
		||||
            loadedFiles.push(data);
 | 
			
		||||
          } else {
 | 
			
		||||
            erroredFiles.set(id, true);
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        return { loadedFiles, erroredFiles };
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
  },
 | 
			
		||||
  async saveFiles({ addedFiles }) {
 | 
			
		||||
    const savedFiles = new Map<FileId, true>();
 | 
			
		||||
    const erroredFiles = new Map<FileId, true>();
 | 
			
		||||
 | 
			
		||||
    // before we use `storage` event synchronization, let's update the flag
 | 
			
		||||
    // optimistically. Hopefully nothing fails, and an IDB read executed
 | 
			
		||||
    // before an IDB write finishes will read the latest value.
 | 
			
		||||
    updateBrowserStateVersion(STORAGE_KEYS.VERSION_FILES);
 | 
			
		||||
 | 
			
		||||
    await Promise.all(
 | 
			
		||||
      [...addedFiles].map(async ([id, fileData]) => {
 | 
			
		||||
        try {
 | 
			
		||||
          await set(id, fileData, filesStore);
 | 
			
		||||
          savedFiles.set(id, true);
 | 
			
		||||
        } catch (error: any) {
 | 
			
		||||
          console.error(error);
 | 
			
		||||
          erroredFiles.set(id, true);
 | 
			
		||||
        }
 | 
			
		||||
      }),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return { savedFiles, erroredFiles };
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const languageDetector = new LanguageDetector();
 | 
			
		||||
languageDetector.init({
 | 
			
		||||
@@ -82,10 +142,32 @@ languageDetector.init({
 | 
			
		||||
  checkWhitelist: false,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const saveDebounced = debounce(
 | 
			
		||||
  async (
 | 
			
		||||
    elements: readonly ExcalidrawElement[],
 | 
			
		||||
    appState: AppState,
 | 
			
		||||
    files: BinaryFiles,
 | 
			
		||||
    onFilesSaved: () => void,
 | 
			
		||||
  ) => {
 | 
			
		||||
    saveToLocalStorage(elements, appState);
 | 
			
		||||
 | 
			
		||||
    await localFileStorage.saveFiles({
 | 
			
		||||
      elements,
 | 
			
		||||
      files,
 | 
			
		||||
    });
 | 
			
		||||
    onFilesSaved();
 | 
			
		||||
  },
 | 
			
		||||
  SAVE_TO_LOCAL_STORAGE_TIMEOUT,
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const onBlur = () => {
 | 
			
		||||
  saveDebounced.flush();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const initializeScene = async (opts: {
 | 
			
		||||
  collabAPI: CollabAPI;
 | 
			
		||||
}): Promise<
 | 
			
		||||
  { scene: ExcalidrawInitialDataState | null } & (
 | 
			
		||||
  { scene: ImportedDataState | null } & (
 | 
			
		||||
    | { isExternalScene: true; id: string; key: string }
 | 
			
		||||
    | { isExternalScene: false; id?: null; key?: null }
 | 
			
		||||
  )
 | 
			
		||||
@@ -212,15 +294,14 @@ const ExcalidrawWrapper = () => {
 | 
			
		||||
  // ---------------------------------------------------------------------------
 | 
			
		||||
 | 
			
		||||
  const initialStatePromiseRef = useRef<{
 | 
			
		||||
    promise: ResolvablePromise<ExcalidrawInitialDataState | null>;
 | 
			
		||||
    promise: ResolvablePromise<ImportedDataState | null>;
 | 
			
		||||
  }>({ promise: null! });
 | 
			
		||||
  if (!initialStatePromiseRef.current.promise) {
 | 
			
		||||
    initialStatePromiseRef.current.promise =
 | 
			
		||||
      resolvablePromise<ExcalidrawInitialDataState | null>();
 | 
			
		||||
      resolvablePromise<ImportedDataState | null>();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    trackEvent("load", "frame", getFrame());
 | 
			
		||||
    // Delayed so that the app has a time to load the latest SW
 | 
			
		||||
    setTimeout(() => {
 | 
			
		||||
      trackEvent("load", "version", getVersion());
 | 
			
		||||
@@ -283,7 +364,7 @@ const ExcalidrawWrapper = () => {
 | 
			
		||||
          });
 | 
			
		||||
        } else if (isInitialLoad) {
 | 
			
		||||
          if (fileIds.length) {
 | 
			
		||||
            LocalData.fileStorage
 | 
			
		||||
            localFileStorage
 | 
			
		||||
              .getFiles(fileIds)
 | 
			
		||||
              .then(({ loadedFiles, erroredFiles }) => {
 | 
			
		||||
                if (loadedFiles.length) {
 | 
			
		||||
@@ -298,7 +379,7 @@ const ExcalidrawWrapper = () => {
 | 
			
		||||
          }
 | 
			
		||||
          // on fresh load, clear unused files from IDB (from previous
 | 
			
		||||
          // session)
 | 
			
		||||
          LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds });
 | 
			
		||||
          clearObsoleteFilesFromIndexedDB({ currentFileIds: fileIds });
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
@@ -375,7 +456,7 @@ const ExcalidrawWrapper = () => {
 | 
			
		||||
              return acc;
 | 
			
		||||
            }, [] as FileId[]) || [];
 | 
			
		||||
          if (fileIds.length) {
 | 
			
		||||
            LocalData.fileStorage
 | 
			
		||||
            localFileStorage
 | 
			
		||||
              .getFiles(fileIds)
 | 
			
		||||
              .then(({ loadedFiles, erroredFiles }) => {
 | 
			
		||||
                if (loadedFiles.length) {
 | 
			
		||||
@@ -392,50 +473,28 @@ const ExcalidrawWrapper = () => {
 | 
			
		||||
      }
 | 
			
		||||
    }, SYNC_BROWSER_TABS_TIMEOUT);
 | 
			
		||||
 | 
			
		||||
    const onUnload = () => {
 | 
			
		||||
      LocalData.flushSave();
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const visibilityChange = (event: FocusEvent | Event) => {
 | 
			
		||||
      if (event.type === EVENT.BLUR || document.hidden) {
 | 
			
		||||
        LocalData.flushSave();
 | 
			
		||||
      }
 | 
			
		||||
      if (
 | 
			
		||||
        event.type === EVENT.VISIBILITY_CHANGE ||
 | 
			
		||||
        event.type === EVENT.FOCUS
 | 
			
		||||
      ) {
 | 
			
		||||
        syncData();
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    window.addEventListener(EVENT.HASHCHANGE, onHashChange, false);
 | 
			
		||||
    window.addEventListener(EVENT.UNLOAD, onUnload, false);
 | 
			
		||||
    window.addEventListener(EVENT.BLUR, visibilityChange, false);
 | 
			
		||||
    document.addEventListener(EVENT.VISIBILITY_CHANGE, visibilityChange, false);
 | 
			
		||||
    window.addEventListener(EVENT.FOCUS, visibilityChange, false);
 | 
			
		||||
    window.addEventListener(EVENT.UNLOAD, onBlur, false);
 | 
			
		||||
    window.addEventListener(EVENT.BLUR, onBlur, false);
 | 
			
		||||
    document.addEventListener(EVENT.VISIBILITY_CHANGE, syncData, false);
 | 
			
		||||
    window.addEventListener(EVENT.FOCUS, syncData, false);
 | 
			
		||||
    return () => {
 | 
			
		||||
      window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false);
 | 
			
		||||
      window.removeEventListener(EVENT.UNLOAD, onUnload, false);
 | 
			
		||||
      window.removeEventListener(EVENT.BLUR, visibilityChange, false);
 | 
			
		||||
      window.removeEventListener(EVENT.FOCUS, visibilityChange, false);
 | 
			
		||||
      document.removeEventListener(
 | 
			
		||||
        EVENT.VISIBILITY_CHANGE,
 | 
			
		||||
        visibilityChange,
 | 
			
		||||
        false,
 | 
			
		||||
      );
 | 
			
		||||
      window.removeEventListener(EVENT.UNLOAD, onBlur, false);
 | 
			
		||||
      window.removeEventListener(EVENT.BLUR, onBlur, false);
 | 
			
		||||
      window.removeEventListener(EVENT.FOCUS, syncData, false);
 | 
			
		||||
      document.removeEventListener(EVENT.VISIBILITY_CHANGE, syncData, false);
 | 
			
		||||
      clearTimeout(titleTimeout);
 | 
			
		||||
    };
 | 
			
		||||
  }, [collabAPI, excalidrawAPI]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const unloadHandler = (event: BeforeUnloadEvent) => {
 | 
			
		||||
      LocalData.flushSave();
 | 
			
		||||
      saveDebounced.flush();
 | 
			
		||||
 | 
			
		||||
      if (
 | 
			
		||||
        excalidrawAPI &&
 | 
			
		||||
        LocalData.fileStorage.shouldPreventUnload(
 | 
			
		||||
          excalidrawAPI.getSceneElements(),
 | 
			
		||||
        )
 | 
			
		||||
        localFileStorage.shouldPreventUnload(excalidrawAPI.getSceneElements())
 | 
			
		||||
      ) {
 | 
			
		||||
        preventUnload(event);
 | 
			
		||||
      }
 | 
			
		||||
@@ -456,13 +515,9 @@ const ExcalidrawWrapper = () => {
 | 
			
		||||
    files: BinaryFiles,
 | 
			
		||||
  ) => {
 | 
			
		||||
    if (collabAPI?.isCollaborating()) {
 | 
			
		||||
      collabAPI.syncElements(elements);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // this check is redundant, but since this is a hot path, it's best
 | 
			
		||||
    // not to evaludate the nested expression every time
 | 
			
		||||
    if (!LocalData.isSavePaused()) {
 | 
			
		||||
      LocalData.save(elements, appState, files, () => {
 | 
			
		||||
      collabAPI.broadcastElements(elements);
 | 
			
		||||
    } else {
 | 
			
		||||
      saveDebounced(elements, appState, files, () => {
 | 
			
		||||
        if (excalidrawAPI) {
 | 
			
		||||
          let didChange = false;
 | 
			
		||||
 | 
			
		||||
@@ -470,9 +525,7 @@ const ExcalidrawWrapper = () => {
 | 
			
		||||
          const elements = excalidrawAPI
 | 
			
		||||
            .getSceneElementsIncludingDeleted()
 | 
			
		||||
            .map((element) => {
 | 
			
		||||
              if (
 | 
			
		||||
                LocalData.fileStorage.shouldUpdateImageElementStatus(element)
 | 
			
		||||
              ) {
 | 
			
		||||
              if (localFileStorage.shouldUpdateImageElementStatus(element)) {
 | 
			
		||||
                didChange = true;
 | 
			
		||||
                const newEl = newElementWith(element, { status: "saved" });
 | 
			
		||||
                if (pendingImageElement === element) {
 | 
			
		||||
@@ -632,16 +685,11 @@ const ExcalidrawWrapper = () => {
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onRoomClose = useCallback(() => {
 | 
			
		||||
    LocalData.fileStorage.reset();
 | 
			
		||||
    localFileStorage.reset();
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      style={{ height: "100%" }}
 | 
			
		||||
      className={clsx("excalidraw-app", {
 | 
			
		||||
        "is-collaborating": collabAPI?.isCollaborating(),
 | 
			
		||||
      })}
 | 
			
		||||
    >
 | 
			
		||||
    <>
 | 
			
		||||
      <Excalidraw
 | 
			
		||||
        ref={excalidrawRefCallback}
 | 
			
		||||
        onChange={onChange}
 | 
			
		||||
@@ -693,7 +741,7 @@ const ExcalidrawWrapper = () => {
 | 
			
		||||
          onClose={() => setErrorMessage("")}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										3
									
								
								src/global.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								src/global.d.ts
									
									
									
									
										vendored
									
									
								
							@@ -13,7 +13,6 @@ interface Window {
 | 
			
		||||
  ClipboardItem: any;
 | 
			
		||||
  __EXCALIDRAW_SHA__: string | undefined;
 | 
			
		||||
  EXCALIDRAW_ASSET_PATH: string | undefined;
 | 
			
		||||
  EXCALIDRAW_EXPORT_SOURCE: string;
 | 
			
		||||
  gtag: Function;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -35,8 +34,6 @@ type Mutable<T> = {
 | 
			
		||||
  -readonly [P in keyof T]: T[P];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type Merge<M, N> = Omit<M, keyof N> & N;
 | 
			
		||||
 | 
			
		||||
/** utility type to assert that the second type is a subtype of the first type.
 | 
			
		||||
 * Returns the subtype. */
 | 
			
		||||
type SubtypeOf<Supertype, Subtype extends Supertype> = Subtype;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +0,0 @@
 | 
			
		||||
import { unstable_createStore } from "jotai";
 | 
			
		||||
 | 
			
		||||
export const jotaiScope = Symbol();
 | 
			
		||||
export const jotaiStore = unstable_createStore();
 | 
			
		||||
@@ -9,7 +9,6 @@
 | 
			
		||||
    "copy": "نسخ",
 | 
			
		||||
    "copyAsPng": "نسخ إلى الحافظة بصيغة PNG",
 | 
			
		||||
    "copyAsSvg": "نسخ إلى الحافظة بصيغة SVG",
 | 
			
		||||
    "copyText": "نسخ إلى الحافظة كنص",
 | 
			
		||||
    "bringForward": "جلب للأمام",
 | 
			
		||||
    "sendToBack": "أرسل للخلف",
 | 
			
		||||
    "bringToFront": "أحضر للأمام",
 | 
			
		||||
@@ -22,7 +21,7 @@
 | 
			
		||||
    "fill": "التعبئة",
 | 
			
		||||
    "strokeWidth": "سُمك الخط",
 | 
			
		||||
    "strokeStyle": "نمط الخط",
 | 
			
		||||
    "strokeStyle_solid": "متصل",
 | 
			
		||||
    "strokeStyle_solid": "كامل",
 | 
			
		||||
    "strokeStyle_dashed": "متقطع",
 | 
			
		||||
    "strokeStyle_dotted": "منقط",
 | 
			
		||||
    "sloppiness": "الإمالة",
 | 
			
		||||
@@ -108,17 +107,10 @@
 | 
			
		||||
    "decreaseFontSize": "تصغير حجم الخط",
 | 
			
		||||
    "increaseFontSize": "تكبير حجم الخط",
 | 
			
		||||
    "unbindText": "",
 | 
			
		||||
    "bindText": "",
 | 
			
		||||
    "link": {
 | 
			
		||||
      "edit": "تعديل الرابط",
 | 
			
		||||
      "create": "إنشاء رابط",
 | 
			
		||||
      "label": "رابط"
 | 
			
		||||
    },
 | 
			
		||||
    "elementLock": {
 | 
			
		||||
      "lock": "",
 | 
			
		||||
      "unlock": "",
 | 
			
		||||
      "lockAll": "",
 | 
			
		||||
      "unlockAll": ""
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "buttons": {
 | 
			
		||||
@@ -167,7 +159,7 @@
 | 
			
		||||
    "couldNotLoadInvalidFile": "تعذر التحميل، الملف غير صالح",
 | 
			
		||||
    "importBackendFailed": "فشل الاستيراد من الخادوم.",
 | 
			
		||||
    "cannotExportEmptyCanvas": "لا يمكن تصدير لوحة فارغة.",
 | 
			
		||||
    "couldNotCopyToClipboard": "",
 | 
			
		||||
    "couldNotCopyToClipboard": "تعذر النسخ إلى الحافظة. حاول استخدام متصفح Chrome.",
 | 
			
		||||
    "decryptFailed": "تعذر فك تشفير البيانات.",
 | 
			
		||||
    "uploadedSecurly": "تم تأمين التحميل بتشفير النهاية إلى النهاية، مما يعني أن خادوم Excalidraw والأطراف الثالثة لا يمكنها قراءة المحتوى.",
 | 
			
		||||
    "loadSceneOverridePrompt": "تحميل الرسم الخارجي سيحل محل المحتوى الموجود لديك. هل ترغب في المتابعة؟",
 | 
			
		||||
@@ -180,7 +172,7 @@
 | 
			
		||||
    "cannotRestoreFromImage": "تعذر استعادة المشهد من ملف الصورة",
 | 
			
		||||
    "invalidSceneUrl": "تعذر استيراد المشهد من عنوان URL المتوفر. إما أنها مشوهة، أو لا تحتوي على بيانات Excalidraw JSON صالحة.",
 | 
			
		||||
    "resetLibrary": "هذا سوف يمسح مكتبتك. هل أنت متأكد؟",
 | 
			
		||||
    "removeItemsFromsLibrary": "حذف {{count}} عنصر (عناصر) من المكتبة؟",
 | 
			
		||||
    "removeItemsFromsLibrary": "",
 | 
			
		||||
    "invalidEncryptionKey": "مفتاح التشفير يجب أن يكون من 22 حرفاً. التعاون المباشر معطل."
 | 
			
		||||
  },
 | 
			
		||||
  "errors": {
 | 
			
		||||
@@ -204,8 +196,7 @@
 | 
			
		||||
    "library": "مكتبة",
 | 
			
		||||
    "lock": "الحفاظ على أداة التحديد نشطة بعد الرسم",
 | 
			
		||||
    "penMode": "",
 | 
			
		||||
    "link": "",
 | 
			
		||||
    "eraser": "ممحاة"
 | 
			
		||||
    "link": ""
 | 
			
		||||
  },
 | 
			
		||||
  "headings": {
 | 
			
		||||
    "canvasActions": "إجراءات اللوحة",
 | 
			
		||||
@@ -230,8 +221,7 @@
 | 
			
		||||
    "placeImage": "",
 | 
			
		||||
    "publishLibrary": "نشر مكتبتك",
 | 
			
		||||
    "bindTextToElement": "",
 | 
			
		||||
    "deepBoxSelect": "",
 | 
			
		||||
    "eraserRevert": ""
 | 
			
		||||
    "deepBoxSelect": ""
 | 
			
		||||
  },
 | 
			
		||||
  "canvasError": {
 | 
			
		||||
    "cannotShowPreview": "تعذر عرض المعاينة",
 | 
			
		||||
@@ -291,15 +281,14 @@
 | 
			
		||||
    "howto": "اتبع التعليمات",
 | 
			
		||||
    "or": "أو",
 | 
			
		||||
    "preventBinding": "منع ارتبط السهم",
 | 
			
		||||
    "tools": "الأدوات",
 | 
			
		||||
    "shapes": "أشكال",
 | 
			
		||||
    "shortcuts": "اختصارات لوحة المفاتيح",
 | 
			
		||||
    "textFinish": "إنهاء التعديل (محرر النص)",
 | 
			
		||||
    "textNewLine": "أضف سطر جديد (محرر نص)",
 | 
			
		||||
    "title": "المساعدة",
 | 
			
		||||
    "view": "عرض",
 | 
			
		||||
    "zoomToFit": "تكبير للملائمة",
 | 
			
		||||
    "zoomToSelection": "تكبير للعنصر المحدد",
 | 
			
		||||
    "toggleElementLock": ""
 | 
			
		||||
    "zoomToSelection": "تكبير للعنصر المحدد"
 | 
			
		||||
  },
 | 
			
		||||
  "clearCanvasDialog": {
 | 
			
		||||
    "title": "مسح اللوحة"
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,6 @@
 | 
			
		||||
    "copy": "Копирай",
 | 
			
		||||
    "copyAsPng": "Копиране в клипборда",
 | 
			
		||||
    "copyAsSvg": "Копирано в клипборда като SVG",
 | 
			
		||||
    "copyText": "",
 | 
			
		||||
    "bringForward": "Преместване напред",
 | 
			
		||||
    "sendToBack": "Изнасяне назад",
 | 
			
		||||
    "bringToFront": "Изнасяне отпред",
 | 
			
		||||
@@ -108,17 +107,10 @@
 | 
			
		||||
    "decreaseFontSize": "",
 | 
			
		||||
    "increaseFontSize": "",
 | 
			
		||||
    "unbindText": "",
 | 
			
		||||
    "bindText": "",
 | 
			
		||||
    "link": {
 | 
			
		||||
      "edit": "",
 | 
			
		||||
      "create": "",
 | 
			
		||||
      "label": ""
 | 
			
		||||
    },
 | 
			
		||||
    "elementLock": {
 | 
			
		||||
      "lock": "",
 | 
			
		||||
      "unlock": "",
 | 
			
		||||
      "lockAll": "",
 | 
			
		||||
      "unlockAll": ""
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "buttons": {
 | 
			
		||||
@@ -167,7 +159,7 @@
 | 
			
		||||
    "couldNotLoadInvalidFile": "Невалиден файл не може да се зареди",
 | 
			
		||||
    "importBackendFailed": "Импортирането от бекенд не беше успешно.",
 | 
			
		||||
    "cannotExportEmptyCanvas": "Не може да се експортира празно платно.",
 | 
			
		||||
    "couldNotCopyToClipboard": "",
 | 
			
		||||
    "couldNotCopyToClipboard": "Неуспешно копиране в клипборда. Опитайте да използвате браузъра Chrome.",
 | 
			
		||||
    "decryptFailed": "Данните не можаха да се дешифрират.",
 | 
			
		||||
    "uploadedSecurly": "Качването е защитено с криптиране от край до край, което означава, че сървърът Excalidraw и трети страни не могат да четат съдържанието.",
 | 
			
		||||
    "loadSceneOverridePrompt": "Зареждането на външна рисунка ще презапише настоящото ви съдържание. Желаете ли да продължите?",
 | 
			
		||||
@@ -204,8 +196,7 @@
 | 
			
		||||
    "library": "Библиотека",
 | 
			
		||||
    "lock": "Поддържайте избрания инструмент активен след рисуване",
 | 
			
		||||
    "penMode": "",
 | 
			
		||||
    "link": "",
 | 
			
		||||
    "eraser": ""
 | 
			
		||||
    "link": ""
 | 
			
		||||
  },
 | 
			
		||||
  "headings": {
 | 
			
		||||
    "canvasActions": "Действия по платното",
 | 
			
		||||
@@ -230,8 +221,7 @@
 | 
			
		||||
    "placeImage": "",
 | 
			
		||||
    "publishLibrary": "",
 | 
			
		||||
    "bindTextToElement": "Натиснете Enter, за да добавите",
 | 
			
		||||
    "deepBoxSelect": "",
 | 
			
		||||
    "eraserRevert": ""
 | 
			
		||||
    "deepBoxSelect": ""
 | 
			
		||||
  },
 | 
			
		||||
  "canvasError": {
 | 
			
		||||
    "cannotShowPreview": "Невъзможност за показване на preview",
 | 
			
		||||
@@ -291,15 +281,14 @@
 | 
			
		||||
    "howto": "Следвайте нашите ръководства",
 | 
			
		||||
    "or": "или",
 | 
			
		||||
    "preventBinding": "Спри прилепяне на стрелките",
 | 
			
		||||
    "tools": "",
 | 
			
		||||
    "shapes": "Фигури",
 | 
			
		||||
    "shortcuts": "Клавиши за бърз достъп",
 | 
			
		||||
    "textFinish": "",
 | 
			
		||||
    "textNewLine": "",
 | 
			
		||||
    "title": "Помощ",
 | 
			
		||||
    "view": "Преглед",
 | 
			
		||||
    "zoomToFit": "Приближи докато се виждат всички елементи",
 | 
			
		||||
    "zoomToSelection": "Приближи селекцията",
 | 
			
		||||
    "toggleElementLock": ""
 | 
			
		||||
    "zoomToSelection": "Приближи селекцията"
 | 
			
		||||
  },
 | 
			
		||||
  "clearCanvasDialog": {
 | 
			
		||||
    "title": ""
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,6 @@
 | 
			
		||||
    "copy": "",
 | 
			
		||||
    "copyAsPng": "",
 | 
			
		||||
    "copyAsSvg": "",
 | 
			
		||||
    "copyText": "",
 | 
			
		||||
    "bringForward": "",
 | 
			
		||||
    "sendToBack": "",
 | 
			
		||||
    "bringToFront": "",
 | 
			
		||||
@@ -108,17 +107,10 @@
 | 
			
		||||
    "decreaseFontSize": "",
 | 
			
		||||
    "increaseFontSize": "",
 | 
			
		||||
    "unbindText": "",
 | 
			
		||||
    "bindText": "",
 | 
			
		||||
    "link": {
 | 
			
		||||
      "edit": "",
 | 
			
		||||
      "create": "",
 | 
			
		||||
      "label": ""
 | 
			
		||||
    },
 | 
			
		||||
    "elementLock": {
 | 
			
		||||
      "lock": "",
 | 
			
		||||
      "unlock": "",
 | 
			
		||||
      "lockAll": "",
 | 
			
		||||
      "unlockAll": ""
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "buttons": {
 | 
			
		||||
@@ -204,8 +196,7 @@
 | 
			
		||||
    "library": "",
 | 
			
		||||
    "lock": "",
 | 
			
		||||
    "penMode": "",
 | 
			
		||||
    "link": "",
 | 
			
		||||
    "eraser": ""
 | 
			
		||||
    "link": ""
 | 
			
		||||
  },
 | 
			
		||||
  "headings": {
 | 
			
		||||
    "canvasActions": "",
 | 
			
		||||
@@ -230,8 +221,7 @@
 | 
			
		||||
    "placeImage": "",
 | 
			
		||||
    "publishLibrary": "",
 | 
			
		||||
    "bindTextToElement": "",
 | 
			
		||||
    "deepBoxSelect": "",
 | 
			
		||||
    "eraserRevert": ""
 | 
			
		||||
    "deepBoxSelect": ""
 | 
			
		||||
  },
 | 
			
		||||
  "canvasError": {
 | 
			
		||||
    "cannotShowPreview": "",
 | 
			
		||||
@@ -291,15 +281,14 @@
 | 
			
		||||
    "howto": "",
 | 
			
		||||
    "or": "",
 | 
			
		||||
    "preventBinding": "",
 | 
			
		||||
    "tools": "",
 | 
			
		||||
    "shapes": "",
 | 
			
		||||
    "shortcuts": "",
 | 
			
		||||
    "textFinish": "",
 | 
			
		||||
    "textNewLine": "",
 | 
			
		||||
    "title": "",
 | 
			
		||||
    "view": "",
 | 
			
		||||
    "zoomToFit": "",
 | 
			
		||||
    "zoomToSelection": "",
 | 
			
		||||
    "toggleElementLock": ""
 | 
			
		||||
    "zoomToSelection": ""
 | 
			
		||||
  },
 | 
			
		||||
  "clearCanvasDialog": {
 | 
			
		||||
    "title": ""
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,6 @@
 | 
			
		||||
    "copy": "Copia",
 | 
			
		||||
    "copyAsPng": "Copia al porta-retalls com a PNG",
 | 
			
		||||
    "copyAsSvg": "Copia al porta-retalls com a SVG",
 | 
			
		||||
    "copyText": "",
 | 
			
		||||
    "bringForward": "Porta endavant",
 | 
			
		||||
    "sendToBack": "Envia enrere",
 | 
			
		||||
    "bringToFront": "Porta al davant",
 | 
			
		||||
@@ -108,17 +107,10 @@
 | 
			
		||||
    "decreaseFontSize": "Redueix la mida de la lletra",
 | 
			
		||||
    "increaseFontSize": "Augmenta la mida de la lletra",
 | 
			
		||||
    "unbindText": "Desvincular el text",
 | 
			
		||||
    "bindText": "",
 | 
			
		||||
    "link": {
 | 
			
		||||
      "edit": "Edita l'enllaç",
 | 
			
		||||
      "create": "Crea un enllaç",
 | 
			
		||||
      "label": "Enllaç"
 | 
			
		||||
    },
 | 
			
		||||
    "elementLock": {
 | 
			
		||||
      "lock": "",
 | 
			
		||||
      "unlock": "",
 | 
			
		||||
      "lockAll": "",
 | 
			
		||||
      "unlockAll": ""
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "buttons": {
 | 
			
		||||
@@ -167,7 +159,7 @@
 | 
			
		||||
    "couldNotLoadInvalidFile": "No s'ha pogut carregar un fitxer no vàlid",
 | 
			
		||||
    "importBackendFailed": "Importació fallida.",
 | 
			
		||||
    "cannotExportEmptyCanvas": "No es pot exportar un llenç buit.",
 | 
			
		||||
    "couldNotCopyToClipboard": "",
 | 
			
		||||
    "couldNotCopyToClipboard": "No s'ha pogut copiar al porta-retalls. Intentar amb el navegador Google Chrome.",
 | 
			
		||||
    "decryptFailed": "No s'ha pogut desencriptar.",
 | 
			
		||||
    "uploadedSecurly": "La càrrega s'ha assegurat amb xifratge punta a punta, cosa que significa que el servidor Excalidraw i tercers no poden llegir el contingut.",
 | 
			
		||||
    "loadSceneOverridePrompt": "Si carregas aquest dibuix extern, substituirá el que tens. Vols continuar?",
 | 
			
		||||
@@ -204,8 +196,7 @@
 | 
			
		||||
    "library": "Biblioteca",
 | 
			
		||||
    "lock": "Mantenir activa l'eina seleccionada desprès de dibuixar",
 | 
			
		||||
    "penMode": "Evita el zoom i accepta solament el dibuix lliure amb bolígraf",
 | 
			
		||||
    "link": "Afegeix / actualitza l'enllaç per a la forma seleccionada",
 | 
			
		||||
    "eraser": ""
 | 
			
		||||
    "link": "Afegeix / actualitza l'enllaç per a la forma seleccionada"
 | 
			
		||||
  },
 | 
			
		||||
  "headings": {
 | 
			
		||||
    "canvasActions": "Accions del llenç",
 | 
			
		||||
@@ -230,8 +221,7 @@
 | 
			
		||||
    "placeImage": "Feu clic per a col·locar la imatge o clic i arrossegar per a establir-ne la mida manualment",
 | 
			
		||||
    "publishLibrary": "Publiqueu la vostra pròpia llibreria",
 | 
			
		||||
    "bindTextToElement": "Premeu enter per a afegir-hi text",
 | 
			
		||||
    "deepBoxSelect": "Manteniu CtrlOrCmd per a selecció profunda, i per a evitar l'arrossegament",
 | 
			
		||||
    "eraserRevert": ""
 | 
			
		||||
    "deepBoxSelect": "Manteniu CtrlOrCmd per a selecció profunda, i per a evitar l'arrossegament"
 | 
			
		||||
  },
 | 
			
		||||
  "canvasError": {
 | 
			
		||||
    "cannotShowPreview": "No es pot mostrar la previsualització",
 | 
			
		||||
@@ -291,15 +281,14 @@
 | 
			
		||||
    "howto": "Seguiu les nostres guies",
 | 
			
		||||
    "or": "o",
 | 
			
		||||
    "preventBinding": "Prevenir vinculació de la fletxa",
 | 
			
		||||
    "tools": "",
 | 
			
		||||
    "shapes": "Formes",
 | 
			
		||||
    "shortcuts": "Dreceres de teclat",
 | 
			
		||||
    "textFinish": "Finalitza l'edició (editor de text)",
 | 
			
		||||
    "textNewLine": "Afegeix una línia nova (editor de text)",
 | 
			
		||||
    "title": "Ajuda",
 | 
			
		||||
    "view": "Visualització",
 | 
			
		||||
    "zoomToFit": "Zoom per veure tots els elements",
 | 
			
		||||
    "zoomToSelection": "Zoom per veure la selecció",
 | 
			
		||||
    "toggleElementLock": ""
 | 
			
		||||
    "zoomToSelection": "Zoom per veure la selecció"
 | 
			
		||||
  },
 | 
			
		||||
  "clearCanvasDialog": {
 | 
			
		||||
    "title": "Neteja el llenç"
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,6 @@
 | 
			
		||||
    "copy": "Kopírovat",
 | 
			
		||||
    "copyAsPng": "Zkopírovat do schránky jako PNG",
 | 
			
		||||
    "copyAsSvg": "Zkopírovat do schránky jako SVG",
 | 
			
		||||
    "copyText": "",
 | 
			
		||||
    "bringForward": "Přenést blíž",
 | 
			
		||||
    "sendToBack": "Přenést do pozadí",
 | 
			
		||||
    "bringToFront": "Přenést do popředí",
 | 
			
		||||
@@ -108,17 +107,10 @@
 | 
			
		||||
    "decreaseFontSize": "",
 | 
			
		||||
    "increaseFontSize": "",
 | 
			
		||||
    "unbindText": "",
 | 
			
		||||
    "bindText": "",
 | 
			
		||||
    "link": {
 | 
			
		||||
      "edit": "",
 | 
			
		||||
      "create": "",
 | 
			
		||||
      "label": ""
 | 
			
		||||
    },
 | 
			
		||||
    "elementLock": {
 | 
			
		||||
      "lock": "",
 | 
			
		||||
      "unlock": "",
 | 
			
		||||
      "lockAll": "",
 | 
			
		||||
      "unlockAll": ""
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "buttons": {
 | 
			
		||||
@@ -204,8 +196,7 @@
 | 
			
		||||
    "library": "",
 | 
			
		||||
    "lock": "",
 | 
			
		||||
    "penMode": "",
 | 
			
		||||
    "link": "",
 | 
			
		||||
    "eraser": ""
 | 
			
		||||
    "link": ""
 | 
			
		||||
  },
 | 
			
		||||
  "headings": {
 | 
			
		||||
    "canvasActions": "",
 | 
			
		||||
@@ -230,8 +221,7 @@
 | 
			
		||||
    "placeImage": "",
 | 
			
		||||
    "publishLibrary": "",
 | 
			
		||||
    "bindTextToElement": "",
 | 
			
		||||
    "deepBoxSelect": "",
 | 
			
		||||
    "eraserRevert": ""
 | 
			
		||||
    "deepBoxSelect": ""
 | 
			
		||||
  },
 | 
			
		||||
  "canvasError": {
 | 
			
		||||
    "cannotShowPreview": "",
 | 
			
		||||
@@ -291,15 +281,14 @@
 | 
			
		||||
    "howto": "",
 | 
			
		||||
    "or": "nebo",
 | 
			
		||||
    "preventBinding": "",
 | 
			
		||||
    "tools": "",
 | 
			
		||||
    "shapes": "",
 | 
			
		||||
    "shortcuts": "",
 | 
			
		||||
    "textFinish": "",
 | 
			
		||||
    "textNewLine": "",
 | 
			
		||||
    "title": "",
 | 
			
		||||
    "view": "",
 | 
			
		||||
    "zoomToFit": "",
 | 
			
		||||
    "zoomToSelection": "",
 | 
			
		||||
    "toggleElementLock": ""
 | 
			
		||||
    "zoomToSelection": ""
 | 
			
		||||
  },
 | 
			
		||||
  "clearCanvasDialog": {
 | 
			
		||||
    "title": ""
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,6 @@
 | 
			
		||||
    "copy": "Kopier",
 | 
			
		||||
    "copyAsPng": "Kopier til klippebord som PNG",
 | 
			
		||||
    "copyAsSvg": "Kopier til klippebord som SVG",
 | 
			
		||||
    "copyText": "",
 | 
			
		||||
    "bringForward": "",
 | 
			
		||||
    "sendToBack": "",
 | 
			
		||||
    "bringToFront": "",
 | 
			
		||||
@@ -108,17 +107,10 @@
 | 
			
		||||
    "decreaseFontSize": "",
 | 
			
		||||
    "increaseFontSize": "",
 | 
			
		||||
    "unbindText": "",
 | 
			
		||||
    "bindText": "",
 | 
			
		||||
    "link": {
 | 
			
		||||
      "edit": "",
 | 
			
		||||
      "create": "",
 | 
			
		||||
      "label": ""
 | 
			
		||||
    },
 | 
			
		||||
    "elementLock": {
 | 
			
		||||
      "lock": "",
 | 
			
		||||
      "unlock": "",
 | 
			
		||||
      "lockAll": "",
 | 
			
		||||
      "unlockAll": ""
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "buttons": {
 | 
			
		||||
@@ -167,7 +159,7 @@
 | 
			
		||||
    "couldNotLoadInvalidFile": "",
 | 
			
		||||
    "importBackendFailed": "",
 | 
			
		||||
    "cannotExportEmptyCanvas": "",
 | 
			
		||||
    "couldNotCopyToClipboard": "",
 | 
			
		||||
    "couldNotCopyToClipboard": "Kunne ikke kopiere til klippebord. Prøv at bruge Chrome browser.",
 | 
			
		||||
    "decryptFailed": "",
 | 
			
		||||
    "uploadedSecurly": "",
 | 
			
		||||
    "loadSceneOverridePrompt": "",
 | 
			
		||||
@@ -204,8 +196,7 @@
 | 
			
		||||
    "library": "",
 | 
			
		||||
    "lock": "",
 | 
			
		||||
    "penMode": "",
 | 
			
		||||
    "link": "",
 | 
			
		||||
    "eraser": ""
 | 
			
		||||
    "link": ""
 | 
			
		||||
  },
 | 
			
		||||
  "headings": {
 | 
			
		||||
    "canvasActions": "",
 | 
			
		||||
@@ -230,8 +221,7 @@
 | 
			
		||||
    "placeImage": "",
 | 
			
		||||
    "publishLibrary": "",
 | 
			
		||||
    "bindTextToElement": "",
 | 
			
		||||
    "deepBoxSelect": "",
 | 
			
		||||
    "eraserRevert": ""
 | 
			
		||||
    "deepBoxSelect": ""
 | 
			
		||||
  },
 | 
			
		||||
  "canvasError": {
 | 
			
		||||
    "cannotShowPreview": "",
 | 
			
		||||
@@ -291,15 +281,14 @@
 | 
			
		||||
    "howto": "",
 | 
			
		||||
    "or": "",
 | 
			
		||||
    "preventBinding": "",
 | 
			
		||||
    "tools": "",
 | 
			
		||||
    "shapes": "",
 | 
			
		||||
    "shortcuts": "",
 | 
			
		||||
    "textFinish": "",
 | 
			
		||||
    "textNewLine": "",
 | 
			
		||||
    "title": "",
 | 
			
		||||
    "view": "",
 | 
			
		||||
    "zoomToFit": "",
 | 
			
		||||
    "zoomToSelection": "",
 | 
			
		||||
    "toggleElementLock": ""
 | 
			
		||||
    "zoomToSelection": ""
 | 
			
		||||
  },
 | 
			
		||||
  "clearCanvasDialog": {
 | 
			
		||||
    "title": ""
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,6 @@
 | 
			
		||||
    "copy": "Kopieren",
 | 
			
		||||
    "copyAsPng": "In Zwischenablage kopieren (PNG)",
 | 
			
		||||
    "copyAsSvg": "In Zwischenablage kopieren (SVG)",
 | 
			
		||||
    "copyText": "In die Zwischenablage als Text kopieren",
 | 
			
		||||
    "bringForward": "Nach vorne",
 | 
			
		||||
    "sendToBack": "In den Hintergrund",
 | 
			
		||||
    "bringToFront": "In den Vordergrund",
 | 
			
		||||
@@ -108,17 +107,10 @@
 | 
			
		||||
    "decreaseFontSize": "Schrift verkleinern",
 | 
			
		||||
    "increaseFontSize": "Schrift vergrößern",
 | 
			
		||||
    "unbindText": "Text lösen",
 | 
			
		||||
    "bindText": "Text an Container binden",
 | 
			
		||||
    "link": {
 | 
			
		||||
      "edit": "Link bearbeiten",
 | 
			
		||||
      "create": "Link erstellen",
 | 
			
		||||
      "label": "Link"
 | 
			
		||||
    },
 | 
			
		||||
    "elementLock": {
 | 
			
		||||
      "lock": "Sperren",
 | 
			
		||||
      "unlock": "Entsperren",
 | 
			
		||||
      "lockAll": "Alle sperren",
 | 
			
		||||
      "unlockAll": "Alle entsperren"
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "buttons": {
 | 
			
		||||
@@ -167,7 +159,7 @@
 | 
			
		||||
    "couldNotLoadInvalidFile": "Ungültige Datei konnte nicht geladen werden",
 | 
			
		||||
    "importBackendFailed": "Import vom Server ist fehlgeschlagen.",
 | 
			
		||||
    "cannotExportEmptyCanvas": "Leere Zeichenfläche kann nicht exportiert werden.",
 | 
			
		||||
    "couldNotCopyToClipboard": "Kopieren in die Zwischenablage fehlgeschlagen.",
 | 
			
		||||
    "couldNotCopyToClipboard": "Konnte nicht in die Zwischenablage kopieren. Versuch es mit dem Chrome Browser.",
 | 
			
		||||
    "decryptFailed": "Daten konnten nicht entschlüsselt werden.",
 | 
			
		||||
    "uploadedSecurly": "Der Upload wurde mit Ende-zu-Ende-Verschlüsselung gespeichert. Weder Excalidraw noch Dritte können den Inhalt einsehen.",
 | 
			
		||||
    "loadSceneOverridePrompt": "Das Laden einer externen Zeichnung ersetzt den vorhandenen Inhalt. Möchtest du fortfahren?",
 | 
			
		||||
@@ -203,9 +195,8 @@
 | 
			
		||||
    "text": "Text",
 | 
			
		||||
    "library": "Bibliothek",
 | 
			
		||||
    "lock": "Ausgewähltes Werkzeug nach Zeichnen aktiv lassen",
 | 
			
		||||
    "penMode": "Verhindere Pinch-Zoom und akzeptiere Eingabe nur vom Stift",
 | 
			
		||||
    "link": "Link für ausgewählte Form hinzufügen / aktualisieren",
 | 
			
		||||
    "eraser": "Radierer"
 | 
			
		||||
    "penMode": "",
 | 
			
		||||
    "link": "Link für ausgewählte Form hinzufügen / aktualisieren"
 | 
			
		||||
  },
 | 
			
		||||
  "headings": {
 | 
			
		||||
    "canvasActions": "Aktionen für Zeichenfläche",
 | 
			
		||||
@@ -230,8 +221,7 @@
 | 
			
		||||
    "placeImage": "Klicken, um das Bild zu platzieren oder klicken und ziehen um seine Größe manuell zu setzen",
 | 
			
		||||
    "publishLibrary": "Veröffentliche deine eigene Bibliothek",
 | 
			
		||||
    "bindTextToElement": "Zum Hinzufügen Eingabetaste drücken",
 | 
			
		||||
    "deepBoxSelect": "Halte CtrlOrCmd gedrückt, um innerhalb der Gruppe auszuwählen, und um Ziehen zu vermeiden",
 | 
			
		||||
    "eraserRevert": "Halte Alt gedrückt, um die zum Löschen markierten Elemente zurückzusetzen"
 | 
			
		||||
    "deepBoxSelect": "Halte CtrlOrCmd gedrückt, um innerhalb der Gruppe auszuwählen, und um Ziehen zu vermeiden"
 | 
			
		||||
  },
 | 
			
		||||
  "canvasError": {
 | 
			
		||||
    "cannotShowPreview": "Vorschau kann nicht angezeigt werden",
 | 
			
		||||
@@ -291,15 +281,14 @@
 | 
			
		||||
    "howto": "Folge unseren Anleitungen",
 | 
			
		||||
    "or": "oder",
 | 
			
		||||
    "preventBinding": "Pfeil-Bindung verhindern",
 | 
			
		||||
    "tools": "Werkzeuge",
 | 
			
		||||
    "shapes": "Formen",
 | 
			
		||||
    "shortcuts": "Tastaturkürzel",
 | 
			
		||||
    "textFinish": "Bearbeitung beenden (Texteditor)",
 | 
			
		||||
    "textNewLine": "Neue Zeile hinzufügen (Texteditor)",
 | 
			
		||||
    "title": "Hilfe",
 | 
			
		||||
    "view": "Ansicht",
 | 
			
		||||
    "zoomToFit": "Zoomen um alle Elemente einzupassen",
 | 
			
		||||
    "zoomToSelection": "Auf Auswahl zoomen",
 | 
			
		||||
    "toggleElementLock": "Auswahl sperren/entsperren"
 | 
			
		||||
    "zoomToSelection": "Auf Auswahl zoomen"
 | 
			
		||||
  },
 | 
			
		||||
  "clearCanvasDialog": {
 | 
			
		||||
    "title": "Zeichenfläche löschen"
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,6 @@
 | 
			
		||||
    "copy": "Αντιγραφή",
 | 
			
		||||
    "copyAsPng": "Αντιγραφή στο πρόχειρο ως PNG",
 | 
			
		||||
    "copyAsSvg": "Αντιγραφή στο πρόχειρο ως SVG",
 | 
			
		||||
    "copyText": "",
 | 
			
		||||
    "bringForward": "Στο προσκήνιο",
 | 
			
		||||
    "sendToBack": "Ένα επίπεδο πίσω",
 | 
			
		||||
    "bringToFront": "Ένα επίπεδο μπροστά",
 | 
			
		||||
@@ -108,17 +107,10 @@
 | 
			
		||||
    "decreaseFontSize": "",
 | 
			
		||||
    "increaseFontSize": "",
 | 
			
		||||
    "unbindText": "",
 | 
			
		||||
    "bindText": "",
 | 
			
		||||
    "link": {
 | 
			
		||||
      "edit": "",
 | 
			
		||||
      "create": "",
 | 
			
		||||
      "label": ""
 | 
			
		||||
    },
 | 
			
		||||
    "elementLock": {
 | 
			
		||||
      "lock": "",
 | 
			
		||||
      "unlock": "",
 | 
			
		||||
      "lockAll": "",
 | 
			
		||||
      "unlockAll": ""
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "buttons": {
 | 
			
		||||
@@ -167,7 +159,7 @@
 | 
			
		||||
    "couldNotLoadInvalidFile": "Δεν μπόρεσε να ανοίξει εσφαλμένο αρχείο",
 | 
			
		||||
    "importBackendFailed": "Η εισαγωγή από το backend απέτυχε.",
 | 
			
		||||
    "cannotExportEmptyCanvas": "Δεν είναι δυνατή η εξαγωγή κενού καμβά.",
 | 
			
		||||
    "couldNotCopyToClipboard": "",
 | 
			
		||||
    "couldNotCopyToClipboard": "Δεν ήταν δυνατή η αντιγραφή στο πρόχειρο. Δοκίμασε τη χρήση του προγράμματος περιήγησης Chrome.",
 | 
			
		||||
    "decryptFailed": "Δεν ήταν δυνατή η αποκρυπτογράφηση δεδομένων.",
 | 
			
		||||
    "uploadedSecurly": "Η μεταφόρτωση έχει εξασφαλιστεί με κρυπτογράφηση από άκρο σε άκρο, πράγμα που σημαίνει ότι ο διακομιστής Excalidraw και τρίτα μέρη δεν μπορούν να διαβάσουν το περιεχόμενο.",
 | 
			
		||||
    "loadSceneOverridePrompt": "Η φόρτωση εξωτερικού σχεδίου θα αντικαταστήσει το υπάρχον περιεχόμενο. Επιθυμείτε να συνεχίσετε;",
 | 
			
		||||
@@ -204,8 +196,7 @@
 | 
			
		||||
    "library": "Βιβλιοθήκη",
 | 
			
		||||
    "lock": "Κράτησε επιλεγμένο το εργαλείο μετά το σχέδιο",
 | 
			
		||||
    "penMode": "",
 | 
			
		||||
    "link": "",
 | 
			
		||||
    "eraser": ""
 | 
			
		||||
    "link": ""
 | 
			
		||||
  },
 | 
			
		||||
  "headings": {
 | 
			
		||||
    "canvasActions": "Ενέργειες καμβά",
 | 
			
		||||
@@ -230,8 +221,7 @@
 | 
			
		||||
    "placeImage": "",
 | 
			
		||||
    "publishLibrary": "Δημοσιεύστε τη δική σας βιβλιοθήκη",
 | 
			
		||||
    "bindTextToElement": "",
 | 
			
		||||
    "deepBoxSelect": "",
 | 
			
		||||
    "eraserRevert": ""
 | 
			
		||||
    "deepBoxSelect": ""
 | 
			
		||||
  },
 | 
			
		||||
  "canvasError": {
 | 
			
		||||
    "cannotShowPreview": "Αδυναμία εμφάνισης προεπισκόπησης",
 | 
			
		||||
@@ -291,15 +281,14 @@
 | 
			
		||||
    "howto": "Ακολουθήστε τους οδηγούς μας",
 | 
			
		||||
    "or": "ή",
 | 
			
		||||
    "preventBinding": "Αποτροπή δέσμευσης βέλων",
 | 
			
		||||
    "tools": "",
 | 
			
		||||
    "shapes": "Σχήματα",
 | 
			
		||||
    "shortcuts": "Συντομεύσεις πληκτρολογίου",
 | 
			
		||||
    "textFinish": "Ολοκλήρωση επεξεργασίας (επεξεργαστής κειμένου)",
 | 
			
		||||
    "textNewLine": "Προσθήκη νέας γραμμής (επεξεργαστής κειμένου)",
 | 
			
		||||
    "title": "Βοήθεια",
 | 
			
		||||
    "view": "Προβολή",
 | 
			
		||||
    "zoomToFit": "Zoom ώστε να χωρέσουν όλα τα στοιχεία",
 | 
			
		||||
    "zoomToSelection": "Ζουμ στην επιλογή",
 | 
			
		||||
    "toggleElementLock": ""
 | 
			
		||||
    "zoomToSelection": "Ζουμ στην επιλογή"
 | 
			
		||||
  },
 | 
			
		||||
  "clearCanvasDialog": {
 | 
			
		||||
    "title": "Καθαρισμός καμβά"
 | 
			
		||||
 
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user