Compare commits

..

49 Commits

Author SHA1 Message Date
pomdtr
ef7330243f export debounce util in excalidraw package 2022-04-24 19:52:07 +00:00
David Luzar
e6a9ff1b96 fix: toolIcon height not using rem (#5092) 2022-04-24 19:21:41 +02:00
David Luzar
832b88249c feat: stop event propagation when key handled (#5091)
* feat: stop event propagation when key handled

* don't handle s/g shortcuts if cmd/ctrl/alt pressed
2022-04-24 18:29:38 +02:00
Aakansha Doshi
9902092fd1 fix setting webpack public path (#5081) 2022-04-23 13:48:57 +05:30
Achille Lacoin
8f0863d335 add a prefix to the extension for image embbedding sceneData (#5079) 2022-04-22 18:31:31 +02:00
David Luzar
9423ac3263 fix: Excalidraw named export type (#5078) 2022-04-22 15:49:36 +02:00
Aakansha Doshi
a66cfe2627 fix: boundElementIds when arrows bound to elements are deleted (#5077)
* fix: boundElementIds when arrows bound to elements are deleted

* fix type checks and updating unrelated elements

Co-authored-by: dwelle <luzar.david@gmail.com>
2022-04-21 18:57:06 +05:30
Achille Lacoin
86cf28f2b4 fix: don't merge libraryItems on updateScene (#5076) 2022-04-21 10:54:02 +02:00
Achille Lacoin
b5a46dd671 fix: SVG metadata extraction regex on multiline elements (#5074)
* Fix SVG metadata extraction regex on multiline elements

* remove multiline flag
2022-04-20 17:07:58 +02:00
David Luzar
cd942c3e3b feat: rewrite library state management & related refactor (#5067)
* support libraryItems promise for `updateScene()` and use `importLibrary`

* fix typing for `getLibraryItemsFromStorage()`

* remove `libraryItemsFromStorage` hack

if there was a point to it then I'm missing it, but this part will be rewritten anyway

* rewrite state handling

(temporarily removed loading states)

* add async support

* refactor and deduplicate library importing logic

* hide hints when library open

* fix snaps

* support promise in `initialData.libraryItems`

* add default to params instead
2022-04-20 14:40:03 +02:00
David Luzar
55ccd5b79b feat: delay initial loading message & tweak design (#5049) 2022-04-19 19:08:13 +02:00
Excalidraw Bot
4348c55c31 chore: Update translations from Crowdin (#4894) 2022-04-18 00:16:43 +02:00
David Luzar
a3fbe40b26 fix: eraser cursor showing on theme change when not using eraser (#4990) 2022-04-17 22:47:36 +02:00
David Luzar
7431ca81d1 fix: update storage.rules (#5020) 2022-04-17 22:47:00 +02:00
David Luzar
4d13dbf625 feat: reconcile when saving to firebase (#4991)
* naming tweaks

* do not mark local element as duplicate when there's no remote counterpart

* merge instead of overwrite elements when saving to firebase & reconcile local state

* decouple syncing from persistence

* fix ts

* clarify doc

* fix reconciliation not removing duplicates
2022-04-17 22:40:39 +02:00
Achille Lacoin
3840e2f4e6 feat: embed scene support for png export in npm package (#5047)
* feat: embed scene support for png export in npm package

* move logic to the callback function

* add exportEmbedScene checkbox in package example

* update readme and changelog

* add PR link in changelog

* reverse sort changelog items
2022-04-16 16:30:11 +02:00
Aakansha Doshi
52d10bb41e feat: hide trash button during collaboration (#5037)
* feat: hide trash button during collaboration

* visually hide the trash icon

* pointer events none

* tweak env docs

* fix typo

Co-authored-by: dwelle <luzar.david@gmail.com>
2022-04-15 09:21:41 -07:00
Aakansha Doshi
96c87f920a build: export only named exports from the package (#5045)
* build: export only named exports from the package

* update docs

* Update src/packages/excalidraw/CHANGELOG.md

* fix lint
2022-04-15 18:12:57 +05:30
zsviczian
7d4189c624 fix: Add image button not working on iPad (#5038) 2022-04-15 12:20:51 +02:00
David Luzar
f3e17c90d3 fix: ensure svg image dimensions are always set (#5044) 2022-04-15 12:05:10 +02:00
David Luzar
70b3a9de49 feat: library restoring changes (#4995)
* restore library items in all cases & refactor

* export `restoreLibraryItems` from package

* feat: rerender library menu when updating via API

* update readme & changelog

* fix changelog
2022-04-14 16:20:35 +02:00
zsviczian
bf6d0eeef7 fix: Pinch zoom in view mode (#5001) 2022-04-12 12:39:28 +02:00
David Luzar
5359e4fec9 feat: refactor local persistence & fix race condition on SW reload (#5032) 2022-04-11 22:15:49 +02:00
David Luzar
58fe639b8d fix: select whole group on righclick & few lock-related fixes (#5022) 2022-04-07 17:53:55 +02:00
Tom Sherman
327ed0e2d1 feat: Element locking (#4964)
Co-authored-by: dwelle <luzar.david@gmail.com>
Co-authored-by: Zsolt Viczian <viczian.zsolt@gmail.com>
2022-04-07 13:43:29 +02:00
Achille Lacoin
c2fce6d8c4 fix: export serializeLibraryAsJSON from the package (#5017) 2022-04-07 12:35:44 +05:30
David Luzar
cb6b7559b4 fix: support copying PNG to clipboard on Safari (#3746) 2022-04-06 14:05:09 +02:00
David Luzar
77d789ed8e fix: more copyText fixes (#5016) 2022-04-05 23:11:00 +02:00
Faustino Kialungila
89471094ce fix: Copy to clipboard all text nodes as text (#5014)
* fix: Copy to clipboard all text nodes as text

* fix: support copying text even if there are selected elements that are no text

* patch: makes paragraphs betwen texts of each element

* patch: allow copying text for bound text
2022-04-05 21:48:59 +02:00
Faustino Kialungila
670ceafc84 feat: Copy to clipboard all text nodes as text (#5013)
* Copy to clipboard all text nodes as text

* fix: only show the option for text elements
2022-04-05 15:31:19 +02:00
Achille Lacoin
873afdacd3 feat: create and expose serializeLibraryAsJSON (#5009)
Co-authored-by: David Luzar <luzar.david@gmail.com>
2022-04-05 14:35:38 +02:00
Valerii Gusev
880e4feede fix: update cursorButton once freedraw is released (#4996)
Co-authored-by: dwelle <luzar.david@gmail.com>
2022-04-01 18:25:21 +02:00
David Luzar
9ba7ca3845 feat: hide penMode button on reload if not enabled (#4992) 2022-03-30 10:53:22 +02:00
zsviczian
734bb4d2ed fix: decouple actionFinalize and actionErase (#4984)
* Update actionCanvas.tsx

* Update actionFinalize.tsx

* lint

* remove Escape trigger from actionErase

* revert to lastActiveTool only if coming from eraser tool

* unrelated: fix restoring `appState.activeTool`

* one more restoring fix

* fix tests

Co-authored-by: dwelle <luzar.david@gmail.com>
2022-03-29 21:37:09 +02:00
David Luzar
f2d2f97546 fix: using stale state when switching tools (#4989) 2022-03-29 17:00:19 +02:00
Aakansha Doshi
2fa69ddc32 refactor: move elementLocked to activeTool.locked (#4983)
* refactor: move elementLocked to activeTool.locked

* fix

* fix snap

* update docs

* Update src/packages/excalidraw/CHANGELOG.md

* revert

* make lastActiveToolBeforeEraser required and nullable

* fix snap
2022-03-29 17:10:19 +05:30
zsviczian
1331cffe93 feat: Eraser toggle to switch back to the previous tool (#4981)
* add typeBeforeEraser

* ESC to switch to lastActiveToolBeforeEraser
2022-03-28 21:33:32 +02:00
David Luzar
f242721f3b chore: add ga for most actions (#4829) 2022-03-28 14:46:40 +02:00
zsviczian
e940aeb1a3 fix: updateWysiwygStyle updatedElement is undefined TypeError (#4980)
Co-authored-by: dwelle <luzar.david@gmail.com>
2022-03-28 00:14:04 +02:00
connorhanafee
580e719580 fix: adding check for link length to prevent early return (#4982)
Co-authored-by: Connor Hanafee <connorp@Connors-MacBook-Pro.local>
Co-authored-by: dwelle <luzar.david@gmail.com>
2022-03-27 23:50:41 +02:00
Aakansha Doshi
127af9db23 refactor: rename elementType to activeTool and make it an object (#4968)
* refactor: rename elementType to activeTool

* update docs

* fix snap

* update activeToll to be an object and review fixes

* fix tests

* fix
2022-03-25 20:46:01 +05:30
Aakansha Doshi
2209e2c1e8 fix: show link icon for bound text containers (#4960) 2022-03-23 00:45:08 +05:30
zsviczian
ed31980f84 feat: Save penDetected and penMode, and detect pen already on ToolButton click (#4955)
* save penMode and penDetected to browser cache

* added on pointer down

* added onPointerDown

* factor out and merge handlers

Co-authored-by: dwelle <luzar.david@gmail.com>
2022-03-22 13:29:27 +01:00
Aakansha Doshi
db28595302 fix: cancel erase elements on pointer up if eraser is not active on pointer up (#4956)
* fix: erase elements on pointer up if present

* cancel erase on pointer up if eraser is not active
2022-03-22 17:14:07 +05:30
Aakansha Doshi
cded1cd63d fix: restore original opacities when alt pressed while erasing (#4954) 2022-03-22 16:40:28 +05:30
Aakansha Doshi
8e447b4c32 fix: don't bind text to container if already present (#4946)
* fix: don't bind text to container if already present

* Add specs and update condition
2022-03-22 15:32:49 +05:30
dependabot[bot]
e29d3fc5e6 chore(deps-dev): bump mini-css-extract-plugin (#4872)
Bumps [mini-css-extract-plugin](https://github.com/webpack-contrib/mini-css-extract-plugin) from 2.4.6 to 2.6.0.
- [Release notes](https://github.com/webpack-contrib/mini-css-extract-plugin/releases)
- [Changelog](https://github.com/webpack-contrib/mini-css-extract-plugin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/mini-css-extract-plugin/compare/v2.4.6...v2.6.0)

---
updated-dependencies:
- dependency-name: mini-css-extract-plugin
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-03-22 13:07:14 +05:30
dependabot[bot]
9da56e46f0 chore(deps-dev): bump lint-staged from 12.3.3 to 12.3.7 (#4941)
Bumps [lint-staged](https://github.com/okonet/lint-staged) from 12.3.3 to 12.3.7.
- [Release notes](https://github.com/okonet/lint-staged/releases)
- [Commits](https://github.com/okonet/lint-staged/compare/v12.3.3...v12.3.7)

---
updated-dependencies:
- dependency-name: lint-staged
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-03-22 13:06:48 +05:30
Aakansha Doshi
625ecc64ed feat: Support binding text to container via context menu (#4935)
* feat: Support binding text to closest container

* Bind text to selected container

* show bind action in canvas and selected container after binding

* allow binding if container has no bound text

* fix

* move logic to show/hide bind actions to contextMenuPredicate

* don't show bind action when clicking on bounding box and not elemnts
2022-03-21 17:54:54 +05:30
174 changed files with 5528 additions and 2104 deletions

View File

@@ -4,9 +4,10 @@ REACT_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
REACT_APP_PORTAL_URL=http://localhost:3002
# Fill to set socket server URL used for collaboration.
# Meant for forks only: excalidraw.com uses custom REACT_APP_PORTAL_URL flow
REACT_APP_WS_SERVER_URL=
# collaboration WebSocket server (https://github.com/excalidraw/excalidraw-room)
REACT_APP_WS_SERVER_URL=http://localhost:3002
# set this only if using the collaboration workflow we use on excalidraw.com
REACT_APP_PORTAL_URL=
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'

View File

@@ -1,12 +1,11 @@
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
match /{migrations} {
match /{scenes}/{scene} {
allow get, write: if true;
// redundant, but let's be explicit'
allow list: if false;
}
match /{files}/rooms/{room}/{file} {
allow get, write: if true;
}
match /{files}/shareLinks/{shareLink}/{file} {
allow get, write: if true;
}
}
}

View File

@@ -36,6 +36,7 @@
"i18next-browser-languagedetector": "6.1.2",
"idb-keyval": "6.0.3",
"image-blob-reduce": "3.0.1",
"jotai": "1.6.4",
"lodash.throttle": "4.1.1",
"nanoid": "3.1.32",
"open-color": "1.9.1",
@@ -67,7 +68,7 @@
"eslint-plugin-prettier": "3.3.1",
"husky": "7.0.4",
"jest-canvas-mock": "2.3.1",
"lint-staged": "12.3.3",
"lint-staged": "12.3.7",
"pepjs": "0.5.3",
"prettier": "2.5.1",
"rewire": "5.0.0"

View File

@@ -124,26 +124,6 @@
user-select: none;
}
.LoadingMessage {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.LoadingMessage span {
background-color: var(--button-gray-1);
border-radius: 5px;
padding: 0.8em 1.2em;
color: var(--popup-text-color);
font-size: 1.3em;
}
#root {
height: 100%;
-webkit-touch-callout: none;
@@ -152,8 +132,10 @@
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
@media screen and (min-width: 1200px) {
@media screen and (min-width: 1200px) {
#root {
-webkit-touch-callout: default;
-webkit-user-select: auto;
-khtml-user-select: auto;
@@ -170,10 +152,6 @@
<header>
<h1 class="visually-hidden">Excalidraw</h1>
</header>
<div id="root">
<div class="LoadingMessage">
<span>Loading scene...</span>
</div>
</div>
<div id="root"></div>
</body>
</html>

View File

@@ -7,6 +7,7 @@ import { t } from "../i18n";
export const actionAddToLibrary = register({
name: "addToLibrary",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),

View File

@@ -43,6 +43,7 @@ const alignSelectedElements = (
export const actionAlignTop = register({
name: "alignTop",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
appState,
@@ -72,6 +73,7 @@ export const actionAlignTop = register({
export const actionAlignBottom = register({
name: "alignBottom",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
appState,
@@ -101,6 +103,7 @@ export const actionAlignBottom = register({
export const actionAlignLeft = register({
name: "alignLeft",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
appState,
@@ -130,6 +133,8 @@ export const actionAlignLeft = register({
export const actionAlignRight = register({
name: "alignRight",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
appState,
@@ -159,6 +164,8 @@ export const actionAlignRight = register({
export const actionAlignVerticallyCentered = register({
name: "alignVerticallyCentered",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
appState,
@@ -184,6 +191,7 @@ export const actionAlignVerticallyCentered = register({
export const actionAlignHorizontallyCentered = register({
name: "alignHorizontallyCentered",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
appState,

View File

@@ -0,0 +1,136 @@
import { VERTICAL_ALIGN } from "../constants";
import { getNonDeletedElements, isTextElement } from "../element";
import { mutateElement } from "../element/mutateElement";
import {
getBoundTextElement,
measureText,
redrawTextBoundingBox,
} from "../element/textElement";
import {
hasBoundTextElement,
isTextBindableContainer,
} from "../element/typeChecks";
import {
ExcalidrawTextContainer,
ExcalidrawTextElement,
} from "../element/types";
import { getSelectedElements } from "../scene";
import { getFontString } from "../utils";
import { register } from "./register";
export const actionUnbindText = register({
name: "unbindText",
contextItemLabel: "labels.unbindText",
trackEvent: { category: "element" },
contextItemPredicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
return selectedElements.some((element) => hasBoundTextElement(element));
},
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
const { width, height, baseline } = measureText(
boundTextElement.originalText,
getFontString(boundTextElement),
);
mutateElement(boundTextElement as ExcalidrawTextElement, {
containerId: null,
width,
height,
baseline,
text: boundTextElement.originalText,
});
mutateElement(element, {
boundElements: element.boundElements?.filter(
(ele) => ele.id !== boundTextElement.id,
),
});
}
});
return {
elements,
appState,
commitToHistory: true,
};
},
});
export const actionBindText = register({
name: "bindText",
contextItemLabel: "labels.bindText",
trackEvent: { category: "element" },
contextItemPredicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
if (selectedElements.length === 2) {
const textElement =
isTextElement(selectedElements[0]) ||
isTextElement(selectedElements[1]);
let bindingContainer;
if (isTextBindableContainer(selectedElements[0])) {
bindingContainer = selectedElements[0];
} else if (isTextBindableContainer(selectedElements[1])) {
bindingContainer = selectedElements[1];
}
if (
textElement &&
bindingContainer &&
getBoundTextElement(bindingContainer) === null
) {
return true;
}
}
return false;
},
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
let textElement: ExcalidrawTextElement;
let container: ExcalidrawTextContainer;
if (
isTextElement(selectedElements[0]) &&
isTextBindableContainer(selectedElements[1])
) {
textElement = selectedElements[0];
container = selectedElements[1];
} else {
textElement = selectedElements[1] as ExcalidrawTextElement;
container = selectedElements[0] as ExcalidrawTextContainer;
}
mutateElement(textElement, {
containerId: container.id,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
});
mutateElement(container, {
boundElements: (container.boundElements || []).concat({
type: "text",
id: textElement.id,
}),
});
redrawTextBoundingBox(textElement, container);
const updatedElements = elements.slice();
const textElementIndex = updatedElements.findIndex(
(ele) => ele.id === textElement.id,
);
updatedElements.splice(textElementIndex, 1);
const containerIndex = updatedElements.findIndex(
(ele) => ele.id === container.id,
);
updatedElements.splice(containerIndex + 1, 0, textElement);
return {
elements: updatedElements,
appState: { ...appState, selectedElementIds: { [container.id]: true } },
commitToHistory: true,
};
},
});

View File

@@ -21,6 +21,7 @@ import clsx from "clsx";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
trackEvent: false,
perform: (_, appState, value) => {
return {
appState: { ...appState, ...value },
@@ -50,6 +51,7 @@ export const actionChangeViewBackgroundColor = register({
export const actionClearCanvas = register({
name: "clearCanvas",
trackEvent: { category: "canvas" },
perform: (elements, appState, _, app) => {
app.imageCache.clear();
return {
@@ -60,15 +62,17 @@ 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,
elementType:
appState.elementType === "image" ? "selection" : appState.elementType,
activeTool:
appState.activeTool.type === "image"
? { ...appState.activeTool, type: "selection" }
: appState.activeTool,
},
commitToHistory: true,
};
@@ -79,6 +83,7 @@ export const actionClearCanvas = register({
export const actionZoomIn = register({
name: "zoomIn",
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
return {
appState: {
@@ -114,6 +119,7 @@ export const actionZoomIn = register({
export const actionZoomOut = register({
name: "zoomOut",
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
return {
appState: {
@@ -149,6 +155,7 @@ export const actionZoomOut = register({
export const actionResetZoom = register({
name: "resetZoom",
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
return {
appState: {
@@ -247,6 +254,7 @@ const zoomToFitElements = (
export const actionZoomToSelected = register({
name: "zoomToSelection",
trackEvent: { category: "canvas" },
perform: (elements, appState) => zoomToFitElements(elements, appState, true),
keyTest: (event) =>
event.code === CODES.TWO &&
@@ -257,6 +265,7 @@ export const actionZoomToSelected = register({
export const actionZoomToFit = register({
name: "zoomToFit",
trackEvent: { category: "canvas" },
perform: (elements, appState) => zoomToFitElements(elements, appState, false),
keyTest: (event) =>
event.code === CODES.ONE &&
@@ -267,6 +276,7 @@ export const actionZoomToFit = register({
export const actionToggleTheme = register({
name: "toggleTheme",
trackEvent: { category: "canvas" },
perform: (_, appState, value) => {
return {
appState: {
@@ -292,13 +302,23 @@ export const actionToggleTheme = register({
export const actionErase = register({
name: "eraser",
trackEvent: { category: "toolbar" },
perform: (elements, appState) => {
return {
appState: {
...appState,
selectedElementIds: {},
selectedGroupIds: {},
elementType: isEraserActive(appState) ? "selection" : "eraser",
activeTool: {
...appState.activeTool,
type: isEraserActive(appState)
? appState.activeTool.lastActiveToolBeforeEraser ?? "selection"
: "eraser",
lastActiveToolBeforeEraser:
appState.activeTool.type === "eraser" //node throws incorrect type error when using isEraserActive()
? null
: appState.activeTool.type,
},
},
commitToHistory: true,
};

View File

@@ -1,14 +1,19 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { copyToClipboard } from "../clipboard";
import {
copyTextToSystemClipboard,
copyToClipboard,
probablySupportsClipboardWriteText,
} from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected";
import { getSelectedElements } from "../scene/selection";
import { exportCanvas } from "../data/index";
import { getNonDeletedElements } from "../element";
import { getNonDeletedElements, isTextElement } from "../element";
import { t } from "../i18n";
export const actionCopy = register({
name: "copy",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
copyToClipboard(getNonDeletedElements(elements), appState, app.files);
@@ -23,6 +28,7 @@ export const actionCopy = register({
export const actionCut = register({
name: "cut",
trackEvent: { category: "element" },
perform: (elements, appState, data, app) => {
actionCopy.perform(elements, appState, data, app);
return actionDeleteSelected.perform(elements, appState);
@@ -33,6 +39,7 @@ export const actionCut = register({
export const actionCopyAsSvg = register({
name: "copyAsSvg",
trackEvent: { category: "element" },
perform: async (elements, appState, _data, app) => {
if (!app.canvas) {
return {
@@ -73,6 +80,7 @@ export const actionCopyAsSvg = register({
export const actionCopyAsPng = register({
name: "copyAsPng",
trackEvent: { category: "element" },
perform: async (elements, appState, _data, app) => {
if (!app.canvas) {
return {
@@ -122,3 +130,35 @@ export const actionCopyAsPng = register({
contextItemLabel: "labels.copyAsPng",
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
});
export const copyText = register({
name: "copyText",
trackEvent: { category: "element" },
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
true,
);
const text = selectedElements
.reduce((acc: string[], element) => {
if (isTextElement(element)) {
acc.push(element.text);
}
return acc;
}, [])
.join("\n\n");
copyTextToSystemClipboard(text);
return {
commitToHistory: false,
};
},
contextItemPredicate: (elements, appState) => {
return (
probablySupportsClipboardWriteText &&
getSelectedElements(elements, appState, true).some(isTextElement)
);
},
contextItemLabel: "labels.copyText",
});

View File

@@ -58,6 +58,7 @@ const handleGroupEditingState = (
export const actionDeleteSelected = register({
name: "deleteSelectedElements",
trackEvent: { category: "element", action: "delete" },
perform: (elements, appState) => {
if (appState.editingLinearElement) {
const {
@@ -133,7 +134,7 @@ export const actionDeleteSelected = register({
elements: nextElements,
appState: {
...nextAppState,
elementType: "selection",
activeTool: { ...appState.activeTool, type: "selection" },
multiElement: null,
},
commitToHistory: isSomeElementSelected(

View File

@@ -39,6 +39,7 @@ const distributeSelectedElements = (
export const distributeHorizontally = register({
name: "distributeHorizontally",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
appState,
@@ -68,6 +69,7 @@ export const distributeHorizontally = register({
export const distributeVertically = register({
name: "distributeVertically",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
appState,

View File

@@ -22,6 +22,7 @@ import { isBoundToContainer } from "../element/typeChecks";
export const actionDuplicateSelection = register({
name: "duplicateSelection",
trackEvent: { category: "element" },
perform: (elements, appState) => {
// duplicate selected point(s) if editing a line
if (appState.editingLinearElement) {

View File

@@ -1,4 +1,3 @@
import { trackEvent } from "../analytics";
import { load, questionCircle, saveAs } from "../components/icons";
import { ProjectName } from "../components/ProjectName";
import { ToolButton } from "../components/ToolButton";
@@ -23,8 +22,8 @@ import { Theme } from "../element/types";
export const actionChangeProjectName = register({
name: "changeProjectName",
trackEvent: false,
perform: (_elements, appState, value) => {
trackEvent("change", "title");
return { appState: { ...appState, name: value }, commitToHistory: false };
},
PanelComponent: ({ appState, updateData, appProps }) => (
@@ -41,6 +40,7 @@ export const actionChangeProjectName = register({
export const actionChangeExportScale = register({
name: "changeExportScale",
trackEvent: { category: "export", action: "scale" },
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportScale: value },
@@ -89,6 +89,7 @@ export const actionChangeExportScale = register({
export const actionChangeExportBackground = register({
name: "changeExportBackground",
trackEvent: { category: "export", action: "toggleBackground" },
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportBackground: value },
@@ -107,6 +108,7 @@ export const actionChangeExportBackground = register({
export const actionChangeExportEmbedScene = register({
name: "changeExportEmbedScene",
trackEvent: { category: "export", action: "embedScene" },
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportEmbedScene: value },
@@ -128,6 +130,7 @@ export const actionChangeExportEmbedScene = register({
export const actionSaveToActiveFile = register({
name: "saveToActiveFile",
trackEvent: { category: "export" },
perform: async (elements, appState, value, app) => {
const fileHandleExists = !!appState.fileHandle;
@@ -172,6 +175,7 @@ export const actionSaveToActiveFile = register({
export const actionSaveFileToDisk = register({
name: "saveFileToDisk",
trackEvent: { category: "export" },
perform: async (elements, appState, value, app) => {
try {
const { fileHandle } = await saveAsJSON(
@@ -210,6 +214,7 @@ export const actionSaveFileToDisk = register({
export const actionLoadScene = register({
name: "loadScene",
trackEvent: { category: "export" },
perform: async (elements, appState, _, app) => {
try {
const {
@@ -252,6 +257,7 @@ export const actionLoadScene = register({
export const actionExportWithDarkMode = register({
name: "exportWithDarkMode",
trackEvent: { category: "export", action: "toggleTheme" },
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportWithDarkMode: value },

View File

@@ -17,6 +17,7 @@ import { isBindingElement } from "../element/typeChecks";
export const actionFinalize = register({
name: "finalize",
trackEvent: false,
perform: (elements, appState, _, { canvas, focusContainer }) => {
if (appState.editingLinearElement) {
const { elementId, startBindingElement, endBindingElement } =
@@ -38,6 +39,7 @@ export const actionFinalize = register({
: undefined,
appState: {
...appState,
cursorButton: "up",
editingLinearElement: null,
},
commitToHistory: true,
@@ -119,13 +121,17 @@ export const actionFinalize = register({
);
}
if (!appState.elementLocked && appState.elementType !== "freedraw") {
if (
!appState.activeTool.locked &&
appState.activeTool.type !== "freedraw"
) {
appState.selectedElementIds[multiPointElement.id] = true;
}
}
if (
(!appState.elementLocked && appState.elementType !== "freedraw") ||
(!appState.activeTool.locked &&
appState.activeTool.type !== "freedraw") ||
!multiPointElement
) {
resetCursor(canvas);
@@ -135,11 +141,20 @@ export const actionFinalize = register({
elements: newElements,
appState: {
...appState,
elementType:
(appState.elementLocked || appState.elementType === "freedraw") &&
cursorButton: "up",
activeTool:
(appState.activeTool.locked ||
appState.activeTool.type === "freedraw") &&
multiPointElement
? appState.elementType
: "selection",
? appState.activeTool
: {
...appState.activeTool,
type:
appState.activeTool.type === "eraser" &&
appState.activeTool.lastActiveToolBeforeEraser
? appState.activeTool.lastActiveToolBeforeEraser
: "selection",
},
draggingElement: null,
multiElement: null,
editingElement: null,
@@ -147,8 +162,8 @@ export const actionFinalize = register({
suggestedBindings: [],
selectedElementIds:
multiPointElement &&
!appState.elementLocked &&
appState.elementType !== "freedraw"
!appState.activeTool.locked &&
appState.activeTool.type !== "freedraw"
? {
...appState.selectedElementIds,
[multiPointElement.id]: true,
@@ -156,7 +171,7 @@ export const actionFinalize = register({
: appState.selectedElementIds,
pendingImageElement: null,
},
commitToHistory: appState.elementType === "freedraw",
commitToHistory: appState.activeTool.type === "freedraw",
};
},
keyTest: (event, appState) =>

View File

@@ -35,6 +35,7 @@ const enableActionFlipVertical = (
export const actionFlipHorizontal = register({
name: "flipHorizontal",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
elements: flipSelectedElements(elements, appState, "horizontal"),
@@ -50,6 +51,7 @@ export const actionFlipHorizontal = register({
export const actionFlipVertical = register({
name: "flipVertical",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
elements: flipSelectedElements(elements, appState, "vertical"),

View File

@@ -54,6 +54,7 @@ const enableActionGroup = (
export const actionGroup = register({
name: "group",
trackEvent: { category: "element" },
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
@@ -147,6 +148,7 @@ export const actionGroup = register({
export const actionUngroup = register({
name: "ungroup",
trackEvent: { category: "element" },
perform: (elements, appState) => {
const groupIds = getSelectedGroupIds(appState);
if (groupIds.length === 0) {

View File

@@ -62,6 +62,7 @@ type ActionCreator = (history: History) => Action;
export const createUndoAction: ActionCreator = (history) => ({
name: "undo",
trackEvent: { category: "history" },
perform: (elements, appState) =>
writeData(elements, appState, () => history.undoOnce()),
keyTest: (event) =>
@@ -82,6 +83,7 @@ export const createUndoAction: ActionCreator = (history) => ({
export const createRedoAction: ActionCreator = (history) => ({
name: "redo",
trackEvent: { category: "history" },
perform: (elements, appState) =>
writeData(elements, appState, () => history.redoOnce()),
keyTest: (event) =>

View File

@@ -9,6 +9,7 @@ import { HelpIcon } from "../components/HelpIcon";
export const actionToggleCanvasMenu = register({
name: "toggleCanvasMenu",
trackEvent: { category: "menu" },
perform: (_, appState) => ({
appState: {
...appState,
@@ -29,6 +30,7 @@ export const actionToggleCanvasMenu = register({
export const actionToggleEditMenu = register({
name: "toggleEditMenu",
trackEvent: { category: "menu" },
perform: (_elements, appState) => ({
appState: {
...appState,
@@ -53,6 +55,7 @@ export const actionToggleEditMenu = register({
export const actionFullScreen = register({
name: "toggleFullScreen",
trackEvent: { category: "canvas", predicate: (appState) => !isFullScreen() },
perform: () => {
if (!isFullScreen()) {
allowFullScreen();
@@ -69,6 +72,7 @@ export const actionFullScreen = register({
export const actionShortcuts = register({
name: "toggleShortcuts",
trackEvent: { category: "menu", action: "toggleHelpDialog" },
perform: (_elements, appState, _, { focusContainer }) => {
if (appState.showHelpDialog) {
focusContainer();

View File

@@ -6,6 +6,7 @@ import { register } from "./register";
export const actionGoToCollaborator = register({
name: "goToCollaborator",
trackEvent: { category: "collab" },
perform: (_elements, appState, value) => {
const point = value as Collaborator["pointer"];
if (!point) {

View File

@@ -166,11 +166,7 @@ const changeFontSize = (
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
fontSize: newFontSize,
});
redrawTextBoundingBox(
newElement,
getContainerElement(oldElement),
appState,
);
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
newElement = offsetElementAfterFontResize(oldElement, newElement);
@@ -198,6 +194,7 @@ const changeFontSize = (
export const actionChangeStrokeColor = register({
name: "changeStrokeColor",
trackEvent: false,
perform: (elements, appState, value) => {
return {
...(value.currentItemStrokeColor && {
@@ -247,6 +244,7 @@ export const actionChangeStrokeColor = register({
export const actionChangeBackgroundColor = register({
name: "changeBackgroundColor",
trackEvent: false,
perform: (elements, appState, value) => {
return {
...(value.currentItemBackgroundColor && {
@@ -289,6 +287,7 @@ export const actionChangeBackgroundColor = register({
export const actionChangeFillStyle = register({
name: "changeFillStyle",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) =>
@@ -338,6 +337,7 @@ export const actionChangeFillStyle = register({
export const actionChangeStrokeWidth = register({
name: "changeStrokeWidth",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) =>
@@ -385,6 +385,7 @@ export const actionChangeStrokeWidth = register({
export const actionChangeSloppiness = register({
name: "changeSloppiness",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) =>
@@ -433,6 +434,7 @@ export const actionChangeSloppiness = register({
export const actionChangeStrokeStyle = register({
name: "changeStrokeStyle",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) =>
@@ -480,6 +482,7 @@ export const actionChangeStrokeStyle = register({
export const actionChangeOpacity = register({
name: "changeOpacity",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(elements, appState, (el) =>
@@ -529,6 +532,7 @@ export const actionChangeOpacity = register({
export const actionChangeFontSize = register({
name: "changeFontSize",
trackEvent: false,
perform: (elements, appState, value) => {
return changeFontSize(elements, appState, () => value, value);
},
@@ -586,6 +590,7 @@ export const actionChangeFontSize = register({
export const actionDecreaseFontSize = register({
name: "decreaseFontSize",
trackEvent: false,
perform: (elements, appState, value) => {
return changeFontSize(elements, appState, (element) =>
Math.round(
@@ -607,6 +612,7 @@ export const actionDecreaseFontSize = register({
export const actionIncreaseFontSize = register({
name: "increaseFontSize",
trackEvent: false,
perform: (elements, appState, value) => {
return changeFontSize(elements, appState, (element) =>
Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
@@ -624,6 +630,7 @@ export const actionIncreaseFontSize = register({
export const actionChangeFontFamily = register({
name: "changeFontFamily",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(
@@ -637,11 +644,7 @@ export const actionChangeFontFamily = register({
fontFamily: value,
},
);
redrawTextBoundingBox(
newElement,
getContainerElement(oldElement),
appState,
);
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
return newElement;
}
@@ -709,6 +712,7 @@ export const actionChangeFontFamily = register({
export const actionChangeTextAlign = register({
name: "changeTextAlign",
trackEvent: false,
perform: (elements, appState, value) => {
return {
elements: changeProperty(
@@ -720,11 +724,7 @@ export const actionChangeTextAlign = register({
oldElement,
{ textAlign: value },
);
redrawTextBoundingBox(
newElement,
getContainerElement(oldElement),
appState,
);
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
return newElement;
}
@@ -785,6 +785,7 @@ export const actionChangeTextAlign = register({
});
export const actionChangeVerticalAlign = register({
name: "changeVerticalAlign",
trackEvent: { category: "element" },
perform: (elements, appState, value) => {
return {
elements: changeProperty(
@@ -797,11 +798,7 @@ export const actionChangeVerticalAlign = register({
{ verticalAlign: value },
);
redrawTextBoundingBox(
newElement,
getContainerElement(oldElement),
appState,
);
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
return newElement;
}
@@ -856,6 +853,7 @@ export const actionChangeVerticalAlign = register({
export const actionChangeSharpness = register({
name: "changeSharpness",
trackEvent: false,
perform: (elements, appState, value) => {
const targetElements = getTargetElements(
getNonDeletedElements(elements),
@@ -863,10 +861,10 @@ export const actionChangeSharpness = register({
);
const shouldUpdateForNonLinearElements = targetElements.length
? targetElements.every((el) => !isLinearElement(el))
: !isLinearElementType(appState.elementType);
: !isLinearElementType(appState.activeTool.type);
const shouldUpdateForLinearElements = targetElements.length
? targetElements.every(isLinearElement)
: isLinearElementType(appState.elementType);
: isLinearElementType(appState.activeTool.type);
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
@@ -906,8 +904,8 @@ export const actionChangeSharpness = register({
elements,
appState,
(element) => element.strokeSharpness,
(canChangeSharpness(appState.elementType) &&
(isLinearElementType(appState.elementType)
(canChangeSharpness(appState.activeTool.type) &&
(isLinearElementType(appState.activeTool.type)
? appState.currentItemLinearStrokeSharpness
: appState.currentItemStrokeSharpness)) ||
null,
@@ -920,6 +918,7 @@ export const actionChangeSharpness = register({
export const actionChangeArrowhead = register({
name: "changeArrowhead",
trackEvent: false,
perform: (
elements,
appState,

View File

@@ -5,6 +5,7 @@ import { getNonDeletedElements, isTextElement } from "../element";
export const actionSelectAll = register({
name: "selectAll",
trackEvent: { category: "canvas" },
perform: (elements, appState) => {
if (appState.editingLinearElement) {
return false;
@@ -17,7 +18,8 @@ export const actionSelectAll = register({
selectedElementIds: elements.reduce((map, element) => {
if (
!element.isDeleted &&
!(isTextElement(element) && element.containerId)
!(isTextElement(element) && element.containerId) &&
element.locked === false
) {
map[element.id] = true;
}

View File

@@ -19,6 +19,7 @@ export let copiedStyles: string = "{}";
export const actionCopyStyles = register({
name: "copyStyles",
trackEvent: { category: "element" },
perform: (elements, appState) => {
const element = elements.find((el) => appState.selectedElementIds[el.id]);
if (element) {
@@ -39,6 +40,7 @@ export const actionCopyStyles = register({
export const actionPasteStyles = register({
name: "pasteStyles",
trackEvent: { category: "element" },
perform: (elements, appState) => {
const pastedElement = JSON.parse(copiedStyles);
if (!isExcalidrawElement(pastedElement)) {
@@ -63,11 +65,7 @@ export const actionPasteStyles = register({
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
});
redrawTextBoundingBox(
newElement,
getContainerElement(newElement),
appState,
);
redrawTextBoundingBox(newElement, getContainerElement(newElement));
}
return newElement;
}

View File

@@ -2,12 +2,14 @@ import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { GRID_SIZE } from "../constants";
import { AppState } from "../types";
import { trackEvent } from "../analytics";
export const actionToggleGridMode = register({
name: "gridMode",
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.gridSize,
},
perform(elements, appState) {
trackEvent("view", "mode", "grid");
return {
appState: {
...appState,

View File

@@ -0,0 +1,63 @@
import { newElementWith } from "../element/mutateElement";
import { ExcalidrawElement } from "../element/types";
import { KEYS } from "../keys";
import { getSelectedElements } from "../scene";
import { arrayToMap } from "../utils";
import { register } from "./register";
export const actionToggleLock = register({
name: "toggleLock",
trackEvent: { category: "element" },
perform: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState, true);
if (!selectedElements.length) {
return false;
}
const operation = getOperation(selectedElements);
const selectedElementsMap = arrayToMap(selectedElements);
return {
elements: elements.map((element) => {
if (!selectedElementsMap.has(element.id)) {
return element;
}
return newElementWith(element, { locked: operation === "lock" });
}),
appState,
commitToHistory: true,
};
},
contextItemLabel: (elements, appState) => {
const selected = getSelectedElements(elements, appState, false);
if (selected.length === 1) {
return selected[0].locked
? "labels.elementLock.unlock"
: "labels.elementLock.lock";
}
if (selected.length > 1) {
return getOperation(selected) === "lock"
? "labels.elementLock.lockAll"
: "labels.elementLock.unlockAll";
}
throw new Error(
"Unexpected zero elements to lock/unlock. This should never happen.",
);
},
keyTest: (event, appState, elements) => {
return (
event.key.toLocaleLowerCase() === KEYS.L &&
event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
getSelectedElements(elements, appState, false).length > 0
);
},
});
const getOperation = (
elements: readonly ExcalidrawElement[],
): "lock" | "unlock" => (elements.some((el) => !el.locked) ? "lock" : "unlock");

View File

@@ -3,6 +3,7 @@ import { CODES, KEYS } from "../keys";
export const actionToggleStats = register({
name: "stats",
trackEvent: { category: "menu" },
perform(elements, appState) {
return {
appState: {

View File

@@ -1,11 +1,13 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { trackEvent } from "../analytics";
export const actionToggleViewMode = register({
name: "viewMode",
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.viewModeEnabled,
},
perform(elements, appState) {
trackEvent("view", "mode", "view");
return {
appState: {
...appState,

View File

@@ -1,12 +1,13 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { trackEvent } from "../analytics";
export const actionToggleZenMode = register({
name: "zenMode",
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.zenModeEnabled,
},
perform(elements, appState) {
trackEvent("view", "mode", "zen");
return {
appState: {
...appState,

View File

@@ -1,44 +0,0 @@
import { getNonDeletedElements } from "../element";
import { mutateElement } from "../element/mutateElement";
import { getBoundTextElement, measureText } from "../element/textElement";
import { ExcalidrawTextElement } from "../element/types";
import { getSelectedElements } from "../scene";
import { getFontString } from "../utils";
import { register } from "./register";
export const actionUnbindText = register({
name: "unbindText",
contextItemLabel: "labels.unbindText",
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
const { width, height, baseline } = measureText(
boundTextElement.originalText,
getFontString(boundTextElement),
);
mutateElement(boundTextElement as ExcalidrawTextElement, {
containerId: null,
width,
height,
baseline,
text: boundTextElement.originalText,
});
mutateElement(element, {
boundElements: element.boundElements?.filter(
(ele) => ele.id !== boundTextElement.id,
),
});
}
});
return {
elements,
appState,
commitToHistory: true,
};
},
});

View File

@@ -18,6 +18,7 @@ import {
export const actionSendBackward = register({
name: "sendBackward",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
elements: moveOneLeft(elements, appState),
@@ -45,6 +46,7 @@ export const actionSendBackward = register({
export const actionBringForward = register({
name: "bringForward",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
elements: moveOneRight(elements, appState),
@@ -72,6 +74,7 @@ export const actionBringForward = register({
export const actionSendToBack = register({
name: "sendToBack",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
elements: moveAllLeft(elements, appState),
@@ -106,6 +109,8 @@ export const actionSendToBack = register({
export const actionBringToFront = register({
name: "bringToFront",
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
elements: moveAllRight(elements, appState),

View File

@@ -75,11 +75,13 @@ export {
actionCut,
actionCopyAsPng,
actionCopyAsSvg,
copyText,
} from "./actionClipboard";
export { actionToggleGridMode } from "./actionToggleGridMode";
export { actionToggleZenMode } from "./actionToggleZenMode";
export { actionToggleStats } from "./actionToggleStats";
export { actionUnbindText } from "./actionUnbindText";
export { actionUnbindText, actionBindText } from "./actionBoundText";
export { actionLink } from "../element/Hyperlink";
export { actionToggleLock } from "./actionToggleLock";

View File

@@ -1,11 +1,11 @@
import React from "react";
import {
Action,
ActionsManagerInterface,
UpdaterFn,
ActionName,
ActionResult,
PanelComponentProps,
ActionSource,
} from "./types";
import { ExcalidrawElement } from "../element/types";
import { AppClassProperties, AppState } from "../types";
@@ -14,21 +14,25 @@ import { trackEvent } from "../analytics";
const trackAction = (
action: Action,
source: "ui" | "keyboard" | "api",
source: ActionSource,
appState: Readonly<AppState>,
elements: readonly ExcalidrawElement[],
app: AppClassProperties,
value: any,
) => {
if (action.trackEvent !== false) {
if (action.trackEvent) {
try {
if (action.trackEvent === true) {
trackEvent(
action.name,
source,
typeof value === "number" || typeof value === "string"
? String(value)
: undefined,
);
} else {
action.trackEvent?.(action, source, value);
if (typeof action.trackEvent === "object") {
const shouldTrack = action.trackEvent.predicate
? action.trackEvent.predicate(appState, elements, value)
: true;
if (shouldTrack) {
trackEvent(
action.trackEvent.category,
action.trackEvent.action || action.name,
`${source} (${app.deviceType.isMobile ? "mobile" : "desktop"})`,
);
}
}
} catch (error) {
console.error("error while logging action:", error);
@@ -36,8 +40,8 @@ const trackAction = (
}
};
export class ActionManager implements ActionsManagerInterface {
actions = {} as ActionsManagerInterface["actions"];
export class ActionManager {
actions = {} as Record<ActionName, Action>;
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
@@ -106,30 +110,26 @@ export class ActionManager implements ActionsManagerInterface {
}
}
trackAction(action, "keyboard", null);
const elements = this.getElementsIncludingDeleted();
const appState = this.getAppState();
const value = null;
trackAction(action, "keyboard", appState, elements, this.app, null);
event.preventDefault();
this.updater(
data[0].perform(
this.getElementsIncludingDeleted(),
this.getAppState(),
null,
this.app,
),
);
event.stopPropagation();
this.updater(data[0].perform(elements, appState, value, this.app));
return true;
}
executeAction(action: Action) {
this.updater(
action.perform(
this.getElementsIncludingDeleted(),
this.getAppState(),
null,
this.app,
),
);
trackAction(action, "api", null);
executeAction(action: Action, source: ActionSource = "api") {
const elements = this.getElementsIncludingDeleted();
const appState = this.getAppState();
const value = null;
trackAction(action, source, appState, elements, this.app, value);
this.updater(action.perform(elements, appState, value, this.app));
}
/**
@@ -147,7 +147,11 @@ export class ActionManager implements ActionsManagerInterface {
) {
const action = this.actions[name];
const PanelComponent = action.PanelComponent!;
const elements = this.getElementsIncludingDeleted();
const appState = this.getAppState();
const updateData = (formState?: any) => {
trackAction(action, "ui", appState, elements, this.app, formState);
this.updater(
action.perform(
this.getElementsIncludingDeleted(),
@@ -156,8 +160,6 @@ export class ActionManager implements ActionsManagerInterface {
this.app,
),
);
trackAction(action, "ui", formState);
};
return (

View File

@@ -29,6 +29,7 @@ export type ShortcutName = SubtypeOf<
| "flipHorizontal"
| "flipVertical"
| "hyperlink"
| "toggleLock"
>;
const shortcutMap: Record<ShortcutName, string[]> = {
@@ -67,6 +68,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
flipVertical: [getShortcutKey("Shift+V")],
viewMode: [getShortcutKey("Alt+R")],
hyperlink: [getShortcutKey("CtrlOrCmd+K")],
toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
};
export const getShortcutFromShortcutName = (name: ShortcutName) => {

View File

@@ -8,6 +8,8 @@ import {
} from "../types";
import { ToolButtonSize } from "../components/ToolButton";
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
/** if false, the action should be prevented */
export type ActionResult =
| {
@@ -39,6 +41,7 @@ export type ActionName =
| "paste"
| "copyAsPng"
| "copyAsSvg"
| "copyText"
| "sendBackward"
| "bringForward"
| "sendToBack"
@@ -107,7 +110,9 @@ export type ActionName =
| "decreaseFontSize"
| "unbindText"
| "hyperlink"
| "eraser";
| "eraser"
| "bindText"
| "toggleLock";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];
@@ -138,15 +143,23 @@ export interface Action {
appState: AppState,
) => boolean;
checked?: (appState: Readonly<AppState>) => boolean;
trackEvent?:
| boolean
| ((action: Action, type: "ui" | "keyboard" | "api", value: any) => void);
}
export interface ActionsManagerInterface {
actions: Record<ActionName, Action>;
registerAction: (action: Action) => void;
handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean;
renderAction: (name: ActionName) => React.ReactElement | null;
executeAction: (action: Action) => void;
trackEvent:
| false
| {
category:
| "toolbar"
| "element"
| "canvas"
| "export"
| "history"
| "menu"
| "collab"
| "hyperlink";
action?: string;
predicate?: (
appState: Readonly<AppState>,
elements: readonly ExcalidrawElement[],
value: any,
) => boolean;
};
}

View File

@@ -4,15 +4,19 @@ export const trackEvent =
typeof window !== "undefined" &&
window.gtag
? (category: string, action: string, label?: string, value?: number) => {
window.gtag("event", action, {
event_category: category,
event_label: label,
value,
});
try {
window.gtag("event", action, {
event_category: category,
event_label: label,
value,
});
} catch (error) {
console.error("error logging to ga", error);
}
}
: typeof process !== "undefined" && process.env?.JEST_WORKER_ID
? (category: string, action: string, label?: string, value?: number) => {}
: (category: string, action: string, label?: string, value?: number) => {
// Uncomment the next line to track locally
// console.info("Track Event", category, action, label, value);
// console.log("Track Event", { category, action, label, value });
};

View File

@@ -41,9 +41,13 @@ export const getDefaultAppState = (): Omit<
editingElement: null,
editingGroupId: null,
editingLinearElement: null,
elementLocked: false,
elementType: "selection",
activeTool: {
type: "selection",
locked: false,
lastActiveToolBeforeEraser: null,
},
penMode: false,
penDetected: false,
errorMessage: null,
exportBackground: true,
exportScale: defaultExportScale,
@@ -129,9 +133,9 @@ const APP_STATE_STORAGE_CONF = (<
editingElement: { browser: false, export: false, server: false },
editingGroupId: { browser: true, export: false, server: false },
editingLinearElement: { browser: false, export: false, server: false },
elementLocked: { browser: true, export: false, server: false },
elementType: { browser: true, export: false, server: false },
penMode: { browser: false, export: false, server: false },
activeTool: { browser: true, export: false, server: false },
penMode: { browser: true, export: false, server: false },
penDetected: { browser: true, export: false, server: false },
errorMessage: { browser: false, export: false, server: false },
exportBackground: { browser: true, export: false, server: false },
exportEmbedScene: { browser: true, export: false, server: false },
@@ -213,7 +217,7 @@ export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
};
export const isEraserActive = ({
elementType,
activeTool,
}: {
elementType: AppState["elementType"];
}) => elementType === "eraser";
activeTool: AppState["activeTool"];
}) => activeTool.type === "eraser";

View File

@@ -167,6 +167,7 @@ const commonProps = {
strokeStyle: "solid",
strokeWidth: 1,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
locked: false,
} as const;
const getChartDimentions = (spreadsheet: Spreadsheet) => {

View File

@@ -8,6 +8,7 @@ import { SVG_EXPORT_TAG } from "./scene/export";
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
import { isInitializedImageElement } from "./element/typeChecks";
import { isPromiseLike } from "./utils";
type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
@@ -166,10 +167,35 @@ export const parseClipboard = async (
}
};
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
await navigator.clipboard.write([
new window.ClipboardItem({ [MIME_TYPES.png]: blob }),
]);
export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
let promise;
try {
// in Safari so far we need to construct the ClipboardItem synchronously
// (i.e. in the same tick) otherwise browser will complain for lack of
// user intent. Using a Promise ClipboardItem constructor solves this.
// https://bugs.webkit.org/show_bug.cgi?id=222262
//
// not await so that we can detect whether the thrown error likely relates
// to a lack of support for the Promise ClipboardItem constructor
promise = navigator.clipboard.write([
new window.ClipboardItem({
[MIME_TYPES.png]: blob,
}),
]);
} catch (error: any) {
// if we're using a Promise ClipboardItem, let's try constructing
// with resolution value instead
if (isPromiseLike(blob)) {
await navigator.clipboard.write([
new window.ClipboardItem({
[MIME_TYPES.png]: await blob,
}),
]);
} else {
throw error;
}
}
await promise;
};
export const copyTextToSystemClipboard = async (text: string | null) => {

View File

@@ -14,23 +14,24 @@ import {
hasText,
} from "../scene";
import { SHAPES } from "../shapes";
import { AppState, DeviceType, Zoom } from "../types";
import { AppState, 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,
elementType,
activeTool,
}: {
appState: AppState;
elements: readonly ExcalidrawElement[];
renderAction: ActionManager["renderAction"];
elementType: AppState["elementType"];
activeTool: AppState["activeTool"]["type"];
}) => {
const targetElements = getTargetElements(
getNonDeletedElements(elements),
@@ -50,15 +51,18 @@ export const SelectedShapeActions = ({
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
const showFillIcons =
hasBackground(elementType) ||
hasBackground(activeTool) ||
targetElements.some(
(element) =>
hasBackground(element.type) && !isTransparent(element.backgroundColor),
);
const showChangeBackgroundIcons =
hasBackground(elementType) ||
hasBackground(activeTool) ||
targetElements.some((element) => hasBackground(element.type));
const showLinkIcon =
targetElements.length === 1 || isSingleElementBoundContainer;
let commonSelectedType: string | null = targetElements[0]?.type || null;
for (const element of targetElements) {
@@ -70,23 +74,23 @@ export const SelectedShapeActions = ({
return (
<div className="panelColumn">
{((hasStrokeColor(elementType) &&
elementType !== "image" &&
{((hasStrokeColor(activeTool) &&
activeTool !== "image" &&
commonSelectedType !== "image") ||
targetElements.some((element) => hasStrokeColor(element.type))) &&
renderAction("changeStrokeColor")}
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
{showFillIcons && renderAction("changeFillStyle")}
{(hasStrokeWidth(elementType) ||
{(hasStrokeWidth(activeTool) ||
targetElements.some((element) => hasStrokeWidth(element.type))) &&
renderAction("changeStrokeWidth")}
{(elementType === "freedraw" ||
{(activeTool === "freedraw" ||
targetElements.some((element) => element.type === "freedraw")) &&
renderAction("changeStrokeShape")}
{(hasStrokeStyle(elementType) ||
{(hasStrokeStyle(activeTool) ||
targetElements.some((element) => hasStrokeStyle(element.type))) && (
<>
{renderAction("changeStrokeStyle")}
@@ -94,12 +98,12 @@ export const SelectedShapeActions = ({
</>
)}
{(canChangeSharpness(elementType) ||
{(canChangeSharpness(activeTool) ||
targetElements.some((element) => canChangeSharpness(element.type))) && (
<>{renderAction("changeSharpness")}</>
)}
{(hasText(elementType) ||
{(hasText(activeTool) ||
targetElements.some((element) => hasText(element.type))) && (
<>
{renderAction("changeFontSize")}
@@ -114,7 +118,7 @@ export const SelectedShapeActions = ({
(element) =>
hasBoundTextElement(element) || isBoundToContainer(element),
) && renderAction("changeVerticalAlign")}
{(canHaveArrowheads(elementType) ||
{(canHaveArrowheads(activeTool) ||
targetElements.some((element) => canHaveArrowheads(element.type))) && (
<>{renderAction("changeArrowhead")}</>
)}
@@ -172,7 +176,7 @@ export const SelectedShapeActions = ({
{!deviceType.isMobile && renderAction("deleteSelectedElements")}
{renderAction("group")}
{renderAction("ungroup")}
{targetElements.length === 1 && renderAction("hyperlink")}
{showLinkIcon && renderAction("hyperlink")}
</div>
</fieldset>
)}
@@ -182,64 +186,68 @@ export const SelectedShapeActions = ({
export const ShapesSwitcher = ({
canvas,
elementType,
activeTool,
setAppState,
onImageAction,
appState,
setDeviceType,
}: {
canvas: HTMLCanvasElement | null;
elementType: AppState["elementType"];
activeTool: AppState["activeTool"];
setAppState: React.Component<any, AppState>["setState"];
onImageAction: (data: { pointerType: PointerType | null }) => void;
appState: AppState;
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 }) => {
}) => (
<>
{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") {
setAppState({
elementType: value,
multiElement: null,
selectedElementIds: {},
penDetected: true,
penMode: true,
});
setCursorForShape(canvas, { ...appState, elementType: value });
if (value === "image") {
onImageAction({ pointerType });
}
}}
/>
);
})}
</>
);
};
}
}}
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 });
}
}}
/>
);
})}
</>
);
export const ZoomActions = ({
renderAction,

File diff suppressed because it is too large Load Diff

View File

@@ -70,7 +70,9 @@ const ContextMenu = ({
dangerous: actionName === "deleteSelectedElements",
checkmark: option.checked?.(appState),
})}
onClick={() => actionManager.executeAction(option)}
onClick={() =>
actionManager.executeAction(option, "contextMenu")
}
>
<div className="context-menu-option__label">{label}</div>
<kbd className="context-menu-option__shortcut">

View File

@@ -363,6 +363,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
getShortcutKey(`Alt+${t("helpDialog.drag")}`),
]}
/>
<Shortcut
label={t("helpDialog.toggleElementLock")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+L")]}
/>
<Shortcut
label={t("buttons.undo")}
shortcuts={[getShortcutKey("CtrlOrCmd+Z")]}

View File

@@ -20,28 +20,32 @@ interface HintViewerProps {
}
const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
const { elementType, isResizing, isRotating, lastPointerDownWith } = appState;
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null;
if (appState.isLibraryOpen) {
return null;
}
if (isEraserActive(appState)) {
return t("hints.eraserRevert");
}
if (elementType === "arrow" || elementType === "line") {
if (activeTool.type === "arrow" || activeTool.type === "line") {
if (!multiMode) {
return t("hints.linearElement");
}
return t("hints.linearElementMulti");
}
if (elementType === "freedraw") {
if (activeTool.type === "freedraw") {
return t("hints.freeDraw");
}
if (elementType === "text") {
if (activeTool.type === "text") {
return t("hints.text");
}
if (appState.elementType === "image" && appState.pendingImageElement) {
if (appState.activeTool.type === "image" && appState.pendingImageElement) {
return t("hints.placeImage");
}
@@ -73,7 +77,7 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
return t("hints.text_editing");
}
if (elementType === "selection") {
if (activeTool.type === "selection") {
if (
appState.draggingElement?.type === "selection" &&
!appState.editingElement &&

View File

@@ -1,6 +1,5 @@
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";
@@ -19,6 +18,7 @@ import OpenColor from "open-color";
import { CheckboxItem } from "./CheckboxItem";
import { DEFAULT_EXPORT_PADDING } from "../constants";
import { nativeFileSystemSupported } from "../data/filesystem";
import { ActionManager } from "../actions/manager";
const supportsContextFilters =
"filter" in document.createElement("canvas").getContext("2d")!;
@@ -90,7 +90,7 @@ const ImageExportModal = ({
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles;
exportPadding?: number;
actionManager: ActionsManagerInterface;
actionManager: ActionManager;
onExportToPng: ExportCB;
onExportToSvg: ExportCB;
onExportToClipboard: ExportCB;
@@ -229,7 +229,7 @@ export const ImageExportDialog = ({
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles;
exportPadding?: number;
actionManager: ActionsManagerInterface;
actionManager: ActionManager;
onExportToPng: ExportCB;
onExportToSvg: ExportCB;
onExportToClipboard: ExportCB;

View File

@@ -1,5 +1,4 @@
import React, { useState } from "react";
import { ActionsManagerInterface } from "../actions/types";
import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { useDeviceType } from "./App";
@@ -12,6 +11,9 @@ import { Card } from "./Card";
import "./ExportDialog.scss";
import { nativeFileSystemSupported } from "../data/filesystem";
import { trackEvent } from "../analytics";
import { ActionManager } from "../actions/manager";
import { getFrame } from "../utils";
export type ExportCB = (
elements: readonly NonDeletedExcalidrawElement[],
@@ -29,7 +31,7 @@ const JSONExportModal = ({
appState: AppState;
files: BinaryFiles;
elements: readonly NonDeletedExcalidrawElement[];
actionManager: ActionsManagerInterface;
actionManager: ActionManager;
onCloseRequest: () => void;
exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null;
@@ -54,7 +56,7 @@ const JSONExportModal = ({
aria-label={t("exportDialog.disk_button")}
showAriaLabel={true}
onClick={() => {
actionManager.executeAction(actionSaveFileToDisk);
actionManager.executeAction(actionSaveFileToDisk, "ui");
}}
/>
</Card>
@@ -70,9 +72,10 @@ const JSONExportModal = ({
title={t("exportDialog.link_button")}
aria-label={t("exportDialog.link_button")}
showAriaLabel={true}
onClick={() =>
onExportToBackend(elements, appState, files, canvas)
}
onClick={() => {
onExportToBackend(elements, appState, files, canvas);
trackEvent("export", "link", `ui (${getFrame()})`);
}}
/>
</Card>
)}
@@ -94,7 +97,7 @@ export const JSONExportDialog = ({
elements: readonly NonDeletedExcalidrawElement[];
appState: AppState;
files: BinaryFiles;
actionManager: ActionsManagerInterface;
actionManager: ActionManager;
exportOpts: ExportOpts;
canvas: HTMLCanvasElement | null;
}) => {

View File

@@ -8,13 +8,7 @@ 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,
DeviceType,
} from "../types";
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
import { muteFSAbortError } from "../utils";
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
@@ -42,6 +36,7 @@ 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 {
@@ -50,7 +45,6 @@ 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;
@@ -81,7 +75,6 @@ const LayerUI = ({
appState,
files,
setAppState,
setDeviceType,
canvas,
elements,
onCollabButtonClick,
@@ -130,6 +123,7 @@ const LayerUI = ({
const createExporter =
(type: ExportType): ExportCB =>
async (exportedElements) => {
trackEvent("export", type, "ui");
const fileHandle = await exportCanvas(
type,
exportedElements,
@@ -256,7 +250,7 @@ const LayerUI = ({
appState={appState}
elements={elements}
renderAction={actionManager.renderAction}
elementType={appState.elementType}
activeTool={appState.activeTool.type}
/>
</Island>
</Section>
@@ -329,12 +323,12 @@ const LayerUI = ({
checked={appState.penMode}
onChange={onPenModeToggle}
title={t("toolBar.penMode")}
penDetected={deviceType.penDetected}
penDetected={appState.penDetected}
/>
<LockButton
zenModeEnabled={zenModeEnabled}
checked={appState.elementLocked}
onChange={onLockToggle}
checked={appState.activeTool.locked}
onChange={() => onLockToggle()}
title={t("toolBar.lock")}
/>
<Island
@@ -353,14 +347,13 @@ const LayerUI = ({
<ShapesSwitcher
appState={appState}
canvas={canvas}
elementType={appState.elementType}
activeTool={appState.activeTool}
setAppState={setAppState}
onImageAction={({ pointerType }) => {
onImageAction({
insertOnCanvasDirectly: pointerType !== "mouse",
});
}}
setDeviceType={setDeviceType}
/>
</Stack.Row>
</Island>
@@ -499,7 +492,7 @@ const LayerUI = ({
const dialogs = (
<>
{appState.isLoading && <LoadingMessage />}
{appState.isLoading && <LoadingMessage delay={250} />}
{appState.errorMessage && (
<ErrorDialog
message={appState.errorMessage}
@@ -539,9 +532,8 @@ const LayerUI = ({
renderJSONExportDialog={renderJSONExportDialog}
renderImageExportDialog={renderImageExportDialog}
setAppState={setAppState}
setDeviceType={setDeviceType}
onCollabButtonClick={onCollabButtonClick}
onLockToggle={onLockToggle}
onLockToggle={() => onLockToggle()}
onPenModeToggle={onPenModeToggle}
canvas={canvas}
isCollaborating={isCollaborating}

View File

@@ -28,8 +28,17 @@
}
.layer-ui__library-message {
padding: 10px 20px;
max-width: 200px;
padding: 2em 4em;
min-width: 200px;
display: flex;
flex-direction: column;
align-items: center;
.Spinner {
margin-bottom: 1em;
}
span {
font-size: 0.8em;
}
}
.publish-library-success {

View File

@@ -1,5 +1,12 @@
import { useRef, useState, useEffect, useCallback, RefObject } from "react";
import Library from "../data/library";
import {
useRef,
useState,
useEffect,
useCallback,
RefObject,
forwardRef,
} from "react";
import Library, { libraryItemsAtom } from "../data/library";
import { t } from "../i18n";
import { randomId } from "../random";
import {
@@ -19,6 +26,10 @@ import LibraryMenuItems from "./LibraryMenuItems";
import { EVENT } from "../constants";
import { KEYS } from "../keys";
import { arrayToMap } from "../utils";
import { trackEvent } from "../analytics";
import { useAtom } from "jotai";
import { jotaiScope } from "../jotai";
import Spinner from "./Spinner";
const useOnClickOutside = (
ref: RefObject<HTMLElement>,
@@ -53,6 +64,17 @@ const getSelectedItems = (
selectedItems: LibraryItem["id"][],
) => libraryItems.filter((item) => selectedItems.includes(item.id));
const LibraryMenuWrapper = forwardRef<
HTMLDivElement,
{ children: React.ReactNode }
>(({ children }, ref) => {
return (
<Island padding={1} ref={ref} className="layer-ui__library">
{children}
</Island>
);
});
export const LibraryMenu = ({
onClose,
onInsertShape,
@@ -102,11 +124,6 @@ export const LibraryMenu = ({
};
}, [onClose]);
const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
const [loadingState, setIsLoading] = useState<
"preloading" | "loading" | "ready"
>("preloading");
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
useState(false);
@@ -114,55 +131,35 @@ export const LibraryMenu = ({
url: string;
authorName: string;
}>(null);
const loadingTimerRef = useRef<number | null>(null);
useEffect(() => {
Promise.race([
new Promise((resolve) => {
loadingTimerRef.current = window.setTimeout(() => {
resolve("loading");
}, 100);
}),
library.loadLibrary().then((items) => {
setLibraryItems(items);
setIsLoading("ready");
}),
]).then((data) => {
if (data === "loading") {
setIsLoading("loading");
}
});
return () => {
clearTimeout(loadingTimerRef.current!);
};
}, [library]);
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
const removeFromLibrary = useCallback(async () => {
const items = await library.loadLibrary();
const nextItems = items.filter((item) => !selectedItems.includes(item.id));
library.saveLibrary(nextItems).catch((error) => {
setLibraryItems(items);
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
});
setSelectedItems([]);
setLibraryItems(nextItems);
}, [library, setAppState, selectedItems, setSelectedItems]);
const removeFromLibrary = useCallback(
async (libraryItems: LibraryItems) => {
const nextItems = libraryItems.filter(
(item) => !selectedItems.includes(item.id),
);
library.saveLibrary(nextItems).catch(() => {
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
});
setSelectedItems([]);
},
[library, setAppState, selectedItems, setSelectedItems],
);
const resetLibrary = useCallback(() => {
library.resetLibrary();
setLibraryItems([]);
focusContainer();
}, [library, focusContainer]);
const addToLibrary = useCallback(
async (elements: LibraryItem["elements"]) => {
async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => {
trackEvent("element", "addToLibrary", "ui");
if (elements.some((element) => element.type === "image")) {
return setAppState({
errorMessage: "Support for adding images to the library coming soon!",
});
}
const items = await library.loadLibrary();
const nextItems: LibraryItems = [
{
status: "unpublished",
@@ -170,14 +167,12 @@ export const LibraryMenu = ({
id: randomId(),
created: Date.now(),
},
...items,
...libraryItems,
];
onAddToLibrary();
library.saveLibrary(nextItems).catch((error) => {
setLibraryItems(items);
library.saveLibrary(nextItems).catch(() => {
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
});
setLibraryItems(nextItems);
},
[onAddToLibrary, library, setAppState],
);
@@ -216,7 +211,7 @@ export const LibraryMenu = ({
}, [setPublishLibSuccess, publishLibSuccess]);
const onPublishLibSuccess = useCallback(
(data) => {
(data, libraryItems: LibraryItems) => {
setShowPublishLibraryDialog(false);
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
const nextLibItems = libraryItems.slice();
@@ -226,101 +221,109 @@ export const LibraryMenu = ({
}
});
library.saveLibrary(nextLibItems);
setLibraryItems(nextLibItems);
},
[
setShowPublishLibraryDialog,
setPublishLibSuccess,
libraryItems,
selectedItems,
library,
],
[setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
);
const [lastSelectedItem, setLastSelectedItem] = useState<
LibraryItem["id"] | null
>(null);
return loadingState === "preloading" ? null : (
<Island padding={1} ref={ref} className="layer-ui__library">
if (libraryItemsData.status === "loading") {
return (
<LibraryMenuWrapper ref={ref}>
<div className="layer-ui__library-message">
<Spinner size="2em" />
<span>{t("labels.libraryLoadingMessage")}</span>
</div>
</LibraryMenuWrapper>
);
}
return (
<LibraryMenuWrapper ref={ref}>
{showPublishLibraryDialog && (
<PublishLibrary
onClose={() => setShowPublishLibraryDialog(false)}
libraryItems={getSelectedItems(libraryItems, selectedItems)}
libraryItems={getSelectedItems(
libraryItemsData.libraryItems,
selectedItems,
)}
appState={appState}
onSuccess={onPublishLibSuccess}
onSuccess={(data) =>
onPublishLibSuccess(data, libraryItemsData.libraryItems)
}
onError={(error) => window.alert(error)}
updateItemsInStorage={() => library.saveLibrary(libraryItems)}
updateItemsInStorage={() =>
library.saveLibrary(libraryItemsData.libraryItems)
}
onRemove={(id: string) =>
setSelectedItems(selectedItems.filter((_id) => _id !== id))
}
/>
)}
{publishLibSuccess && renderPublishSuccess()}
<LibraryMenuItems
libraryItems={libraryItemsData.libraryItems}
onRemoveFromLibrary={() =>
removeFromLibrary(libraryItemsData.libraryItems)
}
onAddToLibrary={(elements) =>
addToLibrary(elements, libraryItemsData.libraryItems)
}
onInsertShape={onInsertShape}
pendingElements={pendingElements}
setAppState={setAppState}
libraryReturnUrl={libraryReturnUrl}
library={library}
theme={theme}
files={files}
id={id}
selectedItems={selectedItems}
onToggle={(id, event) => {
const shouldSelect = !selectedItems.includes(id);
{loadingState === "loading" ? (
<div className="layer-ui__library-message">
{t("labels.libraryLoadingMessage")}
</div>
) : (
<LibraryMenuItems
libraryItems={libraryItems}
onRemoveFromLibrary={removeFromLibrary}
onAddToLibrary={addToLibrary}
onInsertShape={onInsertShape}
pendingElements={pendingElements}
setAppState={setAppState}
libraryReturnUrl={libraryReturnUrl}
library={library}
theme={theme}
files={files}
id={id}
selectedItems={selectedItems}
onToggle={(id, event) => {
const shouldSelect = !selectedItems.includes(id);
if (shouldSelect) {
if (event.shiftKey && lastSelectedItem) {
const rangeStart = libraryItemsData.libraryItems.findIndex(
(item) => item.id === lastSelectedItem,
);
const rangeEnd = libraryItemsData.libraryItems.findIndex(
(item) => item.id === id,
);
if (shouldSelect) {
if (event.shiftKey && lastSelectedItem) {
const rangeStart = libraryItems.findIndex(
(item) => item.id === lastSelectedItem,
);
const rangeEnd = libraryItems.findIndex(
(item) => item.id === id,
);
if (rangeStart === -1 || rangeEnd === -1) {
setSelectedItems([...selectedItems, id]);
return;
}
const selectedItemsMap = arrayToMap(selectedItems);
const nextSelectedIds = libraryItems.reduce(
(acc: LibraryItem["id"][], item, idx) => {
if (
(idx >= rangeStart && idx <= rangeEnd) ||
selectedItemsMap.has(item.id)
) {
acc.push(item.id);
}
return acc;
},
[],
);
setSelectedItems(nextSelectedIds);
} else {
if (rangeStart === -1 || rangeEnd === -1) {
setSelectedItems([...selectedItems, id]);
return;
}
setLastSelectedItem(id);
const selectedItemsMap = arrayToMap(selectedItems);
const nextSelectedIds = libraryItemsData.libraryItems.reduce(
(acc: LibraryItem["id"][], item, idx) => {
if (
(idx >= rangeStart && idx <= rangeEnd) ||
selectedItemsMap.has(item.id)
) {
acc.push(item.id);
}
return acc;
},
[],
);
setSelectedItems(nextSelectedIds);
} else {
setLastSelectedItem(null);
setSelectedItems(selectedItems.filter((_id) => _id !== id));
setSelectedItems([...selectedItems, id]);
}
}}
onPublish={() => setShowPublishLibraryDialog(true)}
resetLibrary={resetLibrary}
/>
)}
</Island>
setLastSelectedItem(id);
} else {
setLastSelectedItem(null);
setSelectedItems(selectedItems.filter((_id) => _id !== id));
}
}}
onPublish={() => setShowPublishLibraryDialog(true)}
resetLibrary={resetLibrary}
/>
</LibraryMenuWrapper>
);
};

View File

@@ -106,11 +106,6 @@ const LibraryMenuItems = ({
icon={load}
onClick={() => {
importLibraryFromJSON(library)
.then(() => {
// Close and then open to get the libraries updated
setAppState({ isLibraryOpen: false });
setAppState({ isLibraryOpen: true });
})
.catch(muteFSAbortError)
.catch((error) => {
setAppState({ errorMessage: error.message });

View File

@@ -1,10 +1,30 @@
import { t } from "../i18n";
import { useState, useEffect } from "react";
import Spinner from "./Spinner";
export const LoadingMessage: React.FC<{ delay?: number }> = ({ delay }) => {
const [isWaiting, setIsWaiting] = useState(!!delay);
useEffect(() => {
if (!delay) {
return;
}
const timer = setTimeout(() => {
setIsWaiting(false);
}, delay);
return () => clearTimeout(timer);
}, [delay]);
if (isWaiting) {
return null;
}
export const LoadingMessage = () => {
// !! KEEP THIS IN SYNC WITH index.html !!
return (
<div className="LoadingMessage">
<span>{t("labels.loadingScene")}</span>
<div>
<Spinner />
</div>
<div className="LoadingMessage-text">{t("labels.loadingScene")}</div>
</div>
);
};

View File

@@ -1,5 +1,5 @@
import React from "react";
import { AppState, DeviceType } from "../types";
import { AppState } from "../types";
import { ActionManager } from "../actions/manager";
import { t } from "../i18n";
import Stack from "./Stack";
@@ -18,7 +18,6 @@ import { UserList } from "./UserList";
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
import { LibraryButton } from "./LibraryButton";
import { PenModeButton } from "./PenModeButton";
import { useDeviceType } from "./App";
type MobileMenuProps = {
appState: AppState;
@@ -26,7 +25,6 @@ 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;
@@ -52,7 +50,6 @@ export const MobileMenu = ({
renderJSONExportDialog,
renderImageExportDialog,
setAppState,
setDeviceType,
onCollabButtonClick,
onLockToggle,
onPenModeToggle,
@@ -64,7 +61,6 @@ export const MobileMenu = ({
onImageAction,
renderTopRightUI,
}: MobileMenuProps) => {
const deviceType = useDeviceType();
const renderToolbar = () => {
return (
<FixedSideContainer side="top" className="App-top-bar">
@@ -78,20 +74,19 @@ export const MobileMenu = ({
<ShapesSwitcher
appState={appState}
canvas={canvas}
elementType={appState.elementType}
activeTool={appState.activeTool}
setAppState={setAppState}
onImageAction={({ pointerType }) => {
onImageAction({
insertOnCanvasDirectly: pointerType !== "mouse",
});
}}
setDeviceType={setDeviceType}
/>
</Stack.Row>
</Island>
{renderTopRightUI && renderTopRightUI(true, appState)}
<LockButton
checked={appState.elementLocked}
checked={appState.activeTool.locked}
onChange={onLockToggle}
title={t("toolBar.lock")}
isMobile
@@ -106,7 +101,7 @@ export const MobileMenu = ({
onChange={onPenModeToggle}
title={t("toolBar.penMode")}
isMobile
penDetected={deviceType.penDetected}
penDetected={appState.penDetected}
/>
</Stack.Row>
{libraryMenu}
@@ -231,7 +226,7 @@ export const MobileMenu = ({
appState={appState}
elements={elements}
renderAction={actionManager.renderAction}
elementType={appState.elementType}
activeTool={appState.activeTool.type}
/>
</Section>
) : null}

View File

@@ -155,7 +155,7 @@
}
width: 2rem;
height: 2em;
height: 2rem;
}
}

View File

@@ -94,7 +94,9 @@ export const MIME_TYPES = {
excalidrawlib: "application/vnd.excalidrawlib+json",
json: "application/json",
svg: "image/svg+xml",
"excalidraw.svg": "image/svg+xml",
png: "image/png",
"excalidraw.png": "image/png",
jpg: "image/jpeg",
gif: "image/gif",
binary: "application/octet-stream",
@@ -188,3 +190,5 @@ export const VERTICAL_ALIGN = {
MIDDLE: "middle",
BOTTOM: "bottom",
};
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;

View File

@@ -16,15 +16,17 @@
left: 0;
z-index: 999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
pointer-events: none;
}
.LoadingMessage span {
background-color: var(--button-gray-1);
border-radius: 5px;
padding: 0.8em 1.2em;
color: var(--popup-text-color);
font-size: 1.3em;
.Spinner {
font-size: 2.8em;
}
.LoadingMessage-text {
margin-top: 1em;
font-size: 0.8em;
}
}

View File

@@ -1,20 +1,16 @@
import { nanoid } from "nanoid";
import { cleanAppStateForExport } from "../appState";
import {
ALLOWED_IMAGE_MIME_TYPES,
EXPORT_DATA_TYPES,
MIME_TYPES,
} from "../constants";
import { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
import { clearElementsForExport } from "../element";
import { ExcalidrawElement, FileId } from "../element/types";
import { CanvasError } from "../errors";
import { t } from "../i18n";
import { calculateScrollCenter } from "../scene";
import { AppState, DataURL } from "../types";
import { AppState, DataURL, LibraryItem } from "../types";
import { bytesToHexString } from "../utils";
import { FileSystemHandle } from "./filesystem";
import { isValidExcalidrawData } from "./json";
import { restore } from "./restore";
import { isValidExcalidrawData, isValidLibrary } from "./json";
import { restore, restoreLibraryItems } from "./restore";
import { ImportedLibraryData } from "./types";
const parseFileContents = async (blob: Blob | File) => {
@@ -163,13 +159,17 @@ export const loadFromBlob = async (
}
};
export const loadLibraryFromBlob = async (blob: Blob) => {
export const loadLibraryFromBlob = async (
blob: Blob,
defaultStatus: LibraryItem["status"] = "unpublished",
) => {
const contents = await parseFileContents(blob);
const data: ImportedLibraryData = JSON.parse(contents);
if (data.type !== EXPORT_DATA_TYPES.excalidrawLibrary) {
throw new Error(t("alerts.couldNotLoadInvalidFile"));
const data: ImportedLibraryData | undefined = JSON.parse(contents);
if (!isValidLibrary(data)) {
throw new Error("Invalid library");
}
return data;
const libraryItems = data.libraryItems || data.library;
return restoreLibraryItems(libraryItems, defaultStatus);
};
export const canvasToBlob = async (

View File

@@ -13,7 +13,9 @@ type FILE_EXTENSION =
| "gif"
| "jpg"
| "png"
| "excalidraw.png"
| "svg"
| "excalidraw.svg"
| "json"
| "excalidraw"
| "excalidrawlib";

View File

@@ -105,7 +105,9 @@ export const encodeSvgMetadata = async ({ text }: { text: string }) => {
export const decodeSvgMetadata = async ({ svg }: { svg: string }) => {
if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) {
const match = svg.match(/<!-- payload-start -->(.+?)<!-- payload-end -->/);
const match = svg.match(
/<!-- payload-start -->\s*(.+?)\s*<!-- payload-end -->/,
);
if (!match) {
throw new Error("INVALID");
}

View File

@@ -16,7 +16,7 @@ export { loadFromBlob } from "./blob";
export { loadFromJSON, saveAsJSON } from "./json";
export const exportCanvas = async (
type: ExportType,
type: Omit<ExportType, "backend">,
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
@@ -56,7 +56,7 @@ export const exportCanvas = async (
{
description: "Export to SVG",
name,
extension: "svg",
extension: appState.exportEmbedScene ? "excalidraw.svg" : "svg",
fileHandle,
},
);
@@ -73,10 +73,10 @@ export const exportCanvas = async (
});
tempCanvas.style.display = "none";
document.body.appendChild(tempCanvas);
let blob = await canvasToBlob(tempCanvas);
tempCanvas.remove();
if (type === "png") {
let blob = await canvasToBlob(tempCanvas);
tempCanvas.remove();
if (appState.exportEmbedScene) {
blob = await (
await import(/* webpackChunkName: "image" */ "./image")
@@ -89,17 +89,24 @@ export const exportCanvas = async (
return await fileSave(blob, {
description: "Export to PNG",
name,
extension: "png",
extension: appState.exportEmbedScene ? "excalidraw.png" : "png",
fileHandle,
});
} else if (type === "clipboard") {
try {
const blob = canvasToBlob(tempCanvas);
await copyBlobToClipboardAsPng(blob);
} catch (error: any) {
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
throw error;
}
throw new Error(t("alerts.couldNotCopyToClipboard"));
} finally {
tempCanvas.remove();
}
} else {
tempCanvas.remove();
// shouldn't happen
throw new Error("Unsupported export type");
}
};

View File

@@ -15,6 +15,7 @@ import {
ExportedDataState,
ImportedDataState,
ExportedLibraryData,
ImportedLibraryData,
} from "./types";
import Library from "./library";
@@ -114,7 +115,7 @@ export const isValidExcalidrawData = (data?: {
);
};
export const isValidLibrary = (json: any) => {
export const isValidLibrary = (json: any): json is ImportedLibraryData => {
return (
typeof json === "object" &&
json &&
@@ -123,14 +124,18 @@ export const isValidLibrary = (json: any) => {
);
};
export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
export const serializeLibraryAsJSON = (libraryItems: LibraryItems) => {
const data: ExportedLibraryData = {
type: EXPORT_DATA_TYPES.excalidrawLibrary,
version: VERSIONS.excalidrawLibrary,
source: EXPORT_SOURCE,
libraryItems,
};
const serialized = JSON.stringify(data, null, 2);
return JSON.stringify(data, null, 2);
};
export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
const serialized = serializeLibraryAsJSON(libraryItems);
await fileSave(
new Blob([serialized], {
type: MIME_TYPES.excalidrawlib,

View File

@@ -1,11 +1,52 @@
import { loadLibraryFromBlob } from "./blob";
import { LibraryItems, LibraryItem } from "../types";
import { restoreElements, restoreLibraryItems } from "./restore";
import { getNonDeletedElements } from "../element";
import { restoreLibraryItems } from "./restore";
import type App from "../components/App";
import { ImportedDataState } from "./types";
import { atom } from "jotai";
import { jotaiStore } from "../jotai";
import { isPromiseLike } from "../utils";
import { t } from "../i18n";
export const libraryItemsAtom = atom<
| { status: "loading"; libraryItems: null; promise: Promise<LibraryItems> }
| { status: "loaded"; libraryItems: LibraryItems }
>({ status: "loaded", libraryItems: [] });
const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
JSON.parse(JSON.stringify(libraryItems));
/**
* checks if library item does not exist already in current library
*/
const isUniqueItem = (
existingLibraryItems: LibraryItems,
targetLibraryItem: LibraryItem,
) => {
return !existingLibraryItems.find((libraryItem) => {
if (libraryItem.elements.length !== targetLibraryItem.elements.length) {
return false;
}
// detect z-index difference by checking the excalidraw elements
// are in order
return libraryItem.elements.every((libItemExcalidrawItem, idx) => {
return (
libItemExcalidrawItem.id === targetLibraryItem.elements[idx].id &&
libItemExcalidrawItem.versionNonce ===
targetLibraryItem.elements[idx].versionNonce
);
});
});
};
class Library {
private libraryCache: LibraryItems | null = null;
/** cache for currently active promise when initializing/updating libaries
asynchronously */
private libraryItemsPromise: Promise<LibraryItems> | null = null;
/** last resolved libraryItems */
private lastLibraryItems: LibraryItems = [];
private app: App;
constructor(app: App) {
@@ -13,107 +54,92 @@ class Library {
}
resetLibrary = async () => {
await this.app.props.onLibraryChange?.([]);
this.libraryCache = [];
};
restoreLibraryItem = (libraryItem: LibraryItem): LibraryItem | null => {
const elements = getNonDeletedElements(
restoreElements(libraryItem.elements, null),
);
return elements.length ? { ...libraryItem, elements } : null;
this.saveLibrary([]);
};
/** imports library (currently merges, removing duplicates) */
async importLibrary(blob: Blob, defaultStatus = "unpublished") {
const libraryFile = await loadLibraryFromBlob(blob);
if (!libraryFile || !(libraryFile.libraryItems || libraryFile.library)) {
return;
}
async importLibrary(
library:
| Blob
| Required<ImportedDataState>["libraryItems"]
| Promise<Required<ImportedDataState>["libraryItems"]>,
defaultStatus: LibraryItem["status"] = "unpublished",
) {
return this.saveLibrary(
new Promise<LibraryItems>(async (resolve, reject) => {
try {
let libraryItems: LibraryItems;
if (library instanceof Blob) {
libraryItems = await loadLibraryFromBlob(library, defaultStatus);
} else {
libraryItems = restoreLibraryItems(await library, defaultStatus);
}
/**
* checks if library item does not exist already in current library
*/
const isUniqueitem = (
existingLibraryItems: LibraryItems,
targetLibraryItem: LibraryItem,
) => {
return !existingLibraryItems.find((libraryItem) => {
if (libraryItem.elements.length !== targetLibraryItem.elements.length) {
return false;
const existingLibraryItems = this.lastLibraryItems;
const filteredItems = [];
for (const item of libraryItems) {
if (isUniqueItem(existingLibraryItems, item)) {
filteredItems.push(item);
}
}
resolve([...filteredItems, ...existingLibraryItems]);
} catch (error) {
reject(new Error(t("errors.importLibraryError")));
}
// detect z-index difference by checking the excalidraw elements
// are in order
return libraryItem.elements.every((libItemExcalidrawItem, idx) => {
return (
libItemExcalidrawItem.id === targetLibraryItem.elements[idx].id &&
libItemExcalidrawItem.versionNonce ===
targetLibraryItem.elements[idx].versionNonce
);
});
});
};
const existingLibraryItems = await this.loadLibrary();
const library = libraryFile.libraryItems || libraryFile.library || [];
const restoredLibItems = restoreLibraryItems(
library,
defaultStatus as "published" | "unpublished",
}),
);
const filteredItems = [];
for (const item of restoredLibItems) {
const restoredItem = this.restoreLibraryItem(item as LibraryItem);
if (restoredItem && isUniqueitem(existingLibraryItems, restoredItem)) {
filteredItems.push(restoredItem);
}
}
await this.saveLibrary([...filteredItems, ...existingLibraryItems]);
}
loadLibrary = (): Promise<LibraryItems> => {
return new Promise(async (resolve) => {
if (this.libraryCache) {
return resolve(JSON.parse(JSON.stringify(this.libraryCache)));
}
try {
const libraryItems = this.app.libraryItemsFromStorage;
if (!libraryItems) {
return resolve([]);
}
const items = libraryItems.reduce((acc, item) => {
const restoredItem = this.restoreLibraryItem(item);
if (restoredItem) {
acc.push(item);
}
return acc;
}, [] as Mutable<LibraryItems>);
// clone to ensure we don't mutate the cached library elements in the app
this.libraryCache = JSON.parse(JSON.stringify(items));
resolve(items);
} catch (error: any) {
console.error(error);
resolve([]);
resolve(
cloneLibraryItems(
await (this.libraryItemsPromise || this.lastLibraryItems),
),
);
} catch (error) {
return resolve(this.lastLibraryItems);
}
});
};
saveLibrary = async (items: LibraryItems) => {
const prevLibraryItems = this.libraryCache;
saveLibrary = async (items: LibraryItems | Promise<LibraryItems>) => {
const prevLibraryItems = this.lastLibraryItems;
try {
const serializedItems = JSON.stringify(items);
// cache optimistically so that the app has access to the latest
// immediately
this.libraryCache = JSON.parse(serializedItems);
await this.app.props.onLibraryChange?.(items);
let nextLibraryItems;
if (isPromiseLike(items)) {
const promise = items.then((items) => cloneLibraryItems(items));
this.libraryItemsPromise = promise;
jotaiStore.set(libraryItemsAtom, {
status: "loading",
promise,
libraryItems: null,
});
nextLibraryItems = await promise;
} else {
nextLibraryItems = cloneLibraryItems(items);
}
this.lastLibraryItems = nextLibraryItems;
this.libraryItemsPromise = null;
jotaiStore.set(libraryItemsAtom, {
status: "loaded",
libraryItems: nextLibraryItems,
});
await this.app.props.onLibraryChange?.(
cloneLibraryItems(nextLibraryItems),
);
} catch (error: any) {
this.libraryCache = prevLibraryItems;
this.lastLibraryItems = prevLibraryItems;
this.libraryItemsPromise = null;
jotaiStore.set(libraryItemsAtom, {
status: "loaded",
libraryItems: prevLibraryItems,
});
throw error;
}
};

View File

@@ -10,7 +10,11 @@ import {
NormalizedZoomValue,
} from "../types";
import { ImportedDataState } from "./types";
import { getNormalizedDimensions, isInvisiblySmallElement } from "../element";
import {
getNonDeletedElements,
getNormalizedDimensions,
isInvisiblySmallElement,
} from "../element";
import { isLinearElementType } from "../element/typeChecks";
import { randomId } from "../random";
import {
@@ -30,8 +34,8 @@ type RestoredAppState = Omit<
"offsetTop" | "offsetLeft" | "width" | "height"
>;
export const AllowedExcalidrawElementTypes: Record<
AppState["elementType"],
export const AllowedExcalidrawActiveTools: Record<
AppState["activeTool"]["type"],
boolean
> = {
selection: true,
@@ -107,6 +111,7 @@ const restoreElementWithProperties = <
: element.boundElements ?? [],
updated: element.updated ?? getUpdatedTimestamp(),
link: element.link ?? null,
locked: element.locked ?? false,
};
return {
@@ -235,10 +240,8 @@ export const restoreAppState = (
localAppState: Partial<AppState> | null | undefined,
): RestoredAppState => {
appState = appState || {};
const defaultAppState = getDefaultAppState();
const nextAppState = {} as typeof defaultAppState;
for (const [key, defaultValue] of Object.entries(defaultAppState) as [
keyof typeof defaultAppState,
any,
@@ -252,12 +255,20 @@ export const restoreAppState = (
? localValue
: defaultValue;
}
return {
...nextAppState,
elementType: AllowedExcalidrawElementTypes[nextAppState.elementType]
? nextAppState.elementType
: "selection",
cursorButton: localAppState?.cursorButton || "up",
// reset on fresh restore so as to hide the UI button if penMode not active
penDetected:
localAppState?.penDetected ??
(appState.penMode ? appState.penDetected ?? false : false),
activeTool: {
lastActiveToolBeforeEraser: null,
locked: nextAppState.activeTool.locked ?? false,
type: AllowedExcalidrawActiveTools[nextAppState.activeTool.type]
? nextAppState.activeTool.type ?? "selection"
: "selection",
},
// Migrates from previous version where appState.zoom was a number
zoom:
typeof appState.zoom === "number"
@@ -269,7 +280,7 @@ export const restoreAppState = (
};
export const restore = (
data: ImportedDataState | null,
data: Pick<ImportedDataState, "appState" | "elements" | "files"> | null,
/**
* Local AppState (`this.state` or initial state from localStorage) so that we
* don't overwrite local state with default values (when values not
@@ -286,28 +297,45 @@ export const restore = (
};
};
const restoreLibraryItem = (libraryItem: LibraryItem) => {
const elements = restoreElements(
getNonDeletedElements(libraryItem.elements),
null,
);
return elements.length ? { ...libraryItem, elements } : null;
};
export const restoreLibraryItems = (
libraryItems: NonOptional<ImportedDataState["libraryItems"]>,
libraryItems: ImportedDataState["libraryItems"] = [],
defaultStatus: LibraryItem["status"],
) => {
const restoredItems: LibraryItem[] = [];
for (const item of libraryItems) {
// migrate older libraries
if (Array.isArray(item)) {
restoredItems.push({
const restoredItem = restoreLibraryItem({
status: defaultStatus,
elements: item,
id: randomId(),
created: Date.now(),
});
if (restoredItem) {
restoredItems.push(restoredItem);
}
} else {
const _item = item as MarkOptional<LibraryItem, "id" | "status">;
restoredItems.push({
const _item = item as MarkOptional<
LibraryItem,
"id" | "status" | "created"
>;
const restoredItem = restoreLibraryItem({
..._item,
id: _item.id || randomId(),
status: _item.status || defaultStatus,
created: _item.created || Date.now(),
});
if (restoredItem) {
restoredItems.push(restoredItem);
}
}
}
return restoredItems;

View File

@@ -262,9 +262,7 @@ export const actionLink = register({
commitToHistory: true,
};
},
trackEvent: (action, source) => {
trackEvent("hyperlink", "edit", source);
},
trackEvent: { category: "hyperlink", action: "click" },
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K,
contextItemLabel: (elements, appState) =>
getContextMenuLabel(elements, appState),
@@ -337,6 +335,9 @@ export const isPointHittingLinkIcon = (
[x, y]: Point,
isMobile: boolean,
) => {
if (!element.link || appState.selectedElementIds[element.id]) {
return false;
}
const threshold = 4 / appState.zoom.value;
if (
!isMobile &&

View File

@@ -255,7 +255,8 @@ export const getHoveredElementForBinding = (
const hoveredElement = getElementAtPosition(
scene.getElements(),
(element) =>
isBindableElement(element) && bindingBorderTest(element, pointerCoords),
isBindableElement(element, false) &&
bindingBorderTest(element, pointerCoords),
);
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
};
@@ -456,13 +457,13 @@ export const getEligibleElementsForBinding = (
): SuggestedBinding[] => {
const includedElementIds = new Set(elements.map(({ id }) => id));
return elements.flatMap((element) =>
isBindingElement(element)
isBindingElement(element, false)
? (getElligibleElementsForBindingElement(
element as NonDeleted<ExcalidrawLinearElement>,
).filter(
(element) => !includedElementIds.has(element.id),
) as SuggestedBinding[])
: isBindableElement(element)
: isBindableElement(element, false)
? getElligibleElementsForBindableElementAndWhere(element).filter(
(binding) => !includedElementIds.has(binding[0].id),
)
@@ -508,7 +509,7 @@ const getElligibleElementsForBindableElementAndWhere = (
return Scene.getScene(bindableElement)!
.getElements()
.map((element) => {
if (!isBindingElement(element)) {
if (!isBindingElement(element, false)) {
return null;
}
const canBindStart = isLinearElementEligibleForNewBindingByBindable(
@@ -659,28 +660,47 @@ export const fixBindingsAfterDeletion = (
const deletedElementIds = new Set(
deletedElements.map((element) => element.id),
);
// Non deleted and need an update
const boundElementIds: Set<ExcalidrawElement["id"]> = new Set();
// non-deleted which bindings need to be updated
const affectedElements: Set<ExcalidrawElement["id"]> = new Set();
deletedElements.forEach((deletedElement) => {
if (isBindableElement(deletedElement)) {
deletedElement.boundElements?.forEach((element) => {
if (!deletedElementIds.has(element.id)) {
boundElementIds.add(element.id);
affectedElements.add(element.id);
}
});
} else if (isBindingElement(deletedElement)) {
if (deletedElement.startBinding) {
affectedElements.add(deletedElement.startBinding.elementId);
}
if (deletedElement.endBinding) {
affectedElements.add(deletedElement.endBinding.elementId);
}
}
});
(
sceneElements.filter(({ id }) =>
boundElementIds.has(id),
) as ExcalidrawLinearElement[]
).forEach((element: ExcalidrawLinearElement) => {
const { startBinding, endBinding } = element;
mutateElement(element, {
startBinding: newBindingAfterDeletion(startBinding, deletedElementIds),
endBinding: newBindingAfterDeletion(endBinding, deletedElementIds),
sceneElements
.filter(({ id }) => affectedElements.has(id))
.forEach((element) => {
if (isBindableElement(element)) {
mutateElement(element, {
boundElements: newBoundElementsAfterDeletion(
element.boundElements,
deletedElementIds,
),
});
} else if (isBindingElement(element)) {
mutateElement(element, {
startBinding: newBindingAfterDeletion(
element.startBinding,
deletedElementIds,
),
endBinding: newBindingAfterDeletion(
element.endBinding,
deletedElementIds,
),
});
}
});
});
};
const newBindingAfterDeletion = (
@@ -692,3 +712,13 @@ const newBindingAfterDeletion = (
}
return binding;
};
const newBoundElementsAfterDeletion = (
boundElements: ExcalidrawElement["boundElements"],
deletedElementIds: Set<ExcalidrawElement["id"]>,
) => {
if (!boundElements) {
return null;
}
return boundElements.filter((ele) => !deletedElementIds.has(ele.id));
};

View File

@@ -92,7 +92,7 @@ export const getDragOffsetXY = (
export const dragNewElement = (
draggingElement: NonDeletedExcalidrawElement,
elementType: AppState["elementType"],
elementType: AppState["activeTool"]["type"],
originX: number,
originY: number,
x: number,

View File

@@ -106,6 +106,20 @@ export const normalizeSVG = async (SVGString: string) => {
svg.setAttribute("xmlns", SVG_NS);
}
if (!svg.hasAttribute("width") || !svg.hasAttribute("height")) {
const viewBox = svg.getAttribute("viewBox");
let width = svg.getAttribute("width") || "50";
let height = svg.getAttribute("height") || "50";
if (viewBox) {
const match = viewBox.match(/\d+ +\d+ +(\d+) +(\d+)/);
if (match) {
[, width, height] = match;
}
}
svg.setAttribute("width", width);
svg.setAttribute("height", height);
}
return svg.outerHTML;
}
};

View File

@@ -205,7 +205,7 @@ export class LinearElementEditor {
);
// suggest bindings for first and last point if selected
if (isBindingElement(element)) {
if (isBindingElement(element, false)) {
const coords: { x: number; y: number }[] = [];
const firstSelectedIndex = selectedPointsIndices[0];

View File

@@ -2,11 +2,7 @@ import { duplicateElement } from "./newElement";
import { mutateElement } from "./mutateElement";
import { API } from "../tests/helpers/api";
import { FONT_FAMILY } from "../constants";
const isPrimitive = (val: any) => {
const type = typeof val;
return val == null || (type !== "object" && type !== "function");
};
import { isPrimitive } from "../utils";
const assertCloneObjects = (source: any, clone: any) => {
for (const key in clone) {

View File

@@ -56,6 +56,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
strokeSharpness,
boundElements = null,
link = null,
locked,
...rest
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
) => {
@@ -83,6 +84,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
boundElements,
updated: getUpdatedTimestamp(),
link,
locked,
};
return element;
};

View File

@@ -12,6 +12,7 @@ import {
ExcalidrawTextElement,
NonDeletedExcalidrawElement,
NonDeleted,
ExcalidrawElement,
} from "./types";
import {
getElementAbsoluteCoords,
@@ -186,7 +187,7 @@ const validateTwoPointElementNormalized = (
};
const getPerfectElementSizeWithRotation = (
elementType: string,
elementType: ExcalidrawElement["type"],
width: number,
height: number,
angle: number,

View File

@@ -10,6 +10,6 @@ export const showSelectedShapeActions = (
!appState.viewModeEnabled &&
(appState.editingElement ||
getSelectedElements(elements, appState).length ||
(appState.elementType !== "selection" &&
appState.elementType !== "eraser")),
(appState.activeTool.type !== "selection" &&
appState.activeTool.type !== "eraser")),
);

View File

@@ -2,6 +2,7 @@ import { ExcalidrawElement } from "./types";
import { mutateElement } from "./mutateElement";
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
import { SHIFT_LOCKING_ANGLE } from "../constants";
import { AppState } from "../types";
export const isInvisiblySmallElement = (
element: ExcalidrawElement,
@@ -16,7 +17,7 @@ export const isInvisiblySmallElement = (
* Makes a perfect shape or diagonal/horizontal/vertical line
*/
export const getPerfectElementSize = (
elementType: string,
elementType: AppState["activeTool"]["type"],
width: number,
height: number,
): { width: number; height: number } => {

View File

@@ -10,13 +10,11 @@ import { mutateElement } from "./mutateElement";
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
import { MaybeTransformHandleType } from "./transformHandles";
import Scene from "../scene/Scene";
import { AppState } from "../types";
import { isTextElement } from ".";
export const redrawTextBoundingBox = (
element: ExcalidrawTextElement,
container: ExcalidrawElement | null,
appState: AppState,
) => {
const maxWidth = container
? container.width - BOUND_TEXT_PADDING * 2
@@ -35,12 +33,12 @@ export const redrawTextBoundingBox = (
getFontString(element),
maxWidth,
);
let coordY = element.y;
let coordX = element.x;
// Resize container and vertically center align the text
if (container) {
let nextHeight = container.height;
coordX = container.x + BOUND_TEXT_PADDING;
if (element.verticalAlign === VERTICAL_ALIGN.TOP) {
coordY = container.y + BOUND_TEXT_PADDING;
} else if (element.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
@@ -55,12 +53,12 @@ export const redrawTextBoundingBox = (
}
mutateElement(container, { height: nextHeight });
}
mutateElement(element, {
width: metrics.width,
height: metrics.height,
baseline: metrics.baseline,
y: coordY,
x: coordX,
text,
});
};

View File

@@ -704,7 +704,7 @@ describe("textWysiwyg", () => {
expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2);
});
it("should unbind bound text when unbind action from context menu is triggred", async () => {
it("should unbind bound text when unbind action from context menu is triggered", async () => {
expect(h.elements.length).toBe(1);
expect(h.elements[0].id).toBe(rectangle.id);
@@ -745,5 +745,47 @@ describe("textWysiwyg", () => {
null,
);
});
it("shouldn't bind to container if container has bound text", async () => {
expect(h.elements.length).toBe(1);
Keyboard.withModifierKeys({}, () => {
Keyboard.keyPress(KEYS.ENTER);
});
expect(h.elements.length).toBe(2);
// Bind first text
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.containerId).toBe(rectangle.id);
let editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Hello World!" } });
editor.blur();
expect(rectangle.boundElements).toStrictEqual([
{ id: text.id, type: "text" },
]);
// Attempt to bind another text
UI.clickTool("text");
mouse.clickAt(
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
);
mouse.down();
expect(h.elements.length).toBe(3);
text = h.elements[2] as ExcalidrawTextElementWithContainer;
editor = document.querySelector(
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
await new Promise((r) => setTimeout(r, 0));
fireEvent.change(editor, { target: { value: "Whats up?" } });
editor.blur();
expect(rectangle.boundElements).toStrictEqual([
{ id: h.elements[1].id, type: "text" },
]);
expect(text.containerId).toBe(null);
});
});
});

View File

@@ -102,9 +102,11 @@ export const textWysiwyg = ({
const updateWysiwygStyle = () => {
const appState = app.state;
const updatedElement = Scene.getScene(element)?.getElement(
id,
) as ExcalidrawTextElement;
const updatedElement =
Scene.getScene(element)?.getElement<ExcalidrawTextElement>(id);
if (!updatedElement) {
return;
}
const { textAlign, verticalAlign } = updatedElement;
const approxLineHeight = getApproxLineHeight(getFontString(updatedElement));

View File

@@ -222,6 +222,13 @@ export const getTransformHandles = (
zoom: Zoom,
pointerType: PointerType = "mouse",
): TransformHandles => {
// so that when locked element is selected (especially when you toggle lock
// via keyboard) the locked element is visually distinct, indicating
// you can't move/resize
if (element.locked) {
return {};
}
let omitSides: { [T in TransformHandleType]?: boolean } = {};
if (
element.type === "arrow" ||

View File

@@ -61,7 +61,7 @@ export const isLinearElement = (
};
export const isLinearElementType = (
elementType: AppState["elementType"],
elementType: AppState["activeTool"]["type"],
): boolean => {
return (
elementType === "arrow" || elementType === "line" // || elementType === "freedraw"
@@ -70,21 +70,28 @@ export const isLinearElementType = (
export const isBindingElement = (
element?: ExcalidrawElement | null,
includeLocked = true,
): element is ExcalidrawLinearElement => {
return element != null && isBindingElementType(element.type);
return (
element != null &&
(!element.locked || includeLocked === true) &&
isBindingElementType(element.type)
);
};
export const isBindingElementType = (
elementType: AppState["elementType"],
elementType: AppState["activeTool"]["type"],
): boolean => {
return elementType === "arrow";
};
export const isBindableElement = (
element: ExcalidrawElement | null,
includeLocked = true,
): element is ExcalidrawBindableElement => {
return (
element != null &&
(!element.locked || includeLocked === true) &&
(element.type === "rectangle" ||
element.type === "diamond" ||
element.type === "ellipse" ||
@@ -95,9 +102,11 @@ export const isBindableElement = (
export const isTextBindableContainer = (
element: ExcalidrawElement | null,
includeLocked = true,
): element is ExcalidrawTextContainer => {
return (
element != null &&
(!element.locked || includeLocked === true) &&
(element.type === "rectangle" ||
element.type === "diamond" ||
element.type === "ellipse" ||

View File

@@ -55,6 +55,7 @@ type _ExcalidrawElementBase = Readonly<{
/** epoch (ms) timestamp of last element update */
updated: number;
link: string | null;
locked: boolean;
}>;
export type ExcalidrawSelectionElement = _ExcalidrawElementBase & {

View File

@@ -11,12 +11,12 @@ export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
// 1 year (https://stackoverflow.com/a/25201898/927631)
export const FILE_CACHE_MAX_AGE_SEC = 31536000;
export const BROADCAST = {
export const WS_EVENTS = {
SERVER_VOLATILE: "server-volatile-broadcast",
SERVER: "server-broadcast",
};
export enum SCENE {
export enum WS_SCENE_EVENT_TYPES {
INIT = "SCENE_INIT",
UPDATE = "SCENE_UPDATE",
}

View File

@@ -11,6 +11,7 @@ import {
import { getSceneVersion } from "../../packages/excalidraw/index";
import { Collaborator, Gesture } from "../../types";
import {
getFrame,
preventUnload,
resolvablePromise,
withBatchedUpdates,
@@ -21,7 +22,7 @@ import {
FIREBASE_STORAGE_PREFIXES,
INITIAL_SCENE_UPDATE_TIMEOUT,
LOAD_IMAGES_TIMEOUT,
SCENE,
WS_SCENE_EVENT_TYPES,
STORAGE_KEYS,
SYNC_FULL_SCENE_INTERVAL_MS,
} from "../app_constants";
@@ -67,6 +68,7 @@ import {
} from "./reconciliation";
import { decryptData } from "../../data/encryption";
import { resetBrowserStateVersions } from "../data/tabSync";
import { LocalData } from "../data/LocalData";
interface CollabState {
modalIsShown: boolean;
@@ -86,7 +88,7 @@ export interface CollabAPI {
onPointerUpdate: CollabInstance["onPointerUpdate"];
initializeSocketClient: CollabInstance["initializeSocketClient"];
onCollabButtonClick: CollabInstance["onCollabButtonClick"];
broadcastElements: CollabInstance["broadcastElements"];
syncElements: CollabInstance["syncElements"];
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
setUsername: (username: string) => void;
}
@@ -108,10 +110,11 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
portal: Portal;
fileManager: FileManager;
excalidrawAPI: Props["excalidrawAPI"];
isCollaborating: boolean = false;
activeIntervalId: number | null;
idleTimeoutId: number | null;
// marked as private to ensure we don't change it outside this class
private _isCollaborating: boolean = false;
private socketInitializationTimer?: number;
private lastBroadcastedOrReceivedSceneVersion: number = -1;
private collaborators = new Map<string, Collaborator>();
@@ -192,6 +195,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
}
}
isCollaborating = () => this._isCollaborating;
private onUnload = () => {
this.destroySocketClient({ isUnload: true });
};
@@ -202,7 +207,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
);
if (
this.isCollaborating &&
this._isCollaborating &&
(this.fileManager.shouldPreventUnload(syncableElements) ||
!isSavedToFirebase(this.portal, syncableElements))
) {
@@ -227,27 +232,40 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
});
saveCollabRoomToFirebase = async (
syncableElements: readonly ExcalidrawElement[] = this.getSyncableElements(
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
),
syncableElements: readonly ExcalidrawElement[],
) => {
try {
await saveToFirebase(this.portal, syncableElements);
const savedData = await saveToFirebase(
this.portal,
syncableElements,
this.excalidrawAPI.getAppState(),
);
if (this.isCollaborating() && savedData && savedData.reconciledElements) {
this.handleRemoteSceneUpdate(
this.reconcileElements(savedData.reconciledElements),
);
}
} catch (error: any) {
console.error(error);
}
};
openPortal = async () => {
trackEvent("share", "room creation");
trackEvent("share", "room creation", `ui (${getFrame()})`);
return this.initializeSocketClient(null);
};
closePortal = () => {
this.queueBroadcastAllElements.cancel();
this.queueSaveToFirebase.cancel();
this.loadImageFiles.cancel();
this.saveCollabRoomToFirebase();
this.saveCollabRoomToFirebase(
this.getSyncableElements(
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
),
);
if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
// hack to ensure that we prefer we disregard any new browser state
// that could have been saved in other tabs while we were collaborating
@@ -284,7 +302,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
this.setState({
activeRoomLink: "",
});
this.isCollaborating = false;
this._isCollaborating = false;
LocalData.resumeSave("collaboration");
}
this.lastBroadcastedOrReceivedSceneVersion = -1;
this.portal.close();
@@ -352,7 +371,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
const scenePromise = resolvablePromise<ImportedDataState | null>();
this.isCollaborating = true;
this._isCollaborating = true;
LocalData.pauseSave("collaboration");
const { default: socketIOClient } = await import(
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
@@ -393,10 +413,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
commitToHistory: true,
});
this.broadcastElements(elements);
const syncableElements = this.getSyncableElements(elements);
this.saveCollabRoomToFirebase(syncableElements);
this.saveCollabRoomToFirebase(this.getSyncableElements(elements));
}
// fallback in case you're not alone in the room but still don't receive
@@ -426,7 +443,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
switch (decryptedData.type) {
case "INVALID_RESPONSE":
return;
case SCENE.INIT: {
case WS_SCENE_EVENT_TYPES.INIT: {
if (!this.portal.socketInitialized) {
this.initializeRoom({ fetchScene: false });
const remoteElements = decryptedData.payload.elements;
@@ -442,7 +459,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
}
break;
}
case SCENE.UPDATE:
case WS_SCENE_EVENT_TYPES.UPDATE:
this.handleRemoteSceneUpdate(
this.reconcileElements(decryptedData.payload.elements),
);
@@ -704,15 +721,20 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
getSceneVersion(elements) >
this.getLastBroadcastedOrReceivedSceneVersion()
) {
this.portal.broadcastScene(SCENE.UPDATE, elements, false);
this.portal.broadcastScene(WS_SCENE_EVENT_TYPES.UPDATE, elements, false);
this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);
this.queueBroadcastAllElements();
}
};
syncElements = (elements: readonly ExcalidrawElement[]) => {
this.broadcastElements(elements);
this.queueSaveToFirebase();
};
queueBroadcastAllElements = throttle(() => {
this.portal.broadcastScene(
SCENE.UPDATE,
WS_SCENE_EVENT_TYPES.UPDATE,
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
true,
);
@@ -724,6 +746,16 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
this.setLastBroadcastedOrReceivedSceneVersion(newVersion);
}, SYNC_FULL_SCENE_INTERVAL_MS);
queueSaveToFirebase = throttle(() => {
if (this.portal.socketInitialized) {
this.saveCollabRoomToFirebase(
this.getSyncableElements(
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
),
);
}
}, SYNC_FULL_SCENE_INTERVAL_MS);
handleClose = () => {
this.setState({ modalIsShown: false });
};
@@ -759,12 +791,12 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
this.contextValue = {} as CollabAPI;
}
this.contextValue.isCollaborating = () => this.isCollaborating;
this.contextValue.isCollaborating = this.isCollaborating;
this.contextValue.username = this.state.username;
this.contextValue.onPointerUpdate = this.onPointerUpdate;
this.contextValue.initializeSocketClient = this.initializeSocketClient;
this.contextValue.onCollabButtonClick = this.onCollabButtonClick;
this.contextValue.broadcastElements = this.broadcastElements;
this.contextValue.syncElements = this.syncElements;
this.contextValue.fetchImageFilesFromFirebase =
this.fetchImageFilesFromFirebase;
this.contextValue.setUsername = this.setUsername;

View File

@@ -3,7 +3,11 @@ import { SocketUpdateData, SocketUpdateDataSource } from "../data";
import CollabWrapper from "./CollabWrapper";
import { ExcalidrawElement } from "../../element/types";
import { BROADCAST, FILE_UPLOAD_TIMEOUT, SCENE } from "../app_constants";
import {
WS_EVENTS,
FILE_UPLOAD_TIMEOUT,
WS_SCENE_EVENT_TYPES,
} from "../app_constants";
import { UserIdleState } from "../../types";
import { trackEvent } from "../../analytics";
import { throttle } from "lodash";
@@ -37,7 +41,7 @@ class Portal {
});
this.socket.on("new-user", async (_socketId: string) => {
this.broadcastScene(
SCENE.INIT,
WS_SCENE_EVENT_TYPES.INIT,
this.collab.getSceneElementsIncludingDeleted(),
/* syncAll */ true,
);
@@ -81,7 +85,7 @@ class Portal {
const { encryptedBuffer, iv } = await encryptData(this.roomKey!, encoded);
this.socket?.emit(
volatile ? BROADCAST.SERVER_VOLATILE : BROADCAST.SERVER,
volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER,
this.roomId,
encryptedBuffer,
iv,
@@ -121,11 +125,11 @@ class Portal {
}, FILE_UPLOAD_TIMEOUT);
broadcastScene = async (
sceneType: SCENE.INIT | SCENE.UPDATE,
updateType: WS_SCENE_EVENT_TYPES.INIT | WS_SCENE_EVENT_TYPES.UPDATE,
allElements: readonly ExcalidrawElement[],
syncAll: boolean,
) => {
if (sceneType === SCENE.INIT && !syncAll) {
if (updateType === WS_SCENE_EVENT_TYPES.INIT && !syncAll) {
throw new Error("syncAll must be true when sending SCENE.INIT");
}
@@ -152,8 +156,8 @@ class Portal {
[] as BroadcastedExcalidrawElement[],
);
const data: SocketUpdateDataSource[typeof sceneType] = {
type: sceneType,
const data: SocketUpdateDataSource[typeof updateType] = {
type: updateType,
payload: {
elements: syncableElements,
},
@@ -166,20 +170,9 @@ class Portal {
);
}
const broadcastPromise = this._broadcastSocketData(
data as SocketUpdateData,
);
this.queueFileUpload();
if (syncAll && this.collab.isCollaborating) {
await Promise.all([
broadcastPromise,
this.collab.saveCollabRoomToFirebase(syncableElements),
]);
} else {
await broadcastPromise;
}
await this._broadcastSocketData(data as SocketUpdateData);
};
broadcastIdleChange = (userState: UserIdleState) => {

View File

@@ -78,8 +78,14 @@ export const reconcileElements = (
continue;
}
// Mark duplicate for removal as it'll be replaced with the remote element
if (local) {
// mark for removal since it'll be replaced with the remote element
// Unless the ramote and local elements are the same element in which case
// we need to keep it as we'd otherwise discard it from the resulting
// array.
if (local[0] === remoteElement) {
continue;
}
duplicates.set(local[0], true);
}

View File

@@ -13,6 +13,8 @@ import { isInitializedImageElement } from "../../element/typeChecks";
import { FILE_UPLOAD_MAX_BYTES } from "../app_constants";
import { encodeFilesForUpload } from "../data/FileManager";
import { MIME_TYPES } from "../../constants";
import { trackEvent } from "../../analytics";
import { getFrame } from "../../utils";
const exportToExcalidrawPlus = async (
elements: readonly NonDeletedExcalidrawElement[],
@@ -92,6 +94,7 @@ export const ExportToExcalidrawPlus: React.FC<{
showAriaLabel={true}
onClick={async () => {
try {
trackEvent("export", "eplus", `ui (${getFrame()})`);
await exportToExcalidrawPlus(elements, appState, files);
} catch (error: any) {
console.error(error);

View File

@@ -0,0 +1,154 @@
/**
* This file deals with saving data state (appState, elements, images, ...)
* locally to the browser.
*
* Notes:
*
* - DataState refers to full state of the app: appState, elements, images,
* though some state is saved separately (collab username, library) for one
* reason or another. We also save different data to different sotrage
* (localStorage, indexedDB).
*/
import { createStore, keys, del, getMany, set } from "idb-keyval";
import { clearAppStateForLocalStorage } from "../../appState";
import { clearElementsForLocalStorage } from "../../element";
import { ExcalidrawElement, FileId } from "../../element/types";
import { AppState, BinaryFileData, BinaryFiles } from "../../types";
import { debounce } from "../../utils";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
import { FileManager } from "./FileManager";
import { Locker } from "./Locker";
import { updateBrowserStateVersion } from "./tabSync";
const filesStore = createStore("files-db", "files-store");
class LocalFileManager extends FileManager {
clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
const allIds = await keys(filesStore);
for (const id of allIds) {
if (!opts.currentFileIds.includes(id as FileId)) {
del(id, filesStore);
}
}
};
}
const saveDataStateToLocalStorage = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
try {
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
JSON.stringify(clearElementsForLocalStorage(elements)),
);
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
JSON.stringify(clearAppStateForLocalStorage(appState)),
);
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
} catch (error: any) {
// Unable to access window.localStorage
console.error(error);
}
};
type SavingLockTypes = "collaboration";
export class LocalData {
private static _save = debounce(
async (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
onFilesSaved: () => void,
) => {
saveDataStateToLocalStorage(elements, appState);
await this.fileStorage.saveFiles({
elements,
files,
});
onFilesSaved();
},
SAVE_TO_LOCAL_STORAGE_TIMEOUT,
);
/** Saves DataState, including files. Bails if saving is paused */
static save = (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
onFilesSaved: () => void,
) => {
// we need to make the `isSavePaused` check synchronously (undebounced)
if (!this.isSavePaused()) {
this._save(elements, appState, files, onFilesSaved);
}
};
static flushSave = () => {
this._save.flush();
};
private static locker = new Locker<SavingLockTypes>();
static pauseSave = (lockType: SavingLockTypes) => {
this.locker.lock(lockType);
};
static resumeSave = (lockType: SavingLockTypes) => {
this.locker.unlock(lockType);
};
static isSavePaused = () => {
return document.hidden || this.locker.isLocked();
};
// ---------------------------------------------------------------------------
static fileStorage = new LocalFileManager({
getFiles(ids) {
return getMany(ids, filesStore).then(
(filesData: (BinaryFileData | undefined)[]) => {
const loadedFiles: BinaryFileData[] = [];
const erroredFiles = new Map<FileId, true>();
filesData.forEach((data, index) => {
const id = ids[index];
if (data) {
loadedFiles.push(data);
} else {
erroredFiles.set(id, true);
}
});
return { loadedFiles, erroredFiles };
},
);
},
async saveFiles({ addedFiles }) {
const savedFiles = new Map<FileId, true>();
const erroredFiles = new Map<FileId, true>();
// before we use `storage` event synchronization, let's update the flag
// optimistically. Hopefully nothing fails, and an IDB read executed
// before an IDB write finishes will read the latest value.
updateBrowserStateVersion(STORAGE_KEYS.VERSION_FILES);
await Promise.all(
[...addedFiles].map(async ([id, fileData]) => {
try {
await set(id, fileData, filesStore);
savedFiles.set(id, true);
} catch (error: any) {
console.error(error);
erroredFiles.set(id, true);
}
}),
);
return { savedFiles, erroredFiles };
},
});
}

View File

@@ -0,0 +1,18 @@
export class Locker<T extends string> {
private locks = new Map<T, true>();
lock = (lockType: T) => {
this.locks.set(lockType, true);
};
/** @returns whether no locks remaining */
unlock = (lockType: T) => {
this.locks.delete(lockType);
return !this.isLocked();
};
/** @returns whether some (or specific) locks are present */
isLocked(lockType?: T) {
return lockType ? this.locks.has(lockType) : !!this.locks.size;
}
}

View File

@@ -2,11 +2,17 @@ import { ExcalidrawElement, FileId } from "../../element/types";
import { getSceneVersion } from "../../element";
import Portal from "../collab/Portal";
import { restoreElements } from "../../data/restore";
import { BinaryFileData, BinaryFileMetadata, DataURL } from "../../types";
import {
AppState,
BinaryFileData,
BinaryFileMetadata,
DataURL,
} from "../../types";
import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
import { decompressData } from "../../data/encode";
import { encryptData, decryptData } from "../../data/encryption";
import { MIME_TYPES } from "../../constants";
import { reconcileElements } from "../collab/reconciliation";
// private
// -----------------------------------------------------------------------------
@@ -108,11 +114,13 @@ const encryptElements = async (
};
const decryptElements = async (
key: string,
iv: Uint8Array,
ciphertext: ArrayBuffer | Uint8Array,
data: FirebaseStoredScene,
roomKey: string,
): Promise<readonly ExcalidrawElement[]> => {
const decrypted = await decryptData(iv, ciphertext, key);
const ciphertext = data.ciphertext.toUint8Array();
const iv = data.iv.toUint8Array();
const decrypted = await decryptData(iv, ciphertext, roomKey);
const decodedData = new TextDecoder("utf-8").decode(
new Uint8Array(decrypted),
);
@@ -171,57 +179,86 @@ export const saveFilesToFirebase = async ({
return { savedFiles, erroredFiles };
};
export const saveToFirebase = async (
portal: Portal,
const createFirebaseSceneDocument = async (
firebase: ResolutionType<typeof loadFirestore>,
elements: readonly ExcalidrawElement[],
roomKey: string,
) => {
const { roomId, roomKey, socket } = portal;
if (
// if no room exists, consider the room saved because there's nothing we can
// do at this point
!roomId ||
!roomKey ||
!socket ||
isSavedToFirebase(portal, elements)
) {
return true;
}
const firebase = await loadFirestore();
const sceneVersion = getSceneVersion(elements);
const { ciphertext, iv } = await encryptElements(roomKey, elements);
const nextDocData = {
return {
sceneVersion,
ciphertext: firebase.firestore.Blob.fromUint8Array(
new Uint8Array(ciphertext),
),
iv: firebase.firestore.Blob.fromUint8Array(iv),
} as FirebaseStoredScene;
};
const db = firebase.firestore();
const docRef = db.collection("scenes").doc(roomId);
const didUpdate = await db.runTransaction(async (transaction) => {
const doc = await transaction.get(docRef);
if (!doc.exists) {
transaction.set(docRef, nextDocData);
return true;
}
const prevDocData = doc.data() as FirebaseStoredScene;
if (prevDocData.sceneVersion >= nextDocData.sceneVersion) {
return false;
}
transaction.update(docRef, nextDocData);
return true;
});
if (didUpdate) {
firebaseSceneVersionCache.set(socket, sceneVersion);
export const saveToFirebase = async (
portal: Portal,
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
const { roomId, roomKey, socket } = portal;
if (
// bail if no room exists as there's nothing we can do at this point
!roomId ||
!roomKey ||
!socket ||
isSavedToFirebase(portal, elements)
) {
return false;
}
return didUpdate;
const firebase = await loadFirestore();
const firestore = firebase.firestore();
const docRef = firestore.collection("scenes").doc(roomId);
const savedData = await firestore.runTransaction(async (transaction) => {
const snapshot = await transaction.get(docRef);
if (!snapshot.exists) {
const sceneDocument = await createFirebaseSceneDocument(
firebase,
elements,
roomKey,
);
transaction.set(docRef, sceneDocument);
return {
sceneVersion: sceneDocument.sceneVersion,
reconciledElements: null,
};
}
const prevDocData = snapshot.data() as FirebaseStoredScene;
const prevElements = await decryptElements(prevDocData, roomKey);
const reconciledElements = reconcileElements(
elements,
prevElements,
appState,
);
const sceneDocument = await createFirebaseSceneDocument(
firebase,
reconciledElements,
roomKey,
);
transaction.update(docRef, sceneDocument);
return {
reconciledElements,
sceneVersion: sceneDocument.sceneVersion,
};
});
firebaseSceneVersionCache.set(socket, savedData.sceneVersion);
return savedData;
};
export const loadFromFirebase = async (
@@ -238,10 +275,7 @@ export const loadFromFirebase = async (
return null;
}
const storedScene = doc.data() as FirebaseStoredScene;
const ciphertext = storedScene.ciphertext.toUint8Array();
const iv = storedScene.iv.toUint8Array();
const elements = await decryptElements(roomKey, iv, ciphertext);
const elements = await decryptElements(storedScene, roomKey);
if (socket) {
firebaseSceneVersionCache.set(socket, getSceneVersion(elements));

View File

@@ -5,8 +5,8 @@ import {
getDefaultAppState,
} from "../../appState";
import { clearElementsForLocalStorage } from "../../element";
import { updateBrowserStateVersion } from "./tabSync";
import { STORAGE_KEYS } from "../app_constants";
import { ImportedDataState } from "../../data/types";
export const saveUsernameToLocalStorage = (username: string) => {
try {
@@ -34,26 +34,6 @@ export const importUsernameFromLocalStorage = (): string | null => {
return null;
};
export const saveToLocalStorage = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
try {
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
JSON.stringify(clearElementsForLocalStorage(elements)),
);
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
JSON.stringify(clearAppStateForLocalStorage(appState)),
);
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
} catch (error: any) {
// Unable to access window.localStorage
console.error(error);
}
};
export const importFromLocalStorage = () => {
let savedElements = null;
let savedState = null;
@@ -123,14 +103,13 @@ export const getTotalStorageSize = () => {
export const getLibraryItemsFromStorage = () => {
try {
const libraryItems =
JSON.parse(
localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string,
) || [];
const libraryItems: ImportedDataState["libraryItems"] = JSON.parse(
localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string,
);
return libraryItems;
} catch (e) {
console.error(e);
return libraryItems || [];
} catch (error) {
console.error(error);
return [];
}
};

View File

@@ -26,3 +26,9 @@
}
}
}
.excalidraw-app.is-collaborating {
[data-testid="clear-canvas-button"] {
visibility: hidden;
pointer-events: none;
}
}

View File

@@ -12,7 +12,6 @@ import {
VERSION_TIMEOUT,
} from "../constants";
import { loadFromBlob } from "../data/blob";
import { ImportedDataState } from "../data/types";
import {
ExcalidrawElement,
FileId,
@@ -20,7 +19,8 @@ import {
} from "../element/types";
import { useCallbackRefState } from "../hooks/useCallbackRefState";
import { Language, t } from "../i18n";
import Excalidraw, {
import {
Excalidraw,
defaultLang,
languages,
} from "../packages/excalidraw/index";
@@ -28,12 +28,13 @@ import {
AppState,
LibraryItems,
ExcalidrawImperativeAPI,
BinaryFileData,
BinaryFiles,
ExcalidrawInitialDataState,
} from "../types";
import {
debounce,
getVersion,
getFrame,
isTestEnv,
preventUnload,
ResolvablePromise,
@@ -41,7 +42,6 @@ import {
} from "../utils";
import {
FIREBASE_STORAGE_PREFIXES,
SAVE_TO_LOCAL_STORAGE_TIMEOUT,
STORAGE_KEYS,
SYNC_BROWSER_TABS_TIMEOUT,
} from "./app_constants";
@@ -56,7 +56,6 @@ import {
getLibraryItemsFromStorage,
importFromLocalStorage,
importUsernameFromLocalStorage,
saveToLocalStorage,
} from "./data/localStorage";
import CustomStats from "./CustomStats";
import { restoreAppState, RestoredDataState } from "../data/restore";
@@ -66,72 +65,13 @@ import { shield } from "../components/icons";
import "./index.scss";
import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus";
import { getMany, set, del, keys, createStore } from "idb-keyval";
import { FileManager, updateStaleImageStatuses } from "./data/FileManager";
import { updateStaleImageStatuses } from "./data/FileManager";
import { newElementWith } from "../element/mutateElement";
import { isInitializedImageElement } from "../element/typeChecks";
import { loadFilesFromFirebase } from "./data/firebase";
import {
isBrowserStorageStateNewer,
updateBrowserStateVersion,
} from "./data/tabSync";
const filesStore = createStore("files-db", "files-store");
const clearObsoleteFilesFromIndexedDB = async (opts: {
currentFileIds: FileId[];
}) => {
const allIds = await keys(filesStore);
for (const id of allIds) {
if (!opts.currentFileIds.includes(id as FileId)) {
del(id, filesStore);
}
}
};
const localFileStorage = new FileManager({
getFiles(ids) {
return getMany(ids, filesStore).then(
(filesData: (BinaryFileData | undefined)[]) => {
const loadedFiles: BinaryFileData[] = [];
const erroredFiles = new Map<FileId, true>();
filesData.forEach((data, index) => {
const id = ids[index];
if (data) {
loadedFiles.push(data);
} else {
erroredFiles.set(id, true);
}
});
return { loadedFiles, erroredFiles };
},
);
},
async saveFiles({ addedFiles }) {
const savedFiles = new Map<FileId, true>();
const erroredFiles = new Map<FileId, true>();
// before we use `storage` event synchronization, let's update the flag
// optimistically. Hopefully nothing fails, and an IDB read executed
// before an IDB write finishes will read the latest value.
updateBrowserStateVersion(STORAGE_KEYS.VERSION_FILES);
await Promise.all(
[...addedFiles].map(async ([id, fileData]) => {
try {
await set(id, fileData, filesStore);
savedFiles.set(id, true);
} catch (error: any) {
console.error(error);
erroredFiles.set(id, true);
}
}),
);
return { savedFiles, erroredFiles };
},
});
import { LocalData } from "./data/LocalData";
import { isBrowserStorageStateNewer } from "./data/tabSync";
import clsx from "clsx";
const languageDetector = new LanguageDetector();
languageDetector.init({
@@ -142,32 +82,10 @@ languageDetector.init({
checkWhitelist: false,
});
const saveDebounced = debounce(
async (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
onFilesSaved: () => void,
) => {
saveToLocalStorage(elements, appState);
await localFileStorage.saveFiles({
elements,
files,
});
onFilesSaved();
},
SAVE_TO_LOCAL_STORAGE_TIMEOUT,
);
const onBlur = () => {
saveDebounced.flush();
};
const initializeScene = async (opts: {
collabAPI: CollabAPI;
}): Promise<
{ scene: ImportedDataState | null } & (
{ scene: ExcalidrawInitialDataState | null } & (
| { isExternalScene: true; id: string; key: string }
| { isExternalScene: false; id?: null; key?: null }
)
@@ -294,14 +212,15 @@ const ExcalidrawWrapper = () => {
// ---------------------------------------------------------------------------
const initialStatePromiseRef = useRef<{
promise: ResolvablePromise<ImportedDataState | null>;
promise: ResolvablePromise<ExcalidrawInitialDataState | null>;
}>({ promise: null! });
if (!initialStatePromiseRef.current.promise) {
initialStatePromiseRef.current.promise =
resolvablePromise<ImportedDataState | null>();
resolvablePromise<ExcalidrawInitialDataState | null>();
}
useEffect(() => {
trackEvent("load", "frame", getFrame());
// Delayed so that the app has a time to load the latest SW
setTimeout(() => {
trackEvent("load", "version", getVersion());
@@ -364,7 +283,7 @@ const ExcalidrawWrapper = () => {
});
} else if (isInitialLoad) {
if (fileIds.length) {
localFileStorage
LocalData.fileStorage
.getFiles(fileIds)
.then(({ loadedFiles, erroredFiles }) => {
if (loadedFiles.length) {
@@ -379,7 +298,7 @@ const ExcalidrawWrapper = () => {
}
// on fresh load, clear unused files from IDB (from previous
// session)
clearObsoleteFilesFromIndexedDB({ currentFileIds: fileIds });
LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds });
}
}
@@ -456,7 +375,7 @@ const ExcalidrawWrapper = () => {
return acc;
}, [] as FileId[]) || [];
if (fileIds.length) {
localFileStorage
LocalData.fileStorage
.getFiles(fileIds)
.then(({ loadedFiles, erroredFiles }) => {
if (loadedFiles.length) {
@@ -473,28 +392,50 @@ const ExcalidrawWrapper = () => {
}
}, SYNC_BROWSER_TABS_TIMEOUT);
const onUnload = () => {
LocalData.flushSave();
};
const visibilityChange = (event: FocusEvent | Event) => {
if (event.type === EVENT.BLUR || document.hidden) {
LocalData.flushSave();
}
if (
event.type === EVENT.VISIBILITY_CHANGE ||
event.type === EVENT.FOCUS
) {
syncData();
}
};
window.addEventListener(EVENT.HASHCHANGE, onHashChange, false);
window.addEventListener(EVENT.UNLOAD, onBlur, false);
window.addEventListener(EVENT.BLUR, onBlur, false);
document.addEventListener(EVENT.VISIBILITY_CHANGE, syncData, false);
window.addEventListener(EVENT.FOCUS, syncData, false);
window.addEventListener(EVENT.UNLOAD, onUnload, false);
window.addEventListener(EVENT.BLUR, visibilityChange, false);
document.addEventListener(EVENT.VISIBILITY_CHANGE, visibilityChange, false);
window.addEventListener(EVENT.FOCUS, visibilityChange, false);
return () => {
window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false);
window.removeEventListener(EVENT.UNLOAD, onBlur, false);
window.removeEventListener(EVENT.BLUR, onBlur, false);
window.removeEventListener(EVENT.FOCUS, syncData, false);
document.removeEventListener(EVENT.VISIBILITY_CHANGE, syncData, false);
window.removeEventListener(EVENT.UNLOAD, onUnload, false);
window.removeEventListener(EVENT.BLUR, visibilityChange, false);
window.removeEventListener(EVENT.FOCUS, visibilityChange, false);
document.removeEventListener(
EVENT.VISIBILITY_CHANGE,
visibilityChange,
false,
);
clearTimeout(titleTimeout);
};
}, [collabAPI, excalidrawAPI]);
useEffect(() => {
const unloadHandler = (event: BeforeUnloadEvent) => {
saveDebounced.flush();
LocalData.flushSave();
if (
excalidrawAPI &&
localFileStorage.shouldPreventUnload(excalidrawAPI.getSceneElements())
LocalData.fileStorage.shouldPreventUnload(
excalidrawAPI.getSceneElements(),
)
) {
preventUnload(event);
}
@@ -515,9 +456,13 @@ const ExcalidrawWrapper = () => {
files: BinaryFiles,
) => {
if (collabAPI?.isCollaborating()) {
collabAPI.broadcastElements(elements);
} else {
saveDebounced(elements, appState, files, () => {
collabAPI.syncElements(elements);
}
// this check is redundant, but since this is a hot path, it's best
// not to evaludate the nested expression every time
if (!LocalData.isSavePaused()) {
LocalData.save(elements, appState, files, () => {
if (excalidrawAPI) {
let didChange = false;
@@ -525,7 +470,9 @@ const ExcalidrawWrapper = () => {
const elements = excalidrawAPI
.getSceneElementsIncludingDeleted()
.map((element) => {
if (localFileStorage.shouldUpdateImageElementStatus(element)) {
if (
LocalData.fileStorage.shouldUpdateImageElementStatus(element)
) {
didChange = true;
const newEl = newElementWith(element, { status: "saved" });
if (pendingImageElement === element) {
@@ -685,11 +632,16 @@ const ExcalidrawWrapper = () => {
};
const onRoomClose = useCallback(() => {
localFileStorage.reset();
LocalData.fileStorage.reset();
}, []);
return (
<>
<div
style={{ height: "100%" }}
className={clsx("excalidraw-app", {
"is-collaborating": collabAPI?.isCollaborating(),
})}
>
<Excalidraw
ref={excalidrawRefCallback}
onChange={onChange}
@@ -741,7 +693,7 @@ const ExcalidrawWrapper = () => {
onClose={() => setErrorMessage("")}
/>
)}
</>
</div>
);
};

2
src/global.d.ts vendored
View File

@@ -34,6 +34,8 @@ type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};
type Merge<M, N> = Omit<M, keyof N> & N;
/** utility type to assert that the second type is a subtype of the first type.
* Returns the subtype. */
type SubtypeOf<Supertype, Subtype extends Supertype> = Subtype;

4
src/jotai.ts Normal file
View File

@@ -0,0 +1,4 @@
import { unstable_createStore } from "jotai";
export const jotaiScope = Symbol();
export const jotaiStore = unstable_createStore();

View File

@@ -9,6 +9,7 @@
"copy": "نسخ",
"copyAsPng": "نسخ إلى الحافظة بصيغة PNG",
"copyAsSvg": "نسخ إلى الحافظة بصيغة SVG",
"copyText": "نسخ إلى الحافظة كنص",
"bringForward": "جلب للأمام",
"sendToBack": "أرسل للخلف",
"bringToFront": "أحضر للأمام",
@@ -21,7 +22,7 @@
"fill": "التعبئة",
"strokeWidth": "سُمك الخط",
"strokeStyle": "نمط الخط",
"strokeStyle_solid": "كامل",
"strokeStyle_solid": تصل",
"strokeStyle_dashed": "متقطع",
"strokeStyle_dotted": "منقط",
"sloppiness": "الإمالة",
@@ -107,10 +108,17 @@
"decreaseFontSize": "تصغير حجم الخط",
"increaseFontSize": "تكبير حجم الخط",
"unbindText": "",
"bindText": "",
"link": {
"edit": "تعديل الرابط",
"create": "إنشاء رابط",
"label": "رابط"
},
"elementLock": {
"lock": "",
"unlock": "",
"lockAll": "",
"unlockAll": ""
}
},
"buttons": {
@@ -159,7 +167,7 @@
"couldNotLoadInvalidFile": "تعذر التحميل، الملف غير صالح",
"importBackendFailed": "فشل الاستيراد من الخادوم.",
"cannotExportEmptyCanvas": "لا يمكن تصدير لوحة فارغة.",
"couldNotCopyToClipboard": "تعذر النسخ إلى الحافظة. حاول استخدام متصفح Chrome.",
"couldNotCopyToClipboard": "",
"decryptFailed": "تعذر فك تشفير البيانات.",
"uploadedSecurly": "تم تأمين التحميل بتشفير النهاية إلى النهاية، مما يعني أن خادوم Excalidraw والأطراف الثالثة لا يمكنها قراءة المحتوى.",
"loadSceneOverridePrompt": "تحميل الرسم الخارجي سيحل محل المحتوى الموجود لديك. هل ترغب في المتابعة؟",
@@ -172,7 +180,7 @@
"cannotRestoreFromImage": "تعذر استعادة المشهد من ملف الصورة",
"invalidSceneUrl": "تعذر استيراد المشهد من عنوان URL المتوفر. إما أنها مشوهة، أو لا تحتوي على بيانات Excalidraw JSON صالحة.",
"resetLibrary": "هذا سوف يمسح مكتبتك. هل أنت متأكد؟",
"removeItemsFromsLibrary": "",
"removeItemsFromsLibrary": "حذف {{count}} عنصر (عناصر) من المكتبة؟",
"invalidEncryptionKey": "مفتاح التشفير يجب أن يكون من 22 حرفاً. التعاون المباشر معطل."
},
"errors": {
@@ -196,7 +204,8 @@
"library": "مكتبة",
"lock": "الحفاظ على أداة التحديد نشطة بعد الرسم",
"penMode": "",
"link": ""
"link": "",
"eraser": "ممحاة"
},
"headings": {
"canvasActions": "إجراءات اللوحة",
@@ -221,7 +230,8 @@
"placeImage": "",
"publishLibrary": "نشر مكتبتك",
"bindTextToElement": "",
"deepBoxSelect": ""
"deepBoxSelect": "",
"eraserRevert": ""
},
"canvasError": {
"cannotShowPreview": "تعذر عرض المعاينة",
@@ -281,14 +291,15 @@
"howto": "اتبع التعليمات",
"or": "أو",
"preventBinding": "منع ارتبط السهم",
"shapes": "أشكال",
"tools": "الأدوات",
"shortcuts": "اختصارات لوحة المفاتيح",
"textFinish": "إنهاء التعديل (محرر النص)",
"textNewLine": "أضف سطر جديد (محرر نص)",
"title": "المساعدة",
"view": "عرض",
"zoomToFit": "تكبير للملائمة",
"zoomToSelection": "تكبير للعنصر المحدد"
"zoomToSelection": "تكبير للعنصر المحدد",
"toggleElementLock": ""
},
"clearCanvasDialog": {
"title": "مسح اللوحة"

View File

@@ -9,6 +9,7 @@
"copy": "Копирай",
"copyAsPng": "Копиране в клипборда",
"copyAsSvg": "Копирано в клипборда като SVG",
"copyText": "",
"bringForward": "Преместване напред",
"sendToBack": "Изнасяне назад",
"bringToFront": "Изнасяне отпред",
@@ -107,10 +108,17 @@
"decreaseFontSize": "",
"increaseFontSize": "",
"unbindText": "",
"bindText": "",
"link": {
"edit": "",
"create": "",
"label": ""
},
"elementLock": {
"lock": "",
"unlock": "",
"lockAll": "",
"unlockAll": ""
}
},
"buttons": {
@@ -159,7 +167,7 @@
"couldNotLoadInvalidFile": "Невалиден файл не може да се зареди",
"importBackendFailed": "Импортирането от бекенд не беше успешно.",
"cannotExportEmptyCanvas": "Не може да се експортира празно платно.",
"couldNotCopyToClipboard": "Неуспешно копиране в клипборда. Опитайте да използвате браузъра Chrome.",
"couldNotCopyToClipboard": "",
"decryptFailed": "Данните не можаха да се дешифрират.",
"uploadedSecurly": "Качването е защитено с криптиране от край до край, което означава, че сървърът Excalidraw и трети страни не могат да четат съдържанието.",
"loadSceneOverridePrompt": "Зареждането на външна рисунка ще презапише настоящото ви съдържание. Желаете ли да продължите?",
@@ -196,7 +204,8 @@
"library": "Библиотека",
"lock": "Поддържайте избрания инструмент активен след рисуване",
"penMode": "",
"link": ""
"link": "",
"eraser": ""
},
"headings": {
"canvasActions": "Действия по платното",
@@ -221,7 +230,8 @@
"placeImage": "",
"publishLibrary": "",
"bindTextToElement": "Натиснете Enter, за да добавите",
"deepBoxSelect": ""
"deepBoxSelect": "",
"eraserRevert": ""
},
"canvasError": {
"cannotShowPreview": "Невъзможност за показване на preview",
@@ -281,14 +291,15 @@
"howto": "Следвайте нашите ръководства",
"or": "или",
"preventBinding": "Спри прилепяне на стрелките",
"shapes": "Фигури",
"tools": "",
"shortcuts": "Клавиши за бърз достъп",
"textFinish": "",
"textNewLine": "",
"title": "Помощ",
"view": "Преглед",
"zoomToFit": "Приближи докато се виждат всички елементи",
"zoomToSelection": "Приближи селекцията"
"zoomToSelection": "Приближи селекцията",
"toggleElementLock": ""
},
"clearCanvasDialog": {
"title": ""

View File

@@ -9,6 +9,7 @@
"copy": "",
"copyAsPng": "",
"copyAsSvg": "",
"copyText": "",
"bringForward": "",
"sendToBack": "",
"bringToFront": "",
@@ -107,10 +108,17 @@
"decreaseFontSize": "",
"increaseFontSize": "",
"unbindText": "",
"bindText": "",
"link": {
"edit": "",
"create": "",
"label": ""
},
"elementLock": {
"lock": "",
"unlock": "",
"lockAll": "",
"unlockAll": ""
}
},
"buttons": {
@@ -196,7 +204,8 @@
"library": "",
"lock": "",
"penMode": "",
"link": ""
"link": "",
"eraser": ""
},
"headings": {
"canvasActions": "",
@@ -221,7 +230,8 @@
"placeImage": "",
"publishLibrary": "",
"bindTextToElement": "",
"deepBoxSelect": ""
"deepBoxSelect": "",
"eraserRevert": ""
},
"canvasError": {
"cannotShowPreview": "",
@@ -281,14 +291,15 @@
"howto": "",
"or": "",
"preventBinding": "",
"shapes": "",
"tools": "",
"shortcuts": "",
"textFinish": "",
"textNewLine": "",
"title": "",
"view": "",
"zoomToFit": "",
"zoomToSelection": ""
"zoomToSelection": "",
"toggleElementLock": ""
},
"clearCanvasDialog": {
"title": ""

View File

@@ -9,6 +9,7 @@
"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",
@@ -107,10 +108,17 @@
"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": {
@@ -159,7 +167,7 @@
"couldNotLoadInvalidFile": "No s'ha pogut carregar un fitxer no vàlid",
"importBackendFailed": "Importació fallida.",
"cannotExportEmptyCanvas": "No es pot exportar un llenç buit.",
"couldNotCopyToClipboard": "No s'ha pogut copiar al porta-retalls. Intentar amb el navegador Google Chrome.",
"couldNotCopyToClipboard": "",
"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?",
@@ -196,7 +204,8 @@
"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"
"link": "Afegeix / actualitza l'enllaç per a la forma seleccionada",
"eraser": ""
},
"headings": {
"canvasActions": "Accions del llenç",
@@ -221,7 +230,8 @@
"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"
"deepBoxSelect": "Manteniu CtrlOrCmd per a selecció profunda, i per a evitar l'arrossegament",
"eraserRevert": ""
},
"canvasError": {
"cannotShowPreview": "No es pot mostrar la previsualització",
@@ -281,14 +291,15 @@
"howto": "Seguiu les nostres guies",
"or": "o",
"preventBinding": "Prevenir vinculació de la fletxa",
"shapes": "Formes",
"tools": "",
"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ó"
"zoomToSelection": "Zoom per veure la selecció",
"toggleElementLock": ""
},
"clearCanvasDialog": {
"title": "Neteja el llenç"

View File

@@ -9,6 +9,7 @@
"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í",
@@ -107,10 +108,17 @@
"decreaseFontSize": "",
"increaseFontSize": "",
"unbindText": "",
"bindText": "",
"link": {
"edit": "",
"create": "",
"label": ""
},
"elementLock": {
"lock": "",
"unlock": "",
"lockAll": "",
"unlockAll": ""
}
},
"buttons": {
@@ -196,7 +204,8 @@
"library": "",
"lock": "",
"penMode": "",
"link": ""
"link": "",
"eraser": ""
},
"headings": {
"canvasActions": "",
@@ -221,7 +230,8 @@
"placeImage": "",
"publishLibrary": "",
"bindTextToElement": "",
"deepBoxSelect": ""
"deepBoxSelect": "",
"eraserRevert": ""
},
"canvasError": {
"cannotShowPreview": "",
@@ -281,14 +291,15 @@
"howto": "",
"or": "nebo",
"preventBinding": "",
"shapes": "",
"tools": "",
"shortcuts": "",
"textFinish": "",
"textNewLine": "",
"title": "",
"view": "",
"zoomToFit": "",
"zoomToSelection": ""
"zoomToSelection": "",
"toggleElementLock": ""
},
"clearCanvasDialog": {
"title": ""

View File

@@ -9,6 +9,7 @@
"copy": "Kopier",
"copyAsPng": "Kopier til klippebord som PNG",
"copyAsSvg": "Kopier til klippebord som SVG",
"copyText": "",
"bringForward": "",
"sendToBack": "",
"bringToFront": "",
@@ -107,10 +108,17 @@
"decreaseFontSize": "",
"increaseFontSize": "",
"unbindText": "",
"bindText": "",
"link": {
"edit": "",
"create": "",
"label": ""
},
"elementLock": {
"lock": "",
"unlock": "",
"lockAll": "",
"unlockAll": ""
}
},
"buttons": {
@@ -159,7 +167,7 @@
"couldNotLoadInvalidFile": "",
"importBackendFailed": "",
"cannotExportEmptyCanvas": "",
"couldNotCopyToClipboard": "Kunne ikke kopiere til klippebord. Prøv at bruge Chrome browser.",
"couldNotCopyToClipboard": "",
"decryptFailed": "",
"uploadedSecurly": "",
"loadSceneOverridePrompt": "",
@@ -196,7 +204,8 @@
"library": "",
"lock": "",
"penMode": "",
"link": ""
"link": "",
"eraser": ""
},
"headings": {
"canvasActions": "",
@@ -221,7 +230,8 @@
"placeImage": "",
"publishLibrary": "",
"bindTextToElement": "",
"deepBoxSelect": ""
"deepBoxSelect": "",
"eraserRevert": ""
},
"canvasError": {
"cannotShowPreview": "",
@@ -281,14 +291,15 @@
"howto": "",
"or": "",
"preventBinding": "",
"shapes": "",
"tools": "",
"shortcuts": "",
"textFinish": "",
"textNewLine": "",
"title": "",
"view": "",
"zoomToFit": "",
"zoomToSelection": ""
"zoomToSelection": "",
"toggleElementLock": ""
},
"clearCanvasDialog": {
"title": ""

View File

@@ -9,6 +9,7 @@
"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",
@@ -107,10 +108,17 @@
"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": {
@@ -159,7 +167,7 @@
"couldNotLoadInvalidFile": "Ungültige Datei konnte nicht geladen werden",
"importBackendFailed": "Import vom Server ist fehlgeschlagen.",
"cannotExportEmptyCanvas": "Leere Zeichenfläche kann nicht exportiert werden.",
"couldNotCopyToClipboard": "Konnte nicht in die Zwischenablage kopieren. Versuch es mit dem Chrome Browser.",
"couldNotCopyToClipboard": "Kopieren in die Zwischenablage fehlgeschlagen.",
"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?",
@@ -195,8 +203,9 @@
"text": "Text",
"library": "Bibliothek",
"lock": "Ausgewähltes Werkzeug nach Zeichnen aktiv lassen",
"penMode": "",
"link": "Link für ausgewählte Form hinzufügen / aktualisieren"
"penMode": "Verhindere Pinch-Zoom und akzeptiere Eingabe nur vom Stift",
"link": "Link für ausgewählte Form hinzufügen / aktualisieren",
"eraser": "Radierer"
},
"headings": {
"canvasActions": "Aktionen für Zeichenfläche",
@@ -221,7 +230,8 @@
"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"
"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"
},
"canvasError": {
"cannotShowPreview": "Vorschau kann nicht angezeigt werden",
@@ -281,14 +291,15 @@
"howto": "Folge unseren Anleitungen",
"or": "oder",
"preventBinding": "Pfeil-Bindung verhindern",
"shapes": "Formen",
"tools": "Werkzeuge",
"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"
"zoomToSelection": "Auf Auswahl zoomen",
"toggleElementLock": "Auswahl sperren/entsperren"
},
"clearCanvasDialog": {
"title": "Zeichenfläche löschen"

View File

@@ -9,6 +9,7 @@
"copy": "Αντιγραφή",
"copyAsPng": "Αντιγραφή στο πρόχειρο ως PNG",
"copyAsSvg": "Αντιγραφή στο πρόχειρο ως SVG",
"copyText": "",
"bringForward": "Στο προσκήνιο",
"sendToBack": "Ένα επίπεδο πίσω",
"bringToFront": "Ένα επίπεδο μπροστά",
@@ -107,10 +108,17 @@
"decreaseFontSize": "",
"increaseFontSize": "",
"unbindText": "",
"bindText": "",
"link": {
"edit": "",
"create": "",
"label": ""
},
"elementLock": {
"lock": "",
"unlock": "",
"lockAll": "",
"unlockAll": ""
}
},
"buttons": {
@@ -159,7 +167,7 @@
"couldNotLoadInvalidFile": "Δεν μπόρεσε να ανοίξει εσφαλμένο αρχείο",
"importBackendFailed": "Η εισαγωγή από το backend απέτυχε.",
"cannotExportEmptyCanvas": "Δεν είναι δυνατή η εξαγωγή κενού καμβά.",
"couldNotCopyToClipboard": "Δεν ήταν δυνατή η αντιγραφή στο πρόχειρο. Δοκίμασε τη χρήση του προγράμματος περιήγησης Chrome.",
"couldNotCopyToClipboard": "",
"decryptFailed": "Δεν ήταν δυνατή η αποκρυπτογράφηση δεδομένων.",
"uploadedSecurly": "Η μεταφόρτωση έχει εξασφαλιστεί με κρυπτογράφηση από άκρο σε άκρο, πράγμα που σημαίνει ότι ο διακομιστής Excalidraw και τρίτα μέρη δεν μπορούν να διαβάσουν το περιεχόμενο.",
"loadSceneOverridePrompt": "Η φόρτωση εξωτερικού σχεδίου θα αντικαταστήσει το υπάρχον περιεχόμενο. Επιθυμείτε να συνεχίσετε;",
@@ -196,7 +204,8 @@
"library": "Βιβλιοθήκη",
"lock": "Κράτησε επιλεγμένο το εργαλείο μετά το σχέδιο",
"penMode": "",
"link": ""
"link": "",
"eraser": ""
},
"headings": {
"canvasActions": "Ενέργειες καμβά",
@@ -221,7 +230,8 @@
"placeImage": "",
"publishLibrary": "Δημοσιεύστε τη δική σας βιβλιοθήκη",
"bindTextToElement": "",
"deepBoxSelect": ""
"deepBoxSelect": "",
"eraserRevert": ""
},
"canvasError": {
"cannotShowPreview": "Αδυναμία εμφάνισης προεπισκόπησης",
@@ -281,14 +291,15 @@
"howto": "Ακολουθήστε τους οδηγούς μας",
"or": "ή",
"preventBinding": "Αποτροπή δέσμευσης βέλων",
"shapes": "Σχήματα",
"tools": "",
"shortcuts": "Συντομεύσεις πληκτρολογίου",
"textFinish": "Ολοκλήρωση επεξεργασίας (επεξεργαστής κειμένου)",
"textNewLine": "Προσθήκη νέας γραμμής (επεξεργαστής κειμένου)",
"title": "Βοήθεια",
"view": "Προβολή",
"zoomToFit": "Zoom ώστε να χωρέσουν όλα τα στοιχεία",
"zoomToSelection": "Ζουμ στην επιλογή"
"zoomToSelection": "Ζουμ στην επιλογή",
"toggleElementLock": ""
},
"clearCanvasDialog": {
"title": "Καθαρισμός καμβά"

View File

@@ -9,6 +9,7 @@
"copy": "Copy",
"copyAsPng": "Copy to clipboard as PNG",
"copyAsSvg": "Copy to clipboard as SVG",
"copyText": "Copy to clipboard as text",
"bringForward": "Bring forward",
"sendToBack": "Send to back",
"bringToFront": "Bring to front",
@@ -107,10 +108,17 @@
"decreaseFontSize": "Decrease font size",
"increaseFontSize": "Increase font size",
"unbindText": "Unbind text",
"bindText": "Bind text to the container",
"link": {
"edit": "Edit link",
"create": "Create link",
"label": "Link"
},
"elementLock": {
"lock": "Lock",
"unlock": "Unlock",
"lockAll": "Lock all",
"unlockAll": "Unlock all"
}
},
"buttons": {
@@ -159,12 +167,11 @@
"couldNotLoadInvalidFile": "Couldn't load invalid file",
"importBackendFailed": "Importing from backend failed.",
"cannotExportEmptyCanvas": "Cannot export empty canvas.",
"couldNotCopyToClipboard": "Couldn't copy to clipboard. Try using Chrome browser.",
"couldNotCopyToClipboard": "Couldn't copy to clipboard.",
"decryptFailed": "Couldn't decrypt data.",
"uploadedSecurly": "The upload has been secured with end-to-end encryption, which means that Excalidraw server and third parties can't read the content.",
"loadSceneOverridePrompt": "Loading external drawing will replace your existing content. Do you wish to continue?",
"collabStopOverridePrompt": "Stopping the session will overwrite your previous, locally stored drawing. Are you sure?\n\n(If you want to keep your local drawing, simply close the browser tab instead.)",
"errorLoadingLibrary": "There was an error loading the third party library.",
"errorAddingToLibrary": "Couldn't add item to the library",
"errorRemovingFromLibrary": "Couldn't remove item from the library",
"confirmAddLibrary": "This will add {{numShapes}} shape(s) to your library. Are you sure?",
@@ -181,7 +188,8 @@
"fileTooBig": "File is too big. Maximum allowed size is {{maxSize}}.",
"svgImageInsertError": "Couldn't insert SVG image. The SVG markup looks invalid.",
"invalidSVGString": "Invalid SVG.",
"cannotResolveCollabServer": "Couldn't connect to the collab server. Please reload the page and try again."
"cannotResolveCollabServer": "Couldn't connect to the collab server. Please reload the page and try again.",
"importLibraryError": "Couldn't load library"
},
"toolBar": {
"selection": "Selection",
@@ -290,7 +298,8 @@
"title": "Help",
"view": "View",
"zoomToFit": "Zoom to fit all elements",
"zoomToSelection": "Zoom to selection"
"zoomToSelection": "Zoom to selection",
"toggleElementLock": "Lock/unlock selection"
},
"clearCanvasDialog": {
"title": "Clear canvas"
@@ -334,7 +343,6 @@
"noteItems": "Each library item must have its own name so it's filterable. The following library items will be included:",
"atleastOneLibItem": "Please select at least one library item to get started"
},
"publishSuccessDialog": {
"title": "Library submitted",
"content": "Thank you {{authorName}}. Your library has been submitted for review. You can track the status",

View File

@@ -9,6 +9,7 @@
"copy": "Copiar",
"copyAsPng": "Copiar al portapapeles como PNG",
"copyAsSvg": "Copiar al portapapeles como SVG",
"copyText": "",
"bringForward": "Traer hacia delante",
"sendToBack": "Enviar al fondo",
"bringToFront": "Traer al frente",
@@ -107,10 +108,17 @@
"decreaseFontSize": "Disminuir tamaño de letra",
"increaseFontSize": "Aumentar el tamaño de letra",
"unbindText": "Desvincular texto",
"bindText": "Vincular texto al contenedor",
"link": {
"edit": "Editar enlace",
"create": "Crear enlace",
"label": "Enlace"
},
"elementLock": {
"lock": "",
"unlock": "",
"lockAll": "",
"unlockAll": ""
}
},
"buttons": {
@@ -159,7 +167,7 @@
"couldNotLoadInvalidFile": "No se pudo cargar el archivo no válido",
"importBackendFailed": "La importación falló.",
"cannotExportEmptyCanvas": "No se puede exportar un lienzo vació",
"couldNotCopyToClipboard": "No se ha podido copiar al portapapeles, intente usar Chrome como navegador.",
"couldNotCopyToClipboard": "No se pudo copiar al portapapeles.",
"decryptFailed": "No se pudieron descifrar los datos.",
"uploadedSecurly": "La carga ha sido asegurada con cifrado de principio a fin, lo que significa que el servidor de Excalidraw y terceros no pueden leer el contenido.",
"loadSceneOverridePrompt": "Si carga este dibujo externo, reemplazará el que tiene. ¿Desea continuar?",
@@ -181,7 +189,7 @@
"fileTooBig": "Archivo demasiado grande. El tamaño máximo permitido es {{maxSize}}.",
"svgImageInsertError": "No se pudo insertar la imagen SVG. El código SVG parece inválido.",
"invalidSVGString": "SVG no válido.",
"cannotResolveCollabServer": ""
"cannotResolveCollabServer": "No se pudo conectar al servidor colaborador. Por favor, vuelva a cargar la página y vuelva a intentarlo."
},
"toolBar": {
"selection": "Selección",
@@ -196,7 +204,8 @@
"library": "Biblioteca",
"lock": "Mantener la herramienta seleccionada activa después de dibujar",
"penMode": "Evitar el zoom de pellizco y aceptar la entrada libre sólo desde el lápiz",
"link": "Añadir/Actualizar enlace para una forma seleccionada"
"link": "Añadir/Actualizar enlace para una forma seleccionada",
"eraser": "Borrar"
},
"headings": {
"canvasActions": "Acciones del lienzo",
@@ -221,7 +230,8 @@
"placeImage": "Haga clic para colocar la imagen o haga clic y arrastre para establecer su tamaño manualmente",
"publishLibrary": "Publica tu propia biblioteca",
"bindTextToElement": "Presione Entrar para agregar",
"deepBoxSelect": "Mantén CtrlOrCmd para seleccionar en profundidad, y para evitar arrastrar"
"deepBoxSelect": "Mantén CtrlOrCmd para seleccionar en profundidad, y para evitar arrastrar",
"eraserRevert": "Mantenga pulsado Alt para revertir los elementos marcados para su eliminación"
},
"canvasError": {
"cannotShowPreview": "No se puede mostrar la vista previa",
@@ -281,14 +291,15 @@
"howto": "Siga nuestras guías",
"or": "o",
"preventBinding": "Evitar yuxtaposición de flechas",
"shapes": "Formas",
"tools": "Herramientas",
"shortcuts": "Atajos del teclado",
"textFinish": "Finalizar edición (editor de texto)",
"textNewLine": "Añadir nueva linea (editor de texto)",
"title": "Ayuda",
"view": "Vista",
"zoomToFit": "Ajustar la vista para mostrar todos los elementos",
"zoomToSelection": "Zoom a la selección"
"zoomToSelection": "Zoom a la selección",
"toggleElementLock": ""
},
"clearCanvasDialog": {
"title": "Borrar lienzo"

View File

@@ -9,6 +9,7 @@
"copy": "Kopiatu",
"copyAsPng": "Kopiatu arbelera PNG gisa",
"copyAsSvg": "Kopiatu arbelera SVG gisa",
"copyText": "Kopiatu arbelera testu gisa",
"bringForward": "Ekarri aurrerago",
"sendToBack": "Eraman atzera",
"bringToFront": "Ekarri aurrera",
@@ -64,7 +65,7 @@
"cartoonist": "Marrazkilaria",
"fileTitle": "Fitxategi izena",
"colorPicker": "Kolore-hautatzailea",
"canvasColors": "",
"canvasColors": "Oihalean erabilita",
"canvasBackground": "Oihalaren atzeko planoa",
"drawingCanvas": "Marrazteko oihala",
"layers": "Geruzak",
@@ -107,10 +108,17 @@
"decreaseFontSize": "Txikitu letra tamaina",
"increaseFontSize": "Handitu letra tamaina",
"unbindText": "Askatu testua",
"bindText": "Lotu testua edukiontziari",
"link": {
"edit": "Editatu esteka",
"create": "Sortu esteka",
"label": "Esteka"
},
"elementLock": {
"lock": "",
"unlock": "",
"lockAll": "",
"unlockAll": ""
}
},
"buttons": {
@@ -181,7 +189,7 @@
"fileTooBig": "Fitxategia handiegia da. Onartutako gehienezko tamaina {{maxSize}} da.",
"svgImageInsertError": "Ezin izan da SVG irudia txertatu. SVG markak baliogabea dirudi.",
"invalidSVGString": "SVG baliogabea.",
"cannotResolveCollabServer": ""
"cannotResolveCollabServer": "Ezin izan da elkarlaneko zerbitzarira konektatu. Mesedez, berriro kargatu orria eta saiatu berriro."
},
"toolBar": {
"selection": "Hautapena",
@@ -196,7 +204,8 @@
"library": "Liburutegia",
"lock": "Mantendu aktibo hautatutako tresna marraztu ondoren",
"penMode": "Saihestu txikiagotzea eta onartu marrazte libreko idazketa solik arkatza bidez",
"link": "Gehitu / Eguneratu esteka hautatutako forma baterako"
"link": "Gehitu / Eguneratu esteka hautatutako forma baterako",
"eraser": "Borragoma"
},
"headings": {
"canvasActions": "Canvas ekintzak",
@@ -221,7 +230,8 @@
"placeImage": "Egin klik irudia kokatzeko, edo egin klik eta arrastatu bere tamaina eskuz ezartzeko",
"publishLibrary": "Argitaratu zure liburutegia",
"bindTextToElement": "Sakatu Sartu testua gehitzeko",
"deepBoxSelect": "Eutsi Ctrl edo Cmd sakatuta aukeraketa sakona egiteko eta arrastatzea saihesteko"
"deepBoxSelect": "Eutsi Ctrl edo Cmd sakatuta aukeraketa sakona egiteko eta arrastatzea saihesteko",
"eraserRevert": "Eduki Alt sakatuta ezabatzeko markatutako elementuak leheneratzeko"
},
"canvasError": {
"cannotShowPreview": "Ezin da oihala aurreikusi",
@@ -281,14 +291,15 @@
"howto": "Jarraitu gure gidak",
"or": "edo",
"preventBinding": "Saihestu gezien gainjartzea",
"shapes": "Formak",
"tools": "Tresnak",
"shortcuts": "Laster-teklak",
"textFinish": "Bukatu edizioa (testu editorea)",
"textNewLine": "Gehitu lerro berri bat (testu editorea)",
"title": "Laguntza",
"view": "Bistaratu",
"zoomToFit": "Egin zoom elementu guztiak ikusteko",
"zoomToSelection": "Zooma hautapenera"
"zoomToSelection": "Zooma hautapenera",
"toggleElementLock": ""
},
"clearCanvasDialog": {
"title": "Garbitu oihala"

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