Compare commits

...

92 Commits

Author SHA1 Message Date
d5b406b055 path fixes 2024-05-09 13:38:37 +05:30
7522285869 watch utils folder and build it when updated 2024-05-09 13:31:07 +05:30
58567dd2b6 move the files to src and tests folders under utils 2024-05-09 13:30:44 +05:30
a76999e9a2 lint 2024-05-08 19:19:14 +05:30
5af532d229 Merge remote-tracking branch 'origin/master' into aakansha/esm 2024-05-08 19:15:37 +05:30
1ed53b153c build: enable consistent type imports eslint rule (#7992)
* build: enable consistent type imports eslint rule

* change to warn

* fix the warning in example and excalidraw-app

* fix packages

* enable type annotations and throw error for the rule
2024-05-08 14:21:50 +05:30
c1926f33bb fix: remove unused param from drawImagePlaceholder (#7991) 2024-05-07 20:22:51 +05:30
6539029d2a fix: docker build of Excalidraw app (#7430)
* fix: docker build of Excalidraw app

Fixes #7403.

* deps: update (container) Nginx to 1.24

---------

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
2024-05-07 18:00:58 +05:30
d1f37cc64f feat: tweak a few icons & add line editor button to side panel (#7990) 2024-05-07 13:18:39 +02:00
f0d25e34c3 chore: Add lcov coverage output to vitest (#7987)
chore: Add lcov coverage output to vitest so VSCode coverage gutters extension works

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2024-05-06 17:35:23 +02:00
96fc3ac5bb lint 2024-05-06 19:09:09 +05:30
906652bac2 lint 2024-05-06 19:05:36 +05:30
53a0428705 update remaining paths to use utils workspace 2024-05-06 18:56:54 +05:30
927e36c7b4 fix tests 2024-05-06 18:42:07 +05:30
e187faee77 update test script 2024-05-06 17:35:33 +05:30
d12d97bfcb add cleanup workspaces script and test-utils 2024-05-06 17:28:58 +05:30
5acb5c9d91 update test script 2024-05-06 17:11:02 +05:30
23ee054025 fix lint 2024-05-06 17:05:19 +05:30
d91b234db1 fix typo 2024-05-06 16:24:35 +05:30
b2b03af1ec fix script 2024-05-06 16:22:18 +05:30
f0876e3c03 tweaks 2024-05-06 16:18:05 +05:30
6d0ee330b7 update build scripts 2024-05-06 15:35:34 +05:30
e48eed6b21 add utils to external 2024-05-06 15:25:44 +05:30
549786a504 add utils to external 2024-05-06 15:15:48 +05:30
73c53a3c7c extend ts config 2024-05-06 13:55:02 +05:30
72a98da527 ignore types in tests 2024-05-03 17:18:42 +05:30
45ff9d1053 add @excalidraw/utils to external and fixes 2024-05-03 17:08:07 +05:30
6a1477a55c feat: use @excalidraw/utils as a workspace in the codebase 2024-05-03 15:54:06 +05:30
d9bbf1eda6 feat: Allow binding only via linear element ends (#7946)
Arrows now only bind to new shapes if their start or end point is dragged close to them. Arrows previously bound to shapes remain bound on move and drag if at the end of the drag/move the points remain in the original shapes' binding area.

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
Co-authored-by: Sammy Lee <sammy.joe.lee@gmail.com>
2024-05-02 08:32:12 +02:00
f79fb9aae2 chore: Bump vitest to 1.5.3 to support VSCode vitest Extension (#7968)
Bump vitest to 1.5.3 to support VSCode vitest Extension

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2024-05-01 20:47:52 +02:00
275f6fbe24 fix: typo in doc api (#7466) 2024-04-30 16:52:42 +00:00
88812e0386 feat: resize elements from the sides (#7855)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-04-30 18:05:00 +02:00
6e5aeb112d feat: record freedraw tool selection to history (#7949) 2024-04-25 17:24:05 +00:00
4d83d1c91e fix: use Reflect API instead of Object.hasOwn (#7958) 2024-04-25 15:36:26 +02:00
a04676d423 fix: CTRL/CMD & arrow point drag unbinds both sides (#6459) (#7877) 2024-04-23 00:01:05 +02:00
c851aaaf7b fix: z-index for laser pointer to be able to draw on embeds and such (#7918) 2024-04-22 23:53:55 +02:00
1bd2b1fe55 feat: export reconciliation (#7917) 2024-04-22 17:27:57 +02:00
015b46ab23 feat: expose StoreAction in relation to multiplayer history (#7898)
Improved Store API and improved handling of actions to eliminate potential concurrency issues
2024-04-22 09:22:25 +00:00
530617be90 feat: multiplayer undo / redo (#7348) 2024-04-17 14:01:24 +02:00
5211b003b8 fix: double text rendering on edit (#7904) 2024-04-17 13:48:04 +02:00
bbcca06b94 fix: collision regressions from vector geometry rewrite (#7902) 2024-04-17 13:31:12 +02:00
f92f04c13c fix: Correct unit from 'eg' to 'deg' (#7891) 2024-04-15 11:11:27 +02:00
890ed9f31f feat: add "toggle grid" to command palette (#7887) 2024-04-13 19:12:29 +02:00
da2e507298 fix: allow same origin for all necessary domains (#7889) 2024-04-13 18:51:30 +02:00
f59b4f6af4 fix: always make sure we render bound text above containers (#7880) 2024-04-12 21:50:02 +02:00
afcde542f9 fix: parse embeddable srcdoc urls strictly (#7884) 2024-04-12 20:51:17 +02:00
4689a6b300 fix: hit test for closed sharp curves (#7881) 2024-04-12 12:58:51 +02:00
0ae9b383d6 fix: Gist embed allowing unsafe html (#7883) 2024-04-12 12:57:43 +02:00
f597bd3e01 fix: command palette tweaks and fixes (#7876) 2024-04-11 11:39:19 +02:00
4987cc53d0 fix: include borders when testing insides of a shape (#7865) 2024-04-09 16:07:36 +02:00
d917db438e fix: external link not opening (#7859) 2024-04-09 16:06:49 +02:00
a33a400f01 fix: add safe check for arrow points length in tranformToExcalidrawElements (#7863)
* fix: add safe check for arrow points length in tranformToExcalidrawElements

* add spec

* throw error only for dev mode

* fix lint
2024-04-09 09:56:21 +05:30
8a162a4cb4 fix: import (#7869) 2024-04-08 16:59:03 +02:00
c6a045d092 fix: theme toggle shortcut event.code (#7868) 2024-04-08 16:55:33 +02:00
cd50aa719f feat: add system mode to the theme selector (#7853)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-04-08 16:46:24 +02:00
92bc08207c fix: remove incorrect check from index.html (#7867) 2024-04-08 16:42:00 +02:00
32df5502ae feat: fractional indexing (#7359)
* Introducing fractional indices as part of `element.index`

* Ensuring invalid fractional indices are always synchronized with the array order

* Simplifying reconciliation based on the fractional indices

* Moving reconciliation inside the `@excalidraw/excalidraw` package

---------

Co-authored-by: Marcel Mraz <marcel@excalidraw.com>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-04-04 13:51:11 +01:00
bbdcd30a73 refactor: update collision from ga to vector geometry (#7636)
* new collision api

* isPointOnShape

* removed redundant code

* new collision methods in app

* curve shape takes starting point

* clean up geometry

* curve rotation

* freedraw

* inside curve

* improve ellipse inside check

* ellipse distance func

* curve inside

* include frame name bounds

* replace previous private methods for getting elements at x,y

* arrow bound text hit detection

* keep iframes on top

* remove dependence on old collision methods from app

* remove old collision functions

* move some hit functions outside of app

* code refactor

* type

* text collision from inside

* fix context menu test

* highest z-index collision

* fix 1px away binding test

* strictly less

* remove unused imports

* lint

* 'ignore' resize flipping test

* more lint fix

* skip 'flips while resizing' test

* more test

* fix merge errors

* fix selection in resize test

* added a bit more comment

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-04-04 16:31:23 +08:00
3e334a67ed feat: show firefox-compatible command palette shortcut alias (#7825) 2024-03-28 18:12:54 +01:00
1d71f84515 fix: stop using lookbehind for backwards compat (#7824) 2024-03-28 17:32:38 +01:00
550a388b2b feat: command palette (#7804)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-03-28 16:16:32 +00:00
6b523563d8 fix: ejs support in html files (#7822) 2024-03-28 14:58:47 +01:00
65bc500598 fix: excalidrawAPI.toggleSidebar not switching between tabs correctly (#7821) 2024-03-28 14:52:23 +01:00
7949aa1f1c feat: upgrade mermaid-to-excalidraw to 0.3.0 (#7819) 2024-03-28 16:44:29 +05:30
15bfa626b4 feat: support to not render remote cursor & username (#7130) 2024-03-18 10:41:06 +01:00
068895db0e feat: expose more collaborator status icons (#7777) 2024-03-18 10:20:07 +01:00
b7babe554b feat: load old library if migration fails 2024-03-11 09:57:01 +01:00
6a385d6663 feat: change LibraryPersistenceAdapter load() source -> priority
to clarify the semantics
2024-03-11 09:40:51 +01:00
2382fad4f6 feat: store library to IndexedDB & support storage adapters (#7655) 2024-03-08 22:29:19 +01:00
480572f893 fix: correcting Assistant metrics (#7758)
* Changed Assistant metrics to the corrrect ones from OS/2 table

* Adding more information about font metrics

* Adding branded types to avoid future mistakes
2024-03-07 16:54:36 +01:00
68b1fdb20e fix: add missing font metrics for Assistant (#7752) 2024-03-06 10:53:37 +01:00
a38e82f999 feat: close dropdown on escape (#7750) 2024-03-05 23:22:34 +01:00
a07f6e9e3a feat: show ai badge for discovery (#7749) 2024-03-05 23:22:25 +01:00
7e471b55eb feat: text measurements based on font metrics (#7693)
* Introduced vertical offset based on harcoded font metrics 

* Unified usage of alphabetic baseline for both canvas & svg export

* Removed baseline property

* Removed font-size rounding on Safari

* Removed artificial width offset
2024-03-05 19:33:27 +00:00
160440b860 feat: improve collab error notification (#7741)
* identify cause

* toast after dialog for error messages in collab

* remove comment

* shake tooltip instead for repeating collab errors

* clear collab error

* empty commit

* simplify & fix reset race condition

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-03-04 20:43:44 +08:00
f207bd0a1c build: export types for @excalidraw/utils (#7736)
* build: export types for @excalidraw/utils

* fix

* add types
2024-02-29 15:43:04 +05:30
99601baffc build: create ESM build for utils package 🥳 (#7500)
* build: create ESM build for utils package

* add deps, exports and import.meta
2024-02-28 19:33:47 +05:30
af1a3d5b76 fix: export utils from excalidraw package in excalidraw library (#7731)
* fix: export utils from excalidraw package in excalidraw library

* don't export utils utilities

* fix import path

* fix export

* don't export export utilites

* fix export paths

* reexport utils from excalidraw package

* add exports from withinBounds

* fix path
2024-02-28 11:14:57 +05:30
36e56267c9 docs: add missing closing angle bracket in integration.mdx (#7729)
Update integration.mdx: Fix missing closing angle bracket in code sample

 A closing angle bracket was missing in a code sample.

Original code:
<div style={{height:"500px", width:"500px"}}
    <Excalidraw />
</div>

Changes:

<div style={{height:"500px", width:"500px"}}>
    <Excalidraw />
</div>
2024-02-27 07:19:20 +00:00
b09b5cb5f4 fix: split renderScene so that locales aren't imported unnecessarily (#7718)
* fix: split renderScene so that locales aren't imported unnecessarily

* lint

* split export code

* rename renderScene to helpers.ts

* add helpers

* fix typo

* fixes

* move renderElementToSvg to export

* lint

* rename export to staticSvgScene

* fix
2024-02-27 10:37:44 +05:30
dd8529743a docs: type mistake in integration of excalidraw (#7723) 2024-02-26 10:24:27 +01:00
f639d44a95 fix: remove dependency of t in blob.ts (#7717)
* remove dependency of t in blob.ts

* fix
2024-02-23 15:05:46 +05:30
f5ab3e4e12 fix: remove dependency of t from clipboard and image (#7712)
* fix: remove dependency of t from clipboard and image

* pass errorMessage to copyTextToSystemClipboard where needed

* wrap copyTextToSystemClipboard and rethrow translated error in caller

* review fix

* typo
2024-02-21 19:45:33 +05:30
361a9449bb fix: remove scene hack from export.ts & remove pass elementsMap to getContainingFrame (#7713)
* fix: remove scene hack from export.ts as its not needed anymore

* remove

* pass elementsMap to getContainingFrame

* remove unused `mapElementIds` param

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-02-21 16:34:20 +05:30
2e719ff671 fix: decouple pure functions from hyperlink to prevent mermaid bundling (#7710)
* move hyperlink code into its folder

* move pure js functions to hyperlink/helpers and move actionLink to actions

* fix tests

* fix
2024-02-20 20:59:01 +05:30
79d9dc2f8f fix: make bounds independent of scene (#7679)
* fix: make bounds independent of scene

* pass only elements to getCommonBounds

* lint

* pass elementsMap to getVisibleAndNonSelectedElements
2024-02-19 19:39:14 +05:30
9013c84524 fix: make LinearElementEditor independent of scene (#7670)
* fix: make LinearElementEditor independent of scene

* more fixes

* pass elements and elementsMap to maybeBindBindableElement,getHoveredElementForBinding,bindingBorderTest,getElligibleElementsForBindableElementAndWhere,isLinearElementEligibleForNewBindingByBindable

* replace `ElementsMap` with `NonDeletedSceneElementsMap` & remove unused params

* fix lint

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-02-19 11:49:01 +05:30
47f87f4ecb fix: remove scene from getElementAbsoluteCoords and dependent functions and use elementsMap (#7663)
* fix: remove scene from getElementAbsoluteCoords and dependent functions and use elementsMap

* lint

* fix

* use non deleted elements where possible

* use non deleted elements map in actions

* pass elementsMap instead of array to elementOverlapsWithFrame

* lint

* fix

* pass elementsMap to getElementsCorners

* pass elementsMap to getEligibleElementsForBinding

* pass elementsMap in bindOrUnbindSelectedElements and unbindLinearElements

* pass elementsMap in elementsAreInFrameBounds,elementOverlapsWithFrame,isCursorInFrame,getElementsInResizingFrame

* pass elementsMap in getElementsWithinSelection, getElementsCompletelyInFrame, isElementContainingFrame, getElementsInNewFrame

* pass elementsMap to getElementWithTransformHandleType

* pass elementsMap to getVisibleGaps, getMaximumGroups,getReferenceSnapPoints,snapDraggedElements

* lint

* pass elementsMap to bindTextToShapeAfterDuplication,bindLinearElementToElement,getTextBindableContainerAtPosition

* revert changes for bindTextToShapeAfterDuplication
2024-02-16 11:35:01 +05:30
73bf50e8a8 fix: remove t from getDefaultAppState and allow name to be nullable (#7666)
* fix: remove t and allow name to be nullable

* pass name as required prop

* remove Unnamed

* pass name to excalidrawPlus as well for better type safe

* render once we have excalidrawAPI to avoid defaulting

* rename `getAppName` -> `getName` (temporary)

* stop preventing editing filenames when `props.name` supplied

* keep `name` as optional param for export functions

* keep `appState.name` on `props.name` state separate

* fix lint

* assertive first

* fix lint

* Add TODO

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-02-15 11:11:18 +05:30
48c3465b19 docs: release patch v0.17.3 (#7673)
* docs: release patch v0.17.3

* update cl
2024-02-09 19:29:50 +05:30
adc4c9f484 fix: prevent panning to trigger history on macos chrome (#7671) 2024-02-08 19:50:50 +01:00
def1df2c68 fix: keep customData when converting to ExcalidrawElement (#7656)
* feat: keep customData when converting to ExcalidrawElement (#7654)

* docs: add changelog for keeping customData when converting to ExcalidrawElement
2024-02-08 17:23:10 +05:30
350 changed files with 57473 additions and 24736 deletions

View File

@ -4,8 +4,15 @@
!.eslintrc.json
!.npmrc
!.prettierrc
!excalidraw-app/
!package.json
!public/
!packages/
!tsconfig.json
!yarn.lock
# keep (sub)sub directories at the end to exclude from explicit included
# e.g. ./packages/excalidraw/{dist,node_modules}
**/build
**/dist
**/node_modules

View File

@ -2,6 +2,7 @@
"extends": ["@excalidraw/eslint-config", "react-app"],
"rules": {
"import/no-anonymous-default-export": "off",
"no-restricted-globals": "off"
"no-restricted-globals": "off",
"@typescript-eslint/consistent-type-imports": ["error", { "prefer": "type-imports", "disallowTypeAnnotations": false, "fixStyle": "separate-type-imports" }]
}
}

View File

@ -14,4 +14,4 @@ jobs:
- name: Install and test
run: |
yarn install
yarn test:app
yarn test

3
.gitignore vendored
View File

@ -25,4 +25,5 @@ packages/excalidraw/types
coverage
dev-dist
html
examples/**/bundle.*
examples/**/bundle.*
meta*.json

View File

@ -2,16 +2,18 @@ FROM node:18 AS build
WORKDIR /opt/node_app
COPY package.json yarn.lock ./
RUN yarn --ignore-optional --network-timeout 600000
COPY . .
# do not ignore optional dependencies:
# Error: Cannot find module @rollup/rollup-linux-x64-gnu
RUN yarn --network-timeout 600000
ARG NODE_ENV=production
COPY . .
RUN yarn build:app:docker
FROM nginx:1.21-alpine
FROM nginx:1.24-alpine
COPY --from=build /opt/node_app/build /usr/share/nginx/html
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
HEALTHCHECK CMD wget -q -O /dev/null http://localhost || exit 1

View File

@ -22,7 +22,7 @@ You can use this prop when you want to access some [Excalidraw APIs](https://git
| API | Signature | Usage |
| --- | --- | --- |
| [updateScene](#updatescene) | `function` | updates the scene with the sceneData |
| [updateLibrary](#updatelibrary) | `function` | updates the scene with the sceneData |
| [updateLibrary](#updatelibrary) | `function` | updates the library |
| [addFiles](#addfiles) | `function` | add files data to the appState |
| [resetScene](#resetscene) | `function` | Resets the scene. If `resetLoadingState` is passed as true then it will also force set the loading state to false. |
| [getSceneElementsIncludingDeleted](#getsceneelementsincludingdeleted) | `function` | Returns all the elements including the deleted in the scene |
@ -65,7 +65,7 @@ You can use this function to update the scene with the sceneData. It accepts the
| `elements` | [`ImportedDataState["elements"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L38) | The `elements` to be updated in the scene |
| `appState` | [`ImportedDataState["appState"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L39) | The `appState` to be updated in the scene. |
| `collaborators` | <code>Map<string, <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L37">Collaborator></a></code> | The list of collaborators to be updated in the scene. |
| `commitToHistory` | `boolean` | Implies if the `history (undo/redo)` should be recorded. Defaults to `false`. |
| `commitToStore` | `boolean` | Implies if the change should be captured and commited to the `store`. Commited changes are emmitted and listened to by other components, such as `History` for undo / redo purposes. Defaults to `false`. |
```jsx live
function App() {
@ -115,7 +115,7 @@ function App() {
<button className="custom-button" onClick={updateScene}>
Update Scene
</button>
<Excalidraw ref={(api) => setExcalidrawAPI(api)} />
<Excalidraw excalidrawAPI={(api) => setExcalidrawAPI(api)} />
</div>
);
}
@ -188,7 +188,7 @@ function App() {
Update Library
</button>
<Excalidraw
ref={(api) => setExcalidrawAPI(api)}
excalidrawAPI={(api) => setExcalidrawAPI(api)}
// initial data retrieved from https://github.com/excalidraw/excalidraw/blob/master/dev-docs/packages/excalidraw/initialData.js
initialData={{
libraryItems: initialData.libraryItems,

View File

@ -58,7 +58,7 @@ If you are using `pages router` then importing the wrapper dynamically would wor
```jsx showLineNumbers
"use client";
import { Excalidraw. convertToExcalidrawElements } from "@excalidraw/excalidraw";
import { Excalidraw, convertToExcalidrawElements } from "@excalidraw/excalidraw";
import "@excalidraw/excalidraw/index.css";
@ -70,7 +70,7 @@ If you are using `pages router` then importing the wrapper dynamically would wor
height: 141.9765625,
},]));
return (
<div style={{height:"500px", width:"500px"}}
<div style={{height:"500px", width:"500px"}}>
<Excalidraw />
</div>
);

View File

@ -12,9 +12,9 @@ import type * as TExcalidraw from "@excalidraw/excalidraw";
import { nanoid } from "nanoid";
import type { ResolvablePromise } from "../utils";
import {
resolvablePromise,
ResolvablePromise,
distance2d,
fileOpen,
withBatchedUpdates,

View File

@ -1,4 +1,4 @@
import { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/dist/excalidraw/types";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/dist/excalidraw/types";
import CustomFooter from "./CustomFooter";
import type * as TExcalidraw from "@excalidraw/excalidraw";

View File

@ -1,6 +1,6 @@
import { unstable_batchedUpdates } from "react-dom";
import { fileOpen as _fileOpen } from "browser-fs-access";
import type { MIME_TYPES } from "@excalidraw/excalidraw";
import { MIME_TYPES } from "@excalidraw/excalidraw";
import { AbortError } from "../../packages/excalidraw/errors";
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;

View File

@ -3,9 +3,9 @@
"version": "0.1.0",
"private": true,
"scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm",
"dev": "yarn build:workspace && next dev -p 3005",
"build": "yarn build:workspace && next build",
"build:workspaces": "yarn workspace @excalidraw/utils run build:esm && yarn workspace @excalidraw/excalidraw run build:esm",
"dev": "yarn build:workspaces && next dev -p 3005",
"build": "yarn build:workspaces && next build",
"start": "next start -p 3006",
"lint": "next lint"
},

View File

@ -12,8 +12,9 @@
"typescript": "^5"
},
"scripts": {
"start": "yarn workspace @excalidraw/excalidraw run build:esm && vite",
"build": "yarn workspace @excalidraw/excalidraw run build:esm && vite build",
"build:workspaces": "yarn workspace @excalidraw/utils run build:esm && yarn workspace @excalidraw/excalidraw run build:esm",
"start": "yarn build:workspaces && vite",
"build": "yarn build:workspaces && vite build",
"build:preview": "yarn build && vite preview --port 5002"
}
}

View File

@ -13,11 +13,10 @@ import {
VERSION_TIMEOUT,
} from "../packages/excalidraw/constants";
import { loadFromBlob } from "../packages/excalidraw/data/blob";
import {
ExcalidrawElement,
import type {
FileId,
NonDeletedExcalidrawElement,
Theme,
OrderedExcalidrawElement,
} from "../packages/excalidraw/element/types";
import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRefState";
import { t } from "../packages/excalidraw/i18n";
@ -27,32 +26,34 @@ import {
LiveCollaborationTrigger,
TTDDialog,
TTDDialogTrigger,
} from "../packages/excalidraw/index";
import {
StoreAction,
reconcileElements,
} from "../packages/excalidraw";
import type {
AppState,
LibraryItems,
ExcalidrawImperativeAPI,
BinaryFiles,
ExcalidrawInitialDataState,
UIAppState,
} from "../packages/excalidraw/types";
import type { ResolvablePromise } from "../packages/excalidraw/utils";
import {
debounce,
getVersion,
getFrame,
isTestEnv,
preventUnload,
ResolvablePromise,
resolvablePromise,
isRunningInIframe,
} from "../packages/excalidraw/utils";
import {
FIREBASE_STORAGE_PREFIXES,
isExcalidrawPlusSignedUser,
STORAGE_KEYS,
SYNC_BROWSER_TABS_TIMEOUT,
} from "./app_constants";
import type { CollabAPI } from "./collab/Collab";
import Collab, {
CollabAPI,
collabAPIAtom,
isCollaboratingAtom,
isOfflineAtom,
@ -64,16 +65,12 @@ import {
loadScene,
} from "./data";
import {
getLibraryItemsFromStorage,
importFromLocalStorage,
importUsernameFromLocalStorage,
} from "./data/localStorage";
import CustomStats from "./CustomStats";
import {
restore,
restoreAppState,
RestoredDataState,
} from "../packages/excalidraw/data/restore";
import type { RestoredDataState } from "../packages/excalidraw/data/restore";
import { restore, restoreAppState } from "../packages/excalidraw/data/restore";
import {
ExportToExcalidrawPlus,
exportToExcalidrawPlus,
@ -82,10 +79,13 @@ import { updateStaleImageStatuses } from "./data/FileManager";
import { newElementWith } from "../packages/excalidraw/element/mutateElement";
import { isInitializedImageElement } from "../packages/excalidraw/element/typeChecks";
import { loadFilesFromFirebase } from "./data/firebase";
import { LocalData } from "./data/LocalData";
import {
LibraryIndexedDBAdapter,
LibraryLocalStorageMigrationAdapter,
LocalData,
} from "./data/LocalData";
import { isBrowserStorageStateNewer } from "./data/tabSync";
import clsx from "clsx";
import { reconcileElements } from "./collab/reconciliation";
import {
parseLibraryTokensFromUrl,
useHandleLibrary,
@ -98,12 +98,29 @@ import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
import { appJotaiStore } from "./app-jotai";
import "./index.scss";
import { ResolutionType } from "../packages/excalidraw/utility-types";
import type { ResolutionType } from "../packages/excalidraw/utility-types";
import { ShareableLinkDialog } from "../packages/excalidraw/components/ShareableLinkDialog";
import { openConfirmModal } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirmState";
import { OverwriteConfirmDialog } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirm";
import Trans from "../packages/excalidraw/components/Trans";
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
import type { RemoteExcalidrawElement } from "../packages/excalidraw/data/reconcile";
import {
CommandPalette,
DEFAULT_CATEGORIES,
} from "../packages/excalidraw/components/CommandPalette/CommandPalette";
import {
GithubIcon,
XBrandIcon,
DiscordIcon,
ExcalLogo,
usersIcon,
exportToPlus,
share,
youtubeIcon,
} from "../packages/excalidraw/components/icons";
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
polyfill();
@ -252,7 +269,7 @@ const initializeScene = async (opts: {
},
elements: reconcileElements(
scene?.elements || [],
excalidrawAPI.getSceneElementsIncludingDeleted(),
excalidrawAPI.getSceneElementsIncludingDeleted() as RemoteExcalidrawElement[],
excalidrawAPI.getAppState(),
),
},
@ -283,6 +300,9 @@ const ExcalidrawWrapper = () => {
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
const isCollabDisabled = isRunningInIframe();
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
const { editorTheme } = useHandleAppTheme();
// initial state
// ---------------------------------------------------------------------------
@ -310,10 +330,13 @@ const ExcalidrawWrapper = () => {
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
return isCollaborationLink(window.location.href);
});
const collabError = useAtomValue(collabErrorIndicatorAtom);
useHandleLibrary({
excalidrawAPI,
getInitialLibraryItems: getLibraryItemsFromStorage,
adapter: LibraryIndexedDBAdapter,
// TODO maybe remove this in several months (shipped: 24-03-11)
migrationAdapter: LibraryLocalStorageMigrationAdapter,
});
useEffect(() => {
@ -411,7 +434,7 @@ const ExcalidrawWrapper = () => {
excalidrawAPI.updateScene({
...data.scene,
...restore(data.scene, null, null, { repairBindings: true }),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
});
}
});
@ -442,9 +465,14 @@ const ExcalidrawWrapper = () => {
setLangCode(langCode);
excalidrawAPI.updateScene({
...localDataState,
storeAction: StoreAction.UPDATE,
});
excalidrawAPI.updateLibrary({
libraryItems: getLibraryItemsFromStorage(),
LibraryIndexedDBAdapter.load().then((data) => {
if (data) {
excalidrawAPI.updateLibrary({
libraryItems: data.libraryItems,
});
}
});
collabAPI?.setUsername(username || "");
}
@ -539,25 +567,8 @@ const ExcalidrawWrapper = () => {
languageDetector.cacheUserLanguage(langCode);
}, [langCode]);
const [theme, setTheme] = useState<Theme>(
() =>
(localStorage.getItem(
STORAGE_KEYS.LOCAL_STORAGE_THEME,
) as Theme | null) ||
// FIXME migration from old LS scheme. Can be removed later. #5660
importFromLocalStorage().appState?.theme ||
THEME.LIGHT,
);
useEffect(() => {
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, theme);
// currently only used for body styling during init (see public/index.html),
// but may change in the future
document.documentElement.classList.toggle("dark", theme === THEME.DARK);
}, [theme]);
const onChange = (
elements: readonly ExcalidrawElement[],
elements: readonly OrderedExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
) => {
@ -565,8 +576,6 @@ const ExcalidrawWrapper = () => {
collabAPI.syncElements(elements);
}
setTheme(appState.theme);
// 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()) {
@ -592,6 +601,7 @@ const ExcalidrawWrapper = () => {
if (didChange) {
excalidrawAPI.updateScene({
elements,
storeAction: StoreAction.UPDATE,
});
}
}
@ -656,15 +666,6 @@ const ExcalidrawWrapper = () => {
);
};
const onLibraryChange = async (items: LibraryItems) => {
if (!items.length) {
localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
return;
}
const serializedItems = JSON.stringify(items);
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
};
const isOffline = useAtomValue(isOfflineAtom);
const onCollabDialogOpen = useCallback(
@ -691,6 +692,45 @@ const ExcalidrawWrapper = () => {
);
}
const ExcalidrawPlusCommand = {
label: "Excalidraw+",
category: DEFAULT_CATEGORIES.links,
predicate: true,
icon: <div style={{ width: 14 }}>{ExcalLogo}</div>,
keywords: ["plus", "cloud", "server"],
perform: () => {
window.open(
`${
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`,
"_blank",
);
},
};
const ExcalidrawPlusAppCommand = {
label: "Sign up",
category: DEFAULT_CATEGORIES.links,
predicate: true,
icon: <div style={{ width: 14 }}>{ExcalLogo}</div>,
keywords: [
"excalidraw",
"plus",
"cloud",
"server",
"signin",
"login",
"signup",
],
perform: () => {
window.open(
`${
import.meta.env.VITE_APP_PLUS_APP
}?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`,
"_blank",
);
},
};
return (
<div
style={{ height: "100%" }}
@ -709,27 +749,30 @@ const ExcalidrawWrapper = () => {
toggleTheme: true,
export: {
onExportToBackend,
renderCustomUI: (elements, appState, files) => {
return (
<ExportToExcalidrawPlus
elements={elements}
appState={appState}
files={files}
onError={(error) => {
excalidrawAPI?.updateScene({
appState: {
errorMessage: error.message,
},
});
}}
onSuccess={() => {
excalidrawAPI?.updateScene({
appState: { openDialog: null },
});
}}
/>
);
},
renderCustomUI: excalidrawAPI
? (elements, appState, files) => {
return (
<ExportToExcalidrawPlus
elements={elements}
appState={appState}
files={files}
name={excalidrawAPI.getName()}
onError={(error) => {
excalidrawAPI?.updateScene({
appState: {
errorMessage: error.message,
},
});
}}
onSuccess={() => {
excalidrawAPI.updateScene({
appState: { openDialog: null },
});
}}
/>
);
}
: undefined,
},
},
}}
@ -737,20 +780,22 @@ const ExcalidrawWrapper = () => {
renderCustomStats={renderCustomStats}
detectScroll={false}
handleKeyboardGlobally={true}
onLibraryChange={onLibraryChange}
autoFocus={true}
theme={theme}
theme={editorTheme}
renderTopRightUI={(isMobile) => {
if (isMobile || !collabAPI || isCollabDisabled) {
return null;
}
return (
<LiveCollaborationTrigger
isCollaborating={isCollaborating}
onSelect={() =>
setShareDialogState({ isOpen: true, type: "share" })
}
/>
<div className="top-right-ui">
{collabError.message && <CollabError collabError={collabError} />}
<LiveCollaborationTrigger
isCollaborating={isCollaborating}
onSelect={() =>
setShareDialogState({ isOpen: true, type: "share" })
}
/>
</div>
);
}}
>
@ -758,6 +803,8 @@ const ExcalidrawWrapper = () => {
onCollabDialogOpen={onCollabDialogOpen}
isCollaborating={isCollaborating}
isCollabEnabled={!isCollabDisabled}
theme={appTheme}
setTheme={(theme) => setAppTheme(theme)}
/>
<AppWelcomeScreen
onCollabDialogOpen={onCollabDialogOpen}
@ -775,6 +822,7 @@ const ExcalidrawWrapper = () => {
excalidrawAPI.getSceneElements(),
excalidrawAPI.getAppState(),
excalidrawAPI.getFiles(),
excalidrawAPI.getName(),
);
}}
>
@ -879,6 +927,181 @@ const ExcalidrawWrapper = () => {
{errorMessage}
</ErrorDialog>
)}
<CommandPalette
customCommandPaletteItems={[
{
label: t("labels.liveCollaboration"),
category: DEFAULT_CATEGORIES.app,
keywords: [
"team",
"multiplayer",
"share",
"public",
"session",
"invite",
],
icon: usersIcon,
perform: () => {
setShareDialogState({
isOpen: true,
type: "collaborationOnly",
});
},
},
{
label: t("roomDialog.button_stopSession"),
category: DEFAULT_CATEGORIES.app,
predicate: () => !!collabAPI?.isCollaborating(),
keywords: [
"stop",
"session",
"end",
"leave",
"close",
"exit",
"collaboration",
],
perform: () => {
if (collabAPI) {
collabAPI.stopCollaboration();
if (!collabAPI.isCollaborating()) {
setShareDialogState({ isOpen: false });
}
}
},
},
{
label: t("labels.share"),
category: DEFAULT_CATEGORIES.app,
predicate: true,
icon: share,
keywords: [
"link",
"shareable",
"readonly",
"export",
"publish",
"snapshot",
"url",
"collaborate",
"invite",
],
perform: async () => {
setShareDialogState({ isOpen: true, type: "share" });
},
},
{
label: "GitHub",
icon: GithubIcon,
category: DEFAULT_CATEGORIES.links,
predicate: true,
keywords: [
"issues",
"bugs",
"requests",
"report",
"features",
"social",
"community",
],
perform: () => {
window.open(
"https://github.com/excalidraw/excalidraw",
"_blank",
"noopener noreferrer",
);
},
},
{
label: t("labels.followUs"),
icon: XBrandIcon,
category: DEFAULT_CATEGORIES.links,
predicate: true,
keywords: ["twitter", "contact", "social", "community"],
perform: () => {
window.open(
"https://x.com/excalidraw",
"_blank",
"noopener noreferrer",
);
},
},
{
label: t("labels.discordChat"),
category: DEFAULT_CATEGORIES.links,
predicate: true,
icon: DiscordIcon,
keywords: [
"chat",
"talk",
"contact",
"bugs",
"requests",
"report",
"feedback",
"suggestions",
"social",
"community",
],
perform: () => {
window.open(
"https://discord.gg/UexuTaE",
"_blank",
"noopener noreferrer",
);
},
},
{
label: "YouTube",
icon: youtubeIcon,
category: DEFAULT_CATEGORIES.links,
predicate: true,
keywords: ["features", "tutorials", "howto", "help", "community"],
perform: () => {
window.open(
"https://youtube.com/@excalidraw",
"_blank",
"noopener noreferrer",
);
},
},
...(isExcalidrawPlusSignedUser
? [
{
...ExcalidrawPlusAppCommand,
label: "Sign in / Go to Excalidraw+",
},
]
: [ExcalidrawPlusCommand, ExcalidrawPlusAppCommand]),
{
label: t("overwriteConfirm.action.excalidrawPlus.button"),
category: DEFAULT_CATEGORIES.export,
icon: exportToPlus,
predicate: true,
keywords: ["plus", "export", "save", "backup"],
perform: () => {
if (excalidrawAPI) {
exportToExcalidrawPlus(
excalidrawAPI.getSceneElements(),
excalidrawAPI.getAppState(),
excalidrawAPI.getFiles(),
excalidrawAPI.getName(),
);
}
},
},
{
...CommandPalette.defaultItems.toggleTheme,
perform: () => {
setAppTheme(
editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
);
},
},
]}
/>
</Excalidraw>
</div>
);

View File

@ -7,8 +7,8 @@ import {
import { DEFAULT_VERSION } from "../packages/excalidraw/constants";
import { t } from "../packages/excalidraw/i18n";
import { copyTextToSystemClipboard } from "../packages/excalidraw/clipboard";
import { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types";
import { UIAppState } from "../packages/excalidraw/types";
import type { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types";
import type { UIAppState } from "../packages/excalidraw/types";
type StorageSizes = { scene: number; total: number };

View File

@ -39,10 +39,14 @@ export const STORAGE_KEYS = {
LOCAL_STORAGE_ELEMENTS: "excalidraw",
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
LOCAL_STORAGE_LIBRARY: "excalidraw-library",
LOCAL_STORAGE_THEME: "excalidraw-theme",
VERSION_DATA_STATE: "version-dataState",
VERSION_FILES: "version-files",
IDB_LIBRARY: "excalidraw-library",
// do not use apart from migrations
__LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library",
} as const;
export const COOKIES = {

View File

@ -1,22 +1,25 @@
import throttle from "lodash.throttle";
import { PureComponent } from "react";
import {
import type {
ExcalidrawImperativeAPI,
SocketId,
} from "../../packages/excalidraw/types";
import { ErrorDialog } from "../../packages/excalidraw/components/ErrorDialog";
import { APP_NAME, ENV, EVENT } from "../../packages/excalidraw/constants";
import { ImportedDataState } from "../../packages/excalidraw/data/types";
import {
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
import type {
ExcalidrawElement,
InitializedExcalidrawImageElement,
OrderedExcalidrawElement,
} from "../../packages/excalidraw/element/types";
import {
StoreAction,
getSceneVersion,
restoreElements,
zoomToFitBounds,
} from "../../packages/excalidraw/index";
import { Collaborator, Gesture } from "../../packages/excalidraw/types";
reconcileElements,
} from "../../packages/excalidraw";
import type { Collaborator, Gesture } from "../../packages/excalidraw/types";
import {
assertNever,
preventUnload,
@ -33,12 +36,14 @@ import {
SYNC_FULL_SCENE_INTERVAL_MS,
WS_EVENTS,
} from "../app_constants";
import type {
SocketUpdateDataSource,
SyncableExcalidrawElement,
} from "../data";
import {
generateCollaborationLinkData,
getCollaborationLink,
getSyncableElements,
SocketUpdateDataSource,
SyncableExcalidrawElement,
} from "../data";
import {
isSavedToFirebase,
@ -69,18 +74,19 @@ import {
isInitializedImageElement,
} from "../../packages/excalidraw/element/typeChecks";
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
import {
ReconciledElements,
reconcileElements as _reconcileElements,
} from "./reconciliation";
import { decryptData } from "../../packages/excalidraw/data/encryption";
import { resetBrowserStateVersions } from "../data/tabSync";
import { LocalData } from "../data/LocalData";
import { atom } from "jotai";
import { appJotaiStore } from "../app-jotai";
import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
import type { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
import { collabErrorIndicatorAtom } from "./CollabError";
import type {
ReconciledExcalidrawElement,
RemoteExcalidrawElement,
} from "../../packages/excalidraw/data/reconcile";
export const collabAPIAtom = atom<CollabAPI | null>(null);
export const isCollaboratingAtom = atom(false);
@ -88,6 +94,8 @@ export const isOfflineAtom = atom(false);
interface CollabState {
errorMessage: string | null;
/** errors related to saving */
dialogNotifiedErrors: Record<string, boolean>;
username: string;
activeRoomLink: string | null;
}
@ -107,7 +115,7 @@ export interface CollabAPI {
setUsername: CollabInstance["setUsername"];
getUsername: CollabInstance["getUsername"];
getActiveRoomLink: CollabInstance["getActiveRoomLink"];
setErrorMessage: CollabInstance["setErrorMessage"];
setCollabError: CollabInstance["setErrorDialog"];
}
interface CollabProps {
@ -129,6 +137,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
super(props);
this.state = {
errorMessage: null,
dialogNotifiedErrors: {},
username: importUsernameFromLocalStorage() || "",
activeRoomLink: null,
};
@ -197,7 +206,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
setUsername: this.setUsername,
getUsername: this.getUsername,
getActiveRoomLink: this.getActiveRoomLink,
setErrorMessage: this.setErrorMessage,
setCollabError: this.setErrorDialog,
};
appJotaiStore.set(collabAPIAtom, collabAPI);
@ -270,24 +279,39 @@ class Collab extends PureComponent<CollabProps, CollabState> {
syncableElements: readonly SyncableExcalidrawElement[],
) => {
try {
const savedData = await saveToFirebase(
const storedElements = await saveToFirebase(
this.portal,
syncableElements,
this.excalidrawAPI.getAppState(),
);
if (this.isCollaborating() && savedData && savedData.reconciledElements) {
this.handleRemoteSceneUpdate(
this.reconcileElements(savedData.reconciledElements),
);
this.resetErrorIndicator();
if (this.isCollaborating() && storedElements) {
this.handleRemoteSceneUpdate(this._reconcileElements(storedElements));
}
} catch (error: any) {
this.setState({
// firestore doesn't return a specific error code when size exceeded
errorMessage: /is longer than.*?bytes/.test(error.message)
? t("errors.collabSaveFailed_sizeExceeded")
: t("errors.collabSaveFailed"),
});
const errorMessage = /is longer than.*?bytes/.test(error.message)
? t("errors.collabSaveFailed_sizeExceeded")
: t("errors.collabSaveFailed");
if (
!this.state.dialogNotifiedErrors[errorMessage] ||
!this.isCollaborating()
) {
this.setErrorDialog(errorMessage);
this.setState({
dialogNotifiedErrors: {
...this.state.dialogNotifiedErrors,
[errorMessage]: true,
},
});
}
if (this.isCollaborating()) {
this.setErrorIndicator(errorMessage);
}
console.error(error);
}
};
@ -296,6 +320,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
this.queueBroadcastAllElements.cancel();
this.queueSaveToFirebase.cancel();
this.loadImageFiles.cancel();
this.resetErrorIndicator(true);
this.saveCollabRoomToFirebase(
getSyncableElements(
@ -334,7 +359,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
this.excalidrawAPI.updateScene({
elements,
commitToHistory: false,
storeAction: StoreAction.UPDATE,
});
}
};
@ -407,7 +432,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
startCollaboration = async (
existingRoomLinkData: null | { roomId: string; roomKey: string },
): Promise<ImportedDataState | null> => {
) => {
if (!this.state.username) {
import("@excalidraw/random-username").then(({ getRandomUsername }) => {
const username = getRandomUsername();
@ -433,7 +458,11 @@ class Collab extends PureComponent<CollabProps, CollabState> {
);
}
const scenePromise = resolvablePromise<ImportedDataState | null>();
// TODO: `ImportedDataState` type here seems abused
const scenePromise = resolvablePromise<
| (ImportedDataState & { elements: readonly OrderedExcalidrawElement[] })
| null
>();
this.setIsCollaborating(true);
LocalData.pauseSave("collaboration");
@ -464,7 +493,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
this.portal.socket.once("connect_error", fallbackInitializationHandler);
} catch (error: any) {
console.error(error);
this.setState({ errorMessage: error.message });
this.setErrorDialog(error.message);
return null;
}
@ -475,14 +504,13 @@ class Collab extends PureComponent<CollabProps, CollabState> {
}
return element;
});
// remove deleted elements from elements array & history to ensure we don't
// remove deleted elements from elements array to ensure we don't
// expose potentially sensitive user data in case user manually deletes
// existing elements (or clears scene), which would otherwise be persisted
// to database even if deleted before creating the room.
this.excalidrawAPI.history.clear();
this.excalidrawAPI.updateScene({
elements,
commitToHistory: true,
storeAction: StoreAction.UPDATE,
});
this.saveCollabRoomToFirebase(getSyncableElements(elements));
@ -516,10 +544,9 @@ class Collab extends PureComponent<CollabProps, CollabState> {
if (!this.portal.socketInitialized) {
this.initializeRoom({ fetchScene: false });
const remoteElements = decryptedData.payload.elements;
const reconciledElements = this.reconcileElements(remoteElements);
this.handleRemoteSceneUpdate(reconciledElements, {
init: true,
});
const reconciledElements =
this._reconcileElements(remoteElements);
this.handleRemoteSceneUpdate(reconciledElements);
// noop if already resolved via init from firebase
scenePromise.resolve({
elements: reconciledElements,
@ -530,7 +557,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
}
case WS_SUBTYPES.UPDATE:
this.handleRemoteSceneUpdate(
this.reconcileElements(decryptedData.payload.elements),
this._reconcileElements(decryptedData.payload.elements),
);
break;
case WS_SUBTYPES.MOUSE_LOCATION: {
@ -678,17 +705,15 @@ class Collab extends PureComponent<CollabProps, CollabState> {
return null;
};
private reconcileElements = (
private _reconcileElements = (
remoteElements: readonly ExcalidrawElement[],
): ReconciledElements => {
): ReconciledExcalidrawElement[] => {
const localElements = this.getSceneElementsIncludingDeleted();
const appState = this.excalidrawAPI.getAppState();
remoteElements = restoreElements(remoteElements, null);
const reconciledElements = _reconcileElements(
const restoredRemoteElements = restoreElements(remoteElements, null);
const reconciledElements = reconcileElements(
localElements,
remoteElements,
restoredRemoteElements as RemoteExcalidrawElement[],
appState,
);
@ -719,20 +744,13 @@ class Collab extends PureComponent<CollabProps, CollabState> {
}, LOAD_IMAGES_TIMEOUT);
private handleRemoteSceneUpdate = (
elements: ReconciledElements,
{ init = false }: { init?: boolean } = {},
elements: ReconciledExcalidrawElement[],
) => {
this.excalidrawAPI.updateScene({
elements,
commitToHistory: !!init,
storeAction: StoreAction.UPDATE,
});
// We haven't yet implemented multiplayer undo functionality, so we clear the undo stack
// when we receive any messages from another peer. This UX can be pretty rough -- if you
// undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
// right now we think this is the right tradeoff.
this.excalidrawAPI.history.clear();
this.loadImageFiles();
};
@ -865,7 +883,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
this.portal.broadcastIdleChange(userState);
};
broadcastElements = (elements: readonly ExcalidrawElement[]) => {
broadcastElements = (elements: readonly OrderedExcalidrawElement[]) => {
if (
getSceneVersion(elements) >
this.getLastBroadcastedOrReceivedSceneVersion()
@ -876,7 +894,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
}
};
syncElements = (elements: readonly ExcalidrawElement[]) => {
syncElements = (elements: readonly OrderedExcalidrawElement[]) => {
this.broadcastElements(elements);
this.queueSaveToFirebase();
};
@ -923,8 +941,26 @@ class Collab extends PureComponent<CollabProps, CollabState> {
getActiveRoomLink = () => this.state.activeRoomLink;
setErrorMessage = (errorMessage: string | null) => {
this.setState({ errorMessage });
setErrorIndicator = (errorMessage: string | null) => {
appJotaiStore.set(collabErrorIndicatorAtom, {
message: errorMessage,
nonce: Date.now(),
});
};
resetErrorIndicator = (resetDialogNotifiedErrors = false) => {
appJotaiStore.set(collabErrorIndicatorAtom, { message: null, nonce: 0 });
if (resetDialogNotifiedErrors) {
this.setState({
dialogNotifiedErrors: {},
});
}
};
setErrorDialog = (errorMessage: string | null) => {
this.setState({
errorMessage,
});
};
render() {
@ -933,7 +969,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
return (
<>
{errorMessage != null && (
<ErrorDialog onClose={() => this.setState({ errorMessage: null })}>
<ErrorDialog onClose={() => this.setErrorDialog(null)}>
{errorMessage}
</ErrorDialog>
)}

View File

@ -0,0 +1,35 @@
@import "../../packages/excalidraw/css/variables.module.scss";
.excalidraw {
.collab-errors-button {
width: 26px;
height: 26px;
margin-inline-end: 1rem;
color: var(--color-danger);
flex-shrink: 0;
}
.collab-errors-button-shake {
animation: strong-shake 0.15s 6;
}
@keyframes strong-shake {
0% {
transform: rotate(0deg);
}
25% {
transform: rotate(10deg);
}
50% {
transform: rotate(0deg);
}
75% {
transform: rotate(-10deg);
}
100% {
transform: rotate(0deg);
}
}
}

View File

@ -0,0 +1,54 @@
import { Tooltip } from "../../packages/excalidraw/components/Tooltip";
import { warning } from "../../packages/excalidraw/components/icons";
import clsx from "clsx";
import { useEffect, useRef, useState } from "react";
import "./CollabError.scss";
import { atom } from "jotai";
type ErrorIndicator = {
message: string | null;
/** used to rerun the useEffect responsible for animation */
nonce: number;
};
export const collabErrorIndicatorAtom = atom<ErrorIndicator>({
message: null,
nonce: 0,
});
const CollabError = ({ collabError }: { collabError: ErrorIndicator }) => {
const [isAnimating, setIsAnimating] = useState(false);
const clearAnimationRef = useRef<string | number | NodeJS.Timeout>();
useEffect(() => {
setIsAnimating(true);
clearAnimationRef.current = setTimeout(() => {
setIsAnimating(false);
}, 1000);
return () => {
clearTimeout(clearAnimationRef.current);
};
}, [collabError.message, collabError.nonce]);
if (!collabError.message) {
return null;
}
return (
<Tooltip label={collabError.message} long={true}>
<div
className={clsx("collab-errors-button", {
"collab-errors-button-shake": isAnimating,
})}
>
{warning}
</div>
</Tooltip>
);
};
CollabError.displayName = "CollabError";
export default CollabError;

View File

@ -1,14 +1,15 @@
import {
isSyncableElement,
import type {
SocketUpdateData,
SocketUpdateDataSource,
SyncableExcalidrawElement,
} from "../data";
import { isSyncableElement } from "../data";
import { TCollabClass } from "./Collab";
import type { TCollabClass } from "./Collab";
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
import type { OrderedExcalidrawElement } from "../../packages/excalidraw/element/types";
import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
import {
import type {
OnUserFollowedPayload,
SocketId,
UserIdleState,
@ -16,10 +17,9 @@ import {
import { trackEvent } from "../../packages/excalidraw/analytics";
import throttle from "lodash.throttle";
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
import { BroadcastedExcalidrawElement } from "./reconciliation";
import { encryptData } from "../../packages/excalidraw/data/encryption";
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
import type { Socket } from "socket.io-client";
import { StoreAction } from "../../packages/excalidraw";
class Portal {
collab: TCollabClass;
@ -128,12 +128,13 @@ class Portal {
}
return element;
}),
storeAction: StoreAction.UPDATE,
});
}, FILE_UPLOAD_TIMEOUT);
broadcastScene = async (
updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE,
allElements: readonly ExcalidrawElement[],
elements: readonly OrderedExcalidrawElement[],
syncAll: boolean,
) => {
if (updateType === WS_SUBTYPES.INIT && !syncAll) {
@ -143,25 +144,17 @@ class Portal {
// sync out only the elements we think we need to to save bandwidth.
// periodically we'll resync the whole thing to make sure no one diverges
// due to a dropped message (server goes down etc).
const syncableElements = allElements.reduce(
(acc, element: BroadcastedExcalidrawElement, idx, elements) => {
if (
(syncAll ||
!this.broadcastedElementVersions.has(element.id) ||
element.version >
this.broadcastedElementVersions.get(element.id)!) &&
isSyncableElement(element)
) {
acc.push({
...element,
// z-index info for the reconciler
[PRECEDING_ELEMENT_KEY]: idx === 0 ? "^" : elements[idx - 1]?.id,
});
}
return acc;
},
[] as BroadcastedExcalidrawElement[],
);
const syncableElements = elements.reduce((acc, element) => {
if (
(syncAll ||
!this.broadcastedElementVersions.has(element.id) ||
element.version > this.broadcastedElementVersions.get(element.id)!) &&
isSyncableElement(element)
) {
acc.push(element);
}
return acc;
}, [] as SyncableExcalidrawElement[]);
const data: SocketUpdateDataSource[typeof updateType] = {
type: updateType,

View File

@ -65,19 +65,18 @@ export const RoomModal = ({
const copyRoomLink = async () => {
try {
await copyTextToSystemClipboard(activeRoomLink);
setJustCopied(true);
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
setJustCopied(false);
}, 3000);
} catch (error: any) {
setErrorMessage(error.message);
} catch (e) {
setErrorMessage(t("errors.copyToSystemClipboardFailed"));
}
setJustCopied(true);
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
setJustCopied(false);
}, 3000);
ref.current?.select();
};

View File

@ -1,154 +0,0 @@
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
import { AppState } from "../../packages/excalidraw/types";
import { arrayToMapWithIndex } from "../../packages/excalidraw/utils";
export type ReconciledElements = readonly ExcalidrawElement[] & {
_brand: "reconciledElements";
};
export type BroadcastedExcalidrawElement = ExcalidrawElement & {
[PRECEDING_ELEMENT_KEY]?: string;
};
const shouldDiscardRemoteElement = (
localAppState: AppState,
local: ExcalidrawElement | undefined,
remote: BroadcastedExcalidrawElement,
): boolean => {
if (
local &&
// local element is being edited
(local.id === localAppState.editingElement?.id ||
local.id === localAppState.resizingElement?.id ||
local.id === localAppState.draggingElement?.id ||
// local element is newer
local.version > remote.version ||
// resolve conflicting edits deterministically by taking the one with
// the lowest versionNonce
(local.version === remote.version &&
local.versionNonce < remote.versionNonce))
) {
return true;
}
return false;
};
export const reconcileElements = (
localElements: readonly ExcalidrawElement[],
remoteElements: readonly BroadcastedExcalidrawElement[],
localAppState: AppState,
): ReconciledElements => {
const localElementsData =
arrayToMapWithIndex<ExcalidrawElement>(localElements);
const reconciledElements: ExcalidrawElement[] = localElements.slice();
const duplicates = new WeakMap<ExcalidrawElement, true>();
let cursor = 0;
let offset = 0;
let remoteElementIdx = -1;
for (const remoteElement of remoteElements) {
remoteElementIdx++;
const local = localElementsData.get(remoteElement.id);
if (shouldDiscardRemoteElement(localAppState, local?.[0], remoteElement)) {
if (remoteElement[PRECEDING_ELEMENT_KEY]) {
delete remoteElement[PRECEDING_ELEMENT_KEY];
}
continue;
}
// Mark duplicate for removal as it'll be replaced with the remote element
if (local) {
// Unless the remote 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);
}
// parent may not be defined in case the remote client is running an older
// excalidraw version
const parent =
remoteElement[PRECEDING_ELEMENT_KEY] ||
remoteElements[remoteElementIdx - 1]?.id ||
null;
if (parent != null) {
delete remoteElement[PRECEDING_ELEMENT_KEY];
// ^ indicates the element is the first in elements array
if (parent === "^") {
offset++;
if (cursor === 0) {
reconciledElements.unshift(remoteElement);
localElementsData.set(remoteElement.id, [
remoteElement,
cursor - offset,
]);
} else {
reconciledElements.splice(cursor + 1, 0, remoteElement);
localElementsData.set(remoteElement.id, [
remoteElement,
cursor + 1 - offset,
]);
cursor++;
}
} else {
let idx = localElementsData.has(parent)
? localElementsData.get(parent)![1]
: null;
if (idx != null) {
idx += offset;
}
if (idx != null && idx >= cursor) {
reconciledElements.splice(idx + 1, 0, remoteElement);
offset++;
localElementsData.set(remoteElement.id, [
remoteElement,
idx + 1 - offset,
]);
cursor = idx + 1;
} else if (idx != null) {
reconciledElements.splice(cursor + 1, 0, remoteElement);
offset++;
localElementsData.set(remoteElement.id, [
remoteElement,
cursor + 1 - offset,
]);
cursor++;
} else {
reconciledElements.push(remoteElement);
localElementsData.set(remoteElement.id, [
remoteElement,
reconciledElements.length - 1 - offset,
]);
}
}
// no parent z-index information, local element exists → replace in place
} else if (local) {
reconciledElements[local[1]] = remoteElement;
localElementsData.set(remoteElement.id, [remoteElement, local[1]]);
// otherwise push to the end
} else {
reconciledElements.push(remoteElement);
localElementsData.set(remoteElement.id, [
remoteElement,
reconciledElements.length - 1 - offset,
]);
}
}
const ret: readonly ExcalidrawElement[] = reconciledElements.filter(
(element) => !duplicates.has(element),
);
return ret as ReconciledElements;
};

View File

@ -1,12 +1,19 @@
import React from "react";
import { PlusPromoIcon } from "../../packages/excalidraw/components/icons";
import {
loginIcon,
ExcalLogo,
} from "../../packages/excalidraw/components/icons";
import type { Theme } from "../../packages/excalidraw/element/types";
import { MainMenu } from "../../packages/excalidraw/index";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { LanguageList } from "./LanguageList";
export const AppMainMenu: React.FC<{
onCollabDialogOpen: () => any;
isCollaborating: boolean;
isCollabEnabled: boolean;
theme: Theme | "system";
setTheme: (theme: Theme | "system") => void;
}> = React.memo((props) => {
return (
<MainMenu>
@ -20,22 +27,35 @@ export const AppMainMenu: React.FC<{
onSelect={() => props.onCollabDialogOpen()}
/>
)}
<MainMenu.DefaultItems.CommandPalette className="highlighted" />
<MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator />
<MainMenu.ItemLink
icon={PlusPromoIcon}
icon={ExcalLogo}
href={`${
import.meta.env.VITE_APP_PLUS_LP
import.meta.env.VITE_APP_PLUS_APP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger`}
className="ExcalidrawPlus"
className=""
>
Excalidraw+
</MainMenu.ItemLink>
<MainMenu.DefaultItems.Socials />
<MainMenu.ItemLink
icon={loginIcon}
href={`${import.meta.env.VITE_APP_PLUS_APP}${
isExcalidrawPlusSignedUser ? "" : "/sign-up"
}?utm_source=signin&utm_medium=app&utm_content=hamburger`}
className="highlighted"
>
{isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
</MainMenu.ItemLink>
<MainMenu.Separator />
<MainMenu.DefaultItems.ToggleTheme />
<MainMenu.DefaultItems.ToggleTheme
allowSystemTheme
theme={props.theme}
onSelect={props.setTheme}
/>
<MainMenu.ItemCustom>
<LanguageList style={{ width: "100%" }} />
</MainMenu.ItemCustom>

View File

@ -1,5 +1,5 @@
import React from "react";
import { PlusPromoIcon } from "../../packages/excalidraw/components/icons";
import { loginIcon } from "../../packages/excalidraw/components/icons";
import { useI18n } from "../../packages/excalidraw/i18n";
import { WelcomeScreen } from "../../packages/excalidraw/index";
import { isExcalidrawPlusSignedUser } from "../app_constants";
@ -61,9 +61,9 @@ export const AppWelcomeScreen: React.FC<{
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest`}
shortcut={null}
icon={PlusPromoIcon}
icon={loginIcon}
>
Try Excalidraw Plus!
Sign up
</WelcomeScreen.Center.MenuItemLink>
)}
</WelcomeScreen.Center.Menu>

View File

@ -3,11 +3,11 @@ import { Card } from "../../packages/excalidraw/components/Card";
import { ToolButton } from "../../packages/excalidraw/components/ToolButton";
import { serializeAsJSON } from "../../packages/excalidraw/data/json";
import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
import {
import type {
FileId,
NonDeletedExcalidrawElement,
} from "../../packages/excalidraw/element/types";
import {
import type {
AppState,
BinaryFileData,
BinaryFiles,
@ -30,6 +30,7 @@ export const exportToExcalidrawPlus = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: Partial<AppState>,
files: BinaryFiles,
name: string,
) => {
const firebase = await loadFirebaseStorage();
@ -53,7 +54,7 @@ export const exportToExcalidrawPlus = async (
.ref(`/migrations/scenes/${id}`)
.put(blob, {
customMetadata: {
data: JSON.stringify({ version: 2, name: appState.name }),
data: JSON.stringify({ version: 2, name }),
created: Date.now().toString(),
},
});
@ -89,9 +90,10 @@ export const ExportToExcalidrawPlus: React.FC<{
elements: readonly NonDeletedExcalidrawElement[];
appState: Partial<AppState>;
files: BinaryFiles;
name: string;
onError: (error: Error) => void;
onSuccess: () => void;
}> = ({ elements, appState, files, onError, onSuccess }) => {
}> = ({ elements, appState, files, name, onError, onSuccess }) => {
const { t } = useI18n();
return (
<Card color="primary">
@ -117,7 +119,7 @@ export const ExportToExcalidrawPlus: React.FC<{
onClick={async () => {
try {
trackEvent("export", "eplus", `ui (${getFrame()})`);
await exportToExcalidrawPlus(elements, appState, files);
await exportToExcalidrawPlus(elements, appState, files, name);
onSuccess();
} catch (error: any) {
console.error(error);

View File

@ -1,7 +1,7 @@
import oc from "open-color";
import React from "react";
import { THEME } from "../../packages/excalidraw/constants";
import { Theme } from "../../packages/excalidraw/element/types";
import type { Theme } from "../../packages/excalidraw/element/types";
// https://github.com/tholman/github-corners
export const GitHubCorner = React.memo(

View File

@ -67,6 +67,8 @@ export class TopErrorBoundary extends React.Component<
window.open(
`https://github.com/excalidraw/excalidraw/issues/new?body=${body}`,
"_blank",
"noopener noreferrer",
);
}

View File

@ -1,14 +1,15 @@
import { StoreAction } from "../../packages/excalidraw";
import { compressData } from "../../packages/excalidraw/data/encode";
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
import {
import type {
ExcalidrawElement,
ExcalidrawImageElement,
FileId,
InitializedExcalidrawImageElement,
} from "../../packages/excalidraw/element/types";
import { t } from "../../packages/excalidraw/i18n";
import {
import type {
BinaryFileData,
BinaryFileMetadata,
ExcalidrawImperativeAPI,
@ -238,5 +239,6 @@ export const updateStaleImageStatuses = (params: {
}
return element;
}),
storeAction: StoreAction.UPDATE,
});
};

View File

@ -10,18 +10,29 @@
* (localStorage, indexedDB).
*/
import { createStore, entries, del, getMany, set, setMany } from "idb-keyval";
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
import {
createStore,
entries,
del,
getMany,
set,
setMany,
get,
} from "idb-keyval";
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
import type { LibraryPersistedData } from "../../packages/excalidraw/data/library";
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
import type {
ExcalidrawElement,
FileId,
} from "../../packages/excalidraw/element/types";
import {
import type {
AppState,
BinaryFileData,
BinaryFiles,
} from "../../packages/excalidraw/types";
import type { MaybePromise } from "../../packages/excalidraw/utility-types";
import { debounce } from "../../packages/excalidraw/utils";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
import { FileManager } from "./FileManager";
@ -183,3 +194,52 @@ export class LocalData {
},
});
}
export class LibraryIndexedDBAdapter {
/** IndexedDB database and store name */
private static idb_name = STORAGE_KEYS.IDB_LIBRARY;
/** library data store key */
private static key = "libraryData";
private static store = createStore(
`${LibraryIndexedDBAdapter.idb_name}-db`,
`${LibraryIndexedDBAdapter.idb_name}-store`,
);
static async load() {
const IDBData = await get<LibraryPersistedData>(
LibraryIndexedDBAdapter.key,
LibraryIndexedDBAdapter.store,
);
return IDBData || null;
}
static save(data: LibraryPersistedData): MaybePromise<void> {
return set(
LibraryIndexedDBAdapter.key,
data,
LibraryIndexedDBAdapter.store,
);
}
}
/** LS Adapter used only for migrating LS library data
* to indexedDB */
export class LibraryLocalStorageMigrationAdapter {
static load() {
const LSData = localStorage.getItem(
STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY,
);
if (LSData != null) {
const libraryItems: ImportedDataState["libraryItems"] =
JSON.parse(LSData);
if (libraryItems) {
return { libraryItems };
}
}
return null;
}
static clear() {
localStorage.removeItem(STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY);
}
}

View File

@ -1,11 +1,13 @@
import {
import { reconcileElements } from "../../packages/excalidraw";
import type {
ExcalidrawElement,
FileId,
OrderedExcalidrawElement,
} from "../../packages/excalidraw/element/types";
import { getSceneVersion } from "../../packages/excalidraw/element";
import Portal from "../collab/Portal";
import type Portal from "../collab/Portal";
import { restoreElements } from "../../packages/excalidraw/data/restore";
import {
import type {
AppState,
BinaryFileData,
BinaryFileMetadata,
@ -18,10 +20,11 @@ import {
decryptData,
} from "../../packages/excalidraw/data/encryption";
import { MIME_TYPES } from "../../packages/excalidraw/constants";
import { reconcileElements } from "../collab/reconciliation";
import { getSyncableElements, SyncableExcalidrawElement } from ".";
import { ResolutionType } from "../../packages/excalidraw/utility-types";
import type { SyncableExcalidrawElement } from ".";
import { getSyncableElements } from ".";
import type { ResolutionType } from "../../packages/excalidraw/utility-types";
import type { Socket } from "socket.io-client";
import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile";
// private
// -----------------------------------------------------------------------------
@ -230,7 +233,7 @@ export const saveToFirebase = async (
!socket ||
isSavedToFirebase(portal, elements)
) {
return false;
return null;
}
const firebase = await loadFirestore();
@ -238,56 +241,59 @@ export const saveToFirebase = async (
const docRef = firestore.collection("scenes").doc(roomId);
const savedData = await firestore.runTransaction(async (transaction) => {
const storedScene = await firestore.runTransaction(async (transaction) => {
const snapshot = await transaction.get(docRef);
if (!snapshot.exists) {
const sceneDocument = await createFirebaseSceneDocument(
const storedScene = await createFirebaseSceneDocument(
firebase,
elements,
roomKey,
);
transaction.set(docRef, sceneDocument);
transaction.set(docRef, storedScene);
return {
elements,
reconciledElements: null,
};
return storedScene;
}
const prevDocData = snapshot.data() as FirebaseStoredScene;
const prevElements = getSyncableElements(
await decryptElements(prevDocData, roomKey),
const prevStoredScene = snapshot.data() as FirebaseStoredScene;
const prevStoredElements = getSyncableElements(
restoreElements(await decryptElements(prevStoredScene, roomKey), null),
);
const reconciledElements = getSyncableElements(
reconcileElements(elements, prevElements, appState),
reconcileElements(
elements,
prevStoredElements as OrderedExcalidrawElement[] as RemoteExcalidrawElement[],
appState,
),
);
const sceneDocument = await createFirebaseSceneDocument(
const storedScene = await createFirebaseSceneDocument(
firebase,
reconciledElements,
roomKey,
);
transaction.update(docRef, sceneDocument);
return {
elements,
reconciledElements,
};
transaction.update(docRef, storedScene);
// Return the stored elements as the in memory `reconciledElements` could have mutated in the meantime
return storedScene;
});
FirebaseSceneVersionCache.set(socket, savedData.elements);
const storedElements = getSyncableElements(
restoreElements(await decryptElements(storedScene, roomKey), null),
);
return { reconciledElements: savedData.reconciledElements };
FirebaseSceneVersionCache.set(socket, storedElements);
return storedElements;
};
export const loadFromFirebase = async (
roomId: string,
roomKey: string,
socket: Socket | null,
): Promise<readonly ExcalidrawElement[] | null> => {
): Promise<readonly SyncableExcalidrawElement[] | null> => {
const firebase = await loadFirestore();
const db = firebase.firestore();
@ -298,14 +304,14 @@ export const loadFromFirebase = async (
}
const storedScene = doc.data() as FirebaseStoredScene;
const elements = getSyncableElements(
await decryptElements(storedScene, roomKey),
restoreElements(await decryptElements(storedScene, roomKey), null),
);
if (socket) {
FirebaseSceneVersionCache.set(socket, elements);
}
return restoreElements(elements, null);
return elements;
};
export const loadFilesFromFirebase = async (

View File

@ -9,38 +9,39 @@ import {
} from "../../packages/excalidraw/data/encryption";
import { serializeAsJSON } from "../../packages/excalidraw/data/json";
import { restore } from "../../packages/excalidraw/data/restore";
import { ImportedDataState } from "../../packages/excalidraw/data/types";
import { SceneBounds } from "../../packages/excalidraw/element/bounds";
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
import type { SceneBounds } from "../../packages/excalidraw/element/bounds";
import { isInvisiblySmallElement } from "../../packages/excalidraw/element/sizeHelpers";
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
import {
import type {
ExcalidrawElement,
FileId,
OrderedExcalidrawElement,
} from "../../packages/excalidraw/element/types";
import { t } from "../../packages/excalidraw/i18n";
import {
import type {
AppState,
BinaryFileData,
BinaryFiles,
SocketId,
UserIdleState,
} from "../../packages/excalidraw/types";
import type { MakeBrand } from "../../packages/excalidraw/utility-types";
import { bytesToHexString } from "../../packages/excalidraw/utils";
import type { WS_SUBTYPES } from "../app_constants";
import {
DELETED_ELEMENT_TIMEOUT,
FILE_UPLOAD_MAX_BYTES,
ROOM_ID_BYTES,
WS_SUBTYPES,
} from "../app_constants";
import { encodeFilesForUpload } from "./FileManager";
import { saveFilesToFirebase } from "./firebase";
export type SyncableExcalidrawElement = ExcalidrawElement & {
_brand: "SyncableExcalidrawElement";
};
export type SyncableExcalidrawElement = OrderedExcalidrawElement &
MakeBrand<"SyncableExcalidrawElement">;
export const isSyncableElement = (
element: ExcalidrawElement,
element: OrderedExcalidrawElement,
): element is SyncableExcalidrawElement => {
if (element.isDeleted) {
if (element.updated > Date.now() - DELETED_ELEMENT_TIMEOUT) {
@ -51,7 +52,9 @@ export const isSyncableElement = (
return !isInvisiblySmallElement(element);
};
export const getSyncableElements = (elements: readonly ExcalidrawElement[]) =>
export const getSyncableElements = (
elements: readonly OrderedExcalidrawElement[],
) =>
elements.filter((element) =>
isSyncableElement(element),
) as SyncableExcalidrawElement[];
@ -266,7 +269,6 @@ export const loadScene = async (
// in the scene database/localStorage, and instead fetch them async
// from a different database
files: data.files,
commitToHistory: false,
};
};

View File

@ -1,12 +1,11 @@
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
import { AppState } from "../../packages/excalidraw/types";
import type { ExcalidrawElement } from "../../packages/excalidraw/element/types";
import type { AppState } from "../../packages/excalidraw/types";
import {
clearAppStateForLocalStorage,
getDefaultAppState,
} from "../../packages/excalidraw/appState";
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
import { STORAGE_KEYS } from "../app_constants";
import { ImportedDataState } from "../../packages/excalidraw/data/types";
export const saveUsernameToLocalStorage = (username: string) => {
try {
@ -88,28 +87,13 @@ export const getTotalStorageSize = () => {
try {
const appState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
const library = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
const appStateSize = appState?.length || 0;
const collabSize = collab?.length || 0;
const librarySize = library?.length || 0;
return appStateSize + collabSize + librarySize + getElementsStorageSize();
return appStateSize + collabSize + getElementsStorageSize();
} catch (error: any) {
console.error(error);
return 0;
}
};
export const getLibraryItemsFromStorage = () => {
try {
const libraryItems: ImportedDataState["libraryItems"] = JSON.parse(
localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string,
);
return libraryItems || [];
} catch (error) {
console.error(error);
return [];
}
};

View File

@ -64,12 +64,30 @@
<!-- to minimize white flash on load when user has dark mode enabled -->
<script>
try {
//
const theme = window.localStorage.getItem("excalidraw-theme");
if (theme === "dark") {
document.documentElement.classList.add("dark");
function setTheme(theme) {
if (theme === "dark") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}
} catch {}
function getTheme() {
const theme = window.localStorage.getItem("excalidraw-theme");
if (theme && theme === "system") {
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
} else {
return theme || "light";
}
}
setTheme(getTheme());
} catch (e) {
console.error("Error setting dark mode", e);
}
</script>
<style>
html.dark {
@ -78,7 +96,7 @@
}
</style>
<!------------------------------------------------------------------------->
<% if ("%PROD%" === "true") { %>
<% if (typeof PROD != 'undefined' && PROD == true) { %>
<script>
// Redirect Excalidraw+ users which have auto-redirect enabled.
//
@ -122,7 +140,8 @@
/>
<link rel="stylesheet" href="/fonts/fonts.css" type="text/css" />
<% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%"==="true" ) { %>
<% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' &&
VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %>
<script>
{
const _WebSocket = window.WebSocket;
@ -196,7 +215,6 @@
</header>
<div id="root"></div>
<script type="module" src="index.tsx"></script>
<% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%" !== 'true') { %>
<!-- 100% privacy friendly analytics -->
<script>
// need to load this script dynamically bcs. of iframe embed tracking
@ -229,6 +247,5 @@
}
</script>
<!-- end LEGACY GOOGLE ANALYTICS -->
<% } %>
</body>
</html>

View File

@ -4,6 +4,13 @@
&.theme--dark {
--color-primary-contrast-offset: #726dff; // to offset Chubb illusion
}
.top-right-ui {
display: flex;
justify-content: center;
align-items: flex-start;
}
.footer-center {
justify-content: flex-end;
margin-top: auto;
@ -31,8 +38,12 @@
background-color: #ecfdf5;
color: #064e3c;
}
&.ExcalidrawPlus {
&.highlighted {
color: var(--color-promo);
font-weight: 700;
.dropdown-menu-item__icon g {
stroke-width: 2;
}
}
}
}

View File

@ -25,7 +25,9 @@
"engines": {
"node": ">=18.0.0"
},
"dependencies": {},
"dependencies": {
"vite-plugin-html": "3.2.2"
},
"prettier": "@excalidraw/prettier-config",
"scripts": {
"build-node": "node ./scripts/build-node.js",

View File

@ -1,4 +1,4 @@
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import * as Popover from "@radix-ui/react-popover";
import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
import { trackEvent } from "../../packages/excalidraw/analytics";
@ -18,10 +18,12 @@ import {
} from "../../packages/excalidraw/components/icons";
import { TextField } from "../../packages/excalidraw/components/TextField";
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
import { activeRoomLinkAtom, CollabAPI } from "../collab/Collab";
import type { CollabAPI } from "../collab/Collab";
import { activeRoomLinkAtom } from "../collab/Collab";
import { atom, useAtom, useAtomValue } from "jotai";
import "./ShareDialog.scss";
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
type OnExportToBackend = () => void;
type ShareDialogType = "share" | "collaborationOnly";
@ -69,20 +71,20 @@ const ActiveRoomDialog = ({
const copyRoomLink = async () => {
try {
await copyTextToSystemClipboard(activeRoomLink);
setJustCopied(true);
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
setJustCopied(false);
}, 3000);
} catch (error: any) {
collabAPI.setErrorMessage(error.message);
} catch (e) {
collabAPI.setCollabError(t("errors.copyToSystemClipboardFailed"));
}
setJustCopied(true);
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
setJustCopied(false);
}, 3000);
ref.current?.select();
};
@ -275,6 +277,14 @@ export const ShareDialog = (props: {
}) => {
const [shareDialogState, setShareDialogState] = useAtom(shareDialogStateAtom);
const { openDialog } = useUIAppState();
useEffect(() => {
if (openDialog) {
setShareDialogState({ isOpen: false });
}
}, [openDialog, setShareDialogState]);
if (!shareDialogState.isOpen) {
return null;
}
@ -285,6 +295,6 @@ export const ShareDialog = (props: {
collabAPI={props.collabAPI}
onExportToBackend={props.onExportToBackend}
type={shareDialogState.type}
></ShareDialogInner>
/>
);
};

View File

@ -224,24 +224,14 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
fill="none"
stroke="none"
/>
<rect
height="4"
rx="1"
width="18"
x="3"
y="8"
/>
<line
x1="12"
x2="12"
y1="8"
y2="21"
<path
d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2"
/>
<path
d="M19 12v7a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-7"
d="M21 12h-13l3 -3"
/>
<path
d="M7.5 8a2.5 2.5 0 0 1 0 -5a4.8 8 0 0 1 4.5 5a4.8 8 0 0 1 4.5 -5a2.5 2.5 0 0 1 0 5"
d="M11 15l-3 -3"
/>
</g>
</svg>
@ -249,7 +239,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
<div
class="welcome-screen-menu-item__text"
>
Try Excalidraw Plus!
Sign up
</div>
</a>
</div>

View File

@ -1,12 +1,19 @@
import { vi } from "vitest";
import {
act,
render,
updateSceneData,
waitFor,
} from "../../packages/excalidraw/tests/test-utils";
import ExcalidrawApp from "../App";
import { API } from "../../packages/excalidraw/tests/helpers/api";
import { createUndoAction } from "../../packages/excalidraw/actions/actionHistory";
import { syncInvalidIndices } from "../../packages/excalidraw/fractionalIndex";
import {
createRedoAction,
createUndoAction,
} from "../../packages/excalidraw/actions/actionHistory";
import { StoreAction, newElementWith } from "../../packages/excalidraw";
const { h } = window;
Object.defineProperty(window, "crypto", {
@ -56,39 +63,190 @@ vi.mock("socket.io-client", () => {
};
});
/**
* These test would deserve to be extended by testing collab with (at least) two clients simultanouesly,
* while having access to both scenes, appstates stores, histories and etc.
* i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
*/
describe("collaboration", () => {
it("creating room should reset deleted elements", async () => {
it("should allow to undo / redo even on force-deleted elements", async () => {
await render(<ExcalidrawApp />);
// To update the scene with deleted elements before starting collab
const rect1Props = {
type: "rectangle",
id: "A",
height: 200,
width: 100,
} as const;
const rect2Props = {
type: "rectangle",
id: "B",
width: 100,
height: 200,
} as const;
const rect1 = API.createElement({ ...rect1Props });
const rect2 = API.createElement({ ...rect2Props });
updateSceneData({
elements: [
API.createElement({ type: "rectangle", id: "A" }),
API.createElement({
type: "rectangle",
id: "B",
isDeleted: true,
}),
],
});
await waitFor(() => {
expect(h.elements).toEqual([
expect.objectContaining({ id: "A" }),
expect.objectContaining({ id: "B", isDeleted: true }),
]);
expect(API.getStateHistory().length).toBe(1);
});
window.collab.startCollaboration(null);
await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
expect(API.getStateHistory().length).toBe(1);
elements: syncInvalidIndices([rect1, rect2]),
storeAction: StoreAction.CAPTURE,
});
updateSceneData({
elements: syncInvalidIndices([
rect1,
newElementWith(h.elements[1], { isDeleted: true }),
]),
storeAction: StoreAction.CAPTURE,
});
const undoAction = createUndoAction(h.history);
// noop
h.app.actionManager.executeAction(undoAction);
await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
expect(API.getStateHistory().length).toBe(1);
expect(API.getUndoStack().length).toBe(2);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
});
// one form of force deletion happens when starting the collab, not to sync potentially sensitive data into the server
window.collab.startCollaboration(null);
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
// we never delete from the local snapshot as it is used for correct diff calculation
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
});
const undoAction = createUndoAction(h.history, h.store);
act(() => h.app.actionManager.executeAction(undoAction));
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false }),
]);
});
// simulate force deleting the element remotely
updateSceneData({
elements: syncInvalidIndices([rect1]),
storeAction: StoreAction.UPDATE,
});
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
});
const redoAction = createRedoAction(h.history, h.store);
act(() => h.app.actionManager.executeAction(redoAction));
// with explicit redo (as removal) we again restore the element from the snapshot!
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
});
act(() => h.app.actionManager.executeAction(undoAction));
// simulate local update
updateSceneData({
elements: syncInvalidIndices([
h.elements[0],
newElementWith(h.elements[1], { x: 100 }),
]),
storeAction: StoreAction.CAPTURE,
});
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
]);
});
act(() => h.app.actionManager.executeAction(undoAction));
// we expect to iterate the stack to the first visible change
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
]);
});
// simulate force deleting the element remotely
updateSceneData({
elements: syncInvalidIndices([rect1]),
storeAction: StoreAction.UPDATE,
});
// snapshot was correctly updated and marked the element as deleted
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true, x: 0 }),
]);
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
});
act(() => h.app.actionManager.executeAction(redoAction));
// with explicit redo (as update) we again restored the element from the snapshot!
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSnapshot()).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
]);
expect(h.history.isRedoStackEmpty).toBeTruthy();
expect(h.elements).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
]);
});
});
});

View File

@ -1,421 +0,0 @@
import { expect } from "chai";
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
import {
BroadcastedExcalidrawElement,
ReconciledElements,
reconcileElements,
} from "../../excalidraw-app/collab/reconciliation";
import { randomInteger } from "../../packages/excalidraw/random";
import { AppState } from "../../packages/excalidraw/types";
import { cloneJSON } from "../../packages/excalidraw/utils";
type Id = string;
type ElementLike = {
id: string;
version: number;
versionNonce: number;
[PRECEDING_ELEMENT_KEY]?: string | null;
};
type Cache = Record<string, ExcalidrawElement | undefined>;
const createElement = (opts: { uid: string } | ElementLike) => {
let uid: string;
let id: string;
let version: number | null;
let parent: string | null = null;
let versionNonce: number | null = null;
if ("uid" in opts) {
const match = opts.uid.match(
/^(?:\((\^|\w+)\))?(\w+)(?::(\d+))?(?:\((\w+)\))?$/,
)!;
parent = match[1];
id = match[2];
version = match[3] ? parseInt(match[3]) : null;
uid = version ? `${id}:${version}` : id;
} else {
({ id, version, versionNonce } = opts);
parent = parent || null;
uid = id;
}
return {
uid,
id,
version,
versionNonce: versionNonce || randomInteger(),
[PRECEDING_ELEMENT_KEY]: parent || null,
};
};
const idsToElements = (
ids: (Id | ElementLike)[],
cache: Cache = {},
): readonly ExcalidrawElement[] => {
return ids.reduce((acc, _uid, idx) => {
const {
uid,
id,
version,
[PRECEDING_ELEMENT_KEY]: parent,
versionNonce,
} = createElement(typeof _uid === "string" ? { uid: _uid } : _uid);
const cached = cache[uid];
const elem = {
id,
version: version ?? 0,
versionNonce,
...cached,
[PRECEDING_ELEMENT_KEY]: parent,
} as BroadcastedExcalidrawElement;
// @ts-ignore
cache[uid] = elem;
acc.push(elem);
return acc;
}, [] as ExcalidrawElement[]);
};
const addParents = (elements: BroadcastedExcalidrawElement[]) => {
return elements.map((el, idx, els) => {
el[PRECEDING_ELEMENT_KEY] = els[idx - 1]?.id || "^";
return el;
});
};
const cleanElements = (elements: ReconciledElements) => {
return elements.map((el) => {
// @ts-ignore
delete el[PRECEDING_ELEMENT_KEY];
// @ts-ignore
delete el.next;
// @ts-ignore
delete el.prev;
return el;
});
};
const test = <U extends `${string}:${"L" | "R"}`>(
local: (Id | ElementLike)[],
remote: (Id | ElementLike)[],
target: U[],
bidirectional = true,
) => {
const cache: Cache = {};
const _local = idsToElements(local, cache);
const _remote = idsToElements(remote, cache);
const _target = target.map((uid) => {
const [, id, source] = uid.match(/^(\w+):([LR])$/)!;
return (source === "L" ? _local : _remote).find((e) => e.id === id)!;
}) as any as ReconciledElements;
const remoteReconciled = reconcileElements(_local, _remote, {} as AppState);
expect(target.length).equal(remoteReconciled.length);
expect(cleanElements(remoteReconciled)).deep.equal(
cleanElements(_target),
"remote reconciliation",
);
const __local = cleanElements(cloneJSON(_remote) as ReconciledElements);
const __remote = addParents(cleanElements(cloneJSON(remoteReconciled)));
if (bidirectional) {
try {
expect(
cleanElements(
reconcileElements(
cloneJSON(__local),
cloneJSON(__remote),
{} as AppState,
),
),
).deep.equal(cleanElements(remoteReconciled), "local re-reconciliation");
} catch (error: any) {
console.error("local original", __local);
console.error("remote reconciled", __remote);
throw error;
}
}
};
export const findIndex = <T>(
array: readonly T[],
cb: (element: T, index: number, array: readonly T[]) => boolean,
fromIndex: number = 0,
) => {
if (fromIndex < 0) {
fromIndex = array.length + fromIndex;
}
fromIndex = Math.min(array.length, Math.max(fromIndex, 0));
let index = fromIndex - 1;
while (++index < array.length) {
if (cb(array[index], index, array)) {
return index;
}
}
return -1;
};
// -----------------------------------------------------------------------------
describe("elements reconciliation", () => {
it("reconcileElements()", () => {
// -------------------------------------------------------------------------
//
// in following tests, we pass:
// (1) an array of local elements and their version (:1, :2...)
// (2) an array of remote elements and their version (:1, :2...)
// (3) expected reconciled elements
//
// in the reconciled array:
// :L means local element was resolved
// :R means remote element was resolved
//
// if a remote element is prefixed with parentheses, the enclosed string:
// (^) means the element is the first element in the array
// (<id>) means the element is preceded by <id> element
//
// if versions are missing, it defaults to version 0
// -------------------------------------------------------------------------
// non-annotated elements
// -------------------------------------------------------------------------
// usually when we sync elements they should always be annotated with
// their (preceding elements) parents, but let's test a couple of cases when
// they're not for whatever reason (remote clients are on older version...),
// in which case the first synced element either replaces existing element
// or is pushed at the end of the array
test(["A:1", "B:1", "C:1"], ["B:2"], ["A:L", "B:R", "C:L"]);
test(["A:1", "B:1", "C"], ["B:2", "A:2"], ["B:R", "A:R", "C:L"]);
test(["A:2", "B:1", "C"], ["B:2", "A:1"], ["A:L", "B:R", "C:L"]);
test(["A:1", "B:1"], ["C:1"], ["A:L", "B:L", "C:R"]);
test(["A", "B"], ["A:1"], ["A:R", "B:L"]);
test(["A"], ["A", "B"], ["A:L", "B:R"]);
test(["A"], ["A:1", "B"], ["A:R", "B:R"]);
test(["A:2"], ["A:1", "B"], ["A:L", "B:R"]);
test(["A:2"], ["B", "A:1"], ["A:L", "B:R"]);
test(["A:1"], ["B", "A:2"], ["B:R", "A:R"]);
test(["A"], ["A:1"], ["A:R"]);
// C isn't added to the end because it follows B (even if B was resolved
// to local version)
test(["A", "B:1", "D"], ["B", "C:2", "A"], ["B:L", "C:R", "A:R", "D:L"]);
// some of the following tests are kinda arbitrary and they're less
// likely to happen in real-world cases
test(["A", "B"], ["B:1", "A:1"], ["B:R", "A:R"]);
test(["A:2", "B:2"], ["B:1", "A:1"], ["A:L", "B:L"]);
test(["A", "B", "C"], ["A", "B:2", "G", "C"], ["A:L", "B:R", "G:R", "C:L"]);
test(["A", "B", "C"], ["A", "B:2", "G"], ["A:L", "B:R", "G:R", "C:L"]);
test(["A", "B", "C"], ["A", "B:2", "G"], ["A:L", "B:R", "G:R", "C:L"]);
test(
["A:2", "B:2", "C"],
["D", "B:1", "A:3"],
["B:L", "A:R", "C:L", "D:R"],
);
test(
["A:2", "B:2", "C"],
["D", "B:2", "A:3", "C"],
["D:R", "B:L", "A:R", "C:L"],
);
test(
["A", "B", "C", "D", "E", "F"],
["A", "B:2", "X", "E:2", "F", "Y"],
["A:L", "B:R", "X:R", "E:R", "F:L", "Y:R", "C:L", "D:L"],
);
// annotated elements
// -------------------------------------------------------------------------
test(
["A", "B", "C"],
["(B)X", "(A)Y", "(Y)Z"],
["A:L", "B:L", "X:R", "Y:R", "Z:R", "C:L"],
);
test(["A"], ["(^)X", "Y"], ["X:R", "Y:R", "A:L"]);
test(["A"], ["(^)X", "Y", "Z"], ["X:R", "Y:R", "Z:R", "A:L"]);
test(
["A", "B"],
["(A)C", "(^)D", "F"],
["A:L", "C:R", "D:R", "F:R", "B:L"],
);
test(
["A", "B", "C", "D"],
["(B)C:1", "B", "D:1"],
["A:L", "C:R", "B:L", "D:R"],
);
test(
["A", "B", "C"],
["(^)X", "(A)Y", "(B)Z"],
["X:R", "A:L", "Y:R", "B:L", "Z:R", "C:L"],
);
test(
["B", "A", "C"],
["(^)X", "(A)Y", "(B)Z"],
["X:R", "B:L", "A:L", "Y:R", "Z:R", "C:L"],
);
test(["A", "B"], ["(A)X", "(A)Y"], ["A:L", "X:R", "Y:R", "B:L"]);
test(
["A", "B", "C", "D", "E"],
["(A)X", "(C)Y", "(D)Z"],
["A:L", "X:R", "B:L", "C:L", "Y:R", "D:L", "Z:R", "E:L"],
);
test(
["X", "Y", "Z"],
["(^)A", "(A)B", "(B)C", "(C)X", "(X)D", "(D)Y", "(Y)Z"],
["A:R", "B:R", "C:R", "X:L", "D:R", "Y:L", "Z:L"],
);
test(
["A", "B", "C", "D", "E"],
["(C)X", "(A)Y", "(D)E:1"],
["A:L", "B:L", "C:L", "X:R", "Y:R", "D:L", "E:R"],
);
test(
["C:1", "B", "D:1"],
["A", "B", "C:1", "D:1"],
["A:R", "B:L", "C:L", "D:L"],
);
test(
["A", "B", "C", "D"],
["(A)C:1", "(C)B", "(B)D:1"],
["A:L", "C:R", "B:L", "D:R"],
);
test(
["A", "B", "C", "D"],
["(A)C:1", "(C)B", "(B)D:1"],
["A:L", "C:R", "B:L", "D:R"],
);
test(
["C:1", "B", "D:1"],
["(^)A", "(A)B", "(B)C:2", "(C)D:1"],
["A:R", "B:L", "C:R", "D:L"],
);
test(
["A", "B", "C", "D"],
["(C)X", "(B)Y", "(A)Z"],
["A:L", "B:L", "C:L", "X:R", "Y:R", "Z:R", "D:L"],
);
test(["A", "B", "C", "D"], ["(A)B:1", "C:1"], ["A:L", "B:R", "C:R", "D:L"]);
test(["A", "B", "C", "D"], ["(A)C:1", "B:1"], ["A:L", "C:R", "B:R", "D:L"]);
test(
["A", "B", "C", "D"],
["(A)C:1", "B", "D:1"],
["A:L", "C:R", "B:L", "D:R"],
);
test(["A:1", "B:1", "C"], ["B:2"], ["A:L", "B:R", "C:L"]);
test(["A:1", "B:1", "C"], ["B:2", "C:2"], ["A:L", "B:R", "C:R"]);
test(["A", "B"], ["(A)C", "(B)D"], ["A:L", "C:R", "B:L", "D:R"]);
test(["A", "B"], ["(X)C", "(X)D"], ["A:L", "B:L", "C:R", "D:R"]);
test(["A", "B"], ["(X)C", "(A)D"], ["A:L", "D:R", "B:L", "C:R"]);
test(["A", "B"], ["(A)B:1"], ["A:L", "B:R"]);
test(["A:2", "B"], ["(A)B:1"], ["A:L", "B:R"]);
test(["A:2", "B:2"], ["B:1"], ["A:L", "B:L"]);
test(["A:2", "B:2"], ["B:1", "C"], ["A:L", "B:L", "C:R"]);
test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]);
test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]);
});
it("test identical elements reconciliation", () => {
const testIdentical = (
local: ElementLike[],
remote: ElementLike[],
expected: Id[],
) => {
const ret = reconcileElements(
local as any as ExcalidrawElement[],
remote as any as ExcalidrawElement[],
{} as AppState,
);
if (new Set(ret.map((x) => x.id)).size !== ret.length) {
throw new Error("reconcileElements: duplicate elements found");
}
expect(ret.map((x) => x.id)).to.deep.equal(expected);
};
// identical id/version/versionNonce
// -------------------------------------------------------------------------
testIdentical(
[{ id: "A", version: 1, versionNonce: 1 }],
[{ id: "A", version: 1, versionNonce: 1 }],
["A"],
);
testIdentical(
[
{ id: "A", version: 1, versionNonce: 1 },
{ id: "B", version: 1, versionNonce: 1 },
],
[
{ id: "B", version: 1, versionNonce: 1 },
{ id: "A", version: 1, versionNonce: 1 },
],
["B", "A"],
);
testIdentical(
[
{ id: "A", version: 1, versionNonce: 1 },
{ id: "B", version: 1, versionNonce: 1 },
],
[
{ id: "B", version: 1, versionNonce: 1 },
{ id: "A", version: 1, versionNonce: 1 },
],
["B", "A"],
);
// actually identical (arrays and element objects)
// -------------------------------------------------------------------------
const elements1 = [
{
id: "A",
version: 1,
versionNonce: 1,
[PRECEDING_ELEMENT_KEY]: null,
},
{
id: "B",
version: 1,
versionNonce: 1,
[PRECEDING_ELEMENT_KEY]: null,
},
];
testIdentical(elements1, elements1, ["A", "B"]);
testIdentical(elements1, elements1.slice(), ["A", "B"]);
testIdentical(elements1.slice(), elements1, ["A", "B"]);
testIdentical(elements1.slice(), elements1.slice(), ["A", "B"]);
const el1 = {
id: "A",
version: 1,
versionNonce: 1,
[PRECEDING_ELEMENT_KEY]: null,
};
const el2 = {
id: "B",
version: 1,
versionNonce: 1,
[PRECEDING_ELEMENT_KEY]: null,
};
testIdentical([el1, el2], [el2, el1], ["A", "B"]);
});
});

View File

@ -0,0 +1,17 @@
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
import { STORAGE_KEYS } from "../app_constants";
export const initLocalStorage = (data: ImportedDataState) => {
if (data.elements) {
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
JSON.stringify(data.elements),
);
}
if (data.appState) {
localStorage.setItem(
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
JSON.stringify(data.appState),
);
}
};

View File

@ -0,0 +1,70 @@
import { atom, useAtom } from "jotai";
import { useEffect, useLayoutEffect, useState } from "react";
import { THEME } from "../packages/excalidraw";
import { EVENT } from "../packages/excalidraw/constants";
import type { Theme } from "../packages/excalidraw/element/types";
import { CODES, KEYS } from "../packages/excalidraw/keys";
import { STORAGE_KEYS } from "./app_constants";
export const appThemeAtom = atom<Theme | "system">(
(localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_THEME) as
| Theme
| "system"
| null) || THEME.LIGHT,
);
const getDarkThemeMediaQuery = (): MediaQueryList | undefined =>
window.matchMedia?.("(prefers-color-scheme: dark)");
export const useHandleAppTheme = () => {
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
const [editorTheme, setEditorTheme] = useState<Theme>(THEME.LIGHT);
useEffect(() => {
const mediaQuery = getDarkThemeMediaQuery();
const handleChange = (e: MediaQueryListEvent) => {
setEditorTheme(e.matches ? THEME.DARK : THEME.LIGHT);
};
if (appTheme === "system") {
mediaQuery?.addEventListener("change", handleChange);
}
const handleKeydown = (event: KeyboardEvent) => {
if (
!event[KEYS.CTRL_OR_CMD] &&
event.altKey &&
event.shiftKey &&
event.code === CODES.D
) {
event.preventDefault();
event.stopImmediatePropagation();
setAppTheme(editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK);
}
};
document.addEventListener(EVENT.KEYDOWN, handleKeydown, { capture: true });
return () => {
mediaQuery?.removeEventListener("change", handleChange);
document.removeEventListener(EVENT.KEYDOWN, handleKeydown, {
capture: true,
});
};
}, [appTheme, editorTheme, setAppTheme]);
useLayoutEffect(() => {
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, appTheme);
if (appTheme === "system") {
setEditorTheme(
getDarkThemeMediaQuery()?.matches ? THEME.DARK : THEME.LIGHT,
);
} else {
setEditorTheme(appTheme);
}
}, [appTheme]);
return { editorTheme };
};

View File

@ -4,6 +4,7 @@ import svgrPlugin from "vite-plugin-svgr";
import { ViteEjsPlugin } from "vite-plugin-ejs";
import { VitePWA } from "vite-plugin-pwa";
import checker from "vite-plugin-checker";
import { createHtmlPlugin } from "vite-plugin-html";
// To load .env.local variables
const envVars = loadEnv("", `../`);
@ -189,6 +190,9 @@ export default defineConfig({
],
},
}),
createHtmlPlugin({
minify: true,
}),
],
publicDir: "../public",
});

View File

@ -26,8 +26,8 @@
"@types/chai": "4.3.0",
"@types/jest": "27.4.0",
"@types/lodash.throttle": "4.1.7",
"@types/react": "18.0.15",
"@types/react-dom": "18.0.6",
"@types/react": "18.2.0",
"@types/react-dom": "18.2.0",
"@types/socket.io-client": "3.0.0",
"@vitejs/plugin-react": "3.1.0",
"@vitest/coverage-v8": "0.33.0",
@ -50,7 +50,7 @@
"vite-plugin-ejs": "1.7.0",
"vite-plugin-pwa": "0.17.4",
"vite-plugin-svgr": "2.4.0",
"vitest": "1.0.1",
"vitest": "1.5.3",
"vitest-canvas-mock": "0.3.2"
},
"engines": {
@ -60,10 +60,11 @@
"prettier": "@excalidraw/prettier-config",
"scripts": {
"build-node": "node ./scripts/build-node.js",
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
"build:version": "node ./scripts/build-version.js",
"build": "yarn --cwd ./excalidraw-app build",
"build:app:docker": "yarn --cwd ./excalidraw-app build:app:docker",
"build:app": "yarn --cwd ./excalidraw-app build:app",
"build:version": "yarn --cwd ./excalidraw-app build:version",
"build": "yarn workspace @excalidraw/utils build:esm && yarn --cwd ./excalidraw-app build",
"clear:workspaces": "yarn workspace @excalidraw/utils run clear && yarn workspace @excalidraw/excalidraw run clear",
"fix:code": "yarn test:code --fix",
"fix:other": "yarn prettier --write",
"fix": "yarn fix:other && yarn fix:code",
@ -71,7 +72,7 @@
"locales-coverage:description": "node scripts/locales-coverage-description.js",
"prepare": "husky install",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"start": "yarn --cwd ./excalidraw-app start",
"start": "yarn clear:workspaces && yarn workspace @excalidraw/utils run build:esm && yarn --cwd ./excalidraw-app start & node scripts/watchUtils.js",
"start:app:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
"test:app": "vitest",
@ -79,8 +80,8 @@
"test:other": "yarn prettier --list-different",
"test:typecheck": "tsc",
"test:update": "yarn test:app --update --watch=false",
"test": "yarn test:app",
"test:coverage": "vitest --coverage",
"test": "yarn clear:workspaces && yarn workspace @excalidraw/utils build:esm && yarn test:app",
"test:coverage": "yarn clear:workspaces && yarn workspace @excalidraw/utils build:esm && yarn test:app --coverage",
"test:coverage:watch": "vitest --coverage --watch",
"test:ui": "yarn test --ui --coverage.enabled=true",
"autorelease": "node scripts/autorelease.js",

View File

@ -15,14 +15,38 @@ Please add the latest change on the top under the correct section.
### Features
- Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348)
- `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853)
- Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
- Add `useHandleLibrary`'s `opts.migrationAdapter` adapter to handle library migration during init, when migrating from one data store to another (e.g. from LocalStorage to IndexedDB). [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
- Soft-deprecate `useHandleLibrary`'s `opts.getInitialLibraryItems` in favor of `opts.adapter`. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
- Add `onPointerUp` prop [#7638](https://github.com/excalidraw/excalidraw/pull/7638).
- Expose `getVisibleSceneBounds` helper to get scene bounds of visible canvas area. [#7450](https://github.com/excalidraw/excalidraw/pull/7450)
### Fixes
- Keep customData when converting to ExcalidrawElement. [#7656](https://github.com/excalidraw/excalidraw/pull/7656)
### Breaking Changes
- `updateScene` API has changed due to the added `Store` component as part of the multiplayer undo / redo initiative. Specifically, `sceneData` property `commitToHistory: boolean` was replaced with `storeAction: StoreActionType`. Make sure to update all instances of `updateScene` according to the _before / after_ table below. [#7898](https://github.com/excalidraw/excalidraw/pull/7898)
| | Before `commitToHistory` | After `storeAction` | Notes |
| --- | --- | --- | --- |
| _Immediately undoable_ | `true` | `"capture"` | As before, use for all updates which should be recorded by the store & history. Should be used for the most of the local updates. These updates will _immediately_ make it to the local undo / redo stacks. |
| _Eventually undoable_ | `false` | `"none"` | Similar to before, use for all updates which should not be recorded immediately (likely exceptions which are part of some async multi-step process) or those not meant to be recorded at all (i.e. updates to `collaborators` object, parts of `AppState` which are not observed by the store & history - not `ObservedAppState`).<br/><br/>**IMPORTANT** It's likely you should switch to `"update"` in all the other cases. Otherwise, all such updates would end up being recorded with the next `"capture"` - triggered either by the next `updateScene` or internally by the editor. These updates will _eventually_ make it to the local undo / redo stacks. |
| _Never undoable_ | n/a | `"update"` | **NEW**: previously there was no equivalent for this value. Now, it's recommended to use `"update"` for all remote updates (from the other clients), scene initialization, or those updates, which should not be locally "undoable". These updates will _never_ make it to the local undo / redo stacks. |
- `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539)
- `ExcalidrawTextElement.baseline` was removed and replaced with a vertical offset computation based on font metrics, performed on each text element re-render. In case of custom font usage, extend the `FONT_METRICS` object with the related properties.
- Create an `ESM` build for `@excalidraw/excalidraw`. The API is in progress and subject to change before stable release. There are some changes on how the package will be consumed
#### Bundler
@ -57,10 +81,12 @@ Please add the latest change on the top under the correct section.
- `appState.openDialog` type was changed from `null | string` to `null | { name: string }`. [#7336](https://github.com/excalidraw/excalidraw/pull/7336)
## 0.17.1 (2023-11-28)
## 0.17.3 (2024-02-09)
### Fixes
- Keep customData when converting to ExcalidrawElement. [#7656](https://github.com/excalidraw/excalidraw/pull/7656)
- Umd build for browser since it was breaking in v0.17.0 [#7349](https://github.com/excalidraw/excalidraw/pull/7349). Also make sure that when using `Vite`, the `process.env.IS_PREACT` is set as `"true"` (string) and not a boolean.
```
@ -69,14 +95,16 @@ define: {
}
```
- Disable caching bounds for arrow labels [#7343](https://github.com/excalidraw/excalidraw/pull/7343)
- Bounds cached prematurely resulting in incorrectly rendered labels [#7339](https://github.com/excalidraw/excalidraw/pull/7339)
## Excalidraw Library
### Fixes
- Disable caching bounds for arrow labels [#7343](https://github.com/excalidraw/excalidraw/pull/7343)
---
## 0.17.0 (2023-11-14)
### Features

View File

@ -3,6 +3,7 @@ import { deepCopyElement } from "../element/newElement";
import { randomId } from "../random";
import { t } from "../i18n";
import { LIBRARY_DISABLED_TYPES } from "../constants";
import { StoreAction } from "../store";
export const actionAddToLibrary = register({
name: "addToLibrary",
@ -17,7 +18,7 @@ export const actionAddToLibrary = register({
for (const type of LIBRARY_DISABLED_TYPES) {
if (selectedElements.some((element) => element.type === type)) {
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
errorMessage: t(`errors.libraryElementTypeError.${type}`),
@ -41,7 +42,7 @@ export const actionAddToLibrary = register({
})
.then(() => {
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
toast: { message: t("toast.addedToLibrary") },
@ -50,7 +51,7 @@ export const actionAddToLibrary = register({
})
.catch((error) => {
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
errorMessage: error.message,
@ -58,5 +59,5 @@ export const actionAddToLibrary = register({
};
});
},
contextItemLabel: "labels.addToLibrary",
label: "labels.addToLibrary",
});

View File

@ -1,4 +1,5 @@
import { alignElements, Alignment } from "../align";
import type { Alignment } from "../align";
import { alignElements } from "../align";
import {
AlignBottomIcon,
AlignLeftIcon,
@ -10,18 +11,19 @@ import {
import { ToolButton } from "../components/ToolButton";
import { getNonDeletedElements } from "../element";
import { isFrameLikeElement } from "../element/typeChecks";
import { ExcalidrawElement } from "../element/types";
import type { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n";
import { KEYS } from "../keys";
import { isSomeElementSelected } from "../scene";
import { AppClassProperties, AppState } from "../types";
import { StoreAction } from "../store";
import type { AppClassProperties, AppState, UIAppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";
const alignActionsPredicate = (
elements: readonly ExcalidrawElement[],
appState: AppState,
appState: UIAppState,
_: unknown,
app: AppClassProperties,
) => {
@ -59,6 +61,8 @@ const alignSelectedElements = (
export const actionAlignTop = register({
name: "alignTop",
label: "labels.alignTop",
icon: AlignTopIcon,
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState, _, app) => {
@ -68,7 +72,7 @@ export const actionAlignTop = register({
position: "start",
axis: "y",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>
@ -90,6 +94,8 @@ export const actionAlignTop = register({
export const actionAlignBottom = register({
name: "alignBottom",
label: "labels.alignBottom",
icon: AlignBottomIcon,
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState, _, app) => {
@ -99,7 +105,7 @@ export const actionAlignBottom = register({
position: "end",
axis: "y",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>
@ -121,6 +127,8 @@ export const actionAlignBottom = register({
export const actionAlignLeft = register({
name: "alignLeft",
label: "labels.alignLeft",
icon: AlignLeftIcon,
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState, _, app) => {
@ -130,7 +138,7 @@ export const actionAlignLeft = register({
position: "start",
axis: "x",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>
@ -152,6 +160,8 @@ export const actionAlignLeft = register({
export const actionAlignRight = register({
name: "alignRight",
label: "labels.alignRight",
icon: AlignRightIcon,
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState, _, app) => {
@ -161,7 +171,7 @@ export const actionAlignRight = register({
position: "end",
axis: "x",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>
@ -183,6 +193,8 @@ export const actionAlignRight = register({
export const actionAlignVerticallyCentered = register({
name: "alignVerticallyCentered",
label: "labels.centerVertically",
icon: CenterVerticallyIcon,
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState, _, app) => {
@ -192,7 +204,7 @@ export const actionAlignVerticallyCentered = register({
position: "center",
axis: "y",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => (
@ -210,6 +222,8 @@ export const actionAlignVerticallyCentered = register({
export const actionAlignHorizontallyCentered = register({
name: "alignHorizontallyCentered",
label: "labels.centerHorizontally",
icon: CenterHorizontallyIcon,
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState, _, app) => {
@ -219,7 +233,7 @@ export const actionAlignHorizontallyCentered = register({
position: "center",
axis: "x",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => (

View File

@ -23,20 +23,22 @@ import {
isTextBindableContainer,
isUsingAdaptiveRadius,
} from "../element/typeChecks";
import {
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextContainer,
ExcalidrawTextElement,
} from "../element/types";
import { AppState } from "../types";
import { Mutable } from "../utility-types";
import { getFontString } from "../utils";
import type { AppState } from "../types";
import type { Mutable } from "../utility-types";
import { arrayToMap, getFontString } from "../utils";
import { register } from "./register";
import { syncMovedIndices } from "../fractionalIndex";
import { StoreAction } from "../store";
export const actionUnbindText = register({
name: "unbindText",
contextItemLabel: "labels.unbindText",
label: "labels.unbindText",
trackEvent: { category: "element" },
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
@ -49,7 +51,7 @@ export const actionUnbindText = register({
selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
const { width, height, baseline } = measureText(
const { width, height } = measureText(
boundTextElement.originalText,
getFontString(boundTextElement),
boundTextElement.lineHeight,
@ -58,12 +60,15 @@ export const actionUnbindText = register({
element.id,
);
resetOriginalContainerCache(element.id);
const { x, y } = computeBoundTextPosition(element, boundTextElement);
const { x, y } = computeBoundTextPosition(
element,
boundTextElement,
elementsMap,
);
mutateElement(boundTextElement as ExcalidrawTextElement, {
containerId: null,
width,
height,
baseline,
text: boundTextElement.originalText,
x,
y,
@ -81,14 +86,14 @@ export const actionUnbindText = register({
return {
elements,
appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
});
export const actionBindText = register({
name: "bindText",
contextItemLabel: "labels.bindText",
label: "labels.bindText",
trackEvent: { category: "element" },
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
@ -145,7 +150,11 @@ export const actionBindText = register({
}),
});
const originalContainerHeight = container.height;
redrawTextBoundingBox(textElement, container);
redrawTextBoundingBox(
textElement,
container,
app.scene.getNonDeletedElementsMap(),
);
// overwritting the cache with original container height so
// it can be restored when unbind
updateOriginalContainerCache(container.id, originalContainerHeight);
@ -153,7 +162,7 @@ export const actionBindText = register({
return {
elements: pushTextAboveContainer(elements, container, textElement),
appState: { ...appState, selectedElementIds: { [container.id]: true } },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
});
@ -173,6 +182,8 @@ const pushTextAboveContainer = (
(ele) => ele.id === container.id,
);
updatedElements.splice(containerIndex + 1, 0, textElement);
syncMovedIndices(updatedElements, arrayToMap([container, textElement]));
return updatedElements;
};
@ -191,12 +202,14 @@ const pushContainerBelowText = (
(ele) => ele.id === textElement.id,
);
updatedElements.splice(textElementIndex, 0, container);
syncMovedIndices(updatedElements, arrayToMap([container, textElement]));
return updatedElements;
};
export const actionWrapTextInContainer = register({
name: "wrapTextInContainer",
contextItemLabel: "labels.createContainerFromText",
label: "labels.createContainerFromText",
trackEvent: { category: "element" },
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
@ -286,13 +299,18 @@ export const actionWrapTextInContainer = register({
},
false,
);
redrawTextBoundingBox(textElement, container);
redrawTextBoundingBox(
textElement,
container,
app.scene.getNonDeletedElementsMap(),
);
updatedElements = pushContainerBelowText(
[...updatedElements, container],
container,
textElement,
);
containerIds[container.id] = true;
}
}
@ -303,7 +321,7 @@ export const actionWrapTextInContainer = register({
...appState,
selectedElementIds: containerIds,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
});

View File

@ -1,15 +1,30 @@
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
import { ZoomInIcon, ZoomOutIcon } from "../components/icons";
import {
handIcon,
MoonIcon,
SunIcon,
TrashIcon,
zoomAreaIcon,
ZoomInIcon,
ZoomOutIcon,
ZoomResetIcon,
} from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
import {
CURSOR_TYPE,
MAX_ZOOM,
MIN_ZOOM,
THEME,
ZOOM_STEP,
} from "../constants";
import { getCommonBounds, getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import type { ExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { CODES, KEYS } from "../keys";
import { getNormalizedZoom } from "../scene";
import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom";
import { AppState, NormalizedZoomValue } from "../types";
import type { AppState, NormalizedZoomValue } from "../types";
import { getShortcutKey, updateActiveTool } from "../utils";
import { register } from "./register";
import { Tooltip } from "../components/Tooltip";
@ -20,11 +35,14 @@ import {
isHandToolActive,
} from "../appState";
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import { SceneBounds } from "../element/bounds";
import type { SceneBounds } from "../element/bounds";
import { setCursor } from "../cursor";
import { StoreAction } from "../store";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
label: "labels.canvasBackground",
paletteName: "Change canvas background color",
trackEvent: false,
predicate: (elements, appState, props, app) => {
return (
@ -35,7 +53,9 @@ export const actionChangeViewBackgroundColor = register({
perform: (_, appState, value) => {
return {
appState: { ...appState, ...value },
commitToHistory: !!value.viewBackgroundColor,
storeAction: !!value.viewBackgroundColor
? StoreAction.CAPTURE
: StoreAction.NONE,
};
},
PanelComponent: ({ elements, appState, updateData, appProps }) => {
@ -59,6 +79,9 @@ export const actionChangeViewBackgroundColor = register({
export const actionClearCanvas = register({
name: "clearCanvas",
label: "labels.clearCanvas",
paletteName: "Clear canvas",
icon: TrashIcon,
trackEvent: { category: "canvas" },
predicate: (elements, appState, props, app) => {
return (
@ -88,14 +111,16 @@ export const actionClearCanvas = register({
? { ...appState.activeTool, type: "selection" }
: appState.activeTool,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
});
export const actionZoomIn = register({
name: "zoomIn",
label: "buttons.zoomIn",
viewMode: true,
icon: ZoomInIcon,
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
return {
@ -111,16 +136,17 @@ export const actionZoomIn = register({
),
userToFollow: null,
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ updateData }) => (
PanelComponent: ({ updateData, appState }) => (
<ToolButton
type="button"
className="zoom-in-button zoom-button"
icon={ZoomInIcon}
title={`${t("buttons.zoomIn")}${getShortcutKey("CtrlOrCmd++")}`}
aria-label={t("buttons.zoomIn")}
disabled={appState.zoom.value >= MAX_ZOOM}
onClick={() => {
updateData(null);
}}
@ -133,6 +159,8 @@ export const actionZoomIn = register({
export const actionZoomOut = register({
name: "zoomOut",
label: "buttons.zoomOut",
icon: ZoomOutIcon,
viewMode: true,
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
@ -149,16 +177,17 @@ export const actionZoomOut = register({
),
userToFollow: null,
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ updateData }) => (
PanelComponent: ({ updateData, appState }) => (
<ToolButton
type="button"
className="zoom-out-button zoom-button"
icon={ZoomOutIcon}
title={`${t("buttons.zoomOut")}${getShortcutKey("CtrlOrCmd+-")}`}
aria-label={t("buttons.zoomOut")}
disabled={appState.zoom.value <= MIN_ZOOM}
onClick={() => {
updateData(null);
}}
@ -171,6 +200,8 @@ export const actionZoomOut = register({
export const actionResetZoom = register({
name: "resetZoom",
label: "buttons.resetZoom",
icon: ZoomResetIcon,
viewMode: true,
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
@ -187,7 +218,7 @@ export const actionResetZoom = register({
),
userToFollow: null,
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ updateData, appState }) => (
@ -262,8 +293,8 @@ export const zoomToFitBounds = ({
// Apply clamping to newZoomValue to be between 10% and 3000%
newZoomValue = Math.min(
Math.max(newZoomValue, 0.1),
30.0,
Math.max(newZoomValue, MIN_ZOOM),
MAX_ZOOM,
) as NormalizedZoomValue;
let appStateWidth = appState.width;
@ -308,7 +339,7 @@ export const zoomToFitBounds = ({
scrollY,
zoom: { value: newZoomValue },
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
};
@ -340,6 +371,8 @@ export const zoomToFit = ({
// size, it won't be zoomed in.
export const actionZoomToFitSelectionInViewport = register({
name: "zoomToFitSelectionInViewport",
label: "labels.zoomToFitViewport",
icon: zoomAreaIcon,
trackEvent: { category: "canvas" },
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
@ -363,6 +396,8 @@ export const actionZoomToFitSelectionInViewport = register({
export const actionZoomToFitSelection = register({
name: "zoomToFitSelection",
label: "helpDialog.zoomToSelection",
icon: zoomAreaIcon,
trackEvent: { category: "canvas" },
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
@ -385,6 +420,8 @@ export const actionZoomToFitSelection = register({
export const actionZoomToFit = register({
name: "zoomToFit",
label: "helpDialog.zoomToFit",
icon: zoomAreaIcon,
viewMode: true,
trackEvent: { category: "canvas" },
perform: (elements, appState) =>
@ -405,6 +442,13 @@ export const actionZoomToFit = register({
export const actionToggleTheme = register({
name: "toggleTheme",
label: (_, appState) => {
return appState.theme === THEME.DARK
? "buttons.lightMode"
: "buttons.darkMode";
},
keywords: ["toggle", "dark", "light", "mode", "theme"],
icon: (appState) => (appState.theme === THEME.LIGHT ? MoonIcon : SunIcon),
viewMode: true,
trackEvent: { category: "canvas" },
perform: (_, appState, value) => {
@ -414,7 +458,7 @@ export const actionToggleTheme = register({
theme:
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
@ -425,6 +469,7 @@ export const actionToggleTheme = register({
export const actionToggleEraserTool = register({
name: "toggleEraserTool",
label: "toolBar.eraser",
trackEvent: { category: "toolbar" },
perform: (elements, appState) => {
let activeTool: AppState["activeTool"];
@ -451,7 +496,7 @@ export const actionToggleEraserTool = register({
activeEmbeddable: null,
activeTool,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) => event.key === KEYS.E,
@ -459,7 +504,11 @@ export const actionToggleEraserTool = register({
export const actionToggleHandTool = register({
name: "toggleHandTool",
label: "toolBar.hand",
paletteName: "Toggle hand tool",
trackEvent: { category: "toolbar" },
icon: handIcon,
viewMode: false,
perform: (elements, appState, _, app) => {
let activeTool: AppState["activeTool"];
@ -486,7 +535,7 @@ export const actionToggleHandTool = register({
activeEmbeddable: null,
activeTool,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>

View File

@ -13,9 +13,13 @@ import { exportCanvas, prepareElementsForExport } from "../data/index";
import { isTextElement } from "../element";
import { t } from "../i18n";
import { isFirefox } from "../constants";
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
import { StoreAction } from "../store";
export const actionCopy = register({
name: "copy",
label: "labels.copy",
icon: DuplicateIcon,
trackEvent: { category: "element" },
perform: async (elements, appState, event: ClipboardEvent | null, app) => {
const elementsToCopy = app.scene.getSelectedElements({
@ -28,7 +32,7 @@ export const actionCopy = register({
await copyToClipboard(elementsToCopy, app.files, event);
} catch (error: any) {
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
errorMessage: error.message,
@ -37,16 +41,16 @@ export const actionCopy = register({
}
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
contextItemLabel: "labels.copy",
// don't supply a shortcut since we handle this conditionally via onCopy event
keyTest: undefined,
});
export const actionPaste = register({
name: "paste",
label: "labels.paste",
trackEvent: { category: "element" },
perform: async (elements, appState, data, app) => {
let types;
@ -63,7 +67,7 @@ export const actionPaste = register({
if (isFirefox) {
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
errorMessage: t("hints.firefox_clipboard_write"),
@ -72,7 +76,7 @@ export const actionPaste = register({
}
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
errorMessage: t("errors.asyncPasteFailedOnRead"),
@ -85,7 +89,7 @@ export const actionPaste = register({
} catch (error: any) {
console.error(error);
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
errorMessage: t("errors.asyncPasteFailedOnParse"),
@ -94,32 +98,34 @@ export const actionPaste = register({
}
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
contextItemLabel: "labels.paste",
// don't supply a shortcut since we handle this conditionally via onCopy event
keyTest: undefined,
});
export const actionCut = register({
name: "cut",
label: "labels.cut",
icon: cutIcon,
trackEvent: { category: "element" },
perform: (elements, appState, event: ClipboardEvent | null, app) => {
actionCopy.perform(elements, appState, event, app);
return actionDeleteSelected.perform(elements, appState);
return actionDeleteSelected.perform(elements, appState, null, app);
},
contextItemLabel: "labels.cut",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
});
export const actionCopyAsSvg = register({
name: "copyAsSvg",
label: "labels.copyAsSvg",
icon: svgIcon,
trackEvent: { category: "element" },
perform: async (elements, appState, _data, app) => {
if (!app.canvas) {
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
};
}
@ -138,10 +144,11 @@ export const actionCopyAsSvg = register({
{
...appState,
exportingFrame,
name: app.getName(),
},
);
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
};
} catch (error: any) {
console.error(error);
@ -150,23 +157,25 @@ export const actionCopyAsSvg = register({
...appState,
errorMessage: error.message,
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
}
},
predicate: (elements) => {
return probablySupportsClipboardWriteText && elements.length > 0;
},
contextItemLabel: "labels.copyAsSvg",
keywords: ["svg", "clipboard", "copy"],
});
export const actionCopyAsPng = register({
name: "copyAsPng",
label: "labels.copyAsPng",
icon: pngIcon,
trackEvent: { category: "element" },
perform: async (elements, appState, _data, app) => {
if (!app.canvas) {
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
};
}
const selectedElements = app.scene.getSelectedElements({
@ -184,6 +193,7 @@ export const actionCopyAsPng = register({
await exportCanvas("clipboard", exportedElements, appState, app.files, {
...appState,
exportingFrame,
name: app.getName(),
});
return {
appState: {
@ -199,7 +209,7 @@ export const actionCopyAsPng = register({
}),
},
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
} catch (error: any) {
console.error(error);
@ -208,19 +218,20 @@ export const actionCopyAsPng = register({
...appState,
errorMessage: error.message,
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
}
},
predicate: (elements) => {
return probablySupportsClipboardBlob && elements.length > 0;
},
contextItemLabel: "labels.copyAsPng",
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
keywords: ["png", "clipboard", "copy"],
});
export const copyText = register({
name: "copyText",
label: "labels.copyText",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements({
@ -236,9 +247,13 @@ export const copyText = register({
return acc;
}, [])
.join("\n\n");
copyTextToSystemClipboard(text);
try {
copyTextToSystemClipboard(text);
} catch (e) {
throw new Error(t("errors.copyToSystemClipboardFailed"));
}
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
predicate: (elements, appState, _, app) => {
@ -252,5 +267,5 @@ export const copyText = register({
.some(isTextElement)
);
},
contextItemLabel: "labels.copyText",
keywords: ["text", "clipboard", "copy"],
});

View File

@ -4,8 +4,8 @@ import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import { register } from "./register";
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import type { ExcalidrawElement } from "../element/types";
import type { AppState } from "../types";
import { newElementWith } from "../element/mutateElement";
import { getElementsInGroup } from "../groups";
import { LinearElementEditor } from "../element/linearElementEditor";
@ -13,6 +13,7 @@ import { fixBindingsAfterDeletion } from "../element/binding";
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
import { updateActiveTool } from "../utils";
import { TrashIcon } from "../components/icons";
import { StoreAction } from "../store";
const deleteSelectedElements = (
elements: readonly ExcalidrawElement[],
@ -72,8 +73,10 @@ const handleGroupEditingState = (
export const actionDeleteSelected = register({
name: "deleteSelectedElements",
label: "labels.delete",
icon: TrashIcon,
trackEvent: { category: "element", action: "delete" },
perform: (elements, appState) => {
perform: (elements, appState, formData, app) => {
if (appState.editingLinearElement) {
const {
elementId,
@ -81,7 +84,8 @@ export const actionDeleteSelected = register({
startBindingElement,
endBindingElement,
} = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId);
const elementsMap = app.scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) {
return false;
}
@ -109,7 +113,7 @@ export const actionDeleteSelected = register({
...nextAppState,
editingLinearElement: null,
},
commitToHistory: false,
storeAction: StoreAction.CAPTURE,
};
}
@ -141,7 +145,7 @@ export const actionDeleteSelected = register({
: [0],
},
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
}
let { elements: nextElements, appState: nextAppState } =
@ -161,13 +165,14 @@ export const actionDeleteSelected = register({
multiElement: null,
activeEmbeddable: null,
},
commitToHistory: isSomeElementSelected(
storeAction: isSomeElementSelected(
getNonDeletedElements(elements),
appState,
),
)
? StoreAction.CAPTURE
: StoreAction.NONE,
};
},
contextItemLabel: "labels.delete",
keyTest: (event, appState, elements) =>
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) &&
!event[KEYS.CTRL_OR_CMD],

View File

@ -3,15 +3,17 @@ import {
DistributeVerticallyIcon,
} from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { distributeElements, Distribution } from "../distribute";
import type { Distribution } from "../distribute";
import { distributeElements } from "../distribute";
import { getNonDeletedElements } from "../element";
import { isFrameLikeElement } from "../element/typeChecks";
import { ExcalidrawElement } from "../element/types";
import type { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n";
import { CODES, KEYS } from "../keys";
import { isSomeElementSelected } from "../scene";
import { AppClassProperties, AppState } from "../types";
import { StoreAction } from "../store";
import type { AppClassProperties, AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";
@ -49,6 +51,7 @@ const distributeSelectedElements = (
export const distributeHorizontally = register({
name: "distributeHorizontally",
label: "labels.distributeHorizontally",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
return {
@ -57,7 +60,7 @@ export const distributeHorizontally = register({
space: "between",
axis: "x",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>
@ -79,6 +82,7 @@ export const distributeHorizontally = register({
export const distributeVertically = register({
name: "distributeVertically",
label: "labels.distributeVertically",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
return {
@ -87,7 +91,7 @@ export const distributeVertically = register({
space: "between",
axis: "y",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>

View File

@ -1,6 +1,6 @@
import { KEYS } from "../keys";
import { register } from "./register";
import { ExcalidrawElement } from "../element/types";
import type { ExcalidrawElement } from "../element/types";
import { duplicateElement, getNonDeletedElements } from "../element";
import { isSomeElementSelected } from "../scene";
import { ToolButton } from "../components/ToolButton";
@ -12,9 +12,9 @@ import {
getSelectedGroupForElement,
getElementsInGroup,
} from "../groups";
import { AppState } from "../types";
import type { AppState } from "../types";
import { fixBindingsAfterDuplication } from "../element/binding";
import { ActionResult } from "./types";
import type { ActionResult } from "./types";
import { GRID_SIZE } from "../constants";
import {
bindTextToShapeAfterDuplication,
@ -31,14 +31,22 @@ import {
excludeElementsInFramesFromSelection,
getSelectedElements,
} from "../scene/selection";
import { syncMovedIndices } from "../fractionalIndex";
import { StoreAction } from "../store";
export const actionDuplicateSelection = register({
name: "duplicateSelection",
label: "labels.duplicateSelection",
icon: DuplicateIcon,
trackEvent: { category: "element" },
perform: (elements, appState) => {
perform: (elements, appState, formData, app) => {
const elementsMap = app.scene.getNonDeletedElementsMap();
// duplicate selected point(s) if editing a line
if (appState.editingLinearElement) {
const ret = LinearElementEditor.duplicateSelectedPoints(appState);
const ret = LinearElementEditor.duplicateSelectedPoints(
appState,
elementsMap,
);
if (!ret) {
return false;
@ -47,16 +55,15 @@ export const actionDuplicateSelection = register({
return {
elements,
appState: ret.appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
}
return {
...duplicateElements(elements, appState),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: "labels.duplicateSelection",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
@ -85,6 +92,7 @@ const duplicateElements = (
const newElements: ExcalidrawElement[] = [];
const oldElements: ExcalidrawElement[] = [];
const oldIdToDuplicatedId = new Map();
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
const duplicateAndOffsetElement = (element: ExcalidrawElement) => {
const newElement = duplicateElement(
@ -96,6 +104,7 @@ const duplicateElements = (
y: element.y + GRID_SIZE / 2,
},
);
duplicatedElementsMap.set(newElement.id, newElement);
oldIdToDuplicatedId.set(element.id, newElement.id);
oldElements.push(element);
newElements.push(newElement);
@ -233,8 +242,10 @@ const duplicateElements = (
}
// step (3)
const finalElements = finalElementsReversed.reverse();
const finalElements = syncMovedIndices(
finalElementsReversed.reverse(),
arrayToMap(newElements),
);
// ---------------------------------------------------------------------------

View File

@ -1,7 +1,10 @@
import { LockedIcon, UnlockedIcon } from "../components/icons";
import { newElementWith } from "../element/mutateElement";
import { isFrameLikeElement } from "../element/typeChecks";
import { ExcalidrawElement } from "../element/types";
import type { ExcalidrawElement } from "../element/types";
import { KEYS } from "../keys";
import { getSelectedElements } from "../scene";
import { StoreAction } from "../store";
import { arrayToMap } from "../utils";
import { register } from "./register";
@ -10,11 +13,31 @@ const shouldLock = (elements: readonly ExcalidrawElement[]) =>
export const actionToggleElementLock = register({
name: "toggleElementLock",
label: (elements, appState, app) => {
const selected = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: false,
});
if (selected.length === 1 && !isFrameLikeElement(selected[0])) {
return selected[0].locked
? "labels.elementLock.unlock"
: "labels.elementLock.lock";
}
return shouldLock(selected)
? "labels.elementLock.lockAll"
: "labels.elementLock.unlockAll";
},
icon: (appState, elements) => {
const selectedElements = getSelectedElements(elements, appState);
return shouldLock(selectedElements) ? LockedIcon : UnlockedIcon;
},
trackEvent: { category: "element" },
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
return !selectedElements.some(
(element) => element.locked && element.frameId,
return (
selectedElements.length > 0 &&
!selectedElements.some((element) => element.locked && element.frameId)
);
},
perform: (elements, appState, _, app) => {
@ -44,24 +67,9 @@ export const actionToggleElementLock = register({
? null
: appState.selectedLinearElement,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: (elements, appState, app) => {
const selected = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: false,
});
if (selected.length === 1 && !isFrameLikeElement(selected[0])) {
return selected[0].locked
? "labels.elementLock.unlock"
: "labels.elementLock.lock";
}
return shouldLock(selected)
? "labels.elementLock.lockAll"
: "labels.elementLock.unlockAll";
},
keyTest: (event, appState, elements, app) => {
return (
event.key.toLocaleLowerCase() === KEYS.L &&
@ -77,10 +85,16 @@ export const actionToggleElementLock = register({
export const actionUnlockAllElements = register({
name: "unlockAllElements",
paletteName: "Unlock all elements",
trackEvent: { category: "canvas" },
viewMode: false,
predicate: (elements) => {
return elements.some((element) => element.locked);
icon: UnlockedIcon,
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
return (
selectedElements.length === 0 &&
elements.some((element) => element.locked)
);
},
perform: (elements, appState) => {
const lockedElements = elements.filter((el) => el.locked);
@ -98,8 +112,8 @@ export const actionUnlockAllElements = register({
lockedElements.map((el) => [el.id, true]),
),
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: "labels.elementLock.unlockAll",
label: "labels.elementLock.unlockAll",
});

View File

@ -1,4 +1,4 @@
import { questionCircle, saveAs } from "../components/icons";
import { ExportIcon, questionCircle, saveAs } from "../components/icons";
import { ProjectName } from "../components/ProjectName";
import { ToolButton } from "../components/ToolButton";
import { Tooltip } from "../components/Tooltip";
@ -16,24 +16,26 @@ import { getSelectedElements, isSomeElementSelected } from "../scene";
import { getNonDeletedElements } from "../element";
import { isImageFileHandle } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem";
import { Theme } from "../element/types";
import type { Theme } from "../element/types";
import "../components/ToolIcon.scss";
import { StoreAction } from "../store";
export const actionChangeProjectName = register({
name: "changeProjectName",
label: "labels.fileTitle",
trackEvent: false,
perform: (_elements, appState, value) => {
return { appState: { ...appState, name: value }, commitToHistory: false };
return {
appState: { ...appState, name: value },
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ appState, updateData, appProps, data }) => (
PanelComponent: ({ appState, updateData, appProps, data, app }) => (
<ProjectName
label={t("labels.fileTitle")}
value={appState.name || "Unnamed"}
value={app.getName()}
onChange={(name: string) => updateData(name)}
isNameEditable={
typeof appProps.name === "undefined" && !appState.viewModeEnabled
}
ignoreFocus={data?.ignoreFocus ?? false}
/>
),
@ -41,11 +43,12 @@ export const actionChangeProjectName = register({
export const actionChangeExportScale = register({
name: "changeExportScale",
label: "imageExportDialog.scale",
trackEvent: { category: "export", action: "scale" },
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportScale: value },
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ elements: allElements, appState, updateData }) => {
@ -90,11 +93,12 @@ export const actionChangeExportScale = register({
export const actionChangeExportBackground = register({
name: "changeExportBackground",
label: "imageExportDialog.label.withBackground",
trackEvent: { category: "export", action: "toggleBackground" },
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportBackground: value },
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ appState, updateData }) => (
@ -109,11 +113,12 @@ export const actionChangeExportBackground = register({
export const actionChangeExportEmbedScene = register({
name: "changeExportEmbedScene",
label: "imageExportDialog.tooltip.embedScene",
trackEvent: { category: "export", action: "embedScene" },
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportEmbedScene: value },
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ appState, updateData }) => (
@ -131,6 +136,8 @@ export const actionChangeExportEmbedScene = register({
export const actionSaveToActiveFile = register({
name: "saveToActiveFile",
label: "buttons.save",
icon: ExportIcon,
trackEvent: { category: "export" },
predicate: (elements, appState, props, app) => {
return (
@ -144,11 +151,16 @@ export const actionSaveToActiveFile = register({
try {
const { fileHandle } = isImageFileHandle(appState.fileHandle)
? await resaveAsImageWithScene(elements, appState, app.files)
: await saveAsJSON(elements, appState, app.files);
? await resaveAsImageWithScene(
elements,
appState,
app.files,
app.getName(),
)
: await saveAsJSON(elements, appState, app.files, app.getName());
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
fileHandle,
@ -170,7 +182,7 @@ export const actionSaveToActiveFile = register({
} else {
console.warn(error);
}
return { commitToHistory: false };
return { storeAction: StoreAction.NONE };
}
},
keyTest: (event) =>
@ -179,6 +191,8 @@ export const actionSaveToActiveFile = register({
export const actionSaveFileToDisk = register({
name: "saveFileToDisk",
label: "exportDialog.disk_title",
icon: ExportIcon,
viewMode: true,
trackEvent: { category: "export" },
perform: async (elements, appState, value, app) => {
@ -190,9 +204,10 @@ export const actionSaveFileToDisk = register({
fileHandle: null,
},
app.files,
app.getName(),
);
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
openDialog: null,
@ -206,7 +221,7 @@ export const actionSaveFileToDisk = register({
} else {
console.warn(error);
}
return { commitToHistory: false };
return { storeAction: StoreAction.NONE };
}
},
keyTest: (event) =>
@ -227,6 +242,7 @@ export const actionSaveFileToDisk = register({
export const actionLoadScene = register({
name: "loadScene",
label: "buttons.load",
trackEvent: { category: "export" },
predicate: (elements, appState, props, app) => {
return (
@ -244,7 +260,7 @@ export const actionLoadScene = register({
elements: loadedElements,
appState: loadedAppState,
files,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
} catch (error: any) {
if (error?.name === "AbortError") {
@ -255,7 +271,7 @@ export const actionLoadScene = register({
elements,
appState: { ...appState, errorMessage: error.message },
files: app.files,
commitToHistory: false,
storeAction: StoreAction.NONE,
};
}
},
@ -264,11 +280,12 @@ export const actionLoadScene = register({
export const actionExportWithDarkMode = register({
name: "exportWithDarkMode",
label: "imageExportDialog.label.darkMode",
trackEvent: { category: "export", action: "toggleTheme" },
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportWithDarkMode: value },
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ appState, updateData }) => (

View File

@ -1,6 +1,6 @@
import { KEYS } from "../keys";
import { isInvisiblySmallElement } from "../element";
import { updateActiveTool } from "../utils";
import { arrayToMap, updateActiveTool } from "../utils";
import { ToolButton } from "../components/ToolButton";
import { done } from "../components/icons";
import { t } from "../i18n";
@ -8,28 +8,28 @@ import { register } from "./register";
import { mutateElement } from "../element/mutateElement";
import { isPathALoop } from "../math";
import { LinearElementEditor } from "../element/linearElementEditor";
import Scene from "../scene/Scene";
import {
maybeBindLinearElement,
bindOrUnbindLinearElement,
} from "../element/binding";
import { isBindingElement, isLinearElement } from "../element/typeChecks";
import { AppState } from "../types";
import type { AppState } from "../types";
import { resetCursor } from "../cursor";
import { StoreAction } from "../store";
export const actionFinalize = register({
name: "finalize",
label: "",
trackEvent: false,
perform: (
elements,
appState,
_,
{ interactiveCanvas, focusContainer, scene },
) => {
perform: (elements, appState, _, app) => {
const { interactiveCanvas, focusContainer, scene } = app;
const elementsMap = scene.getNonDeletedElementsMap();
if (appState.editingLinearElement) {
const { elementId, startBindingElement, endBindingElement } =
appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId);
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (element) {
if (isBindingElement(element)) {
@ -37,6 +37,7 @@ export const actionFinalize = register({
element,
startBindingElement,
endBindingElement,
elementsMap,
);
}
return {
@ -48,8 +49,9 @@ export const actionFinalize = register({
...appState,
cursorButton: "up",
editingLinearElement: null,
selectedLinearElement: null,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
}
}
@ -90,7 +92,9 @@ export const actionFinalize = register({
});
}
}
if (isInvisiblySmallElement(multiPointElement)) {
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
newElements = newElements.filter(
(el) => el.id !== multiPointElement.id,
);
@ -125,13 +129,9 @@ export const actionFinalize = register({
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
multiPointElement,
-1,
arrayToMap(elements),
);
maybeBindLinearElement(
multiPointElement,
appState,
Scene.getScene(multiPointElement)!,
{ x, y },
);
maybeBindLinearElement(multiPointElement, appState, { x, y }, app);
}
}
@ -186,11 +186,12 @@ export const actionFinalize = register({
// To select the linear element when user has finished mutipoint editing
selectedLinearElement:
multiPointElement && isLinearElement(multiPointElement)
? new LinearElementEditor(multiPointElement, scene)
? new LinearElementEditor(multiPointElement)
: appState.selectedLinearElement,
pendingImageElementId: null,
},
commitToHistory: appState.activeTool.type === "freedraw",
// TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event, appState) =>

View File

@ -1,26 +1,29 @@
import { register } from "./register";
import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element";
import {
import type {
ExcalidrawElement,
NonDeleted,
NonDeletedElementsMap,
NonDeletedSceneElementsMap,
} from "../element/types";
import { resizeMultipleElements } from "../element/resizeElements";
import { AppState } from "../types";
import type { AppClassProperties, AppState } from "../types";
import { arrayToMap } from "../utils";
import { CODES, KEYS } from "../keys";
import { getCommonBoundingBox } from "../element/bounds";
import {
bindOrUnbindSelectedElements,
bindOrUnbindLinearElements,
isBindingEnabled,
unbindLinearElements,
} from "../element/binding";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { flipHorizontal, flipVertical } from "../components/icons";
import { StoreAction } from "../store";
import { isLinearElement } from "../element/typeChecks";
export const actionFlipHorizontal = register({
name: "flipHorizontal",
label: "labels.flipHorizontal",
icon: flipHorizontal,
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
return {
@ -30,20 +33,22 @@ export const actionFlipHorizontal = register({
app.scene.getNonDeletedElementsMap(),
appState,
"horizontal",
app,
),
appState,
app,
),
appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) => event.shiftKey && event.code === CODES.H,
contextItemLabel: "labels.flipHorizontal",
});
export const actionFlipVertical = register({
name: "flipVertical",
label: "labels.flipVertical",
icon: flipVertical,
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
return {
@ -53,24 +58,25 @@ export const actionFlipVertical = register({
app.scene.getNonDeletedElementsMap(),
appState,
"vertical",
app,
),
appState,
app,
),
appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>
event.shiftKey && event.code === CODES.V && !event[KEYS.CTRL_OR_CMD],
contextItemLabel: "labels.flipVertical",
});
const flipSelectedElements = (
elements: readonly ExcalidrawElement[],
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap,
elementsMap: NonDeletedSceneElementsMap,
appState: Readonly<AppState>,
flipDirection: "horizontal" | "vertical",
app: AppClassProperties,
) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
@ -86,6 +92,7 @@ const flipSelectedElements = (
elementsMap,
appState,
flipDirection,
app,
);
const updatedElementsMap = arrayToMap(updatedElements);
@ -97,9 +104,10 @@ const flipSelectedElements = (
const flipElements = (
selectedElements: NonDeleted<ExcalidrawElement>[],
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap,
elementsMap: NonDeletedSceneElementsMap,
appState: AppState,
flipDirection: "horizontal" | "vertical",
app: AppClassProperties,
): ExcalidrawElement[] => {
const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements);
@ -109,13 +117,17 @@ const flipElements = (
elementsMap,
"nw",
true,
true,
flipDirection === "horizontal" ? maxX : minX,
flipDirection === "horizontal" ? minY : maxY,
);
(isBindingEnabled(appState)
? bindOrUnbindSelectedElements
: unbindLinearElements)(selectedElements);
bindOrUnbindLinearElements(
selectedElements.filter(isLinearElement),
app,
isBindingEnabled(appState),
[],
);
return selectedElements;
};

View File

@ -1,15 +1,20 @@
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import type { ExcalidrawElement } from "../element/types";
import { removeAllElementsFromFrame } from "../frame";
import { getFrameChildren } from "../frame";
import { KEYS } from "../keys";
import { AppClassProperties, AppState } from "../types";
import type { AppClassProperties, AppState, UIAppState } from "../types";
import { updateActiveTool } from "../utils";
import { setCursorForShape } from "../cursor";
import { register } from "./register";
import { isFrameLikeElement } from "../element/typeChecks";
import { frameToolIcon } from "../components/icons";
import { StoreAction } from "../store";
const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
const isSingleFrameSelected = (
appState: UIAppState,
app: AppClassProperties,
) => {
const selectedElements = app.scene.getSelectedElements(appState);
return (
@ -19,6 +24,7 @@ const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
export const actionSelectAllElementsInFrame = register({
name: "selectAllElementsInFrame",
label: "labels.selectAllElementsInFrame",
trackEvent: { category: "canvas" },
perform: (elements, appState, _, app) => {
const selectedElement =
@ -39,23 +45,23 @@ export const actionSelectAllElementsInFrame = register({
return acc;
}, {} as Record<ExcalidrawElement["id"], true>),
},
commitToHistory: false,
storeAction: StoreAction.CAPTURE,
};
}
return {
elements,
appState,
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
contextItemLabel: "labels.selectAllElementsInFrame",
predicate: (elements, appState, _, app) =>
isSingleFrameSelected(appState, app),
});
export const actionRemoveAllElementsFromFrame = register({
name: "removeAllElementsFromFrame",
label: "labels.removeAllElementsFromFrame",
trackEvent: { category: "history" },
perform: (elements, appState, _, app) => {
const selectedElement =
@ -70,23 +76,23 @@ export const actionRemoveAllElementsFromFrame = register({
[selectedElement.id]: true,
},
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
}
return {
elements,
appState,
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
contextItemLabel: "labels.removeAllElementsFromFrame",
predicate: (elements, appState, _, app) =>
isSingleFrameSelected(appState, app),
});
export const actionupdateFrameRendering = register({
name: "updateFrameRendering",
label: "labels.updateFrameRendering",
viewMode: true,
trackEvent: { category: "canvas" },
perform: (elements, appState) => {
@ -99,16 +105,18 @@ export const actionupdateFrameRendering = register({
enabled: !appState.frameRendering.enabled,
},
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
contextItemLabel: "labels.updateFrameRendering",
checked: (appState: AppState) => appState.frameRendering.enabled,
});
export const actionSetFrameAsActiveTool = register({
name: "setFrameAsActiveTool",
label: "toolBar.frame",
trackEvent: { category: "toolbar" },
icon: frameToolIcon,
viewMode: false,
perform: (elements, appState, _, app) => {
const nextActiveTool = updateActiveTool(appState, {
type: "frame",
@ -127,7 +135,7 @@ export const actionSetFrameAsActiveTool = register({
type: "frame",
}),
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
keyTest: (event) =>

View File

@ -17,8 +17,12 @@ import {
import { getNonDeletedElements } from "../element";
import { randomId } from "../random";
import { ToolButton } from "../components/ToolButton";
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
import { AppClassProperties, AppState } from "../types";
import type {
ExcalidrawElement,
ExcalidrawTextElement,
OrderedExcalidrawElement,
} from "../element/types";
import type { AppClassProperties, AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks";
import {
getElementsInResizingFrame,
@ -27,6 +31,8 @@ import {
removeElementsFromFrame,
replaceAllElementsInFrame,
} from "../frame";
import { syncMovedIndices } from "../fractionalIndex";
import { StoreAction } from "../store";
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
if (elements.length >= 2) {
@ -61,6 +67,8 @@ const enableActionGroup = (
export const actionGroup = register({
name: "group",
label: "labels.group",
icon: (appState) => <GroupIcon theme={appState.theme} />,
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements({
@ -69,7 +77,7 @@ export const actionGroup = register({
});
if (selectedElements.length < 2) {
// nothing to group
return { appState, elements, commitToHistory: false };
return { appState, elements, storeAction: StoreAction.NONE };
}
// if everything is already grouped into 1 group, there is nothing to do
const selectedGroupIds = getSelectedGroupIds(appState);
@ -89,7 +97,7 @@ export const actionGroup = register({
]);
if (combinedSet.size === elementIdsInGroup.size) {
// no incremental ids in the selected ids
return { appState, elements, commitToHistory: false };
return { appState, elements, storeAction: StoreAction.NONE };
}
}
@ -131,18 +139,19 @@ export const actionGroup = register({
// to the z order of the highest element in the layer stack
const elementsInGroup = getElementsInGroup(nextElements, newGroupId);
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
const lastGroupElementIndex = nextElements.lastIndexOf(lastElementInGroup);
const lastGroupElementIndex = nextElements.lastIndexOf(
lastElementInGroup as OrderedExcalidrawElement,
);
const elementsAfterGroup = nextElements.slice(lastGroupElementIndex + 1);
const elementsBeforeGroup = nextElements
.slice(0, lastGroupElementIndex)
.filter(
(updatedElement) => !isElementInGroup(updatedElement, newGroupId),
);
nextElements = [
...elementsBeforeGroup,
...elementsInGroup,
...elementsAfterGroup,
];
const reorderedElements = syncMovedIndices(
[...elementsBeforeGroup, ...elementsInGroup, ...elementsAfterGroup],
arrayToMap(elementsInGroup),
);
return {
appState: {
@ -153,11 +162,10 @@ export const actionGroup = register({
getNonDeletedElements(nextElements),
),
},
elements: nextElements,
commitToHistory: true,
elements: reorderedElements,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: "labels.group",
predicate: (elements, appState, _, app) =>
enableActionGroup(elements, appState, app),
keyTest: (event) =>
@ -177,11 +185,15 @@ export const actionGroup = register({
export const actionUngroup = register({
name: "ungroup",
label: "labels.ungroup",
icon: (appState) => <UngroupIcon theme={appState.theme} />,
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
const groupIds = getSelectedGroupIds(appState);
const elementsMap = arrayToMap(elements);
if (groupIds.length === 0) {
return { appState, elements, commitToHistory: false };
return { appState, elements, storeAction: StoreAction.NONE };
}
let nextElements = [...elements];
@ -226,7 +238,12 @@ export const actionUngroup = register({
if (frame) {
nextElements = replaceAllElementsInFrame(
nextElements,
getElementsInResizingFrame(nextElements, frame, appState),
getElementsInResizingFrame(
nextElements,
frame,
appState,
elementsMap,
),
frame,
app,
);
@ -249,14 +266,13 @@ export const actionUngroup = register({
return {
appState: { ...appState, ...updateAppState },
elements: nextElements,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>
event.shiftKey &&
event[KEYS.CTRL_OR_CMD] &&
event.key === KEYS.G.toUpperCase(),
contextItemLabel: "labels.ungroup",
predicate: (elements, appState) => getSelectedGroupIds(appState).length > 0,
PanelComponent: ({ elements, appState, updateData }) => (

View File

@ -1,105 +1,120 @@
import { Action, ActionResult } from "./types";
import type { Action, ActionResult } from "./types";
import { UndoIcon, RedoIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import History, { HistoryEntry } from "../history";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import type { History } from "../history";
import { HistoryChangedEvent } from "../history";
import type { AppState } from "../types";
import { KEYS } from "../keys";
import { newElementWith } from "../element/mutateElement";
import { fixBindingsAfterDeletion } from "../element/binding";
import { arrayToMap } from "../utils";
import { isWindows } from "../constants";
import type { SceneElementsMap } from "../element/types";
import type { Store } from "../store";
import { StoreAction } from "../store";
import { useEmitter } from "../hooks/useEmitter";
const writeData = (
prevElements: readonly ExcalidrawElement[],
appState: AppState,
updater: () => HistoryEntry | null,
appState: Readonly<AppState>,
updater: () => [SceneElementsMap, AppState] | void,
): ActionResult => {
const commitToHistory = false;
if (
!appState.multiElement &&
!appState.resizingElement &&
!appState.editingElement &&
!appState.draggingElement
) {
const data = updater();
if (data === null) {
return { commitToHistory };
const result = updater();
if (!result) {
return { storeAction: StoreAction.NONE };
}
const prevElementMap = arrayToMap(prevElements);
const nextElements = data.elements;
const nextElementMap = arrayToMap(nextElements);
const deletedElements = prevElements.filter(
(prevElement) => !nextElementMap.has(prevElement.id),
);
const elements = nextElements
.map((nextElement) =>
newElementWith(
prevElementMap.get(nextElement.id) || nextElement,
nextElement,
),
)
.concat(
deletedElements.map((prevElement) =>
newElementWith(prevElement, { isDeleted: true }),
),
);
fixBindingsAfterDeletion(elements, deletedElements);
const [nextElementsMap, nextAppState] = result;
const nextElements = Array.from(nextElementsMap.values());
return {
elements,
appState: { ...appState, ...data.appState },
commitToHistory,
syncHistory: true,
appState: nextAppState,
elements: nextElements,
storeAction: StoreAction.UPDATE,
};
}
return { commitToHistory };
return { storeAction: StoreAction.NONE };
};
type ActionCreator = (history: History) => Action;
type ActionCreator = (history: History, store: Store) => Action;
export const createUndoAction: ActionCreator = (history) => ({
export const createUndoAction: ActionCreator = (history, store) => ({
name: "undo",
label: "buttons.undo",
icon: UndoIcon,
trackEvent: { category: "history" },
viewMode: false,
perform: (elements, appState) =>
writeData(elements, appState, () => history.undoOnce()),
writeData(appState, () =>
history.undo(
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
appState,
store.snapshot,
),
),
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] &&
event.key.toLowerCase() === KEYS.Z &&
!event.shiftKey,
PanelComponent: ({ updateData, data }) => (
<ToolButton
type="button"
icon={UndoIcon}
aria-label={t("buttons.undo")}
onClick={updateData}
size={data?.size || "medium"}
/>
),
commitToHistory: () => false,
PanelComponent: ({ updateData, data }) => {
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
history.onHistoryChangedEmitter,
new HistoryChangedEvent(),
);
return (
<ToolButton
type="button"
icon={UndoIcon}
aria-label={t("buttons.undo")}
onClick={updateData}
size={data?.size || "medium"}
disabled={isUndoStackEmpty}
/>
);
},
});
export const createRedoAction: ActionCreator = (history) => ({
export const createRedoAction: ActionCreator = (history, store) => ({
name: "redo",
label: "buttons.redo",
icon: RedoIcon,
trackEvent: { category: "history" },
viewMode: false,
perform: (elements, appState) =>
writeData(elements, appState, () => history.redoOnce()),
writeData(appState, () =>
history.redo(
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
appState,
store.snapshot,
),
),
keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
event.key.toLowerCase() === KEYS.Z) ||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
PanelComponent: ({ updateData, data }) => (
<ToolButton
type="button"
icon={RedoIcon}
aria-label={t("buttons.redo")}
onClick={updateData}
size={data?.size || "medium"}
/>
),
commitToHistory: () => false,
PanelComponent: ({ updateData, data }) => {
const { isRedoStackEmpty } = useEmitter(
history.onHistoryChangedEmitter,
new HistoryChangedEvent(),
);
return (
<ToolButton
type="button"
icon={RedoIcon}
aria-label={t("buttons.redo")}
onClick={updateData}
size={data?.size || "medium"}
disabled={isRedoStackEmpty}
/>
);
},
});

View File

@ -1,45 +0,0 @@
import { LinearElementEditor } from "../element/linearElementEditor";
import { isLinearElement } from "../element/typeChecks";
import { ExcalidrawLinearElement } from "../element/types";
import { register } from "./register";
export const actionToggleLinearEditor = register({
name: "toggleLinearEditor",
trackEvent: {
category: "element",
},
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
return true;
}
return false;
},
perform(elements, appState, _, app) {
const selectedElement = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
})[0] as ExcalidrawLinearElement;
const editingLinearElement =
appState.editingLinearElement?.elementId === selectedElement.id
? null
: new LinearElementEditor(selectedElement, app.scene);
return {
appState: {
...appState,
editingLinearElement,
},
commitToHistory: false,
};
},
contextItemLabel: (elements, appState, app) => {
const selectedElement = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
})[0] as ExcalidrawLinearElement;
return appState.editingLinearElement?.elementId === selectedElement.id
? "labels.lineEditor.exit"
: "labels.lineEditor.edit";
},
});

View File

@ -0,0 +1,76 @@
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
import { LinearElementEditor } from "../element/linearElementEditor";
import { isLinearElement } from "../element/typeChecks";
import type { ExcalidrawLinearElement } from "../element/types";
import { StoreAction } from "../store";
import { register } from "./register";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import { lineEditorIcon } from "../components/icons";
export const actionToggleLinearEditor = register({
name: "toggleLinearEditor",
category: DEFAULT_CATEGORIES.elements,
label: (elements, appState, app) => {
const selectedElement = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
})[0] as ExcalidrawLinearElement | undefined;
return selectedElement?.type === "arrow"
? "labels.lineEditor.editArrow"
: "labels.lineEditor.edit";
},
keywords: ["line"],
trackEvent: {
category: "element",
},
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
if (
!appState.editingLinearElement &&
selectedElements.length === 1 &&
isLinearElement(selectedElements[0])
) {
return true;
}
return false;
},
perform(elements, appState, _, app) {
const selectedElement = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
})[0] as ExcalidrawLinearElement;
const editingLinearElement =
appState.editingLinearElement?.elementId === selectedElement.id
? null
: new LinearElementEditor(selectedElement);
return {
appState: {
...appState,
editingLinearElement,
},
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ appState, updateData, app }) => {
const selectedElement = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
})[0] as ExcalidrawLinearElement;
const label = t(
selectedElement.type === "arrow"
? "labels.lineEditor.editArrow"
: "labels.lineEditor.edit",
);
return (
<ToolButton
type="button"
icon={lineEditorIcon}
title={label}
aria-label={label}
onClick={() => updateData(null)}
/>
);
},
});

View File

@ -0,0 +1,55 @@
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
import { LinkIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { isEmbeddableElement } from "../element/typeChecks";
import { t } from "../i18n";
import { KEYS } from "../keys";
import { getSelectedElements } from "../scene";
import { StoreAction } from "../store";
import { getShortcutKey } from "../utils";
import { register } from "./register";
export const actionLink = register({
name: "hyperlink",
label: (elements, appState) => getContextMenuLabel(elements, appState),
icon: LinkIcon,
perform: (elements, appState) => {
if (appState.showHyperlinkPopup === "editor") {
return false;
}
return {
elements,
appState: {
...appState,
showHyperlinkPopup: "editor",
openMenu: null,
},
storeAction: StoreAction.CAPTURE,
};
},
trackEvent: { category: "hyperlink", action: "click" },
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K,
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
return selectedElements.length === 1;
},
PanelComponent: ({ elements, appState, updateData }) => {
const selectedElements = getSelectedElements(elements, appState);
return (
<ToolButton
type="button"
icon={LinkIcon}
aria-label={t(getContextMenuLabel(elements, appState))}
title={`${
isEmbeddableElement(elements[0])
? t("labels.link.labelEmbed")
: t("labels.link.label")
} - ${getShortcutKey("CtrlOrCmd+K")}`}
onClick={() => updateData(null)}
selected={selectedElements.length === 1 && !!selectedElements[0].link}
/>
);
},
});

View File

@ -1,19 +1,21 @@
import { HamburgerMenuIcon, palette } from "../components/icons";
import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
import { register } from "./register";
import { KEYS } from "../keys";
import { StoreAction } from "../store";
export const actionToggleCanvasMenu = register({
name: "toggleCanvasMenu",
label: "buttons.menu",
trackEvent: { category: "menu" },
perform: (_, appState) => ({
appState: {
...appState,
openMenu: appState.openMenu === "canvas" ? null : "canvas",
},
commitToHistory: false,
storeAction: StoreAction.NONE,
}),
PanelComponent: ({ appState, updateData }) => (
<ToolButton
@ -28,13 +30,14 @@ export const actionToggleCanvasMenu = register({
export const actionToggleEditMenu = register({
name: "toggleEditMenu",
label: "buttons.edit",
trackEvent: { category: "menu" },
perform: (_elements, appState) => ({
appState: {
...appState,
openMenu: appState.openMenu === "shape" ? null : "shape",
},
commitToHistory: false,
storeAction: StoreAction.NONE,
}),
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
@ -53,6 +56,8 @@ export const actionToggleEditMenu = register({
export const actionShortcuts = register({
name: "toggleShortcuts",
label: "welcomeScreen.defaults.helpHint",
icon: HelpIconThin,
viewMode: true,
trackEvent: { category: "menu", action: "toggleHelpDialog" },
perform: (_elements, appState, _, { focusContainer }) => {
@ -69,7 +74,7 @@ export const actionShortcuts = register({
name: "help",
},
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
keyTest: (event) => event.key === KEYS.QUESTION_MARK,

View File

@ -1,13 +1,20 @@
import { getClientColor } from "../clients";
import { Avatar } from "../components/Avatar";
import { GoToCollaboratorComponentProps } from "../components/UserList";
import { eyeIcon } from "../components/icons";
import type { GoToCollaboratorComponentProps } from "../components/UserList";
import {
eyeIcon,
microphoneIcon,
microphoneMutedIcon,
} from "../components/icons";
import { t } from "../i18n";
import { Collaborator } from "../types";
import { StoreAction } from "../store";
import type { Collaborator } from "../types";
import { register } from "./register";
import clsx from "clsx";
export const actionGoToCollaborator = register({
name: "goToCollaborator",
label: "Go to a collaborator",
viewMode: true,
trackEvent: { category: "collab" },
perform: (_elements, appState, collaborator: Collaborator) => {
@ -21,7 +28,7 @@ export const actionGoToCollaborator = register({
...appState,
userToFollow: null,
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
}
@ -35,18 +42,49 @@ export const actionGoToCollaborator = register({
// Close mobile menu
openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ updateData, data, appState }) => {
const { clientId, collaborator, withName, isBeingFollowed } =
const { socketId, collaborator, withName, isBeingFollowed } =
data as GoToCollaboratorComponentProps;
const background = getClientColor(clientId);
const background = getClientColor(socketId, collaborator);
const statusClassNames = clsx({
"is-followed": isBeingFollowed,
"is-current-user": collaborator.isCurrentUser === true,
"is-speaking": collaborator.isSpeaking,
"is-in-call": collaborator.isInCall,
"is-muted": collaborator.isMuted,
});
const statusIconJSX = collaborator.isInCall ? (
collaborator.isSpeaking ? (
<div
className="UserList__collaborator-status-icon-speaking-indicator"
title={t("userList.hint.isSpeaking")}
>
<div />
<div />
<div />
</div>
) : collaborator.isMuted ? (
<div
className="UserList__collaborator-status-icon-microphone-muted"
title={t("userList.hint.micMuted")}
>
{microphoneMutedIcon}
</div>
) : (
<div title={t("userList.hint.inCall")}>{microphoneIcon}</div>
)
) : null;
return withName ? (
<div
className="dropdown-menu-item dropdown-menu-item-base UserList__collaborator"
className={`dropdown-menu-item dropdown-menu-item-base UserList__collaborator ${statusClassNames}`}
style={{ [`--avatar-size` as any]: "1.5rem" }}
onClick={() => updateData<Collaborator>(collaborator)}
>
<Avatar
@ -54,32 +92,42 @@ export const actionGoToCollaborator = register({
onClick={() => {}}
name={collaborator.username || ""}
src={collaborator.avatarUrl}
isBeingFollowed={isBeingFollowed}
isCurrentUser={collaborator.isCurrentUser === true}
className={statusClassNames}
/>
<div className="UserList__collaborator-name">
{collaborator.username}
</div>
<div
className="UserList__collaborator-follow-status-icon"
style={{ visibility: isBeingFollowed ? "visible" : "hidden" }}
title={isBeingFollowed ? t("userList.hint.followStatus") : undefined}
aria-hidden
>
{eyeIcon}
<div className="UserList__collaborator-status-icons" aria-hidden>
{isBeingFollowed && (
<div
className="UserList__collaborator-status-icon-is-followed"
title={t("userList.hint.followStatus")}
>
{eyeIcon}
</div>
)}
{statusIconJSX}
</div>
</div>
) : (
<Avatar
color={background}
onClick={() => {
updateData(collaborator);
}}
name={collaborator.username || ""}
src={collaborator.avatarUrl}
isBeingFollowed={isBeingFollowed}
isCurrentUser={collaborator.isCurrentUser === true}
/>
<div
className={`UserList__collaborator UserList__collaborator--avatar-only ${statusClassNames}`}
>
<Avatar
color={background}
onClick={() => {
updateData(collaborator);
}}
name={collaborator.username || ""}
src={collaborator.avatarUrl}
className={statusClassNames}
/>
{statusIconJSX && (
<div className="UserList__collaborator-status-icon">
{statusIconJSX}
</div>
)}
</div>
);
},
});

View File

@ -1,4 +1,4 @@
import { AppClassProperties, AppState, Primitive } from "../types";
import type { AppClassProperties, AppState, Primitive } from "../types";
import {
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_PICKS,
@ -49,6 +49,7 @@ import {
ArrowheadCircleOutlineIcon,
ArrowheadDiamondIcon,
ArrowheadDiamondOutlineIcon,
fontSizeIcon,
} from "../components/icons";
import {
DEFAULT_FONT_FAMILY,
@ -73,7 +74,7 @@ import {
isLinearElement,
isUsingAdaptiveRadius,
} from "../element/typeChecks";
import {
import type {
Arrowhead,
ExcalidrawElement,
ExcalidrawLinearElement,
@ -95,6 +96,7 @@ import {
import { hasStrokeColor } from "../scene/comparisons";
import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";
import { StoreAction } from "../store";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@ -209,6 +211,7 @@ const changeFontSize = (
redrawTextBoundingBox(
newElement,
app.scene.getContainerElement(oldElement),
app.scene.getNonDeletedElementsMap(),
);
newElement = offsetElementAfterFontResize(oldElement, newElement);
@ -229,7 +232,7 @@ const changeFontSize = (
? [...newFontSizes][0]
: fallbackValue ?? appState.currentItemFontSize,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
};
@ -237,6 +240,7 @@ const changeFontSize = (
export const actionChangeStrokeColor = register({
name: "changeStrokeColor",
label: "labels.stroke",
trackEvent: false,
perform: (elements, appState, value) => {
return {
@ -258,7 +262,9 @@ export const actionChangeStrokeColor = register({
...appState,
...value,
},
commitToHistory: !!value.currentItemStrokeColor,
storeAction: !!value.currentItemStrokeColor
? StoreAction.CAPTURE
: StoreAction.NONE,
};
},
PanelComponent: ({ elements, appState, updateData, appProps }) => (
@ -287,6 +293,7 @@ export const actionChangeStrokeColor = register({
export const actionChangeBackgroundColor = register({
name: "changeBackgroundColor",
label: "labels.changeBackground",
trackEvent: false,
perform: (elements, appState, value) => {
return {
@ -301,7 +308,9 @@ export const actionChangeBackgroundColor = register({
...appState,
...value,
},
commitToHistory: !!value.currentItemBackgroundColor,
storeAction: !!value.currentItemBackgroundColor
? StoreAction.CAPTURE
: StoreAction.NONE,
};
},
PanelComponent: ({ elements, appState, updateData, appProps }) => (
@ -330,6 +339,7 @@ export const actionChangeBackgroundColor = register({
export const actionChangeFillStyle = register({
name: "changeFillStyle",
label: "labels.fill",
trackEvent: false,
perform: (elements, appState, value, app) => {
trackEvent(
@ -344,7 +354,7 @@ export const actionChangeFillStyle = register({
}),
),
appState: { ...appState, currentItemFillStyle: value },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
@ -407,6 +417,7 @@ export const actionChangeFillStyle = register({
export const actionChangeStrokeWidth = register({
name: "changeStrokeWidth",
label: "labels.strokeWidth",
trackEvent: false,
perform: (elements, appState, value) => {
return {
@ -416,7 +427,7 @@ export const actionChangeStrokeWidth = register({
}),
),
appState: { ...appState, currentItemStrokeWidth: value },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
@ -460,6 +471,7 @@ export const actionChangeStrokeWidth = register({
export const actionChangeSloppiness = register({
name: "changeSloppiness",
label: "labels.sloppiness",
trackEvent: false,
perform: (elements, appState, value) => {
return {
@ -470,7 +482,7 @@ export const actionChangeSloppiness = register({
}),
),
appState: { ...appState, currentItemRoughness: value },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
@ -511,6 +523,7 @@ export const actionChangeSloppiness = register({
export const actionChangeStrokeStyle = register({
name: "changeStrokeStyle",
label: "labels.strokeStyle",
trackEvent: false,
perform: (elements, appState, value) => {
return {
@ -520,7 +533,7 @@ export const actionChangeStrokeStyle = register({
}),
),
appState: { ...appState, currentItemStrokeStyle: value },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
@ -561,6 +574,7 @@ export const actionChangeStrokeStyle = register({
export const actionChangeOpacity = register({
name: "changeOpacity",
label: "labels.opacity",
trackEvent: false,
perform: (elements, appState, value) => {
return {
@ -574,7 +588,7 @@ export const actionChangeOpacity = register({
true,
),
appState: { ...appState, currentItemOpacity: value },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
@ -602,6 +616,7 @@ export const actionChangeOpacity = register({
export const actionChangeFontSize = register({
name: "changeFontSize",
label: "labels.fontSize",
trackEvent: false,
perform: (elements, appState, value, app) => {
return changeFontSize(elements, appState, app, () => value, value);
@ -672,6 +687,8 @@ export const actionChangeFontSize = register({
export const actionDecreaseFontSize = register({
name: "decreaseFontSize",
label: "labels.decreaseFontSize",
icon: fontSizeIcon,
trackEvent: false,
perform: (elements, appState, value, app) => {
return changeFontSize(elements, appState, app, (element) =>
@ -694,6 +711,8 @@ export const actionDecreaseFontSize = register({
export const actionIncreaseFontSize = register({
name: "increaseFontSize",
label: "labels.increaseFontSize",
icon: fontSizeIcon,
trackEvent: false,
perform: (elements, appState, value, app) => {
return changeFontSize(elements, appState, app, (element) =>
@ -712,6 +731,7 @@ export const actionIncreaseFontSize = register({
export const actionChangeFontFamily = register({
name: "changeFontFamily",
label: "labels.fontFamily",
trackEvent: false,
perform: (elements, appState, value, app) => {
return {
@ -730,6 +750,7 @@ export const actionChangeFontFamily = register({
redrawTextBoundingBox(
newElement,
app.scene.getContainerElement(oldElement),
app.scene.getNonDeletedElementsMap(),
);
return newElement;
}
@ -742,7 +763,7 @@ export const actionChangeFontFamily = register({
...appState,
currentItemFontFamily: value,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => {
@ -814,6 +835,7 @@ export const actionChangeFontFamily = register({
export const actionChangeTextAlign = register({
name: "changeTextAlign",
label: "Change text alignment",
trackEvent: false,
perform: (elements, appState, value, app) => {
return {
@ -829,6 +851,7 @@ export const actionChangeTextAlign = register({
redrawTextBoundingBox(
newElement,
app.scene.getContainerElement(oldElement),
app.scene.getNonDeletedElementsMap(),
);
return newElement;
}
@ -841,7 +864,7 @@ export const actionChangeTextAlign = register({
...appState,
currentItemTextAlign: value,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => {
@ -902,6 +925,7 @@ export const actionChangeTextAlign = register({
export const actionChangeVerticalAlign = register({
name: "changeVerticalAlign",
label: "Change vertical alignment",
trackEvent: { category: "element" },
perform: (elements, appState, value, app) => {
return {
@ -918,6 +942,7 @@ export const actionChangeVerticalAlign = register({
redrawTextBoundingBox(
newElement,
app.scene.getContainerElement(oldElement),
app.scene.getNonDeletedElementsMap(),
);
return newElement;
}
@ -929,7 +954,7 @@ export const actionChangeVerticalAlign = register({
appState: {
...appState,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => {
@ -990,6 +1015,7 @@ export const actionChangeVerticalAlign = register({
export const actionChangeRoundness = register({
name: "changeRoundness",
label: "Change edge roundness",
trackEvent: false,
perform: (elements, appState, value) => {
return {
@ -1009,7 +1035,7 @@ export const actionChangeRoundness = register({
...appState,
currentItemRoundness: value,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
@ -1128,6 +1154,7 @@ const getArrowheadOptions = (flip: boolean) => {
export const actionChangeArrowhead = register({
name: "changeArrowhead",
label: "Change arrowheads",
trackEvent: false,
perform: (
elements,
@ -1160,7 +1187,7 @@ export const actionChangeArrowhead = register({
? "currentItemStartArrowhead"
: "currentItemEndArrowhead"]: value.type,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => {

View File

@ -2,14 +2,19 @@ import { KEYS } from "../keys";
import { register } from "./register";
import { selectGroupsForSelectedElements } from "../groups";
import { getNonDeletedElements, isTextElement } from "../element";
import { ExcalidrawElement } from "../element/types";
import type { ExcalidrawElement } from "../element/types";
import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor";
import { excludeElementsInFramesFromSelection } from "../scene/selection";
import { selectAllIcon } from "../components/icons";
import { StoreAction } from "../store";
export const actionSelectAll = register({
name: "selectAll",
label: "labels.selectAll",
icon: selectAllIcon,
trackEvent: { category: "canvas" },
viewMode: false,
perform: (elements, appState, value, app) => {
if (appState.editingLinearElement) {
return false;
@ -43,12 +48,11 @@ export const actionSelectAll = register({
// single linear element selected
Object.keys(selectedElementIds).length === 1 &&
isLinearElement(elements[0])
? new LinearElementEditor(elements[0], app.scene)
? new LinearElementEditor(elements[0])
: null,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: "labels.selectAll",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A,
});

View File

@ -24,13 +24,17 @@ import {
isArrowElement,
} from "../element/typeChecks";
import { getSelectedElements } from "../scene";
import { ExcalidrawTextElement } from "../element/types";
import type { ExcalidrawTextElement } from "../element/types";
import { paintIcon } from "../components/icons";
import { StoreAction } from "../store";
// `copiedStyles` is exported only for tests.
export let copiedStyles: string = "{}";
export const actionCopyStyles = register({
name: "copyStyles",
label: "labels.copyStyles",
icon: paintIcon,
trackEvent: { category: "element" },
perform: (elements, appState, formData, app) => {
const elementsCopied = [];
@ -51,23 +55,24 @@ export const actionCopyStyles = register({
...appState,
toast: { message: t("toast.copyStyles") },
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
contextItemLabel: "labels.copyStyles",
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
});
export const actionPasteStyles = register({
name: "pasteStyles",
label: "labels.pasteStyles",
icon: paintIcon,
trackEvent: { category: "element" },
perform: (elements, appState, formData, app) => {
const elementsCopied = JSON.parse(copiedStyles);
const pastedElement = elementsCopied[0];
const boundTextElement = elementsCopied[1];
if (!isExcalidrawElement(pastedElement)) {
return { elements, commitToHistory: false };
return { elements, storeAction: StoreAction.NONE };
}
const selectedElements = getSelectedElements(elements, appState, {
@ -128,7 +133,11 @@ export const actionPasteStyles = register({
element.id === newElement.containerId,
) || null;
}
redrawTextBoundingBox(newElement, container);
redrawTextBoundingBox(
newElement,
container,
app.scene.getNonDeletedElementsMap(),
);
}
if (
@ -152,10 +161,9 @@ export const actionPasteStyles = register({
}
return element;
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: "labels.pasteStyles",
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
});

View File

@ -1,10 +1,15 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { GRID_SIZE } from "../constants";
import { AppState } from "../types";
import type { AppState } from "../types";
import { gridIcon } from "../components/icons";
import { StoreAction } from "../store";
export const actionToggleGridMode = register({
name: "gridMode",
icon: gridIcon,
keywords: ["snap"],
label: "labels.toggleGrid",
viewMode: true,
trackEvent: {
category: "canvas",
@ -17,13 +22,12 @@ export const actionToggleGridMode = register({
gridSize: this.checked!(appState) ? null : GRID_SIZE,
objectsSnapModeEnabled: false,
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
checked: (appState: AppState) => appState.gridSize !== null,
predicate: (element, appState, props) => {
return typeof props.gridModeEnabled === "undefined";
},
contextItemLabel: "labels.showGrid",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
});

View File

@ -1,9 +1,13 @@
import { magnetIcon } from "../components/icons";
import { CODES, KEYS } from "../keys";
import { StoreAction } from "../store";
import { register } from "./register";
export const actionToggleObjectsSnapMode = register({
name: "objectsSnapMode",
viewMode: true,
label: "buttons.objectsSnapMode",
icon: magnetIcon,
viewMode: false,
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.objectsSnapModeEnabled,
@ -15,14 +19,13 @@ export const actionToggleObjectsSnapMode = register({
objectsSnapModeEnabled: !this.checked!(appState),
gridSize: null,
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
checked: (appState) => appState.objectsSnapModeEnabled,
predicate: (elements, appState, appProps) => {
return typeof appProps.objectsSnapModeEnabled === "undefined";
},
contextItemLabel: "buttons.objectsSnapMode",
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.S,
});

View File

@ -1,8 +1,13 @@
import { register } from "./register";
import { CODES, KEYS } from "../keys";
import { abacusIcon } from "../components/icons";
import { StoreAction } from "../store";
export const actionToggleStats = register({
name: "stats",
label: "stats.title",
icon: abacusIcon,
paletteName: "Toggle stats",
viewMode: true,
trackEvent: { category: "menu" },
perform(elements, appState) {
@ -11,11 +16,10 @@ export const actionToggleStats = register({
...appState,
showStats: !this.checked!(appState),
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
checked: (appState) => appState.showStats,
contextItemLabel: "stats.title",
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
});

View File

@ -1,8 +1,13 @@
import { eyeIcon } from "../components/icons";
import { CODES, KEYS } from "../keys";
import { StoreAction } from "../store";
import { register } from "./register";
export const actionToggleViewMode = register({
name: "viewMode",
label: "labels.viewMode",
paletteName: "Toggle view mode",
icon: eyeIcon,
viewMode: true,
trackEvent: {
category: "canvas",
@ -14,14 +19,13 @@ export const actionToggleViewMode = register({
...appState,
viewModeEnabled: !this.checked!(appState),
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
checked: (appState) => appState.viewModeEnabled,
predicate: (elements, appState, appProps) => {
return typeof appProps.viewModeEnabled === "undefined";
},
contextItemLabel: "labels.viewMode",
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R,
});

View File

@ -1,8 +1,13 @@
import { coffeeIcon } from "../components/icons";
import { CODES, KEYS } from "../keys";
import { StoreAction } from "../store";
import { register } from "./register";
export const actionToggleZenMode = register({
name: "zenMode",
label: "buttons.zenMode",
icon: coffeeIcon,
paletteName: "Toggle zen mode",
viewMode: true,
trackEvent: {
category: "canvas",
@ -14,14 +19,13 @@ export const actionToggleZenMode = register({
...appState,
zenModeEnabled: !this.checked!(appState),
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
checked: (appState) => appState.zenModeEnabled,
predicate: (elements, appState, appProps) => {
return typeof appProps.zenModeEnabled === "undefined";
},
contextItemLabel: "buttons.zenMode",
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,
});

View File

@ -1,4 +1,3 @@
import React from "react";
import {
moveOneLeft,
moveOneRight,
@ -16,18 +15,21 @@ import {
SendToBackIcon,
} from "../components/icons";
import { isDarwin } from "../constants";
import { StoreAction } from "../store";
export const actionSendBackward = register({
name: "sendBackward",
label: "labels.sendBackward",
keywords: ["move down", "zindex", "layer"],
icon: SendBackwardIcon,
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
elements: moveOneLeft(elements, appState),
appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: "labels.sendBackward",
keyPriority: 40,
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] &&
@ -47,15 +49,17 @@ export const actionSendBackward = register({
export const actionBringForward = register({
name: "bringForward",
label: "labels.bringForward",
keywords: ["move up", "zindex", "layer"],
icon: BringForwardIcon,
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
elements: moveOneRight(elements, appState),
appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: "labels.bringForward",
keyPriority: 40,
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] &&
@ -75,15 +79,17 @@ export const actionBringForward = register({
export const actionSendToBack = register({
name: "sendToBack",
label: "labels.sendToBack",
keywords: ["move down", "zindex", "layer"],
icon: SendToBackIcon,
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
elements: moveAllLeft(elements, appState),
appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: "labels.sendToBack",
keyTest: (event) =>
isDarwin
? event[KEYS.CTRL_OR_CMD] &&
@ -110,16 +116,18 @@ export const actionSendToBack = register({
export const actionBringToFront = register({
name: "bringToFront",
label: "labels.bringToFront",
keywords: ["move up", "zindex", "layer"],
icon: BringToFrontIcon,
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
elements: moveAllRight(elements, appState),
appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: "labels.bringToFront",
keyTest: (event) =>
isDarwin
? event[KEYS.CTRL_OR_CMD] &&

View File

@ -83,6 +83,6 @@ export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode";
export { actionToggleStats } from "./actionToggleStats";
export { actionUnbindText, actionBindText } from "./actionBoundText";
export { actionLink } from "../element/Hyperlink";
export { actionLink } from "./actionLink";
export { actionToggleElementLock } from "./actionElementLock";
export { actionToggleLinearEditor } from "./actionLinearEditor";

View File

@ -1,5 +1,5 @@
import React from "react";
import {
import type {
Action,
UpdaterFn,
ActionName,
@ -7,8 +7,11 @@ import {
PanelComponentProps,
ActionSource,
} from "./types";
import { ExcalidrawElement } from "../element/types";
import { AppClassProperties, AppState } from "../types";
import type {
ExcalidrawElement,
OrderedExcalidrawElement,
} from "../element/types";
import type { AppClassProperties, AppState } from "../types";
import { trackEvent } from "../analytics";
import { isPromiseLike } from "../utils";
@ -46,13 +49,13 @@ export class ActionManager {
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
getAppState: () => Readonly<AppState>;
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
getElementsIncludingDeleted: () => readonly OrderedExcalidrawElement[];
app: AppClassProperties;
constructor(
updater: UpdaterFn,
getAppState: () => AppState,
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
getElementsIncludingDeleted: () => readonly OrderedExcalidrawElement[],
app: AppClassProperties,
) {
this.updater = (actionResult) => {

View File

@ -1,4 +1,4 @@
import { Action } from "./types";
import type { Action } from "./types";
export let actions: readonly Action[] = [];

View File

@ -1,8 +1,8 @@
import { isDarwin } from "../constants";
import { t } from "../i18n";
import { SubtypeOf } from "../utility-types";
import type { SubtypeOf } from "../utility-types";
import { getShortcutKey } from "../utils";
import { ActionName } from "./types";
import type { ActionName } from "./types";
export type ShortcutName =
| SubtypeOf<
@ -36,9 +36,22 @@ export type ShortcutName =
| "flipVertical"
| "hyperlink"
| "toggleElementLock"
| "resetZoom"
| "zoomOut"
| "zoomIn"
| "zoomToFit"
| "zoomToFitSelectionInViewport"
| "zoomToFitSelection"
| "toggleEraserTool"
| "toggleHandTool"
| "setFrameAsActiveTool"
| "saveFileToDisk"
| "saveToActiveFile"
| "toggleShortcuts"
>
| "saveScene"
| "imageExport";
| "imageExport"
| "commandPalette";
const shortcutMap: Record<ShortcutName, string[]> = {
toggleTheme: [getShortcutKey("Shift+Alt+D")],
@ -46,6 +59,10 @@ const shortcutMap: Record<ShortcutName, string[]> = {
loadScene: [getShortcutKey("CtrlOrCmd+O")],
clearCanvas: [getShortcutKey("CtrlOrCmd+Delete")],
imageExport: [getShortcutKey("CtrlOrCmd+Shift+E")],
commandPalette: [
getShortcutKey("CtrlOrCmd+/"),
getShortcutKey("CtrlOrCmd+Shift+P"),
],
cut: [getShortcutKey("CtrlOrCmd+X")],
copy: [getShortcutKey("CtrlOrCmd+C")],
paste: [getShortcutKey("CtrlOrCmd+V")],
@ -83,10 +100,24 @@ const shortcutMap: Record<ShortcutName, string[]> = {
viewMode: [getShortcutKey("Alt+R")],
hyperlink: [getShortcutKey("CtrlOrCmd+K")],
toggleElementLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
resetZoom: [getShortcutKey("CtrlOrCmd+0")],
zoomOut: [getShortcutKey("CtrlOrCmd+-")],
zoomIn: [getShortcutKey("CtrlOrCmd++")],
zoomToFitSelection: [getShortcutKey("Shift+3")],
zoomToFit: [getShortcutKey("Shift+1")],
zoomToFitSelectionInViewport: [getShortcutKey("Shift+2")],
toggleEraserTool: [getShortcutKey("E")],
toggleHandTool: [getShortcutKey("H")],
setFrameAsActiveTool: [getShortcutKey("F")],
saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")],
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
toggleShortcuts: [getShortcutKey("?")],
};
export const getShortcutFromShortcutName = (name: ShortcutName) => {
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
const shortcuts = shortcutMap[name];
// if multiple shortcuts available, take the first one
return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
return shortcuts && shortcuts.length > 0
? shortcuts[idx] || shortcuts[0]
: "";
};

View File

@ -1,14 +1,24 @@
import React from "react";
import { ExcalidrawElement } from "../element/types";
import {
import type React from "react";
import type {
ExcalidrawElement,
OrderedExcalidrawElement,
} from "../element/types";
import type {
AppClassProperties,
AppState,
ExcalidrawProps,
BinaryFiles,
UIAppState,
} from "../types";
import { MarkOptional } from "../utility-types";
import type { MarkOptional } from "../utility-types";
import type { StoreActionType } from "../store";
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
export type ActionSource =
| "ui"
| "keyboard"
| "contextMenu"
| "api"
| "commandPalette";
/** if false, the action should be prevented */
export type ActionResult =
@ -19,14 +29,13 @@ export type ActionResult =
"offsetTop" | "offsetLeft" | "width" | "height"
> | null;
files?: BinaryFiles | null;
commitToHistory: boolean;
syncHistory?: boolean;
storeAction: StoreActionType;
replaceFiles?: boolean;
}
| false;
type ActionFn = (
elements: readonly ExcalidrawElement[],
elements: readonly OrderedExcalidrawElement[],
appState: Readonly<AppState>,
formData: any,
app: AppClassProperties,
@ -124,7 +133,8 @@ export type ActionName =
| "setFrameAsActiveTool"
| "setEmbeddableAsActiveTool"
| "createContainerFromText"
| "wrapTextInContainer";
| "wrapTextInContainer"
| "commandPalette";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];
@ -137,6 +147,20 @@ export type PanelComponentProps = {
export interface Action {
name: ActionName;
label:
| string
| ((
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
app: AppClassProperties,
) => string);
keywords?: string[];
icon?:
| React.ReactNode
| ((
appState: UIAppState,
elements: readonly ExcalidrawElement[],
) => React.ReactNode);
PanelComponent?: React.FC<PanelComponentProps>;
perform: ActionFn;
keyPriority?: number;
@ -146,13 +170,6 @@ export interface Action {
elements: readonly ExcalidrawElement[],
app: AppClassProperties,
) => boolean;
contextItemLabel?:
| string
| ((
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
app: AppClassProperties,
) => string);
predicate?: (
elements: readonly ExcalidrawElement[],
appState: AppState,

View File

@ -1,6 +1,7 @@
import { ElementsMap, ExcalidrawElement } from "./element/types";
import type { ElementsMap, ExcalidrawElement } from "./element/types";
import { newElementWith } from "./element/mutateElement";
import { BoundingBox, getCommonBoundingBox } from "./element/bounds";
import type { BoundingBox } from "./element/bounds";
import { getCommonBoundingBox } from "./element/bounds";
import { getMaximumGroups } from "./groups";
export interface Alignment {

View File

@ -1,6 +1,6 @@
// place here categories that you want to track. We want to track just a
// small subset of categories at a given time.
const ALLOWED_CATEGORIES_TO_TRACK = ["ai"] as string[];
const ALLOWED_CATEGORIES_TO_TRACK = ["ai", "command_palette"] as string[];
export const trackEvent = (
category: string,

View File

@ -1,6 +1,7 @@
import { LaserPointer, LaserPointerOptions } from "@excalidraw/laser-pointer";
import { AnimationFrameHandler } from "./animation-frame-handler";
import { AppState } from "./types";
import type { LaserPointerOptions } from "@excalidraw/laser-pointer";
import { LaserPointer } from "@excalidraw/laser-pointer";
import type { AnimationFrameHandler } from "./animation-frame-handler";
import type { AppState } from "./types";
import { getSvgPathFromStroke, sceneCoordsToViewportCoords } from "./utils";
import type App from "./components/App";
import { SVG_NS } from "./constants";

View File

@ -7,9 +7,7 @@ import {
EXPORT_SCALES,
THEME,
} from "./constants";
import { t } from "./i18n";
import { AppState, NormalizedZoomValue } from "./types";
import { getDateTime } from "./utils";
import type { AppState, NormalizedZoomValue } from "./types";
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
? devicePixelRatio
@ -65,7 +63,7 @@ export const getDefaultAppState = (): Omit<
isRotating: false,
lastPointerDownWith: "mouse",
multiElement: null,
name: `${t("labels.untitled")}-${getDateTime()}`,
name: null,
contextMenu: null,
openMenu: null,
openPopup: null,

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,5 @@
import {
Spreadsheet,
tryParseCells,
tryParseNumber,
VALID_SPREADSHEET,
} from "./charts";
import type { Spreadsheet } from "./charts";
import { tryParseCells, tryParseNumber, VALID_SPREADSHEET } from "./charts";
describe("charts", () => {
describe("tryParseNumber", () => {

View File

@ -9,7 +9,7 @@ import {
VERTICAL_ALIGN,
} from "./constants";
import { newElement, newLinearElement, newTextElement } from "./element";
import { NonDeletedExcalidrawElement } from "./element/types";
import type { NonDeletedExcalidrawElement } from "./element/types";
import { randomId } from "./random";
export type ChartElements = readonly NonDeletedExcalidrawElement[];

View File

@ -1,3 +1,18 @@
import {
COLOR_CHARCOAL_BLACK,
COLOR_VOICE_CALL,
COLOR_WHITE,
THEME,
} from "./constants";
import { roundRect } from "./renderer/roundRect";
import type { InteractiveCanvasRenderConfig } from "./scene/types";
import type {
Collaborator,
InteractiveCanvasAppState,
SocketId,
} from "./types";
import { UserIdleState } from "./types";
function hashToInteger(id: string) {
let hash = 0;
if (id.length === 0) {
@ -11,14 +26,12 @@ function hashToInteger(id: string) {
}
export const getClientColor = (
/**
* any uniquely identifying key, such as user id or socket id
*/
id: string,
socketId: SocketId,
collaborator: Collaborator | undefined,
) => {
// to get more even distribution in case `id` is not uniformly distributed to
// begin with, we hash it
const hash = Math.abs(hashToInteger(id));
const hash = Math.abs(hashToInteger(collaborator?.id || socketId));
// we want to get a multiple of 10 number in the range of 0-360 (in other
// words a hue value of step size 10). There are 37 such values including 0.
const hue = (hash % 37) * 10;
@ -38,3 +51,209 @@ export const getNameInitial = (name?: string | null) => {
firstCodePoint ? String.fromCodePoint(firstCodePoint) : "?"
).toUpperCase();
};
export const renderRemoteCursors = ({
context,
renderConfig,
appState,
normalizedWidth,
normalizedHeight,
}: {
context: CanvasRenderingContext2D;
renderConfig: InteractiveCanvasRenderConfig;
appState: InteractiveCanvasAppState;
normalizedWidth: number;
normalizedHeight: number;
}) => {
// Paint remote pointers
for (const [socketId, pointer] of renderConfig.remotePointerViewportCoords) {
let { x, y } = pointer;
const collaborator = appState.collaborators.get(socketId);
x -= appState.offsetLeft;
y -= appState.offsetTop;
const width = 11;
const height = 14;
const isOutOfBounds =
x < 0 ||
x > normalizedWidth - width ||
y < 0 ||
y > normalizedHeight - height;
x = Math.max(x, 0);
x = Math.min(x, normalizedWidth - width);
y = Math.max(y, 0);
y = Math.min(y, normalizedHeight - height);
const background = getClientColor(socketId, collaborator);
context.save();
context.strokeStyle = background;
context.fillStyle = background;
const userState = renderConfig.remotePointerUserStates.get(socketId);
const isInactive =
isOutOfBounds ||
userState === UserIdleState.IDLE ||
userState === UserIdleState.AWAY;
if (isInactive) {
context.globalAlpha = 0.3;
}
if (renderConfig.remotePointerButton.get(socketId) === "down") {
context.beginPath();
context.arc(x, y, 15, 0, 2 * Math.PI, false);
context.lineWidth = 3;
context.strokeStyle = "#ffffff88";
context.stroke();
context.closePath();
context.beginPath();
context.arc(x, y, 15, 0, 2 * Math.PI, false);
context.lineWidth = 1;
context.strokeStyle = background;
context.stroke();
context.closePath();
}
// TODO remove the dark theme color after we stop inverting canvas colors
const IS_SPEAKING_COLOR =
appState.theme === THEME.DARK ? "#2f6330" : COLOR_VOICE_CALL;
const isSpeaking = collaborator?.isSpeaking;
if (isSpeaking) {
// cursor outline for currently speaking user
context.fillStyle = IS_SPEAKING_COLOR;
context.strokeStyle = IS_SPEAKING_COLOR;
context.lineWidth = 10;
context.lineJoin = "round";
context.beginPath();
context.moveTo(x, y);
context.lineTo(x + 0, y + 14);
context.lineTo(x + 4, y + 9);
context.lineTo(x + 11, y + 8);
context.closePath();
context.stroke();
context.fill();
}
// Background (white outline) for arrow
context.fillStyle = COLOR_WHITE;
context.strokeStyle = COLOR_WHITE;
context.lineWidth = 6;
context.lineJoin = "round";
context.beginPath();
context.moveTo(x, y);
context.lineTo(x + 0, y + 14);
context.lineTo(x + 4, y + 9);
context.lineTo(x + 11, y + 8);
context.closePath();
context.stroke();
context.fill();
// Arrow
context.fillStyle = background;
context.strokeStyle = background;
context.lineWidth = 2;
context.lineJoin = "round";
context.beginPath();
if (isInactive) {
context.moveTo(x - 1, y - 1);
context.lineTo(x - 1, y + 15);
context.lineTo(x + 5, y + 10);
context.lineTo(x + 12, y + 9);
context.closePath();
context.fill();
} else {
context.moveTo(x, y);
context.lineTo(x + 0, y + 14);
context.lineTo(x + 4, y + 9);
context.lineTo(x + 11, y + 8);
context.closePath();
context.fill();
context.stroke();
}
const username = renderConfig.remotePointerUsernames.get(socketId) || "";
if (!isOutOfBounds && username) {
context.font = "600 12px sans-serif"; // font has to be set before context.measureText()
const offsetX = (isSpeaking ? x + 0 : x) + width / 2;
const offsetY = (isSpeaking ? y + 0 : y) + height + 2;
const paddingHorizontal = 5;
const paddingVertical = 3;
const measure = context.measureText(username);
const measureHeight =
measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent;
const finalHeight = Math.max(measureHeight, 12);
const boxX = offsetX - 1;
const boxY = offsetY - 1;
const boxWidth = measure.width + 2 + paddingHorizontal * 2 + 2;
const boxHeight = finalHeight + 2 + paddingVertical * 2 + 2;
if (context.roundRect) {
context.beginPath();
context.roundRect(boxX, boxY, boxWidth, boxHeight, 8);
context.fillStyle = background;
context.fill();
context.strokeStyle = COLOR_WHITE;
context.stroke();
if (isSpeaking) {
context.beginPath();
context.roundRect(boxX - 2, boxY - 2, boxWidth + 4, boxHeight + 4, 8);
context.strokeStyle = IS_SPEAKING_COLOR;
context.stroke();
}
} else {
roundRect(context, boxX, boxY, boxWidth, boxHeight, 8, COLOR_WHITE);
}
context.fillStyle = COLOR_CHARCOAL_BLACK;
context.fillText(
username,
offsetX + paddingHorizontal + 1,
offsetY +
paddingVertical +
measure.actualBoundingBoxAscent +
Math.floor((finalHeight - measureHeight) / 2) +
2,
);
// draw three vertical bars signalling someone is speaking
if (isSpeaking) {
context.fillStyle = IS_SPEAKING_COLOR;
const barheight = 8;
const margin = 8;
const gap = 5;
context.fillRect(
boxX + boxWidth + margin,
boxY + (boxHeight / 2 - barheight / 2),
2,
barheight,
);
context.fillRect(
boxX + boxWidth + margin + gap,
boxY + (boxHeight / 2 - (barheight * 2) / 2),
2,
barheight * 2,
);
context.fillRect(
boxX + boxWidth + margin + gap * 2,
boxY + (boxHeight / 2 - barheight / 2),
2,
barheight,
);
}
}
context.restore();
context.closePath();
}
};

View File

@ -1,9 +1,10 @@
import {
import type {
ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "./element/types";
import { BinaryFiles } from "./types";
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
import type { BinaryFiles } from "./types";
import type { Spreadsheet } from "./charts";
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
import {
ALLOWED_PASTE_MIME_TYPES,
EXPORT_DATA_TYPES,
@ -16,8 +17,7 @@ import {
import { deepCopyElement } from "./element/newElement";
import { mutateElement } from "./element/mutateElement";
import { getContainingFrame } from "./frame";
import { isMemberOf, isPromiseLike } from "./utils";
import { t } from "./i18n";
import { arrayToMap, isMemberOf, isPromiseLike } from "./utils";
type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
@ -126,6 +126,7 @@ export const serializeAsClipboardJSON = ({
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}) => {
const elementsMap = arrayToMap(elements);
const framesToCopy = new Set(
elements.filter((element) => isFrameLikeElement(element)),
);
@ -152,8 +153,8 @@ export const serializeAsClipboardJSON = ({
type: EXPORT_DATA_TYPES.excalidrawClipboard,
elements: elements.map((element) => {
if (
getContainingFrame(element) &&
!framesToCopy.has(getContainingFrame(element)!)
getContainingFrame(element, elementsMap) &&
!framesToCopy.has(getContainingFrame(element, elementsMap)!)
) {
const copiedElement = deepCopyElement(element);
mutateElement(copiedElement, {
@ -434,7 +435,7 @@ export const copyTextToSystemClipboard = async (
// (3) if that fails, use document.execCommand
if (!copyTextViaExecCommand(text)) {
throw new Error(t("errors.copyToSystemClipboardFailed"));
throw new Error("Error copying to clipboard.");
}
};

View File

@ -1,5 +1,5 @@
import oc from "open-color";
import { Merge } from "./utility-types";
import type { Merge } from "./utility-types";
// FIXME can't put to utils.ts rn because of circular dependency
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(

View File

@ -12,6 +12,7 @@
font-size: 0.875rem !important;
width: var(--lg-button-size);
height: var(--lg-button-size);
svg {
width: var(--lg-icon-size) !important;
height: var(--lg-icon-size) !important;

View File

@ -1,6 +1,7 @@
import { useState } from "react";
import { ActionManager } from "../actions/manager";
import {
import type { ActionManager } from "../actions/manager";
import type {
ExcalidrawElement,
ExcalidrawElementType,
NonDeletedElementsMap,
NonDeletedSceneElementsMap,
@ -16,13 +17,17 @@ import {
hasStrokeWidth,
} from "../scene";
import { SHAPES } from "../shapes";
import { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
import { capitalizeString, isTransparent } from "../utils";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { hasStrokeColor } from "../scene/comparisons";
import { trackEvent } from "../analytics";
import { hasBoundTextElement, isTextElement } from "../element/typeChecks";
import {
hasBoundTextElement,
isLinearElement,
isTextElement,
} from "../element/typeChecks";
import clsx from "clsx";
import { actionToggleZenMode } from "../actions";
import { Tooltip } from "./Tooltip";
@ -45,6 +50,40 @@ import {
import { KEYS } from "../keys";
import { useTunnels } from "../context/tunnels";
export const canChangeStrokeColor = (
appState: UIAppState,
targetElements: ExcalidrawElement[],
) => {
let commonSelectedType: ExcalidrawElementType | null =
targetElements[0]?.type || null;
for (const element of targetElements) {
if (element.type !== commonSelectedType) {
commonSelectedType = null;
break;
}
}
return (
(hasStrokeColor(appState.activeTool.type) &&
appState.activeTool.type !== "image" &&
commonSelectedType !== "image" &&
commonSelectedType !== "frame" &&
commonSelectedType !== "magicframe") ||
targetElements.some((element) => hasStrokeColor(element.type))
);
};
export const canChangeBackgroundColor = (
appState: UIAppState,
targetElements: ExcalidrawElement[],
) => {
return (
hasBackground(appState.activeTool.type) ||
targetElements.some((element) => hasBackground(element.type))
);
};
export const SelectedShapeActions = ({
appState,
elementsMap,
@ -75,35 +114,22 @@ export const SelectedShapeActions = ({
(element) =>
hasBackground(element.type) && !isTransparent(element.backgroundColor),
);
const showChangeBackgroundIcons =
hasBackground(appState.activeTool.type) ||
targetElements.some((element) => hasBackground(element.type));
const showLinkIcon =
targetElements.length === 1 || isSingleElementBoundContainer;
let commonSelectedType: ExcalidrawElementType | null =
targetElements[0]?.type || null;
for (const element of targetElements) {
if (element.type !== commonSelectedType) {
commonSelectedType = null;
break;
}
}
const showLineEditorAction =
!appState.editingLinearElement &&
targetElements.length === 1 &&
isLinearElement(targetElements[0]);
return (
<div className="panelColumn">
<div>
{((hasStrokeColor(appState.activeTool.type) &&
appState.activeTool.type !== "image" &&
commonSelectedType !== "image" &&
commonSelectedType !== "frame" &&
commonSelectedType !== "magicframe") ||
targetElements.some((element) => hasStrokeColor(element.type))) &&
{canChangeStrokeColor(appState, targetElements) &&
renderAction("changeStrokeColor")}
</div>
{showChangeBackgroundIcons && (
{canChangeBackgroundColor(appState, targetElements) && (
<div>{renderAction("changeBackgroundColor")}</div>
)}
{showFillIcons && renderAction("changeFillStyle")}
@ -156,8 +182,8 @@ export const SelectedShapeActions = ({
<div className="buttonList">
{renderAction("sendToBack")}
{renderAction("sendBackward")}
{renderAction("bringToFront")}
{renderAction("bringForward")}
{renderAction("bringToFront")}
</div>
</fieldset>
@ -212,6 +238,7 @@ export const SelectedShapeActions = ({
{renderAction("group")}
{renderAction("ungroup")}
{showLinkIcon && renderAction("hyperlink")}
{showLineEditorAction && renderAction("toggleLinearEditor")}
</div>
</fieldset>
)}
@ -306,6 +333,25 @@ export const ShapesSwitcher = ({
title={t("toolBar.extraTools")}
>
{extraToolsIcon}
{app.props.aiEnabled !== false && (
<div
style={{
display: "inline-flex",
marginLeft: "auto",
padding: "2px 4px",
borderRadius: 6,
fontSize: 8,
fontFamily: "Cascadia, monospace",
position: "absolute",
background: "var(--color-promo)",
color: "var(--color-surface-lowest)",
bottom: 3,
right: 4,
}}
>
AI
</div>
)}
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={() => setIsExtraToolsMenuOpen(false)}

File diff suppressed because it is too large Load Diff

View File

@ -9,8 +9,7 @@ type AvatarProps = {
color: string;
name: string;
src?: string;
isBeingFollowed?: boolean;
isCurrentUser: boolean;
className?: string;
};
export const Avatar = ({
@ -18,22 +17,14 @@ export const Avatar = ({
onClick,
name,
src,
isBeingFollowed,
isCurrentUser,
className,
}: AvatarProps) => {
const shortName = getNameInitial(name);
const [error, setError] = useState(false);
const loadImg = !error && src;
const style = loadImg ? undefined : { background: color };
return (
<div
className={clsx("Avatar", {
"Avatar--is-followed": isBeingFollowed,
"Avatar--is-current-user": isCurrentUser,
})}
style={style}
onClick={onClick}
>
<div className={clsx("Avatar", className)} style={style} onClick={onClick}>
{loadImg ? (
<img
className="Avatar-img"

View File

@ -1,10 +1,8 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getColor } from "./ColorPicker";
import { useAtom } from "jotai";
import {
ColorPickerType,
activeColorPickerSectionAtom,
} from "./colorPickerUtils";
import type { ColorPickerType } from "./colorPickerUtils";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import { eyeDropperIcon } from "../icons";
import { jotaiScope } from "../../jotai";
import { KEYS } from "../../keys";

View File

@ -1,16 +1,15 @@
import { isInteractive, isTransparent, isWritableElement } from "../../utils";
import { ExcalidrawElement } from "../../element/types";
import { AppState } from "../../types";
import type { ExcalidrawElement } from "../../element/types";
import type { AppState } from "../../types";
import { TopPicks } from "./TopPicks";
import { Picker } from "./Picker";
import * as Popover from "@radix-ui/react-popover";
import { useAtom } from "jotai";
import {
activeColorPickerSectionAtom,
ColorPickerType,
} from "./colorPickerUtils";
import type { ColorPickerType } from "./colorPickerUtils";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import { useDevice, useExcalidrawContainer } from "../App";
import { ColorTuple, COLOR_PALETTE, ColorPaletteCustom } from "../../colors";
import type { ColorTuple, ColorPaletteCustom } from "../../colors";
import { COLOR_PALETTE } from "../../colors";
import PickerHeading from "./PickerHeading";
import { t } from "../../i18n";
import clsx from "clsx";

View File

@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import { t } from "../../i18n";
import { ExcalidrawElement } from "../../element/types";
import type { ExcalidrawElement } from "../../element/types";
import { ShadeList } from "./ShadeList";
import PickerColorList from "./PickerColorList";
@ -9,15 +9,15 @@ import { useAtom } from "jotai";
import { CustomColorList } from "./CustomColorList";
import { colorPickerKeyNavHandler } from "./keyboardNavHandlers";
import PickerHeading from "./PickerHeading";
import type { ColorPickerType } from "./colorPickerUtils";
import {
ColorPickerType,
activeColorPickerSectionAtom,
getColorNameAndShadeFromColor,
getMostUsedCustomColors,
isCustomColor,
} from "./colorPickerUtils";
import type { ColorPaletteCustom } from "../../colors";
import {
ColorPaletteCustom,
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
} from "../../colors";

View File

@ -7,8 +7,9 @@ import {
getColorNameAndShadeFromColor,
} from "./colorPickerUtils";
import HotkeyLabel from "./HotkeyLabel";
import { ColorPaletteCustom } from "../../colors";
import { TranslationKeys, t } from "../../i18n";
import type { ColorPaletteCustom } from "../../colors";
import type { TranslationKeys } from "../../i18n";
import { t } from "../../i18n";
interface PickerColorListProps {
palette: ColorPaletteCustom;

View File

@ -1,4 +1,4 @@
import { ReactNode } from "react";
import type { ReactNode } from "react";
const PickerHeading = ({ children }: { children: ReactNode }) => (
<div className="color-picker__heading">{children}</div>

View File

@ -7,7 +7,7 @@ import {
} from "./colorPickerUtils";
import HotkeyLabel from "./HotkeyLabel";
import { t } from "../../i18n";
import { ColorPaletteCustom } from "../../colors";
import type { ColorPaletteCustom } from "../../colors";
interface ShadeListProps {
hex: string;

View File

@ -1,5 +1,5 @@
import clsx from "clsx";
import { ColorPickerType } from "./colorPickerUtils";
import type { ColorPickerType } from "./colorPickerUtils";
import {
DEFAULT_CANVAS_BACKGROUND_PICKS,
DEFAULT_ELEMENT_BACKGROUND_PICKS,

View File

@ -1,10 +1,7 @@
import { ExcalidrawElement } from "../../element/types";
import type { ExcalidrawElement } from "../../element/types";
import { atom } from "jotai";
import {
ColorPickerColor,
ColorPaletteCustom,
MAX_CUSTOM_COLORS_USED_IN_CANVAS,
} from "../../colors";
import type { ColorPickerColor, ColorPaletteCustom } from "../../colors";
import { MAX_CUSTOM_COLORS_USED_IN_CANVAS } from "../../colors";
export const getColorNameAndShadeFromColor = ({
palette,

View File

@ -1,14 +1,13 @@
import { KEYS } from "../../keys";
import {
import type {
ColorPickerColor,
ColorPalette,
ColorPaletteCustom,
COLORS_PER_ROW,
COLOR_PALETTE,
} from "../../colors";
import { ValueOf } from "../../utility-types";
import { COLORS_PER_ROW, COLOR_PALETTE } from "../../colors";
import type { ValueOf } from "../../utility-types";
import type { ActiveColorPickerSectionAtomType } from "./colorPickerUtils";
import {
ActiveColorPickerSectionAtomType,
colorPickerHotkeyBindings,
getColorNameAndShadeFromColor,
} from "./colorPickerUtils";

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