mirror of
https://github.com/excalidraw/excalidraw
synced 2025-07-25 13:58:22 +08:00
Compare commits
143 Commits
v0.11.0
...
aakansha-c
Author | SHA1 | Date | |
---|---|---|---|
c4b951a0c5 | |||
c93d8f4bd0 | |||
645f9a5dc0 | |||
b30066ca19 | |||
aae8e2fa5d | |||
9e6d5fdbcb | |||
22b2e10ddb | |||
d53ac2a61e | |||
6a0f800716 | |||
aee1e2451e | |||
da94eb1284 | |||
ea51251fe6 | |||
399ce1e01a | |||
7df8302ba2 | |||
af8c59b5bb | |||
cf0f00285b | |||
b5c67a384c | |||
af93cedc08 | |||
128b7741c1 | |||
b6a6f2d465 | |||
6bcbf8b50a | |||
666516d7e9 | |||
b941c5b661 | |||
8f8c85c64e | |||
116b0c48da | |||
aa2971e8c5 | |||
5656ac1e3e | |||
1edde7291c | |||
1ca56204b1 | |||
f3ae7a8506 | |||
5f57daa132 | |||
e6a9ff1b96 | |||
832b88249c | |||
9902092fd1 | |||
8f0863d335 | |||
9423ac3263 | |||
db9c9eb3d2 | |||
2e8c4d25f2 | |||
4953828d86 | |||
a66cfe2627 | |||
86cf28f2b4 | |||
b5a46dd671 | |||
cd942c3e3b | |||
6eb0cf6a10 | |||
55ccd5b79b | |||
ba48aa24a0 | |||
4e75f10b2c | |||
d2d3599661 | |||
4348c55c31 | |||
a3fbe40b26 | |||
7431ca81d1 | |||
4d13dbf625 | |||
3840e2f4e6 | |||
52d10bb41e | |||
96c87f920a | |||
7d4189c624 | |||
f3e17c90d3 | |||
70b3a9de49 | |||
bf6d0eeef7 | |||
5359e4fec9 | |||
58fe639b8d | |||
327ed0e2d1 | |||
c2fce6d8c4 | |||
cb6b7559b4 | |||
77d789ed8e | |||
89471094ce | |||
670ceafc84 | |||
873afdacd3 | |||
880e4feede | |||
9ba7ca3845 | |||
ed3eda3401 | |||
734bb4d2ed | |||
f2d2f97546 | |||
d27b32dd2c | |||
2fa69ddc32 | |||
2337842f57 | |||
5b78f50fe3 | |||
1331cffe93 | |||
f242721f3b | |||
a4a95a591a | |||
e940aeb1a3 | |||
580e719580 | |||
3d459076fb | |||
127af9db23 | |||
14a23c6c50 | |||
5f4a5b1789 | |||
47498796e0 | |||
8706277d14 | |||
3d0a1106ff | |||
61699ff3c2 | |||
39d0084a5e | |||
2209e2c1e8 | |||
ed31980f84 | |||
db28595302 | |||
cded1cd63d | |||
8e447b4c32 | |||
e29d3fc5e6 | |||
9da56e46f0 | |||
625ecc64ed | |||
ceb43ed8fb | |||
8c0a0415de | |||
192debd829 | |||
1cfb4dfd8b | |||
fb32886355 | |||
065df495ba | |||
558227f744 | |||
6d45430344 | |||
3aa0c5ebc0 | |||
e940993e0e | |||
8f90aeb8d5 | |||
e92d133973 | |||
b682d88167 | |||
7daf1a7944 | |||
5c0eff50a0 | |||
19056d635b | |||
4d5f00ff08 | |||
20de06ef50 | |||
1849ff6ee2 | |||
6765fc16be | |||
5ca4f5bbf4 | |||
9392ec276d | |||
b26e4fcf99 | |||
45f3410da8 | |||
94b387ef7b | |||
6d0716eb6b | |||
8e26d5b500 | |||
c5a7723185 | |||
49172ac2d3 | |||
618a846451 | |||
d9f49ffd67 | |||
46e43baad1 | |||
bd35b682fa | |||
b6f9a8005e | |||
1acfaf6b6e | |||
5cf7087754 | |||
b2d49155ef | |||
9745461db7 | |||
21e9fcb2f5 | |||
e203203993 | |||
f224e4d596 | |||
e0ca689759 | |||
f792eb5ae7 | |||
4604c8d823 |
@ -4,5 +4,10 @@ REACT_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
|
||||
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
||||
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
||||
|
||||
REACT_APP_SOCKET_SERVER_URL=http://localhost:3002
|
||||
# collaboration WebSocket server (https://github.com/excalidraw/excalidraw-room)
|
||||
REACT_APP_WS_SERVER_URL=http://localhost:3002
|
||||
|
||||
# set this only if using the collaboration workflow we use on excalidraw.com
|
||||
REACT_APP_PORTAL_URL=
|
||||
|
||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
|
||||
|
@ -4,7 +4,11 @@ REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
||||
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
||||
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
||||
|
||||
REACT_APP_SOCKET_SERVER_URL=https://oss-collab-us1.excalidraw.com
|
||||
REACT_APP_PORTAL_URL=https://portal.excalidraw.com
|
||||
# Fill to set socket server URL used for collaboration.
|
||||
# Meant for forks only: excalidraw.com uses custom REACT_APP_PORTAL_URL flow
|
||||
REACT_APP_WS_SERVER_URL=
|
||||
|
||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
||||
|
||||
# production-only vars
|
||||
|
29
.github/workflows/build-packages.yml
vendored
29
.github/workflows/build-packages.yml
vendored
@ -1,29 +0,0 @@
|
||||
name: Build packages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
packages:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup Node.js 14.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 14.x
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
yarn --frozen-lockfile
|
||||
yarn --cwd src/packages/excalidraw
|
||||
yarn --cwd src/packages/utils
|
||||
- name: Build @excalidraw/excalidraw
|
||||
run: |
|
||||
yarn --cwd src/packages/excalidraw run pack
|
||||
- name: Build @excalidraw/utils
|
||||
run: |
|
||||
yarn --cwd src/packages/utils run pack
|
47
README.md
47
README.md
@ -32,6 +32,10 @@ Last but not least, we're thankful to these companies for offering their service
|
||||
|
||||
[](https://vercel.com) [](https://sentry.io) [](https://crowdin.com)
|
||||
|
||||
## Who's integrating Excalidraw
|
||||
|
||||
[Google Cloud](https://googlecloudcheatsheet.withgoogle.com/architecture) • [Meta](https://meta.com/) • [CodeSandbox](https://codesandbox.io/) • [Obsidian Excalidraw](https://github.com/zsviczian/obsidian-excalidraw-plugin) • [Replit](https://replit.com/) • [Slite](https://slite.com/) • [Notion](https://notion.so/) • [HackerRank](https://www.hackerrank.com/) •
|
||||
|
||||
## Documentation
|
||||
|
||||
### Shortcuts
|
||||
@ -124,14 +128,41 @@ For collaboration, you will need to set up [collab server](https://github.com/ex
|
||||
|
||||
#### Commands
|
||||
|
||||
| Command | Description |
|
||||
| ------------------ | --------------------------------- |
|
||||
| `yarn` | Install the dependencies |
|
||||
| `yarn start` | Run the project |
|
||||
| `yarn fix` | Reformat all files with Prettier |
|
||||
| `yarn test` | Run tests |
|
||||
| `yarn test:update` | Update test snapshots |
|
||||
| `yarn test:code` | Test for formatting with Prettier |
|
||||
##### Install the dependencies
|
||||
|
||||
```
|
||||
yarn
|
||||
```
|
||||
|
||||
##### Run the project
|
||||
|
||||
```
|
||||
yarn start
|
||||
```
|
||||
|
||||
##### Reformat all files with Prettier
|
||||
|
||||
```
|
||||
yarn fix
|
||||
```
|
||||
|
||||
##### Run tests
|
||||
|
||||
```
|
||||
yarn test
|
||||
```
|
||||
|
||||
##### Update test snapshots
|
||||
|
||||
```
|
||||
yarn test:update
|
||||
```
|
||||
|
||||
##### Test for formatting with Prettier
|
||||
|
||||
```
|
||||
yarn test:code
|
||||
```
|
||||
|
||||
#### Docker Compose
|
||||
|
||||
|
@ -1,12 +1,11 @@
|
||||
rules_version = '2';
|
||||
service firebase.storage {
|
||||
match /b/{bucket}/o {
|
||||
match /{migrations} {
|
||||
match /{scenes}/{scene} {
|
||||
allow get, write: if true;
|
||||
// redundant, but let's be explicit'
|
||||
allow list: if false;
|
||||
}
|
||||
match /{files}/rooms/{room}/{file} {
|
||||
allow get, write: if true;
|
||||
}
|
||||
match /{files}/shareLinks/{shareLink}/{file} {
|
||||
allow get, write: if true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -29,15 +29,16 @@
|
||||
"@types/react": "17.0.39",
|
||||
"@types/react-dom": "17.0.11",
|
||||
"@types/socket.io-client": "1.4.36",
|
||||
"browser-fs-access": "0.23.0",
|
||||
"browser-fs-access": "0.29.1",
|
||||
"clsx": "1.1.1",
|
||||
"fake-indexeddb": "3.1.7",
|
||||
"firebase": "8.3.3",
|
||||
"i18next-browser-languagedetector": "6.1.2",
|
||||
"idb-keyval": "6.0.3",
|
||||
"image-blob-reduce": "3.0.1",
|
||||
"jotai": "1.6.4",
|
||||
"lodash.throttle": "4.1.1",
|
||||
"nanoid": "3.1.32",
|
||||
"nanoid": "3.3.3",
|
||||
"open-color": "1.9.1",
|
||||
"pako": "1.0.11",
|
||||
"perfect-freehand": "1.0.16",
|
||||
@ -65,10 +66,9 @@
|
||||
"dotenv": "10.0.0",
|
||||
"eslint-config-prettier": "8.3.0",
|
||||
"eslint-plugin-prettier": "3.3.1",
|
||||
"firebase-tools": "9.23.0",
|
||||
"husky": "7.0.4",
|
||||
"jest-canvas-mock": "2.3.1",
|
||||
"lint-staged": "12.3.3",
|
||||
"lint-staged": "12.3.7",
|
||||
"pepjs": "0.5.3",
|
||||
"prettier": "2.5.1",
|
||||
"rewire": "5.0.0"
|
||||
|
@ -72,12 +72,6 @@
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
|
||||
<link
|
||||
href="%REACT_APP_SOCKET_SERVER_URL%/socket.io"
|
||||
rel="preconnect"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
|
||||
<link
|
||||
rel="manifest"
|
||||
href="manifest.json"
|
||||
@ -130,26 +124,6 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.LoadingMessage {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.LoadingMessage span {
|
||||
background-color: var(--button-gray-1);
|
||||
border-radius: 5px;
|
||||
padding: 0.8em 1.2em;
|
||||
color: var(--popup-text-color);
|
||||
font-size: 1.3em;
|
||||
}
|
||||
#root {
|
||||
height: 100%;
|
||||
-webkit-touch-callout: none;
|
||||
@ -158,8 +132,10 @@
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1200px) {
|
||||
@media screen and (min-width: 1200px) {
|
||||
#root {
|
||||
-webkit-touch-callout: default;
|
||||
-webkit-user-select: auto;
|
||||
-khtml-user-select: auto;
|
||||
@ -176,10 +152,6 @@
|
||||
<header>
|
||||
<h1 class="visually-hidden">Excalidraw</h1>
|
||||
</header>
|
||||
<div id="root">
|
||||
<div class="LoadingMessage">
|
||||
<span>Loading scene...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -7,6 +7,7 @@ import { t } from "../i18n";
|
||||
|
||||
export const actionAddToLibrary = register({
|
||||
name: "addToLibrary",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
@ -24,9 +25,9 @@ export const actionAddToLibrary = register({
|
||||
}
|
||||
|
||||
return app.library
|
||||
.loadLibrary()
|
||||
.getLatestLibrary()
|
||||
.then((items) => {
|
||||
return app.library.saveLibrary([
|
||||
return app.library.setLibrary([
|
||||
{
|
||||
id: randomId(),
|
||||
status: "unpublished",
|
||||
|
@ -43,6 +43,7 @@ const alignSelectedElements = (
|
||||
|
||||
export const actionAlignTop = register({
|
||||
name: "alignTop",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
@ -72,6 +73,7 @@ export const actionAlignTop = register({
|
||||
|
||||
export const actionAlignBottom = register({
|
||||
name: "alignBottom",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
@ -101,6 +103,7 @@ export const actionAlignBottom = register({
|
||||
|
||||
export const actionAlignLeft = register({
|
||||
name: "alignLeft",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
@ -130,6 +133,8 @@ export const actionAlignLeft = register({
|
||||
|
||||
export const actionAlignRight = register({
|
||||
name: "alignRight",
|
||||
trackEvent: { category: "element" },
|
||||
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
@ -159,6 +164,8 @@ export const actionAlignRight = register({
|
||||
|
||||
export const actionAlignVerticallyCentered = register({
|
||||
name: "alignVerticallyCentered",
|
||||
trackEvent: { category: "element" },
|
||||
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
@ -184,6 +191,7 @@ export const actionAlignVerticallyCentered = register({
|
||||
|
||||
export const actionAlignHorizontallyCentered = register({
|
||||
name: "alignHorizontallyCentered",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
|
136
src/actions/actionBoundText.tsx
Normal file
136
src/actions/actionBoundText.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import { VERTICAL_ALIGN } from "../constants";
|
||||
import { getNonDeletedElements, isTextElement } from "../element";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
measureText,
|
||||
redrawTextBoundingBox,
|
||||
} from "../element/textElement";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isTextBindableContainer,
|
||||
} from "../element/typeChecks";
|
||||
import {
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawTextElement,
|
||||
} from "../element/types";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getFontString } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionUnbindText = register({
|
||||
name: "unbindText",
|
||||
contextItemLabel: "labels.unbindText",
|
||||
trackEvent: { category: "element" },
|
||||
contextItemPredicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
return selectedElements.some((element) => hasBoundTextElement(element));
|
||||
},
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
selectedElements.forEach((element) => {
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
const { width, height, baseline } = measureText(
|
||||
boundTextElement.originalText,
|
||||
getFontString(boundTextElement),
|
||||
);
|
||||
mutateElement(boundTextElement as ExcalidrawTextElement, {
|
||||
containerId: null,
|
||||
width,
|
||||
height,
|
||||
baseline,
|
||||
text: boundTextElement.originalText,
|
||||
});
|
||||
mutateElement(element, {
|
||||
boundElements: element.boundElements?.filter(
|
||||
(ele) => ele.id !== boundTextElement.id,
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
return {
|
||||
elements,
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const actionBindText = register({
|
||||
name: "bindText",
|
||||
contextItemLabel: "labels.bindText",
|
||||
trackEvent: { category: "element" },
|
||||
contextItemPredicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
|
||||
if (selectedElements.length === 2) {
|
||||
const textElement =
|
||||
isTextElement(selectedElements[0]) ||
|
||||
isTextElement(selectedElements[1]);
|
||||
|
||||
let bindingContainer;
|
||||
if (isTextBindableContainer(selectedElements[0])) {
|
||||
bindingContainer = selectedElements[0];
|
||||
} else if (isTextBindableContainer(selectedElements[1])) {
|
||||
bindingContainer = selectedElements[1];
|
||||
}
|
||||
if (
|
||||
textElement &&
|
||||
bindingContainer &&
|
||||
getBoundTextElement(bindingContainer) === null
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
|
||||
let textElement: ExcalidrawTextElement;
|
||||
let container: ExcalidrawTextContainer;
|
||||
|
||||
if (
|
||||
isTextElement(selectedElements[0]) &&
|
||||
isTextBindableContainer(selectedElements[1])
|
||||
) {
|
||||
textElement = selectedElements[0];
|
||||
container = selectedElements[1];
|
||||
} else {
|
||||
textElement = selectedElements[1] as ExcalidrawTextElement;
|
||||
container = selectedElements[0] as ExcalidrawTextContainer;
|
||||
}
|
||||
mutateElement(textElement, {
|
||||
containerId: container.id,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
});
|
||||
mutateElement(container, {
|
||||
boundElements: (container.boundElements || []).concat({
|
||||
type: "text",
|
||||
id: textElement.id,
|
||||
}),
|
||||
});
|
||||
redrawTextBoundingBox(textElement, container);
|
||||
const updatedElements = elements.slice();
|
||||
const textElementIndex = updatedElements.findIndex(
|
||||
(ele) => ele.id === textElement.id,
|
||||
);
|
||||
updatedElements.splice(textElementIndex, 1);
|
||||
const containerIndex = updatedElements.findIndex(
|
||||
(ele) => ele.id === container.id,
|
||||
);
|
||||
updatedElements.splice(containerIndex + 1, 0, textElement);
|
||||
return {
|
||||
elements: updatedElements,
|
||||
appState: { ...appState, selectedElementIds: { [container.id]: true } },
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
});
|
@ -1,5 +1,5 @@
|
||||
import { ColorPicker } from "../components/ColorPicker";
|
||||
import { zoomIn, zoomOut } from "../components/icons";
|
||||
import { eraser, zoomIn, zoomOut } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||
import { THEME, ZOOM_STEP } from "../constants";
|
||||
@ -15,18 +15,20 @@ import { getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { getDefaultAppState, isEraserActive } from "../appState";
|
||||
import ClearCanvas from "../components/ClearCanvas";
|
||||
import clsx from "clsx";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
name: "changeViewBackgroundColor",
|
||||
trackEvent: false,
|
||||
perform: (_, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, ...value },
|
||||
commitToHistory: !!value.viewBackgroundColor,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => {
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<ColorPicker
|
||||
@ -39,6 +41,8 @@ export const actionChangeViewBackgroundColor = register({
|
||||
updateData({ openPopup: active ? "canvasColorPicker" : null })
|
||||
}
|
||||
data-testid="canvas-background-picker"
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@ -47,6 +51,7 @@ export const actionChangeViewBackgroundColor = register({
|
||||
|
||||
export const actionClearCanvas = register({
|
||||
name: "clearCanvas",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
app.imageCache.clear();
|
||||
return {
|
||||
@ -57,7 +62,6 @@ export const actionClearCanvas = register({
|
||||
...getDefaultAppState(),
|
||||
files: {},
|
||||
theme: appState.theme,
|
||||
elementLocked: appState.elementLocked,
|
||||
penMode: appState.penMode,
|
||||
penDetected: appState.penDetected,
|
||||
exportBackground: appState.exportBackground,
|
||||
@ -65,8 +69,10 @@ export const actionClearCanvas = register({
|
||||
gridSize: appState.gridSize,
|
||||
showStats: appState.showStats,
|
||||
pasteDialog: appState.pasteDialog,
|
||||
elementType:
|
||||
appState.elementType === "image" ? "selection" : appState.elementType,
|
||||
activeTool:
|
||||
appState.activeTool.type === "image"
|
||||
? { ...appState.activeTool, type: "selection" }
|
||||
: appState.activeTool,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
@ -77,6 +83,7 @@ export const actionClearCanvas = register({
|
||||
|
||||
export const actionZoomIn = register({
|
||||
name: "zoomIn",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
return {
|
||||
appState: {
|
||||
@ -112,6 +119,7 @@ export const actionZoomIn = register({
|
||||
|
||||
export const actionZoomOut = register({
|
||||
name: "zoomOut",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
return {
|
||||
appState: {
|
||||
@ -147,6 +155,7 @@ export const actionZoomOut = register({
|
||||
|
||||
export const actionResetZoom = register({
|
||||
name: "resetZoom",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
return {
|
||||
appState: {
|
||||
@ -245,6 +254,7 @@ const zoomToFitElements = (
|
||||
|
||||
export const actionZoomToSelected = register({
|
||||
name: "zoomToSelection",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => zoomToFitElements(elements, appState, true),
|
||||
keyTest: (event) =>
|
||||
event.code === CODES.TWO &&
|
||||
@ -255,6 +265,7 @@ export const actionZoomToSelected = register({
|
||||
|
||||
export const actionZoomToFit = register({
|
||||
name: "zoomToFit",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => zoomToFitElements(elements, appState, false),
|
||||
keyTest: (event) =>
|
||||
event.code === CODES.ONE &&
|
||||
@ -265,6 +276,7 @@ export const actionZoomToFit = register({
|
||||
|
||||
export const actionToggleTheme = register({
|
||||
name: "toggleTheme",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_, appState, value) => {
|
||||
return {
|
||||
appState: {
|
||||
@ -287,3 +299,63 @@ export const actionToggleTheme = register({
|
||||
),
|
||||
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
|
||||
});
|
||||
|
||||
export const actionErase = register({
|
||||
name: "eraser",
|
||||
trackEvent: { category: "toolbar" },
|
||||
perform: (elements, appState) => {
|
||||
const activeTool: any = { ...appState.activeTool };
|
||||
|
||||
if (appState.activeTool.type !== "eraser") {
|
||||
if (appState.activeTool.type === "custom") {
|
||||
activeTool.lastActiveToolBeforeEraser = {
|
||||
type: "custom",
|
||||
customType: appState.activeTool.customType,
|
||||
};
|
||||
} else {
|
||||
activeTool.lastActiveToolBeforeEraser = appState.activeTool.type;
|
||||
}
|
||||
}
|
||||
if (isEraserActive(appState)) {
|
||||
if (appState.activeTool.lastActiveToolBeforeEraser) {
|
||||
if (
|
||||
typeof appState.activeTool.lastActiveToolBeforeEraser === "object" &&
|
||||
appState.activeTool.lastActiveToolBeforeEraser?.type === "custom"
|
||||
) {
|
||||
activeTool.type = "custom";
|
||||
activeTool.customType =
|
||||
appState.activeTool.lastActiveToolBeforeEraser.customType;
|
||||
} else {
|
||||
activeTool.type = appState.activeTool.lastActiveToolBeforeEraser;
|
||||
}
|
||||
} else {
|
||||
activeTool.type = "selection";
|
||||
}
|
||||
} else {
|
||||
activeTool.type = "eraser";
|
||||
}
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
activeTool,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.key === KEYS.E,
|
||||
PanelComponent: ({ elements, appState, updateData, data }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={eraser}
|
||||
className={clsx("eraser", { active: isEraserActive(appState) })}
|
||||
title={`${t("toolBar.eraser")}-${getShortcutKey("E")}`}
|
||||
aria-label={t("toolBar.eraser")}
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
size={data?.size || "medium"}
|
||||
></ToolButton>
|
||||
),
|
||||
});
|
||||
|
@ -1,16 +1,23 @@
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { copyToClipboard } from "../clipboard";
|
||||
import {
|
||||
copyTextToSystemClipboard,
|
||||
copyToClipboard,
|
||||
probablySupportsClipboardWriteText,
|
||||
} from "../clipboard";
|
||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||
import { getSelectedElements } from "../scene/selection";
|
||||
import { exportCanvas } from "../data/index";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { getNonDeletedElements, isTextElement } from "../element";
|
||||
import { t } from "../i18n";
|
||||
|
||||
export const actionCopy = register({
|
||||
name: "copy",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
copyToClipboard(getNonDeletedElements(elements), appState, app.files);
|
||||
const selectedElements = getSelectedElements(elements, appState, true);
|
||||
|
||||
copyToClipboard(selectedElements, appState, app.files);
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
@ -23,6 +30,7 @@ export const actionCopy = register({
|
||||
|
||||
export const actionCut = register({
|
||||
name: "cut",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, data, app) => {
|
||||
actionCopy.perform(elements, appState, data, app);
|
||||
return actionDeleteSelected.perform(elements, appState);
|
||||
@ -33,6 +41,7 @@ export const actionCut = register({
|
||||
|
||||
export const actionCopyAsSvg = register({
|
||||
name: "copyAsSvg",
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, _data, app) => {
|
||||
if (!app.canvas) {
|
||||
return {
|
||||
@ -73,6 +82,7 @@ export const actionCopyAsSvg = register({
|
||||
|
||||
export const actionCopyAsPng = register({
|
||||
name: "copyAsPng",
|
||||
trackEvent: { category: "element" },
|
||||
perform: async (elements, appState, _data, app) => {
|
||||
if (!app.canvas) {
|
||||
return {
|
||||
@ -122,3 +132,35 @@ export const actionCopyAsPng = register({
|
||||
contextItemLabel: "labels.copyAsPng",
|
||||
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
|
||||
});
|
||||
|
||||
export const copyText = register({
|
||||
name: "copyText",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
);
|
||||
|
||||
const text = selectedElements
|
||||
.reduce((acc: string[], element) => {
|
||||
if (isTextElement(element)) {
|
||||
acc.push(element.text);
|
||||
}
|
||||
return acc;
|
||||
}, [])
|
||||
.join("\n\n");
|
||||
copyTextToSystemClipboard(text);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemPredicate: (elements, appState) => {
|
||||
return (
|
||||
probablySupportsClipboardWriteText &&
|
||||
getSelectedElements(elements, appState, true).some(isTextElement)
|
||||
);
|
||||
},
|
||||
contextItemLabel: "labels.copyText",
|
||||
});
|
||||
|
@ -58,6 +58,7 @@ const handleGroupEditingState = (
|
||||
|
||||
export const actionDeleteSelected = register({
|
||||
name: "deleteSelectedElements",
|
||||
trackEvent: { category: "element", action: "delete" },
|
||||
perform: (elements, appState) => {
|
||||
if (appState.editingLinearElement) {
|
||||
const {
|
||||
@ -133,7 +134,7 @@ export const actionDeleteSelected = register({
|
||||
elements: nextElements,
|
||||
appState: {
|
||||
...nextAppState,
|
||||
elementType: "selection",
|
||||
activeTool: { ...appState.activeTool, type: "selection" },
|
||||
multiElement: null,
|
||||
},
|
||||
commitToHistory: isSomeElementSelected(
|
||||
|
@ -7,7 +7,7 @@ import { distributeElements, Distribution } from "../disitrubte";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { CODES } from "../keys";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { AppState } from "../types";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
@ -39,6 +39,7 @@ const distributeSelectedElements = (
|
||||
|
||||
export const distributeHorizontally = register({
|
||||
name: "distributeHorizontally",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
@ -49,7 +50,8 @@ export const distributeHorizontally = register({
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.altKey && event.code === CODES.H,
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.H,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
@ -67,6 +69,7 @@ export const distributeHorizontally = register({
|
||||
|
||||
export const distributeVertically = register({
|
||||
name: "distributeVertically",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
@ -77,7 +80,8 @@ export const distributeVertically = register({
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.altKey && event.code === CODES.V,
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
|
@ -22,6 +22,7 @@ import { isBoundToContainer } from "../element/typeChecks";
|
||||
|
||||
export const actionDuplicateSelection = register({
|
||||
name: "duplicateSelection",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
// duplicate selected point(s) if editing a line
|
||||
if (appState.editingLinearElement) {
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { trackEvent } from "../analytics";
|
||||
import { load, questionCircle, saveAs } from "../components/icons";
|
||||
import { ProjectName } from "../components/ProjectName";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
@ -8,7 +7,7 @@ import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||
import { loadFromJSON, saveAsJSON } from "../data";
|
||||
import { resaveAsImageWithScene } from "../data/resave";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { useDeviceType } from "../components/App";
|
||||
import { KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { CheckboxItem } from "../components/CheckboxItem";
|
||||
@ -23,8 +22,8 @@ import { Theme } from "../element/types";
|
||||
|
||||
export const actionChangeProjectName = register({
|
||||
name: "changeProjectName",
|
||||
trackEvent: false,
|
||||
perform: (_elements, appState, value) => {
|
||||
trackEvent("change", "title");
|
||||
return { appState: { ...appState, name: value }, commitToHistory: false };
|
||||
},
|
||||
PanelComponent: ({ appState, updateData, appProps }) => (
|
||||
@ -41,6 +40,7 @@ export const actionChangeProjectName = register({
|
||||
|
||||
export const actionChangeExportScale = register({
|
||||
name: "changeExportScale",
|
||||
trackEvent: { category: "export", action: "scale" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportScale: value },
|
||||
@ -89,6 +89,7 @@ export const actionChangeExportScale = register({
|
||||
|
||||
export const actionChangeExportBackground = register({
|
||||
name: "changeExportBackground",
|
||||
trackEvent: { category: "export", action: "toggleBackground" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportBackground: value },
|
||||
@ -107,6 +108,7 @@ export const actionChangeExportBackground = register({
|
||||
|
||||
export const actionChangeExportEmbedScene = register({
|
||||
name: "changeExportEmbedScene",
|
||||
trackEvent: { category: "export", action: "embedScene" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportEmbedScene: value },
|
||||
@ -128,6 +130,7 @@ export const actionChangeExportEmbedScene = register({
|
||||
|
||||
export const actionSaveToActiveFile = register({
|
||||
name: "saveToActiveFile",
|
||||
trackEvent: { category: "export" },
|
||||
perform: async (elements, appState, value, app) => {
|
||||
const fileHandleExists = !!appState.fileHandle;
|
||||
|
||||
@ -172,6 +175,7 @@ export const actionSaveToActiveFile = register({
|
||||
|
||||
export const actionSaveFileToDisk = register({
|
||||
name: "saveFileToDisk",
|
||||
trackEvent: { category: "export" },
|
||||
perform: async (elements, appState, value, app) => {
|
||||
try {
|
||||
const { fileHandle } = await saveAsJSON(
|
||||
@ -200,7 +204,7 @@ export const actionSaveFileToDisk = register({
|
||||
icon={saveAs}
|
||||
title={t("buttons.saveAs")}
|
||||
aria-label={t("buttons.saveAs")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
showAriaLabel={useDeviceType().isMobile}
|
||||
hidden={!nativeFileSystemSupported}
|
||||
onClick={() => updateData(null)}
|
||||
data-testid="save-as-button"
|
||||
@ -210,6 +214,7 @@ export const actionSaveFileToDisk = register({
|
||||
|
||||
export const actionLoadScene = register({
|
||||
name: "loadScene",
|
||||
trackEvent: { category: "export" },
|
||||
perform: async (elements, appState, _, app) => {
|
||||
try {
|
||||
const {
|
||||
@ -243,7 +248,7 @@ export const actionLoadScene = register({
|
||||
icon={load}
|
||||
title={t("buttons.load")}
|
||||
aria-label={t("buttons.load")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
showAriaLabel={useDeviceType().isMobile}
|
||||
onClick={updateData}
|
||||
data-testid="load-button"
|
||||
/>
|
||||
@ -252,6 +257,7 @@ export const actionLoadScene = register({
|
||||
|
||||
export const actionExportWithDarkMode = register({
|
||||
name: "exportWithDarkMode",
|
||||
trackEvent: { category: "export", action: "toggleTheme" },
|
||||
perform: (_elements, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, exportWithDarkMode: value },
|
||||
|
@ -17,6 +17,7 @@ import { isBindingElement } from "../element/typeChecks";
|
||||
|
||||
export const actionFinalize = register({
|
||||
name: "finalize",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, _, { canvas, focusContainer }) => {
|
||||
if (appState.editingLinearElement) {
|
||||
const { elementId, startBindingElement, endBindingElement } =
|
||||
@ -38,6 +39,7 @@ export const actionFinalize = register({
|
||||
: undefined,
|
||||
appState: {
|
||||
...appState,
|
||||
cursorButton: "up",
|
||||
editingLinearElement: null,
|
||||
},
|
||||
commitToHistory: true,
|
||||
@ -119,27 +121,47 @@ export const actionFinalize = register({
|
||||
);
|
||||
}
|
||||
|
||||
if (!appState.elementLocked && appState.elementType !== "freedraw") {
|
||||
if (
|
||||
!appState.activeTool.locked &&
|
||||
appState.activeTool.type !== "freedraw"
|
||||
) {
|
||||
appState.selectedElementIds[multiPointElement.id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(!appState.elementLocked && appState.elementType !== "freedraw") ||
|
||||
(!appState.activeTool.locked &&
|
||||
appState.activeTool.type !== "freedraw") ||
|
||||
!multiPointElement
|
||||
) {
|
||||
resetCursor(canvas);
|
||||
}
|
||||
|
||||
const activeTool: any = { ...appState.activeTool };
|
||||
if (appState.activeTool.lastActiveToolBeforeEraser) {
|
||||
if (
|
||||
typeof appState.activeTool.lastActiveToolBeforeEraser === "object" &&
|
||||
appState.activeTool.lastActiveToolBeforeEraser.type === "custom"
|
||||
) {
|
||||
activeTool.type = appState.activeTool.lastActiveToolBeforeEraser.type;
|
||||
activeTool.customType =
|
||||
appState.activeTool.lastActiveToolBeforeEraser.customType;
|
||||
} else {
|
||||
activeTool.type = appState.activeTool.lastActiveToolBeforeEraser;
|
||||
}
|
||||
} else {
|
||||
activeTool.type = "selection";
|
||||
}
|
||||
return {
|
||||
elements: newElements,
|
||||
appState: {
|
||||
...appState,
|
||||
elementType:
|
||||
(appState.elementLocked || appState.elementType === "freedraw") &&
|
||||
cursorButton: "up",
|
||||
activeTool:
|
||||
(appState.activeTool.locked ||
|
||||
appState.activeTool.type === "freedraw") &&
|
||||
multiPointElement
|
||||
? appState.elementType
|
||||
: "selection",
|
||||
? appState.activeTool
|
||||
: activeTool,
|
||||
draggingElement: null,
|
||||
multiElement: null,
|
||||
editingElement: null,
|
||||
@ -147,8 +169,8 @@ export const actionFinalize = register({
|
||||
suggestedBindings: [],
|
||||
selectedElementIds:
|
||||
multiPointElement &&
|
||||
!appState.elementLocked &&
|
||||
appState.elementType !== "freedraw"
|
||||
!appState.activeTool.locked &&
|
||||
appState.activeTool.type !== "freedraw"
|
||||
? {
|
||||
...appState.selectedElementIds,
|
||||
[multiPointElement.id]: true,
|
||||
@ -156,7 +178,7 @@ export const actionFinalize = register({
|
||||
: appState.selectedElementIds,
|
||||
pendingImageElement: null,
|
||||
},
|
||||
commitToHistory: appState.elementType === "freedraw",
|
||||
commitToHistory: appState.activeTool.type === "freedraw",
|
||||
};
|
||||
},
|
||||
keyTest: (event, appState) =>
|
||||
@ -165,7 +187,7 @@ export const actionFinalize = register({
|
||||
(!appState.draggingElement && appState.multiElement === null))) ||
|
||||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
||||
appState.multiElement !== null),
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
PanelComponent: ({ appState, updateData, data }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={done}
|
||||
@ -173,6 +195,7 @@ export const actionFinalize = register({
|
||||
aria-label={t("buttons.done")}
|
||||
onClick={updateData}
|
||||
visible={appState.multiElement != null}
|
||||
size={data?.size || "medium"}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
@ -35,6 +35,7 @@ const enableActionFlipVertical = (
|
||||
|
||||
export const actionFlipHorizontal = register({
|
||||
name: "flipHorizontal",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: flipSelectedElements(elements, appState, "horizontal"),
|
||||
@ -50,6 +51,7 @@ export const actionFlipHorizontal = register({
|
||||
|
||||
export const actionFlipVertical = register({
|
||||
name: "flipVertical",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: flipSelectedElements(elements, appState, "vertical"),
|
||||
@ -155,7 +157,7 @@ const flipElement = (
|
||||
// calculate new x-coord for transformation
|
||||
newNCoordsX = usingNWHandle ? element.x + 2 * width : element.x - 2 * width;
|
||||
resizeSingleElement(
|
||||
element,
|
||||
new Map().set(element.id, element),
|
||||
true,
|
||||
element,
|
||||
usingNWHandle ? "nw" : "ne",
|
||||
|
@ -54,6 +54,7 @@ const enableActionGroup = (
|
||||
|
||||
export const actionGroup = register({
|
||||
name: "group",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
@ -147,6 +148,7 @@ export const actionGroup = register({
|
||||
|
||||
export const actionUngroup = register({
|
||||
name: "ungroup",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
const groupIds = getSelectedGroupIds(appState);
|
||||
if (groupIds.length === 0) {
|
||||
|
@ -62,6 +62,7 @@ type ActionCreator = (history: History) => Action;
|
||||
|
||||
export const createUndoAction: ActionCreator = (history) => ({
|
||||
name: "undo",
|
||||
trackEvent: { category: "history" },
|
||||
perform: (elements, appState) =>
|
||||
writeData(elements, appState, () => history.undoOnce()),
|
||||
keyTest: (event) =>
|
||||
@ -82,6 +83,7 @@ export const createUndoAction: ActionCreator = (history) => ({
|
||||
|
||||
export const createRedoAction: ActionCreator = (history) => ({
|
||||
name: "redo",
|
||||
trackEvent: { category: "history" },
|
||||
perform: (elements, appState) =>
|
||||
writeData(elements, appState, () => history.redoOnce()),
|
||||
keyTest: (event) =>
|
||||
|
@ -9,6 +9,7 @@ import { HelpIcon } from "../components/HelpIcon";
|
||||
|
||||
export const actionToggleCanvasMenu = register({
|
||||
name: "toggleCanvasMenu",
|
||||
trackEvent: { category: "menu" },
|
||||
perform: (_, appState) => ({
|
||||
appState: {
|
||||
...appState,
|
||||
@ -29,6 +30,7 @@ export const actionToggleCanvasMenu = register({
|
||||
|
||||
export const actionToggleEditMenu = register({
|
||||
name: "toggleEditMenu",
|
||||
trackEvent: { category: "menu" },
|
||||
perform: (_elements, appState) => ({
|
||||
appState: {
|
||||
...appState,
|
||||
@ -53,6 +55,7 @@ export const actionToggleEditMenu = register({
|
||||
|
||||
export const actionFullScreen = register({
|
||||
name: "toggleFullScreen",
|
||||
trackEvent: { category: "canvas", predicate: (appState) => !isFullScreen() },
|
||||
perform: () => {
|
||||
if (!isFullScreen()) {
|
||||
allowFullScreen();
|
||||
@ -69,6 +72,7 @@ export const actionFullScreen = register({
|
||||
|
||||
export const actionShortcuts = register({
|
||||
name: "toggleShortcuts",
|
||||
trackEvent: { category: "menu", action: "toggleHelpDialog" },
|
||||
perform: (_elements, appState, _, { focusContainer }) => {
|
||||
if (appState.showHelpDialog) {
|
||||
focusContainer();
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { getClientColors, getClientInitials } from "../clients";
|
||||
import { getClientColors } from "../clients";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { Collaborator } from "../types";
|
||||
@ -6,6 +6,7 @@ import { register } from "./register";
|
||||
|
||||
export const actionGoToCollaborator = register({
|
||||
name: "goToCollaborator",
|
||||
trackEvent: { category: "collab" },
|
||||
perform: (_elements, appState, value) => {
|
||||
const point = value as Collaborator["pointer"];
|
||||
if (!point) {
|
||||
@ -42,16 +43,15 @@ export const actionGoToCollaborator = register({
|
||||
}
|
||||
|
||||
const { background, stroke } = getClientColors(clientId, appState);
|
||||
const shortName = getClientInitials(collaborator.username);
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
color={background}
|
||||
border={stroke}
|
||||
onClick={() => updateData(collaborator.pointer)}
|
||||
>
|
||||
{shortName}
|
||||
</Avatar>
|
||||
name={collaborator.username || ""}
|
||||
src={collaborator.src}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
@ -30,11 +30,15 @@ import {
|
||||
TextAlignCenterIcon,
|
||||
TextAlignLeftIcon,
|
||||
TextAlignRightIcon,
|
||||
TextAlignTopIcon,
|
||||
TextAlignBottomIcon,
|
||||
TextAlignMiddleIcon,
|
||||
} from "../components/icons";
|
||||
import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
FONT_FAMILY,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import {
|
||||
getNonDeletedElements,
|
||||
@ -58,6 +62,7 @@ import {
|
||||
ExcalidrawTextElement,
|
||||
FontFamilyValues,
|
||||
TextAlign,
|
||||
VerticalAlign,
|
||||
} from "../element/types";
|
||||
import { getLanguage, t } from "../i18n";
|
||||
import { KEYS } from "../keys";
|
||||
@ -161,11 +166,7 @@ const changeFontSize = (
|
||||
let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
|
||||
fontSize: newFontSize,
|
||||
});
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
getContainerElement(oldElement),
|
||||
appState,
|
||||
);
|
||||
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
||||
|
||||
newElement = offsetElementAfterFontResize(oldElement, newElement);
|
||||
|
||||
@ -193,6 +194,7 @@ const changeFontSize = (
|
||||
|
||||
export const actionChangeStrokeColor = register({
|
||||
name: "changeStrokeColor",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
...(value.currentItemStrokeColor && {
|
||||
@ -233,6 +235,8 @@ export const actionChangeStrokeColor = register({
|
||||
setActive={(active) =>
|
||||
updateData({ openPopup: active ? "strokeColorPicker" : null })
|
||||
}
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
@ -240,6 +244,7 @@ export const actionChangeStrokeColor = register({
|
||||
|
||||
export const actionChangeBackgroundColor = register({
|
||||
name: "changeBackgroundColor",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
...(value.currentItemBackgroundColor && {
|
||||
@ -273,6 +278,8 @@ export const actionChangeBackgroundColor = register({
|
||||
setActive={(active) =>
|
||||
updateData({ openPopup: active ? "backgroundColorPicker" : null })
|
||||
}
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
@ -280,6 +287,7 @@ export const actionChangeBackgroundColor = register({
|
||||
|
||||
export const actionChangeFillStyle = register({
|
||||
name: "changeFillStyle",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
@ -329,6 +337,7 @@ export const actionChangeFillStyle = register({
|
||||
|
||||
export const actionChangeStrokeWidth = register({
|
||||
name: "changeStrokeWidth",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
@ -376,6 +385,7 @@ export const actionChangeStrokeWidth = register({
|
||||
|
||||
export const actionChangeSloppiness = register({
|
||||
name: "changeSloppiness",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
@ -424,6 +434,7 @@ export const actionChangeSloppiness = register({
|
||||
|
||||
export const actionChangeStrokeStyle = register({
|
||||
name: "changeStrokeStyle",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
@ -471,6 +482,7 @@ export const actionChangeStrokeStyle = register({
|
||||
|
||||
export const actionChangeOpacity = register({
|
||||
name: "changeOpacity",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
@ -491,20 +503,6 @@ export const actionChangeOpacity = register({
|
||||
max="100"
|
||||
step="10"
|
||||
onChange={(event) => updateData(+event.target.value)}
|
||||
onWheel={(event) => {
|
||||
event.stopPropagation();
|
||||
const target = event.target as HTMLInputElement;
|
||||
const STEP = 10;
|
||||
const MAX = 100;
|
||||
const MIN = 0;
|
||||
const value = +target.value;
|
||||
|
||||
if (event.deltaY < 0 && value < MAX) {
|
||||
updateData(value + STEP);
|
||||
} else if (event.deltaY > 0 && value > MIN) {
|
||||
updateData(value - STEP);
|
||||
}
|
||||
}}
|
||||
value={
|
||||
getFormValue(
|
||||
elements,
|
||||
@ -520,6 +518,7 @@ export const actionChangeOpacity = register({
|
||||
|
||||
export const actionChangeFontSize = register({
|
||||
name: "changeFontSize",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return changeFontSize(elements, appState, () => value, value);
|
||||
},
|
||||
@ -577,6 +576,7 @@ export const actionChangeFontSize = register({
|
||||
|
||||
export const actionDecreaseFontSize = register({
|
||||
name: "decreaseFontSize",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return changeFontSize(elements, appState, (element) =>
|
||||
Math.round(
|
||||
@ -598,6 +598,7 @@ export const actionDecreaseFontSize = register({
|
||||
|
||||
export const actionIncreaseFontSize = register({
|
||||
name: "increaseFontSize",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return changeFontSize(elements, appState, (element) =>
|
||||
Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
|
||||
@ -615,6 +616,7 @@ export const actionIncreaseFontSize = register({
|
||||
|
||||
export const actionChangeFontFamily = register({
|
||||
name: "changeFontFamily",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(
|
||||
@ -628,11 +630,7 @@ export const actionChangeFontFamily = register({
|
||||
fontFamily: value,
|
||||
},
|
||||
);
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
getContainerElement(oldElement),
|
||||
appState,
|
||||
);
|
||||
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
||||
return newElement;
|
||||
}
|
||||
|
||||
@ -700,6 +698,7 @@ export const actionChangeFontFamily = register({
|
||||
|
||||
export const actionChangeTextAlign = register({
|
||||
name: "changeTextAlign",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(
|
||||
@ -709,15 +708,9 @@ export const actionChangeTextAlign = register({
|
||||
if (isTextElement(oldElement)) {
|
||||
const newElement: ExcalidrawTextElement = newElementWith(
|
||||
oldElement,
|
||||
{
|
||||
textAlign: value,
|
||||
},
|
||||
);
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
getContainerElement(oldElement),
|
||||
appState,
|
||||
{ textAlign: value },
|
||||
);
|
||||
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
||||
return newElement;
|
||||
}
|
||||
|
||||
@ -732,51 +725,121 @@ export const actionChangeTextAlign = register({
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<fieldset>
|
||||
<legend>{t("labels.textAlign")}</legend>
|
||||
<ButtonIconSelect<TextAlign | false>
|
||||
group="text-align"
|
||||
options={[
|
||||
{
|
||||
value: "left",
|
||||
text: t("labels.left"),
|
||||
icon: <TextAlignLeftIcon theme={appState.theme} />,
|
||||
},
|
||||
{
|
||||
value: "center",
|
||||
text: t("labels.center"),
|
||||
icon: <TextAlignCenterIcon theme={appState.theme} />,
|
||||
},
|
||||
{
|
||||
value: "right",
|
||||
text: t("labels.right"),
|
||||
icon: <TextAlignRightIcon theme={appState.theme} />,
|
||||
},
|
||||
]}
|
||||
value={getFormValue(
|
||||
elements,
|
||||
appState,
|
||||
(element) => {
|
||||
if (isTextElement(element)) {
|
||||
return element.textAlign;
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>{t("labels.textAlign")}</legend>
|
||||
<ButtonIconSelect<TextAlign | false>
|
||||
group="text-align"
|
||||
options={[
|
||||
{
|
||||
value: "left",
|
||||
text: t("labels.left"),
|
||||
icon: <TextAlignLeftIcon theme={appState.theme} />,
|
||||
},
|
||||
{
|
||||
value: "center",
|
||||
text: t("labels.center"),
|
||||
icon: <TextAlignCenterIcon theme={appState.theme} />,
|
||||
},
|
||||
{
|
||||
value: "right",
|
||||
text: t("labels.right"),
|
||||
icon: <TextAlignRightIcon theme={appState.theme} />,
|
||||
},
|
||||
]}
|
||||
value={getFormValue(
|
||||
elements,
|
||||
appState,
|
||||
(element) => {
|
||||
if (isTextElement(element)) {
|
||||
return element.textAlign;
|
||||
}
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
return boundTextElement.textAlign;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
appState.currentItemTextAlign,
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
</fieldset>
|
||||
);
|
||||
},
|
||||
});
|
||||
export const actionChangeVerticalAlign = register({
|
||||
name: "changeVerticalAlign",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(
|
||||
elements,
|
||||
appState,
|
||||
(oldElement) => {
|
||||
if (isTextElement(oldElement)) {
|
||||
const newElement: ExcalidrawTextElement = newElementWith(
|
||||
oldElement,
|
||||
{ verticalAlign: value },
|
||||
);
|
||||
|
||||
redrawTextBoundingBox(newElement, getContainerElement(oldElement));
|
||||
return newElement;
|
||||
}
|
||||
|
||||
return oldElement;
|
||||
},
|
||||
true,
|
||||
),
|
||||
appState: {
|
||||
...appState,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
return (
|
||||
<fieldset>
|
||||
<ButtonIconSelect<VerticalAlign | false>
|
||||
group="text-align"
|
||||
options={[
|
||||
{
|
||||
value: VERTICAL_ALIGN.TOP,
|
||||
text: t("labels.alignTop"),
|
||||
icon: <TextAlignTopIcon theme={appState.theme} />,
|
||||
},
|
||||
{
|
||||
value: VERTICAL_ALIGN.MIDDLE,
|
||||
text: t("labels.centerVertically"),
|
||||
icon: <TextAlignMiddleIcon theme={appState.theme} />,
|
||||
},
|
||||
{
|
||||
value: VERTICAL_ALIGN.BOTTOM,
|
||||
text: t("labels.alignBottom"),
|
||||
icon: <TextAlignBottomIcon theme={appState.theme} />,
|
||||
},
|
||||
]}
|
||||
value={getFormValue(elements, appState, (element) => {
|
||||
if (isTextElement(element) && element.containerId) {
|
||||
return element.verticalAlign;
|
||||
}
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
return boundTextElement.textAlign;
|
||||
return boundTextElement.verticalAlign;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
appState.currentItemTextAlign,
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
</fieldset>
|
||||
),
|
||||
})}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
</fieldset>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const actionChangeSharpness = register({
|
||||
name: "changeSharpness",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
const targetElements = getTargetElements(
|
||||
getNonDeletedElements(elements),
|
||||
@ -784,10 +847,10 @@ export const actionChangeSharpness = register({
|
||||
);
|
||||
const shouldUpdateForNonLinearElements = targetElements.length
|
||||
? targetElements.every((el) => !isLinearElement(el))
|
||||
: !isLinearElementType(appState.elementType);
|
||||
: !isLinearElementType(appState.activeTool.type);
|
||||
const shouldUpdateForLinearElements = targetElements.length
|
||||
? targetElements.every(isLinearElement)
|
||||
: isLinearElementType(appState.elementType);
|
||||
: isLinearElementType(appState.activeTool.type);
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
@ -827,8 +890,8 @@ export const actionChangeSharpness = register({
|
||||
elements,
|
||||
appState,
|
||||
(element) => element.strokeSharpness,
|
||||
(canChangeSharpness(appState.elementType) &&
|
||||
(isLinearElementType(appState.elementType)
|
||||
(canChangeSharpness(appState.activeTool.type) &&
|
||||
(isLinearElementType(appState.activeTool.type)
|
||||
? appState.currentItemLinearStrokeSharpness
|
||||
: appState.currentItemStrokeSharpness)) ||
|
||||
null,
|
||||
@ -841,6 +904,7 @@ export const actionChangeSharpness = register({
|
||||
|
||||
export const actionChangeArrowhead = register({
|
||||
name: "changeArrowhead",
|
||||
trackEvent: false,
|
||||
perform: (
|
||||
elements,
|
||||
appState,
|
||||
|
@ -5,6 +5,7 @@ import { getNonDeletedElements, isTextElement } from "../element";
|
||||
|
||||
export const actionSelectAll = register({
|
||||
name: "selectAll",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => {
|
||||
if (appState.editingLinearElement) {
|
||||
return false;
|
||||
@ -17,7 +18,8 @@ export const actionSelectAll = register({
|
||||
selectedElementIds: elements.reduce((map, element) => {
|
||||
if (
|
||||
!element.isDeleted &&
|
||||
!(isTextElement(element) && element.containerId)
|
||||
!(isTextElement(element) && element.containerId) &&
|
||||
element.locked === false
|
||||
) {
|
||||
map[element.id] = true;
|
||||
}
|
||||
|
71
src/actions/actionStyles.test.tsx
Normal file
71
src/actions/actionStyles.test.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import ExcalidrawApp from "../excalidraw-app";
|
||||
import { t } from "../i18n";
|
||||
import { CODES } from "../keys";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
|
||||
import { fireEvent, render, screen } from "../tests/test-utils";
|
||||
import { copiedStyles } from "./actionStyles";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
describe("actionStyles", () => {
|
||||
beforeEach(async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
});
|
||||
it("should copy & paste styles via keyboard", () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(20, 20);
|
||||
|
||||
// Change some styles of second rectangle
|
||||
UI.clickLabeledElement("Stroke");
|
||||
UI.clickLabeledElement(t("colors.c92a2a"));
|
||||
UI.clickLabeledElement("Background");
|
||||
UI.clickLabeledElement(t("colors.e64980"));
|
||||
// Fill style
|
||||
fireEvent.click(screen.getByTitle("Cross-hatch"));
|
||||
// Stroke width
|
||||
fireEvent.click(screen.getByTitle("Bold"));
|
||||
// Stroke style
|
||||
fireEvent.click(screen.getByTitle("Dotted"));
|
||||
// Roughness
|
||||
fireEvent.click(screen.getByTitle("Cartoonist"));
|
||||
// Opacity
|
||||
fireEvent.change(screen.getByLabelText("Opacity"), {
|
||||
target: { value: "60" },
|
||||
});
|
||||
|
||||
mouse.reset();
|
||||
|
||||
API.setSelectedElements([h.elements[1]]);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
|
||||
Keyboard.codeDown(CODES.C);
|
||||
});
|
||||
const secondRect = JSON.parse(copiedStyles);
|
||||
expect(secondRect.id).toBe(h.elements[1].id);
|
||||
|
||||
mouse.reset();
|
||||
// Paste styles to first rectangle
|
||||
API.setSelectedElements([h.elements[0]]);
|
||||
Keyboard.withModifierKeys({ ctrl: true, alt: true }, () => {
|
||||
Keyboard.codeDown(CODES.V);
|
||||
});
|
||||
|
||||
const firstRect = API.getSelectedElement();
|
||||
expect(firstRect.id).toBe(h.elements[0].id);
|
||||
expect(firstRect.strokeColor).toBe("#c92a2a");
|
||||
expect(firstRect.backgroundColor).toBe("#e64980");
|
||||
expect(firstRect.fillStyle).toBe("cross-hatch");
|
||||
expect(firstRect.strokeWidth).toBe(2); // Bold: 2
|
||||
expect(firstRect.strokeStyle).toBe("dotted");
|
||||
expect(firstRect.roughness).toBe(2); // Cartoonist: 2
|
||||
expect(firstRect.opacity).toBe(60);
|
||||
});
|
||||
});
|
@ -19,6 +19,7 @@ export let copiedStyles: string = "{}";
|
||||
|
||||
export const actionCopyStyles = register({
|
||||
name: "copyStyles",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
const element = elements.find((el) => appState.selectedElementIds[el.id]);
|
||||
if (element) {
|
||||
@ -39,6 +40,7 @@ export const actionCopyStyles = register({
|
||||
|
||||
export const actionPasteStyles = register({
|
||||
name: "pasteStyles",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
const pastedElement = JSON.parse(copiedStyles);
|
||||
if (!isExcalidrawElement(pastedElement)) {
|
||||
@ -63,11 +65,7 @@ export const actionPasteStyles = register({
|
||||
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
|
||||
});
|
||||
|
||||
redrawTextBoundingBox(
|
||||
element,
|
||||
getContainerElement(element),
|
||||
appState,
|
||||
);
|
||||
redrawTextBoundingBox(newElement, getContainerElement(newElement));
|
||||
}
|
||||
return newElement;
|
||||
}
|
||||
|
@ -2,12 +2,14 @@ import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { GRID_SIZE } from "../constants";
|
||||
import { AppState } from "../types";
|
||||
import { trackEvent } from "../analytics";
|
||||
|
||||
export const actionToggleGridMode = register({
|
||||
name: "gridMode",
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => !appState.gridSize,
|
||||
},
|
||||
perform(elements, appState) {
|
||||
trackEvent("view", "mode", "grid");
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
|
63
src/actions/actionToggleLock.ts
Normal file
63
src/actions/actionToggleLock.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { KEYS } from "../keys";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleLock = register({
|
||||
name: "toggleLock",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState, true);
|
||||
|
||||
if (!selectedElements.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const operation = getOperation(selectedElements);
|
||||
const selectedElementsMap = arrayToMap(selectedElements);
|
||||
|
||||
return {
|
||||
elements: elements.map((element) => {
|
||||
if (!selectedElementsMap.has(element.id)) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return newElementWith(element, { locked: operation === "lock" });
|
||||
}),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
contextItemLabel: (elements, appState) => {
|
||||
const selected = getSelectedElements(elements, appState, false);
|
||||
if (selected.length === 1) {
|
||||
return selected[0].locked
|
||||
? "labels.elementLock.unlock"
|
||||
: "labels.elementLock.lock";
|
||||
}
|
||||
|
||||
if (selected.length > 1) {
|
||||
return getOperation(selected) === "lock"
|
||||
? "labels.elementLock.lockAll"
|
||||
: "labels.elementLock.unlockAll";
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Unexpected zero elements to lock/unlock. This should never happen.",
|
||||
);
|
||||
},
|
||||
keyTest: (event, appState, elements) => {
|
||||
return (
|
||||
event.key.toLocaleLowerCase() === KEYS.L &&
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
event.shiftKey &&
|
||||
getSelectedElements(elements, appState, false).length > 0
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const getOperation = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): "lock" | "unlock" => (elements.some((el) => !el.locked) ? "lock" : "unlock");
|
@ -3,6 +3,7 @@ import { CODES, KEYS } from "../keys";
|
||||
|
||||
export const actionToggleStats = register({
|
||||
name: "stats",
|
||||
trackEvent: { category: "menu" },
|
||||
perform(elements, appState) {
|
||||
return {
|
||||
appState: {
|
||||
|
@ -1,11 +1,13 @@
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { trackEvent } from "../analytics";
|
||||
|
||||
export const actionToggleViewMode = register({
|
||||
name: "viewMode",
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => !appState.viewModeEnabled,
|
||||
},
|
||||
perform(elements, appState) {
|
||||
trackEvent("view", "mode", "view");
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
|
@ -1,12 +1,13 @@
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { trackEvent } from "../analytics";
|
||||
|
||||
export const actionToggleZenMode = register({
|
||||
name: "zenMode",
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => !appState.zenModeEnabled,
|
||||
},
|
||||
perform(elements, appState) {
|
||||
trackEvent("view", "mode", "zen");
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
|
@ -1,44 +0,0 @@
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import { getBoundTextElement, measureText } from "../element/textElement";
|
||||
import { ExcalidrawTextElement } from "../element/types";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getFontString } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionUnbindText = register({
|
||||
name: "unbindText",
|
||||
contextItemLabel: "labels.unbindText",
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
selectedElements.forEach((element) => {
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
const { width, height, baseline } = measureText(
|
||||
boundTextElement.originalText,
|
||||
getFontString(boundTextElement),
|
||||
);
|
||||
mutateElement(boundTextElement as ExcalidrawTextElement, {
|
||||
containerId: null,
|
||||
width,
|
||||
height,
|
||||
baseline,
|
||||
text: boundTextElement.originalText,
|
||||
});
|
||||
mutateElement(element, {
|
||||
boundElements: element.boundElements?.filter(
|
||||
(ele) => ele.id !== boundTextElement.id,
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
return {
|
||||
elements,
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
});
|
@ -18,6 +18,7 @@ import {
|
||||
|
||||
export const actionSendBackward = register({
|
||||
name: "sendBackward",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveOneLeft(elements, appState),
|
||||
@ -45,6 +46,7 @@ export const actionSendBackward = register({
|
||||
|
||||
export const actionBringForward = register({
|
||||
name: "bringForward",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveOneRight(elements, appState),
|
||||
@ -72,6 +74,7 @@ export const actionBringForward = register({
|
||||
|
||||
export const actionSendToBack = register({
|
||||
name: "sendToBack",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveAllLeft(elements, appState),
|
||||
@ -106,6 +109,8 @@ export const actionSendToBack = register({
|
||||
|
||||
export const actionBringToFront = register({
|
||||
name: "bringToFront",
|
||||
trackEvent: { category: "element" },
|
||||
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: moveAllRight(elements, appState),
|
||||
|
@ -17,6 +17,7 @@ export {
|
||||
actionChangeFontSize,
|
||||
actionChangeFontFamily,
|
||||
actionChangeTextAlign,
|
||||
actionChangeVerticalAlign,
|
||||
} from "./actionProperties";
|
||||
|
||||
export {
|
||||
@ -74,11 +75,13 @@ export {
|
||||
actionCut,
|
||||
actionCopyAsPng,
|
||||
actionCopyAsSvg,
|
||||
copyText,
|
||||
} from "./actionClipboard";
|
||||
|
||||
export { actionToggleGridMode } from "./actionToggleGridMode";
|
||||
export { actionToggleZenMode } from "./actionToggleZenMode";
|
||||
|
||||
export { actionToggleStats } from "./actionToggleStats";
|
||||
export { actionUnbindText } from "./actionUnbindText";
|
||||
export { actionUnbindText, actionBindText } from "./actionBoundText";
|
||||
export { actionLink } from "../element/Hyperlink";
|
||||
export { actionToggleLock } from "./actionToggleLock";
|
||||
|
@ -1,18 +1,47 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Action,
|
||||
ActionsManagerInterface,
|
||||
UpdaterFn,
|
||||
ActionName,
|
||||
ActionResult,
|
||||
PanelComponentProps,
|
||||
ActionSource,
|
||||
} from "./types";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import { MODES } from "../constants";
|
||||
import { trackEvent } from "../analytics";
|
||||
|
||||
export class ActionManager implements ActionsManagerInterface {
|
||||
actions = {} as ActionsManagerInterface["actions"];
|
||||
const trackAction = (
|
||||
action: Action,
|
||||
source: ActionSource,
|
||||
appState: Readonly<AppState>,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
app: AppClassProperties,
|
||||
value: any,
|
||||
) => {
|
||||
if (action.trackEvent) {
|
||||
try {
|
||||
if (typeof action.trackEvent === "object") {
|
||||
const shouldTrack = action.trackEvent.predicate
|
||||
? action.trackEvent.predicate(appState, elements, value)
|
||||
: true;
|
||||
if (shouldTrack) {
|
||||
trackEvent(
|
||||
action.trackEvent.category,
|
||||
action.trackEvent.action || action.name,
|
||||
`${source} (${app.deviceType.isMobile ? "mobile" : "desktop"})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("error while logging action:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export class ActionManager {
|
||||
actions = {} as Record<ActionName, Action>;
|
||||
|
||||
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
|
||||
|
||||
@ -65,9 +94,15 @@ export class ActionManager implements ActionsManagerInterface {
|
||||
),
|
||||
);
|
||||
|
||||
if (data.length === 0) {
|
||||
if (data.length !== 1) {
|
||||
if (data.length > 1) {
|
||||
console.warn("Canceling as multiple actions match this shortcut", data);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const action = data[0];
|
||||
|
||||
const { viewModeEnabled } = this.getAppState();
|
||||
if (viewModeEnabled) {
|
||||
if (!Object.values(MODES).includes(data[0].name)) {
|
||||
@ -75,27 +110,26 @@ export class ActionManager implements ActionsManagerInterface {
|
||||
}
|
||||
}
|
||||
|
||||
const elements = this.getElementsIncludingDeleted();
|
||||
const appState = this.getAppState();
|
||||
const value = null;
|
||||
|
||||
trackAction(action, "keyboard", appState, elements, this.app, null);
|
||||
|
||||
event.preventDefault();
|
||||
this.updater(
|
||||
data[0].perform(
|
||||
this.getElementsIncludingDeleted(),
|
||||
this.getAppState(),
|
||||
null,
|
||||
this.app,
|
||||
),
|
||||
);
|
||||
event.stopPropagation();
|
||||
this.updater(data[0].perform(elements, appState, value, this.app));
|
||||
return true;
|
||||
}
|
||||
|
||||
executeAction(action: Action) {
|
||||
this.updater(
|
||||
action.perform(
|
||||
this.getElementsIncludingDeleted(),
|
||||
this.getAppState(),
|
||||
null,
|
||||
this.app,
|
||||
),
|
||||
);
|
||||
executeAction(action: Action, source: ActionSource = "api") {
|
||||
const elements = this.getElementsIncludingDeleted();
|
||||
const appState = this.getAppState();
|
||||
const value = null;
|
||||
|
||||
trackAction(action, source, appState, elements, this.app, value);
|
||||
|
||||
this.updater(action.perform(elements, appState, value, this.app));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -113,7 +147,11 @@ export class ActionManager implements ActionsManagerInterface {
|
||||
) {
|
||||
const action = this.actions[name];
|
||||
const PanelComponent = action.PanelComponent!;
|
||||
const elements = this.getElementsIncludingDeleted();
|
||||
const appState = this.getAppState();
|
||||
const updateData = (formState?: any) => {
|
||||
trackAction(action, "ui", appState, elements, this.app, formState);
|
||||
|
||||
this.updater(
|
||||
action.perform(
|
||||
this.getElementsIncludingDeleted(),
|
||||
|
@ -1,8 +1,10 @@
|
||||
import { t } from "../i18n";
|
||||
import { isDarwin } from "../keys";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { ActionName } from "./types";
|
||||
|
||||
export type ShortcutName =
|
||||
export type ShortcutName = SubtypeOf<
|
||||
ActionName,
|
||||
| "cut"
|
||||
| "copy"
|
||||
| "paste"
|
||||
@ -26,7 +28,9 @@ export type ShortcutName =
|
||||
| "viewMode"
|
||||
| "flipHorizontal"
|
||||
| "flipVertical"
|
||||
| "link";
|
||||
| "hyperlink"
|
||||
| "toggleLock"
|
||||
>;
|
||||
|
||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
cut: [getShortcutKey("CtrlOrCmd+X")],
|
||||
@ -63,11 +67,12 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
flipHorizontal: [getShortcutKey("Shift+H")],
|
||||
flipVertical: [getShortcutKey("Shift+V")],
|
||||
viewMode: [getShortcutKey("Alt+R")],
|
||||
link: [getShortcutKey("CtrlOrCmd+K")],
|
||||
hyperlink: [getShortcutKey("CtrlOrCmd+K")],
|
||||
toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
|
||||
};
|
||||
|
||||
export const getShortcutFromShortcutName = (name: ShortcutName) => {
|
||||
const shortcuts = shortcutMap[name];
|
||||
// if multiple shortcuts availiable, take the first one
|
||||
// if multiple shortcuts available, take the first one
|
||||
return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
|
||||
};
|
||||
|
@ -8,6 +8,8 @@ import {
|
||||
} from "../types";
|
||||
import { ToolButtonSize } from "../components/ToolButton";
|
||||
|
||||
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
|
||||
|
||||
/** if false, the action should be prevented */
|
||||
export type ActionResult =
|
||||
| {
|
||||
@ -39,6 +41,7 @@ export type ActionName =
|
||||
| "paste"
|
||||
| "copyAsPng"
|
||||
| "copyAsSvg"
|
||||
| "copyText"
|
||||
| "sendBackward"
|
||||
| "bringForward"
|
||||
| "sendToBack"
|
||||
@ -82,6 +85,7 @@ export type ActionName =
|
||||
| "zoomToSelection"
|
||||
| "changeFontFamily"
|
||||
| "changeTextAlign"
|
||||
| "changeVerticalAlign"
|
||||
| "toggleFullScreen"
|
||||
| "toggleShortcuts"
|
||||
| "group"
|
||||
@ -105,7 +109,10 @@ export type ActionName =
|
||||
| "increaseFontSize"
|
||||
| "decreaseFontSize"
|
||||
| "unbindText"
|
||||
| "link";
|
||||
| "hyperlink"
|
||||
| "eraser"
|
||||
| "bindText"
|
||||
| "toggleLock";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
@ -136,12 +143,23 @@ export interface Action {
|
||||
appState: AppState,
|
||||
) => boolean;
|
||||
checked?: (appState: Readonly<AppState>) => boolean;
|
||||
}
|
||||
|
||||
export interface ActionsManagerInterface {
|
||||
actions: Record<ActionName, Action>;
|
||||
registerAction: (action: Action) => void;
|
||||
handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean;
|
||||
renderAction: (name: ActionName) => React.ReactElement | null;
|
||||
executeAction: (action: Action) => void;
|
||||
trackEvent:
|
||||
| false
|
||||
| {
|
||||
category:
|
||||
| "toolbar"
|
||||
| "element"
|
||||
| "canvas"
|
||||
| "export"
|
||||
| "history"
|
||||
| "menu"
|
||||
| "collab"
|
||||
| "hyperlink";
|
||||
action?: string;
|
||||
predicate?: (
|
||||
appState: Readonly<AppState>,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
value: any,
|
||||
) => boolean;
|
||||
};
|
||||
}
|
||||
|
@ -3,16 +3,20 @@ export const trackEvent =
|
||||
process.env?.REACT_APP_GOOGLE_ANALYTICS_ID &&
|
||||
typeof window !== "undefined" &&
|
||||
window.gtag
|
||||
? (category: string, name: string, label?: string, value?: number) => {
|
||||
window.gtag("event", name, {
|
||||
event_category: category,
|
||||
event_label: label,
|
||||
value,
|
||||
});
|
||||
? (category: string, action: string, label?: string, value?: number) => {
|
||||
try {
|
||||
window.gtag("event", action, {
|
||||
event_category: category,
|
||||
event_label: label,
|
||||
value,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("error logging to ga", error);
|
||||
}
|
||||
}
|
||||
: typeof process !== "undefined" && process.env?.JEST_WORKER_ID
|
||||
? (category: string, name: string, label?: string, value?: number) => {}
|
||||
: (category: string, name: string, label?: string, value?: number) => {
|
||||
? (category: string, action: string, label?: string, value?: number) => {}
|
||||
: (category: string, action: string, label?: string, value?: number) => {
|
||||
// Uncomment the next line to track locally
|
||||
// console.info("Track Event", category, name, label, value);
|
||||
// console.log("Track Event", { category, action, label, value });
|
||||
};
|
||||
|
@ -41,8 +41,11 @@ export const getDefaultAppState = (): Omit<
|
||||
editingElement: null,
|
||||
editingGroupId: null,
|
||||
editingLinearElement: null,
|
||||
elementLocked: false,
|
||||
elementType: "selection",
|
||||
activeTool: {
|
||||
type: "selection",
|
||||
locked: false,
|
||||
lastActiveToolBeforeEraser: null,
|
||||
},
|
||||
penMode: false,
|
||||
penDetected: false,
|
||||
errorMessage: null,
|
||||
@ -130,10 +133,9 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
editingElement: { browser: false, export: false, server: false },
|
||||
editingGroupId: { browser: true, export: false, server: false },
|
||||
editingLinearElement: { browser: false, export: false, server: false },
|
||||
elementLocked: { browser: true, export: false, server: false },
|
||||
elementType: { browser: true, export: false, server: false },
|
||||
penMode: { browser: false, export: false, server: false },
|
||||
penDetected: { browser: false, export: false, server: false },
|
||||
activeTool: { browser: true, export: false, server: false },
|
||||
penMode: { browser: true, export: false, server: false },
|
||||
penDetected: { browser: true, export: false, server: false },
|
||||
errorMessage: { browser: false, export: false, server: false },
|
||||
exportBackground: { browser: true, export: false, server: false },
|
||||
exportEmbedScene: { browser: true, export: false, server: false },
|
||||
@ -213,3 +215,9 @@ export const cleanAppStateForExport = (appState: Partial<AppState>) => {
|
||||
export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
|
||||
return _clearAppStateForStorage(appState, "server");
|
||||
};
|
||||
|
||||
export const isEraserActive = ({
|
||||
activeTool,
|
||||
}: {
|
||||
activeTool: AppState["activeTool"];
|
||||
}) => activeTool.type === "eraser";
|
||||
|
@ -1,5 +1,10 @@
|
||||
import colors from "./colors";
|
||||
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, ENV } from "./constants";
|
||||
import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
ENV,
|
||||
VERTICAL_ALIGN,
|
||||
} from "./constants";
|
||||
import { newElement, newLinearElement, newTextElement } from "./element";
|
||||
import { NonDeletedExcalidrawElement } from "./element/types";
|
||||
import { randomId } from "./random";
|
||||
@ -103,7 +108,7 @@ const transposeCells = (cells: string[][]) => {
|
||||
};
|
||||
|
||||
export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
|
||||
// Copy/paste from excel, spreadhseets, tsv, csv.
|
||||
// Copy/paste from excel, spreadsheets, tsv, csv.
|
||||
// For now we only accept 2 columns with an optional header
|
||||
|
||||
// Check for tab separated values
|
||||
@ -161,7 +166,8 @@ const commonProps = {
|
||||
strokeSharpness: "sharp",
|
||||
strokeStyle: "solid",
|
||||
strokeWidth: 1,
|
||||
verticalAlign: "middle",
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
locked: false,
|
||||
} as const;
|
||||
|
||||
const getChartDimentions = (spreadsheet: Spreadsheet) => {
|
||||
|
@ -2,16 +2,16 @@ import {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./element/types";
|
||||
import { getSelectedElements } from "./scene";
|
||||
import { AppState, BinaryFiles } from "./types";
|
||||
import { SVG_EXPORT_TAG } from "./scene/export";
|
||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
|
||||
import { isInitializedImageElement } from "./element/typeChecks";
|
||||
import { isPromiseLike } from "./utils";
|
||||
|
||||
type ElementsClipboard = {
|
||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
||||
elements: ExcalidrawElement[];
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
files: BinaryFiles | undefined;
|
||||
};
|
||||
|
||||
@ -56,19 +56,20 @@ const clipboardContainsElements = (
|
||||
export const copyToClipboard = async (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
files: BinaryFiles | null,
|
||||
) => {
|
||||
// select binded text elements when copying
|
||||
const selectedElements = getSelectedElements(elements, appState, true);
|
||||
const contents: ElementsClipboard = {
|
||||
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||
elements: selectedElements,
|
||||
files: selectedElements.reduce((acc, element) => {
|
||||
if (isInitializedImageElement(element) && files[element.fileId]) {
|
||||
acc[element.fileId] = files[element.fileId];
|
||||
}
|
||||
return acc;
|
||||
}, {} as BinaryFiles),
|
||||
elements,
|
||||
files: files
|
||||
? elements.reduce((acc, element) => {
|
||||
if (isInitializedImageElement(element) && files[element.fileId]) {
|
||||
acc[element.fileId] = files[element.fileId];
|
||||
}
|
||||
return acc;
|
||||
}, {} as BinaryFiles)
|
||||
: undefined,
|
||||
};
|
||||
const json = JSON.stringify(contents);
|
||||
CLIPBOARD = json;
|
||||
@ -124,7 +125,7 @@ const getSystemClipboard = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* Attemps to parse clipboard. Prefers system clipboard.
|
||||
* Attempts to parse clipboard. Prefers system clipboard.
|
||||
*/
|
||||
export const parseClipboard = async (
|
||||
event: ClipboardEvent | null,
|
||||
@ -166,10 +167,35 @@ export const parseClipboard = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const copyBlobToClipboardAsPng = async (blob: Blob) => {
|
||||
await navigator.clipboard.write([
|
||||
new window.ClipboardItem({ [MIME_TYPES.png]: blob }),
|
||||
]);
|
||||
export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
|
||||
let promise;
|
||||
try {
|
||||
// in Safari so far we need to construct the ClipboardItem synchronously
|
||||
// (i.e. in the same tick) otherwise browser will complain for lack of
|
||||
// user intent. Using a Promise ClipboardItem constructor solves this.
|
||||
// https://bugs.webkit.org/show_bug.cgi?id=222262
|
||||
//
|
||||
// not await so that we can detect whether the thrown error likely relates
|
||||
// to a lack of support for the Promise ClipboardItem constructor
|
||||
promise = navigator.clipboard.write([
|
||||
new window.ClipboardItem({
|
||||
[MIME_TYPES.png]: blob,
|
||||
}),
|
||||
]);
|
||||
} catch (error: any) {
|
||||
// if we're using a Promise ClipboardItem, let's try constructing
|
||||
// with resolution value instead
|
||||
if (isPromiseLike(blob)) {
|
||||
await navigator.clipboard.write([
|
||||
new window.ClipboardItem({
|
||||
[MIME_TYPES.png]: await blob,
|
||||
}),
|
||||
]);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await promise;
|
||||
};
|
||||
|
||||
export const copyTextToSystemClipboard = async (text: string | null) => {
|
||||
|
@ -3,7 +3,7 @@ import { ActionManager } from "../actions/manager";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement, PointerType } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { useDeviceType } from "../components/App";
|
||||
import {
|
||||
canChangeSharpness,
|
||||
canHaveArrowheads,
|
||||
@ -19,36 +19,50 @@ import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
|
||||
import Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { hasStrokeColor } from "../scene/comparisons";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
|
||||
|
||||
export const SelectedShapeActions = ({
|
||||
appState,
|
||||
elements,
|
||||
renderAction,
|
||||
elementType,
|
||||
activeTool,
|
||||
}: {
|
||||
appState: AppState;
|
||||
elements: readonly ExcalidrawElement[];
|
||||
renderAction: ActionManager["renderAction"];
|
||||
elementType: ExcalidrawElement["type"];
|
||||
activeTool: AppState["activeTool"]["type"];
|
||||
}) => {
|
||||
const targetElements = getTargetElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
|
||||
let isSingleElementBoundContainer = false;
|
||||
if (
|
||||
targetElements.length === 2 &&
|
||||
(hasBoundTextElement(targetElements[0]) ||
|
||||
hasBoundTextElement(targetElements[1]))
|
||||
) {
|
||||
isSingleElementBoundContainer = true;
|
||||
}
|
||||
const isEditing = Boolean(appState.editingElement);
|
||||
const isMobile = useIsMobile();
|
||||
const deviceType = useDeviceType();
|
||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||
|
||||
const showFillIcons =
|
||||
hasBackground(elementType) ||
|
||||
hasBackground(activeTool) ||
|
||||
targetElements.some(
|
||||
(element) =>
|
||||
hasBackground(element.type) && !isTransparent(element.backgroundColor),
|
||||
);
|
||||
const showChangeBackgroundIcons =
|
||||
hasBackground(elementType) ||
|
||||
hasBackground(activeTool) ||
|
||||
targetElements.some((element) => hasBackground(element.type));
|
||||
|
||||
const showLinkIcon =
|
||||
targetElements.length === 1 || isSingleElementBoundContainer;
|
||||
|
||||
let commonSelectedType: string | null = targetElements[0]?.type || null;
|
||||
|
||||
for (const element of targetElements) {
|
||||
@ -60,23 +74,23 @@ export const SelectedShapeActions = ({
|
||||
|
||||
return (
|
||||
<div className="panelColumn">
|
||||
{((hasStrokeColor(elementType) &&
|
||||
elementType !== "image" &&
|
||||
{((hasStrokeColor(activeTool) &&
|
||||
activeTool !== "image" &&
|
||||
commonSelectedType !== "image") ||
|
||||
targetElements.some((element) => hasStrokeColor(element.type))) &&
|
||||
renderAction("changeStrokeColor")}
|
||||
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
|
||||
{showFillIcons && renderAction("changeFillStyle")}
|
||||
|
||||
{(hasStrokeWidth(elementType) ||
|
||||
{(hasStrokeWidth(activeTool) ||
|
||||
targetElements.some((element) => hasStrokeWidth(element.type))) &&
|
||||
renderAction("changeStrokeWidth")}
|
||||
|
||||
{(elementType === "freedraw" ||
|
||||
{(activeTool === "freedraw" ||
|
||||
targetElements.some((element) => element.type === "freedraw")) &&
|
||||
renderAction("changeStrokeShape")}
|
||||
|
||||
{(hasStrokeStyle(elementType) ||
|
||||
{(hasStrokeStyle(activeTool) ||
|
||||
targetElements.some((element) => hasStrokeStyle(element.type))) && (
|
||||
<>
|
||||
{renderAction("changeStrokeStyle")}
|
||||
@ -84,12 +98,12 @@ export const SelectedShapeActions = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{(canChangeSharpness(elementType) ||
|
||||
{(canChangeSharpness(activeTool) ||
|
||||
targetElements.some((element) => canChangeSharpness(element.type))) && (
|
||||
<>{renderAction("changeSharpness")}</>
|
||||
)}
|
||||
|
||||
{(hasText(elementType) ||
|
||||
{(hasText(activeTool) ||
|
||||
targetElements.some((element) => hasText(element.type))) && (
|
||||
<>
|
||||
{renderAction("changeFontSize")}
|
||||
@ -100,7 +114,11 @@ export const SelectedShapeActions = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{(canHaveArrowheads(elementType) ||
|
||||
{targetElements.some(
|
||||
(element) =>
|
||||
hasBoundTextElement(element) || isBoundToContainer(element),
|
||||
) && renderAction("changeVerticalAlign")}
|
||||
{(canHaveArrowheads(activeTool) ||
|
||||
targetElements.some((element) => canHaveArrowheads(element.type))) && (
|
||||
<>{renderAction("changeArrowhead")}</>
|
||||
)}
|
||||
@ -117,7 +135,7 @@ export const SelectedShapeActions = ({
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{targetElements.length > 1 && (
|
||||
{targetElements.length > 1 && !isSingleElementBoundContainer && (
|
||||
<fieldset>
|
||||
<legend>{t("labels.align")}</legend>
|
||||
<div className="buttonList">
|
||||
@ -154,11 +172,11 @@ export const SelectedShapeActions = ({
|
||||
<fieldset>
|
||||
<legend>{t("labels.actions")}</legend>
|
||||
<div className="buttonList">
|
||||
{!isMobile && renderAction("duplicateSelection")}
|
||||
{!isMobile && renderAction("deleteSelectedElements")}
|
||||
{!deviceType.isMobile && renderAction("duplicateSelection")}
|
||||
{!deviceType.isMobile && renderAction("deleteSelectedElements")}
|
||||
{renderAction("group")}
|
||||
{renderAction("ungroup")}
|
||||
{targetElements.length === 1 && renderAction("link")}
|
||||
{showLinkIcon && renderAction("hyperlink")}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
@ -168,14 +186,16 @@ export const SelectedShapeActions = ({
|
||||
|
||||
export const ShapesSwitcher = ({
|
||||
canvas,
|
||||
elementType,
|
||||
activeTool,
|
||||
setAppState,
|
||||
onImageAction,
|
||||
appState,
|
||||
}: {
|
||||
canvas: HTMLCanvasElement | null;
|
||||
elementType: ExcalidrawElement["type"];
|
||||
activeTool: AppState["activeTool"];
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
onImageAction: (data: { pointerType: PointerType | null }) => void;
|
||||
appState: AppState;
|
||||
}) => (
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key }, index) => {
|
||||
@ -190,20 +210,35 @@ export const ShapesSwitcher = ({
|
||||
key={value}
|
||||
type="radio"
|
||||
icon={icon}
|
||||
checked={elementType === value}
|
||||
checked={activeTool.type === value}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||
keyBindingLabel={`${index + 1}`}
|
||||
aria-label={capitalizeString(label)}
|
||||
aria-keyshortcuts={shortcut}
|
||||
data-testid={value}
|
||||
onPointerDown={({ pointerType }) => {
|
||||
if (!appState.penDetected && pointerType === "pen") {
|
||||
setAppState({
|
||||
penDetected: true,
|
||||
penMode: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
if (appState.activeTool.type !== value) {
|
||||
trackEvent("toolbar", value, "ui");
|
||||
}
|
||||
const nextActiveTool = { ...activeTool, type: value };
|
||||
setAppState({
|
||||
elementType: value,
|
||||
activeTool: nextActiveTool,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
setCursorForShape(canvas, value);
|
||||
setCursorForShape(canvas, {
|
||||
...appState,
|
||||
activeTool: nextActiveTool,
|
||||
});
|
||||
if (value === "image") {
|
||||
onImageAction({ pointerType });
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -12,5 +12,11 @@
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
|
||||
&-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,20 +1,28 @@
|
||||
import "./Avatar.scss";
|
||||
|
||||
import React from "react";
|
||||
import { getClientInitials } from "../clients";
|
||||
|
||||
type AvatarProps = {
|
||||
children: string;
|
||||
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
||||
color: string;
|
||||
border: string;
|
||||
name: string;
|
||||
src?: string;
|
||||
};
|
||||
|
||||
export const Avatar = ({ children, color, border, onClick }: AvatarProps) => (
|
||||
<div
|
||||
className="Avatar"
|
||||
style={{ background: color, border: `1px solid ${border}` }}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
export const Avatar = ({ color, border, onClick, name, src }: AvatarProps) => {
|
||||
const shortName = getClientInitials(name);
|
||||
const style = src
|
||||
? undefined
|
||||
: { background: color, border: `1px solid ${border}` };
|
||||
return (
|
||||
<div className="Avatar" style={style} onClick={onClick}>
|
||||
{src ? (
|
||||
<img className="Avatar-img" src={src} alt={shortName} />
|
||||
) : (
|
||||
shortName
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "./App";
|
||||
import { useDeviceType } from "./App";
|
||||
import { trash } from "./icons";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
|
||||
@ -19,7 +19,7 @@ const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
|
||||
icon={trash}
|
||||
title={t("buttons.clearReset")}
|
||||
aria-label={t("buttons.clearReset")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
showAriaLabel={useDeviceType().isMobile}
|
||||
onClick={toggleDialog}
|
||||
data-testid="clear-canvas-button"
|
||||
/>
|
||||
|
@ -1,7 +1,7 @@
|
||||
import clsx from "clsx";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { useDeviceType } from "../components/App";
|
||||
import { users } from "./icons";
|
||||
|
||||
import "./CollabButton.scss";
|
||||
@ -26,7 +26,7 @@ const CollabButton = ({
|
||||
type="button"
|
||||
title={t("labels.liveCollaboration")}
|
||||
aria-label={t("labels.liveCollaboration")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
showAriaLabel={useDeviceType().isMobile}
|
||||
>
|
||||
{collaboratorCount > 0 && (
|
||||
<div className="CollabButton-collaborators">{collaboratorCount}</div>
|
||||
|
@ -46,7 +46,7 @@
|
||||
top: -11px;
|
||||
}
|
||||
|
||||
.color-picker-content {
|
||||
.color-picker-content--default {
|
||||
padding: 0.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, auto);
|
||||
@ -59,6 +59,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-content--canvas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.25rem;
|
||||
|
||||
&-title {
|
||||
color: $oc-gray-6;
|
||||
font-size: 12px;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
&-colors {
|
||||
padding: 0.5rem 0;
|
||||
|
||||
.color-picker-swatch {
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker-content .color-input-container {
|
||||
grid-column: 1 / span 5;
|
||||
}
|
||||
|
@ -7,6 +7,53 @@ import { isArrowKey, KEYS } from "../keys";
|
||||
import { t, getLanguage } from "../i18n";
|
||||
import { isWritableElement } from "../utils";
|
||||
import colors from "../colors";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
|
||||
const MAX_CUSTOM_COLORS = 5;
|
||||
const MAX_DEFAULT_COLORS = 15;
|
||||
|
||||
export const getCustomColors = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
type: "elementBackground" | "elementStroke",
|
||||
) => {
|
||||
const customColors: string[] = [];
|
||||
const updatedElements = elements
|
||||
.filter((element) => !element.isDeleted)
|
||||
.sort((ele1, ele2) => ele2.updated - ele1.updated);
|
||||
|
||||
let index = 0;
|
||||
const elementColorTypeMap = {
|
||||
elementBackground: "backgroundColor",
|
||||
elementStroke: "strokeColor",
|
||||
};
|
||||
const colorType = elementColorTypeMap[type] as
|
||||
| "backgroundColor"
|
||||
| "strokeColor";
|
||||
while (
|
||||
index < updatedElements.length &&
|
||||
customColors.length < MAX_CUSTOM_COLORS
|
||||
) {
|
||||
const element = updatedElements[index];
|
||||
|
||||
if (
|
||||
customColors.length < MAX_CUSTOM_COLORS &&
|
||||
isCustomColor(element[colorType], type) &&
|
||||
!customColors.includes(element[colorType])
|
||||
) {
|
||||
customColors.push(element[colorType]);
|
||||
}
|
||||
index++;
|
||||
}
|
||||
return customColors;
|
||||
};
|
||||
|
||||
const isCustomColor = (
|
||||
color: string,
|
||||
type: "elementBackground" | "elementStroke",
|
||||
) => {
|
||||
return !colors[type].includes(color);
|
||||
};
|
||||
|
||||
const isValidColor = (color: string) => {
|
||||
const style = new Option().style;
|
||||
@ -35,6 +82,7 @@ const keyBindings = [
|
||||
["1", "2", "3", "4", "5"],
|
||||
["q", "w", "e", "r", "t"],
|
||||
["a", "s", "d", "f", "g"],
|
||||
["z", "x", "c", "v", "b"],
|
||||
].flat();
|
||||
|
||||
const Picker = ({
|
||||
@ -45,6 +93,7 @@ const Picker = ({
|
||||
label,
|
||||
showInput = true,
|
||||
type,
|
||||
elements,
|
||||
}: {
|
||||
colors: string[];
|
||||
color: string | null;
|
||||
@ -53,12 +102,20 @@ const Picker = ({
|
||||
label: string;
|
||||
showInput: boolean;
|
||||
type: "canvasBackground" | "elementBackground" | "elementStroke";
|
||||
elements: readonly ExcalidrawElement[];
|
||||
}) => {
|
||||
const firstItem = React.useRef<HTMLButtonElement>();
|
||||
const activeItem = React.useRef<HTMLButtonElement>();
|
||||
const gallery = React.useRef<HTMLDivElement>();
|
||||
const colorInput = React.useRef<HTMLInputElement>();
|
||||
|
||||
const [customColors] = React.useState(() => {
|
||||
if (type === "canvasBackground") {
|
||||
return [];
|
||||
}
|
||||
return getCustomColors(elements, type);
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
// After the component is first mounted focus on first input
|
||||
if (activeItem.current) {
|
||||
@ -85,23 +142,42 @@ const Picker = ({
|
||||
} else if (isArrowKey(event.key)) {
|
||||
const { activeElement } = document;
|
||||
const isRTL = getLanguage().rtl;
|
||||
const index = Array.prototype.indexOf.call(
|
||||
gallery!.current!.children,
|
||||
let isCustom = false;
|
||||
let index = Array.prototype.indexOf.call(
|
||||
gallery!.current!.querySelector(".color-picker-content--default")!
|
||||
.children,
|
||||
activeElement,
|
||||
);
|
||||
if (index === -1) {
|
||||
index = Array.prototype.indexOf.call(
|
||||
gallery!.current!.querySelector(
|
||||
".color-picker-content--canvas-colors",
|
||||
)!.children,
|
||||
activeElement,
|
||||
);
|
||||
if (index !== -1) {
|
||||
isCustom = true;
|
||||
}
|
||||
}
|
||||
const parentSelector = isCustom
|
||||
? gallery!.current!.querySelector(
|
||||
".color-picker-content--canvas-colors",
|
||||
)!
|
||||
: gallery!.current!.querySelector(".color-picker-content--default")!;
|
||||
|
||||
if (index !== -1) {
|
||||
const length = gallery!.current!.children.length - (showInput ? 1 : 0);
|
||||
const length = parentSelector!.children.length - (showInput ? 1 : 0);
|
||||
const nextIndex =
|
||||
event.key === (isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT)
|
||||
? (index + 1) % length
|
||||
: event.key === (isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT)
|
||||
? (length + index - 1) % length
|
||||
: event.key === KEYS.ARROW_DOWN
|
||||
: !isCustom && event.key === KEYS.ARROW_DOWN
|
||||
? (index + 5) % length
|
||||
: event.key === KEYS.ARROW_UP
|
||||
: !isCustom && event.key === KEYS.ARROW_UP
|
||||
? (length + index - 5) % length
|
||||
: index;
|
||||
(gallery!.current!.children![nextIndex] as any).focus();
|
||||
(parentSelector!.children![nextIndex] as HTMLElement)?.focus();
|
||||
}
|
||||
event.preventDefault();
|
||||
} else if (
|
||||
@ -109,7 +185,15 @@ const Picker = ({
|
||||
!isWritableElement(event.target)
|
||||
) {
|
||||
const index = keyBindings.indexOf(event.key.toLowerCase());
|
||||
(gallery!.current!.children![index] as any).focus();
|
||||
const isCustom = index >= MAX_DEFAULT_COLORS;
|
||||
const parentSelector = isCustom
|
||||
? gallery!.current!.querySelector(
|
||||
".color-picker-content--canvas-colors",
|
||||
)!
|
||||
: gallery!.current!.querySelector(".color-picker-content--default")!;
|
||||
const actualIndex = isCustom ? index - MAX_DEFAULT_COLORS : index;
|
||||
(parentSelector!.children![actualIndex] as HTMLElement)?.focus();
|
||||
|
||||
event.preventDefault();
|
||||
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
|
||||
event.preventDefault();
|
||||
@ -119,6 +203,50 @@ const Picker = ({
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const renderColors = (colors: Array<string>, custom: boolean = false) => {
|
||||
return colors.map((_color, i) => {
|
||||
const _colorWithoutHash = _color.replace("#", "");
|
||||
const keyBinding = custom
|
||||
? keyBindings[i + MAX_DEFAULT_COLORS]
|
||||
: keyBindings[i];
|
||||
const label = custom
|
||||
? _colorWithoutHash
|
||||
: t(`colors.${_colorWithoutHash}`);
|
||||
return (
|
||||
<button
|
||||
className="color-picker-swatch"
|
||||
onClick={(event) => {
|
||||
(event.currentTarget as HTMLButtonElement).focus();
|
||||
onChange(_color);
|
||||
}}
|
||||
title={`${label}${
|
||||
!isTransparent(_color) ? ` (${_color})` : ""
|
||||
} — ${keyBinding.toUpperCase()}`}
|
||||
aria-label={label}
|
||||
aria-keyshortcuts={keyBindings[i]}
|
||||
style={{ color: _color }}
|
||||
key={_color}
|
||||
ref={(el) => {
|
||||
if (!custom && el && i === 0) {
|
||||
firstItem.current = el;
|
||||
}
|
||||
if (el && _color === color) {
|
||||
activeItem.current = el;
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
onChange(_color);
|
||||
}}
|
||||
>
|
||||
{isTransparent(_color) ? (
|
||||
<div className="color-picker-transparent"></div>
|
||||
) : undefined}
|
||||
<span className="color-picker-keybinding">{keyBinding}</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`color-picker color-picker-type-${type}`}
|
||||
@ -138,41 +266,20 @@ const Picker = ({
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
{colors.map((_color, i) => {
|
||||
const _colorWithoutHash = _color.replace("#", "");
|
||||
return (
|
||||
<button
|
||||
className="color-picker-swatch"
|
||||
onClick={(event) => {
|
||||
(event.currentTarget as HTMLButtonElement).focus();
|
||||
onChange(_color);
|
||||
}}
|
||||
title={`${t(`colors.${_colorWithoutHash}`)}${
|
||||
!isTransparent(_color) ? ` (${_color})` : ""
|
||||
} — ${keyBindings[i].toUpperCase()}`}
|
||||
aria-label={t(`colors.${_colorWithoutHash}`)}
|
||||
aria-keyshortcuts={keyBindings[i]}
|
||||
style={{ color: _color }}
|
||||
key={_color}
|
||||
ref={(el) => {
|
||||
if (el && i === 0) {
|
||||
firstItem.current = el;
|
||||
}
|
||||
if (el && _color === color) {
|
||||
activeItem.current = el;
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
onChange(_color);
|
||||
}}
|
||||
>
|
||||
{isTransparent(_color) ? (
|
||||
<div className="color-picker-transparent"></div>
|
||||
) : undefined}
|
||||
<span className="color-picker-keybinding">{keyBindings[i]}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="color-picker-content--default">
|
||||
{renderColors(colors)}
|
||||
</div>
|
||||
{!!customColors.length && (
|
||||
<div className="color-picker-content--canvas">
|
||||
<span className="color-picker-content--canvas-title">
|
||||
{t("labels.canvasColors")}
|
||||
</span>
|
||||
<div className="color-picker-content--canvas-colors">
|
||||
{renderColors(customColors, true)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showInput && (
|
||||
<ColorInput
|
||||
color={color}
|
||||
@ -246,6 +353,8 @@ export const ColorPicker = ({
|
||||
label,
|
||||
isActive,
|
||||
setActive,
|
||||
elements,
|
||||
appState,
|
||||
}: {
|
||||
type: "canvasBackground" | "elementBackground" | "elementStroke";
|
||||
color: string | null;
|
||||
@ -253,6 +362,8 @@ export const ColorPicker = ({
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
setActive: (active: boolean) => void;
|
||||
elements: readonly ExcalidrawElement[];
|
||||
appState: AppState;
|
||||
}) => {
|
||||
const pickerButton = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
@ -294,6 +405,7 @@ export const ColorPicker = ({
|
||||
label={label}
|
||||
showInput={false}
|
||||
type={type}
|
||||
elements={elements}
|
||||
/>
|
||||
</Popover>
|
||||
) : null}
|
||||
|
@ -70,7 +70,9 @@ const ContextMenu = ({
|
||||
dangerous: actionName === "deleteSelectedElements",
|
||||
checkmark: option.checked?.(appState),
|
||||
})}
|
||||
onClick={() => actionManager.executeAction(option)}
|
||||
onClick={() =>
|
||||
actionManager.executeAction(option, "contextMenu")
|
||||
}
|
||||
>
|
||||
<div className="context-menu-option__label">{label}</div>
|
||||
<kbd className="context-menu-option__shortcut">
|
||||
|
@ -2,7 +2,7 @@ import clsx from "clsx";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||
import { t } from "../i18n";
|
||||
import { useExcalidrawContainer, useIsMobile } from "../components/App";
|
||||
import { useExcalidrawContainer, useDeviceType } from "../components/App";
|
||||
import { KEYS } from "../keys";
|
||||
import "./Dialog.scss";
|
||||
import { back, close } from "./icons";
|
||||
@ -94,7 +94,7 @@ export const Dialog = (props: DialogProps) => {
|
||||
onClick={onClose}
|
||||
aria-label={t("buttons.close")}
|
||||
>
|
||||
{useIsMobile() ? back : close}
|
||||
{useDeviceType().isMobile ? back : close}
|
||||
</button>
|
||||
</h2>
|
||||
<div className="Dialog__content">{props.children}</div>
|
||||
|
@ -139,7 +139,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
<Section title={t("helpDialog.shortcuts")}>
|
||||
<Columns>
|
||||
<Column>
|
||||
<ShortcutIsland caption={t("helpDialog.shapes")}>
|
||||
<ShortcutIsland caption={t("helpDialog.tools")}>
|
||||
<Shortcut
|
||||
label={t("toolBar.selection")}
|
||||
shortcuts={["V", "1"]}
|
||||
@ -149,7 +149,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
shortcuts={["R", "2"]}
|
||||
/>
|
||||
<Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} />
|
||||
<Shortcut label={t("toolBar.ellipse")} shortcuts={["E", "4"]} />
|
||||
<Shortcut label={t("toolBar.ellipse")} shortcuts={["O", "4"]} />
|
||||
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
|
||||
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
|
||||
<Shortcut
|
||||
@ -159,6 +159,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
|
||||
<Shortcut label={t("toolBar.image")} shortcuts={["9"]} />
|
||||
<Shortcut label={t("toolBar.library")} shortcuts={["0"]} />
|
||||
<Shortcut
|
||||
label={t("toolBar.eraser")}
|
||||
shortcuts={[getShortcutKey("E")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.editSelectedShape")}
|
||||
shortcuts={[
|
||||
@ -359,6 +363,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
getShortcutKey(`Alt+${t("helpDialog.drag")}`),
|
||||
]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.toggleElementLock")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+L")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("buttons.undo")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Z")]}
|
||||
|
@ -11,6 +11,7 @@ import {
|
||||
isTextElement,
|
||||
} from "../element/typeChecks";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { isEraserActive } from "../appState";
|
||||
|
||||
interface HintViewerProps {
|
||||
appState: AppState;
|
||||
@ -19,25 +20,32 @@ interface HintViewerProps {
|
||||
}
|
||||
|
||||
const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
||||
const { elementType, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||
const multiMode = appState.multiElement !== null;
|
||||
|
||||
if (elementType === "arrow" || elementType === "line") {
|
||||
if (appState.isLibraryOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isEraserActive(appState)) {
|
||||
return t("hints.eraserRevert");
|
||||
}
|
||||
if (activeTool.type === "arrow" || activeTool.type === "line") {
|
||||
if (!multiMode) {
|
||||
return t("hints.linearElement");
|
||||
}
|
||||
return t("hints.linearElementMulti");
|
||||
}
|
||||
|
||||
if (elementType === "freedraw") {
|
||||
if (activeTool.type === "freedraw") {
|
||||
return t("hints.freeDraw");
|
||||
}
|
||||
|
||||
if (elementType === "text") {
|
||||
if (activeTool.type === "text") {
|
||||
return t("hints.text");
|
||||
}
|
||||
|
||||
if (appState.elementType === "image" && appState.pendingImageElement) {
|
||||
if (appState.activeTool.type === "image" && appState.pendingImageElement) {
|
||||
return t("hints.placeImage");
|
||||
}
|
||||
|
||||
@ -69,7 +77,7 @@ const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
|
||||
return t("hints.text_editing");
|
||||
}
|
||||
|
||||
if (elementType === "selection") {
|
||||
if (activeTool.type === "selection") {
|
||||
if (
|
||||
appState.draggingElement?.type === "selection" &&
|
||||
!appState.editingElement &&
|
||||
|
@ -1,12 +1,11 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { render, unmountComponentAtNode } from "react-dom";
|
||||
import { ActionsManagerInterface } from "../actions/types";
|
||||
import { probablySupportsClipboardBlob } from "../clipboard";
|
||||
import { canvasToBlob } from "../data/blob";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { CanvasError } from "../errors";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "./App";
|
||||
import { useDeviceType } from "./App";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { exportToCanvas } from "../scene/export";
|
||||
import { AppState, BinaryFiles } from "../types";
|
||||
@ -19,6 +18,7 @@ import OpenColor from "open-color";
|
||||
import { CheckboxItem } from "./CheckboxItem";
|
||||
import { DEFAULT_EXPORT_PADDING } from "../constants";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
|
||||
const supportsContextFilters =
|
||||
"filter" in document.createElement("canvas").getContext("2d")!;
|
||||
@ -90,7 +90,7 @@ const ImageExportModal = ({
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
files: BinaryFiles;
|
||||
exportPadding?: number;
|
||||
actionManager: ActionsManagerInterface;
|
||||
actionManager: ActionManager;
|
||||
onExportToPng: ExportCB;
|
||||
onExportToSvg: ExportCB;
|
||||
onExportToClipboard: ExportCB;
|
||||
@ -229,7 +229,7 @@ export const ImageExportDialog = ({
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
files: BinaryFiles;
|
||||
exportPadding?: number;
|
||||
actionManager: ActionsManagerInterface;
|
||||
actionManager: ActionManager;
|
||||
onExportToPng: ExportCB;
|
||||
onExportToSvg: ExportCB;
|
||||
onExportToClipboard: ExportCB;
|
||||
@ -250,7 +250,7 @@ export const ImageExportDialog = ({
|
||||
icon={exportImage}
|
||||
type="button"
|
||||
aria-label={t("buttons.exportImage")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
showAriaLabel={useDeviceType().isMobile}
|
||||
title={t("buttons.exportImage")}
|
||||
/>
|
||||
{modalIsShown && (
|
||||
|
@ -1,8 +1,7 @@
|
||||
import React, { useState } from "react";
|
||||
import { ActionsManagerInterface } from "../actions/types";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "./App";
|
||||
import { useDeviceType } from "./App";
|
||||
import { AppState, ExportOpts, BinaryFiles } from "../types";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { exportFile, exportToFileIcon, link } from "./icons";
|
||||
@ -12,6 +11,9 @@ import { Card } from "./Card";
|
||||
|
||||
import "./ExportDialog.scss";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { getFrame } from "../utils";
|
||||
|
||||
export type ExportCB = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
@ -29,7 +31,7 @@ const JSONExportModal = ({
|
||||
appState: AppState;
|
||||
files: BinaryFiles;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
actionManager: ActionsManagerInterface;
|
||||
actionManager: ActionManager;
|
||||
onCloseRequest: () => void;
|
||||
exportOpts: ExportOpts;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
@ -54,7 +56,7 @@ const JSONExportModal = ({
|
||||
aria-label={t("exportDialog.disk_button")}
|
||||
showAriaLabel={true}
|
||||
onClick={() => {
|
||||
actionManager.executeAction(actionSaveFileToDisk);
|
||||
actionManager.executeAction(actionSaveFileToDisk, "ui");
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
@ -70,9 +72,10 @@ const JSONExportModal = ({
|
||||
title={t("exportDialog.link_button")}
|
||||
aria-label={t("exportDialog.link_button")}
|
||||
showAriaLabel={true}
|
||||
onClick={() =>
|
||||
onExportToBackend(elements, appState, files, canvas)
|
||||
}
|
||||
onClick={() => {
|
||||
onExportToBackend(elements, appState, files, canvas);
|
||||
trackEvent("export", "link", `ui (${getFrame()})`);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
@ -94,7 +97,7 @@ export const JSONExportDialog = ({
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
appState: AppState;
|
||||
files: BinaryFiles;
|
||||
actionManager: ActionsManagerInterface;
|
||||
actionManager: ActionManager;
|
||||
exportOpts: ExportOpts;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
}) => {
|
||||
@ -114,7 +117,7 @@ export const JSONExportDialog = ({
|
||||
icon={exportFile}
|
||||
type="button"
|
||||
aria-label={t("buttons.export")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
showAriaLabel={useDeviceType().isMobile}
|
||||
title={t("buttons.export")}
|
||||
/>
|
||||
{modalIsShown && (
|
||||
|
@ -6,7 +6,6 @@ import { exportCanvas } from "../data";
|
||||
import { isTextElement, showSelectedShapeActions } from "../element";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { Language, t } from "../i18n";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
||||
import { ExportType } from "../scene/types";
|
||||
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
|
||||
@ -37,6 +36,8 @@ import { LibraryMenu } from "./LibraryMenu";
|
||||
import "./LayerUI.scss";
|
||||
import "./Toolbar.scss";
|
||||
import { PenModeButton } from "./PenModeButton";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { useDeviceType } from "../components/App";
|
||||
|
||||
interface LayerUIProps {
|
||||
actionManager: ActionManager;
|
||||
@ -67,6 +68,7 @@ interface LayerUIProps {
|
||||
library: Library;
|
||||
id: string;
|
||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||
renderCustomElementWidget?: (appState: AppState) => void;
|
||||
}
|
||||
|
||||
const LayerUI = ({
|
||||
@ -94,8 +96,9 @@ const LayerUI = ({
|
||||
library,
|
||||
id,
|
||||
onImageAction,
|
||||
renderCustomElementWidget,
|
||||
}: LayerUIProps) => {
|
||||
const isMobile = useIsMobile();
|
||||
const deviceType = useDeviceType();
|
||||
|
||||
const renderJSONExportDialog = () => {
|
||||
if (!UIOptions.canvasActions.export) {
|
||||
@ -122,6 +125,7 @@ const LayerUI = ({
|
||||
const createExporter =
|
||||
(type: ExportType): ExportCB =>
|
||||
async (exportedElements) => {
|
||||
trackEvent("export", type, "ui");
|
||||
const fileHandle = await exportCanvas(
|
||||
type,
|
||||
exportedElements,
|
||||
@ -238,7 +242,7 @@ const LayerUI = ({
|
||||
className={CLASSES.SHAPE_ACTIONS_MENU}
|
||||
padding={2}
|
||||
style={{
|
||||
// we want to make sure this doesn't overflow so substracting 200
|
||||
// we want to make sure this doesn't overflow so subtracting 200
|
||||
// which is approximately height of zoom footer and top left menu items with some buffer
|
||||
// if active file name is displayed, subtracting 248 to account for its height
|
||||
maxHeight: `${appState.height - (appState.fileHandle ? 248 : 200)}px`,
|
||||
@ -248,7 +252,7 @@ const LayerUI = ({
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
renderAction={actionManager.renderAction}
|
||||
elementType={appState.elementType}
|
||||
activeTool={appState.activeTool.type}
|
||||
/>
|
||||
</Island>
|
||||
</Section>
|
||||
@ -325,8 +329,8 @@ const LayerUI = ({
|
||||
/>
|
||||
<LockButton
|
||||
zenModeEnabled={zenModeEnabled}
|
||||
checked={appState.elementLocked}
|
||||
onChange={onLockToggle}
|
||||
checked={appState.activeTool.locked}
|
||||
onChange={() => onLockToggle()}
|
||||
title={t("toolBar.lock")}
|
||||
/>
|
||||
<Island
|
||||
@ -338,13 +342,14 @@ const LayerUI = ({
|
||||
<HintViewer
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
isMobile={isMobile}
|
||||
isMobile={deviceType.isMobile}
|
||||
/>
|
||||
{heading}
|
||||
<Stack.Row gap={1}>
|
||||
<ShapesSwitcher
|
||||
appState={appState}
|
||||
canvas={canvas}
|
||||
elementType={appState.elementType}
|
||||
activeTool={appState.activeTool}
|
||||
setAppState={setAppState}
|
||||
onImageAction={({ pointerType }) => {
|
||||
onImageAction({
|
||||
@ -388,7 +393,7 @@ const LayerUI = ({
|
||||
</Tooltip>
|
||||
))}
|
||||
</UserList>
|
||||
{renderTopRightUI?.(isMobile, appState)}
|
||||
{renderTopRightUI?.(deviceType.isMobile, appState)}
|
||||
</div>
|
||||
</div>
|
||||
</FixedSideContainer>
|
||||
@ -418,16 +423,41 @@ const LayerUI = ({
|
||||
/>
|
||||
</Island>
|
||||
{!viewModeEnabled && (
|
||||
<div
|
||||
className={clsx("undo-redo-buttons zen-mode-transition", {
|
||||
"layer-ui__wrapper__footer-left--transition-bottom":
|
||||
zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
{actionManager.renderAction("undo", { size: "small" })}
|
||||
{actionManager.renderAction("redo", { size: "small" })}
|
||||
</div>
|
||||
<>
|
||||
<div
|
||||
className={clsx("undo-redo-buttons zen-mode-transition", {
|
||||
"layer-ui__wrapper__footer-left--transition-bottom":
|
||||
zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
{actionManager.renderAction("undo", { size: "small" })}
|
||||
{actionManager.renderAction("redo", { size: "small" })}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx("eraser-buttons zen-mode-transition", {
|
||||
"layer-ui__wrapper__footer-left--transition-left":
|
||||
zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
{actionManager.renderAction("eraser", { size: "small" })}
|
||||
{renderCustomElementWidget &&
|
||||
renderCustomElementWidget(appState)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!viewModeEnabled &&
|
||||
appState.multiElement &&
|
||||
deviceType.isTouchScreen && (
|
||||
<div
|
||||
className={clsx("finalize-button zen-mode-transition", {
|
||||
"layer-ui__wrapper__footer-left--transition-left":
|
||||
zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
{actionManager.renderAction("finalize", { size: "small" })}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
@ -466,7 +496,7 @@ const LayerUI = ({
|
||||
|
||||
const dialogs = (
|
||||
<>
|
||||
{appState.isLoading && <LoadingMessage />}
|
||||
{appState.isLoading && <LoadingMessage delay={250} />}
|
||||
{appState.errorMessage && (
|
||||
<ErrorDialog
|
||||
message={appState.errorMessage}
|
||||
@ -495,7 +525,7 @@ const LayerUI = ({
|
||||
</>
|
||||
);
|
||||
|
||||
return isMobile ? (
|
||||
return deviceType.isMobile ? (
|
||||
<>
|
||||
{dialogs}
|
||||
<MobileMenu
|
||||
@ -507,7 +537,7 @@ const LayerUI = ({
|
||||
renderImageExportDialog={renderImageExportDialog}
|
||||
setAppState={setAppState}
|
||||
onCollabButtonClick={onCollabButtonClick}
|
||||
onLockToggle={onLockToggle}
|
||||
onLockToggle={() => onLockToggle()}
|
||||
onPenModeToggle={onPenModeToggle}
|
||||
canvas={canvas}
|
||||
isCollaborating={isCollaborating}
|
||||
|
@ -13,6 +13,10 @@
|
||||
width: 100%;
|
||||
margin: 2px 0;
|
||||
|
||||
.Spinner {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
button {
|
||||
// 2px from the left to account for focus border of left-most button
|
||||
margin: 0 2px;
|
||||
@ -28,8 +32,17 @@
|
||||
}
|
||||
|
||||
.layer-ui__library-message {
|
||||
padding: 10px 20px;
|
||||
max-width: 200px;
|
||||
padding: 2em 4em;
|
||||
min-width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
.Spinner {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
span {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
||||
|
||||
.publish-library-success {
|
||||
|
@ -1,5 +1,12 @@
|
||||
import { useRef, useState, useEffect, useCallback, RefObject } from "react";
|
||||
import Library from "../data/library";
|
||||
import {
|
||||
useRef,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
RefObject,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
import Library, { libraryItemsAtom } from "../data/library";
|
||||
import { t } from "../i18n";
|
||||
import { randomId } from "../random";
|
||||
import {
|
||||
@ -19,6 +26,10 @@ import LibraryMenuItems from "./LibraryMenuItems";
|
||||
import { EVENT } from "../constants";
|
||||
import { KEYS } from "../keys";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { useAtom } from "jotai";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import Spinner from "./Spinner";
|
||||
|
||||
const useOnClickOutside = (
|
||||
ref: RefObject<HTMLElement>,
|
||||
@ -53,6 +64,17 @@ const getSelectedItems = (
|
||||
selectedItems: LibraryItem["id"][],
|
||||
) => libraryItems.filter((item) => selectedItems.includes(item.id));
|
||||
|
||||
const LibraryMenuWrapper = forwardRef<
|
||||
HTMLDivElement,
|
||||
{ children: React.ReactNode }
|
||||
>(({ children }, ref) => {
|
||||
return (
|
||||
<Island padding={1} ref={ref} className="layer-ui__library">
|
||||
{children}
|
||||
</Island>
|
||||
);
|
||||
});
|
||||
|
||||
export const LibraryMenu = ({
|
||||
onClose,
|
||||
onInsertShape,
|
||||
@ -102,11 +124,6 @@ export const LibraryMenu = ({
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
|
||||
|
||||
const [loadingState, setIsLoading] = useState<
|
||||
"preloading" | "loading" | "ready"
|
||||
>("preloading");
|
||||
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
||||
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
|
||||
useState(false);
|
||||
@ -114,55 +131,35 @@ export const LibraryMenu = ({
|
||||
url: string;
|
||||
authorName: string;
|
||||
}>(null);
|
||||
const loadingTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.race([
|
||||
new Promise((resolve) => {
|
||||
loadingTimerRef.current = window.setTimeout(() => {
|
||||
resolve("loading");
|
||||
}, 100);
|
||||
}),
|
||||
library.loadLibrary().then((items) => {
|
||||
setLibraryItems(items);
|
||||
setIsLoading("ready");
|
||||
}),
|
||||
]).then((data) => {
|
||||
if (data === "loading") {
|
||||
setIsLoading("loading");
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
clearTimeout(loadingTimerRef.current!);
|
||||
};
|
||||
}, [library]);
|
||||
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
||||
|
||||
const removeFromLibrary = useCallback(async () => {
|
||||
const items = await library.loadLibrary();
|
||||
|
||||
const nextItems = items.filter((item) => !selectedItems.includes(item.id));
|
||||
library.saveLibrary(nextItems).catch((error) => {
|
||||
setLibraryItems(items);
|
||||
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
||||
});
|
||||
setSelectedItems([]);
|
||||
setLibraryItems(nextItems);
|
||||
}, [library, setAppState, selectedItems, setSelectedItems]);
|
||||
const removeFromLibrary = useCallback(
|
||||
async (libraryItems: LibraryItems) => {
|
||||
const nextItems = libraryItems.filter(
|
||||
(item) => !selectedItems.includes(item.id),
|
||||
);
|
||||
library.setLibrary(nextItems).catch(() => {
|
||||
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
||||
});
|
||||
setSelectedItems([]);
|
||||
},
|
||||
[library, setAppState, selectedItems, setSelectedItems],
|
||||
);
|
||||
|
||||
const resetLibrary = useCallback(() => {
|
||||
library.resetLibrary();
|
||||
setLibraryItems([]);
|
||||
focusContainer();
|
||||
}, [library, focusContainer]);
|
||||
|
||||
const addToLibrary = useCallback(
|
||||
async (elements: LibraryItem["elements"]) => {
|
||||
async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => {
|
||||
trackEvent("element", "addToLibrary", "ui");
|
||||
if (elements.some((element) => element.type === "image")) {
|
||||
return setAppState({
|
||||
errorMessage: "Support for adding images to the library coming soon!",
|
||||
});
|
||||
}
|
||||
const items = await library.loadLibrary();
|
||||
const nextItems: LibraryItems = [
|
||||
{
|
||||
status: "unpublished",
|
||||
@ -170,14 +167,12 @@ export const LibraryMenu = ({
|
||||
id: randomId(),
|
||||
created: Date.now(),
|
||||
},
|
||||
...items,
|
||||
...libraryItems,
|
||||
];
|
||||
onAddToLibrary();
|
||||
library.saveLibrary(nextItems).catch((error) => {
|
||||
setLibraryItems(items);
|
||||
library.setLibrary(nextItems).catch(() => {
|
||||
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
||||
});
|
||||
setLibraryItems(nextItems);
|
||||
},
|
||||
[onAddToLibrary, library, setAppState],
|
||||
);
|
||||
@ -216,7 +211,7 @@ export const LibraryMenu = ({
|
||||
}, [setPublishLibSuccess, publishLibSuccess]);
|
||||
|
||||
const onPublishLibSuccess = useCallback(
|
||||
(data) => {
|
||||
(data, libraryItems: LibraryItems) => {
|
||||
setShowPublishLibraryDialog(false);
|
||||
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
|
||||
const nextLibItems = libraryItems.slice();
|
||||
@ -225,102 +220,114 @@ export const LibraryMenu = ({
|
||||
libItem.status = "published";
|
||||
}
|
||||
});
|
||||
library.saveLibrary(nextLibItems);
|
||||
setLibraryItems(nextLibItems);
|
||||
library.setLibrary(nextLibItems);
|
||||
},
|
||||
[
|
||||
setShowPublishLibraryDialog,
|
||||
setPublishLibSuccess,
|
||||
libraryItems,
|
||||
selectedItems,
|
||||
library,
|
||||
],
|
||||
[setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library],
|
||||
);
|
||||
|
||||
const [lastSelectedItem, setLastSelectedItem] = useState<
|
||||
LibraryItem["id"] | null
|
||||
>(null);
|
||||
|
||||
return loadingState === "preloading" ? null : (
|
||||
<Island padding={1} ref={ref} className="layer-ui__library">
|
||||
if (
|
||||
libraryItemsData.status === "loading" &&
|
||||
!libraryItemsData.isInitialized
|
||||
) {
|
||||
return (
|
||||
<LibraryMenuWrapper ref={ref}>
|
||||
<div className="layer-ui__library-message">
|
||||
<Spinner size="2em" />
|
||||
<span>{t("labels.libraryLoadingMessage")}</span>
|
||||
</div>
|
||||
</LibraryMenuWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LibraryMenuWrapper ref={ref}>
|
||||
{showPublishLibraryDialog && (
|
||||
<PublishLibrary
|
||||
onClose={() => setShowPublishLibraryDialog(false)}
|
||||
libraryItems={getSelectedItems(libraryItems, selectedItems)}
|
||||
libraryItems={getSelectedItems(
|
||||
libraryItemsData.libraryItems,
|
||||
selectedItems,
|
||||
)}
|
||||
appState={appState}
|
||||
onSuccess={onPublishLibSuccess}
|
||||
onSuccess={(data) =>
|
||||
onPublishLibSuccess(data, libraryItemsData.libraryItems)
|
||||
}
|
||||
onError={(error) => window.alert(error)}
|
||||
updateItemsInStorage={() => library.saveLibrary(libraryItems)}
|
||||
updateItemsInStorage={() =>
|
||||
library.setLibrary(libraryItemsData.libraryItems)
|
||||
}
|
||||
onRemove={(id: string) =>
|
||||
setSelectedItems(selectedItems.filter((_id) => _id !== id))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{publishLibSuccess && renderPublishSuccess()}
|
||||
<LibraryMenuItems
|
||||
isLoading={libraryItemsData.status === "loading"}
|
||||
libraryItems={libraryItemsData.libraryItems}
|
||||
onRemoveFromLibrary={() =>
|
||||
removeFromLibrary(libraryItemsData.libraryItems)
|
||||
}
|
||||
onAddToLibrary={(elements) =>
|
||||
addToLibrary(elements, libraryItemsData.libraryItems)
|
||||
}
|
||||
onInsertShape={onInsertShape}
|
||||
pendingElements={pendingElements}
|
||||
setAppState={setAppState}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
library={library}
|
||||
theme={theme}
|
||||
files={files}
|
||||
id={id}
|
||||
selectedItems={selectedItems}
|
||||
onToggle={(id, event) => {
|
||||
const shouldSelect = !selectedItems.includes(id);
|
||||
|
||||
{loadingState === "loading" ? (
|
||||
<div className="layer-ui__library-message">
|
||||
{t("labels.libraryLoadingMessage")}
|
||||
</div>
|
||||
) : (
|
||||
<LibraryMenuItems
|
||||
libraryItems={libraryItems}
|
||||
onRemoveFromLibrary={removeFromLibrary}
|
||||
onAddToLibrary={addToLibrary}
|
||||
onInsertShape={onInsertShape}
|
||||
pendingElements={pendingElements}
|
||||
setAppState={setAppState}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
library={library}
|
||||
theme={theme}
|
||||
files={files}
|
||||
id={id}
|
||||
selectedItems={selectedItems}
|
||||
onToggle={(id, event) => {
|
||||
const shouldSelect = !selectedItems.includes(id);
|
||||
if (shouldSelect) {
|
||||
if (event.shiftKey && lastSelectedItem) {
|
||||
const rangeStart = libraryItemsData.libraryItems.findIndex(
|
||||
(item) => item.id === lastSelectedItem,
|
||||
);
|
||||
const rangeEnd = libraryItemsData.libraryItems.findIndex(
|
||||
(item) => item.id === id,
|
||||
);
|
||||
|
||||
if (shouldSelect) {
|
||||
if (event.shiftKey && lastSelectedItem) {
|
||||
const rangeStart = libraryItems.findIndex(
|
||||
(item) => item.id === lastSelectedItem,
|
||||
);
|
||||
const rangeEnd = libraryItems.findIndex(
|
||||
(item) => item.id === id,
|
||||
);
|
||||
|
||||
if (rangeStart === -1 || rangeEnd === -1) {
|
||||
setSelectedItems([...selectedItems, id]);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedItemsMap = arrayToMap(selectedItems);
|
||||
const nextSelectedIds = libraryItems.reduce(
|
||||
(acc: LibraryItem["id"][], item, idx) => {
|
||||
if (
|
||||
(idx >= rangeStart && idx <= rangeEnd) ||
|
||||
selectedItemsMap.has(item.id)
|
||||
) {
|
||||
acc.push(item.id);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
setSelectedItems(nextSelectedIds);
|
||||
} else {
|
||||
if (rangeStart === -1 || rangeEnd === -1) {
|
||||
setSelectedItems([...selectedItems, id]);
|
||||
return;
|
||||
}
|
||||
setLastSelectedItem(id);
|
||||
|
||||
const selectedItemsMap = arrayToMap(selectedItems);
|
||||
const nextSelectedIds = libraryItemsData.libraryItems.reduce(
|
||||
(acc: LibraryItem["id"][], item, idx) => {
|
||||
if (
|
||||
(idx >= rangeStart && idx <= rangeEnd) ||
|
||||
selectedItemsMap.has(item.id)
|
||||
) {
|
||||
acc.push(item.id);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
setSelectedItems(nextSelectedIds);
|
||||
} else {
|
||||
setLastSelectedItem(null);
|
||||
setSelectedItems(selectedItems.filter((_id) => _id !== id));
|
||||
setSelectedItems([...selectedItems, id]);
|
||||
}
|
||||
}}
|
||||
onPublish={() => setShowPublishLibraryDialog(true)}
|
||||
resetLibrary={resetLibrary}
|
||||
/>
|
||||
)}
|
||||
</Island>
|
||||
setLastSelectedItem(id);
|
||||
} else {
|
||||
setLastSelectedItem(null);
|
||||
setSelectedItems(selectedItems.filter((_id) => _id !== id));
|
||||
}
|
||||
}}
|
||||
onPublish={() => setShowPublishLibraryDialog(true)}
|
||||
resetLibrary={resetLibrary}
|
||||
/>
|
||||
</LibraryMenuWrapper>
|
||||
);
|
||||
};
|
||||
|
@ -12,7 +12,7 @@ import {
|
||||
LibraryItems,
|
||||
} from "../types";
|
||||
import { muteFSAbortError } from "../utils";
|
||||
import { useIsMobile } from "./App";
|
||||
import { useDeviceType } from "./App";
|
||||
import ConfirmDialog from "./ConfirmDialog";
|
||||
import { exportToFileIcon, load, publishIcon, trash } from "./icons";
|
||||
import { LibraryUnit } from "./LibraryUnit";
|
||||
@ -22,8 +22,10 @@ import { Tooltip } from "./Tooltip";
|
||||
|
||||
import "./LibraryMenuItems.scss";
|
||||
import { VERSIONS } from "../constants";
|
||||
import Spinner from "./Spinner";
|
||||
|
||||
const LibraryMenuItems = ({
|
||||
isLoading,
|
||||
libraryItems,
|
||||
onRemoveFromLibrary,
|
||||
onAddToLibrary,
|
||||
@ -40,6 +42,7 @@ const LibraryMenuItems = ({
|
||||
onPublish,
|
||||
resetLibrary,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
libraryItems: LibraryItems;
|
||||
pendingElements: LibraryItem["elements"];
|
||||
onRemoveFromLibrary: () => void;
|
||||
@ -85,7 +88,7 @@ const LibraryMenuItems = ({
|
||||
|
||||
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const isMobile = useDeviceType().isMobile;
|
||||
|
||||
const renderLibraryActions = () => {
|
||||
const itemsSelected = !!selectedItems.length;
|
||||
@ -106,14 +109,10 @@ const LibraryMenuItems = ({
|
||||
icon={load}
|
||||
onClick={() => {
|
||||
importLibraryFromJSON(library)
|
||||
.then(() => {
|
||||
// Close and then open to get the libraries updated
|
||||
setAppState({ isLibraryOpen: false });
|
||||
setAppState({ isLibraryOpen: true });
|
||||
})
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
setAppState({ errorMessage: error.message });
|
||||
console.error(error);
|
||||
setAppState({ errorMessage: t("errors.importLibraryError") });
|
||||
});
|
||||
}}
|
||||
className="library-actions--load"
|
||||
@ -130,7 +129,7 @@ const LibraryMenuItems = ({
|
||||
onClick={async () => {
|
||||
const libraryItems = itemsSelected
|
||||
? items
|
||||
: await library.loadLibrary();
|
||||
: await library.getLatestLibrary();
|
||||
saveLibraryAsJSON(libraryItems)
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
@ -289,16 +288,20 @@ const LibraryMenuItems = ({
|
||||
{showRemoveLibAlert && renderRemoveLibAlert()}
|
||||
<div className="layer-ui__library-header" key="library-header">
|
||||
{renderLibraryActions()}
|
||||
<a
|
||||
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
||||
window.name || "_blank"
|
||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
||||
VERSIONS.excalidrawLibrary
|
||||
}`}
|
||||
target="_excalidraw_libraries"
|
||||
>
|
||||
{t("labels.libraries")}
|
||||
</a>
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<a
|
||||
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
||||
window.name || "_blank"
|
||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
||||
VERSIONS.excalidrawLibrary
|
||||
}`}
|
||||
target="_excalidraw_libraries"
|
||||
>
|
||||
{t("labels.libraries")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<Stack.Col
|
||||
className="library-menu-items-container__items"
|
||||
|
@ -2,7 +2,7 @@ import clsx from "clsx";
|
||||
import oc from "open-color";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { MIME_TYPES } from "../constants";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { useDeviceType } from "../components/App";
|
||||
import { exportToSvg } from "../scene/export";
|
||||
import { BinaryFiles, LibraryItem } from "../types";
|
||||
import "./LibraryUnit.scss";
|
||||
@ -66,7 +66,7 @@ export const LibraryUnit = ({
|
||||
}, [elements, files]);
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const isMobile = useDeviceType().isMobile;
|
||||
const adder = isPending && (
|
||||
<div className="library-unit__adder">{PLUS_ICON}</div>
|
||||
);
|
||||
|
@ -1,10 +1,30 @@
|
||||
import { t } from "../i18n";
|
||||
import { useState, useEffect } from "react";
|
||||
import Spinner from "./Spinner";
|
||||
|
||||
export const LoadingMessage: React.FC<{ delay?: number }> = ({ delay }) => {
|
||||
const [isWaiting, setIsWaiting] = useState(!!delay);
|
||||
|
||||
useEffect(() => {
|
||||
if (!delay) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
setIsWaiting(false);
|
||||
}, delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [delay]);
|
||||
|
||||
if (isWaiting) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export const LoadingMessage = () => {
|
||||
// !! KEEP THIS IN SYNC WITH index.html !!
|
||||
return (
|
||||
<div className="LoadingMessage">
|
||||
<span>{t("labels.loadingScene")}</span>
|
||||
<div>
|
||||
<Spinner />
|
||||
</div>
|
||||
<div className="LoadingMessage-text">{t("labels.loadingScene")}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -8,7 +8,7 @@ import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { FixedSideContainer } from "./FixedSideContainer";
|
||||
import { Island } from "./Island";
|
||||
import { HintViewer } from "./HintViewer";
|
||||
import { calculateScrollCenter } from "../scene";
|
||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
||||
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||
import { Section } from "./Section";
|
||||
import CollabButton from "./CollabButton";
|
||||
@ -72,8 +72,9 @@ export const MobileMenu = ({
|
||||
{heading}
|
||||
<Stack.Row gap={1}>
|
||||
<ShapesSwitcher
|
||||
appState={appState}
|
||||
canvas={canvas}
|
||||
elementType={appState.elementType}
|
||||
activeTool={appState.activeTool}
|
||||
setAppState={setAppState}
|
||||
onImageAction={({ pointerType }) => {
|
||||
onImageAction({
|
||||
@ -85,7 +86,7 @@ export const MobileMenu = ({
|
||||
</Island>
|
||||
{renderTopRightUI && renderTopRightUI(true, appState)}
|
||||
<LockButton
|
||||
checked={appState.elementLocked}
|
||||
checked={appState.activeTool.locked}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
isMobile
|
||||
@ -113,6 +114,12 @@ export const MobileMenu = ({
|
||||
};
|
||||
|
||||
const renderAppToolbar = () => {
|
||||
// Render eraser conditionally in mobile
|
||||
const showEraser =
|
||||
!appState.viewModeEnabled &&
|
||||
!appState.editingElement &&
|
||||
getSelectedElements(elements, appState).length === 0;
|
||||
|
||||
if (viewModeEnabled) {
|
||||
return (
|
||||
<div className="App-toolbar-content">
|
||||
@ -120,12 +127,16 @@ export const MobileMenu = ({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="App-toolbar-content">
|
||||
{actionManager.renderAction("toggleCanvasMenu")}
|
||||
{actionManager.renderAction("toggleEditMenu")}
|
||||
|
||||
{actionManager.renderAction("undo")}
|
||||
{actionManager.renderAction("redo")}
|
||||
{showEraser && actionManager.renderAction("eraser")}
|
||||
|
||||
{actionManager.renderAction(
|
||||
appState.multiElement ? "finalize" : "duplicateSelection",
|
||||
)}
|
||||
@ -215,7 +226,7 @@ export const MobileMenu = ({
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
renderAction={actionManager.renderAction}
|
||||
elementType={appState.elementType}
|
||||
activeTool={appState.activeTool.type}
|
||||
/>
|
||||
</Section>
|
||||
) : null}
|
||||
|
@ -4,7 +4,7 @@ import React, { useState, useLayoutEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import clsx from "clsx";
|
||||
import { KEYS } from "../keys";
|
||||
import { useExcalidrawContainer, useIsMobile } from "./App";
|
||||
import { useExcalidrawContainer, useDeviceType } from "./App";
|
||||
import { AppState } from "../types";
|
||||
import { THEME } from "../constants";
|
||||
|
||||
@ -59,17 +59,17 @@ export const Modal = (props: {
|
||||
const useBodyRoot = (theme: AppState["theme"]) => {
|
||||
const [div, setDiv] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const isMobileRef = useRef(isMobile);
|
||||
isMobileRef.current = isMobile;
|
||||
const deviceType = useDeviceType();
|
||||
const isMobileRef = useRef(deviceType.isMobile);
|
||||
isMobileRef.current = deviceType.isMobile;
|
||||
|
||||
const { container: excalidrawContainer } = useExcalidrawContainer();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (div) {
|
||||
div.classList.toggle("excalidraw--mobile", isMobile);
|
||||
div.classList.toggle("excalidraw--mobile", deviceType.isMobile);
|
||||
}
|
||||
}, [div, isMobile]);
|
||||
}, [div, deviceType.isMobile]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const isDarkTheme =
|
||||
|
@ -2,7 +2,7 @@ import React from "react";
|
||||
import { getCommonBounds } from "../element/bounds";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { useDeviceType } from "../components/App";
|
||||
import { getTargetElements } from "../scene";
|
||||
import { AppState, ExcalidrawProps } from "../types";
|
||||
import { close } from "./icons";
|
||||
@ -16,13 +16,13 @@ export const Stats = (props: {
|
||||
onClose: () => void;
|
||||
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
||||
}) => {
|
||||
const isMobile = useIsMobile();
|
||||
const deviceType = useDeviceType();
|
||||
|
||||
const boundingBox = getCommonBounds(props.elements);
|
||||
const selectedElements = getTargetElements(props.elements, props.appState);
|
||||
const selectedBoundingBox = getCommonBounds(selectedElements);
|
||||
|
||||
if (isMobile && props.appState.openMenu) {
|
||||
if (deviceType.isMobile && props.appState.openMenu) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -48,6 +48,7 @@ type ToolButtonProps =
|
||||
type: "radio";
|
||||
checked: boolean;
|
||||
onChange?(data: { pointerType: PointerType | null }): void;
|
||||
onPointerDown?(data: { pointerType: PointerType }): void;
|
||||
});
|
||||
|
||||
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
@ -149,6 +150,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
title={props.title}
|
||||
onPointerDown={(event) => {
|
||||
lastPointerTypeRef.current = event.pointerType || null;
|
||||
props.onPointerDown?.({ pointerType: event.pointerType || null });
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
requestAnimationFrame(() => {
|
||||
|
@ -155,7 +155,7 @@
|
||||
}
|
||||
|
||||
width: 2rem;
|
||||
height: 2em;
|
||||
height: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
// container in body where the actual tooltip is appended to
|
||||
.excalidraw-tooltip {
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
|
||||
padding: 8px;
|
||||
|
@ -885,6 +885,40 @@ export const TextAlignRightIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
),
|
||||
);
|
||||
|
||||
export const TextAlignTopIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="m16,132l416,0c8.837,0 16,-7.163 16,-16l0,-40c0,-8.837 -7.163,-16 -16,-16l-416,0c-8.837,0 -16,7.163 -16,16l0,40c0,8.837 7.163,16 16,16zm0,160l416,0c8.837,0 16,-7.163 16,-16l0,-40c0,-8.837 -7.163,-16 -16,-16l-416,0c-8.837,0 -16,7.163 -16,16l0,40c0,8.837 7.163,16 16,16z"
|
||||
fill={iconFillColor(theme)}
|
||||
strokeLinecap="round"
|
||||
/>,
|
||||
{ width: 448, height: 512 },
|
||||
),
|
||||
);
|
||||
|
||||
export const TextAlignBottomIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
d="M16,292L432,292C440.837,292 448,284.837 448,276L448,236C448,227.163 440.837,220 432,220L16,220C7.163,220 0,227.163 0,236L0,276C0,284.837 7.163,292 16,292ZM16,452L432,452C440.837,452 448,444.837 448,436L448,396C448,387.163 440.837,380 432,380L16,380C7.163,380 0,387.163 0,396L0,436C0,444.837 7.163,452 16,452Z"
|
||||
fill={iconFillColor(theme)}
|
||||
strokeLinecap="round"
|
||||
/>,
|
||||
{ width: 448, height: 512 },
|
||||
),
|
||||
);
|
||||
|
||||
export const TextAlignMiddleIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
transform="matrix(1,0,0,1,0,80)"
|
||||
d="M16,132L432,132C440.837,132 448,124.837 448,116L448,76C448,67.163 440.837,60 432,60L16,60C7.163,60 0,67.163 0,76L0,116C0,124.837 7.163,132 16,132ZM16,292L432,292C440.837,292 448,284.837 448,276L448,236C448,227.163 440.837,220 432,220L16,220C7.163,220 0,227.163 0,236L0,276C0,284.837 7.163,292 16,292Z"
|
||||
fill={iconFillColor(theme)}
|
||||
strokeLinecap="round"
|
||||
/>,
|
||||
{ width: 448, height: 512 },
|
||||
),
|
||||
);
|
||||
|
||||
export const publishIcon = createIcon(
|
||||
<path
|
||||
d="M537.6 226.6c4.1-10.7 6.4-22.4 6.4-34.6 0-53-43-96-96-96-19.7 0-38.1 6-53.3 16.2C367 64.2 315.3 32 256 32c-88.4 0-160 71.6-160 160 0 2.7.1 5.4.2 8.1C40.2 219.8 0 273.2 0 336c0 79.5 64.5 144 144 144h368c70.7 0 128-57.3 128-128 0-61.9-44-113.6-102.4-125.4zM393.4 288H328v112c0 8.8-7.2 16-16 16h-48c-8.8 0-16-7.2-16-16V288h-65.4c-14.3 0-21.4-17.2-11.3-27.3l105.4-105.4c6.2-6.2 16.4-6.2 22.6 0l105.4 105.4c10.1 10.1 2.9 27.3-11.3 27.3z"
|
||||
@ -900,3 +934,7 @@ export const editIcon = createIcon(
|
||||
></path>,
|
||||
{ width: 640, height: 512 },
|
||||
);
|
||||
|
||||
export const eraser = createIcon(
|
||||
<path d="M480 416C497.7 416 512 430.3 512 448C512 465.7 497.7 480 480 480H150.6C133.7 480 117.4 473.3 105.4 461.3L25.37 381.3C.3786 356.3 .3786 315.7 25.37 290.7L258.7 57.37C283.7 32.38 324.3 32.38 349.3 57.37L486.6 194.7C511.6 219.7 511.6 260.3 486.6 285.3L355.9 416H480zM265.4 416L332.7 348.7L195.3 211.3L70.63 336L150.6 416L265.4 416z" />,
|
||||
);
|
||||
|
@ -1,5 +1,5 @@
|
||||
import cssVariables from "./css/variables.module.scss";
|
||||
import { AppProps } from "./types";
|
||||
import { AppProps, CustomElementConfig } from "./types";
|
||||
import { FontFamilyValues } from "./element/types";
|
||||
|
||||
export const APP_NAME = "Excalidraw";
|
||||
@ -94,7 +94,9 @@ export const MIME_TYPES = {
|
||||
excalidrawlib: "application/vnd.excalidrawlib+json",
|
||||
json: "application/json",
|
||||
svg: "image/svg+xml",
|
||||
"excalidraw.svg": "image/svg+xml",
|
||||
png: "image/png",
|
||||
"excalidraw.png": "image/png",
|
||||
jpg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
binary: "application/octet-stream",
|
||||
@ -106,7 +108,8 @@ export const EXPORT_DATA_TYPES = {
|
||||
excalidrawLibrary: "excalidrawlib",
|
||||
} as const;
|
||||
|
||||
export const EXPORT_SOURCE = window.location.origin;
|
||||
export const EXPORT_SOURCE =
|
||||
window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin;
|
||||
|
||||
// time in milliseconds
|
||||
export const IMAGE_RENDER_TIMEOUT = 500;
|
||||
@ -152,6 +155,17 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
||||
},
|
||||
};
|
||||
|
||||
export const DEFAULT_CUSTOM_ELEMENT_CONFIG: Required<CustomElementConfig> = {
|
||||
type: "custom",
|
||||
customType: "custom",
|
||||
transformHandles: true,
|
||||
displayData: { content: "", type: "svg" },
|
||||
width: 40,
|
||||
height: 40,
|
||||
stackedOnTop: false,
|
||||
onCreate: () => {},
|
||||
disableContextMenu: false,
|
||||
};
|
||||
export const MQ_MAX_WIDTH_PORTRAIT = 730;
|
||||
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
||||
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
|
||||
@ -182,3 +196,11 @@ export const VERSIONS = {
|
||||
} as const;
|
||||
|
||||
export const BOUND_TEXT_PADDING = 5;
|
||||
|
||||
export const VERTICAL_ALIGN = {
|
||||
TOP: "top",
|
||||
MIDDLE: "middle",
|
||||
BOTTOM: "bottom",
|
||||
};
|
||||
|
||||
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
|
||||
|
@ -16,15 +16,17 @@
|
||||
left: 0;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.LoadingMessage span {
|
||||
background-color: var(--button-gray-1);
|
||||
border-radius: 5px;
|
||||
padding: 0.8em 1.2em;
|
||||
color: var(--popup-text-color);
|
||||
font-size: 1.3em;
|
||||
.Spinner {
|
||||
font-size: 2.8em;
|
||||
}
|
||||
|
||||
.LoadingMessage-text {
|
||||
margin-top: 1em;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
||||
|
@ -290,6 +290,16 @@
|
||||
width: 100%;
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
.eraser {
|
||||
&.ToolIcon:hover {
|
||||
--icon-fill-color: #fff;
|
||||
--keybinding-color: #fff;
|
||||
}
|
||||
&.active {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.App-toolbar-content {
|
||||
@ -467,7 +477,17 @@
|
||||
font-family: var(--ui-font);
|
||||
}
|
||||
|
||||
.undo-redo-buttons {
|
||||
.finalize-button {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
gap: 0.4em;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
margin-inline-start: 0.6em;
|
||||
}
|
||||
|
||||
.undo-redo-buttons,
|
||||
.eraser-buttons {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
gap: 0.4em;
|
||||
|
@ -1,20 +1,16 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { cleanAppStateForExport } from "../appState";
|
||||
import {
|
||||
ALLOWED_IMAGE_MIME_TYPES,
|
||||
EXPORT_DATA_TYPES,
|
||||
MIME_TYPES,
|
||||
} from "../constants";
|
||||
import { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
|
||||
import { clearElementsForExport } from "../element";
|
||||
import { ExcalidrawElement, FileId } from "../element/types";
|
||||
import { CanvasError } from "../errors";
|
||||
import { t } from "../i18n";
|
||||
import { calculateScrollCenter } from "../scene";
|
||||
import { AppState, DataURL } from "../types";
|
||||
import { AppState, DataURL, LibraryItem } from "../types";
|
||||
import { bytesToHexString } from "../utils";
|
||||
import { FileSystemHandle } from "./filesystem";
|
||||
import { isValidExcalidrawData } from "./json";
|
||||
import { restore } from "./restore";
|
||||
import { isValidExcalidrawData, isValidLibrary } from "./json";
|
||||
import { restore, restoreLibraryItems } from "./restore";
|
||||
import { ImportedLibraryData } from "./types";
|
||||
|
||||
const parseFileContents = async (blob: Blob | File) => {
|
||||
@ -163,13 +159,17 @@ export const loadFromBlob = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const loadLibraryFromBlob = async (blob: Blob) => {
|
||||
export const loadLibraryFromBlob = async (
|
||||
blob: Blob,
|
||||
defaultStatus: LibraryItem["status"] = "unpublished",
|
||||
) => {
|
||||
const contents = await parseFileContents(blob);
|
||||
const data: ImportedLibraryData = JSON.parse(contents);
|
||||
if (data.type !== EXPORT_DATA_TYPES.excalidrawLibrary) {
|
||||
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
||||
const data: ImportedLibraryData | undefined = JSON.parse(contents);
|
||||
if (!isValidLibrary(data)) {
|
||||
throw new Error("Invalid library");
|
||||
}
|
||||
return data;
|
||||
const libraryItems = data.libraryItems || data.library;
|
||||
return restoreLibraryItems(libraryItems, defaultStatus);
|
||||
};
|
||||
|
||||
export const canvasToBlob = async (
|
||||
|
@ -13,7 +13,9 @@ type FILE_EXTENSION =
|
||||
| "gif"
|
||||
| "jpg"
|
||||
| "png"
|
||||
| "excalidraw.png"
|
||||
| "svg"
|
||||
| "excalidraw.svg"
|
||||
| "json"
|
||||
| "excalidraw"
|
||||
| "excalidrawlib";
|
||||
|
@ -105,7 +105,9 @@ export const encodeSvgMetadata = async ({ text }: { text: string }) => {
|
||||
|
||||
export const decodeSvgMetadata = async ({ svg }: { svg: string }) => {
|
||||
if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) {
|
||||
const match = svg.match(/<!-- payload-start -->(.+?)<!-- payload-end -->/);
|
||||
const match = svg.match(
|
||||
/<!-- payload-start -->\s*(.+?)\s*<!-- payload-end -->/,
|
||||
);
|
||||
if (!match) {
|
||||
throw new Error("INVALID");
|
||||
}
|
||||
|
@ -16,7 +16,7 @@ export { loadFromBlob } from "./blob";
|
||||
export { loadFromJSON, saveAsJSON } from "./json";
|
||||
|
||||
export const exportCanvas = async (
|
||||
type: ExportType,
|
||||
type: Omit<ExportType, "backend">,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
@ -56,7 +56,7 @@ export const exportCanvas = async (
|
||||
{
|
||||
description: "Export to SVG",
|
||||
name,
|
||||
extension: "svg",
|
||||
extension: appState.exportEmbedScene ? "excalidraw.svg" : "svg",
|
||||
fileHandle,
|
||||
},
|
||||
);
|
||||
@ -73,10 +73,10 @@ export const exportCanvas = async (
|
||||
});
|
||||
tempCanvas.style.display = "none";
|
||||
document.body.appendChild(tempCanvas);
|
||||
let blob = await canvasToBlob(tempCanvas);
|
||||
tempCanvas.remove();
|
||||
|
||||
if (type === "png") {
|
||||
let blob = await canvasToBlob(tempCanvas);
|
||||
tempCanvas.remove();
|
||||
if (appState.exportEmbedScene) {
|
||||
blob = await (
|
||||
await import(/* webpackChunkName: "image" */ "./image")
|
||||
@ -89,17 +89,24 @@ export const exportCanvas = async (
|
||||
return await fileSave(blob, {
|
||||
description: "Export to PNG",
|
||||
name,
|
||||
extension: "png",
|
||||
extension: appState.exportEmbedScene ? "excalidraw.png" : "png",
|
||||
fileHandle,
|
||||
});
|
||||
} else if (type === "clipboard") {
|
||||
try {
|
||||
const blob = canvasToBlob(tempCanvas);
|
||||
await copyBlobToClipboardAsPng(blob);
|
||||
} catch (error: any) {
|
||||
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(t("alerts.couldNotCopyToClipboard"));
|
||||
} finally {
|
||||
tempCanvas.remove();
|
||||
}
|
||||
} else {
|
||||
tempCanvas.remove();
|
||||
// shouldn't happen
|
||||
throw new Error("Unsupported export type");
|
||||
}
|
||||
};
|
||||
|
@ -15,6 +15,7 @@ import {
|
||||
ExportedDataState,
|
||||
ImportedDataState,
|
||||
ExportedLibraryData,
|
||||
ImportedLibraryData,
|
||||
} from "./types";
|
||||
import Library from "./library";
|
||||
|
||||
@ -114,7 +115,7 @@ export const isValidExcalidrawData = (data?: {
|
||||
);
|
||||
};
|
||||
|
||||
export const isValidLibrary = (json: any) => {
|
||||
export const isValidLibrary = (json: any): json is ImportedLibraryData => {
|
||||
return (
|
||||
typeof json === "object" &&
|
||||
json &&
|
||||
@ -123,14 +124,18 @@ export const isValidLibrary = (json: any) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
|
||||
export const serializeLibraryAsJSON = (libraryItems: LibraryItems) => {
|
||||
const data: ExportedLibraryData = {
|
||||
type: EXPORT_DATA_TYPES.excalidrawLibrary,
|
||||
version: VERSIONS.excalidrawLibrary,
|
||||
source: EXPORT_SOURCE,
|
||||
libraryItems,
|
||||
};
|
||||
const serialized = JSON.stringify(data, null, 2);
|
||||
return JSON.stringify(data, null, 2);
|
||||
};
|
||||
|
||||
export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
|
||||
const serialized = serializeLibraryAsJSON(libraryItems);
|
||||
await fileSave(
|
||||
new Blob([serialized], {
|
||||
type: MIME_TYPES.excalidrawlib,
|
||||
|
@ -1,121 +1,205 @@
|
||||
import { loadLibraryFromBlob } from "./blob";
|
||||
import { LibraryItems, LibraryItem } from "../types";
|
||||
import { restoreElements, restoreLibraryItems } from "./restore";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { restoreLibraryItems } from "./restore";
|
||||
import type App from "../components/App";
|
||||
import { ImportedDataState } from "./types";
|
||||
import { atom } from "jotai";
|
||||
import { jotaiStore } from "../jotai";
|
||||
|
||||
export const libraryItemsAtom = atom<{
|
||||
status: "loading" | "loaded";
|
||||
isInitialized: boolean;
|
||||
libraryItems: LibraryItems;
|
||||
}>({ status: "loaded", isInitialized: true, libraryItems: [] });
|
||||
|
||||
const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
|
||||
JSON.parse(JSON.stringify(libraryItems));
|
||||
|
||||
/**
|
||||
* checks if library item does not exist already in current library
|
||||
*/
|
||||
const isUniqueItem = (
|
||||
existingLibraryItems: LibraryItems,
|
||||
targetLibraryItem: LibraryItem,
|
||||
) => {
|
||||
return !existingLibraryItems.find((libraryItem) => {
|
||||
if (libraryItem.elements.length !== targetLibraryItem.elements.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// detect z-index difference by checking the excalidraw elements
|
||||
// are in order
|
||||
return libraryItem.elements.every((libItemExcalidrawItem, idx) => {
|
||||
return (
|
||||
libItemExcalidrawItem.id === targetLibraryItem.elements[idx].id &&
|
||||
libItemExcalidrawItem.versionNonce ===
|
||||
targetLibraryItem.elements[idx].versionNonce
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/** Merges otherItems into localItems. Unique items in otherItems array are
|
||||
sorted first. */
|
||||
export const mergeLibraryItems = (
|
||||
localItems: LibraryItems,
|
||||
otherItems: LibraryItems,
|
||||
): LibraryItems => {
|
||||
const newItems = [];
|
||||
for (const item of otherItems) {
|
||||
if (isUniqueItem(localItems, item)) {
|
||||
newItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return [...newItems, ...localItems];
|
||||
};
|
||||
|
||||
class Library {
|
||||
private libraryCache: LibraryItems | null = null;
|
||||
/** latest libraryItems */
|
||||
private lastLibraryItems: LibraryItems = [];
|
||||
/** indicates whether library is initialized with library items (has gone
|
||||
* though at least one update) */
|
||||
private isInitialized = false;
|
||||
|
||||
private app: App;
|
||||
|
||||
constructor(app: App) {
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
resetLibrary = async () => {
|
||||
await this.app.props.onLibraryChange?.([]);
|
||||
this.libraryCache = [];
|
||||
private updateQueue: Promise<LibraryItems>[] = [];
|
||||
|
||||
private getLastUpdateTask = (): Promise<LibraryItems> | undefined => {
|
||||
return this.updateQueue[this.updateQueue.length - 1];
|
||||
};
|
||||
|
||||
restoreLibraryItem = (libraryItem: LibraryItem): LibraryItem | null => {
|
||||
const elements = getNonDeletedElements(
|
||||
restoreElements(libraryItem.elements, null),
|
||||
);
|
||||
return elements.length ? { ...libraryItem, elements } : null;
|
||||
};
|
||||
|
||||
/** imports library (currently merges, removing duplicates) */
|
||||
async importLibrary(blob: Blob, defaultStatus = "unpublished") {
|
||||
const libraryFile = await loadLibraryFromBlob(blob);
|
||||
if (!libraryFile || !(libraryFile.libraryItems || libraryFile.library)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* checks if library item does not exist already in current library
|
||||
*/
|
||||
const isUniqueitem = (
|
||||
existingLibraryItems: LibraryItems,
|
||||
targetLibraryItem: LibraryItem,
|
||||
) => {
|
||||
return !existingLibraryItems.find((libraryItem) => {
|
||||
if (libraryItem.elements.length !== targetLibraryItem.elements.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// detect z-index difference by checking the excalidraw elements
|
||||
// are in order
|
||||
return libraryItem.elements.every((libItemExcalidrawItem, idx) => {
|
||||
return (
|
||||
libItemExcalidrawItem.id === targetLibraryItem.elements[idx].id &&
|
||||
libItemExcalidrawItem.versionNonce ===
|
||||
targetLibraryItem.elements[idx].versionNonce
|
||||
);
|
||||
});
|
||||
private notifyListeners = () => {
|
||||
if (this.updateQueue.length > 0) {
|
||||
jotaiStore.set(libraryItemsAtom, {
|
||||
status: "loading",
|
||||
libraryItems: this.lastLibraryItems,
|
||||
isInitialized: this.isInitialized,
|
||||
});
|
||||
};
|
||||
|
||||
const existingLibraryItems = await this.loadLibrary();
|
||||
|
||||
const library = libraryFile.libraryItems || libraryFile.library || [];
|
||||
const restoredLibItems = restoreLibraryItems(
|
||||
library,
|
||||
defaultStatus as "published" | "unpublished",
|
||||
);
|
||||
const filteredItems = [];
|
||||
for (const item of restoredLibItems) {
|
||||
const restoredItem = this.restoreLibraryItem(item as LibraryItem);
|
||||
if (restoredItem && isUniqueitem(existingLibraryItems, restoredItem)) {
|
||||
filteredItems.push(restoredItem);
|
||||
} else {
|
||||
this.isInitialized = true;
|
||||
jotaiStore.set(libraryItemsAtom, {
|
||||
status: "loaded",
|
||||
libraryItems: this.lastLibraryItems,
|
||||
isInitialized: this.isInitialized,
|
||||
});
|
||||
try {
|
||||
this.app.props.onLibraryChange?.(
|
||||
cloneLibraryItems(this.lastLibraryItems),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await this.saveLibrary([...filteredItems, ...existingLibraryItems]);
|
||||
resetLibrary = () => {
|
||||
return this.setLibrary([]);
|
||||
};
|
||||
|
||||
/**
|
||||
* imports library (from blob or libraryItems), merging with current library
|
||||
* (attempting to remove duplicates)
|
||||
*/
|
||||
importLibrary(
|
||||
library:
|
||||
| Blob
|
||||
| Required<ImportedDataState>["libraryItems"]
|
||||
| Promise<Required<ImportedDataState>["libraryItems"]>,
|
||||
defaultStatus: LibraryItem["status"] = "unpublished",
|
||||
): Promise<LibraryItems> {
|
||||
return this.setLibrary(
|
||||
() =>
|
||||
new Promise<LibraryItems>(async (resolve, reject) => {
|
||||
try {
|
||||
let libraryItems: LibraryItems;
|
||||
if (library instanceof Blob) {
|
||||
libraryItems = await loadLibraryFromBlob(library, defaultStatus);
|
||||
} else {
|
||||
libraryItems = restoreLibraryItems(await library, defaultStatus);
|
||||
}
|
||||
|
||||
resolve(mergeLibraryItems(this.lastLibraryItems, libraryItems));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
loadLibrary = (): Promise<LibraryItems> => {
|
||||
/**
|
||||
* @returns latest cloned libraryItems. Awaits all in-progress updates first.
|
||||
*/
|
||||
getLatestLibrary = (): Promise<LibraryItems> => {
|
||||
return new Promise(async (resolve) => {
|
||||
if (this.libraryCache) {
|
||||
return resolve(JSON.parse(JSON.stringify(this.libraryCache)));
|
||||
}
|
||||
|
||||
try {
|
||||
const libraryItems = this.app.libraryItemsFromStorage;
|
||||
if (!libraryItems) {
|
||||
return resolve([]);
|
||||
const libraryItems = await (this.getLastUpdateTask() ||
|
||||
this.lastLibraryItems);
|
||||
if (this.updateQueue.length > 0) {
|
||||
resolve(this.getLatestLibrary());
|
||||
} else {
|
||||
resolve(cloneLibraryItems(libraryItems));
|
||||
}
|
||||
|
||||
const items = libraryItems.reduce((acc, item) => {
|
||||
const restoredItem = this.restoreLibraryItem(item);
|
||||
if (restoredItem) {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
}, [] as Mutable<LibraryItems>);
|
||||
|
||||
// clone to ensure we don't mutate the cached library elements in the app
|
||||
this.libraryCache = JSON.parse(JSON.stringify(items));
|
||||
|
||||
resolve(items);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
resolve([]);
|
||||
} catch (error) {
|
||||
return resolve(this.lastLibraryItems);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
saveLibrary = async (items: LibraryItems) => {
|
||||
const prevLibraryItems = this.libraryCache;
|
||||
try {
|
||||
const serializedItems = JSON.stringify(items);
|
||||
// cache optimistically so that the app has access to the latest
|
||||
// immediately
|
||||
this.libraryCache = JSON.parse(serializedItems);
|
||||
await this.app.props.onLibraryChange?.(items);
|
||||
} catch (error: any) {
|
||||
this.libraryCache = prevLibraryItems;
|
||||
throw error;
|
||||
}
|
||||
setLibrary = (
|
||||
/**
|
||||
* LibraryItems that will replace current items. Can be a function which
|
||||
* will be invoked after all previous tasks are resolved
|
||||
* (this is the prefered way to update the library to avoid race conditions,
|
||||
* but you'll want to manually merge the library items in the callback
|
||||
* - which is what we're doing in Library.importLibrary()).
|
||||
*
|
||||
* If supplied promise is rejected with AbortError, we swallow it and
|
||||
* do not update the library.
|
||||
*/
|
||||
libraryItems:
|
||||
| LibraryItems
|
||||
| Promise<LibraryItems>
|
||||
| ((
|
||||
latestLibraryItems: LibraryItems,
|
||||
) => LibraryItems | Promise<LibraryItems>),
|
||||
): Promise<LibraryItems> => {
|
||||
const task = new Promise<LibraryItems>(async (resolve, reject) => {
|
||||
try {
|
||||
await this.getLastUpdateTask();
|
||||
|
||||
if (typeof libraryItems === "function") {
|
||||
libraryItems = libraryItems(this.lastLibraryItems);
|
||||
}
|
||||
|
||||
this.lastLibraryItems = cloneLibraryItems(await libraryItems);
|
||||
|
||||
resolve(this.lastLibraryItems);
|
||||
} catch (error: any) {
|
||||
reject(error);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.name === "AbortError") {
|
||||
console.warn("Library update aborted by user");
|
||||
return this.lastLibraryItems;
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
this.updateQueue = this.updateQueue.filter((_task) => _task !== task);
|
||||
this.notifyListeners();
|
||||
});
|
||||
|
||||
this.updateQueue.push(task);
|
||||
this.notifyListeners();
|
||||
|
||||
return task;
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -10,7 +10,11 @@ import {
|
||||
NormalizedZoomValue,
|
||||
} from "../types";
|
||||
import { ImportedDataState } from "./types";
|
||||
import { getNormalizedDimensions, isInvisiblySmallElement } from "../element";
|
||||
import {
|
||||
getNonDeletedElements,
|
||||
getNormalizedDimensions,
|
||||
isInvisiblySmallElement,
|
||||
} from "../element";
|
||||
import { isLinearElementType } from "../element/typeChecks";
|
||||
import { randomId } from "../random";
|
||||
import {
|
||||
@ -30,9 +34,9 @@ type RestoredAppState = Omit<
|
||||
"offsetTop" | "offsetLeft" | "width" | "height"
|
||||
>;
|
||||
|
||||
export const AllowedExcalidrawElementTypes: Record<
|
||||
ExcalidrawElement["type"],
|
||||
true
|
||||
export const AllowedExcalidrawActiveTools: Record<
|
||||
AppState["activeTool"]["type"],
|
||||
boolean
|
||||
> = {
|
||||
selection: true,
|
||||
text: true,
|
||||
@ -43,6 +47,8 @@ export const AllowedExcalidrawElementTypes: Record<
|
||||
image: true,
|
||||
arrow: true,
|
||||
freedraw: true,
|
||||
eraser: false,
|
||||
custom: true,
|
||||
};
|
||||
|
||||
export type RestoredDataState = {
|
||||
@ -106,6 +112,7 @@ const restoreElementWithProperties = <
|
||||
: element.boundElements ?? [],
|
||||
updated: element.updated ?? getUpdatedTimestamp(),
|
||||
link: element.link ?? null,
|
||||
locked: element.locked ?? false,
|
||||
};
|
||||
|
||||
return {
|
||||
@ -192,6 +199,10 @@ const restoreElement = (
|
||||
y,
|
||||
});
|
||||
}
|
||||
case "custom":
|
||||
return restoreElementWithProperties(element, {
|
||||
customType: element.customType || "custom",
|
||||
});
|
||||
// generic elements
|
||||
case "ellipse":
|
||||
return restoreElementWithProperties(element, {});
|
||||
@ -234,10 +245,8 @@ export const restoreAppState = (
|
||||
localAppState: Partial<AppState> | null | undefined,
|
||||
): RestoredAppState => {
|
||||
appState = appState || {};
|
||||
|
||||
const defaultAppState = getDefaultAppState();
|
||||
const nextAppState = {} as typeof defaultAppState;
|
||||
|
||||
for (const [key, defaultValue] of Object.entries(defaultAppState) as [
|
||||
keyof typeof defaultAppState,
|
||||
any,
|
||||
@ -251,12 +260,27 @@ export const restoreAppState = (
|
||||
? localValue
|
||||
: defaultValue;
|
||||
}
|
||||
|
||||
const activeTool: any = {
|
||||
lastActiveToolBeforeEraser: null,
|
||||
locked: nextAppState.activeTool.locked ?? false,
|
||||
type: "selection",
|
||||
};
|
||||
if (AllowedExcalidrawActiveTools[nextAppState.activeTool.type]) {
|
||||
if (nextAppState.activeTool.type === "custom") {
|
||||
activeTool.type = "custom";
|
||||
activeTool.customType = nextAppState.activeTool.customType ?? "custom";
|
||||
} else {
|
||||
activeTool.type = nextAppState.activeTool.type;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...nextAppState,
|
||||
elementType: AllowedExcalidrawElementTypes[nextAppState.elementType]
|
||||
? nextAppState.elementType
|
||||
: "selection",
|
||||
cursorButton: localAppState?.cursorButton || "up",
|
||||
// reset on fresh restore so as to hide the UI button if penMode not active
|
||||
penDetected:
|
||||
localAppState?.penDetected ??
|
||||
(appState.penMode ? appState.penDetected ?? false : false),
|
||||
activeTool,
|
||||
// Migrates from previous version where appState.zoom was a number
|
||||
zoom:
|
||||
typeof appState.zoom === "number"
|
||||
@ -268,7 +292,7 @@ export const restoreAppState = (
|
||||
};
|
||||
|
||||
export const restore = (
|
||||
data: ImportedDataState | null,
|
||||
data: Pick<ImportedDataState, "appState" | "elements" | "files"> | null,
|
||||
/**
|
||||
* Local AppState (`this.state` or initial state from localStorage) so that we
|
||||
* don't overwrite local state with default values (when values not
|
||||
@ -285,28 +309,45 @@ export const restore = (
|
||||
};
|
||||
};
|
||||
|
||||
const restoreLibraryItem = (libraryItem: LibraryItem) => {
|
||||
const elements = restoreElements(
|
||||
getNonDeletedElements(libraryItem.elements),
|
||||
null,
|
||||
);
|
||||
return elements.length ? { ...libraryItem, elements } : null;
|
||||
};
|
||||
|
||||
export const restoreLibraryItems = (
|
||||
libraryItems: NonOptional<ImportedDataState["libraryItems"]>,
|
||||
libraryItems: ImportedDataState["libraryItems"] = [],
|
||||
defaultStatus: LibraryItem["status"],
|
||||
) => {
|
||||
const restoredItems: LibraryItem[] = [];
|
||||
for (const item of libraryItems) {
|
||||
// migrate older libraries
|
||||
if (Array.isArray(item)) {
|
||||
restoredItems.push({
|
||||
const restoredItem = restoreLibraryItem({
|
||||
status: defaultStatus,
|
||||
elements: item,
|
||||
id: randomId(),
|
||||
created: Date.now(),
|
||||
});
|
||||
if (restoredItem) {
|
||||
restoredItems.push(restoredItem);
|
||||
}
|
||||
} else {
|
||||
const _item = item as MarkOptional<LibraryItem, "id" | "status">;
|
||||
restoredItems.push({
|
||||
const _item = item as MarkOptional<
|
||||
LibraryItem,
|
||||
"id" | "status" | "created"
|
||||
>;
|
||||
const restoredItem = restoreLibraryItem({
|
||||
..._item,
|
||||
id: _item.id || randomId(),
|
||||
status: _item.status || defaultStatus,
|
||||
created: _item.created || Date.now(),
|
||||
});
|
||||
if (restoredItem) {
|
||||
restoredItems.push(restoredItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
return restoredItems;
|
||||
|
@ -31,6 +31,7 @@ import { isPointHittingElementBoundingBox } from "./collision";
|
||||
import { getElementAbsoluteCoords } from "./";
|
||||
|
||||
import "./Hyperlink.scss";
|
||||
import { trackEvent } from "../analytics";
|
||||
|
||||
const CONTAINER_WIDTH = 320;
|
||||
const SPACE_BOTTOM = 85;
|
||||
@ -69,6 +70,10 @@ export const Hyperlink = ({
|
||||
|
||||
const link = normalizeLink(inputRef.current.value);
|
||||
|
||||
if (!element.link && link) {
|
||||
trackEvent("hyperlink", "create");
|
||||
}
|
||||
|
||||
mutateElement(element, { link });
|
||||
setAppState({ showHyperlinkPopup: "info" });
|
||||
}, [element, setAppState]);
|
||||
@ -108,6 +113,7 @@ export const Hyperlink = ({
|
||||
}, [appState, element, isEditing, setAppState]);
|
||||
|
||||
const handleRemove = useCallback(() => {
|
||||
trackEvent("hyperlink", "delete");
|
||||
mutateElement(element, { link: null });
|
||||
if (isEditing) {
|
||||
inputRef.current!.value = "";
|
||||
@ -116,6 +122,7 @@ export const Hyperlink = ({
|
||||
}, [setAppState, element, isEditing]);
|
||||
|
||||
const onEdit = () => {
|
||||
trackEvent("hyperlink", "edit", "popup-ui");
|
||||
setAppState({ showHyperlinkPopup: "editor" });
|
||||
};
|
||||
const { x, y } = getCoordsForPopover(element, appState);
|
||||
@ -239,11 +246,12 @@ export const isLocalLink = (link: string | null) => {
|
||||
};
|
||||
|
||||
export const actionLink = register({
|
||||
name: "link",
|
||||
name: "hyperlink",
|
||||
perform: (elements, appState) => {
|
||||
if (appState.showHyperlinkPopup === "editor") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState: {
|
||||
@ -254,6 +262,7 @@ export const actionLink = register({
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
trackEvent: { category: "hyperlink", action: "click" },
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K,
|
||||
contextItemLabel: (elements, appState) =>
|
||||
getContextMenuLabel(elements, appState),
|
||||
@ -326,6 +335,9 @@ export const isPointHittingLinkIcon = (
|
||||
[x, y]: Point,
|
||||
isMobile: boolean,
|
||||
) => {
|
||||
if (!element.link || appState.selectedElementIds[element.id]) {
|
||||
return false;
|
||||
}
|
||||
const threshold = 4 / appState.zoom.value;
|
||||
if (
|
||||
!isMobile &&
|
||||
@ -400,6 +412,7 @@ const renderTooltip = (
|
||||
},
|
||||
"top",
|
||||
);
|
||||
trackEvent("hyperlink", "tooltip", "link-icon");
|
||||
|
||||
IS_HYPERLINK_TOOLTIP_VISIBLE = true;
|
||||
};
|
||||
|
@ -255,7 +255,8 @@ export const getHoveredElementForBinding = (
|
||||
const hoveredElement = getElementAtPosition(
|
||||
scene.getElements(),
|
||||
(element) =>
|
||||
isBindableElement(element) && bindingBorderTest(element, pointerCoords),
|
||||
isBindableElement(element, false) &&
|
||||
bindingBorderTest(element, pointerCoords),
|
||||
);
|
||||
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
|
||||
};
|
||||
@ -456,13 +457,13 @@ export const getEligibleElementsForBinding = (
|
||||
): SuggestedBinding[] => {
|
||||
const includedElementIds = new Set(elements.map(({ id }) => id));
|
||||
return elements.flatMap((element) =>
|
||||
isBindingElement(element)
|
||||
isBindingElement(element, false)
|
||||
? (getElligibleElementsForBindingElement(
|
||||
element as NonDeleted<ExcalidrawLinearElement>,
|
||||
).filter(
|
||||
(element) => !includedElementIds.has(element.id),
|
||||
) as SuggestedBinding[])
|
||||
: isBindableElement(element)
|
||||
: isBindableElement(element, false)
|
||||
? getElligibleElementsForBindableElementAndWhere(element).filter(
|
||||
(binding) => !includedElementIds.has(binding[0].id),
|
||||
)
|
||||
@ -508,7 +509,7 @@ const getElligibleElementsForBindableElementAndWhere = (
|
||||
return Scene.getScene(bindableElement)!
|
||||
.getElements()
|
||||
.map((element) => {
|
||||
if (!isBindingElement(element)) {
|
||||
if (!isBindingElement(element, false)) {
|
||||
return null;
|
||||
}
|
||||
const canBindStart = isLinearElementEligibleForNewBindingByBindable(
|
||||
@ -659,28 +660,47 @@ export const fixBindingsAfterDeletion = (
|
||||
const deletedElementIds = new Set(
|
||||
deletedElements.map((element) => element.id),
|
||||
);
|
||||
// Non deleted and need an update
|
||||
const boundElementIds: Set<ExcalidrawElement["id"]> = new Set();
|
||||
// non-deleted which bindings need to be updated
|
||||
const affectedElements: Set<ExcalidrawElement["id"]> = new Set();
|
||||
deletedElements.forEach((deletedElement) => {
|
||||
if (isBindableElement(deletedElement)) {
|
||||
deletedElement.boundElements?.forEach((element) => {
|
||||
if (!deletedElementIds.has(element.id)) {
|
||||
boundElementIds.add(element.id);
|
||||
affectedElements.add(element.id);
|
||||
}
|
||||
});
|
||||
} else if (isBindingElement(deletedElement)) {
|
||||
if (deletedElement.startBinding) {
|
||||
affectedElements.add(deletedElement.startBinding.elementId);
|
||||
}
|
||||
if (deletedElement.endBinding) {
|
||||
affectedElements.add(deletedElement.endBinding.elementId);
|
||||
}
|
||||
}
|
||||
});
|
||||
(
|
||||
sceneElements.filter(({ id }) =>
|
||||
boundElementIds.has(id),
|
||||
) as ExcalidrawLinearElement[]
|
||||
).forEach((element: ExcalidrawLinearElement) => {
|
||||
const { startBinding, endBinding } = element;
|
||||
mutateElement(element, {
|
||||
startBinding: newBindingAfterDeletion(startBinding, deletedElementIds),
|
||||
endBinding: newBindingAfterDeletion(endBinding, deletedElementIds),
|
||||
sceneElements
|
||||
.filter(({ id }) => affectedElements.has(id))
|
||||
.forEach((element) => {
|
||||
if (isBindableElement(element)) {
|
||||
mutateElement(element, {
|
||||
boundElements: newBoundElementsAfterDeletion(
|
||||
element.boundElements,
|
||||
deletedElementIds,
|
||||
),
|
||||
});
|
||||
} else if (isBindingElement(element)) {
|
||||
mutateElement(element, {
|
||||
startBinding: newBindingAfterDeletion(
|
||||
element.startBinding,
|
||||
deletedElementIds,
|
||||
),
|
||||
endBinding: newBindingAfterDeletion(
|
||||
element.endBinding,
|
||||
deletedElementIds,
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const newBindingAfterDeletion = (
|
||||
@ -692,3 +712,13 @@ const newBindingAfterDeletion = (
|
||||
}
|
||||
return binding;
|
||||
};
|
||||
|
||||
const newBoundElementsAfterDeletion = (
|
||||
boundElements: ExcalidrawElement["boundElements"],
|
||||
deletedElementIds: Set<ExcalidrawElement["id"]>,
|
||||
) => {
|
||||
if (!boundElements) {
|
||||
return null;
|
||||
}
|
||||
return boundElements.filter((ele) => !deletedElementIds.has(ele.id));
|
||||
};
|
||||
|
@ -25,6 +25,7 @@ import {
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawCustomElement,
|
||||
} from "./types";
|
||||
|
||||
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
|
||||
@ -32,13 +33,20 @@ import { Point } from "../types";
|
||||
import { Drawable } from "roughjs/bin/core";
|
||||
import { AppState } from "../types";
|
||||
import { getShapeForElement } from "../renderer/renderElement";
|
||||
import { hasBoundTextElement, isImageElement } from "./typeChecks";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isCustomElement,
|
||||
isImageElement,
|
||||
} from "./typeChecks";
|
||||
import { isTextElement } from ".";
|
||||
import { isTransparent } from "../utils";
|
||||
|
||||
const isElementDraggableFromInside = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
): boolean => {
|
||||
if (isCustomElement(element)) {
|
||||
return true;
|
||||
}
|
||||
if (element.type === "arrow") {
|
||||
return false;
|
||||
}
|
||||
@ -166,6 +174,7 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
|
||||
case "text":
|
||||
case "diamond":
|
||||
case "ellipse":
|
||||
case "custom":
|
||||
const distance = distanceToBindableElement(args.element, args.point);
|
||||
return args.check(distance, args.threshold);
|
||||
case "freedraw": {
|
||||
@ -199,6 +208,7 @@ export const distanceToBindableElement = (
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "custom":
|
||||
return distanceToRectangle(element, point);
|
||||
case "diamond":
|
||||
return distanceToDiamond(element, point);
|
||||
@ -228,7 +238,8 @@ const distanceToRectangle = (
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawImageElement,
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawCustomElement,
|
||||
point: Point,
|
||||
): number => {
|
||||
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
|
||||
@ -504,6 +515,7 @@ export const determineFocusDistance = (
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "custom":
|
||||
return c / (hwidth * (nabs + q * mabs));
|
||||
case "diamond":
|
||||
return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
|
||||
@ -536,6 +548,7 @@ export const determineFocusPoint = (
|
||||
case "image":
|
||||
case "text":
|
||||
case "diamond":
|
||||
case "custom":
|
||||
point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
|
||||
break;
|
||||
case "ellipse":
|
||||
@ -586,6 +599,7 @@ const getSortedElementLineIntersections = (
|
||||
case "image":
|
||||
case "text":
|
||||
case "diamond":
|
||||
case "custom":
|
||||
const corners = getCorners(element);
|
||||
intersections = corners
|
||||
.flatMap((point, i) => {
|
||||
@ -619,7 +633,8 @@ const getCorners = (
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawTextElement,
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawCustomElement,
|
||||
scale: number = 1,
|
||||
): GA.Point[] => {
|
||||
const hx = (scale * element.width) / 2;
|
||||
@ -628,6 +643,7 @@ const getCorners = (
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "custom":
|
||||
return [
|
||||
GA.point(hx, hy),
|
||||
GA.point(hx, -hy),
|
||||
@ -646,7 +662,7 @@ const getCorners = (
|
||||
|
||||
// Returns intersection of `line` with `segment`, with `segment` moved by
|
||||
// `gap` in its polar direction.
|
||||
// If intersection conincides with second segment point returns empty array.
|
||||
// If intersection coincides with second segment point returns empty array.
|
||||
const intersectSegment = (
|
||||
line: GA.Line,
|
||||
segment: [GA.Point, GA.Point],
|
||||
@ -770,7 +786,8 @@ export const findFocusPointForRectangulars = (
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawTextElement,
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawCustomElement,
|
||||
// Between -1 and 1 for how far away should the focus point be relative
|
||||
// to the size of the element. Sign determines orientation.
|
||||
relativeDistance: number,
|
||||
|
@ -1,12 +1,10 @@
|
||||
import { SHAPES } from "../shapes";
|
||||
import { updateBoundElements } from "./binding";
|
||||
import { getCommonBounds } from "./bounds";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { getPerfectElementSize } from "./sizeHelpers";
|
||||
import Scene from "../scene/Scene";
|
||||
import { NonDeletedExcalidrawElement } from "./types";
|
||||
import { AppState, PointerDownState } from "../types";
|
||||
import { getBoundTextElementId } from "./textElement";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
import { isSelectedViaGroup } from "../groups";
|
||||
|
||||
export const dragSelectedElements = (
|
||||
@ -39,16 +37,14 @@ export const dragSelectedElements = (
|
||||
// container is part of a group, but we're dragging the container directly
|
||||
(appState.editingGroupId && !isSelectedViaGroup(appState, element))
|
||||
) {
|
||||
const boundTextElementId = getBoundTextElementId(element);
|
||||
if (boundTextElementId) {
|
||||
const textElement =
|
||||
Scene.getScene(element)!.getElement(boundTextElementId);
|
||||
const textElement = getBoundTextElement(element);
|
||||
if (textElement) {
|
||||
updateElementCoords(
|
||||
lockDirection,
|
||||
distanceX,
|
||||
distanceY,
|
||||
pointerDownState,
|
||||
textElement!,
|
||||
textElement,
|
||||
offset,
|
||||
);
|
||||
}
|
||||
@ -96,7 +92,7 @@ export const getDragOffsetXY = (
|
||||
|
||||
export const dragNewElement = (
|
||||
draggingElement: NonDeletedExcalidrawElement,
|
||||
elementType: typeof SHAPES[number]["value"],
|
||||
elementType: AppState["activeTool"]["type"],
|
||||
originX: number,
|
||||
originY: number,
|
||||
x: number,
|
||||
|
@ -106,6 +106,20 @@ export const normalizeSVG = async (SVGString: string) => {
|
||||
svg.setAttribute("xmlns", SVG_NS);
|
||||
}
|
||||
|
||||
if (!svg.hasAttribute("width") || !svg.hasAttribute("height")) {
|
||||
const viewBox = svg.getAttribute("viewBox");
|
||||
let width = svg.getAttribute("width") || "50";
|
||||
let height = svg.getAttribute("height") || "50";
|
||||
if (viewBox) {
|
||||
const match = viewBox.match(/\d+ +\d+ +(\d+) +(\d+)/);
|
||||
if (match) {
|
||||
[, width, height] = match;
|
||||
}
|
||||
}
|
||||
svg.setAttribute("width", width);
|
||||
svg.setAttribute("height", height);
|
||||
}
|
||||
|
||||
return svg.outerHTML;
|
||||
}
|
||||
};
|
||||
|
@ -205,7 +205,7 @@ export class LinearElementEditor {
|
||||
);
|
||||
|
||||
// suggest bindings for first and last point if selected
|
||||
if (isBindingElement(element)) {
|
||||
if (isBindingElement(element, false)) {
|
||||
const coords: { x: number; y: number }[] = [];
|
||||
|
||||
const firstSelectedIndex = selectedPointsIndices[0];
|
||||
@ -401,7 +401,7 @@ export class LinearElementEditor {
|
||||
ret.hitElement = element;
|
||||
} else {
|
||||
// You might be wandering why we are storing the binding elements on
|
||||
// LinearElementEditor and passing them in, insted of calculating them
|
||||
// LinearElementEditor and passing them in, instead of calculating them
|
||||
// from the end points of the `linearElement` - this is to allow disabling
|
||||
// binding (which needs to happen at the point the user finishes moving
|
||||
// the point).
|
||||
|
@ -2,11 +2,7 @@ import { duplicateElement } from "./newElement";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { FONT_FAMILY } from "../constants";
|
||||
|
||||
const isPrimitive = (val: any) => {
|
||||
const type = typeof val;
|
||||
return val == null || (type !== "object" && type !== "function");
|
||||
};
|
||||
import { isPrimitive } from "../utils";
|
||||
|
||||
const assertCloneObjects = (source: any, clone: any) => {
|
||||
for (const key in clone) {
|
||||
|
@ -12,6 +12,7 @@ import {
|
||||
ExcalidrawFreeDrawElement,
|
||||
FontFamilyValues,
|
||||
ExcalidrawRectangleElement,
|
||||
ExcalidrawCustomElement,
|
||||
} from "../element/types";
|
||||
import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils";
|
||||
import { randomInteger, randomId } from "../random";
|
||||
@ -22,8 +23,7 @@ import { getElementAbsoluteCoords } from ".";
|
||||
import { adjustXYWithRotation } from "../math";
|
||||
import { getResizedElementAbsoluteCoords } from "./bounds";
|
||||
import { getContainerElement, measureText, wrapText } from "./textElement";
|
||||
import { isBoundToContainer } from "./typeChecks";
|
||||
import { BOUND_TEXT_PADDING } from "../constants";
|
||||
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
|
||||
|
||||
type ElementConstructorOpts = MarkOptional<
|
||||
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
||||
@ -57,6 +57,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||
strokeSharpness,
|
||||
boundElements = null,
|
||||
link = null,
|
||||
locked,
|
||||
...rest
|
||||
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
|
||||
) => {
|
||||
@ -84,6 +85,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||
boundElements,
|
||||
updated: getUpdatedTimestamp(),
|
||||
link,
|
||||
locked,
|
||||
};
|
||||
return element;
|
||||
};
|
||||
@ -175,7 +177,7 @@ const getAdjustedDimensions = (
|
||||
let y: number;
|
||||
if (
|
||||
textAlign === "center" &&
|
||||
verticalAlign === "middle" &&
|
||||
verticalAlign === VERTICAL_ALIGN.MIDDLE &&
|
||||
!element.containerId
|
||||
) {
|
||||
const prevMetrics = measureText(
|
||||
@ -221,8 +223,7 @@ const getAdjustedDimensions = (
|
||||
|
||||
// make sure container dimensions are set properly when
|
||||
// text editor overflows beyond viewport dimensions
|
||||
if (isBoundToContainer(element)) {
|
||||
const container = getContainerElement(element)!;
|
||||
if (container) {
|
||||
let height = container.height;
|
||||
let width = container.width;
|
||||
if (nextHeight > height - BOUND_TEXT_PADDING * 2) {
|
||||
@ -320,6 +321,17 @@ export const newImageElement = (
|
||||
};
|
||||
};
|
||||
|
||||
export const newCustomElement = (
|
||||
customType: string,
|
||||
opts: {
|
||||
type: ExcalidrawCustomElement["type"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawCustomElement> => {
|
||||
return {
|
||||
..._newElementBase<ExcalidrawCustomElement>("custom", opts),
|
||||
customType,
|
||||
};
|
||||
};
|
||||
// Simplified deep clone for the purpose of cloning ExcalidrawElement only
|
||||
// (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.)
|
||||
//
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { SHIFT_LOCKING_ANGLE } from "../constants";
|
||||
import { BOUND_TEXT_PADDING, SHIFT_LOCKING_ANGLE } from "../constants";
|
||||
import { rescalePoints } from "../points";
|
||||
|
||||
import {
|
||||
@ -12,6 +12,7 @@ import {
|
||||
ExcalidrawTextElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeleted,
|
||||
ExcalidrawElement,
|
||||
} from "./types";
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
@ -35,6 +36,7 @@ import {
|
||||
import { Point, PointerDownState } from "../types";
|
||||
import Scene from "../scene/Scene";
|
||||
import {
|
||||
getApproxMinLineHeight,
|
||||
getApproxMinLineWidth,
|
||||
getBoundTextElement,
|
||||
getBoundTextElementId,
|
||||
@ -105,7 +107,7 @@ export const transformElements = (
|
||||
updateBoundElements(element);
|
||||
} else if (transformHandleType) {
|
||||
resizeSingleElement(
|
||||
pointerDownState.originalElements.get(element.id) as typeof element,
|
||||
pointerDownState.originalElements,
|
||||
shouldMaintainAspectRatio,
|
||||
element,
|
||||
transformHandleType,
|
||||
@ -140,7 +142,6 @@ export const transformElements = (
|
||||
pointerX,
|
||||
pointerY,
|
||||
);
|
||||
handleBindTextResize(selectedElements, transformHandleType);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -186,7 +187,7 @@ const validateTwoPointElementNormalized = (
|
||||
};
|
||||
|
||||
const getPerfectElementSizeWithRotation = (
|
||||
elementType: string,
|
||||
elementType: ExcalidrawElement["type"],
|
||||
width: number,
|
||||
height: number,
|
||||
angle: number,
|
||||
@ -397,7 +398,7 @@ const resizeSingleTextElement = (
|
||||
};
|
||||
|
||||
export const resizeSingleElement = (
|
||||
stateAtResizeStart: NonDeletedExcalidrawElement,
|
||||
originalElements: PointerDownState["originalElements"],
|
||||
shouldMaintainAspectRatio: boolean,
|
||||
element: NonDeletedExcalidrawElement,
|
||||
transformHandleDirection: TransformHandleDirection,
|
||||
@ -405,6 +406,7 @@ export const resizeSingleElement = (
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
) => {
|
||||
const stateAtResizeStart = originalElements.get(element.id)!;
|
||||
// Gets bounds corners
|
||||
const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
|
||||
stateAtResizeStart,
|
||||
@ -429,8 +431,6 @@ export const resizeSingleElement = (
|
||||
element.height,
|
||||
);
|
||||
|
||||
const boundTextElementId = getBoundTextElementId(element);
|
||||
|
||||
const boundsCurrentWidth = esx2 - esx1;
|
||||
const boundsCurrentHeight = esy2 - esy1;
|
||||
|
||||
@ -441,6 +441,9 @@ export const resizeSingleElement = (
|
||||
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
|
||||
let scaleY = atStartBoundsHeight / boundsCurrentHeight;
|
||||
|
||||
let boundTextFont: { fontSize?: number; baseline?: number } = {};
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
|
||||
if (transformHandleDirection.includes("e")) {
|
||||
scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth;
|
||||
}
|
||||
@ -453,6 +456,7 @@ export const resizeSingleElement = (
|
||||
if (transformHandleDirection.includes("n")) {
|
||||
scaleY = (startBottomRight[1] - rotatedPointer[1]) / boundsCurrentHeight;
|
||||
}
|
||||
|
||||
// Linear elements dimensions differ from bounds dimensions
|
||||
const eleInitialWidth = stateAtResizeStart.width;
|
||||
const eleInitialHeight = stateAtResizeStart.height;
|
||||
@ -482,6 +486,37 @@ export const resizeSingleElement = (
|
||||
}
|
||||
}
|
||||
|
||||
if (boundTextElement) {
|
||||
const stateOfBoundTextElementAtResize = originalElements.get(
|
||||
boundTextElement.id,
|
||||
) as typeof boundTextElement | undefined;
|
||||
if (stateOfBoundTextElementAtResize) {
|
||||
boundTextFont = {
|
||||
fontSize: stateOfBoundTextElementAtResize.fontSize,
|
||||
baseline: stateOfBoundTextElementAtResize.baseline,
|
||||
};
|
||||
}
|
||||
if (shouldMaintainAspectRatio) {
|
||||
const nextFont = measureFontSizeFromWH(
|
||||
boundTextElement,
|
||||
eleNewWidth - BOUND_TEXT_PADDING * 2,
|
||||
eleNewHeight - BOUND_TEXT_PADDING * 2,
|
||||
);
|
||||
if (nextFont === null) {
|
||||
return;
|
||||
}
|
||||
boundTextFont = {
|
||||
fontSize: nextFont.size,
|
||||
baseline: nextFont.baseline,
|
||||
};
|
||||
} else {
|
||||
const minWidth = getApproxMinLineWidth(getFontString(boundTextElement));
|
||||
const minHeight = getApproxMinLineHeight(getFontString(boundTextElement));
|
||||
eleNewWidth = Math.ceil(Math.max(eleNewWidth, minWidth));
|
||||
eleNewHeight = Math.ceil(Math.max(eleNewHeight, minHeight));
|
||||
}
|
||||
}
|
||||
|
||||
const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
|
||||
getResizedElementAbsoluteCoords(
|
||||
stateAtResizeStart,
|
||||
@ -491,11 +526,6 @@ export const resizeSingleElement = (
|
||||
const newBoundsWidth = newBoundsX2 - newBoundsX1;
|
||||
const newBoundsHeight = newBoundsY2 - newBoundsY1;
|
||||
|
||||
// don't allow resize to negative dimensions when text is bounded to container
|
||||
if ((newBoundsWidth < 0 || newBoundsHeight < 0) && boundTextElementId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate new topLeft based on fixed corner during resize
|
||||
let newTopLeft = [...startTopLeft] as [number, number];
|
||||
if (["n", "w", "nw"].includes(transformHandleDirection)) {
|
||||
@ -588,13 +618,9 @@ export const resizeSingleElement = (
|
||||
],
|
||||
});
|
||||
}
|
||||
let minWidth = 0;
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
minWidth = getApproxMinLineWidth(getFontString(boundTextElement));
|
||||
}
|
||||
|
||||
if (
|
||||
resizedElement.width >= minWidth &&
|
||||
resizedElement.width !== 0 &&
|
||||
resizedElement.height !== 0 &&
|
||||
Number.isFinite(resizedElement.x) &&
|
||||
Number.isFinite(resizedElement.y)
|
||||
@ -603,7 +629,10 @@ export const resizeSingleElement = (
|
||||
newSize: { width: resizedElement.width, height: resizedElement.height },
|
||||
});
|
||||
mutateElement(element, resizedElement);
|
||||
handleBindTextResize([element], transformHandleDirection);
|
||||
if (boundTextElement && boundTextFont) {
|
||||
mutateElement(boundTextElement, { fontSize: boundTextFont.fontSize });
|
||||
}
|
||||
handleBindTextResize(element, transformHandleDirection);
|
||||
}
|
||||
};
|
||||
|
||||
@ -674,7 +703,25 @@ const resizeMultipleElements = (
|
||||
}
|
||||
const width = element.width * scale;
|
||||
const height = element.height * scale;
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
let font: { fontSize?: number; baseline?: number } = {};
|
||||
|
||||
if (boundTextElement) {
|
||||
const nextFont = measureFontSizeFromWH(
|
||||
boundTextElement,
|
||||
width - BOUND_TEXT_PADDING * 2,
|
||||
height - BOUND_TEXT_PADDING * 2,
|
||||
);
|
||||
|
||||
if (nextFont === null) {
|
||||
return null;
|
||||
}
|
||||
font = {
|
||||
fontSize: nextFont.size,
|
||||
baseline: nextFont.baseline,
|
||||
};
|
||||
}
|
||||
|
||||
if (isTextElement(element)) {
|
||||
const nextFont = measureFontSizeFromWH(element, width, height);
|
||||
if (nextFont === null) {
|
||||
@ -718,6 +765,15 @@ const resizeMultipleElements = (
|
||||
if (updates) {
|
||||
elements.forEach((element, index) => {
|
||||
mutateElement(element, updates[index]);
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
|
||||
if (boundTextElement) {
|
||||
mutateElement(boundTextElement, {
|
||||
fontSize: updates[index].fontSize,
|
||||
baseline: updates[index].baseline,
|
||||
});
|
||||
handleBindTextResize(element, transformHandleType);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -10,5 +10,6 @@ export const showSelectedShapeActions = (
|
||||
!appState.viewModeEnabled &&
|
||||
(appState.editingElement ||
|
||||
getSelectedElements(elements, appState).length ||
|
||||
appState.elementType !== "selection"),
|
||||
(appState.activeTool.type !== "selection" &&
|
||||
appState.activeTool.type !== "eraser")),
|
||||
);
|
||||
|
@ -32,7 +32,7 @@ describe("getPerfectElementSize", () => {
|
||||
expect(width).toEqual(135);
|
||||
expect(height).toEqual(135);
|
||||
});
|
||||
it("should return height:0 and width:0 when width and heigh are 0", () => {
|
||||
it("should return height:0 and width:0 when width and height are 0", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 0, 0);
|
||||
expect(width).toEqual(0);
|
||||
expect(height).toEqual(0);
|
||||
|
@ -2,6 +2,7 @@ import { ExcalidrawElement } from "./types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
|
||||
import { SHIFT_LOCKING_ANGLE } from "../constants";
|
||||
import { AppState } from "../types";
|
||||
|
||||
export const isInvisiblySmallElement = (
|
||||
element: ExcalidrawElement,
|
||||
@ -16,7 +17,7 @@ export const isInvisiblySmallElement = (
|
||||
* Makes a perfect shape or diagonal/horizontal/vertical line
|
||||
*/
|
||||
export const getPerfectElementSize = (
|
||||
elementType: string,
|
||||
elementType: AppState["activeTool"]["type"],
|
||||
width: number,
|
||||
height: number,
|
||||
): { width: number; height: number } => {
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { getFontString, arrayToMap, isTestEnv } from "../utils";
|
||||
import {
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
@ -8,15 +7,14 @@ import {
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { BOUND_TEXT_PADDING } from "../constants";
|
||||
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
|
||||
import { MaybeTransformHandleType } from "./transformHandles";
|
||||
import Scene from "../scene/Scene";
|
||||
import { AppState } from "../types";
|
||||
import { isTextElement } from ".";
|
||||
|
||||
export const redrawTextBoundingBox = (
|
||||
element: ExcalidrawTextElement,
|
||||
container: ExcalidrawElement | null,
|
||||
appState: AppState,
|
||||
) => {
|
||||
const maxWidth = container
|
||||
? container.width - BOUND_TEXT_PADDING * 2
|
||||
@ -35,24 +33,32 @@ export const redrawTextBoundingBox = (
|
||||
getFontString(element),
|
||||
maxWidth,
|
||||
);
|
||||
|
||||
let coordY = element.y;
|
||||
let coordX = element.x;
|
||||
// Resize container and vertically center align the text
|
||||
if (container) {
|
||||
coordY = container.y + container.height / 2 - metrics.height / 2;
|
||||
let nextHeight = container.height;
|
||||
if (metrics.height > container.height - BOUND_TEXT_PADDING * 2) {
|
||||
nextHeight = metrics.height + BOUND_TEXT_PADDING * 2;
|
||||
coordY = container.y + nextHeight / 2 - metrics.height / 2;
|
||||
coordX = container.x + BOUND_TEXT_PADDING;
|
||||
if (element.verticalAlign === VERTICAL_ALIGN.TOP) {
|
||||
coordY = container.y + BOUND_TEXT_PADDING;
|
||||
} else if (element.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
coordY =
|
||||
container.y + container.height - metrics.height - BOUND_TEXT_PADDING;
|
||||
} else {
|
||||
coordY = container.y + container.height / 2 - metrics.height / 2;
|
||||
if (metrics.height > container.height - BOUND_TEXT_PADDING * 2) {
|
||||
nextHeight = metrics.height + BOUND_TEXT_PADDING * 2;
|
||||
coordY = container.y + nextHeight / 2 - metrics.height / 2;
|
||||
}
|
||||
}
|
||||
mutateElement(container, { height: nextHeight });
|
||||
}
|
||||
|
||||
mutateElement(element, {
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
baseline: metrics.baseline,
|
||||
y: coordY,
|
||||
x: coordX,
|
||||
text,
|
||||
});
|
||||
};
|
||||
@ -71,91 +77,99 @@ export const bindTextToShapeAfterDuplication = (
|
||||
const boundTextElementId = getBoundTextElementId(element);
|
||||
|
||||
if (boundTextElementId) {
|
||||
const newTextElementId = oldIdToDuplicatedId.get(boundTextElementId)!;
|
||||
mutateElement(
|
||||
sceneElementMap.get(newElementId) as ExcalidrawBindableElement,
|
||||
{
|
||||
boundElements: element.boundElements?.concat({
|
||||
type: "text",
|
||||
id: newTextElementId,
|
||||
}),
|
||||
},
|
||||
);
|
||||
mutateElement(
|
||||
sceneElementMap.get(newTextElementId) as ExcalidrawTextElement,
|
||||
{
|
||||
containerId: newElementId,
|
||||
},
|
||||
);
|
||||
const newTextElementId = oldIdToDuplicatedId.get(boundTextElementId);
|
||||
if (newTextElementId) {
|
||||
const newContainer = sceneElementMap.get(newElementId);
|
||||
if (newContainer) {
|
||||
mutateElement(newContainer, {
|
||||
boundElements: element.boundElements?.concat({
|
||||
type: "text",
|
||||
id: newTextElementId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
const newTextElement = sceneElementMap.get(newTextElementId);
|
||||
if (newTextElement && isTextElement(newTextElement)) {
|
||||
mutateElement(newTextElement, {
|
||||
containerId: newContainer ? newElementId : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const handleBindTextResize = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
element: NonDeletedExcalidrawElement,
|
||||
transformHandleType: MaybeTransformHandleType,
|
||||
) => {
|
||||
elements.forEach((element) => {
|
||||
const boundTextElementId = getBoundTextElementId(element);
|
||||
if (boundTextElementId) {
|
||||
const textElement = Scene.getScene(element)!.getElement(
|
||||
boundTextElementId,
|
||||
) as ExcalidrawTextElement;
|
||||
if (textElement && textElement.text) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
let text = textElement.text;
|
||||
let nextHeight = textElement.height;
|
||||
let containerHeight = element.height;
|
||||
let nextBaseLine = textElement.baseline;
|
||||
if (transformHandleType !== "n" && transformHandleType !== "s") {
|
||||
if (text) {
|
||||
text = wrapText(
|
||||
textElement.originalText,
|
||||
getFontString(textElement),
|
||||
element.width,
|
||||
);
|
||||
}
|
||||
|
||||
const dimensions = measureText(
|
||||
text,
|
||||
const boundTextElementId = getBoundTextElementId(element);
|
||||
if (boundTextElementId) {
|
||||
const textElement = Scene.getScene(element)!.getElement(
|
||||
boundTextElementId,
|
||||
) as ExcalidrawTextElement;
|
||||
if (textElement && textElement.text) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
let text = textElement.text;
|
||||
let nextHeight = textElement.height;
|
||||
let containerHeight = element.height;
|
||||
let nextBaseLine = textElement.baseline;
|
||||
if (transformHandleType !== "n" && transformHandleType !== "s") {
|
||||
if (text) {
|
||||
text = wrapText(
|
||||
textElement.originalText,
|
||||
getFontString(textElement),
|
||||
element.width,
|
||||
);
|
||||
nextHeight = dimensions.height;
|
||||
nextBaseLine = dimensions.baseline;
|
||||
}
|
||||
// increase height in case text element height exceeds
|
||||
if (nextHeight > element.height - BOUND_TEXT_PADDING * 2) {
|
||||
containerHeight = nextHeight + BOUND_TEXT_PADDING * 2;
|
||||
const diff = containerHeight - element.height;
|
||||
// fix the y coord when resizing from ne/nw/n
|
||||
const updatedY =
|
||||
transformHandleType === "ne" ||
|
||||
transformHandleType === "nw" ||
|
||||
transformHandleType === "n"
|
||||
? element.y - diff
|
||||
: element.y;
|
||||
mutateElement(element, {
|
||||
height: containerHeight,
|
||||
y: updatedY,
|
||||
});
|
||||
}
|
||||
|
||||
const updatedY = element.y + containerHeight / 2 - nextHeight / 2;
|
||||
mutateElement(textElement, {
|
||||
const dimensions = measureText(
|
||||
text,
|
||||
// preserve padding and set width correctly
|
||||
width: element.width - BOUND_TEXT_PADDING * 2,
|
||||
height: nextHeight,
|
||||
x: element.x + BOUND_TEXT_PADDING,
|
||||
getFontString(textElement),
|
||||
element.width,
|
||||
);
|
||||
nextHeight = dimensions.height;
|
||||
nextBaseLine = dimensions.baseline;
|
||||
}
|
||||
// increase height in case text element height exceeds
|
||||
if (nextHeight > element.height - BOUND_TEXT_PADDING * 2) {
|
||||
containerHeight = nextHeight + BOUND_TEXT_PADDING * 2;
|
||||
const diff = containerHeight - element.height;
|
||||
// fix the y coord when resizing from ne/nw/n
|
||||
const updatedY =
|
||||
transformHandleType === "ne" ||
|
||||
transformHandleType === "nw" ||
|
||||
transformHandleType === "n"
|
||||
? element.y - diff
|
||||
: element.y;
|
||||
mutateElement(element, {
|
||||
height: containerHeight,
|
||||
y: updatedY,
|
||||
baseline: nextBaseLine,
|
||||
});
|
||||
}
|
||||
|
||||
let updatedY;
|
||||
if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
|
||||
updatedY = element.y + BOUND_TEXT_PADDING;
|
||||
} else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
updatedY = element.y + element.height - nextHeight - BOUND_TEXT_PADDING;
|
||||
} else {
|
||||
updatedY = element.y + element.height / 2 - nextHeight / 2;
|
||||
}
|
||||
|
||||
mutateElement(textElement, {
|
||||
text,
|
||||
// preserve padding and set width correctly
|
||||
width: element.width - BOUND_TEXT_PADDING * 2,
|
||||
height: nextHeight,
|
||||
x: element.x + BOUND_TEXT_PADDING,
|
||||
y: updatedY,
|
||||
baseline: nextBaseLine,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
|
||||
@ -355,6 +369,7 @@ export const charWidth = (() => {
|
||||
const width = getTextWidth(char, font);
|
||||
cachedCharWidth[font][ascii] = width;
|
||||
}
|
||||
|
||||
return cachedCharWidth[font][ascii];
|
||||
};
|
||||
|
||||
@ -367,14 +382,14 @@ export const charWidth = (() => {
|
||||
};
|
||||
})();
|
||||
export const getApproxMinLineWidth = (font: FontString) => {
|
||||
const minCharWidth = getMinCharWidth(font);
|
||||
if (minCharWidth === 0) {
|
||||
const maxCharWidth = getMaxCharWidth(font);
|
||||
if (maxCharWidth === 0) {
|
||||
return (
|
||||
measureText(DUMMY_TEXT.split("").join("\n"), font).width +
|
||||
BOUND_TEXT_PADDING * 2
|
||||
);
|
||||
}
|
||||
return minCharWidth + BOUND_TEXT_PADDING * 2;
|
||||
return maxCharWidth + BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const getApproxMinLineHeight = (font: FontString) => {
|
||||
@ -391,6 +406,15 @@ export const getMinCharWidth = (font: FontString) => {
|
||||
return Math.min(...cacheWithOutEmpty);
|
||||
};
|
||||
|
||||
export const getMaxCharWidth = (font: FontString) => {
|
||||
const cache = charWidth.getCache(font);
|
||||
if (!cache) {
|
||||
return 0;
|
||||
}
|
||||
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
|
||||
return Math.max(...cacheWithOutEmpty);
|
||||
};
|
||||
|
||||
export const getApproxCharsToFitInWidth = (font: FontString, width: number) => {
|
||||
// Generally lower case is used so converting to lower case
|
||||
const dummyText = DUMMY_TEXT.toLocaleLowerCase();
|
||||
@ -416,7 +440,10 @@ export const getApproxCharsToFitInWidth = (font: FontString, width: number) => {
|
||||
};
|
||||
|
||||
export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
|
||||
return container?.boundElements?.filter((ele) => ele.type === "text")[0]?.id;
|
||||
return container?.boundElements?.length
|
||||
? container?.boundElements?.filter((ele) => ele.type === "text")[0]?.id ||
|
||||
null
|
||||
: null;
|
||||
};
|
||||
|
||||
export const getBoundTextElement = (element: ExcalidrawElement | null) => {
|
||||
|
@ -12,6 +12,8 @@ import {
|
||||
ExcalidrawTextElementWithContainer,
|
||||
} from "./types";
|
||||
import * as textElementUtils from "./textElement";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
// Unmount ReactDOM from root
|
||||
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
||||
|
||||
@ -19,7 +21,201 @@ const tab = " ";
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
describe("textWysiwyg", () => {
|
||||
describe("Test unbounded text", () => {
|
||||
describe("start text editing", () => {
|
||||
const { h } = window;
|
||||
beforeEach(async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
h.elements = [];
|
||||
});
|
||||
|
||||
it("should prefer editing selected text element (non-bindable container present)", async () => {
|
||||
const line = API.createElement({
|
||||
type: "line",
|
||||
width: 100,
|
||||
height: 0,
|
||||
points: [
|
||||
[0, 0],
|
||||
[100, 0],
|
||||
],
|
||||
});
|
||||
const textSize = 20;
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
text: "ola",
|
||||
x: line.width / 2 - textSize / 2,
|
||||
y: -textSize / 2,
|
||||
width: textSize,
|
||||
height: textSize,
|
||||
});
|
||||
h.elements = [text, line];
|
||||
|
||||
API.setSelectedElements([text]);
|
||||
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
expect(h.state.editingElement?.id).toBe(text.id);
|
||||
expect(
|
||||
(h.state.editingElement as ExcalidrawTextElement).containerId,
|
||||
).toBe(null);
|
||||
});
|
||||
|
||||
it("should prefer editing selected text element (bindable container present)", async () => {
|
||||
const container = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
boundElements: [],
|
||||
});
|
||||
const textSize = 20;
|
||||
|
||||
const boundText = API.createElement({
|
||||
type: "text",
|
||||
text: "ola",
|
||||
x: container.width / 2 - textSize / 2,
|
||||
y: container.height / 2 - textSize / 2,
|
||||
width: textSize,
|
||||
height: textSize,
|
||||
containerId: container.id,
|
||||
});
|
||||
|
||||
const boundText2 = API.createElement({
|
||||
type: "text",
|
||||
text: "ola",
|
||||
x: container.width / 2 - textSize / 2,
|
||||
y: container.height / 2 - textSize / 2,
|
||||
width: textSize,
|
||||
height: textSize,
|
||||
containerId: container.id,
|
||||
});
|
||||
|
||||
h.elements = [container, boundText, boundText2];
|
||||
|
||||
mutateElement(container, {
|
||||
boundElements: [{ type: "text", id: boundText.id }],
|
||||
});
|
||||
|
||||
API.setSelectedElements([boundText2]);
|
||||
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
expect(h.state.editingElement?.id).toBe(boundText2.id);
|
||||
});
|
||||
|
||||
it("should not create bound text on ENTER if text exists at container center", () => {
|
||||
const container = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
});
|
||||
const textSize = 20;
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
text: "ola",
|
||||
x: container.width / 2 - textSize / 2,
|
||||
y: container.height / 2 - textSize / 2,
|
||||
width: textSize,
|
||||
height: textSize,
|
||||
containerId: container.id,
|
||||
});
|
||||
|
||||
h.elements = [container, text];
|
||||
|
||||
API.setSelectedElements([container]);
|
||||
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
expect(h.state.editingElement?.id).toBe(text.id);
|
||||
});
|
||||
|
||||
it("should edit existing bound text on ENTER even if higher z-index unbound text exists at container center", () => {
|
||||
const container = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 100,
|
||||
boundElements: [],
|
||||
});
|
||||
const textSize = 20;
|
||||
|
||||
const boundText = API.createElement({
|
||||
type: "text",
|
||||
text: "ola",
|
||||
x: container.width / 2 - textSize / 2,
|
||||
y: container.height / 2 - textSize / 2,
|
||||
width: textSize,
|
||||
height: textSize,
|
||||
containerId: container.id,
|
||||
});
|
||||
|
||||
const boundText2 = API.createElement({
|
||||
type: "text",
|
||||
text: "ola",
|
||||
x: container.width / 2 - textSize / 2,
|
||||
y: container.height / 2 - textSize / 2,
|
||||
width: textSize,
|
||||
height: textSize,
|
||||
containerId: container.id,
|
||||
});
|
||||
|
||||
h.elements = [container, boundText, boundText2];
|
||||
|
||||
mutateElement(container, {
|
||||
boundElements: [{ type: "text", id: boundText.id }],
|
||||
});
|
||||
|
||||
API.setSelectedElements([container]);
|
||||
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
expect(h.state.editingElement?.id).toBe(boundText.id);
|
||||
});
|
||||
|
||||
it("should edit text under cursor when clicked with text tool", () => {
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
text: "ola",
|
||||
x: 60,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
h.elements = [text];
|
||||
UI.clickTool("text");
|
||||
|
||||
mouse.clickAt(text.x + 50, text.y + 50);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
expect(editor).not.toBe(null);
|
||||
expect(h.state.editingElement?.id).toBe(text.id);
|
||||
expect(h.elements.length).toBe(1);
|
||||
});
|
||||
|
||||
it("should edit text under cursor when double-clicked with selection tool", () => {
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
text: "ola",
|
||||
x: 60,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
h.elements = [text];
|
||||
UI.clickTool("selection");
|
||||
|
||||
mouse.doubleClickAt(text.x + 50, text.y + 50);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
expect(editor).not.toBe(null);
|
||||
expect(h.state.editingElement?.id).toBe(text.id);
|
||||
expect(h.elements.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test container-unbound text", () => {
|
||||
const { h } = window;
|
||||
|
||||
let textarea: HTMLTextAreaElement;
|
||||
@ -235,7 +431,7 @@ describe("textWysiwyg", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test bounded text", () => {
|
||||
describe("Test container-bound text", () => {
|
||||
let rectangle: any;
|
||||
const { h } = window;
|
||||
|
||||
@ -315,6 +511,62 @@ describe("textWysiwyg", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("shouldn't bind to non-text-bindable containers", async () => {
|
||||
const line = API.createElement({
|
||||
type: "line",
|
||||
width: 100,
|
||||
height: 0,
|
||||
points: [
|
||||
[0, 0],
|
||||
[100, 0],
|
||||
],
|
||||
});
|
||||
h.elements = [line];
|
||||
|
||||
UI.clickTool("text");
|
||||
|
||||
mouse.clickAt(line.x + line.width / 2, line.y + line.height / 2);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: {
|
||||
value: "Hello World!",
|
||||
},
|
||||
});
|
||||
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
|
||||
expect(line.boundElements).toBe(null);
|
||||
expect(h.elements[1].type).toBe("text");
|
||||
expect((h.elements[1] as ExcalidrawTextElement).containerId).toBe(null);
|
||||
});
|
||||
|
||||
it("should'nt bind text to container when not double clicked on center", async () => {
|
||||
expect(h.elements.length).toBe(1);
|
||||
expect(h.elements[0].id).toBe(rectangle.id);
|
||||
|
||||
// clicking somewhere on top left
|
||||
mouse.doubleClickAt(rectangle.x + 20, rectangle.y + 20);
|
||||
expect(h.elements.length).toBe(2);
|
||||
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(null);
|
||||
mouse.down();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toBe(null);
|
||||
});
|
||||
|
||||
it("should update font family correctly on undo/redo by selecting bounded text when font family was updated", async () => {
|
||||
expect(h.elements.length).toBe(1);
|
||||
|
||||
@ -475,7 +727,7 @@ describe("textWysiwyg", () => {
|
||||
expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2);
|
||||
});
|
||||
|
||||
it("should unbind bound text when unbind action from context menu is triggred", async () => {
|
||||
it("should unbind bound text when unbind action from context menu is triggered", async () => {
|
||||
expect(h.elements.length).toBe(1);
|
||||
expect(h.elements[0].id).toBe(rectangle.id);
|
||||
|
||||
@ -516,5 +768,47 @@ describe("textWysiwyg", () => {
|
||||
null,
|
||||
);
|
||||
});
|
||||
it("shouldn't bind to container if container has bound text", async () => {
|
||||
expect(h.elements.length).toBe(1);
|
||||
|
||||
Keyboard.withModifierKeys({}, () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
});
|
||||
|
||||
expect(h.elements.length).toBe(2);
|
||||
|
||||
// Bind first text
|
||||
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
let editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: text.id, type: "text" },
|
||||
]);
|
||||
|
||||
// Attempt to bind another text
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(
|
||||
rectangle.x + rectangle.width / 2,
|
||||
rectangle.y + rectangle.height / 2,
|
||||
);
|
||||
mouse.down();
|
||||
expect(h.elements.length).toBe(3);
|
||||
text = h.elements[2] as ExcalidrawTextElementWithContainer;
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Whats up?" } });
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: h.elements[1].id, type: "text" },
|
||||
]);
|
||||
expect(text.containerId).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -7,7 +7,7 @@ import {
|
||||
} from "../utils";
|
||||
import Scene from "../scene/Scene";
|
||||
import { isBoundToContainer, isTextElement } from "./typeChecks";
|
||||
import { CLASSES, BOUND_TEXT_PADDING } from "../constants";
|
||||
import { CLASSES, BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
@ -102,9 +102,13 @@ export const textWysiwyg = ({
|
||||
|
||||
const updateWysiwygStyle = () => {
|
||||
const appState = app.state;
|
||||
const updatedElement = Scene.getScene(element)?.getElement(
|
||||
id,
|
||||
) as ExcalidrawTextElement;
|
||||
const updatedElement =
|
||||
Scene.getScene(element)?.getElement<ExcalidrawTextElement>(id);
|
||||
if (!updatedElement) {
|
||||
return;
|
||||
}
|
||||
const { textAlign, verticalAlign } = updatedElement;
|
||||
|
||||
const approxLineHeight = getApproxLineHeight(getFontString(updatedElement));
|
||||
if (updatedElement && isTextElement(updatedElement)) {
|
||||
let coordX = updatedElement.x;
|
||||
@ -114,7 +118,7 @@ export const textWysiwyg = ({
|
||||
|
||||
let maxHeight = updatedElement.height;
|
||||
let width = updatedElement.width;
|
||||
// Set to element height by default since thats
|
||||
// Set to element height by default since that's
|
||||
// what is going to be used for unbounded text
|
||||
let height = updatedElement.height;
|
||||
if (container && updatedElement.containerId) {
|
||||
@ -140,7 +144,7 @@ export const textWysiwyg = ({
|
||||
maxHeight = container.height - BOUND_TEXT_PADDING * 2;
|
||||
width = maxWidth;
|
||||
// The coordinates of text box set a distance of
|
||||
// 30px to preserve padding
|
||||
// 5px to preserve padding
|
||||
coordX = container.x + BOUND_TEXT_PADDING;
|
||||
// autogrow container height if text exceeds
|
||||
if (height > maxHeight) {
|
||||
@ -160,12 +164,34 @@ export const textWysiwyg = ({
|
||||
// is reached
|
||||
else {
|
||||
// vertically center align the text
|
||||
coordY = container.y + container.height / 2 - height / 2;
|
||||
if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
|
||||
coordY = container.y + container.height / 2 - height / 2;
|
||||
}
|
||||
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
coordY =
|
||||
container.y + container.height - height - BOUND_TEXT_PADDING;
|
||||
}
|
||||
}
|
||||
}
|
||||
const [viewportX, viewportY] = getViewportCoords(coordX, coordY);
|
||||
const { textAlign } = updatedElement;
|
||||
const initialSelectionStart = editable.selectionStart;
|
||||
const initialSelectionEnd = editable.selectionEnd;
|
||||
const initialLength = editable.value.length;
|
||||
editable.value = updatedElement.originalText;
|
||||
|
||||
// restore cursor position after value updated so it doesn't
|
||||
// go to the end of text when container auto expanded
|
||||
if (
|
||||
initialSelectionStart === initialSelectionEnd &&
|
||||
initialSelectionEnd !== initialLength
|
||||
) {
|
||||
// get diff between length and selection end and shift
|
||||
// the cursor by "diff" times to position correctly
|
||||
const diff = initialLength - initialSelectionEnd;
|
||||
editable.selectionStart = editable.value.length - diff;
|
||||
editable.selectionEnd = editable.value.length - diff;
|
||||
}
|
||||
|
||||
const lines = updatedElement.originalText.split("\n");
|
||||
const lineHeight = updatedElement.containerId
|
||||
? approxLineHeight
|
||||
@ -195,6 +221,7 @@ export const textWysiwyg = ({
|
||||
editorMaxHeight,
|
||||
),
|
||||
textAlign,
|
||||
verticalAlign,
|
||||
color: updatedElement.strokeColor,
|
||||
opacity: updatedElement.opacity / 100,
|
||||
filter: "var(--theme-filter)",
|
||||
@ -256,9 +283,16 @@ export const textWysiwyg = ({
|
||||
// using scrollHeight here since we need to calculate
|
||||
// number of lines so cannot use editable.style.height
|
||||
// as that gets updated below
|
||||
const lines = editable.scrollHeight / getApproxLineHeight(font);
|
||||
// Rounding here so that the lines calculated is more accurate in all browsers.
|
||||
// The scrollHeight and approxLineHeight differs in diff browsers
|
||||
// eg it gives 1.05 in firefox for handewritten small font due to which
|
||||
// height gets updated as lines > 1 and leads to jumping text for first line in bound container
|
||||
// hence rounding here to avoid that
|
||||
const lines = Math.round(
|
||||
editable.scrollHeight / getApproxLineHeight(font),
|
||||
);
|
||||
// auto increase height only when lines > 1 so its
|
||||
// measured correctly and vertically alignes for
|
||||
// measured correctly and vertically aligns for
|
||||
// first line as well as setting height to "auto"
|
||||
// doubles the height as soon as user starts typing
|
||||
if (isBoundToContainer(element) && lines > 1) {
|
||||
@ -271,7 +305,6 @@ export const textWysiwyg = ({
|
||||
font,
|
||||
container!.width,
|
||||
).split("\n").length;
|
||||
|
||||
// This is browser behaviour when setting height to "auto"
|
||||
// It sets the height needed for 2 lines even if actual
|
||||
// line count is 1 as mentioned above as well
|
||||
@ -289,8 +322,6 @@ export const textWysiwyg = ({
|
||||
}
|
||||
|
||||
editable.onkeydown = (event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (!event.shiftKey && actionZoomIn.keyTest(event)) {
|
||||
event.preventDefault();
|
||||
app.actionManager.executeAction(actionZoomIn);
|
||||
@ -397,7 +428,7 @@ export const textWysiwyg = ({
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns indeces of start positions of selected lines, in reverse order
|
||||
* @returns indices of start positions of selected lines, in reverse order
|
||||
*/
|
||||
const getSelectedLinesStartIndices = () => {
|
||||
let { selectionStart, selectionEnd, value } = editable;
|
||||
|
@ -222,6 +222,13 @@ export const getTransformHandles = (
|
||||
zoom: Zoom,
|
||||
pointerType: PointerType = "mouse",
|
||||
): TransformHandles => {
|
||||
// so that when locked element is selected (especially when you toggle lock
|
||||
// via keyboard) the locked element is visually distinct, indicating
|
||||
// you can't move/resize
|
||||
if (element.locked) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let omitSides: { [T in TransformHandleType]?: boolean } = {};
|
||||
if (
|
||||
element.type === "arrow" ||
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { AppState } from "../types";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
@ -8,6 +9,8 @@ import {
|
||||
InitializedExcalidrawImageElement,
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawCustomElement,
|
||||
} from "./types";
|
||||
|
||||
export const isGenericElement = (
|
||||
@ -59,7 +62,7 @@ export const isLinearElement = (
|
||||
};
|
||||
|
||||
export const isLinearElementType = (
|
||||
elementType: ExcalidrawElement["type"],
|
||||
elementType: AppState["activeTool"]["type"],
|
||||
): boolean => {
|
||||
return (
|
||||
elementType === "arrow" || elementType === "line" // || elementType === "freedraw"
|
||||
@ -68,21 +71,28 @@ export const isLinearElementType = (
|
||||
|
||||
export const isBindingElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
includeLocked = true,
|
||||
): element is ExcalidrawLinearElement => {
|
||||
return element != null && isBindingElementType(element.type);
|
||||
return (
|
||||
element != null &&
|
||||
(!element.locked || includeLocked === true) &&
|
||||
isBindingElementType(element.type)
|
||||
);
|
||||
};
|
||||
|
||||
export const isBindingElementType = (
|
||||
elementType: ExcalidrawElement["type"],
|
||||
elementType: AppState["activeTool"]["type"],
|
||||
): boolean => {
|
||||
return elementType === "arrow";
|
||||
};
|
||||
|
||||
export const isBindableElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
includeLocked = true,
|
||||
): element is ExcalidrawBindableElement => {
|
||||
return (
|
||||
element != null &&
|
||||
(!element.locked || includeLocked === true) &&
|
||||
(element.type === "rectangle" ||
|
||||
element.type === "diamond" ||
|
||||
element.type === "ellipse" ||
|
||||
@ -91,9 +101,13 @@ export const isBindableElement = (
|
||||
);
|
||||
};
|
||||
|
||||
export const isTextBindableContainer = (element: ExcalidrawElement | null) => {
|
||||
export const isTextBindableContainer = (
|
||||
element: ExcalidrawElement | null,
|
||||
includeLocked = true,
|
||||
): element is ExcalidrawTextContainer => {
|
||||
return (
|
||||
element != null &&
|
||||
(!element.locked || includeLocked === true) &&
|
||||
(element.type === "rectangle" ||
|
||||
element.type === "diamond" ||
|
||||
element.type === "ellipse" ||
|
||||
@ -129,3 +143,7 @@ export const isBoundToContainer = (
|
||||
element !== null && isTextElement(element) && element.containerId !== null
|
||||
);
|
||||
};
|
||||
|
||||
export const isCustomElement = (
|
||||
element: ExcalidrawElement,
|
||||
): element is ExcalidrawCustomElement => element && element.type === "custom";
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Point } from "../types";
|
||||
import { FONT_FAMILY, THEME } from "../constants";
|
||||
import { FONT_FAMILY, THEME, VERTICAL_ALIGN } from "../constants";
|
||||
|
||||
export type ChartType = "bar" | "line";
|
||||
export type FillStyle = "hachure" | "cross-hatch" | "solid";
|
||||
@ -12,7 +12,9 @@ export type PointerType = "mouse" | "pen" | "touch";
|
||||
export type StrokeSharpness = "round" | "sharp";
|
||||
export type StrokeStyle = "solid" | "dashed" | "dotted";
|
||||
export type TextAlign = "left" | "center" | "right";
|
||||
export type VerticalAlign = "top" | "middle";
|
||||
|
||||
type VerticalAlignKeys = keyof typeof VERTICAL_ALIGN;
|
||||
export type VerticalAlign = typeof VERTICAL_ALIGN[VerticalAlignKeys];
|
||||
|
||||
type _ExcalidrawElementBase = Readonly<{
|
||||
id: string;
|
||||
@ -53,6 +55,7 @@ type _ExcalidrawElementBase = Readonly<{
|
||||
/** epoch (ms) timestamp of last element update */
|
||||
updated: number;
|
||||
link: string | null;
|
||||
locked: boolean;
|
||||
}>;
|
||||
|
||||
export type ExcalidrawSelectionElement = _ExcalidrawElementBase & {
|
||||
@ -81,6 +84,9 @@ export type ExcalidrawImageElement = _ExcalidrawElementBase &
|
||||
scale: [number, number];
|
||||
}>;
|
||||
|
||||
export type ExcalidrawCustomElement = _ExcalidrawElementBase &
|
||||
Readonly<{ type: "custom"; customType: string }>;
|
||||
|
||||
export type InitializedExcalidrawImageElement = MarkNonNullable<
|
||||
ExcalidrawImageElement,
|
||||
"fileId"
|
||||
@ -105,7 +111,8 @@ export type ExcalidrawElement =
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawLinearElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawImageElement;
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawCustomElement;
|
||||
|
||||
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
|
||||
isDeleted: boolean;
|
||||
@ -131,10 +138,17 @@ export type ExcalidrawBindableElement =
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawCustomElement;
|
||||
|
||||
export type ExcalidrawTextContainer =
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement
|
||||
| ExcalidrawImageElement;
|
||||
|
||||
export type ExcalidrawTextElementWithContainer = {
|
||||
containerId: ExcalidrawGenericElement["id"];
|
||||
containerId: ExcalidrawTextContainer["id"];
|
||||
} & ExcalidrawTextElement;
|
||||
|
||||
export type PointBinding = {
|
||||
|
@ -5,17 +5,18 @@ export const FILE_UPLOAD_TIMEOUT = 300;
|
||||
export const LOAD_IMAGES_TIMEOUT = 500;
|
||||
export const SYNC_FULL_SCENE_INTERVAL_MS = 20000;
|
||||
export const SYNC_BROWSER_TABS_TIMEOUT = 50;
|
||||
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
|
||||
|
||||
export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
|
||||
// 1 year (https://stackoverflow.com/a/25201898/927631)
|
||||
export const FILE_CACHE_MAX_AGE_SEC = 31536000;
|
||||
|
||||
export const BROADCAST = {
|
||||
export const WS_EVENTS = {
|
||||
SERVER_VOLATILE: "server-volatile-broadcast",
|
||||
SERVER: "server-broadcast",
|
||||
};
|
||||
|
||||
export enum SCENE {
|
||||
export enum WS_SCENE_EVENT_TYPES {
|
||||
INIT = "SCENE_INIT",
|
||||
UPDATE = "SCENE_UPDATE",
|
||||
}
|
||||
|
@ -11,24 +11,26 @@ import {
|
||||
import { getSceneVersion } from "../../packages/excalidraw/index";
|
||||
import { Collaborator, Gesture } from "../../types";
|
||||
import {
|
||||
getFrame,
|
||||
preventUnload,
|
||||
resolvablePromise,
|
||||
withBatchedUpdates,
|
||||
} from "../../utils";
|
||||
import {
|
||||
CURSOR_SYNC_TIMEOUT,
|
||||
FILE_UPLOAD_MAX_BYTES,
|
||||
FIREBASE_STORAGE_PREFIXES,
|
||||
INITIAL_SCENE_UPDATE_TIMEOUT,
|
||||
LOAD_IMAGES_TIMEOUT,
|
||||
SCENE,
|
||||
WS_SCENE_EVENT_TYPES,
|
||||
STORAGE_KEYS,
|
||||
SYNC_FULL_SCENE_INTERVAL_MS,
|
||||
} from "../app_constants";
|
||||
import {
|
||||
generateCollaborationLinkData,
|
||||
getCollaborationLink,
|
||||
getCollabServer,
|
||||
SocketUpdateDataSource,
|
||||
SOCKET_SERVER,
|
||||
} from "../data";
|
||||
import {
|
||||
isSavedToFirebase,
|
||||
@ -66,6 +68,7 @@ import {
|
||||
} from "./reconciliation";
|
||||
import { decryptData } from "../../data/encryption";
|
||||
import { resetBrowserStateVersions } from "../data/tabSync";
|
||||
import { LocalData } from "../data/LocalData";
|
||||
|
||||
interface CollabState {
|
||||
modalIsShown: boolean;
|
||||
@ -85,7 +88,7 @@ export interface CollabAPI {
|
||||
onPointerUpdate: CollabInstance["onPointerUpdate"];
|
||||
initializeSocketClient: CollabInstance["initializeSocketClient"];
|
||||
onCollabButtonClick: CollabInstance["onCollabButtonClick"];
|
||||
broadcastElements: CollabInstance["broadcastElements"];
|
||||
syncElements: CollabInstance["syncElements"];
|
||||
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
|
||||
setUsername: (username: string) => void;
|
||||
}
|
||||
@ -107,10 +110,11 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
portal: Portal;
|
||||
fileManager: FileManager;
|
||||
excalidrawAPI: Props["excalidrawAPI"];
|
||||
isCollaborating: boolean = false;
|
||||
activeIntervalId: number | null;
|
||||
idleTimeoutId: number | null;
|
||||
|
||||
// marked as private to ensure we don't change it outside this class
|
||||
private _isCollaborating: boolean = false;
|
||||
private socketInitializationTimer?: number;
|
||||
private lastBroadcastedOrReceivedSceneVersion: number = -1;
|
||||
private collaborators = new Map<string, Collaborator>();
|
||||
@ -191,6 +195,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
}
|
||||
}
|
||||
|
||||
isCollaborating = () => this._isCollaborating;
|
||||
|
||||
private onUnload = () => {
|
||||
this.destroySocketClient({ isUnload: true });
|
||||
};
|
||||
@ -201,7 +207,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
);
|
||||
|
||||
if (
|
||||
this.isCollaborating &&
|
||||
this._isCollaborating &&
|
||||
(this.fileManager.shouldPreventUnload(syncableElements) ||
|
||||
!isSavedToFirebase(this.portal, syncableElements))
|
||||
) {
|
||||
@ -226,27 +232,40 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
});
|
||||
|
||||
saveCollabRoomToFirebase = async (
|
||||
syncableElements: readonly ExcalidrawElement[] = this.getSyncableElements(
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
),
|
||||
syncableElements: readonly ExcalidrawElement[],
|
||||
) => {
|
||||
try {
|
||||
await saveToFirebase(this.portal, syncableElements);
|
||||
const savedData = await saveToFirebase(
|
||||
this.portal,
|
||||
syncableElements,
|
||||
this.excalidrawAPI.getAppState(),
|
||||
);
|
||||
|
||||
if (this.isCollaborating() && savedData && savedData.reconciledElements) {
|
||||
this.handleRemoteSceneUpdate(
|
||||
this.reconcileElements(savedData.reconciledElements),
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
openPortal = async () => {
|
||||
trackEvent("share", "room creation");
|
||||
trackEvent("share", "room creation", `ui (${getFrame()})`);
|
||||
return this.initializeSocketClient(null);
|
||||
};
|
||||
|
||||
closePortal = () => {
|
||||
this.queueBroadcastAllElements.cancel();
|
||||
this.queueSaveToFirebase.cancel();
|
||||
this.loadImageFiles.cancel();
|
||||
|
||||
this.saveCollabRoomToFirebase();
|
||||
this.saveCollabRoomToFirebase(
|
||||
this.getSyncableElements(
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
),
|
||||
);
|
||||
if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
|
||||
// hack to ensure that we prefer we disregard any new browser state
|
||||
// that could have been saved in other tabs while we were collaborating
|
||||
@ -283,7 +302,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
this.setState({
|
||||
activeRoomLink: "",
|
||||
});
|
||||
this.isCollaborating = false;
|
||||
this._isCollaborating = false;
|
||||
LocalData.resumeSave("collaboration");
|
||||
}
|
||||
this.lastBroadcastedOrReceivedSceneVersion = -1;
|
||||
this.portal.close();
|
||||
@ -351,34 +371,32 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
|
||||
const scenePromise = resolvablePromise<ImportedDataState | null>();
|
||||
|
||||
this.isCollaborating = true;
|
||||
this._isCollaborating = true;
|
||||
LocalData.pauseSave("collaboration");
|
||||
|
||||
const { default: socketIOClient }: any = await import(
|
||||
const { default: socketIOClient } = await import(
|
||||
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
|
||||
);
|
||||
|
||||
this.portal.open(socketIOClient(SOCKET_SERVER), roomId, roomKey);
|
||||
try {
|
||||
const socketServerData = await getCollabServer();
|
||||
|
||||
if (existingRoomLinkData) {
|
||||
this.excalidrawAPI.resetScene();
|
||||
this.portal.socket = this.portal.open(
|
||||
socketIOClient(socketServerData.url, {
|
||||
transports: socketServerData.polling
|
||||
? ["websocket", "polling"]
|
||||
: ["websocket"],
|
||||
}),
|
||||
roomId,
|
||||
roomKey,
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
this.setState({ errorMessage: error.message });
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const elements = await loadFromFirebase(
|
||||
roomId,
|
||||
roomKey,
|
||||
this.portal.socket,
|
||||
);
|
||||
if (elements) {
|
||||
scenePromise.resolve({
|
||||
elements,
|
||||
scrollToContent: true,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
// log the error and move on. other peers will sync us the scene.
|
||||
console.error(error);
|
||||
}
|
||||
} else {
|
||||
if (!existingRoomLinkData) {
|
||||
const elements = this.excalidrawAPI.getSceneElements().map((element) => {
|
||||
if (isImageElement(element) && element.status === "saved") {
|
||||
return newElementWith(element, { status: "pending" });
|
||||
@ -395,21 +413,21 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
commitToHistory: true,
|
||||
});
|
||||
|
||||
this.broadcastElements(elements);
|
||||
|
||||
const syncableElements = this.getSyncableElements(elements);
|
||||
this.saveCollabRoomToFirebase(syncableElements);
|
||||
this.saveCollabRoomToFirebase(this.getSyncableElements(elements));
|
||||
}
|
||||
|
||||
// fallback in case you're not alone in the room but still don't receive
|
||||
// initial SCENE_UPDATE message
|
||||
// initial SCENE_INIT message
|
||||
this.socketInitializationTimer = window.setTimeout(() => {
|
||||
this.initializeSocket();
|
||||
this.initializeRoom({
|
||||
roomLinkData: existingRoomLinkData,
|
||||
fetchScene: true,
|
||||
});
|
||||
scenePromise.resolve(null);
|
||||
}, INITIAL_SCENE_UPDATE_TIMEOUT);
|
||||
|
||||
// All socket listeners are moving to Portal
|
||||
this.portal.socket!.on(
|
||||
this.portal.socket.on(
|
||||
"client-broadcast",
|
||||
async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
|
||||
if (!this.portal.roomKey) {
|
||||
@ -425,9 +443,9 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
switch (decryptedData.type) {
|
||||
case "INVALID_RESPONSE":
|
||||
return;
|
||||
case SCENE.INIT: {
|
||||
case WS_SCENE_EVENT_TYPES.INIT: {
|
||||
if (!this.portal.socketInitialized) {
|
||||
this.initializeSocket();
|
||||
this.initializeRoom({ fetchScene: false });
|
||||
const remoteElements = decryptedData.payload.elements;
|
||||
const reconciledElements = this.reconcileElements(remoteElements);
|
||||
this.handleRemoteSceneUpdate(reconciledElements, {
|
||||
@ -441,7 +459,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SCENE.UPDATE:
|
||||
case WS_SCENE_EVENT_TYPES.UPDATE:
|
||||
this.handleRemoteSceneUpdate(
|
||||
this.reconcileElements(decryptedData.payload.elements),
|
||||
);
|
||||
@ -481,12 +499,15 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
},
|
||||
);
|
||||
|
||||
this.portal.socket!.on("first-in-room", () => {
|
||||
this.portal.socket.on("first-in-room", async () => {
|
||||
if (this.portal.socket) {
|
||||
this.portal.socket.off("first-in-room");
|
||||
}
|
||||
this.initializeSocket();
|
||||
scenePromise.resolve(null);
|
||||
const sceneData = await this.initializeRoom({
|
||||
fetchScene: true,
|
||||
roomLinkData: existingRoomLinkData,
|
||||
});
|
||||
scenePromise.resolve(sceneData);
|
||||
});
|
||||
|
||||
this.initializeIdleDetector();
|
||||
@ -498,9 +519,45 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
return scenePromise;
|
||||
};
|
||||
|
||||
private initializeSocket = () => {
|
||||
this.portal.socketInitialized = true;
|
||||
private initializeRoom = async ({
|
||||
fetchScene,
|
||||
roomLinkData,
|
||||
}:
|
||||
| {
|
||||
fetchScene: true;
|
||||
roomLinkData: { roomId: string; roomKey: string } | null;
|
||||
}
|
||||
| { fetchScene: false; roomLinkData?: null }) => {
|
||||
clearTimeout(this.socketInitializationTimer!);
|
||||
if (fetchScene && roomLinkData && this.portal.socket) {
|
||||
this.excalidrawAPI.resetScene();
|
||||
|
||||
try {
|
||||
const elements = await loadFromFirebase(
|
||||
roomLinkData.roomId,
|
||||
roomLinkData.roomKey,
|
||||
this.portal.socket,
|
||||
);
|
||||
if (elements) {
|
||||
this.setLastBroadcastedOrReceivedSceneVersion(
|
||||
getSceneVersion(elements),
|
||||
);
|
||||
|
||||
return {
|
||||
elements,
|
||||
scrollToContent: true,
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
// log the error and move on. other peers will sync us the scene.
|
||||
console.error(error);
|
||||
} finally {
|
||||
this.portal.socketInitialized = true;
|
||||
}
|
||||
} else {
|
||||
this.portal.socketInitialized = true;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
private reconcileElements = (
|
||||
@ -564,7 +621,9 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
window.clearTimeout(this.idleTimeoutId);
|
||||
this.idleTimeoutId = null;
|
||||
}
|
||||
|
||||
this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD);
|
||||
|
||||
if (!this.activeIntervalId) {
|
||||
this.activeIntervalId = window.setInterval(
|
||||
this.reportActive,
|
||||
@ -639,15 +698,18 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
return this.excalidrawAPI.getSceneElementsIncludingDeleted();
|
||||
};
|
||||
|
||||
onPointerUpdate = (payload: {
|
||||
pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
|
||||
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
|
||||
pointersMap: Gesture["pointers"];
|
||||
}) => {
|
||||
payload.pointersMap.size < 2 &&
|
||||
this.portal.socket &&
|
||||
this.portal.broadcastMouseLocation(payload);
|
||||
};
|
||||
onPointerUpdate = throttle(
|
||||
(payload: {
|
||||
pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
|
||||
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
|
||||
pointersMap: Gesture["pointers"];
|
||||
}) => {
|
||||
payload.pointersMap.size < 2 &&
|
||||
this.portal.socket &&
|
||||
this.portal.broadcastMouseLocation(payload);
|
||||
},
|
||||
CURSOR_SYNC_TIMEOUT,
|
||||
);
|
||||
|
||||
onIdleStateChange = (userState: UserIdleState) => {
|
||||
this.setState({ userState });
|
||||
@ -659,15 +721,20 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
getSceneVersion(elements) >
|
||||
this.getLastBroadcastedOrReceivedSceneVersion()
|
||||
) {
|
||||
this.portal.broadcastScene(SCENE.UPDATE, elements, false);
|
||||
this.portal.broadcastScene(WS_SCENE_EVENT_TYPES.UPDATE, elements, false);
|
||||
this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);
|
||||
this.queueBroadcastAllElements();
|
||||
}
|
||||
};
|
||||
|
||||
syncElements = (elements: readonly ExcalidrawElement[]) => {
|
||||
this.broadcastElements(elements);
|
||||
this.queueSaveToFirebase();
|
||||
};
|
||||
|
||||
queueBroadcastAllElements = throttle(() => {
|
||||
this.portal.broadcastScene(
|
||||
SCENE.UPDATE,
|
||||
WS_SCENE_EVENT_TYPES.UPDATE,
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
true,
|
||||
);
|
||||
@ -679,6 +746,16 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
this.setLastBroadcastedOrReceivedSceneVersion(newVersion);
|
||||
}, SYNC_FULL_SCENE_INTERVAL_MS);
|
||||
|
||||
queueSaveToFirebase = throttle(() => {
|
||||
if (this.portal.socketInitialized) {
|
||||
this.saveCollabRoomToFirebase(
|
||||
this.getSyncableElements(
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}, SYNC_FULL_SCENE_INTERVAL_MS);
|
||||
|
||||
handleClose = () => {
|
||||
this.setState({ modalIsShown: false });
|
||||
};
|
||||
@ -714,12 +791,12 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
this.contextValue = {} as CollabAPI;
|
||||
}
|
||||
|
||||
this.contextValue.isCollaborating = () => this.isCollaborating;
|
||||
this.contextValue.isCollaborating = this.isCollaborating;
|
||||
this.contextValue.username = this.state.username;
|
||||
this.contextValue.onPointerUpdate = this.onPointerUpdate;
|
||||
this.contextValue.initializeSocketClient = this.initializeSocketClient;
|
||||
this.contextValue.onCollabButtonClick = this.onCollabButtonClick;
|
||||
this.contextValue.broadcastElements = this.broadcastElements;
|
||||
this.contextValue.syncElements = this.syncElements;
|
||||
this.contextValue.fetchImageFilesFromFirebase =
|
||||
this.fetchImageFilesFromFirebase;
|
||||
this.contextValue.setUsername = this.setUsername;
|
||||
|
@ -3,7 +3,11 @@ import { SocketUpdateData, SocketUpdateDataSource } from "../data";
|
||||
import CollabWrapper from "./CollabWrapper";
|
||||
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import { BROADCAST, FILE_UPLOAD_TIMEOUT, SCENE } from "../app_constants";
|
||||
import {
|
||||
WS_EVENTS,
|
||||
FILE_UPLOAD_TIMEOUT,
|
||||
WS_SCENE_EVENT_TYPES,
|
||||
} from "../app_constants";
|
||||
import { UserIdleState } from "../../types";
|
||||
import { trackEvent } from "../../analytics";
|
||||
import { throttle } from "lodash";
|
||||
@ -37,7 +41,7 @@ class Portal {
|
||||
});
|
||||
this.socket.on("new-user", async (_socketId: string) => {
|
||||
this.broadcastScene(
|
||||
SCENE.INIT,
|
||||
WS_SCENE_EVENT_TYPES.INIT,
|
||||
this.collab.getSceneElementsIncludingDeleted(),
|
||||
/* syncAll */ true,
|
||||
);
|
||||
@ -45,6 +49,8 @@ class Portal {
|
||||
this.socket.on("room-user-change", (clients: string[]) => {
|
||||
this.collab.setCollaborators(clients);
|
||||
});
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
close() {
|
||||
@ -79,7 +85,7 @@ class Portal {
|
||||
const { encryptedBuffer, iv } = await encryptData(this.roomKey!, encoded);
|
||||
|
||||
this.socket?.emit(
|
||||
volatile ? BROADCAST.SERVER_VOLATILE : BROADCAST.SERVER,
|
||||
volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER,
|
||||
this.roomId,
|
||||
encryptedBuffer,
|
||||
iv,
|
||||
@ -119,11 +125,11 @@ class Portal {
|
||||
}, FILE_UPLOAD_TIMEOUT);
|
||||
|
||||
broadcastScene = async (
|
||||
sceneType: SCENE.INIT | SCENE.UPDATE,
|
||||
updateType: WS_SCENE_EVENT_TYPES.INIT | WS_SCENE_EVENT_TYPES.UPDATE,
|
||||
allElements: readonly ExcalidrawElement[],
|
||||
syncAll: boolean,
|
||||
) => {
|
||||
if (sceneType === SCENE.INIT && !syncAll) {
|
||||
if (updateType === WS_SCENE_EVENT_TYPES.INIT && !syncAll) {
|
||||
throw new Error("syncAll must be true when sending SCENE.INIT");
|
||||
}
|
||||
|
||||
@ -150,8 +156,8 @@ class Portal {
|
||||
[] as BroadcastedExcalidrawElement[],
|
||||
);
|
||||
|
||||
const data: SocketUpdateDataSource[typeof sceneType] = {
|
||||
type: sceneType,
|
||||
const data: SocketUpdateDataSource[typeof updateType] = {
|
||||
type: updateType,
|
||||
payload: {
|
||||
elements: syncableElements,
|
||||
},
|
||||
@ -164,20 +170,9 @@ class Portal {
|
||||
);
|
||||
}
|
||||
|
||||
const broadcastPromise = this._broadcastSocketData(
|
||||
data as SocketUpdateData,
|
||||
);
|
||||
|
||||
this.queueFileUpload();
|
||||
|
||||
if (syncAll && this.collab.isCollaborating) {
|
||||
await Promise.all([
|
||||
broadcastPromise,
|
||||
this.collab.saveCollabRoomToFirebase(syncableElements),
|
||||
]);
|
||||
} else {
|
||||
await broadcastPromise;
|
||||
}
|
||||
await this._broadcastSocketData(data as SocketUpdateData);
|
||||
};
|
||||
|
||||
broadcastIdleChange = (userState: UserIdleState) => {
|
||||
|
@ -78,8 +78,14 @@ export const reconcileElements = (
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mark duplicate for removal as it'll be replaced with the remote element
|
||||
if (local) {
|
||||
// mark for removal since it'll be replaced with the remote element
|
||||
// Unless the ramote and local elements are the same element in which case
|
||||
// we need to keep it as we'd otherwise discard it from the resulting
|
||||
// array.
|
||||
if (local[0] === remoteElement) {
|
||||
continue;
|
||||
}
|
||||
duplicates.set(local[0], true);
|
||||
}
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user