Compare commits
114 Commits
make_defau
...
devolved-i
Author | SHA1 | Date | |
---|---|---|---|
b20fe944e7 | |||
1156ef6b96 | |||
dc25fe06d0 | |||
1e17c1967b | |||
23a8891e0e | |||
6c81a32d62 | |||
f0f5430313 | |||
e18e945cd3 | |||
bd0c6e63ff | |||
dbae33e4f8 | |||
0f4a053759 | |||
1837147c55 | |||
15f698dc21 | |||
ce507b0a0b | |||
02598c6163 | |||
3e2890bd21 | |||
210649f383 | |||
f8087e01c8 | |||
e2522645f7 | |||
d46a9166be | |||
675da16ca4 | |||
2b1b62d8f2 | |||
627c56ef1c | |||
0d0fe32485 | |||
6576b9442e | |||
ee4cb2d4a9 | |||
cdcc91faa5 | |||
9093341dc1 | |||
1973ae9444 | |||
10cd6a24b0 | |||
6abf4f52ff | |||
4624ec2bd6 | |||
e8685c5236 | |||
6e9df2bae7 | |||
ed0bec41dc | |||
d4e12a2962 | |||
978e85a33b | |||
b5e26ba81f | |||
4392a4644a | |||
b1c8c538ee | |||
f6492895df | |||
e75f5f20e7 | |||
0a0be839b9 | |||
03f6d9c783 | |||
df745c1098 | |||
b888f7e7ba | |||
3010253f72 | |||
0815f3282e | |||
9dd58288de | |||
c2e2bb495c | |||
a31cfe1f07 | |||
d3367bfe12 | |||
70791dfa7b | |||
d63ec678db | |||
26acebcdb6 | |||
9dc930b447 | |||
6e767fc949 | |||
49bd683401 | |||
3922ee8c11 | |||
8c2bc94336 | |||
fb4d97ef78 | |||
a8e28afbad | |||
68347ba476 | |||
ce2c341910 | |||
07cc858926 | |||
a7a2936f7c | |||
e72ff6be66 | |||
0ea29675df | |||
da2ad4f37c | |||
33a7cf0d3f | |||
f8e890df7b | |||
12337a54a3 | |||
7a9ed2cfa1 | |||
553bf2956f | |||
1e16a6e5bd | |||
e26f374ca6 | |||
c799b28a0e | |||
ee703206b0 | |||
543c624405 | |||
0bf6830373 | |||
af79461f41 | |||
fd699c0447 | |||
6a16caf13c | |||
511eb62228 | |||
04c46fc01a | |||
49e792649d | |||
4e1caf2417 | |||
034f2e470b | |||
adcd28f348 | |||
62f1ed74f1 | |||
8fa4273969 | |||
672068ce7e | |||
f1fc308a5d | |||
001880ba88 | |||
3a130cb102 | |||
e682cf9bf6 | |||
2a169924d0 | |||
eb6e75b806 | |||
38857b9e9d | |||
342289f261 | |||
095d7de618 | |||
f57d52028a | |||
60557df23a | |||
bafbe9bbc8 | |||
eb71e571e0 | |||
b608ab74cc | |||
a13c4f72f5 | |||
629341da4d | |||
778e4b08af | |||
e16266ce5d | |||
b6708fb73f | |||
cdffed285d | |||
3aa01ad272 | |||
4acdc47ef0 |
@ -1 +1 @@
|
||||
REACT_APP_INCLUDE_GTAG=true
|
||||
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
|
||||
|
BIN
.github/assets/logo.png
vendored
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 20 KiB |
2
.github/workflows/build-docker.yml
vendored
@ -6,7 +6,7 @@ on:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build-docker:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
4
.github/workflows/build-packages.yml
vendored
@ -13,10 +13,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
- name: Setup Node.js 12.x
|
||||
- name: Setup Node.js 14.x
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 12.x
|
||||
node-version: 14.x
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
|
18
.github/workflows/cancel.yml
vendored
Normal file
@ -0,0 +1,18 @@
|
||||
name: Cancel previous runs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
cancel:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
timeout-minutes: 3
|
||||
steps:
|
||||
- uses: styfle/cancel-workflow-action@0.6.0
|
||||
with:
|
||||
workflow_id: 400555, 400556, 905313, 1451724, 1710116, 3185001, 3438604
|
||||
access_token: ${{ secrets.GITHUB_TOKEN }}
|
12
.github/workflows/lint.yml
vendored
@ -1,10 +1,6 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
on: pull_request
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
@ -13,10 +9,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
- name: Setup Node.js 12.x
|
||||
- name: Setup Node.js 14.x
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 12.x
|
||||
node-version: 14.x
|
||||
|
||||
- name: Install and lint
|
||||
run: |
|
||||
@ -24,5 +20,3 @@ jobs:
|
||||
npm run test:other
|
||||
npm run test:code
|
||||
npm run test:typecheck
|
||||
env:
|
||||
CI: true
|
||||
|
10
.github/workflows/locales-coverage.yml
vendored
@ -14,18 +14,18 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||
|
||||
- name: Setup Node.js 12.x
|
||||
- name: Setup Node.js 14.x
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 12.x
|
||||
node-version: 14.x
|
||||
|
||||
- name: Create report file
|
||||
run: |
|
||||
npm run locales-coverage
|
||||
FILE_CHANGED=$(git diff src/locales/percentages.json)
|
||||
if [ ! -z "${FILE_CHANGED}" ]; then
|
||||
git config --global user.name 'Kostas Bariotis'
|
||||
git config --global user.email 'konmpar@gmail.com'
|
||||
git config --global user.name 'Excalidraw Bot'
|
||||
git config --global user.email 'bot@excalidraw.com'
|
||||
git add src/locales/percentages.json
|
||||
git commit -am "Auto commit: Calculate translation coverage"
|
||||
git push
|
||||
@ -43,5 +43,5 @@ jobs:
|
||||
uses: kt3k/update-pr-description@v1.0.1
|
||||
with:
|
||||
pr_body: ${{ steps.getCommentBody.outputs.body }}
|
||||
pr_title: "chore: New Crowdin updates"
|
||||
pr_title: "chore: Update translations from Crowdin"
|
||||
github_token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||
|
3
.github/workflows/semantic-pr-title.yml
vendored
@ -10,7 +10,8 @@ on:
|
||||
jobs:
|
||||
main:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@v2.1.0
|
||||
- uses: amannn/action-semantic-pull-request@v3.0.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
5
.github/workflows/sentry-production.yml
vendored
@ -8,13 +8,14 @@ on:
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1.0.0
|
||||
|
||||
- name: Setup Node.js 12.x
|
||||
- name: Setup Node.js 14.x
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 12.x
|
||||
node-version: 14.x
|
||||
|
||||
- name: Install and build
|
||||
run: |
|
||||
|
12
.github/workflows/test.yml
vendored
@ -1,10 +1,6 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
on: pull_request
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@ -13,14 +9,12 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
- name: Setup Node.js 12.x
|
||||
- name: Setup Node.js 14.x
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 12.x
|
||||
node-version: 14.x
|
||||
|
||||
- name: Install and test
|
||||
run: |
|
||||
npm ci
|
||||
npm run test:app
|
||||
env:
|
||||
CI: true
|
||||
|
@ -1,3 +1,4 @@
|
||||
{
|
||||
"proseWrap": "never",
|
||||
"trailingComma": "all"
|
||||
}
|
||||
|
@ -8,8 +8,7 @@
|
||||
1. Run `npm install` to install dependencies
|
||||
1. Create a branch for your PR with `git checkout -b your-branch-name`
|
||||
|
||||
> To keep `master` branch pointing to remote repository and make
|
||||
> pull requests from branches on your fork. To do this, run:
|
||||
> To keep `master` branch pointing to remote repository and make pull requests from branches on your fork. To do this, run:
|
||||
>
|
||||
> ```sh
|
||||
> git remote add upstream https://github.com/excalidraw/excalidraw.git
|
||||
@ -25,3 +24,41 @@
|
||||
1. Tap on `Fork Sandbox`
|
||||
1. Write your code
|
||||
1. Commit and PR automatically
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
Don't worry if you get any of the below wrong, or if you don't know how. We'll gladly help out.
|
||||
|
||||
### Title
|
||||
|
||||
Make sure the title starts with a semantic prefix:
|
||||
|
||||
- **feat**: A new feature
|
||||
- **fix**: A bug fix
|
||||
- **improvement**: An improvement to a current feature
|
||||
- **docs**: Documentation only changes
|
||||
- **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)
|
||||
- **refactor**: A code change that neither fixes a bug nor adds a feature
|
||||
- **perf**: A code change that improves performance
|
||||
- **test**: Adding missing tests or correcting existing tests
|
||||
- **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
|
||||
- **ci**: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)
|
||||
- **chore**: Other changes that don't modify src or test files
|
||||
- **revert**: Reverts a previous commit
|
||||
|
||||
### Changelog
|
||||
|
||||
Add a brief description of your pull request to the changelog located here: [`src/packages/excalidraw/CHANGELOG.md`](src/packages/excalidraw/CHANGELOG.md)
|
||||
|
||||
Notes:
|
||||
|
||||
- Make sure to prepend to the section corresponding with the semantic prefix you selected in the title
|
||||
- Link to your pull request - this will require updating the CHANGELOG _after_ creating the pull request
|
||||
|
||||
### Testing
|
||||
|
||||
Once you submit your pull request it will automatically be tested. Be sure to check the results of the test and fix any issues that arise.
|
||||
|
||||
It's also a good idea to consider if your change should include additional tests. This is highly recommended for new features or bug-fixes. For example, it's good practice to create a test for each bug you fix which ensures that we don't regress the code in the future.
|
||||
|
||||
Finally - always manually test your changes using the convenient staging environment deployed for each pull request. As much as local development attempts to replicate production, there can still be subtle differences in behavior. For larger features consider testing your change in multiple browsers as well.
|
||||
|
@ -5,7 +5,6 @@ WORKDIR /opt/node_app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm i --no-optional
|
||||
|
||||
ARG REACT_APP_INCLUDE_GTAG=false
|
||||
ARG NODE_ENV=production
|
||||
|
||||
COPY . .
|
||||
|
101
README.md
@ -1,8 +1,8 @@
|
||||
<div align="center" style="display:flex;flex-direction:column;">
|
||||
<a href="https://excalidraw.com">
|
||||
<img src="./public/og-image.png" alt="Excalidraw logo: Sketch handrawn like diagrams." />
|
||||
<img width="540" src="./public/og-image-sm.png" alt="Excalidraw logo: Sketch handrawn like diagrams." />
|
||||
</a>
|
||||
<h3>Virtual whiteboard for sketching hand-drawn like diagrams.</h3>
|
||||
<h3>Virtual whiteboard for sketching hand-drawn like diagrams.<br>Collaborative and end to end encrypted.</h3>
|
||||
<p>
|
||||
<a href="https://twitter.com/Excalidraw">
|
||||
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+excalidraw&style=social&logo=twitter">
|
||||
@ -10,9 +10,6 @@
|
||||
<a target="_blank" href="https://crowdin.com/project/excalidraw">
|
||||
<img src="https://badges.crowdin.net/excalidraw/localized.svg">
|
||||
</a>
|
||||
<a target="_blank" href="https://hub.docker.com/r/excalidraw/excalidraw">
|
||||
<img src="https://img.shields.io/docker/pulls/excalidraw/excalidraw">
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -20,13 +17,51 @@
|
||||
|
||||
Go to [excalidraw.com](https://excalidraw.com) to start sketching.
|
||||
|
||||
Read our [blog](https://blog.excalidraw.com) and follow the [guides](https://howto.excalidraw.com) to learn more about Excalidraw and how to use it effectively.
|
||||
Read the latest news and updates on our [blog](https://blog.excalidraw.com). A good start is to see all the updates of [One Year of Excalidraw](https://blog.excalidraw.com/one-year-of-excalidraw/).
|
||||
|
||||
## Documentation
|
||||
|
||||
### Shortcuts
|
||||
|
||||
You can almost do anything with shortcuts. Click on the help icon on the bottom right corner to see them all.
|
||||
|
||||
### Curved lines and arrows
|
||||
|
||||
Choose line or arrow and click click click instead of drag.
|
||||
|
||||
### Charts
|
||||
|
||||
You can easily create charts by copy pasting data from Excel or just plain comma separated text.
|
||||
|
||||
### Translating
|
||||
|
||||
To translate Excalidraw into other languages, please visit [our Crowdin page](https://crowdin.com/project/excalidraw). To add a new language, [open an issue](https://github.com/excalidraw/excalidraw/issues/new) so we can get things set up on our end first.
|
||||
|
||||
Translations will be available on the app if they exceed a certain threshold of completion (currently 85%).
|
||||
|
||||
### Create a collaboration session manually
|
||||
|
||||
In order to create a session manually you just need to generate a link of this form:
|
||||
|
||||
```
|
||||
https://excalidraw.com/#room=[0-9a-f]{20},[a-zA-Z0-9_-]{22}
|
||||
```
|
||||
|
||||
#### Example
|
||||
|
||||
```
|
||||
https://excalidraw.com/#room=91bd46ae3aa84dff9d20,pfLqgEoY1c2ioq8LmGwsFA
|
||||
```
|
||||
|
||||
The first set of digits is the room. This is visible from the server that’s going to dispatch messages to everyone that knows this number.
|
||||
|
||||
The second set of digits is the encryption key. The Excalidraw server doesn’t know about it. This is what all the participants use to encrypt/decrypt the messages.
|
||||
|
||||
## Shape libraries
|
||||
|
||||
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
|
||||
|
||||
## Run the code
|
||||
## Developement
|
||||
|
||||
### Code Sandbox
|
||||
|
||||
@ -63,7 +98,7 @@ You can use docker-compose to work on excalidraw locally if you don't want to se
|
||||
docker-compose up --build -d
|
||||
```
|
||||
|
||||
## Self hosting
|
||||
### Self hosting
|
||||
|
||||
We publish a Docker image with the Excalidraw client at [excalidraw/excalidraw](https://hub.docker.com/r/excalidraw/excalidraw). You can use it to self host your own client under your own domain, on Kubernetes, AWS ECS, etc.
|
||||
|
||||
@ -82,57 +117,11 @@ We are working towards providing a full-fledged solution for self hosting your o
|
||||
|
||||
Pull requests are welcome. For major changes, please [open an issue](https://github.com/excalidraw/excalidraw/issues/new) first to discuss what you would like to change.
|
||||
|
||||
## Translating
|
||||
## Notable used tools
|
||||
|
||||
To translate Excalidraw into other languages, please visit [our Crowdin page](https://crowdin.com/project/excalidraw). To add a new language, [open an issue](https://github.com/excalidraw/excalidraw/issues/new) so we can get things set up on our end first.
|
||||
|
||||
Translations will be available on the app if they exceed a certain threshold of completion (currently 85%).
|
||||
|
||||
## Excalidraw is built using these awesome tools
|
||||
|
||||
- [React](https://reactjs.org)
|
||||
- [Create React App](https://github.com/facebook/create-react-app)
|
||||
- [Rough.js](https://roughjs.com)
|
||||
- [TypeScript](https://www.typescriptlang.org)
|
||||
- [Vercel](https://vercel.com)
|
||||
|
||||
And the main source of inspiration for starting the project is the awesome [Zwibbler](https://zwibbler.com/demo/) app.
|
||||
|
||||
## Testimonials
|
||||
|
||||
<a href="https://twitter.com/Lissy_Sykes/status/1213813117177729026"><img width="398" src="https://user-images.githubusercontent.com/197597/71783813-dbf8a600-2fa0-11ea-9c0d-bb3cc45969e6.png"></a>
|
||||
<a href="https://twitter.com/dan_abramov/status/1213762494428262400"><img width="398" src="https://user-images.githubusercontent.com/197597/71783990-4d395880-2fa3-11ea-9ad7-186138db5003.png"></a>
|
||||
|
||||
<a href="https://twitter.com/kyehohenberger/status/1214288572037025792"><img width="423" src="https://user-images.githubusercontent.com/197597/71851802-34f13880-308c-11ea-9416-191099e6349c.png"></a>
|
||||
<a href="https://twitter.com/lucasazzola/status/1215126440330416128"><img width="429" src="https://user-images.githubusercontent.com/197597/72039003-48e99580-3258-11ea-8daa-85dd055f2a82.png">
|
||||
|
||||
<a href="https://twitter.com/jordwalke/status/1214858186789806080"><img width="434" src="https://user-images.githubusercontent.com/197597/72036874-07a1b780-3251-11ea-99e8-6bafd93483a0.png"></a>
|
||||
|
||||
## Contributors
|
||||
|
||||
### Code Contributors
|
||||
|
||||
This project exists thanks to all the people who contribute. [[Contribute](CONTRIBUTING.md)].
|
||||
<a href="https://github.com/excalidraw/excalidraw/graphs/contributors"><img src="https://opencollective.com/excalidraw/contributors.svg?width=890&button=false" /></a>
|
||||
|
||||
### Financial Contributors
|
||||
|
||||
Become a financial contributor and help us sustain our community. [[Contribute](https://opencollective.com/excalidraw/contribute)]
|
||||
|
||||
#### Individuals
|
||||
|
||||
<a href="https://opencollective.com/excalidraw"><img src="https://opencollective.com/excalidraw/individuals.svg?width=890"></a>
|
||||
|
||||
#### Organizations
|
||||
|
||||
Support this project with your organization. Your logo will show up here with a link to your website. [[Contribute](https://opencollective.com/excalidraw/contribute)]
|
||||
|
||||
<a href="https://opencollective.com/excalidraw/organization/0/website"><img src="https://opencollective.com/excalidraw/organization/0/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/excalidraw/organization/1/website"><img src="https://opencollective.com/excalidraw/organization/1/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/excalidraw/organization/2/website"><img src="https://opencollective.com/excalidraw/organization/2/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/excalidraw/organization/3/website"><img src="https://opencollective.com/excalidraw/organization/3/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/excalidraw/organization/4/website"><img src="https://opencollective.com/excalidraw/organization/4/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/excalidraw/organization/5/website"><img src="https://opencollective.com/excalidraw/organization/5/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/excalidraw/organization/6/website"><img src="https://opencollective.com/excalidraw/organization/6/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/excalidraw/organization/7/website"><img src="https://opencollective.com/excalidraw/organization/7/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/excalidraw/organization/8/website"><img src="https://opencollective.com/excalidraw/organization/8/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/excalidraw/organization/9/website"><img src="https://opencollective.com/excalidraw/organization/9/avatar.svg"></a>
|
||||
|
64
analytics.md
@ -1,64 +0,0 @@
|
||||
| Excalidraw | Category | Name | Label | Value |
|
||||
| ----------------------- | -------- | ---------------------------------- | ------------------------------- | --------- |
|
||||
| Shape / Selection | shape | selection, rectangle, diamond, etc | `toolbar` or `shortcut` |
|
||||
| Text on double click | shape | text | `double-click` |
|
||||
| Lock selection | shape | lock | `on` or `off` |
|
||||
| Clear canvas | action | clear canvas |
|
||||
| Zoom in | action | zoom | in | `zoom` |
|
||||
| Zoom out | action | zoom | out | `zoom` |
|
||||
| Zoom fit | action | zoom | fit | `zoom` |
|
||||
| Zoom reset | action | zoom | reset | `zoom` |
|
||||
| Scroll back to content | action | scroll to content |
|
||||
| Load file | io | load | `MIME type` |
|
||||
| Import from URL | io | import |
|
||||
| Save | io | save |
|
||||
| Save as | io | save as |
|
||||
| Export to backend | io | export | backend |
|
||||
| Export as SVG | io | export | `svg` or `clipboard-svg` |
|
||||
| Export to PNG | io | export | `png` or `clipboard-png` |
|
||||
| Canvas color | change | canvas color | `color` |
|
||||
| Background color | change | background color | `color` |
|
||||
| Stroke color | change | stroke color | `color` |
|
||||
| Stroke width | change | stroke | width | `width` |
|
||||
| Stroke style | change | style | `solid` or `dashed` or `dotted` |
|
||||
| Stroke sloppiness | change | stroke | sloppiness | `value` |
|
||||
| Fill | change | fill | `value` |
|
||||
| Edge | change | edge | `value` |
|
||||
| Opacity | change | opacity | value | `opacity` |
|
||||
| Project name | change | title |
|
||||
| Theme | change | theme | `light` or `dark` |
|
||||
| Change language | change | language | `language` |
|
||||
| Send to back | layer | move | `back` |
|
||||
| Send backward | layer | move | `down` |
|
||||
| Bring to front | layer | move | `front` |
|
||||
| Bring forward | layer | move | `up` |
|
||||
| Align left | align | align | `left` |
|
||||
| Align right | align | align | `right` |
|
||||
| Align top | align | align | `top` |
|
||||
| Align bottom | align | align | `bottom` |
|
||||
| Center horizontally | align | horizontally | `center` |
|
||||
| Center vertically | align | vertically | `center` |
|
||||
| Distribute horizontally | align | distribute | `horizontally` |
|
||||
| Distribute vertically | align | distribute | `vertically` |
|
||||
| Start session | share | session start |
|
||||
| Join session | share | session join |
|
||||
| Start end | share | session end |
|
||||
| Copy room link | share | copy link |
|
||||
| Go to collaborator | share | go to collaborator |
|
||||
| Change name | share | name |
|
||||
| Add to library | library | add |
|
||||
| Remove from library | library | remove |
|
||||
| Load library | library | load |
|
||||
| Save library | library | save |
|
||||
| Import library | library | import |
|
||||
| Shortcuts dialog | dialog | shortcuts |
|
||||
| Collaboration dialog | dialog | collaboration |
|
||||
| Export dialog | dialog | export |
|
||||
| Library dialog | dialog | library |
|
||||
| E2EE shield | exit | e2ee shield |
|
||||
| GitHub corner | exit | github |
|
||||
| Excalidraw blog | exit | blog |
|
||||
| Excalidraw guides | exit | guides |
|
||||
| File issues | exit | issues |
|
||||
| First load | load | first load |
|
||||
| Load from stroage | load | storage | size | `bytes` |
|
3969
package-lock.json
generated
35
package.json
@ -19,23 +19,22 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/browser": "5.29.2",
|
||||
"@sentry/integrations": "5.29.2",
|
||||
"@testing-library/jest-dom": "5.11.8",
|
||||
"@testing-library/react": "11.2.2",
|
||||
"@types/jest": "26.0.19",
|
||||
"@types/nanoid": "2.1.0",
|
||||
"@sentry/browser": "6.0.3",
|
||||
"@sentry/integrations": "6.0.3",
|
||||
"@testing-library/jest-dom": "5.11.9",
|
||||
"@testing-library/react": "11.2.3",
|
||||
"@types/jest": "26.0.20",
|
||||
"@types/react": "17.0.0",
|
||||
"@types/react-dom": "17.0.0",
|
||||
"@types/socket.io-client": "1.4.34",
|
||||
"browser-nativefs": "0.12.0",
|
||||
"@types/socket.io-client": "1.4.35",
|
||||
"browser-fs-access": "0.13.0",
|
||||
"clsx": "1.1.1",
|
||||
"firebase": "8.2.1",
|
||||
"firebase": "8.2.5",
|
||||
"i18next-browser-languagedetector": "6.0.1",
|
||||
"lodash.throttle": "4.1.1",
|
||||
"nanoid": "2.1.11",
|
||||
"nanoid": "3.1.20",
|
||||
"node-sass": "4.14.1",
|
||||
"open-color": "1.7.0",
|
||||
"open-color": "1.8.0",
|
||||
"pako": "1.0.11",
|
||||
"png-chunk-text": "1.0.0",
|
||||
"png-chunks-encode": "1.0.0",
|
||||
@ -52,10 +51,10 @@
|
||||
"devDependencies": {
|
||||
"@types/lodash.throttle": "4.1.6",
|
||||
"@types/pako": "1.0.1",
|
||||
"eslint-config-prettier": "7.1.0",
|
||||
"eslint-plugin-prettier": "3.3.0",
|
||||
"firebase-tools": "9.1.0",
|
||||
"husky": "4.3.6",
|
||||
"eslint-config-prettier": "7.2.0",
|
||||
"eslint-plugin-prettier": "3.3.1",
|
||||
"firebase-tools": "9.2.2",
|
||||
"husky": "4.3.8",
|
||||
"jest-canvas-mock": "2.3.0",
|
||||
"lint-staged": "10.5.3",
|
||||
"pepjs": "0.5.3",
|
||||
@ -73,7 +72,7 @@
|
||||
},
|
||||
"jest": {
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-nativefs)/)"
|
||||
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
|
||||
],
|
||||
"resetMocks": false
|
||||
},
|
||||
@ -81,8 +80,8 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build-node": "node ./scripts/build-node.js",
|
||||
"build:app:docker": "REACT_APP_INCLUDE_GTAG=false REACT_APP_DISABLE_SENTRY=true react-scripts build",
|
||||
"build:app": "REACT_APP_INCLUDE_GTAG=true REACT_APP_GIT_SHA=$NOW_GITHUB_COMMIT_SHA react-scripts build",
|
||||
"build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build",
|
||||
"build:app": "REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
|
||||
"build:version": "node ./scripts/build-version.js",
|
||||
"build": "npm run build:app && npm run build:version",
|
||||
"eject": "react-scripts eject",
|
||||
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 4.3 KiB |
@ -57,6 +57,7 @@
|
||||
|
||||
<!-- Excalidraw version -->
|
||||
<meta name="version" content="{version}" />
|
||||
|
||||
<link
|
||||
rel="preload"
|
||||
href="FG_Virgil.woff2"
|
||||
@ -86,10 +87,10 @@
|
||||
|
||||
<link rel="stylesheet" href="fonts.css" type="text/css" />
|
||||
|
||||
<% if (process.env.REACT_APP_INCLUDE_GTAG === 'true') { %>
|
||||
<% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
|
||||
<script
|
||||
async
|
||||
src="https://www.googletagmanager.com/gtag/js?id=UA-387204-13"
|
||||
src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%"
|
||||
></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
@ -97,7 +98,7 @@
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
gtag("js", new Date());
|
||||
gtag("config", "UA-387204-13");
|
||||
gtag("config", "%REACT_APP_GOOGLE_ANALYTICS_ID%");
|
||||
</script>
|
||||
<% } %>
|
||||
|
||||
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 2.6 KiB |
BIN
public/og-image-sm.png
Normal file
After Width: | Height: | Size: 76 KiB |
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 70 KiB |
@ -5,23 +5,40 @@ const path = require("path");
|
||||
const versionFile = path.join("build", "version.json");
|
||||
const indexFile = path.join("build", "index.html");
|
||||
|
||||
const zero = (digit) => `0${digit}`.slice(-2);
|
||||
const versionDate = (date) => date.toISOString().replace(".000", "");
|
||||
|
||||
const versionDate = (date) => {
|
||||
const date_ = `${date.getFullYear()}-${zero(date.getMonth() + 1)}-${zero(
|
||||
date.getDate(),
|
||||
)}`;
|
||||
const time = `${zero(date.getHours())}-${zero(date.getMinutes())}-${zero(
|
||||
date.getSeconds(),
|
||||
)}`;
|
||||
return `${date_}-${time}`;
|
||||
const commitHash = () => {
|
||||
try {
|
||||
return require("child_process")
|
||||
.execSync("git rev-parse --short HEAD")
|
||||
.toString()
|
||||
.trim();
|
||||
} catch {
|
||||
return "none";
|
||||
}
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
const commitDate = (hash) => {
|
||||
try {
|
||||
const unix = require("child_process")
|
||||
.execSync(`git show -s --format=%ct ${hash}`)
|
||||
.toString()
|
||||
.trim();
|
||||
const date = new Date(parseInt(unix) * 1000);
|
||||
return versionDate(date);
|
||||
} catch {
|
||||
return versionDate(new Date());
|
||||
}
|
||||
};
|
||||
|
||||
const getFullVersion = () => {
|
||||
const hash = commitHash();
|
||||
return `${commitDate(hash)}-${hash}`;
|
||||
};
|
||||
|
||||
const data = JSON.stringify(
|
||||
{
|
||||
version: versionDate(now),
|
||||
version: getFullVersion(),
|
||||
},
|
||||
undefined,
|
||||
2,
|
||||
@ -34,7 +51,7 @@ fs.readFile(indexFile, "utf8", (error, data) => {
|
||||
if (error) {
|
||||
return console.error(error);
|
||||
}
|
||||
const result = data.replace(/{version}/g, versionDate(now));
|
||||
const result = data.replace(/{version}/g, getFullVersion());
|
||||
|
||||
fs.writeFile(indexFile, result, "utf8", (error) => {
|
||||
if (error) {
|
||||
|
@ -4,25 +4,27 @@ const THRESSHOLD = 85;
|
||||
|
||||
const crowdinMap = {
|
||||
"ar-SA": "en-ar",
|
||||
"el-GR": "en-el",
|
||||
"fi-FI": "en-fi",
|
||||
"ja-JP": "en-ja",
|
||||
"bg-BG": "en-bg",
|
||||
"ca-ES": "en-ca",
|
||||
"de-DE": "en-de",
|
||||
"el-GR": "en-el",
|
||||
"es-ES": "en-es",
|
||||
"fa-IR": "en-fa",
|
||||
"fi-FI": "en-fi",
|
||||
"fr-FR": "en-fr",
|
||||
"he-IL": "en-he",
|
||||
"hi-IN": "en-hi",
|
||||
"hu-HU": "en-hu",
|
||||
"id-ID": "en-id",
|
||||
"it-IT": "en-it",
|
||||
"ja-JP": "en-ja",
|
||||
"kab-KAB": "en-kab",
|
||||
"ko-KR": "en-ko",
|
||||
"my-MM": "en-my",
|
||||
"nb-NO": "en-nb",
|
||||
"nl-NL": "en-nl",
|
||||
"nn-NO": "en-nnno",
|
||||
"pa-IN": "en-pain",
|
||||
"pl-PL": "en-pl",
|
||||
"pt-BR": "en-ptbr",
|
||||
"pt-PT": "en-pt",
|
||||
@ -39,7 +41,7 @@ const crowdinMap = {
|
||||
const flags = {
|
||||
"ar-SA": "🇸🇦",
|
||||
"bg-BG": "🇧🇬",
|
||||
"ca-ES": "🇪🇸",
|
||||
"ca-ES": "🏳",
|
||||
"de-DE": "🇩🇪",
|
||||
"el-GR": "🇬🇷",
|
||||
"es-ES": "🇪🇸",
|
||||
@ -52,11 +54,13 @@ const flags = {
|
||||
"id-ID": "🇮🇩",
|
||||
"it-IT": "🇮🇹",
|
||||
"ja-JP": "🇯🇵",
|
||||
"kab-KAB": "🏳",
|
||||
"ko-KR": "🇰🇷",
|
||||
"my-MM": "🇲🇲",
|
||||
"nb-NO": "🇳🇴",
|
||||
"nl-NL": "🇳🇱",
|
||||
"nn-NO": "🇳🇴",
|
||||
"pa-IN": "🇮🇳",
|
||||
"pl-PL": "🇵🇱",
|
||||
"pt-BR": "🇧🇷",
|
||||
"pt-PT": "🇵🇹",
|
||||
@ -73,7 +77,7 @@ const flags = {
|
||||
const languages = {
|
||||
"ar-SA": "العربية",
|
||||
"bg-BG": "Български",
|
||||
"ca-ES": "Catalan",
|
||||
"ca-ES": "Català",
|
||||
"de-DE": "Deutsch",
|
||||
"el-GR": "Ελληνικά",
|
||||
"es-ES": "Español",
|
||||
@ -86,11 +90,13 @@ const languages = {
|
||||
"id-ID": "Bahasa Indonesia",
|
||||
"it-IT": "Italiano",
|
||||
"ja-JP": "日本語",
|
||||
"kab-KAB": "Taqbaylit",
|
||||
"ko-KR": "한국어",
|
||||
"my-MM": "Burmese",
|
||||
"nb-NO": "Norsk bokmål",
|
||||
"nl-NL": "Nederlands",
|
||||
"nn-NO": "Norsk nynorsk",
|
||||
"pa-IN": "ਪੰਜਾਬੀ",
|
||||
"pl-PL": "Polski",
|
||||
"pt-BR": "Português Brasileiro",
|
||||
"pt-PT": "Português",
|
||||
@ -134,7 +140,7 @@ const printRow = (id, locale, coverage) => {
|
||||
} else {
|
||||
result += `${boldIf(language, isOver)} | `;
|
||||
}
|
||||
result += `${coverage === 100 ? "✅" : boldIf(coverage, isOver)} |`;
|
||||
result += `${coverage === 100 ? "💯" : boldIf(coverage, isOver)} |`;
|
||||
return result;
|
||||
};
|
||||
|
||||
|
@ -3,7 +3,6 @@ import { getSelectedElements } from "../scene";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { deepCopyElement } from "../element/newElement";
|
||||
import { Library } from "../data/library";
|
||||
import { EVENT_LIBRARY, trackEvent } from "../analytics";
|
||||
|
||||
export const actionAddToLibrary = register({
|
||||
name: "addToLibrary",
|
||||
@ -16,9 +15,7 @@ export const actionAddToLibrary = register({
|
||||
Library.loadLibrary().then((items) => {
|
||||
Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]);
|
||||
});
|
||||
trackEvent(EVENT_LIBRARY, "add");
|
||||
return false;
|
||||
},
|
||||
contextMenuOrder: 6,
|
||||
contextItemLabel: "labels.addToLibrary",
|
||||
});
|
||||
|
@ -1,7 +1,5 @@
|
||||
import React from "react";
|
||||
import { KEYS } from "../keys";
|
||||
import { t } from "../i18n";
|
||||
import { register } from "./register";
|
||||
import { alignElements, Alignment } from "../align";
|
||||
import {
|
||||
AlignBottomIcon,
|
||||
AlignLeftIcon,
|
||||
@ -10,14 +8,15 @@ import {
|
||||
CenterHorizontallyIcon,
|
||||
CenterVerticallyIcon,
|
||||
} from "../components/icons";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { getElementMap, getNonDeletedElements } from "../element";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { getElementMap, getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { KEYS } from "../keys";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { AppState } from "../types";
|
||||
import { alignElements, Alignment } from "../align";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { trackEvent, EVENT_ALIGN } from "../analytics";
|
||||
import { register } from "./register";
|
||||
|
||||
const enableActionGroup = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
@ -44,7 +43,6 @@ const alignSelectedElements = (
|
||||
export const actionAlignTop = register({
|
||||
name: "alignTop",
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "align", "top");
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, {
|
||||
@ -74,7 +72,6 @@ export const actionAlignTop = register({
|
||||
export const actionAlignBottom = register({
|
||||
name: "alignBottom",
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "align", "bottom");
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, {
|
||||
@ -104,7 +101,6 @@ export const actionAlignBottom = register({
|
||||
export const actionAlignLeft = register({
|
||||
name: "alignLeft",
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "align", "left");
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, {
|
||||
@ -134,7 +130,6 @@ export const actionAlignLeft = register({
|
||||
export const actionAlignRight = register({
|
||||
name: "alignRight",
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "align", "right");
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, {
|
||||
@ -164,7 +159,6 @@ export const actionAlignRight = register({
|
||||
export const actionAlignVerticallyCentered = register({
|
||||
name: "alignVerticallyCentered",
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "vertically", "center");
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, {
|
||||
@ -190,7 +184,6 @@ export const actionAlignVerticallyCentered = register({
|
||||
export const actionAlignHorizontallyCentered = register({
|
||||
name: "alignHorizontallyCentered",
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "horizontally", "center");
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, {
|
||||
|
@ -1,10 +1,9 @@
|
||||
import React from "react";
|
||||
import { EVENT_ACTION, EVENT_CHANGE, trackEvent } from "../analytics";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import colors from "../colors";
|
||||
import { ColorPicker } from "../components/ColorPicker";
|
||||
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { ZOOM_STEP } from "../constants";
|
||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
@ -15,21 +14,12 @@ import { getNormalizedZoom, getSelectedElements } from "../scene";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { getNewZoom } from "../scene/zoom";
|
||||
import { AppState, NormalizedZoomValue } from "../types";
|
||||
import { getNewSceneName, getShortcutKey } from "../utils";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
name: "changeViewBackgroundColor",
|
||||
perform: (_, appState, value) => {
|
||||
if (value !== appState.viewBackgroundColor) {
|
||||
trackEvent(
|
||||
EVENT_CHANGE,
|
||||
"canvas color",
|
||||
colors.canvasBackground.includes(value)
|
||||
? `${value} (picker ${colors.canvasBackground.indexOf(value)})`
|
||||
: value,
|
||||
);
|
||||
}
|
||||
return {
|
||||
appState: { ...appState, viewBackgroundColor: value },
|
||||
commitToHistory: true,
|
||||
@ -52,14 +42,12 @@ export const actionChangeViewBackgroundColor = register({
|
||||
export const actionClearCanvas = register({
|
||||
name: "clearCanvas",
|
||||
perform: (elements, appState: AppState) => {
|
||||
trackEvent(EVENT_ACTION, "clear canvas");
|
||||
return {
|
||||
elements: elements.map((element) =>
|
||||
newElementWith(element, { isDeleted: true }),
|
||||
),
|
||||
appState: {
|
||||
...getDefaultAppState(),
|
||||
name: getNewSceneName(),
|
||||
appearance: appState.appearance,
|
||||
elementLocked: appState.elementLocked,
|
||||
exportBackground: appState.exportBackground,
|
||||
@ -88,8 +76,6 @@ export const actionClearCanvas = register({
|
||||
),
|
||||
});
|
||||
|
||||
const ZOOM_STEP = 0.1;
|
||||
|
||||
export const actionZoomIn = register({
|
||||
name: "zoomIn",
|
||||
perform: (_elements, appState) => {
|
||||
@ -99,7 +85,6 @@ export const actionZoomIn = register({
|
||||
{ left: appState.offsetLeft, top: appState.offsetTop },
|
||||
{ x: appState.width / 2, y: appState.height / 2 },
|
||||
);
|
||||
trackEvent(EVENT_ACTION, "zoom", "in", zoom.value * 100);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
@ -134,7 +119,6 @@ export const actionZoomOut = register({
|
||||
{ x: appState.width / 2, y: appState.height / 2 },
|
||||
);
|
||||
|
||||
trackEvent(EVENT_ACTION, "zoom", "out", zoom.value * 100);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
@ -162,7 +146,6 @@ export const actionZoomOut = register({
|
||||
export const actionResetZoom = register({
|
||||
name: "resetZoom",
|
||||
perform: (_elements, appState) => {
|
||||
trackEvent(EVENT_ACTION, "zoom", "reset", 100);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
@ -235,12 +218,10 @@ const zoomToFitElements = (
|
||||
left: appState.offsetLeft,
|
||||
top: appState.offsetTop,
|
||||
});
|
||||
const action = zoomToSelection ? "selection" : "fit";
|
||||
|
||||
const [x1, y1, x2, y2] = commonBounds;
|
||||
const centerX = (x1 + x2) / 2;
|
||||
const centerY = (y1 + y2) / 2;
|
||||
trackEvent(EVENT_ACTION, "zoom", action, newZoom.value * 100);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
|
114
src/actions/actionClipboard.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { copyToClipboard } from "../clipboard";
|
||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||
import { getSelectedElements } from "../scene/selection";
|
||||
import { exportCanvas } from "../data/index";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { t } from "../i18n";
|
||||
|
||||
export const actionCopy = register({
|
||||
name: "copy",
|
||||
perform: (elements, appState) => {
|
||||
copyToClipboard(getNonDeletedElements(elements), appState);
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.copy",
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.C,
|
||||
});
|
||||
|
||||
export const actionCut = register({
|
||||
name: "cut",
|
||||
perform: (elements, appState, data, app) => {
|
||||
actionCopy.perform(elements, appState, data, app);
|
||||
return actionDeleteSelected.perform(elements, appState, data, app);
|
||||
},
|
||||
contextItemLabel: "labels.cut",
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.X,
|
||||
});
|
||||
|
||||
export const actionCopyAsSvg = register({
|
||||
name: "copyAsSvg",
|
||||
perform: async (elements, appState, _data, app) => {
|
||||
if (!app.canvas) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
};
|
||||
}
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
try {
|
||||
await exportCanvas(
|
||||
"clipboard-svg",
|
||||
selectedElements.length
|
||||
? selectedElements
|
||||
: getNonDeletedElements(elements),
|
||||
appState,
|
||||
app.canvas,
|
||||
appState,
|
||||
);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: error.message,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
}
|
||||
},
|
||||
contextItemLabel: "labels.copyAsSvg",
|
||||
});
|
||||
|
||||
export const actionCopyAsPng = register({
|
||||
name: "copyAsPng",
|
||||
perform: async (elements, appState, _data, app) => {
|
||||
if (!app.canvas) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
};
|
||||
}
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
try {
|
||||
await exportCanvas(
|
||||
"clipboard",
|
||||
selectedElements.length
|
||||
? selectedElements
|
||||
: getNonDeletedElements(elements),
|
||||
appState,
|
||||
app.canvas,
|
||||
appState,
|
||||
);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
toastMessage: t("toast.copyToClipboardAsPng"),
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: error.message,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
}
|
||||
},
|
||||
contextItemLabel: "labels.copyAsPng",
|
||||
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
|
||||
});
|
@ -136,7 +136,6 @@ export const actionDeleteSelected = register({
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.delete",
|
||||
contextMenuOrder: 999999,
|
||||
keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
|
@ -1,19 +1,18 @@
|
||||
import React from "react";
|
||||
import { CODES } from "../keys";
|
||||
import { t } from "../i18n";
|
||||
import { register } from "./register";
|
||||
import {
|
||||
DistributeHorizontallyIcon,
|
||||
DistributeVerticallyIcon,
|
||||
} from "../components/icons";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { getElementMap, getNonDeletedElements } from "../element";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { distributeElements, Distribution } from "../disitrubte";
|
||||
import { getElementMap, getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { CODES } from "../keys";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { AppState } from "../types";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { EVENT_ALIGN, trackEvent } from "../analytics";
|
||||
import { register } from "./register";
|
||||
|
||||
const enableActionGroup = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
@ -40,7 +39,6 @@ const distributeSelectedElements = (
|
||||
export const distributeHorizontally = register({
|
||||
name: "distributeHorizontally",
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "distribute", "horizontally");
|
||||
return {
|
||||
appState,
|
||||
elements: distributeSelectedElements(elements, appState, {
|
||||
@ -69,7 +67,6 @@ export const distributeHorizontally = register({
|
||||
export const distributeVertically = register({
|
||||
name: "distributeVertically",
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "distribute", "vertically");
|
||||
return {
|
||||
appState,
|
||||
elements: distributeSelectedElements(elements, appState, {
|
||||
|
@ -1,29 +1,26 @@
|
||||
import React from "react";
|
||||
import { EVENT_CHANGE, EVENT_IO, trackEvent } from "../analytics";
|
||||
import { load, save, saveAs } from "../components/icons";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { load, questionCircle, save, saveAs } from "../components/icons";
|
||||
import { ProjectName } from "../components/ProjectName";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import "../components/ToolIcon.scss";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { questionCircle } from "../components/icons";
|
||||
import { loadFromJSON, saveAsJSON } from "../data";
|
||||
import { t } from "../i18n";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { KEYS } from "../keys";
|
||||
import { muteFSAbortError } from "../utils";
|
||||
import { register } from "./register";
|
||||
import "../components/ToolIcon.scss";
|
||||
import { SCENE_NAME_FALLBACK } from "../constants";
|
||||
|
||||
export const actionChangeProjectName = register({
|
||||
name: "changeProjectName",
|
||||
perform: (_elements, appState, value) => {
|
||||
trackEvent(EVENT_CHANGE, "title");
|
||||
trackEvent("change", "title");
|
||||
return { appState: { ...appState, name: value }, commitToHistory: false };
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<ProjectName
|
||||
label={t("labels.fileTitle")}
|
||||
value={appState.name || SCENE_NAME_FALLBACK}
|
||||
value={appState.name || "Unnamed"}
|
||||
onChange={(name: string) => updateData(name)}
|
||||
/>
|
||||
),
|
||||
@ -101,7 +98,6 @@ export const actionSaveScene = register({
|
||||
perform: async (elements, appState, value) => {
|
||||
try {
|
||||
const { fileHandle } = await saveAsJSON(elements, appState);
|
||||
trackEvent(EVENT_IO, "save");
|
||||
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
||||
} catch (error) {
|
||||
if (error?.name !== "AbortError") {
|
||||
@ -132,7 +128,6 @@ export const actionSaveAsScene = register({
|
||||
...appState,
|
||||
fileHandle: null,
|
||||
});
|
||||
trackEvent(EVENT_IO, "save as");
|
||||
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
||||
} catch (error) {
|
||||
if (error?.name !== "AbortError") {
|
||||
@ -160,18 +155,29 @@ export const actionSaveAsScene = register({
|
||||
|
||||
export const actionLoadScene = register({
|
||||
name: "loadScene",
|
||||
perform: (
|
||||
elements,
|
||||
appState,
|
||||
{ elements: loadedElements, appState: loadedAppState, error },
|
||||
) => ({
|
||||
elements: loadedElements,
|
||||
appState: {
|
||||
...loadedAppState,
|
||||
errorMessage: error,
|
||||
},
|
||||
commitToHistory: true,
|
||||
}),
|
||||
perform: async (elements, appState) => {
|
||||
try {
|
||||
const {
|
||||
elements: loadedElements,
|
||||
appState: loadedAppState,
|
||||
} = await loadFromJSON(appState);
|
||||
return {
|
||||
elements: loadedElements,
|
||||
appState: loadedAppState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error?.name === "AbortError") {
|
||||
return false;
|
||||
}
|
||||
return {
|
||||
elements,
|
||||
appState: { ...appState, errorMessage: error.message },
|
||||
commitToHistory: false,
|
||||
};
|
||||
}
|
||||
},
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
@ -179,16 +185,7 @@ export const actionLoadScene = register({
|
||||
title={t("buttons.load")}
|
||||
aria-label={t("buttons.load")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
onClick={() => {
|
||||
loadFromJSON(appState)
|
||||
.then(({ elements, appState }) => {
|
||||
updateData({ elements, appState });
|
||||
})
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
updateData({ error: error.message });
|
||||
});
|
||||
}}
|
||||
onClick={updateData}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
@ -118,11 +118,14 @@ export const actionFinalize = register({
|
||||
);
|
||||
}
|
||||
|
||||
if (!appState.elementLocked) {
|
||||
if (!appState.elementLocked && appState.elementType !== "draw") {
|
||||
appState.selectedElementIds[multiPointElement.id] = true;
|
||||
}
|
||||
}
|
||||
if (!appState.elementLocked || !multiPointElement) {
|
||||
if (
|
||||
(!appState.elementLocked && appState.elementType !== "draw") ||
|
||||
!multiPointElement
|
||||
) {
|
||||
resetCursor();
|
||||
}
|
||||
return {
|
||||
@ -130,7 +133,8 @@ export const actionFinalize = register({
|
||||
appState: {
|
||||
...appState,
|
||||
elementType:
|
||||
appState.elementLocked && multiPointElement
|
||||
(appState.elementLocked || appState.elementType === "draw") &&
|
||||
multiPointElement
|
||||
? appState.elementType
|
||||
: "selection",
|
||||
draggingElement: null,
|
||||
@ -139,7 +143,9 @@ export const actionFinalize = register({
|
||||
startBoundElement: null,
|
||||
suggestedBindings: [],
|
||||
selectedElementIds:
|
||||
multiPointElement && !appState.elementLocked
|
||||
multiPointElement &&
|
||||
!appState.elementLocked &&
|
||||
appState.elementType !== "draw"
|
||||
? {
|
||||
...appState.selectedElementIds,
|
||||
[multiPointElement.id]: true,
|
||||
|
@ -125,7 +125,6 @@ export const actionGroup = register({
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
contextMenuOrder: 4,
|
||||
contextItemLabel: "labels.group",
|
||||
contextItemPredicate: (elements, appState) =>
|
||||
enableActionGroup(elements, appState),
|
||||
@ -174,7 +173,6 @@ export const actionUngroup = register({
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
|
||||
contextMenuOrder: 5,
|
||||
contextItemLabel: "labels.ungroup",
|
||||
contextItemPredicate: (elements, appState) =>
|
||||
getSelectedGroupIds(appState).length > 0,
|
||||
|
@ -6,7 +6,7 @@ import { t } from "../i18n";
|
||||
import { SceneHistory, HistoryEntry } from "../history";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { KEYS } from "../keys";
|
||||
import { isWindows, KEYS } from "../keys";
|
||||
import { getElementMap } from "../element";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||
@ -59,16 +59,16 @@ const writeData = (
|
||||
return { commitToHistory };
|
||||
};
|
||||
|
||||
const testUndo = (shift: boolean) => (event: KeyboardEvent) =>
|
||||
event[KEYS.CTRL_OR_CMD] && /z/i.test(event.key) && event.shiftKey === shift;
|
||||
|
||||
type ActionCreator = (history: SceneHistory) => Action;
|
||||
|
||||
export const createUndoAction: ActionCreator = (history) => ({
|
||||
name: "undo",
|
||||
perform: (elements, appState) =>
|
||||
writeData(elements, appState, () => history.undoOnce()),
|
||||
keyTest: testUndo(false),
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
event.key.toLowerCase() === KEYS.Z &&
|
||||
!event.shiftKey,
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
@ -84,7 +84,11 @@ export const createRedoAction: ActionCreator = (history) => ({
|
||||
name: "redo",
|
||||
perform: (elements, appState) =>
|
||||
writeData(elements, appState, () => history.redoOnce()),
|
||||
keyTest: testUndo(true),
|
||||
keyTest: (event) =>
|
||||
(event[KEYS.CTRL_OR_CMD] &&
|
||||
event.shiftKey &&
|
||||
event.key.toLowerCase() === KEYS.Z) ||
|
||||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
|
@ -7,7 +7,6 @@ import { register } from "./register";
|
||||
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { HelpIcon } from "../components/HelpIcon";
|
||||
import { EVENT_DIALOG, trackEvent } from "../analytics";
|
||||
|
||||
export const actionToggleCanvasMenu = register({
|
||||
name: "toggleCanvasMenu",
|
||||
@ -72,17 +71,16 @@ export const actionFullScreen = register({
|
||||
export const actionShortcuts = register({
|
||||
name: "toggleShortcuts",
|
||||
perform: (_elements, appState) => {
|
||||
trackEvent(EVENT_DIALOG, "shortcuts");
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
showShortcutsDialog: true,
|
||||
showHelpDialog: !appState.showHelpDialog,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<HelpIcon title={t("shortcutsDialog.title")} onClick={updateData} />
|
||||
<HelpIcon title={t("helpDialog.title")} onClick={updateData} />
|
||||
),
|
||||
keyTest: (event) => event.key === KEYS.QUESTION_MARK,
|
||||
});
|
||||
|
@ -1,16 +1,14 @@
|
||||
import React from "react";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import { register } from "./register";
|
||||
import { getClientColors, getClientInitials } from "../clients";
|
||||
import { Collaborator } from "../types";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { EVENT_SHARE, trackEvent } from "../analytics";
|
||||
import { Collaborator } from "../types";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionGoToCollaborator = register({
|
||||
name: "goToCollaborator",
|
||||
perform: (_elements, appState, value) => {
|
||||
const point = value as Collaborator["pointer"];
|
||||
trackEvent(EVENT_SHARE, "go to collaborator");
|
||||
if (!point) {
|
||||
return { appState, commitToHistory: false };
|
||||
}
|
||||
|
@ -1,56 +1,53 @@
|
||||
import React from "react";
|
||||
import { getLanguage } from "../i18n";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
TextAlign,
|
||||
FontFamily,
|
||||
ExcalidrawLinearElement,
|
||||
Arrowhead,
|
||||
} from "../element/types";
|
||||
import {
|
||||
getCommonAttributeOfSelectedElements,
|
||||
isSomeElementSelected,
|
||||
getTargetElements,
|
||||
canChangeSharpness,
|
||||
canHaveArrowheads,
|
||||
} from "../scene";
|
||||
import { ButtonSelect } from "../components/ButtonSelect";
|
||||
import { AppState } from "../../src/types";
|
||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
||||
import { ButtonSelect } from "../components/ButtonSelect";
|
||||
import { ColorPicker } from "../components/ColorPicker";
|
||||
import { IconPicker } from "../components/IconPicker";
|
||||
import {
|
||||
isTextElement,
|
||||
redrawTextBoundingBox,
|
||||
getNonDeletedElements,
|
||||
} from "../element";
|
||||
import { isLinearElement, isLinearElementType } from "../element/typeChecks";
|
||||
import { ColorPicker } from "../components/ColorPicker";
|
||||
import { AppState } from "../../src/types";
|
||||
import { t } from "../i18n";
|
||||
import { register } from "./register";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { DEFAULT_FONT_SIZE, DEFAULT_FONT_FAMILY } from "../constants";
|
||||
import { randomInteger } from "../random";
|
||||
import {
|
||||
FillHachureIcon,
|
||||
FillCrossHatchIcon,
|
||||
FillSolidIcon,
|
||||
StrokeWidthIcon,
|
||||
StrokeStyleSolidIcon,
|
||||
StrokeStyleDashedIcon,
|
||||
StrokeStyleDottedIcon,
|
||||
EdgeSharpIcon,
|
||||
EdgeRoundIcon,
|
||||
SloppinessArchitectIcon,
|
||||
SloppinessArtistIcon,
|
||||
SloppinessCartoonistIcon,
|
||||
ArrowheadArrowIcon,
|
||||
ArrowheadBarIcon,
|
||||
ArrowheadDotIcon,
|
||||
ArrowheadNoneIcon,
|
||||
EdgeRoundIcon,
|
||||
EdgeSharpIcon,
|
||||
FillCrossHatchIcon,
|
||||
FillHachureIcon,
|
||||
FillSolidIcon,
|
||||
SloppinessArchitectIcon,
|
||||
SloppinessArtistIcon,
|
||||
SloppinessCartoonistIcon,
|
||||
StrokeStyleDashedIcon,
|
||||
StrokeStyleDottedIcon,
|
||||
StrokeStyleSolidIcon,
|
||||
StrokeWidthIcon,
|
||||
} from "../components/icons";
|
||||
import { EVENT_CHANGE, trackEvent } from "../analytics";
|
||||
import colors from "../colors";
|
||||
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "../constants";
|
||||
import {
|
||||
getNonDeletedElements,
|
||||
isTextElement,
|
||||
redrawTextBoundingBox,
|
||||
} from "../element";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { isLinearElement, isLinearElementType } from "../element/typeChecks";
|
||||
import {
|
||||
Arrowhead,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextElement,
|
||||
FontFamily,
|
||||
TextAlign,
|
||||
} from "../element/types";
|
||||
import { getLanguage, t } from "../i18n";
|
||||
import { randomInteger } from "../random";
|
||||
import {
|
||||
canChangeSharpness,
|
||||
canHaveArrowheads,
|
||||
getCommonAttributeOfSelectedElements,
|
||||
getTargetElements,
|
||||
isSomeElementSelected,
|
||||
} from "../scene";
|
||||
import { register } from "./register";
|
||||
|
||||
const changeProperty = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
@ -92,15 +89,6 @@ const getFormValue = function <T>(
|
||||
export const actionChangeStrokeColor = register({
|
||||
name: "changeStrokeColor",
|
||||
perform: (elements, appState, value) => {
|
||||
if (value !== appState.currentItemStrokeColor) {
|
||||
trackEvent(
|
||||
EVENT_CHANGE,
|
||||
"stroke color",
|
||||
colors.elementStroke.includes(value)
|
||||
? `${value} (picker ${colors.elementStroke.indexOf(value)})`
|
||||
: value,
|
||||
);
|
||||
}
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
@ -132,16 +120,6 @@ export const actionChangeStrokeColor = register({
|
||||
export const actionChangeBackgroundColor = register({
|
||||
name: "changeBackgroundColor",
|
||||
perform: (elements, appState, value) => {
|
||||
if (value !== appState.currentItemBackgroundColor) {
|
||||
trackEvent(
|
||||
EVENT_CHANGE,
|
||||
"background color",
|
||||
colors.elementBackground.includes(value)
|
||||
? `${value} (picker ${colors.elementBackground.indexOf(value)})`
|
||||
: value,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
@ -173,7 +151,6 @@ export const actionChangeBackgroundColor = register({
|
||||
export const actionChangeFillStyle = register({
|
||||
name: "changeFillStyle",
|
||||
perform: (elements, appState, value) => {
|
||||
trackEvent(EVENT_CHANGE, "fill", value);
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
@ -223,7 +200,6 @@ export const actionChangeFillStyle = register({
|
||||
export const actionChangeStrokeWidth = register({
|
||||
name: "changeStrokeWidth",
|
||||
perform: (elements, appState, value) => {
|
||||
trackEvent(EVENT_CHANGE, "stroke", "width", value);
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
@ -286,7 +262,6 @@ export const actionChangeStrokeWidth = register({
|
||||
export const actionChangeSloppiness = register({
|
||||
name: "changeSloppiness",
|
||||
perform: (elements, appState, value) => {
|
||||
trackEvent(EVENT_CHANGE, "stroke", "sloppiness", value);
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
@ -335,7 +310,6 @@ export const actionChangeSloppiness = register({
|
||||
export const actionChangeStrokeStyle = register({
|
||||
name: "changeStrokeStyle",
|
||||
perform: (elements, appState, value) => {
|
||||
trackEvent(EVENT_CHANGE, "style", value);
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
@ -383,7 +357,6 @@ export const actionChangeStrokeStyle = register({
|
||||
export const actionChangeOpacity = register({
|
||||
name: "changeOpacity",
|
||||
perform: (elements, appState, value) => {
|
||||
trackEvent(EVENT_CHANGE, "opacity", "value", value);
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
@ -580,7 +553,6 @@ export const actionChangeSharpness = register({
|
||||
const shouldUpdateForLinearElements = targetElements.length
|
||||
? targetElements.every(isLinearElement)
|
||||
: isLinearElementType(appState.elementType);
|
||||
trackEvent(EVENT_CHANGE, "edge", value);
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
@ -642,12 +614,6 @@ export const actionChangeArrowhead = register({
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) => {
|
||||
if (isLinearElement(el)) {
|
||||
trackEvent(
|
||||
EVENT_CHANGE,
|
||||
`arrowhead ${value.position}`,
|
||||
value.type || "none",
|
||||
);
|
||||
|
||||
const { position, type } = value;
|
||||
|
||||
if (position === "start") {
|
||||
|
@ -4,6 +4,7 @@ import {
|
||||
redrawTextBoundingBox,
|
||||
} from "../element";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { t } from "../i18n";
|
||||
import { register } from "./register";
|
||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||
import {
|
||||
@ -23,13 +24,16 @@ export const actionCopyStyles = register({
|
||||
copiedStyles = JSON.stringify(element);
|
||||
}
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
toastMessage: t("toast.copyStyles"),
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.copyStyles",
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
|
||||
contextMenuOrder: 0,
|
||||
});
|
||||
|
||||
export const actionPasteStyles = register({
|
||||
@ -69,5 +73,4 @@ export const actionPasteStyles = register({
|
||||
contextItemLabel: "labels.pasteStyles",
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
|
||||
contextMenuOrder: 1,
|
||||
});
|
||||
|
22
src/actions/actionToggleGridMode.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
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",
|
||||
perform(elements, appState) {
|
||||
trackEvent("view", "mode", "grid");
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
gridSize: this.checked!(appState) ? null : GRID_SIZE,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
checked: (appState: AppState) => appState.gridSize !== null,
|
||||
contextItemLabel: "labels.gridMode",
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
|
||||
});
|
16
src/actions/actionToggleStats.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleStats = register({
|
||||
name: "stats",
|
||||
perform(elements, appState) {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
showStats: !this.checked!(appState),
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.showStats,
|
||||
contextItemLabel: "stats.title",
|
||||
});
|
22
src/actions/actionToggleViewMode.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { trackEvent } from "../analytics";
|
||||
|
||||
export const actionToggleViewMode = register({
|
||||
name: "viewMode",
|
||||
perform(elements, appState) {
|
||||
trackEvent("view", "mode", "view");
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
viewModeEnabled: !this.checked!(appState),
|
||||
selectedElementIds: {},
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.viewModeEnabled,
|
||||
contextItemLabel: "labels.viewMode",
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R,
|
||||
});
|
22
src/actions/actionToggleZenMode.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { register } from "./register";
|
||||
import { trackEvent } from "../analytics";
|
||||
|
||||
export const actionToggleZenMode = register({
|
||||
name: "zenMode",
|
||||
perform(elements, appState) {
|
||||
trackEvent("view", "mode", "zen");
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
zenModeEnabled: !this.checked!(appState),
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.zenModeEnabled,
|
||||
contextItemLabel: "buttons.zenMode",
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,
|
||||
});
|
@ -65,3 +65,15 @@ export {
|
||||
distributeHorizontally,
|
||||
distributeVertically,
|
||||
} from "./actionDistribute";
|
||||
|
||||
export {
|
||||
actionCopy,
|
||||
actionCut,
|
||||
actionCopyAsPng,
|
||||
actionCopyAsSvg,
|
||||
} from "./actionClipboard";
|
||||
|
||||
export { actionToggleGridMode } from "./actionToggleGridMode";
|
||||
export { actionToggleZenMode } from "./actionToggleZenMode";
|
||||
|
||||
export { actionToggleStats } from "./actionToggleStats";
|
||||
|
@ -3,14 +3,15 @@ import {
|
||||
Action,
|
||||
ActionsManagerInterface,
|
||||
UpdaterFn,
|
||||
ActionFilterFn,
|
||||
ActionName,
|
||||
ActionResult,
|
||||
} from "./types";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { t } from "../i18n";
|
||||
import { ShortcutName } from "./shortcuts";
|
||||
import { AppState, ExcalidrawProps } from "../types";
|
||||
|
||||
// This is the <App> component, but for now we don't care about anything but its
|
||||
// `canvas` state.
|
||||
type App = { canvas: HTMLCanvasElement | null; props: ExcalidrawProps };
|
||||
|
||||
export class ActionManager implements ActionsManagerInterface {
|
||||
actions = {} as ActionsManagerInterface["actions"];
|
||||
@ -18,13 +19,14 @@ export class ActionManager implements ActionsManagerInterface {
|
||||
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
|
||||
|
||||
getAppState: () => Readonly<AppState>;
|
||||
|
||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
|
||||
app: App;
|
||||
|
||||
constructor(
|
||||
updater: UpdaterFn,
|
||||
getAppState: () => AppState,
|
||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
|
||||
app: App,
|
||||
) {
|
||||
this.updater = (actionResult) => {
|
||||
if (actionResult && "then" in actionResult) {
|
||||
@ -37,6 +39,7 @@ export class ActionManager implements ActionsManagerInterface {
|
||||
};
|
||||
this.getAppState = getAppState;
|
||||
this.getElementsIncludingDeleted = getElementsIncludingDeleted;
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
registerAction(action: Action) {
|
||||
@ -63,6 +66,12 @@ export class ActionManager implements ActionsManagerInterface {
|
||||
if (data.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const { viewModeEnabled } = this.getAppState();
|
||||
if (viewModeEnabled) {
|
||||
if (data[0].name !== "viewMode") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
this.updater(
|
||||
@ -70,6 +79,7 @@ export class ActionManager implements ActionsManagerInterface {
|
||||
this.getElementsIncludingDeleted(),
|
||||
this.getAppState(),
|
||||
null,
|
||||
this.app,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
@ -81,43 +91,11 @@ export class ActionManager implements ActionsManagerInterface {
|
||||
this.getElementsIncludingDeleted(),
|
||||
this.getAppState(),
|
||||
null,
|
||||
this.app,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
getContextMenuItems(actionFilter: ActionFilterFn = (action) => action) {
|
||||
return Object.values(this.actions)
|
||||
.filter(actionFilter)
|
||||
.filter((action) => "contextItemLabel" in action)
|
||||
.filter((action) =>
|
||||
action.contextItemPredicate
|
||||
? action.contextItemPredicate(
|
||||
this.getElementsIncludingDeleted(),
|
||||
this.getAppState(),
|
||||
)
|
||||
: true,
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.contextMenuOrder !== undefined ? a.contextMenuOrder : 999) -
|
||||
(b.contextMenuOrder !== undefined ? b.contextMenuOrder : 999),
|
||||
)
|
||||
.map((action) => ({
|
||||
// take last bit of the label "labels.<shortcutName>"
|
||||
shortcutName: action.contextItemLabel?.split(".").pop() as ShortcutName,
|
||||
label: action.contextItemLabel ? t(action.contextItemLabel) : "",
|
||||
action: () => {
|
||||
this.updater(
|
||||
action.perform(
|
||||
this.getElementsIncludingDeleted(),
|
||||
this.getAppState(),
|
||||
null,
|
||||
),
|
||||
);
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// Id is an attribute that we can use to pass in data like keys.
|
||||
// This is needed for dynamically generated action components
|
||||
// like the user list. We can use this key to extract more
|
||||
@ -132,6 +110,7 @@ export class ActionManager implements ActionsManagerInterface {
|
||||
this.getElementsIncludingDeleted(),
|
||||
this.getAppState(),
|
||||
formState,
|
||||
this.app,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
@ -9,7 +9,7 @@ export type ShortcutName =
|
||||
| "copyStyles"
|
||||
| "pasteStyles"
|
||||
| "selectAll"
|
||||
| "delete"
|
||||
| "deleteSelectedElements"
|
||||
| "duplicateSelection"
|
||||
| "sendBackward"
|
||||
| "bringForward"
|
||||
@ -20,8 +20,10 @@ export type ShortcutName =
|
||||
| "group"
|
||||
| "ungroup"
|
||||
| "gridMode"
|
||||
| "zenMode"
|
||||
| "stats"
|
||||
| "addToLibrary";
|
||||
| "addToLibrary"
|
||||
| "viewMode";
|
||||
|
||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
cut: [getShortcutKey("CtrlOrCmd+X")],
|
||||
@ -30,10 +32,10 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")],
|
||||
pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")],
|
||||
selectAll: [getShortcutKey("CtrlOrCmd+A")],
|
||||
delete: [getShortcutKey("Del")],
|
||||
deleteSelectedElements: [getShortcutKey("Del")],
|
||||
duplicateSelection: [
|
||||
getShortcutKey("CtrlOrCmd+D"),
|
||||
getShortcutKey(`Alt+${t("shortcutsDialog.drag")}`),
|
||||
getShortcutKey(`Alt+${t("helpDialog.drag")}`),
|
||||
],
|
||||
sendBackward: [getShortcutKey("CtrlOrCmd+[")],
|
||||
bringForward: [getShortcutKey("CtrlOrCmd+]")],
|
||||
@ -52,8 +54,10 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
group: [getShortcutKey("CtrlOrCmd+G")],
|
||||
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
|
||||
gridMode: [getShortcutKey("CtrlOrCmd+'")],
|
||||
zenMode: [getShortcutKey("Alt+Z")],
|
||||
stats: [],
|
||||
addToLibrary: [],
|
||||
viewMode: [getShortcutKey("Alt+R")],
|
||||
};
|
||||
|
||||
export const getShortcutFromShortcutName = (name: ShortcutName) => {
|
||||
|
@ -16,12 +16,18 @@ type ActionFn = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
formData: any,
|
||||
app: { canvas: HTMLCanvasElement | null },
|
||||
) => ActionResult | Promise<ActionResult>;
|
||||
|
||||
export type UpdaterFn = (res: ActionResult) => void;
|
||||
export type ActionFilterFn = (action: Action) => void;
|
||||
|
||||
export type ActionName =
|
||||
| "copy"
|
||||
| "cut"
|
||||
| "paste"
|
||||
| "copyAsPng"
|
||||
| "copyAsSvg"
|
||||
| "sendBackward"
|
||||
| "bringForward"
|
||||
| "sendToBack"
|
||||
@ -29,6 +35,9 @@ export type ActionName =
|
||||
| "copyStyles"
|
||||
| "selectAll"
|
||||
| "pasteStyles"
|
||||
| "gridMode"
|
||||
| "zenMode"
|
||||
| "stats"
|
||||
| "changeStrokeColor"
|
||||
| "changeBackgroundColor"
|
||||
| "changeFillStyle"
|
||||
@ -75,7 +84,8 @@ export type ActionName =
|
||||
| "alignVerticallyCentered"
|
||||
| "alignHorizontallyCentered"
|
||||
| "distributeHorizontally"
|
||||
| "distributeVertically";
|
||||
| "distributeVertically"
|
||||
| "viewMode";
|
||||
|
||||
export interface Action {
|
||||
name: ActionName;
|
||||
@ -93,19 +103,16 @@ export interface Action {
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => boolean;
|
||||
contextItemLabel?: string;
|
||||
contextMenuOrder?: number;
|
||||
contextItemPredicate?: (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => boolean;
|
||||
checked?: (appState: Readonly<AppState>) => boolean;
|
||||
}
|
||||
|
||||
export interface ActionsManagerInterface {
|
||||
actions: Record<ActionName, Action>;
|
||||
registerAction: (action: Action) => void;
|
||||
handleKeyDown: (event: KeyboardEvent) => boolean;
|
||||
getContextMenuItems: (
|
||||
actionFilter: ActionFilterFn,
|
||||
) => { label: string; action: () => void }[];
|
||||
renderAction: (name: ActionName) => React.ReactElement | null;
|
||||
}
|
||||
|
@ -1,18 +1,8 @@
|
||||
export const EVENT_ACTION = "action";
|
||||
export const EVENT_ALIGN = "align";
|
||||
export const EVENT_CHANGE = "change";
|
||||
export const EVENT_DIALOG = "dialog";
|
||||
export const EVENT_EXIT = "exit";
|
||||
export const EVENT_IO = "io";
|
||||
export const EVENT_LAYER = "layer";
|
||||
export const EVENT_LIBRARY = "library";
|
||||
export const EVENT_LOAD = "load";
|
||||
export const EVENT_SHAPE = "shape";
|
||||
export const EVENT_SHARE = "share";
|
||||
export const EVENT_MAGIC = "magic";
|
||||
|
||||
export const trackEvent =
|
||||
typeof window !== "undefined" && window.gtag
|
||||
typeof process !== "undefined" &&
|
||||
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,
|
||||
@ -20,8 +10,9 @@ export const trackEvent =
|
||||
value,
|
||||
});
|
||||
}
|
||||
: typeof process !== "undefined" && process?.env?.JEST_WORKER_ID
|
||||
: typeof process !== "undefined" && process.env?.JEST_WORKER_ID
|
||||
? (category: string, name: string, label?: string, value?: number) => {}
|
||||
: (category: string, name: string, label?: string, value?: number) => {
|
||||
console.info("Track Event", category, name, label, value);
|
||||
// Uncomment the next line to track locally
|
||||
// console.info("Track Event", category, name, label, value);
|
||||
};
|
||||
|
@ -2,20 +2,16 @@ import oc from "open-color";
|
||||
import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
SCENE_NAME_FALLBACK,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
} from "./constants";
|
||||
import { AppState, FlooredNumber, NormalizedZoomValue } from "./types";
|
||||
import { t } from "./i18n";
|
||||
import { AppState, NormalizedZoomValue } from "./types";
|
||||
import { getDateTime } from "./utils";
|
||||
|
||||
type DefaultAppState = Omit<AppState, "offsetTop" | "offsetLeft" | "name"> & {
|
||||
/**
|
||||
* You should override this with current appState.name, or whatever is
|
||||
* applicable at a given place where you get default appState.
|
||||
*/
|
||||
name: undefined;
|
||||
};
|
||||
|
||||
export const getDefaultAppState = (): DefaultAppState => {
|
||||
export const getDefaultAppState = (): Omit<
|
||||
AppState,
|
||||
"offsetTop" | "offsetLeft"
|
||||
> => {
|
||||
return {
|
||||
appearance: "light",
|
||||
collaborators: new Map(),
|
||||
@ -54,31 +50,29 @@ export const getDefaultAppState = (): DefaultAppState => {
|
||||
isRotating: false,
|
||||
lastPointerDownWith: "mouse",
|
||||
multiElement: null,
|
||||
// for safety (because TS mostly doesn't distinguish optional types and
|
||||
// undefined values), we set `name` to the fallback name, but we cast it to
|
||||
// `undefined` so that TS forces us to explicitly specify it wherever
|
||||
// possible
|
||||
name: (SCENE_NAME_FALLBACK as unknown) as undefined,
|
||||
name: `${t("labels.untitled")}-${getDateTime()}`,
|
||||
openMenu: null,
|
||||
pasteDialog: { shown: false, data: null },
|
||||
previousSelectedElementIds: {},
|
||||
resizingElement: null,
|
||||
scrolledOutside: false,
|
||||
scrollX: 0 as FlooredNumber,
|
||||
scrollY: 0 as FlooredNumber,
|
||||
scrollX: 0,
|
||||
scrollY: 0,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
selectionElement: null,
|
||||
shouldAddWatermark: false,
|
||||
shouldCacheIgnoreZoom: false,
|
||||
showShortcutsDialog: false,
|
||||
showHelpDialog: false,
|
||||
showStats: false,
|
||||
startBoundElement: null,
|
||||
suggestedBindings: [],
|
||||
toastMessage: null,
|
||||
viewBackgroundColor: oc.white,
|
||||
width: window.innerWidth,
|
||||
zenModeEnabled: false,
|
||||
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
|
||||
viewModeEnabled: false,
|
||||
};
|
||||
};
|
||||
|
||||
@ -149,14 +143,16 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
selectionElement: { browser: false, export: false },
|
||||
shouldAddWatermark: { browser: true, export: false },
|
||||
shouldCacheIgnoreZoom: { browser: true, export: false },
|
||||
showShortcutsDialog: { browser: false, export: false },
|
||||
showHelpDialog: { browser: false, export: false },
|
||||
showStats: { browser: true, export: false },
|
||||
startBoundElement: { browser: false, export: false },
|
||||
suggestedBindings: { browser: false, export: false },
|
||||
toastMessage: { browser: false, export: false },
|
||||
viewBackgroundColor: { browser: true, export: true },
|
||||
width: { browser: false, export: false },
|
||||
zenModeEnabled: { browser: true, export: false },
|
||||
zoom: { browser: true, export: false },
|
||||
viewModeEnabled: { browser: false, export: false },
|
||||
});
|
||||
|
||||
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { EVENT_MAGIC, trackEvent } from "./analytics";
|
||||
import colors from "./colors";
|
||||
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, ENV } from "./constants";
|
||||
import { newElement, newLinearElement, newTextElement } from "./element";
|
||||
@ -473,7 +472,6 @@ export const renderSpreadsheet = (
|
||||
x: number,
|
||||
y: number,
|
||||
): ChartElements => {
|
||||
trackEvent(EVENT_MAGIC, "chart", chartType, spreadsheet.values.length);
|
||||
if (chartType === "line") {
|
||||
return chartTypeLine(spreadsheet, x, y);
|
||||
}
|
||||
|
@ -1,23 +1,22 @@
|
||||
import React from "react";
|
||||
import { AppState, Zoom } from "../types";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import {
|
||||
hasBackground,
|
||||
hasStroke,
|
||||
canChangeSharpness,
|
||||
hasText,
|
||||
canHaveArrowheads,
|
||||
getTargetElements,
|
||||
hasBackground,
|
||||
hasStroke,
|
||||
hasText,
|
||||
} from "../scene";
|
||||
import { t } from "../i18n";
|
||||
import { SHAPES } from "../shapes";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { AppState, Zoom } from "../types";
|
||||
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
|
||||
import Stack from "./Stack";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { trackEvent, EVENT_SHAPE, EVENT_DIALOG } from "../analytics";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
|
||||
export const SelectedShapeActions = ({
|
||||
appState,
|
||||
@ -164,9 +163,9 @@ export const ShapesSwitcher = ({
|
||||
{SHAPES.map(({ value, icon, key }, index) => {
|
||||
const label = t(`toolBar.${value}`);
|
||||
const letter = typeof key === "string" ? key : key[0];
|
||||
const shortcut = `${capitalizeString(letter)} ${t(
|
||||
"shortcutsDialog.or",
|
||||
)} ${index + 1}`;
|
||||
const shortcut = `${capitalizeString(letter)} ${t("helpDialog.or")} ${
|
||||
index + 1
|
||||
}`;
|
||||
return (
|
||||
<ToolButton
|
||||
className="Shape"
|
||||
@ -181,7 +180,6 @@ export const ShapesSwitcher = ({
|
||||
aria-keyshortcuts={shortcut}
|
||||
data-testid={value}
|
||||
onChange={() => {
|
||||
trackEvent(EVENT_SHAPE, value, "toolbar");
|
||||
setAppState({
|
||||
elementType: value,
|
||||
multiElement: null,
|
||||
@ -203,9 +201,6 @@ export const ShapesSwitcher = ({
|
||||
title={`${capitalizeString(t("toolBar.library"))} — 9`}
|
||||
aria-label={capitalizeString(t("toolBar.library"))}
|
||||
onClick={() => {
|
||||
if (!isLibraryOpen) {
|
||||
trackEvent(EVENT_DIALOG, "library");
|
||||
}
|
||||
setAppState({ isLibraryOpen: !isLibraryOpen });
|
||||
}}
|
||||
/>
|
||||
|
@ -2,18 +2,36 @@ import { Point, simplify } from "points-on-curve";
|
||||
import React from "react";
|
||||
import { RoughCanvas } from "roughjs/bin/canvas";
|
||||
import rough from "roughjs/bin/rough";
|
||||
import clsx from "clsx";
|
||||
|
||||
import "../actions";
|
||||
import { actionDeleteSelected, actionFinalize } from "../actions";
|
||||
import {
|
||||
actionAddToLibrary,
|
||||
actionBringForward,
|
||||
actionBringToFront,
|
||||
actionCopy,
|
||||
actionCopyAsPng,
|
||||
actionCopyAsSvg,
|
||||
actionCopyStyles,
|
||||
actionCut,
|
||||
actionDeleteSelected,
|
||||
actionDuplicateSelection,
|
||||
actionFinalize,
|
||||
actionGroup,
|
||||
actionPasteStyles,
|
||||
actionSelectAll,
|
||||
actionSendBackward,
|
||||
actionSendToBack,
|
||||
actionToggleGridMode,
|
||||
actionToggleStats,
|
||||
actionToggleZenMode,
|
||||
actionUngroup,
|
||||
} from "../actions";
|
||||
import { createRedoAction, createUndoAction } from "../actions/actionHistory";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { actions } from "../actions/register";
|
||||
import { ActionResult } from "../actions/types";
|
||||
import {
|
||||
EVENT_DIALOG,
|
||||
EVENT_LIBRARY,
|
||||
EVENT_SHAPE,
|
||||
trackEvent,
|
||||
} from "../analytics";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import {
|
||||
copyToClipboard,
|
||||
@ -23,7 +41,6 @@ import {
|
||||
} from "../clipboard";
|
||||
import {
|
||||
APP_NAME,
|
||||
CANVAS_ONLY_ACTIONS,
|
||||
CURSOR_TYPE,
|
||||
DEFAULT_VERTICAL_ALIGN,
|
||||
DRAGGING_THRESHOLD,
|
||||
@ -31,15 +48,15 @@ import {
|
||||
ELEMENT_TRANSLATE_AMOUNT,
|
||||
ENV,
|
||||
EVENT,
|
||||
GRID_SIZE,
|
||||
LINE_CONFIRM_THRESHOLD,
|
||||
MIME_TYPES,
|
||||
POINTER_BUTTON,
|
||||
TAP_TWICE_TIMEOUT,
|
||||
TEXT_TO_CENTER_SNAP_THRESHOLD,
|
||||
TOUCH_CTX_MENU_TIMEOUT,
|
||||
ZOOM_STEP,
|
||||
} from "../constants";
|
||||
import { exportCanvas, loadFromBlob } from "../data";
|
||||
import { loadFromBlob } from "../data";
|
||||
import { isValidLibrary } from "../data/json";
|
||||
import { Library } from "../data/library";
|
||||
import { restore } from "../data/restore";
|
||||
@ -111,7 +128,7 @@ import {
|
||||
selectGroupsForSelectedElements,
|
||||
} from "../groups";
|
||||
import { createHistory, SceneHistory } from "../history";
|
||||
import { t, getLanguage, setLanguage, languages, defaultLang } from "../i18n";
|
||||
import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n";
|
||||
import {
|
||||
CODES,
|
||||
getResizeCenterPointKey,
|
||||
@ -132,7 +149,6 @@ import {
|
||||
getSelectedElements,
|
||||
isOverScrollBars,
|
||||
isSomeElementSelected,
|
||||
normalizeScroll,
|
||||
} from "../scene";
|
||||
import Scene from "../scene/Scene";
|
||||
import { SceneState, ScrollBars } from "../scene/types";
|
||||
@ -148,7 +164,6 @@ import {
|
||||
import {
|
||||
debounce,
|
||||
distance,
|
||||
getNewSceneName,
|
||||
isInputLike,
|
||||
isToolIcon,
|
||||
isWritableElement,
|
||||
@ -161,9 +176,12 @@ import {
|
||||
viewportCoordsToSceneCoords,
|
||||
withBatchedUpdates,
|
||||
} from "../utils";
|
||||
import ContextMenu from "./ContextMenu";
|
||||
import { isMobile } from "../is-mobile";
|
||||
import ContextMenu, { ContextMenuOption } from "./ContextMenu";
|
||||
import LayerUI from "./LayerUI";
|
||||
import { Stats } from "./Stats";
|
||||
import { Toast } from "./Toast";
|
||||
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
|
||||
|
||||
const { history } = createHistory();
|
||||
|
||||
@ -253,6 +271,7 @@ export type ExcalidrawImperativeAPI = {
|
||||
};
|
||||
setScrollToCenter: InstanceType<typeof App>["setScrollToCenter"];
|
||||
getSceneElements: InstanceType<typeof App>["getSceneElements"];
|
||||
getAppState: () => InstanceType<typeof App>["state"];
|
||||
readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
|
||||
ready: true;
|
||||
};
|
||||
@ -279,14 +298,15 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
offsetLeft,
|
||||
offsetTop,
|
||||
excalidrawRef,
|
||||
viewModeEnabled = false,
|
||||
} = props;
|
||||
this.state = {
|
||||
...defaultAppState,
|
||||
name: getNewSceneName(),
|
||||
isLoading: true,
|
||||
width,
|
||||
height,
|
||||
...this.getCanvasOffsets({ offsetLeft, offsetTop }),
|
||||
viewModeEnabled,
|
||||
};
|
||||
if (excalidrawRef) {
|
||||
const readyPromise =
|
||||
@ -304,6 +324,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
},
|
||||
setScrollToCenter: this.setScrollToCenter,
|
||||
getSceneElements: this.getSceneElements,
|
||||
getAppState: () => this.state,
|
||||
} as const;
|
||||
if (typeof excalidrawRef === "function") {
|
||||
excalidrawRef(api);
|
||||
@ -318,6 +339,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
this.syncActionResult,
|
||||
() => this.state,
|
||||
() => this.scene.getElementsIncludingDeleted(),
|
||||
this,
|
||||
);
|
||||
this.actionManager.registerAll(actions);
|
||||
|
||||
@ -325,6 +347,62 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
this.actionManager.registerAction(createRedoAction(history));
|
||||
}
|
||||
|
||||
private renderCanvas() {
|
||||
const canvasScale = window.devicePixelRatio;
|
||||
const {
|
||||
width: canvasDOMWidth,
|
||||
height: canvasDOMHeight,
|
||||
viewModeEnabled,
|
||||
} = this.state;
|
||||
const canvasWidth = canvasDOMWidth * canvasScale;
|
||||
const canvasHeight = canvasDOMHeight * canvasScale;
|
||||
if (viewModeEnabled) {
|
||||
return (
|
||||
<canvas
|
||||
id="canvas"
|
||||
style={{
|
||||
width: canvasDOMWidth,
|
||||
height: canvasDOMHeight,
|
||||
cursor: "grabbing",
|
||||
}}
|
||||
width={canvasWidth}
|
||||
height={canvasHeight}
|
||||
ref={this.handleCanvasRef}
|
||||
onContextMenu={this.handleCanvasContextMenu}
|
||||
onPointerMove={this.handleCanvasPointerMove}
|
||||
onPointerUp={this.removePointer}
|
||||
onPointerCancel={this.removePointer}
|
||||
onTouchMove={this.handleTouchMove}
|
||||
onPointerDown={this.handleCanvasPointerDown}
|
||||
>
|
||||
{t("labels.drawingCanvas")}
|
||||
</canvas>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<canvas
|
||||
id="canvas"
|
||||
style={{
|
||||
width: canvasDOMWidth,
|
||||
height: canvasDOMHeight,
|
||||
}}
|
||||
width={canvasWidth}
|
||||
height={canvasHeight}
|
||||
ref={this.handleCanvasRef}
|
||||
onContextMenu={this.handleCanvasContextMenu}
|
||||
onPointerDown={this.handleCanvasPointerDown}
|
||||
onDoubleClick={this.handleCanvasDoubleClick}
|
||||
onPointerMove={this.handleCanvasPointerMove}
|
||||
onPointerUp={this.removePointer}
|
||||
onPointerCancel={this.removePointer}
|
||||
onTouchMove={this.handleTouchMove}
|
||||
onDrop={this.handleCanvasOnDrop}
|
||||
>
|
||||
{t("labels.drawingCanvas")}
|
||||
</canvas>
|
||||
);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const {
|
||||
zenModeEnabled,
|
||||
@ -332,20 +410,19 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
height: canvasDOMHeight,
|
||||
offsetTop,
|
||||
offsetLeft,
|
||||
viewModeEnabled,
|
||||
} = this.state;
|
||||
|
||||
const { onCollabButtonClick, onExportToBackend, renderFooter } = this.props;
|
||||
const canvasScale = window.devicePixelRatio;
|
||||
|
||||
const canvasWidth = canvasDOMWidth * canvasScale;
|
||||
const canvasHeight = canvasDOMHeight * canvasScale;
|
||||
|
||||
const DEFAULT_PASTE_X = canvasDOMWidth / 2;
|
||||
const DEFAULT_PASTE_Y = canvasDOMHeight / 2;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="excalidraw"
|
||||
className={clsx("excalidraw", {
|
||||
"excalidraw--view-mode": viewModeEnabled,
|
||||
})}
|
||||
ref={this.excalidrawContainerRef}
|
||||
style={{
|
||||
width: canvasDOMWidth,
|
||||
@ -375,36 +452,24 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
isCollaborating={this.props.isCollaborating || false}
|
||||
onExportToBackend={onExportToBackend}
|
||||
renderCustomFooter={renderFooter}
|
||||
viewModeEnabled={viewModeEnabled}
|
||||
/>
|
||||
<div className="excalidraw-textEditorContainer" />
|
||||
{this.state.showStats && (
|
||||
<Stats
|
||||
appState={this.state}
|
||||
setAppState={this.setAppState}
|
||||
elements={this.scene.getElements()}
|
||||
onClose={this.toggleStats}
|
||||
/>
|
||||
)}
|
||||
<main>
|
||||
<canvas
|
||||
id="canvas"
|
||||
style={{
|
||||
width: canvasDOMWidth,
|
||||
height: canvasDOMHeight,
|
||||
}}
|
||||
width={canvasWidth}
|
||||
height={canvasHeight}
|
||||
ref={this.handleCanvasRef}
|
||||
onContextMenu={this.handleCanvasContextMenu}
|
||||
onPointerDown={this.handleCanvasPointerDown}
|
||||
onDoubleClick={this.handleCanvasDoubleClick}
|
||||
onPointerMove={this.handleCanvasPointerMove}
|
||||
onPointerUp={this.removePointer}
|
||||
onPointerCancel={this.removePointer}
|
||||
onTouchMove={this.handleTouchMove}
|
||||
onDrop={this.handleCanvasOnDrop}
|
||||
>
|
||||
{t("labels.drawingCanvas")}
|
||||
</canvas>
|
||||
</main>
|
||||
{this.state.toastMessage !== null && (
|
||||
<Toast
|
||||
message={this.state.toastMessage}
|
||||
clearToast={this.clearToast}
|
||||
/>
|
||||
)}
|
||||
<main>{this.renderCanvas()}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -444,6 +509,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
if (actionResult.commitToHistory) {
|
||||
history.resumeRecording();
|
||||
}
|
||||
|
||||
let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false;
|
||||
|
||||
if (typeof this.props.viewModeEnabled !== "undefined") {
|
||||
viewModeEnabled = this.props.viewModeEnabled;
|
||||
}
|
||||
|
||||
this.setState(
|
||||
(state) => ({
|
||||
...actionResult.appState,
|
||||
@ -453,6 +525,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
height: state.height,
|
||||
offsetTop: state.offsetTop,
|
||||
offsetLeft: state.offsetLeft,
|
||||
viewModeEnabled,
|
||||
}),
|
||||
() => {
|
||||
if (actionResult.syncHistory) {
|
||||
@ -506,7 +579,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
)
|
||||
) {
|
||||
await Library.importLibrary(blob);
|
||||
trackEvent(EVENT_LIBRARY, "import");
|
||||
this.setState({
|
||||
isLibraryOpen: true,
|
||||
});
|
||||
@ -530,7 +602,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
this.scene.replaceAllElements([]);
|
||||
this.setState((state) => ({
|
||||
...getDefaultAppState(),
|
||||
name: getNewSceneName(),
|
||||
isLoading: opts?.resetLoadingState ? false : state.isLoading,
|
||||
appearance: this.state.appearance,
|
||||
}));
|
||||
@ -637,7 +708,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
}
|
||||
|
||||
this.scene.addCallback(this.onSceneUpdated);
|
||||
|
||||
this.addEventListeners();
|
||||
|
||||
// optim to avoid extra render on init
|
||||
@ -704,25 +774,16 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
}
|
||||
|
||||
private addEventListeners() {
|
||||
this.removeEventListeners();
|
||||
document.addEventListener(EVENT.COPY, this.onCopy);
|
||||
document.addEventListener(EVENT.PASTE, this.pasteFromClipboard);
|
||||
document.addEventListener(EVENT.CUT, this.onCut);
|
||||
|
||||
document.addEventListener(EVENT.KEYDOWN, this.onKeyDown, false);
|
||||
document.addEventListener(EVENT.KEYUP, this.onKeyUp, { passive: true });
|
||||
document.addEventListener(
|
||||
EVENT.MOUSE_MOVE,
|
||||
this.updateCurrentCursorPosition,
|
||||
);
|
||||
window.addEventListener(EVENT.RESIZE, this.onResize, false);
|
||||
window.addEventListener(EVENT.UNLOAD, this.onUnload, false);
|
||||
window.addEventListener(EVENT.BLUR, this.onBlur, false);
|
||||
window.addEventListener(EVENT.DRAG_OVER, this.disableEvent, false);
|
||||
window.addEventListener(EVENT.DROP, this.disableEvent, false);
|
||||
|
||||
// rerender text elements on font load to fix #637 && #1553
|
||||
document.fonts?.addEventListener?.("loadingdone", this.onFontLoaded);
|
||||
|
||||
// Safari-only desktop pinch zoom
|
||||
document.addEventListener(
|
||||
EVENT.GESTURE_START,
|
||||
@ -739,6 +800,18 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
this.onGestureEnd as any,
|
||||
false,
|
||||
);
|
||||
if (this.state.viewModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.addEventListener(EVENT.PASTE, this.pasteFromClipboard);
|
||||
document.addEventListener(EVENT.CUT, this.onCut);
|
||||
|
||||
window.addEventListener(EVENT.RESIZE, this.onResize, false);
|
||||
window.addEventListener(EVENT.UNLOAD, this.onUnload, false);
|
||||
window.addEventListener(EVENT.BLUR, this.onBlur, false);
|
||||
window.addEventListener(EVENT.DRAG_OVER, this.disableEvent, false);
|
||||
window.addEventListener(EVENT.DROP, this.disableEvent, false);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: ExcalidrawProps, prevState: AppState) {
|
||||
@ -761,6 +834,17 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
});
|
||||
}
|
||||
|
||||
if (prevProps.viewModeEnabled !== this.props.viewModeEnabled) {
|
||||
this.setState(
|
||||
{ viewModeEnabled: !!this.props.viewModeEnabled },
|
||||
this.addEventListeners,
|
||||
);
|
||||
}
|
||||
|
||||
if (prevState.viewModeEnabled !== this.state.viewModeEnabled) {
|
||||
this.addEventListeners();
|
||||
}
|
||||
|
||||
document
|
||||
.querySelector(".excalidraw")
|
||||
?.classList.toggle("Appearance_dark", this.state.appearance === "dark");
|
||||
@ -800,6 +884,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
const pointerViewportCoords: SceneState["remotePointerViewportCoords"] = {};
|
||||
const remoteSelectedElementIds: SceneState["remoteSelectedElementIds"] = {};
|
||||
const pointerUsernames: { [id: string]: string } = {};
|
||||
const pointerUserStates: { [id: string]: string } = {};
|
||||
this.state.collaborators.forEach((user, socketId) => {
|
||||
if (user.selectedElementIds) {
|
||||
for (const id of Object.keys(user.selectedElementIds)) {
|
||||
@ -815,6 +900,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
if (user.username) {
|
||||
pointerUsernames[socketId] = user.username;
|
||||
}
|
||||
if (user.userState) {
|
||||
pointerUserStates[socketId] = user.userState;
|
||||
}
|
||||
pointerViewportCoords[socketId] = sceneCoordsToViewportCoords(
|
||||
{
|
||||
sceneX: user.pointer.x,
|
||||
@ -849,6 +937,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
remotePointerButton: cursorButton,
|
||||
remoteSelectedElementIds,
|
||||
remotePointerUsernames: pointerUsernames,
|
||||
remotePointerUserStates: pointerUserStates,
|
||||
shouldCacheIgnoreZoom: this.state.shouldCacheIgnoreZoom,
|
||||
},
|
||||
{
|
||||
@ -908,43 +997,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
copyToClipboard(this.scene.getElements(), this.state);
|
||||
};
|
||||
|
||||
private copyToClipboardAsPng = async () => {
|
||||
const elements = this.scene.getElements();
|
||||
|
||||
const selectedElements = getSelectedElements(elements, this.state);
|
||||
try {
|
||||
await exportCanvas(
|
||||
"clipboard",
|
||||
selectedElements.length ? selectedElements : elements,
|
||||
this.state,
|
||||
this.canvas!,
|
||||
this.state,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.setState({ errorMessage: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
private copyToClipboardAsSvg = async () => {
|
||||
const selectedElements = getSelectedElements(
|
||||
this.scene.getElements(),
|
||||
this.state,
|
||||
);
|
||||
try {
|
||||
await exportCanvas(
|
||||
"clipboard-svg",
|
||||
selectedElements.length ? selectedElements : this.scene.getElements(),
|
||||
this.state,
|
||||
this.canvas!,
|
||||
this.state,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.setState({ errorMessage: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
private static resetTapTwice() {
|
||||
didTapTwice = false;
|
||||
}
|
||||
@ -1137,7 +1189,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
|
||||
toggleLock = () => {
|
||||
this.setState((prevState) => {
|
||||
trackEvent(EVENT_SHAPE, "lock", !prevState.elementLocked ? "on" : "off");
|
||||
return {
|
||||
elementLocked: !prevState.elementLocked,
|
||||
elementType: prevState.elementLocked
|
||||
@ -1148,24 +1199,14 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
};
|
||||
|
||||
toggleZenMode = () => {
|
||||
this.setState({
|
||||
zenModeEnabled: !this.state.zenModeEnabled,
|
||||
});
|
||||
};
|
||||
|
||||
toggleGridMode = () => {
|
||||
this.setState({
|
||||
gridSize: this.state.gridSize ? null : GRID_SIZE,
|
||||
});
|
||||
this.actionManager.executeAction(actionToggleZenMode);
|
||||
};
|
||||
|
||||
toggleStats = () => {
|
||||
if (!this.state.showStats) {
|
||||
trackEvent(EVENT_DIALOG, "stats");
|
||||
trackEvent("dialog", "stats");
|
||||
}
|
||||
this.setState({
|
||||
showStats: !this.state.showStats,
|
||||
});
|
||||
this.actionManager.executeAction(actionToggleStats);
|
||||
};
|
||||
|
||||
setScrollToCenter = (remoteElements: readonly ExcalidrawElement[]) => {
|
||||
@ -1178,6 +1219,10 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
});
|
||||
};
|
||||
|
||||
clearToast = () => {
|
||||
this.setState({ toastMessage: null });
|
||||
};
|
||||
|
||||
public updateScene = withBatchedUpdates((sceneData: SceneData) => {
|
||||
if (sceneData.commitToHistory) {
|
||||
history.resumeRecording();
|
||||
@ -1247,35 +1292,23 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
|
||||
if (event.key === KEYS.QUESTION_MARK) {
|
||||
this.setState({
|
||||
showShortcutsDialog: true,
|
||||
showHelpDialog: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z) {
|
||||
this.toggleZenMode();
|
||||
}
|
||||
|
||||
if (event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE) {
|
||||
this.toggleGridMode();
|
||||
}
|
||||
if (event[KEYS.CTRL_OR_CMD]) {
|
||||
this.setState({ isBindingEnabled: false });
|
||||
}
|
||||
|
||||
if (event.code === CODES.C && event.altKey && event.shiftKey) {
|
||||
this.copyToClipboardAsPng();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.actionManager.handleKeyDown(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.viewModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event[KEYS.CTRL_OR_CMD]) {
|
||||
this.setState({ isBindingEnabled: false });
|
||||
}
|
||||
|
||||
if (event.code === CODES.NINE) {
|
||||
if (!this.state.isLibraryOpen) {
|
||||
trackEvent(EVENT_DIALOG, "library");
|
||||
}
|
||||
this.setState({ isLibraryOpen: !this.state.isLibraryOpen });
|
||||
}
|
||||
|
||||
@ -1360,7 +1393,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
) {
|
||||
const shape = findShapeByKey(event.key);
|
||||
if (shape) {
|
||||
trackEvent(EVENT_SHAPE, shape, "shortcut");
|
||||
this.selectShapeTool(shape);
|
||||
} else if (event.key === KEYS.Q) {
|
||||
this.toggleLock();
|
||||
@ -1744,7 +1776,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
resetCursor();
|
||||
|
||||
if (!event[KEYS.CTRL_OR_CMD]) {
|
||||
trackEvent(EVENT_SHAPE, "text", "double-click");
|
||||
this.startTextEditing({
|
||||
sceneX,
|
||||
sceneY,
|
||||
@ -1781,8 +1812,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
const scaleFactor = distance / gesture.initialDistance;
|
||||
|
||||
this.setState(({ zoom, scrollX, scrollY, offsetLeft, offsetTop }) => ({
|
||||
scrollX: normalizeScroll(scrollX + deltaX / zoom.value),
|
||||
scrollY: normalizeScroll(scrollY + deltaY / zoom.value),
|
||||
scrollX: scrollX + deltaX / zoom.value,
|
||||
scrollY: scrollY + deltaY / zoom.value,
|
||||
zoom: getNewZoom(
|
||||
getNormalizedZoom(initialScale * scaleFactor),
|
||||
zoom,
|
||||
@ -1799,10 +1830,11 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
if (isHoldingSpace || isPanning || isDraggingScrollBar) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isPointerOverScrollBars = isOverScrollBars(
|
||||
currentScrollBars,
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
event.clientX - this.state.offsetLeft,
|
||||
event.clientY - this.state.offsetTop,
|
||||
);
|
||||
const isOverScrollBar = isPointerOverScrollBars.isOverEither;
|
||||
if (!this.state.draggingElement && !this.state.multiElement) {
|
||||
@ -2083,14 +2115,16 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
|
||||
lastPointerUp = onPointerUp;
|
||||
|
||||
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
|
||||
window.addEventListener(EVENT.POINTER_UP, onPointerUp);
|
||||
window.addEventListener(EVENT.KEYDOWN, onKeyDown);
|
||||
window.addEventListener(EVENT.KEYUP, onKeyUp);
|
||||
pointerDownState.eventListeners.onMove = onPointerMove;
|
||||
pointerDownState.eventListeners.onUp = onPointerUp;
|
||||
pointerDownState.eventListeners.onKeyUp = onKeyUp;
|
||||
pointerDownState.eventListeners.onKeyDown = onKeyDown;
|
||||
if (!this.state.viewModeEnabled) {
|
||||
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
|
||||
window.addEventListener(EVENT.POINTER_UP, onPointerUp);
|
||||
window.addEventListener(EVENT.KEYDOWN, onKeyDown);
|
||||
window.addEventListener(EVENT.KEYUP, onKeyUp);
|
||||
pointerDownState.eventListeners.onMove = onPointerMove;
|
||||
pointerDownState.eventListeners.onUp = onPointerUp;
|
||||
pointerDownState.eventListeners.onKeyUp = onKeyUp;
|
||||
pointerDownState.eventListeners.onKeyDown = onKeyDown;
|
||||
}
|
||||
};
|
||||
|
||||
private maybeOpenContextMenuAfterPointerDownOnTouchDevices = (
|
||||
@ -2140,7 +2174,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
!(
|
||||
gesture.pointers.size === 0 &&
|
||||
(event.button === POINTER_BUTTON.WHEEL ||
|
||||
(event.button === POINTER_BUTTON.MAIN && isHoldingSpace))
|
||||
(event.button === POINTER_BUTTON.MAIN && isHoldingSpace) ||
|
||||
this.state.viewModeEnabled)
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
@ -2193,12 +2228,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
}
|
||||
|
||||
this.setState({
|
||||
scrollX: normalizeScroll(
|
||||
this.state.scrollX - deltaX / this.state.zoom.value,
|
||||
),
|
||||
scrollY: normalizeScroll(
|
||||
this.state.scrollY - deltaY / this.state.zoom.value,
|
||||
),
|
||||
scrollX: this.state.scrollX - deltaX / this.state.zoom.value,
|
||||
scrollY: this.state.scrollY - deltaY / this.state.zoom.value,
|
||||
});
|
||||
});
|
||||
const teardown = withBatchedUpdates(
|
||||
@ -2259,8 +2290,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
),
|
||||
scrollbars: isOverScrollBars(
|
||||
currentScrollBars,
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
event.clientX - this.state.offsetLeft,
|
||||
event.clientY - this.state.offsetTop,
|
||||
),
|
||||
// we need to duplicate because we'll be updating this state
|
||||
lastCoords: { ...origin },
|
||||
@ -3012,9 +3043,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
const x = event.clientX;
|
||||
const dx = x - pointerDownState.lastCoords.x;
|
||||
this.setState({
|
||||
scrollX: normalizeScroll(
|
||||
this.state.scrollX - dx / this.state.zoom.value,
|
||||
),
|
||||
scrollX: this.state.scrollX - dx / this.state.zoom.value,
|
||||
});
|
||||
pointerDownState.lastCoords.x = x;
|
||||
return true;
|
||||
@ -3024,9 +3053,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
const y = event.clientY;
|
||||
const dy = y - pointerDownState.lastCoords.y;
|
||||
this.setState({
|
||||
scrollY: normalizeScroll(
|
||||
this.state.scrollY - dy / this.state.zoom.value,
|
||||
),
|
||||
scrollY: this.state.scrollY - dy / this.state.zoom.value,
|
||||
});
|
||||
pointerDownState.lastCoords.y = y;
|
||||
return true;
|
||||
@ -3144,7 +3171,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
);
|
||||
}
|
||||
this.setState({ suggestedBindings: [], startBoundElement: null });
|
||||
if (!elementLocked) {
|
||||
if (!elementLocked && elementType !== "draw") {
|
||||
resetCursor();
|
||||
this.setState((prevState) => ({
|
||||
draggingElement: null,
|
||||
@ -3291,7 +3318,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!elementLocked && draggingElement) {
|
||||
if (!elementLocked && elementType !== "draw" && draggingElement) {
|
||||
this.setState((prevState) => ({
|
||||
selectedElementIds: {
|
||||
...prevState.selectedElementIds,
|
||||
@ -3315,7 +3342,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
);
|
||||
}
|
||||
|
||||
if (!elementLocked) {
|
||||
if (!elementLocked && elementType !== "draw") {
|
||||
resetCursor();
|
||||
this.setState({
|
||||
draggingElement: null,
|
||||
@ -3426,11 +3453,40 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
}
|
||||
};
|
||||
|
||||
private handleCanvasImageDrop = async (
|
||||
event: React.DragEvent<HTMLCanvasElement>,
|
||||
file: File,
|
||||
) => {
|
||||
try {
|
||||
const shapes = await (
|
||||
await import(
|
||||
/* webpackChunkName: "pixelated-image" */ "../data/pixelated-image"
|
||||
)
|
||||
).pixelateImage(file, 20, 1200, event.clientX, event.clientY);
|
||||
|
||||
const nextElements = [
|
||||
...this.scene.getElementsIncludingDeleted(),
|
||||
...shapes,
|
||||
];
|
||||
|
||||
this.scene.replaceAllElements(nextElements);
|
||||
} catch (error) {
|
||||
return this.setState({
|
||||
isLoading: false,
|
||||
errorMessage: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private handleCanvasOnDrop = async (
|
||||
event: React.DragEvent<HTMLCanvasElement>,
|
||||
) => {
|
||||
let imageFile: File | null = null;
|
||||
try {
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (file?.type.indexOf("image/") === 0) {
|
||||
imageFile = file;
|
||||
}
|
||||
if (file?.type === "image/png" || file?.type === "image/svg+xml") {
|
||||
const { elements, appState } = await loadFromBlob(file, this.state);
|
||||
this.syncActionResult({
|
||||
@ -3442,8 +3498,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
commitToHistory: true,
|
||||
});
|
||||
return;
|
||||
} else if (imageFile) {
|
||||
return await this.handleCanvasImageDrop(event, imageFile);
|
||||
}
|
||||
} catch (error) {
|
||||
if (imageFile) {
|
||||
return await this.handleCanvasImageDrop(event, imageFile);
|
||||
}
|
||||
return this.setState({
|
||||
isLoading: false,
|
||||
errorMessage: error.message,
|
||||
@ -3590,9 +3651,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
transformElements(
|
||||
pointerDownState,
|
||||
transformHandleType,
|
||||
(newTransformHandle) => {
|
||||
pointerDownState.resize.handleType = newTransformHandle;
|
||||
},
|
||||
selectedElements,
|
||||
pointerDownState.resize.arrowDirection,
|
||||
getRotateWithDiscreteAngleKey(event),
|
||||
@ -3622,46 +3680,87 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
this.state,
|
||||
);
|
||||
|
||||
const maybeGroupAction = actionGroup.contextItemPredicate!(
|
||||
this.actionManager.getElementsIncludingDeleted(),
|
||||
this.actionManager.getAppState(),
|
||||
);
|
||||
|
||||
const maybeUngroupAction = actionUngroup.contextItemPredicate!(
|
||||
this.actionManager.getElementsIncludingDeleted(),
|
||||
this.actionManager.getAppState(),
|
||||
);
|
||||
|
||||
const separator = "separator";
|
||||
|
||||
const _isMobile = isMobile();
|
||||
|
||||
const elements = this.scene.getElements();
|
||||
const element = this.getElementAtPosition(x, y);
|
||||
const options: ContextMenuOption[] = [];
|
||||
if (probablySupportsClipboardBlob && elements.length > 0) {
|
||||
options.push(actionCopyAsPng);
|
||||
}
|
||||
|
||||
if (probablySupportsClipboardWriteText && elements.length > 0) {
|
||||
options.push(actionCopyAsSvg);
|
||||
}
|
||||
if (!element) {
|
||||
const viewModeOptions: ContextMenuOption[] = [
|
||||
...options,
|
||||
actionToggleStats,
|
||||
];
|
||||
|
||||
if (typeof this.props.viewModeEnabled === "undefined") {
|
||||
viewModeOptions.push(actionToggleViewMode);
|
||||
}
|
||||
|
||||
ContextMenu.push({
|
||||
options: viewModeOptions,
|
||||
top: clientY,
|
||||
left: clientX,
|
||||
actionManager: this.actionManager,
|
||||
appState: this.state,
|
||||
});
|
||||
|
||||
if (this.state.viewModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
ContextMenu.push({
|
||||
options: [
|
||||
navigator.clipboard && {
|
||||
shortcutName: "paste",
|
||||
label: t("labels.paste"),
|
||||
action: () => this.pasteFromClipboard(null),
|
||||
},
|
||||
_isMobile &&
|
||||
navigator.clipboard && {
|
||||
name: "paste",
|
||||
perform: (elements, appStates) => {
|
||||
this.pasteFromClipboard(null);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.paste",
|
||||
},
|
||||
_isMobile && navigator.clipboard && separator,
|
||||
probablySupportsClipboardBlob &&
|
||||
elements.length > 0 && {
|
||||
shortcutName: "copyAsPng",
|
||||
label: t("labels.copyAsPng"),
|
||||
action: this.copyToClipboardAsPng,
|
||||
},
|
||||
elements.length > 0 &&
|
||||
actionCopyAsPng,
|
||||
probablySupportsClipboardWriteText &&
|
||||
elements.length > 0 && {
|
||||
shortcutName: "copyAsSvg",
|
||||
label: t("labels.copyAsSvg"),
|
||||
action: this.copyToClipboardAsSvg,
|
||||
},
|
||||
...this.actionManager.getContextMenuItems((action) =>
|
||||
CANVAS_ONLY_ACTIONS.includes(action.name),
|
||||
),
|
||||
{
|
||||
checked: this.state.gridSize !== null,
|
||||
shortcutName: "gridMode",
|
||||
label: t("labels.gridMode"),
|
||||
action: this.toggleGridMode,
|
||||
},
|
||||
{
|
||||
checked: this.state.showStats,
|
||||
shortcutName: "stats",
|
||||
label: t("stats.title"),
|
||||
action: this.toggleStats,
|
||||
},
|
||||
elements.length > 0 &&
|
||||
actionCopyAsSvg,
|
||||
((probablySupportsClipboardBlob && elements.length > 0) ||
|
||||
(probablySupportsClipboardWriteText && elements.length > 0)) &&
|
||||
separator,
|
||||
actionSelectAll,
|
||||
separator,
|
||||
actionToggleGridMode,
|
||||
actionToggleZenMode,
|
||||
typeof this.props.viewModeEnabled === "undefined" &&
|
||||
actionToggleViewMode,
|
||||
actionToggleStats,
|
||||
],
|
||||
top: clientY,
|
||||
left: clientX,
|
||||
actionManager: this.actionManager,
|
||||
appState: this.state,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -3670,39 +3769,55 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
this.setState({ selectedElementIds: { [element.id]: true } });
|
||||
}
|
||||
|
||||
if (this.state.viewModeEnabled) {
|
||||
ContextMenu.push({
|
||||
options: [navigator.clipboard && actionCopy, ...options],
|
||||
top: clientY,
|
||||
left: clientX,
|
||||
actionManager: this.actionManager,
|
||||
appState: this.state,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
ContextMenu.push({
|
||||
options: [
|
||||
{
|
||||
shortcutName: "cut",
|
||||
label: t("labels.cut"),
|
||||
action: this.cutAll,
|
||||
},
|
||||
navigator.clipboard && {
|
||||
shortcutName: "copy",
|
||||
label: t("labels.copy"),
|
||||
action: this.copyAll,
|
||||
},
|
||||
navigator.clipboard && {
|
||||
shortcutName: "paste",
|
||||
label: t("labels.paste"),
|
||||
action: () => this.pasteFromClipboard(null),
|
||||
},
|
||||
probablySupportsClipboardBlob && {
|
||||
shortcutName: "copyAsPng",
|
||||
label: t("labels.copyAsPng"),
|
||||
action: this.copyToClipboardAsPng,
|
||||
},
|
||||
probablySupportsClipboardWriteText && {
|
||||
shortcutName: "copyAsSvg",
|
||||
label: t("labels.copyAsSvg"),
|
||||
action: this.copyToClipboardAsSvg,
|
||||
},
|
||||
...this.actionManager.getContextMenuItems(
|
||||
(action) => !CANVAS_ONLY_ACTIONS.includes(action.name),
|
||||
),
|
||||
_isMobile && actionCut,
|
||||
_isMobile && navigator.clipboard && actionCopy,
|
||||
_isMobile &&
|
||||
navigator.clipboard && {
|
||||
name: "paste",
|
||||
perform: (elements, appStates) => {
|
||||
this.pasteFromClipboard(null);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.paste",
|
||||
},
|
||||
_isMobile && separator,
|
||||
...options,
|
||||
separator,
|
||||
actionCopyStyles,
|
||||
actionPasteStyles,
|
||||
separator,
|
||||
maybeGroupAction && actionGroup,
|
||||
maybeUngroupAction && actionUngroup,
|
||||
(maybeGroupAction || maybeUngroupAction) && separator,
|
||||
actionAddToLibrary,
|
||||
separator,
|
||||
actionSendBackward,
|
||||
actionBringForward,
|
||||
actionSendToBack,
|
||||
actionBringToFront,
|
||||
separator,
|
||||
actionDuplicateSelection,
|
||||
actionDeleteSelected,
|
||||
],
|
||||
top: clientY,
|
||||
left: clientX,
|
||||
actionManager: this.actionManager,
|
||||
appState: this.state,
|
||||
});
|
||||
};
|
||||
|
||||
@ -3733,9 +3848,15 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
let newZoom = this.state.zoom.value - delta / 100;
|
||||
// increase zoom steps the more zoomed-in we are (applies to >100% only)
|
||||
newZoom += Math.log10(Math.max(1, this.state.zoom.value)) * -sign;
|
||||
// round to nearest step
|
||||
newZoom = Math.round(newZoom * ZOOM_STEP * 100) / (ZOOM_STEP * 100);
|
||||
|
||||
this.setState(({ zoom, offsetLeft, offsetTop }) => ({
|
||||
zoom: getNewZoom(
|
||||
getNormalizedZoom(zoom.value - delta / 100),
|
||||
getNormalizedZoom(newZoom),
|
||||
zoom,
|
||||
{ left: offsetLeft, top: offsetTop },
|
||||
{
|
||||
@ -3758,14 +3879,14 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
if (event.shiftKey) {
|
||||
this.setState(({ zoom, scrollX }) => ({
|
||||
// on Mac, shift+wheel tends to result in deltaX
|
||||
scrollX: normalizeScroll(scrollX - (deltaY || deltaX) / zoom.value),
|
||||
scrollX: scrollX - (deltaY || deltaX) / zoom.value,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState(({ zoom, scrollX, scrollY }) => ({
|
||||
scrollX: normalizeScroll(scrollX - deltaX / zoom.value),
|
||||
scrollY: normalizeScroll(scrollY - deltaY / zoom.value),
|
||||
scrollX: scrollX - deltaX / zoom.value,
|
||||
scrollY: scrollY - deltaY / zoom.value,
|
||||
}));
|
||||
});
|
||||
|
||||
@ -3825,7 +3946,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
};
|
||||
|
||||
private resetShouldCacheIgnoreZoomDebounced = debounce(() => {
|
||||
this.setState({ shouldCacheIgnoreZoom: false });
|
||||
if (!this.unmounted) {
|
||||
this.setState({ shouldCacheIgnoreZoom: false });
|
||||
}
|
||||
}, 300);
|
||||
|
||||
private getCanvasOffsets(offsets?: {
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import "../css/_variables";
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.Avatar {
|
||||
|
@ -1,6 +1,5 @@
|
||||
import React from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { EVENT_CHANGE, trackEvent } from "../analytics";
|
||||
import { AppState } from "../types";
|
||||
import { DarkModeToggle } from "./DarkModeToggle";
|
||||
|
||||
@ -19,8 +18,6 @@ export const BackgroundPickerAndDarkModeToggle = ({
|
||||
<DarkModeToggle
|
||||
value={appState.appearance}
|
||||
onChange={(appearance) => {
|
||||
// TODO: track the theme on the first load too
|
||||
trackEvent(EVENT_CHANGE, "theme", appearance);
|
||||
setAppState({ appearance });
|
||||
}}
|
||||
/>
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import "../css/_variables";
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.CollabButton.is-collaborating {
|
||||
|
@ -6,7 +6,6 @@ import useIsMobile from "../is-mobile";
|
||||
import { users } from "./icons";
|
||||
|
||||
import "./CollabButton.scss";
|
||||
import { EVENT_DIALOG, trackEvent } from "../analytics";
|
||||
|
||||
const CollabButton = ({
|
||||
isCollaborating,
|
||||
@ -23,10 +22,7 @@ const CollabButton = ({
|
||||
className={clsx("CollabButton", {
|
||||
"is-collaborating": isCollaborating,
|
||||
})}
|
||||
onClick={() => {
|
||||
trackEvent(EVENT_DIALOG, "collaboration");
|
||||
onClick();
|
||||
}}
|
||||
onClick={onClick}
|
||||
icon={users}
|
||||
type="button"
|
||||
title={t("buttons.roomDialog")}
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import "../css/_variables";
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.color-picker {
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import "../css/_variables";
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.context-menu {
|
||||
@ -9,9 +9,10 @@
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
margin: -0.25rem 0 0 0.125rem;
|
||||
padding: 0.25rem 0;
|
||||
padding: 0.5rem 0;
|
||||
background-color: var(--popup-secondary-background-color);
|
||||
border: 1px solid var(--button-gray-3);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.context-menu button {
|
||||
@ -54,6 +55,7 @@
|
||||
.context-menu-option__shortcut {
|
||||
justify-self: end;
|
||||
opacity: 0.6;
|
||||
font-family: inherit;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
@ -87,4 +89,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-option-separator {
|
||||
border: none;
|
||||
border-top: 1px solid $oc-gray-5;
|
||||
}
|
||||
}
|
||||
|
@ -2,28 +2,36 @@ import React from "react";
|
||||
import { render, unmountComponentAtNode } from "react-dom";
|
||||
import clsx from "clsx";
|
||||
import { Popover } from "./Popover";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import "./ContextMenu.scss";
|
||||
import {
|
||||
getShortcutFromShortcutName,
|
||||
ShortcutName,
|
||||
} from "../actions/shortcuts";
|
||||
import { Action } from "../actions/types";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { AppState } from "../types";
|
||||
|
||||
type ContextMenuOption = {
|
||||
checked?: boolean;
|
||||
shortcutName: ShortcutName;
|
||||
label: string;
|
||||
action(): void;
|
||||
};
|
||||
export type ContextMenuOption = "separator" | Action;
|
||||
|
||||
type Props = {
|
||||
type ContextMenuProps = {
|
||||
options: ContextMenuOption[];
|
||||
onCloseRequest?(): void;
|
||||
top: number;
|
||||
left: number;
|
||||
actionManager: ActionManager;
|
||||
appState: Readonly<AppState>;
|
||||
};
|
||||
|
||||
const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => {
|
||||
const ContextMenu = ({
|
||||
options,
|
||||
onCloseRequest,
|
||||
top,
|
||||
left,
|
||||
actionManager,
|
||||
appState,
|
||||
}: ContextMenuProps) => {
|
||||
const isDarkTheme = !!document
|
||||
.querySelector(".excalidraw")
|
||||
?.classList.contains("Appearance_dark");
|
||||
@ -43,23 +51,34 @@ const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => {
|
||||
className="context-menu"
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
{options.map(({ action, checked, shortcutName, label }, idx) => (
|
||||
<li data-testid={shortcutName} key={idx} onClick={onCloseRequest}>
|
||||
<button
|
||||
className={`context-menu-option
|
||||
${shortcutName === "delete" ? "dangerous" : ""}
|
||||
${checked ? "checkmark" : ""}`}
|
||||
onClick={action}
|
||||
>
|
||||
<div className="context-menu-option__label">{label}</div>
|
||||
<div className="context-menu-option__shortcut">
|
||||
{shortcutName
|
||||
? getShortcutFromShortcutName(shortcutName)
|
||||
: ""}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{options.map((option, idx) => {
|
||||
if (option === "separator") {
|
||||
return <hr key={idx} className="context-menu-option-separator" />;
|
||||
}
|
||||
|
||||
const actionName = option.name;
|
||||
const label = option.contextItemLabel
|
||||
? t(option.contextItemLabel)
|
||||
: "";
|
||||
return (
|
||||
<li key={idx} data-testid={actionName} onClick={onCloseRequest}>
|
||||
<button
|
||||
className={clsx("context-menu-option", {
|
||||
dangerous: actionName === "deleteSelectedElements",
|
||||
checkmark: option.checked?.(appState),
|
||||
})}
|
||||
onClick={() => actionManager.executeAction(option)}
|
||||
>
|
||||
<div className="context-menu-option__label">{label}</div>
|
||||
<kbd className="context-menu-option__shortcut">
|
||||
{actionName
|
||||
? getShortcutFromShortcutName(actionName as ShortcutName)
|
||||
: ""}
|
||||
</kbd>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Popover>
|
||||
</div>
|
||||
@ -78,8 +97,10 @@ const getContextMenuNode = (): HTMLDivElement => {
|
||||
|
||||
type ContextMenuParams = {
|
||||
options: (ContextMenuOption | false | null | undefined)[];
|
||||
top: number;
|
||||
left: number;
|
||||
top: ContextMenuProps["top"];
|
||||
left: ContextMenuProps["left"];
|
||||
actionManager: ContextMenuProps["actionManager"];
|
||||
appState: Readonly<AppState>;
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
@ -101,6 +122,8 @@ export default {
|
||||
left={params.left}
|
||||
options={options}
|
||||
onCloseRequest={handleClose}
|
||||
actionManager={params.actionManager}
|
||||
appState={params.appState}
|
||||
/>,
|
||||
getContextMenuNode(),
|
||||
);
|
||||
|
@ -1,6 +1,11 @@
|
||||
@import "../css/_variables";
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.Dialog {
|
||||
user-select: text;
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
.Dialog__title {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
@ -10,6 +15,7 @@
|
||||
padding: calc(var(--space-factor) * 2);
|
||||
text-align: center;
|
||||
font-variant: small-caps;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.Dialog__titleContent {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import clsx from "clsx";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||
import { t } from "../i18n";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { KEYS } from "../keys";
|
||||
@ -8,14 +9,6 @@ import { back, close } from "./icons";
|
||||
import { Island } from "./Island";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
const useRefState = <T,>() => {
|
||||
const [refValue, setRefValue] = useState<T | null>(null);
|
||||
const refCallback = useCallback((value: T) => {
|
||||
setRefValue(value);
|
||||
}, []);
|
||||
return [refValue, refCallback] as const;
|
||||
};
|
||||
|
||||
export const Dialog = (props: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
@ -24,7 +17,7 @@ export const Dialog = (props: {
|
||||
title: React.ReactNode;
|
||||
autofocus?: boolean;
|
||||
}) => {
|
||||
const [islandNode, setIslandNode] = useRefState<HTMLDivElement>();
|
||||
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!islandNode) {
|
||||
@ -80,7 +73,7 @@ export const Dialog = (props: {
|
||||
onCloseRequest={props.onCloseRequest}
|
||||
>
|
||||
<Island ref={setIslandNode}>
|
||||
<h3 id="dialog-title" className="Dialog__title">
|
||||
<h2 id="dialog-title" className="Dialog__title">
|
||||
<span className="Dialog__titleContent">{props.title}</span>
|
||||
<button
|
||||
className="Modal__close"
|
||||
@ -89,7 +82,7 @@ export const Dialog = (props: {
|
||||
>
|
||||
{useIsMobile() ? back : close}
|
||||
</button>
|
||||
</h3>
|
||||
</h2>
|
||||
<div className="Dialog__content">{props.children}</div>
|
||||
</Island>
|
||||
</Modal>
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import "../css/_variables";
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.ExportDialog__preview {
|
||||
|
@ -1,7 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { render, unmountComponentAtNode } from "react-dom";
|
||||
import { ActionsManagerInterface } from "../actions/types";
|
||||
import { EVENT_DIALOG, trackEvent } from "../analytics";
|
||||
import { probablySupportsClipboardBlob } from "../clipboard";
|
||||
import { canvasToBlob } from "../data/blob";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
@ -251,7 +250,6 @@ export const ExportDialog = ({
|
||||
<>
|
||||
<ToolButton
|
||||
onClick={() => {
|
||||
trackEvent(EVENT_DIALOG, "export");
|
||||
setModalIsShown(true);
|
||||
}}
|
||||
icon={exportFile}
|
||||
|
@ -1,6 +1,5 @@
|
||||
import React from "react";
|
||||
import oc from "open-color";
|
||||
import { EVENT_EXIT, trackEvent } from "../analytics";
|
||||
import React from "react";
|
||||
|
||||
// https://github.com/tholman/github-corners
|
||||
export const GitHubCorner = React.memo(
|
||||
@ -17,9 +16,6 @@ export const GitHubCorner = React.memo(
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="GitHub repository"
|
||||
onClick={() => {
|
||||
trackEvent(EVENT_EXIT, "github");
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d="M0 0l115 115h15l12 27 108 108V0z"
|
||||
|
@ -1,23 +1,28 @@
|
||||
@import "../css/_variables";
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.ShortcutsDialog-island {
|
||||
.HelpDialog h3 {
|
||||
border-bottom: 1px solid var(--button-gray-2);
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.HelpDialog--island {
|
||||
border: 1px solid var(--button-gray-2);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ShortcutsDialog-island-title {
|
||||
.HelpDialog--island-title {
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
background-color: var(--button-gray-1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ShorcutsDialog-shortcut {
|
||||
.HelpDialog--shortcut {
|
||||
border-top: 1px solid var(--button-gray-2);
|
||||
}
|
||||
|
||||
.ShorcutsDialog-key {
|
||||
.HelpDialog--key {
|
||||
word-break: keep-all;
|
||||
border: 1px solid var(--button-gray-2);
|
||||
padding: 2px 8px;
|
||||
@ -29,14 +34,23 @@
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.ShortcutsDialog-footer {
|
||||
.HelpDialog--header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-evenly;
|
||||
border-top: 1px solid var(--button-gray-2);
|
||||
margin-top: 8px;
|
||||
padding-top: 16px;
|
||||
margin-bottom: 32px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.HelpDialog--btn {
|
||||
border: 1px solid var(--link-color);
|
||||
padding: 8px 32px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.HelpDialog--btn:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
359
src/components/HelpDialog.tsx
Normal file
@ -0,0 +1,359 @@
|
||||
import React from "react";
|
||||
import { t } from "../i18n";
|
||||
import { isDarwin, isWindows } from "../keys";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import "./HelpDialog.scss";
|
||||
|
||||
const Header = () => (
|
||||
<div className="HelpDialog--header">
|
||||
<a
|
||||
className="HelpDialog--btn"
|
||||
href="https://github.com/excalidraw/excalidraw#documentation"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("helpDialog.documentation")}
|
||||
</a>
|
||||
<a
|
||||
className="HelpDialog--btn"
|
||||
href="https://blog.excalidraw.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("helpDialog.blog")}
|
||||
</a>
|
||||
<a
|
||||
className="HelpDialog--btn"
|
||||
href="https://github.com/excalidraw/excalidraw/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("helpDialog.github")}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Section = (props: { title: string; children: React.ReactNode }) => (
|
||||
<>
|
||||
<h3>{props.title}</h3>
|
||||
{props.children}
|
||||
</>
|
||||
);
|
||||
|
||||
const Columns = (props: { children: React.ReactNode }) => (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Column = (props: { children: React.ReactNode }) => (
|
||||
<div style={{ width: "49%" }}>{props.children}</div>
|
||||
);
|
||||
|
||||
const ShortcutIsland = (props: {
|
||||
caption: string;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<div className="HelpDialog--island">
|
||||
<h3 className="HelpDialog--island-title">{props.caption}</h3>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Shortcut = (props: {
|
||||
label: string;
|
||||
shortcuts: string[];
|
||||
isOr: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<div className="HelpDialog--shortcut">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
margin: "0",
|
||||
padding: "4px 8px",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flex: "0 0 auto",
|
||||
justifyContent: "flex-end",
|
||||
marginInlineStart: "auto",
|
||||
minWidth: "30%",
|
||||
}}
|
||||
>
|
||||
{props.shortcuts.map((shortcut, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<ShortcutKey>{shortcut}</ShortcutKey>
|
||||
{props.isOr &&
|
||||
index !== props.shortcuts.length - 1 &&
|
||||
t("helpDialog.or")}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Shortcut.defaultProps = {
|
||||
isOr: true,
|
||||
};
|
||||
|
||||
const ShortcutKey = (props: { children: React.ReactNode }) => (
|
||||
<kbd className="HelpDialog--key" {...props} />
|
||||
);
|
||||
|
||||
export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
const handleClose = React.useCallback(() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
onCloseRequest={handleClose}
|
||||
title={t("helpDialog.title")}
|
||||
className={"HelpDialog"}
|
||||
>
|
||||
<Header />
|
||||
<Section title={t("helpDialog.shortcuts")}>
|
||||
<Columns>
|
||||
<Column>
|
||||
<ShortcutIsland caption={t("helpDialog.shapes")}>
|
||||
<Shortcut
|
||||
label={t("toolBar.selection")}
|
||||
shortcuts={["V", "1"]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("toolBar.rectangle")}
|
||||
shortcuts={["R", "2"]}
|
||||
/>
|
||||
<Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} />
|
||||
<Shortcut label={t("toolBar.ellipse")} shortcuts={["E", "4"]} />
|
||||
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
|
||||
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
|
||||
<Shortcut
|
||||
label={t("toolBar.draw")}
|
||||
shortcuts={["Shift+P", "7"]}
|
||||
/>
|
||||
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
|
||||
<Shortcut
|
||||
label={t("helpDialog.textNewLine")}
|
||||
shortcuts={[
|
||||
getShortcutKey("Enter"),
|
||||
getShortcutKey("Shift+Enter"),
|
||||
]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.textFinish")}
|
||||
shortcuts={[
|
||||
getShortcutKey("Esc"),
|
||||
getShortcutKey("CtrlOrCmd+Enter"),
|
||||
]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.curvedArrow")}
|
||||
shortcuts={[
|
||||
"A",
|
||||
t("helpDialog.click"),
|
||||
t("helpDialog.click"),
|
||||
t("helpDialog.click"),
|
||||
]}
|
||||
isOr={false}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.curvedLine")}
|
||||
shortcuts={[
|
||||
"L",
|
||||
t("helpDialog.click"),
|
||||
t("helpDialog.click"),
|
||||
t("helpDialog.click"),
|
||||
]}
|
||||
isOr={false}
|
||||
/>
|
||||
<Shortcut label={t("toolBar.lock")} shortcuts={["Q"]} />
|
||||
<Shortcut
|
||||
label={t("helpDialog.preventBinding")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd")]}
|
||||
/>
|
||||
</ShortcutIsland>
|
||||
<ShortcutIsland caption={t("helpDialog.view")}>
|
||||
<Shortcut
|
||||
label={t("buttons.zoomIn")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd++")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("buttons.zoomOut")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+-")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("buttons.resetZoom")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+0")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.zoomToFit")}
|
||||
shortcuts={["Shift+1"]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.zoomToSelection")}
|
||||
shortcuts={["Shift+2"]}
|
||||
/>
|
||||
<Shortcut label={t("buttons.fullScreen")} shortcuts={["F"]} />
|
||||
<Shortcut
|
||||
label={t("buttons.zenMode")}
|
||||
shortcuts={[getShortcutKey("Alt+Z")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.gridMode")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+'")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.viewMode")}
|
||||
shortcuts={[getShortcutKey("Alt+R")]}
|
||||
/>
|
||||
</ShortcutIsland>
|
||||
</Column>
|
||||
<Column>
|
||||
<ShortcutIsland caption={t("helpDialog.editor")}>
|
||||
<Shortcut
|
||||
label={t("labels.selectAll")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+A")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.multiSelect")}
|
||||
shortcuts={[getShortcutKey(`Shift+${t("helpDialog.click")}`)]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.moveCanvas")}
|
||||
shortcuts={[
|
||||
getShortcutKey(`Space+${t("helpDialog.drag")}`),
|
||||
getShortcutKey(`Wheel+${t("helpDialog.drag")}`),
|
||||
]}
|
||||
isOr={true}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.cut")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+X")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.copy")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+C")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.paste")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+V")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.copyAsPng")}
|
||||
shortcuts={[getShortcutKey("Shift+Alt+C")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.copyStyles")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Alt+C")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.pasteStyles")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Alt+V")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.delete")}
|
||||
shortcuts={[getShortcutKey("Del")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.sendToBack")}
|
||||
shortcuts={[
|
||||
isDarwin
|
||||
? getShortcutKey("CtrlOrCmd+Alt+[")
|
||||
: getShortcutKey("CtrlOrCmd+Shift+["),
|
||||
]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.bringToFront")}
|
||||
shortcuts={[
|
||||
isDarwin
|
||||
? getShortcutKey("CtrlOrCmd+Alt+]")
|
||||
: getShortcutKey("CtrlOrCmd+Shift+]"),
|
||||
]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.sendBackward")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+[")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.bringForward")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+]")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.alignTop")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Up")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.alignBottom")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Down")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.alignLeft")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Left")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.alignRight")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Right")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.duplicateSelection")}
|
||||
shortcuts={[
|
||||
getShortcutKey("CtrlOrCmd+D"),
|
||||
getShortcutKey(`Alt+${t("helpDialog.drag")}`),
|
||||
]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("buttons.undo")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Z")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("buttons.redo")}
|
||||
shortcuts={
|
||||
isWindows
|
||||
? [
|
||||
getShortcutKey("CtrlOrCmd+Y"),
|
||||
getShortcutKey("CtrlOrCmd+Shift+Z"),
|
||||
]
|
||||
: [getShortcutKey("CtrlOrCmd+Shift+Z")]
|
||||
}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.group")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+G")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.ungroup")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]}
|
||||
/>
|
||||
</ShortcutIsland>
|
||||
</Column>
|
||||
</Columns>
|
||||
</Section>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { questionCircle } from "../components/icons";
|
||||
|
||||
type HelpIconProps = {
|
||||
title?: string;
|
||||
@ -7,19 +8,8 @@ type HelpIconProps = {
|
||||
onClick?(): void;
|
||||
};
|
||||
|
||||
const ICON = (
|
||||
<svg
|
||||
width="30"
|
||||
height="22"
|
||||
viewBox="0 0 512 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M528 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h480c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM128 180v-40c0-6.627-5.373-12-12-12H76c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm-336 96v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm-336 96v-40c0-6.627-5.373-12-12-12H76c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm288 0v-40c0-6.627-5.373-12-12-12H172c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h232c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const HelpIcon = (props: HelpIconProps) => (
|
||||
<label title={`${props.title} — ?`} className="help-icon">
|
||||
<div onClick={props.onClick}>{ICON}</div>
|
||||
<div onClick={props.onClick}>{questionCircle}</div>
|
||||
</label>
|
||||
);
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import "../css/_variables";
|
||||
@import "../css/variables.module";
|
||||
|
||||
// this is loosely based on the longest hint text
|
||||
$wide-viewport-width: 1000px;
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import "../css/_variables";
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.picker-container {
|
||||
@ -102,6 +102,7 @@
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
font-size: 0.7em;
|
||||
color: var(--keybinding-color);
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
right: 2px;
|
||||
|
@ -38,6 +38,8 @@
|
||||
}
|
||||
|
||||
.layer-ui__wrapper {
|
||||
z-index: var(--zIndex-layerUI);
|
||||
|
||||
.encrypted-icon {
|
||||
position: relative;
|
||||
margin-inline-start: 15px;
|
||||
|
@ -1,56 +1,46 @@
|
||||
import clsx from "clsx";
|
||||
import React, {
|
||||
RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
RefObject,
|
||||
useEffect,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { showSelectedShapeActions } from "../element";
|
||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
||||
import { exportCanvas } from "../data";
|
||||
|
||||
import { AppState, LibraryItems, LibraryItem } from "../types";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { Island } from "./Island";
|
||||
import Stack from "./Stack";
|
||||
import { FixedSideContainer } from "./FixedSideContainer";
|
||||
import { UserList } from "./UserList";
|
||||
import { LockIcon } from "./LockIcon";
|
||||
import { ExportDialog, ExportCB } from "./ExportDialog";
|
||||
import { CLASSES } from "../constants";
|
||||
import { exportCanvas } from "../data";
|
||||
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
|
||||
import { Library } from "../data/library";
|
||||
import { isTextElement, showSelectedShapeActions } from "../element";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { Language, t } from "../i18n";
|
||||
import { HintViewer } from "./HintViewer";
|
||||
import useIsMobile from "../is-mobile";
|
||||
|
||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
||||
import { ExportType } from "../scene/types";
|
||||
import { MobileMenu } from "./MobileMenu";
|
||||
import { ZoomActions, SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||
import { Section } from "./Section";
|
||||
import { AppState, LibraryItem, LibraryItems } from "../types";
|
||||
import { muteFSAbortError } from "../utils";
|
||||
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
|
||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||
import CollabButton from "./CollabButton";
|
||||
import { ErrorDialog } from "./ErrorDialog";
|
||||
import { ShortcutsDialog } from "./ShortcutsDialog";
|
||||
import { LoadingMessage } from "./LoadingMessage";
|
||||
import { CLASSES } from "../constants";
|
||||
import { shield, exportFile, load } from "./icons";
|
||||
import { ExportCB, ExportDialog } from "./ExportDialog";
|
||||
import { FixedSideContainer } from "./FixedSideContainer";
|
||||
import { GitHubCorner } from "./GitHubCorner";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
import { HintViewer } from "./HintViewer";
|
||||
import { exportFile, load, shield } from "./icons";
|
||||
import { Island } from "./Island";
|
||||
import "./LayerUI.scss";
|
||||
import { LibraryUnit } from "./LibraryUnit";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { saveLibraryAsJSON, importLibraryFromJSON } from "../data/json";
|
||||
import { muteFSAbortError } from "../utils";
|
||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||
import clsx from "clsx";
|
||||
import { Library } from "../data/library";
|
||||
import {
|
||||
EVENT_ACTION,
|
||||
EVENT_EXIT,
|
||||
EVENT_LIBRARY,
|
||||
trackEvent,
|
||||
} from "../analytics";
|
||||
import { LoadingMessage } from "./LoadingMessage";
|
||||
import { LockIcon } from "./LockIcon";
|
||||
import { MobileMenu } from "./MobileMenu";
|
||||
import { PasteChartDialog } from "./PasteChartDialog";
|
||||
import { Section } from "./Section";
|
||||
import { HelpDialog } from "./HelpDialog";
|
||||
import Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { UserList } from "./UserList";
|
||||
|
||||
interface LayerUIProps {
|
||||
actionManager: ActionManager;
|
||||
@ -71,6 +61,7 @@ interface LayerUIProps {
|
||||
canvas: HTMLCanvasElement | null,
|
||||
) => void;
|
||||
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
|
||||
viewModeEnabled: boolean;
|
||||
}
|
||||
|
||||
const useOnClickOutside = (
|
||||
@ -159,13 +150,7 @@ const LibraryMenuItems = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
<a
|
||||
href="https://libraries.excalidraw.com"
|
||||
target="_excalidraw_libraries"
|
||||
onClick={() => {
|
||||
trackEvent(EVENT_EXIT, "libraries");
|
||||
}}
|
||||
>
|
||||
<a href="https://libraries.excalidraw.com" target="_excalidraw_libraries">
|
||||
{t("labels.libraries")}
|
||||
</a>
|
||||
</div>,
|
||||
@ -267,7 +252,6 @@ const LibraryMenu = ({
|
||||
const items = await Library.loadLibrary();
|
||||
const nextItems = items.filter((_, index) => index !== indexToRemove);
|
||||
Library.saveLibrary(nextItems);
|
||||
trackEvent(EVENT_LIBRARY, "remove");
|
||||
setLibraryItems(nextItems);
|
||||
}, []);
|
||||
|
||||
@ -276,7 +260,6 @@ const LibraryMenu = ({
|
||||
const items = await Library.loadLibrary();
|
||||
const nextItems = [...items, elements];
|
||||
onAddToLibrary();
|
||||
trackEvent(EVENT_LIBRARY, "add");
|
||||
Library.saveLibrary(nextItems);
|
||||
setLibraryItems(nextItems);
|
||||
},
|
||||
@ -317,6 +300,7 @@ const LayerUI = ({
|
||||
isCollaborating,
|
||||
onExportToBackend,
|
||||
renderCustomFooter,
|
||||
viewModeEnabled,
|
||||
}: LayerUIProps) => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
@ -328,9 +312,6 @@ const LayerUI = ({
|
||||
href="https://blog.excalidraw.com/end-to-end-encryption/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => {
|
||||
trackEvent(EVENT_EXIT, "e2ee shield");
|
||||
}}
|
||||
>
|
||||
<Tooltip label={t("encrypted.tooltip")} position="above" long={true}>
|
||||
{shield}
|
||||
@ -379,6 +360,28 @@ const LayerUI = ({
|
||||
);
|
||||
};
|
||||
|
||||
const renderViewModeCanvasActions = () => {
|
||||
return (
|
||||
<Section
|
||||
heading="canvasActions"
|
||||
className={clsx("zen-mode-transition", {
|
||||
"transition-left": zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
{/* the zIndex ensures this menu has higher stacking order,
|
||||
see https://github.com/excalidraw/excalidraw/pull/1445 */}
|
||||
<Island padding={2} style={{ zIndex: 1 }}>
|
||||
<Stack.Col gap={4}>
|
||||
<Stack.Row gap={1} justifyContent="space-between">
|
||||
{actionManager.renderAction("saveScene")}
|
||||
{actionManager.renderAction("saveAsScene")}
|
||||
{renderExportDialog()}
|
||||
</Stack.Row>
|
||||
</Stack.Col>
|
||||
</Island>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
const renderCanvasActions = () => (
|
||||
<Section
|
||||
heading="canvasActions"
|
||||
@ -469,38 +472,42 @@ const LayerUI = ({
|
||||
gap={4}
|
||||
className={clsx({ "disable-pointerEvents": zenModeEnabled })}
|
||||
>
|
||||
{renderCanvasActions()}
|
||||
{viewModeEnabled
|
||||
? renderViewModeCanvasActions()
|
||||
: renderCanvasActions()}
|
||||
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
||||
</Stack.Col>
|
||||
<Section heading="shapes">
|
||||
{(heading) => (
|
||||
<Stack.Col gap={4} align="start">
|
||||
<Stack.Row gap={1}>
|
||||
<Island
|
||||
padding={1}
|
||||
className={clsx({ "zen-mode": zenModeEnabled })}
|
||||
>
|
||||
<HintViewer appState={appState} elements={elements} />
|
||||
{heading}
|
||||
<Stack.Row gap={1}>
|
||||
<ShapesSwitcher
|
||||
elementType={appState.elementType}
|
||||
setAppState={setAppState}
|
||||
isLibraryOpen={appState.isLibraryOpen}
|
||||
/>
|
||||
</Stack.Row>
|
||||
</Island>
|
||||
<LockIcon
|
||||
zenModeEnabled={zenModeEnabled}
|
||||
checked={appState.elementLocked}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
/>
|
||||
</Stack.Row>
|
||||
{libraryMenu}
|
||||
</Stack.Col>
|
||||
)}
|
||||
</Section>
|
||||
{!viewModeEnabled && (
|
||||
<Section heading="shapes">
|
||||
{(heading) => (
|
||||
<Stack.Col gap={4} align="start">
|
||||
<Stack.Row gap={1}>
|
||||
<Island
|
||||
padding={1}
|
||||
className={clsx({ "zen-mode": zenModeEnabled })}
|
||||
>
|
||||
<HintViewer appState={appState} elements={elements} />
|
||||
{heading}
|
||||
<Stack.Row gap={1}>
|
||||
<ShapesSwitcher
|
||||
elementType={appState.elementType}
|
||||
setAppState={setAppState}
|
||||
isLibraryOpen={appState.isLibraryOpen}
|
||||
/>
|
||||
</Stack.Row>
|
||||
</Island>
|
||||
<LockIcon
|
||||
zenModeEnabled={zenModeEnabled}
|
||||
checked={appState.elementLocked}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
/>
|
||||
</Stack.Row>
|
||||
{libraryMenu}
|
||||
</Stack.Col>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
<UserList
|
||||
className={clsx("zen-mode-transition", {
|
||||
"transition-right": zenModeEnabled,
|
||||
@ -545,6 +552,20 @@ const LayerUI = ({
|
||||
);
|
||||
};
|
||||
|
||||
const renderGitHubCorner = () => {
|
||||
return (
|
||||
<aside
|
||||
className={clsx(
|
||||
"layer-ui__wrapper__github-corner zen-mode-transition",
|
||||
{
|
||||
"transition-right": zenModeEnabled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<GitHubCorner appearance={appState.appearance} />
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
const renderFooter = () => (
|
||||
<footer role="contentinfo" className="layer-ui__wrapper__footer">
|
||||
<div
|
||||
@ -567,7 +588,6 @@ const LayerUI = ({
|
||||
<button
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
trackEvent(EVENT_ACTION, "scroll to content");
|
||||
setAppState({
|
||||
...calculateScrollCenter(elements, appState, canvas),
|
||||
});
|
||||
@ -588,10 +608,8 @@ const LayerUI = ({
|
||||
onClose={() => setAppState({ errorMessage: null })}
|
||||
/>
|
||||
)}
|
||||
{appState.showShortcutsDialog && (
|
||||
<ShortcutsDialog
|
||||
onClose={() => setAppState({ showShortcutsDialog: false })}
|
||||
/>
|
||||
{appState.showHelpDialog && (
|
||||
<HelpDialog onClose={() => setAppState({ showHelpDialog: false })} />
|
||||
)}
|
||||
{appState.pasteDialog.shown && (
|
||||
<PasteChartDialog
|
||||
@ -623,25 +641,22 @@ const LayerUI = ({
|
||||
canvas={canvas}
|
||||
isCollaborating={isCollaborating}
|
||||
renderCustomFooter={renderCustomFooter}
|
||||
viewModeEnabled={viewModeEnabled}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="layer-ui__wrapper">
|
||||
<div
|
||||
className={clsx("layer-ui__wrapper", {
|
||||
"disable-pointerEvents":
|
||||
appState.draggingElement ||
|
||||
appState.resizingElement ||
|
||||
(appState.editingElement && !isTextElement(appState.editingElement)),
|
||||
})}
|
||||
>
|
||||
{dialogs}
|
||||
{renderFixedSideContainer()}
|
||||
{renderBottomAppMenu()}
|
||||
{
|
||||
<aside
|
||||
className={clsx(
|
||||
"layer-ui__wrapper__github-corner zen-mode-transition",
|
||||
{
|
||||
"transition-right": zenModeEnabled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<GitHubCorner appearance={appState.appearance} />
|
||||
</aside>
|
||||
}
|
||||
{renderGitHubCorner()}
|
||||
{renderFooter()}
|
||||
</div>
|
||||
);
|
||||
|
@ -16,7 +16,6 @@ import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
||||
import { LockIcon } from "./LockIcon";
|
||||
import { UserList } from "./UserList";
|
||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||
import { EVENT_ACTION, trackEvent } from "../analytics";
|
||||
|
||||
type MobileMenuProps = {
|
||||
appState: AppState;
|
||||
@ -30,6 +29,7 @@ type MobileMenuProps = {
|
||||
canvas: HTMLCanvasElement | null;
|
||||
isCollaborating: boolean;
|
||||
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
|
||||
viewModeEnabled: boolean;
|
||||
};
|
||||
|
||||
export const MobileMenu = ({
|
||||
@ -44,122 +44,164 @@ export const MobileMenu = ({
|
||||
canvas,
|
||||
isCollaborating,
|
||||
renderCustomFooter,
|
||||
}: MobileMenuProps) => (
|
||||
<>
|
||||
<FixedSideContainer side="top">
|
||||
<Section heading="shapes">
|
||||
{(heading) => (
|
||||
<Stack.Col gap={4} align="center">
|
||||
<Stack.Row gap={1}>
|
||||
<Island padding={1}>
|
||||
{heading}
|
||||
<Stack.Row gap={1}>
|
||||
<ShapesSwitcher
|
||||
elementType={appState.elementType}
|
||||
setAppState={setAppState}
|
||||
isLibraryOpen={appState.isLibraryOpen}
|
||||
/>
|
||||
</Stack.Row>
|
||||
</Island>
|
||||
<LockIcon
|
||||
checked={appState.elementLocked}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
/>
|
||||
</Stack.Row>
|
||||
{libraryMenu}
|
||||
</Stack.Col>
|
||||
)}
|
||||
</Section>
|
||||
<HintViewer appState={appState} elements={elements} />
|
||||
</FixedSideContainer>
|
||||
<div
|
||||
className="App-bottom-bar"
|
||||
style={{
|
||||
marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
|
||||
marginLeft: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
|
||||
marginRight: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
|
||||
}}
|
||||
>
|
||||
<Island padding={0}>
|
||||
{appState.openMenu === "canvas" ? (
|
||||
<Section className="App-mobile-menu" heading="canvasActions">
|
||||
<div className="panelColumn">
|
||||
<Stack.Col gap={4}>
|
||||
{actionManager.renderAction("loadScene")}
|
||||
{actionManager.renderAction("saveScene")}
|
||||
{actionManager.renderAction("saveAsScene")}
|
||||
{exportButton}
|
||||
{actionManager.renderAction("clearCanvas")}
|
||||
{onCollabButtonClick && (
|
||||
<CollabButton
|
||||
isCollaborating={isCollaborating}
|
||||
collaboratorCount={appState.collaborators.size}
|
||||
onClick={onCollabButtonClick}
|
||||
/>
|
||||
)}
|
||||
<BackgroundPickerAndDarkModeToggle
|
||||
actionManager={actionManager}
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
viewModeEnabled,
|
||||
}: MobileMenuProps) => {
|
||||
const renderToolbar = () => {
|
||||
return (
|
||||
<FixedSideContainer side="top" className="App-top-bar">
|
||||
<Section heading="shapes">
|
||||
{(heading) => (
|
||||
<Stack.Col gap={4} align="center">
|
||||
<Stack.Row gap={1}>
|
||||
<Island padding={1}>
|
||||
{heading}
|
||||
<Stack.Row gap={1}>
|
||||
<ShapesSwitcher
|
||||
elementType={appState.elementType}
|
||||
setAppState={setAppState}
|
||||
isLibraryOpen={appState.isLibraryOpen}
|
||||
/>
|
||||
</Stack.Row>
|
||||
</Island>
|
||||
<LockIcon
|
||||
checked={appState.elementLocked}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
/>
|
||||
{renderCustomFooter?.(true)}
|
||||
<fieldset>
|
||||
<legend>{t("labels.collaborators")}</legend>
|
||||
<UserList mobile>
|
||||
{Array.from(appState.collaborators)
|
||||
// Collaborator is either not initialized or is actually the current user.
|
||||
.filter(([_, client]) => Object.keys(client).length !== 0)
|
||||
.map(([clientId, client]) => (
|
||||
<React.Fragment key={clientId}>
|
||||
{actionManager.renderAction(
|
||||
"goToCollaborator",
|
||||
clientId,
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</UserList>
|
||||
</fieldset>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
</Section>
|
||||
) : appState.openMenu === "shape" &&
|
||||
showSelectedShapeActions(appState, elements) ? (
|
||||
<Section className="App-mobile-menu" heading="selectedShapeActions">
|
||||
<SelectedShapeActions
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
renderAction={actionManager.renderAction}
|
||||
elementType={appState.elementType}
|
||||
/>
|
||||
</Section>
|
||||
) : null}
|
||||
<footer className="App-toolbar">
|
||||
<div className="App-toolbar-content">
|
||||
{actionManager.renderAction("toggleCanvasMenu")}
|
||||
{actionManager.renderAction("toggleEditMenu")}
|
||||
{actionManager.renderAction("undo")}
|
||||
{actionManager.renderAction("redo")}
|
||||
{actionManager.renderAction(
|
||||
appState.multiElement ? "finalize" : "duplicateSelection",
|
||||
)}
|
||||
{actionManager.renderAction("deleteSelectedElements")}
|
||||
</div>
|
||||
{appState.scrolledOutside && !appState.openMenu && (
|
||||
<button
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
trackEvent(EVENT_ACTION, "scroll to content");
|
||||
setAppState({
|
||||
...calculateScrollCenter(elements, appState, canvas),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("buttons.scrollBackToContent")}
|
||||
</button>
|
||||
</Stack.Row>
|
||||
{libraryMenu}
|
||||
</Stack.Col>
|
||||
)}
|
||||
</footer>
|
||||
</Island>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
</Section>
|
||||
<HintViewer appState={appState} elements={elements} />
|
||||
</FixedSideContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const renderAppToolbar = () => {
|
||||
if (viewModeEnabled) {
|
||||
return (
|
||||
<div className="App-toolbar-content">
|
||||
{actionManager.renderAction("toggleCanvasMenu")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="App-toolbar-content">
|
||||
{actionManager.renderAction("toggleCanvasMenu")}
|
||||
{actionManager.renderAction("toggleEditMenu")}
|
||||
{actionManager.renderAction("undo")}
|
||||
{actionManager.renderAction("redo")}
|
||||
{actionManager.renderAction(
|
||||
appState.multiElement ? "finalize" : "duplicateSelection",
|
||||
)}
|
||||
{actionManager.renderAction("deleteSelectedElements")}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCanvasActions = () => {
|
||||
if (viewModeEnabled) {
|
||||
return (
|
||||
<>
|
||||
{actionManager.renderAction("saveScene")}
|
||||
{actionManager.renderAction("saveAsScene")}
|
||||
{exportButton}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{actionManager.renderAction("loadScene")}
|
||||
{actionManager.renderAction("saveScene")}
|
||||
{actionManager.renderAction("saveAsScene")}
|
||||
{exportButton}
|
||||
{actionManager.renderAction("clearCanvas")}
|
||||
{onCollabButtonClick && (
|
||||
<CollabButton
|
||||
isCollaborating={isCollaborating}
|
||||
collaboratorCount={appState.collaborators.size}
|
||||
onClick={onCollabButtonClick}
|
||||
/>
|
||||
)}
|
||||
{
|
||||
<BackgroundPickerAndDarkModeToggle
|
||||
actionManager={actionManager}
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{!viewModeEnabled && renderToolbar()}
|
||||
<div
|
||||
className="App-bottom-bar"
|
||||
style={{
|
||||
marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
|
||||
marginLeft: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
|
||||
marginRight: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
|
||||
}}
|
||||
>
|
||||
<Island padding={0}>
|
||||
{appState.openMenu === "canvas" ? (
|
||||
<Section className="App-mobile-menu" heading="canvasActions">
|
||||
<div className="panelColumn">
|
||||
<Stack.Col gap={4}>
|
||||
{renderCanvasActions()}
|
||||
{renderCustomFooter?.(true)}
|
||||
<fieldset>
|
||||
<legend>{t("labels.collaborators")}</legend>
|
||||
<UserList mobile>
|
||||
{Array.from(appState.collaborators)
|
||||
// Collaborator is either not initialized or is actually the current user.
|
||||
.filter(
|
||||
([_, client]) => Object.keys(client).length !== 0,
|
||||
)
|
||||
.map(([clientId, client]) => (
|
||||
<React.Fragment key={clientId}>
|
||||
{actionManager.renderAction(
|
||||
"goToCollaborator",
|
||||
clientId,
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</UserList>
|
||||
</fieldset>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
</Section>
|
||||
) : appState.openMenu === "shape" &&
|
||||
!viewModeEnabled &&
|
||||
showSelectedShapeActions(appState, elements) ? (
|
||||
<Section className="App-mobile-menu" heading="selectedShapeActions">
|
||||
<SelectedShapeActions
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
renderAction={actionManager.renderAction}
|
||||
elementType={appState.elementType}
|
||||
/>
|
||||
</Section>
|
||||
) : null}
|
||||
<footer className="App-toolbar">
|
||||
{renderAppToolbar()}
|
||||
{appState.scrolledOutside && !appState.openMenu && (
|
||||
<button
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
setAppState({
|
||||
...calculateScrollCenter(elements, appState, canvas),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("buttons.scrollBackToContent")}
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
</Island>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import "../css/_variables";
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.Modal {
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import "../css/_variables";
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.PasteChartDialog {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import oc from "open-color";
|
||||
import React, { useLayoutEffect, useRef, useState } from "react";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { ChartElements, renderSpreadsheet, Spreadsheet } from "../charts";
|
||||
import { ChartType } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
@ -86,6 +87,7 @@ export const PasteChartDialog = ({
|
||||
|
||||
const handleChartClick = (chartType: ChartType, elements: ChartElements) => {
|
||||
onInsertChart(elements);
|
||||
trackEvent("magic", "chart", chartType);
|
||||
setAppState({
|
||||
currentChartType: chartType,
|
||||
pasteDialog: {
|
||||
|
@ -1,338 +0,0 @@
|
||||
import React from "react";
|
||||
import { t } from "../i18n";
|
||||
import { isDarwin } from "../keys";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import "./ShortcutsDialog.scss";
|
||||
import { EVENT_EXIT, trackEvent } from "../analytics";
|
||||
|
||||
const Columns = (props: { children: React.ReactNode }) => (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Column = (props: { children: React.ReactNode }) => (
|
||||
<div style={{ width: "49%" }}>{props.children}</div>
|
||||
);
|
||||
|
||||
const ShortcutIsland = (props: {
|
||||
caption: string;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<div className="ShortcutsDialog-island">
|
||||
<h3 className="ShortcutsDialog-island-title">{props.caption}</h3>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Shortcut = (props: {
|
||||
label: string;
|
||||
shortcuts: string[];
|
||||
isOr: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<div className="ShorcutsDialog-shortcut">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
margin: "0",
|
||||
padding: "4px 8px",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flex: "0 0 auto",
|
||||
justifyContent: "flex-end",
|
||||
marginInlineStart: "auto",
|
||||
minWidth: "30%",
|
||||
}}
|
||||
>
|
||||
{props.shortcuts.map((shortcut, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<ShortcutKey>{shortcut}</ShortcutKey>
|
||||
{props.isOr &&
|
||||
index !== props.shortcuts.length - 1 &&
|
||||
t("shortcutsDialog.or")}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Shortcut.defaultProps = {
|
||||
isOr: true,
|
||||
};
|
||||
|
||||
const ShortcutKey = (props: { children: React.ReactNode }) => (
|
||||
<span className="ShorcutsDialog-key" {...props} />
|
||||
);
|
||||
|
||||
const Footer = () => (
|
||||
<div className="ShortcutsDialog-footer">
|
||||
<a
|
||||
href="https://blog.excalidraw.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => {
|
||||
trackEvent(EVENT_EXIT, "blog");
|
||||
}}
|
||||
>
|
||||
{t("shortcutsDialog.blog")}
|
||||
</a>
|
||||
<a
|
||||
href="https://howto.excalidraw.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => {
|
||||
trackEvent(EVENT_EXIT, "guides");
|
||||
}}
|
||||
>
|
||||
{t("shortcutsDialog.howto")}
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/excalidraw/excalidraw/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => {
|
||||
trackEvent(EVENT_EXIT, "issues");
|
||||
}}
|
||||
>
|
||||
{t("shortcutsDialog.github")}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ShortcutsDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
const handleClose = React.useCallback(() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog onCloseRequest={handleClose} title={t("shortcutsDialog.title")}>
|
||||
<Columns>
|
||||
<Column>
|
||||
<ShortcutIsland caption={t("shortcutsDialog.shapes")}>
|
||||
<Shortcut label={t("toolBar.selection")} shortcuts={["V", "1"]} />
|
||||
<Shortcut label={t("toolBar.rectangle")} shortcuts={["R", "2"]} />
|
||||
<Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} />
|
||||
<Shortcut label={t("toolBar.ellipse")} shortcuts={["E", "4"]} />
|
||||
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
|
||||
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
|
||||
<Shortcut
|
||||
label={t("toolBar.draw")}
|
||||
shortcuts={["Shift+P", "7"]}
|
||||
/>
|
||||
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
|
||||
<Shortcut
|
||||
label={t("shortcutsDialog.textNewLine")}
|
||||
shortcuts={[
|
||||
getShortcutKey("Enter"),
|
||||
getShortcutKey("Shift+Enter"),
|
||||
]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("shortcutsDialog.textFinish")}
|
||||
shortcuts={[
|
||||
getShortcutKey("Esc"),
|
||||
getShortcutKey("CtrlOrCmd+Enter"),
|
||||
]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("shortcutsDialog.curvedArrow")}
|
||||
shortcuts={[
|
||||
"A",
|
||||
t("shortcutsDialog.click"),
|
||||
t("shortcutsDialog.click"),
|
||||
t("shortcutsDialog.click"),
|
||||
]}
|
||||
isOr={false}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("shortcutsDialog.curvedLine")}
|
||||
shortcuts={[
|
||||
"L",
|
||||
t("shortcutsDialog.click"),
|
||||
t("shortcutsDialog.click"),
|
||||
t("shortcutsDialog.click"),
|
||||
]}
|
||||
isOr={false}
|
||||
/>
|
||||
<Shortcut label={t("toolBar.lock")} shortcuts={["Q"]} />
|
||||
<Shortcut
|
||||
label={t("shortcutsDialog.preventBinding")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd")]}
|
||||
/>
|
||||
</ShortcutIsland>
|
||||
<ShortcutIsland caption={t("shortcutsDialog.view")}>
|
||||
<Shortcut
|
||||
label={t("buttons.zoomIn")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd++")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("buttons.zoomOut")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+-")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("buttons.resetZoom")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+0")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("shortcutsDialog.zoomToFit")}
|
||||
shortcuts={["Shift+1"]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("shortcutsDialog.zoomToSelection")}
|
||||
shortcuts={["Shift+2"]}
|
||||
/>
|
||||
<Shortcut label={t("buttons.fullScreen")} shortcuts={["F"]} />
|
||||
<Shortcut
|
||||
label={t("buttons.zenMode")}
|
||||
shortcuts={[getShortcutKey("Alt+Z")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.gridMode")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+'")]}
|
||||
/>
|
||||
</ShortcutIsland>
|
||||
</Column>
|
||||
<Column>
|
||||
<ShortcutIsland caption={t("shortcutsDialog.editor")}>
|
||||
<Shortcut
|
||||
label={t("labels.selectAll")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+A")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.multiSelect")}
|
||||
shortcuts={[
|
||||
getShortcutKey(`Shift+${t("shortcutsDialog.click")}`),
|
||||
]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.moveCanvas")}
|
||||
shortcuts={[
|
||||
getShortcutKey(`Space+${t("shortcutsDialog.drag")}`),
|
||||
getShortcutKey(`Wheel+${t("shortcutsDialog.drag")}`),
|
||||
]}
|
||||
isOr={true}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.cut")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+X")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.copy")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+C")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.paste")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+V")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.copyAsPng")}
|
||||
shortcuts={[getShortcutKey("Shift+Alt+C")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.copyStyles")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Alt+C")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.pasteStyles")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Alt+V")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.delete")}
|
||||
shortcuts={[getShortcutKey("Del")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.sendToBack")}
|
||||
shortcuts={[
|
||||
isDarwin
|
||||
? getShortcutKey("CtrlOrCmd+Alt+[")
|
||||
: getShortcutKey("CtrlOrCmd+Shift+["),
|
||||
]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.bringToFront")}
|
||||
shortcuts={[
|
||||
isDarwin
|
||||
? getShortcutKey("CtrlOrCmd+Alt+]")
|
||||
: getShortcutKey("CtrlOrCmd+Shift+]"),
|
||||
]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.sendBackward")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+[")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.bringForward")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+]")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.alignTop")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Up")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.alignBottom")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Down")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.alignLeft")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Left")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.alignRight")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Right")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.duplicateSelection")}
|
||||
shortcuts={[
|
||||
getShortcutKey("CtrlOrCmd+D"),
|
||||
getShortcutKey(`Alt+${t("shortcutsDialog.drag")}`),
|
||||
]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("buttons.undo")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Z")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("buttons.redo")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Z")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.group")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+G")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.ungroup")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]}
|
||||
/>
|
||||
</ShortcutIsland>
|
||||
</Column>
|
||||
</Columns>
|
||||
<Footer />
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,4 +1,4 @@
|
||||
@import "../css/_variables";
|
||||
@import "../css/variables.module";
|
||||
|
||||
.Stats {
|
||||
position: fixed;
|
||||
|
@ -1,4 +1,6 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { copyTextToSystemClipboard } from "../clipboard";
|
||||
import { DEFAULT_VERSION } from "../constants";
|
||||
import { getCommonBounds } from "../element/bounds";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import {
|
||||
@ -9,7 +11,7 @@ import { t } from "../i18n";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { getTargetElements } from "../scene";
|
||||
import { AppState } from "../types";
|
||||
import { debounce, nFormatter } from "../utils";
|
||||
import { debounce, getVersion, nFormatter } from "../utils";
|
||||
import { close } from "./icons";
|
||||
import { Island } from "./Island";
|
||||
import "./Stats.scss";
|
||||
@ -25,6 +27,7 @@ const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => {
|
||||
|
||||
export const Stats = (props: {
|
||||
appState: AppState;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
@ -50,6 +53,17 @@ export const Stats = (props: {
|
||||
return null;
|
||||
}
|
||||
|
||||
const version = getVersion();
|
||||
let hash;
|
||||
let timestamp;
|
||||
|
||||
if (version !== DEFAULT_VERSION) {
|
||||
timestamp = version.slice(0, 16).replace("T", " ");
|
||||
hash = version.slice(21);
|
||||
} else {
|
||||
timestamp = t("stats.versionNotAvailable");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="Stats">
|
||||
<Island padding={2}>
|
||||
@ -156,6 +170,28 @@ export const Stats = (props: {
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<th colSpan={2}>{t("stats.version")}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
colSpan={2}
|
||||
style={{ textAlign: "center", cursor: "pointer" }}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await copyTextToSystemClipboard(getVersion());
|
||||
props.setAppState({
|
||||
toastMessage: t("toast.copyToClipboard"),
|
||||
});
|
||||
} catch {}
|
||||
}}
|
||||
title={t("stats.versionCopy")}
|
||||
>
|
||||
{timestamp}
|
||||
<br />
|
||||
{hash}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Island>
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import "../css/_variables.scss";
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.TextInput {
|
||||
|
32
src/components/Toast.scss
Normal file
@ -0,0 +1,32 @@
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.Toast {
|
||||
animation: fade-in 0.5s;
|
||||
background-color: var(--button-gray-1);
|
||||
border-radius: 4px;
|
||||
bottom: 10px;
|
||||
box-sizing: border-box;
|
||||
cursor: default;
|
||||
left: 50%;
|
||||
margin-left: -150px;
|
||||
padding: 4px 0;
|
||||
position: fixed;
|
||||
text-align: center;
|
||||
width: 300px;
|
||||
z-index: 999999;
|
||||
}
|
||||
|
||||
.Toast__message {
|
||||
color: var(--popup-text-color);
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
34
src/components/Toast.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import React, { useCallback, useEffect, useRef } from "react";
|
||||
import { TOAST_TIMEOUT } from "../constants";
|
||||
import "./Toast.scss";
|
||||
|
||||
export const Toast = ({
|
||||
message,
|
||||
clearToast,
|
||||
}: {
|
||||
message: string;
|
||||
clearToast: () => void;
|
||||
}) => {
|
||||
const timerRef = useRef<number>(0);
|
||||
|
||||
const scheduleTimeout = useCallback(
|
||||
() =>
|
||||
(timerRef.current = window.setTimeout(() => clearToast(), TOAST_TIMEOUT)),
|
||||
[clearToast],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
scheduleTimeout();
|
||||
return () => clearTimeout(timerRef.current);
|
||||
}, [scheduleTimeout, message]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="Toast"
|
||||
onMouseEnter={() => clearTimeout(timerRef?.current)}
|
||||
onMouseLeave={scheduleTimeout}
|
||||
>
|
||||
<p className="Toast__message">{message}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,5 +1,5 @@
|
||||
@import "open-color/open-color.scss";
|
||||
@import "../css/variables";
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.ToolIcon {
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import "../css/_variables";
|
||||
@import "../css/variables.module";
|
||||
.excalidraw {
|
||||
.Tooltip {
|
||||
position: relative;
|
||||
@ -48,15 +48,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// the following 3 rules ensure that the tooltip doesn't show (nor affect
|
||||
// the cursor) when you drag over when you draw on canvas, but at the same
|
||||
// time it still works when clicking on the link/shield
|
||||
|
||||
body:active & .Tooltip:not(:hover) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
body:not(:active) & .Tooltip:hover .Tooltip__label {
|
||||
.Tooltip:hover .Tooltip__label {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
|
@ -46,6 +46,7 @@ export enum EVENT {
|
||||
TOUCH_START = "touchstart",
|
||||
TOUCH_END = "touchend",
|
||||
HASHCHANGE = "hashchange",
|
||||
VISIBILITY_CHANGE = "visibilitychange",
|
||||
}
|
||||
|
||||
export const ENV = {
|
||||
@ -70,6 +71,7 @@ export const DEFAULT_FONT_SIZE = 20;
|
||||
export const DEFAULT_FONT_FAMILY: FontFamily = 1;
|
||||
export const DEFAULT_TEXT_ALIGN = "left";
|
||||
export const DEFAULT_VERTICAL_ALIGN = "top";
|
||||
export const DEFAULT_VERSION = "{version}";
|
||||
|
||||
export const CANVAS_ONLY_ACTIONS = ["selectAll"];
|
||||
|
||||
@ -88,5 +90,12 @@ export const STORAGE_KEYS = {
|
||||
export const TAP_TWICE_TIMEOUT = 300;
|
||||
export const TOUCH_CTX_MENU_TIMEOUT = 500;
|
||||
export const TITLE_TIMEOUT = 10000;
|
||||
export const TOAST_TIMEOUT = 5000;
|
||||
export const VERSION_TIMEOUT = 30000;
|
||||
|
||||
export const SCENE_NAME_FALLBACK = "Untitled";
|
||||
export const ZOOM_STEP = 0.1;
|
||||
|
||||
// Report a user inactive after IDLE_THRESHOLD milliseconds
|
||||
export const IDLE_THRESHOLD = 60_000;
|
||||
// Report a user active each ACTIVE_THRESHOLD milliseconds
|
||||
export const ACTIVE_THRESHOLD = 3_000;
|
||||
|
42
src/createInverseContext.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
|
||||
export const createInverseContext = <T extends unknown = null>(
|
||||
initialValue: T,
|
||||
) => {
|
||||
const Context = React.createContext(initialValue) as React.Context<T> & {
|
||||
_updateProviderValue?: (value: T) => void;
|
||||
};
|
||||
|
||||
class InverseConsumer extends React.Component {
|
||||
state = { value: initialValue };
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
Context._updateProviderValue = (value: T) => this.setState({ value });
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<Context.Provider value={this.state.value}>
|
||||
{this.props.children}
|
||||
</Context.Provider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class InverseProvider extends React.Component<{ value: T }> {
|
||||
componentDidMount() {
|
||||
Context._updateProviderValue?.(this.props.value);
|
||||
}
|
||||
componentDidUpdate() {
|
||||
Context._updateProviderValue?.(this.props.value);
|
||||
}
|
||||
render() {
|
||||
return <Context.Consumer>{() => this.props.children}</Context.Consumer>;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
Context,
|
||||
Consumer: InverseConsumer,
|
||||
Provider: InverseProvider,
|
||||
};
|
||||
};
|
@ -1,6 +1,12 @@
|
||||
@import "./_variables";
|
||||
@import "./variables.module";
|
||||
@import "./theme";
|
||||
|
||||
:root {
|
||||
--zIndex-canvas: 1;
|
||||
--zIndex-wysiwyg: 2;
|
||||
--zIndex-layerUI: 3;
|
||||
}
|
||||
|
||||
.excalidraw {
|
||||
color: var(--text-color-primary);
|
||||
display: flex;
|
||||
@ -13,7 +19,7 @@
|
||||
a {
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
color: $oc-blue-7; /* OC Blue 7 */
|
||||
color: var(--link-color);
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
@ -30,6 +36,8 @@
|
||||
image-rendering: pixelated; // chromium
|
||||
// NOTE: must be declared *after* the above
|
||||
image-rendering: -moz-crisp-edges; // FF
|
||||
|
||||
z-index: var(--zIndex-canvas);
|
||||
}
|
||||
|
||||
&.Appearance_dark {
|
||||
@ -216,6 +224,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.App-top-bar {
|
||||
z-index: var(--zIndex-layerUI);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.App-bottom-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@ -282,7 +296,7 @@
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.App-menu_top > * {
|
||||
.layer-ui__wrapper:not(.disable-pointerEvents) .App-menu_top > * {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
@ -323,7 +337,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.App-menu_bottom > * {
|
||||
.layer-ui__wrapper:not(.disable-pointerEvents) .App-menu_bottom > * {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
@ -431,6 +445,7 @@
|
||||
cursor: pointer;
|
||||
fill: $oc-gray-6;
|
||||
bottom: 14px;
|
||||
width: 1.5rem;
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
right: 14px;
|
||||
@ -491,6 +506,13 @@
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
&.excalidraw--view-mode {
|
||||
.App-menu {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
.App-bottom-bar,
|
||||
.FixedSideContainer,
|
||||
|
@ -32,6 +32,7 @@
|
||||
--popup-text-color: #{$oc-black};
|
||||
--popup-text-inverted-color: #{$oc-white};
|
||||
--dialog-border: #{$oc-gray-6};
|
||||
--link-color: #{$oc-blue-7};
|
||||
}
|
||||
|
||||
.excalidraw {
|
||||
|
@ -2,3 +2,7 @@
|
||||
|
||||
// keep up to date with is-mobile.tsx
|
||||
$is-mobile-query: "(max-width: 600px), (max-height: 500px) and (max-width: 1000px)";
|
||||
|
||||
:export {
|
||||
isMobileQuery: unquote($is-mobile-query);
|
||||
}
|
@ -1,4 +1,3 @@
|
||||
import { EVENT_IO, trackEvent } from "../analytics";
|
||||
import { cleanAppStateForExport } from "../appState";
|
||||
import { MIME_TYPES } from "../constants";
|
||||
import { clearElementsForExport } from "../element";
|
||||
@ -111,7 +110,6 @@ export const loadFromBlob = async (
|
||||
localAppState,
|
||||
);
|
||||
|
||||
trackEvent(EVENT_IO, "load", getMimeType(blob));
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { fileSave } from "browser-nativefs";
|
||||
import { EVENT_IO, trackEvent } from "../analytics";
|
||||
import { fileSave } from "browser-fs-access";
|
||||
import {
|
||||
copyCanvasToClipboardAsPng,
|
||||
copyTextToSystemClipboard,
|
||||
@ -8,8 +7,8 @@ import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { exportToCanvas, exportToSvg } from "../scene/export";
|
||||
import { ExportType } from "../scene/types";
|
||||
import { canvasToBlob } from "./blob";
|
||||
import { AppState } from "../types";
|
||||
import { canvasToBlob } from "./blob";
|
||||
import { serializeAsJSON } from "./json";
|
||||
|
||||
export { loadFromBlob } from "./blob";
|
||||
@ -37,7 +36,7 @@ export const exportCanvas = async (
|
||||
},
|
||||
) => {
|
||||
if (elements.length === 0) {
|
||||
return window.alert(t("alerts.cannotExportEmptyCanvas"));
|
||||
throw new Error(t("alerts.cannotExportEmptyCanvas"));
|
||||
}
|
||||
if (type === "svg" || type === "clipboard-svg") {
|
||||
const tempSvg = exportToSvg(elements, {
|
||||
@ -60,10 +59,8 @@ export const exportCanvas = async (
|
||||
fileName: `${name}.svg`,
|
||||
extensions: [".svg"],
|
||||
});
|
||||
trackEvent(EVENT_IO, "export", "svg");
|
||||
return;
|
||||
} else if (type === "clipboard-svg") {
|
||||
trackEvent(EVENT_IO, "export", "clipboard-svg");
|
||||
copyTextToSystemClipboard(tempSvg.outerHTML);
|
||||
return;
|
||||
}
|
||||
@ -95,11 +92,9 @@ export const exportCanvas = async (
|
||||
fileName,
|
||||
extensions: [".png"],
|
||||
});
|
||||
trackEvent(EVENT_IO, "export", "png");
|
||||
} else if (type === "clipboard") {
|
||||
try {
|
||||
await copyCanvasToClipboardAsPng(tempCanvas);
|
||||
trackEvent(EVENT_IO, "export", "clipboard-png");
|
||||
} catch (error) {
|
||||
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
|
||||
throw error;
|
||||
|
@ -1,13 +1,11 @@
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { fileOpen, fileSave } from "browser-fs-access";
|
||||
import { cleanAppStateForExport } from "../appState";
|
||||
|
||||
import { fileOpen, fileSave } from "browser-nativefs";
|
||||
import { loadFromBlob } from "./blob";
|
||||
import { Library } from "./library";
|
||||
import { MIME_TYPES } from "../constants";
|
||||
import { clearElementsForExport } from "../element";
|
||||
import { EVENT_LIBRARY, trackEvent } from "../analytics";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { loadFromBlob } from "./blob";
|
||||
import { Library } from "./library";
|
||||
|
||||
export const serializeAsJSON = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
@ -84,7 +82,6 @@ export const saveLibraryAsJSON = async () => {
|
||||
description: "Excalidraw library file",
|
||||
extensions: [".excalidrawlib"],
|
||||
});
|
||||
trackEvent(EVENT_LIBRARY, "save");
|
||||
};
|
||||
|
||||
export const importLibraryFromJSON = async () => {
|
||||
@ -93,6 +90,5 @@ export const importLibraryFromJSON = async () => {
|
||||
extensions: [".json", ".excalidrawlib"],
|
||||
mimeTypes: ["application/json"],
|
||||
});
|
||||
trackEvent(EVENT_LIBRARY, "load");
|
||||
Library.importLibrary(blob);
|
||||
};
|
||||
|
106
src/data/pixelated-image.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { ExcalidrawGenericElement, NonDeleted } from "../element/types";
|
||||
import { newElement } from "../element";
|
||||
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "../constants";
|
||||
import { randomId } from "../random";
|
||||
|
||||
const loadImage = async (url: string): Promise<HTMLImageElement> => {
|
||||
const image = new Image();
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = (err) =>
|
||||
reject(
|
||||
new Error(
|
||||
`Failed to load image: ${err ? err.toString : "unknown error"}`,
|
||||
),
|
||||
);
|
||||
image.onabort = () =>
|
||||
reject(new Error(`Failed to load image: image load aborted`));
|
||||
image.src = url;
|
||||
});
|
||||
};
|
||||
|
||||
const commonProps = {
|
||||
fillStyle: "solid",
|
||||
fontFamily: DEFAULT_FONT_FAMILY,
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
opacity: 100,
|
||||
roughness: 1,
|
||||
strokeColor: "transparent",
|
||||
strokeSharpness: "sharp",
|
||||
strokeStyle: "solid",
|
||||
strokeWidth: 1,
|
||||
verticalAlign: "middle",
|
||||
} as const;
|
||||
|
||||
export const pixelateImage = async (
|
||||
blob: Blob,
|
||||
cellSize: number,
|
||||
suggestedMaxShapeCount: number,
|
||||
x: number,
|
||||
y: number,
|
||||
) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const image = await loadImage(url);
|
||||
|
||||
// initialize canvas for pixelation
|
||||
const { width, height } = image;
|
||||
let canvasWidth = Math.floor(width / cellSize);
|
||||
let canvasHeight = Math.floor(height / cellSize);
|
||||
const shapeCount = canvasHeight * canvasWidth;
|
||||
if (shapeCount > suggestedMaxShapeCount) {
|
||||
canvasWidth = Math.floor(
|
||||
canvasWidth * (suggestedMaxShapeCount / shapeCount),
|
||||
);
|
||||
canvasHeight = Math.floor(
|
||||
canvasHeight * (suggestedMaxShapeCount / shapeCount),
|
||||
);
|
||||
}
|
||||
const xOffset = x - (canvasWidth * cellSize) / 2;
|
||||
const yOffset = y - (canvasHeight * cellSize) / 2;
|
||||
|
||||
const canvas =
|
||||
"OffscreenCanvas" in window
|
||||
? new OffscreenCanvas(canvasWidth, canvasHeight)
|
||||
: document.createElement("canvas");
|
||||
canvas.width = canvasWidth;
|
||||
canvas.height = canvasHeight;
|
||||
|
||||
// Draw image on canvas
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(image, 0, 0, width, height, 0, 0, canvasWidth, canvasHeight);
|
||||
const imageData = ctx.getImageData(0, 0, canvasWidth, canvasHeight);
|
||||
const buffer = imageData.data;
|
||||
|
||||
const groupId = randomId();
|
||||
const shapes: NonDeleted<ExcalidrawGenericElement>[] = [];
|
||||
|
||||
for (let row = 0; row < canvasHeight; row++) {
|
||||
for (let col = 0; col < canvasWidth; col++) {
|
||||
const offset = row * canvasWidth * 4 + col * 4;
|
||||
const r = buffer[offset];
|
||||
const g = buffer[offset + 1];
|
||||
const b = buffer[offset + 2];
|
||||
const alpha = buffer[offset + 3];
|
||||
if (alpha) {
|
||||
const color = `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
const rectangle = newElement({
|
||||
backgroundColor: color,
|
||||
groupIds: [groupId],
|
||||
...commonProps,
|
||||
type: "rectangle",
|
||||
x: xOffset + col * cellSize,
|
||||
y: yOffset + row * cellSize,
|
||||
width: cellSize,
|
||||
height: cellSize,
|
||||
});
|
||||
shapes.push(rectangle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return shapes;
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
};
|
@ -15,7 +15,6 @@ import {
|
||||
DEFAULT_VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { getNewSceneName } from "../utils";
|
||||
|
||||
const getFontFamilyByName = (fontFamilyName: string): FontFamily => {
|
||||
for (const [id, fontFamilyString] of Object.entries(FONT_FAMILY)) {
|
||||
@ -167,7 +166,6 @@ const restoreAppState = (
|
||||
|
||||
return {
|
||||
...nextAppState,
|
||||
name: appState.name ?? localAppState?.name ?? getNewSceneName(),
|
||||
offsetLeft: appState.offsetLeft || 0,
|
||||
offsetTop: appState.offsetTop || 0,
|
||||
// Migrates from previous version where appState.zoom was a number
|
||||
|
@ -34,7 +34,6 @@ export {
|
||||
export {
|
||||
resizeTest,
|
||||
getCursorForResizingElement,
|
||||
normalizeTransformHandleType,
|
||||
getElementWithTransformHandleType,
|
||||
getTransformHandleTypeFromCoords,
|
||||
} from "./resizeTest";
|
||||
|
@ -4,7 +4,6 @@ import { rescalePoints } from "../points";
|
||||
import {
|
||||
rotate,
|
||||
adjustXYWithRotation,
|
||||
getFlipAdjustment,
|
||||
centerPoint,
|
||||
rotatePoint,
|
||||
} from "../math";
|
||||
@ -13,21 +12,16 @@ import {
|
||||
ExcalidrawTextElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeleted,
|
||||
ExcalidrawGenericElement,
|
||||
ExcalidrawElement,
|
||||
} from "./types";
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
getCommonBounds,
|
||||
getResizedElementAbsoluteCoords,
|
||||
} from "./bounds";
|
||||
import { isGenericElement, isLinearElement, isTextElement } from "./typeChecks";
|
||||
import { isLinearElement, isTextElement } from "./typeChecks";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { getPerfectElementSize } from "./sizeHelpers";
|
||||
import {
|
||||
getCursorForResizingElement,
|
||||
normalizeTransformHandleType,
|
||||
} from "./resizeTest";
|
||||
import { getCursorForResizingElement } from "./resizeTest";
|
||||
import { measureText, getFontString } from "../utils";
|
||||
import { updateBoundElements } from "./binding";
|
||||
import {
|
||||
@ -49,7 +43,6 @@ const normalizeAngle = (angle: number): number => {
|
||||
export const transformElements = (
|
||||
pointerDownState: PointerDownState,
|
||||
transformHandleType: MaybeTransformHandleType,
|
||||
setTransformHandle: (nextTransformHandle: MaybeTransformHandleType) => void,
|
||||
selectedElements: readonly NonDeletedExcalidrawElement[],
|
||||
resizeArrowDirection: "origin" | "end",
|
||||
isRotateWithDiscreteAngle: boolean,
|
||||
@ -101,36 +94,15 @@ export const transformElements = (
|
||||
);
|
||||
updateBoundElements(element);
|
||||
} else if (transformHandleType) {
|
||||
if (isGenericElement(element)) {
|
||||
resizeSingleGenericElement(
|
||||
pointerDownState.originalElements.get(element.id) as typeof element,
|
||||
shouldKeepSidesRatio,
|
||||
element,
|
||||
transformHandleType,
|
||||
isResizeCenterPoint,
|
||||
pointerX,
|
||||
pointerY,
|
||||
);
|
||||
} else {
|
||||
const keepSquareAspectRatio = shouldKeepSidesRatio;
|
||||
resizeSingleNonGenericElement(
|
||||
element,
|
||||
transformHandleType,
|
||||
isResizeCenterPoint,
|
||||
keepSquareAspectRatio,
|
||||
pointerX,
|
||||
pointerY,
|
||||
);
|
||||
setTransformHandle(
|
||||
normalizeTransformHandleType(element, transformHandleType),
|
||||
);
|
||||
if (element.width < 0) {
|
||||
mutateElement(element, { width: -element.width });
|
||||
}
|
||||
if (element.height < 0) {
|
||||
mutateElement(element, { height: -element.height });
|
||||
}
|
||||
}
|
||||
resizeSingleElement(
|
||||
pointerDownState.originalElements.get(element.id) as typeof element,
|
||||
shouldKeepSidesRatio,
|
||||
element,
|
||||
transformHandleType,
|
||||
isResizeCenterPoint,
|
||||
pointerX,
|
||||
pointerY,
|
||||
);
|
||||
}
|
||||
|
||||
// update cursor
|
||||
@ -414,8 +386,8 @@ const resizeSingleTextElement = (
|
||||
}
|
||||
};
|
||||
|
||||
const resizeSingleGenericElement = (
|
||||
stateAtResizeStart: NonDeleted<ExcalidrawGenericElement>,
|
||||
const resizeSingleElement = (
|
||||
stateAtResizeStart: NonDeletedExcalidrawElement,
|
||||
shouldKeepSidesRatio: boolean,
|
||||
element: NonDeletedExcalidrawElement,
|
||||
transformHandleDirection: TransformHandleDirection,
|
||||
@ -423,251 +395,184 @@ const resizeSingleGenericElement = (
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(stateAtResizeStart);
|
||||
// Gets bounds corners
|
||||
const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
|
||||
stateAtResizeStart,
|
||||
stateAtResizeStart.width,
|
||||
stateAtResizeStart.height,
|
||||
);
|
||||
const startTopLeft: Point = [x1, y1];
|
||||
const startBottomRight: Point = [x2, y2];
|
||||
const startCenter: Point = centerPoint(startTopLeft, startBottomRight);
|
||||
|
||||
// Calculate new dimensions based on cursor position
|
||||
let newWidth = stateAtResizeStart.width;
|
||||
let newHeight = stateAtResizeStart.height;
|
||||
const rotatedPointer = rotatePoint(
|
||||
[pointerX, pointerY],
|
||||
startCenter,
|
||||
-stateAtResizeStart.angle,
|
||||
);
|
||||
|
||||
//Get bounds corners rendered on screen
|
||||
const [esx1, esy1, esx2, esy2] = getResizedElementAbsoluteCoords(
|
||||
element,
|
||||
element.width,
|
||||
element.height,
|
||||
);
|
||||
const boundsCurrentWidth = esx2 - esx1;
|
||||
const boundsCurrentHeight = esy2 - esy1;
|
||||
|
||||
// It's important we set the initial scale value based on the width and height at resize start,
|
||||
// otherwise previous dimensions affected by modifiers will be taken into account.
|
||||
const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0];
|
||||
const atStartBoundsHeight = startBottomRight[1] - startTopLeft[1];
|
||||
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
|
||||
let scaleY = atStartBoundsHeight / boundsCurrentHeight;
|
||||
|
||||
if (transformHandleDirection.includes("e")) {
|
||||
newWidth = rotatedPointer[0] - startTopLeft[0];
|
||||
scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth;
|
||||
}
|
||||
if (transformHandleDirection.includes("s")) {
|
||||
newHeight = rotatedPointer[1] - startTopLeft[1];
|
||||
scaleY = (rotatedPointer[1] - startTopLeft[1]) / boundsCurrentHeight;
|
||||
}
|
||||
if (transformHandleDirection.includes("w")) {
|
||||
newWidth = startBottomRight[0] - rotatedPointer[0];
|
||||
scaleX = (startBottomRight[0] - rotatedPointer[0]) / boundsCurrentWidth;
|
||||
}
|
||||
if (transformHandleDirection.includes("n")) {
|
||||
newHeight = startBottomRight[1] - rotatedPointer[1];
|
||||
scaleY = (startBottomRight[1] - rotatedPointer[1]) / boundsCurrentHeight;
|
||||
}
|
||||
// Linear elements dimensions differ from bounds dimensions
|
||||
const eleInitialWidth = stateAtResizeStart.width;
|
||||
const eleInitialHeight = stateAtResizeStart.height;
|
||||
// We have to use dimensions of element on screen, otherwise the scaling of the
|
||||
// dimensions won't match the cursor for linear elements.
|
||||
let eleNewWidth = element.width * scaleX;
|
||||
let eleNewHeight = element.height * scaleY;
|
||||
|
||||
// adjust dimensions for resizing from center
|
||||
if (isResizeFromCenter) {
|
||||
newWidth = 2 * newWidth - stateAtResizeStart.width;
|
||||
newHeight = 2 * newHeight - stateAtResizeStart.height;
|
||||
eleNewWidth = 2 * eleNewWidth - eleInitialWidth;
|
||||
eleNewHeight = 2 * eleNewHeight - eleInitialHeight;
|
||||
}
|
||||
|
||||
// adjust dimensions to keep sides ratio
|
||||
if (shouldKeepSidesRatio) {
|
||||
const widthRatio = Math.abs(newWidth) / stateAtResizeStart.width;
|
||||
const heightRatio = Math.abs(newHeight) / stateAtResizeStart.height;
|
||||
const widthRatio = Math.abs(eleNewWidth) / eleInitialWidth;
|
||||
const heightRatio = Math.abs(eleNewHeight) / eleInitialHeight;
|
||||
if (transformHandleDirection.length === 1) {
|
||||
newHeight *= widthRatio;
|
||||
newWidth *= heightRatio;
|
||||
eleNewHeight *= widthRatio;
|
||||
eleNewWidth *= heightRatio;
|
||||
}
|
||||
if (transformHandleDirection.length === 2) {
|
||||
const ratio = Math.max(widthRatio, heightRatio);
|
||||
newWidth = stateAtResizeStart.width * ratio * Math.sign(newWidth);
|
||||
newHeight = stateAtResizeStart.height * ratio * Math.sign(newHeight);
|
||||
eleNewWidth = eleInitialWidth * ratio * Math.sign(eleNewWidth);
|
||||
eleNewHeight = eleInitialHeight * ratio * Math.sign(eleNewHeight);
|
||||
}
|
||||
}
|
||||
|
||||
const [
|
||||
newBoundsX1,
|
||||
newBoundsY1,
|
||||
newBoundsX2,
|
||||
newBoundsY2,
|
||||
] = getResizedElementAbsoluteCoords(
|
||||
stateAtResizeStart,
|
||||
eleNewWidth,
|
||||
eleNewHeight,
|
||||
);
|
||||
const newBoundsWidth = newBoundsX2 - newBoundsX1;
|
||||
const newBoundsHeight = newBoundsY2 - newBoundsY1;
|
||||
|
||||
// Calculate new topLeft based on fixed corner during resize
|
||||
let newTopLeft = startTopLeft as [number, number];
|
||||
let newTopLeft = [...startTopLeft] as [number, number];
|
||||
if (["n", "w", "nw"].includes(transformHandleDirection)) {
|
||||
newTopLeft = [
|
||||
startBottomRight[0] - Math.abs(newWidth),
|
||||
startBottomRight[1] - Math.abs(newHeight),
|
||||
startBottomRight[0] - Math.abs(newBoundsWidth),
|
||||
startBottomRight[1] - Math.abs(newBoundsHeight),
|
||||
];
|
||||
}
|
||||
if (transformHandleDirection === "ne") {
|
||||
const bottomLeft = [
|
||||
stateAtResizeStart.x,
|
||||
stateAtResizeStart.y + stateAtResizeStart.height,
|
||||
];
|
||||
newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(newHeight)];
|
||||
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
|
||||
newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(newBoundsHeight)];
|
||||
}
|
||||
if (transformHandleDirection === "sw") {
|
||||
const topRight = [
|
||||
stateAtResizeStart.x + stateAtResizeStart.width,
|
||||
stateAtResizeStart.y,
|
||||
];
|
||||
newTopLeft = [topRight[0] - Math.abs(newWidth), topRight[1]];
|
||||
const topRight = [startBottomRight[0], startTopLeft[1]];
|
||||
newTopLeft = [topRight[0] - Math.abs(newBoundsWidth), topRight[1]];
|
||||
}
|
||||
|
||||
// Keeps opposite handle fixed during resize
|
||||
if (shouldKeepSidesRatio) {
|
||||
if (["s", "n"].includes(transformHandleDirection)) {
|
||||
newTopLeft[0] = startCenter[0] - newWidth / 2;
|
||||
newTopLeft[0] = startCenter[0] - newBoundsWidth / 2;
|
||||
}
|
||||
if (["e", "w"].includes(transformHandleDirection)) {
|
||||
newTopLeft[1] = startCenter[1] - newHeight / 2;
|
||||
newTopLeft[1] = startCenter[1] - newBoundsHeight / 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Flip horizontally
|
||||
if (newWidth < 0) {
|
||||
if (eleNewWidth < 0) {
|
||||
if (transformHandleDirection.includes("e")) {
|
||||
newTopLeft[0] -= Math.abs(newWidth);
|
||||
newTopLeft[0] -= Math.abs(newBoundsWidth);
|
||||
}
|
||||
if (transformHandleDirection.includes("w")) {
|
||||
newTopLeft[0] += Math.abs(newWidth);
|
||||
newTopLeft[0] += Math.abs(newBoundsWidth);
|
||||
}
|
||||
}
|
||||
// Flip vertically
|
||||
if (newHeight < 0) {
|
||||
if (eleNewHeight < 0) {
|
||||
if (transformHandleDirection.includes("s")) {
|
||||
newTopLeft[1] -= Math.abs(newHeight);
|
||||
newTopLeft[1] -= Math.abs(newBoundsHeight);
|
||||
}
|
||||
if (transformHandleDirection.includes("n")) {
|
||||
newTopLeft[1] += Math.abs(newHeight);
|
||||
newTopLeft[1] += Math.abs(newBoundsHeight);
|
||||
}
|
||||
}
|
||||
|
||||
if (isResizeFromCenter) {
|
||||
newTopLeft[0] = startCenter[0] - Math.abs(newWidth) / 2;
|
||||
newTopLeft[1] = startCenter[1] - Math.abs(newHeight) / 2;
|
||||
newTopLeft[0] = startCenter[0] - Math.abs(newBoundsWidth) / 2;
|
||||
newTopLeft[1] = startCenter[1] - Math.abs(newBoundsHeight) / 2;
|
||||
}
|
||||
|
||||
// adjust topLeft to new rotation point
|
||||
const angle = stateAtResizeStart.angle;
|
||||
const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle);
|
||||
const newCenter: Point = [
|
||||
newTopLeft[0] + Math.abs(newWidth) / 2,
|
||||
newTopLeft[1] + Math.abs(newHeight) / 2,
|
||||
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
|
||||
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
|
||||
];
|
||||
const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle);
|
||||
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
|
||||
|
||||
// Readjust points for linear elements
|
||||
const rescaledPoints = rescalePointsInElement(
|
||||
stateAtResizeStart,
|
||||
eleNewWidth,
|
||||
eleNewHeight,
|
||||
);
|
||||
// For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
|
||||
// So we need to readjust (x,y) to be where the first point should be
|
||||
const newOrigin = [...newTopLeft];
|
||||
newOrigin[0] += stateAtResizeStart.x - newBoundsX1;
|
||||
newOrigin[1] += stateAtResizeStart.y - newBoundsY1;
|
||||
|
||||
const resizedElement = {
|
||||
width: Math.abs(newWidth),
|
||||
height: Math.abs(newHeight),
|
||||
x: newTopLeft[0],
|
||||
y: newTopLeft[1],
|
||||
width: Math.abs(eleNewWidth),
|
||||
height: Math.abs(eleNewHeight),
|
||||
x: newOrigin[0],
|
||||
y: newOrigin[1],
|
||||
...rescaledPoints,
|
||||
};
|
||||
updateBoundElements(element, {
|
||||
newSize: { width: resizedElement.width, height: resizedElement.height },
|
||||
});
|
||||
mutateElement(element, resizedElement);
|
||||
};
|
||||
|
||||
const resizeSingleNonGenericElement = (
|
||||
element: NonDeleted<Exclude<ExcalidrawElement, ExcalidrawGenericElement>>,
|
||||
transformHandleType: "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se",
|
||||
isResizeFromCenter: boolean,
|
||||
keepSquareAspectRatio: boolean,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
|
||||
// rotation pointer with reverse angle
|
||||
const [rotatedX, rotatedY] = rotate(
|
||||
pointerX,
|
||||
pointerY,
|
||||
cx,
|
||||
cy,
|
||||
-element.angle,
|
||||
);
|
||||
|
||||
let scaleX = 1;
|
||||
let scaleY = 1;
|
||||
if (
|
||||
transformHandleType === "e" ||
|
||||
transformHandleType === "ne" ||
|
||||
transformHandleType === "se"
|
||||
) {
|
||||
scaleX = (rotatedX - x1) / (x2 - x1);
|
||||
}
|
||||
if (
|
||||
transformHandleType === "s" ||
|
||||
transformHandleType === "sw" ||
|
||||
transformHandleType === "se"
|
||||
) {
|
||||
scaleY = (rotatedY - y1) / (y2 - y1);
|
||||
}
|
||||
if (
|
||||
transformHandleType === "w" ||
|
||||
transformHandleType === "nw" ||
|
||||
transformHandleType === "sw"
|
||||
) {
|
||||
scaleX = (x2 - rotatedX) / (x2 - x1);
|
||||
}
|
||||
if (
|
||||
transformHandleType === "n" ||
|
||||
transformHandleType === "nw" ||
|
||||
transformHandleType === "ne"
|
||||
) {
|
||||
scaleY = (y2 - rotatedY) / (y2 - y1);
|
||||
}
|
||||
let nextWidth = element.width * scaleX;
|
||||
let nextHeight = element.height * scaleY;
|
||||
if (keepSquareAspectRatio) {
|
||||
nextWidth = nextHeight = Math.max(nextWidth, nextHeight);
|
||||
}
|
||||
|
||||
const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
|
||||
element,
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
);
|
||||
const deltaX1 = (x1 - nextX1) / 2;
|
||||
const deltaY1 = (y1 - nextY1) / 2;
|
||||
const deltaX2 = (x2 - nextX2) / 2;
|
||||
const deltaY2 = (y2 - nextY2) / 2;
|
||||
|
||||
const rescaledPoints = rescalePointsInElement(element, nextWidth, nextHeight);
|
||||
|
||||
updateBoundElements(element, {
|
||||
newSize: { width: nextWidth, height: nextHeight },
|
||||
});
|
||||
const [finalX1, finalY1, finalX2, finalY2] = getResizedElementAbsoluteCoords(
|
||||
{
|
||||
...element,
|
||||
...rescaledPoints,
|
||||
},
|
||||
Math.abs(nextWidth),
|
||||
Math.abs(nextHeight),
|
||||
);
|
||||
const [flipDiffX, flipDiffY] = getFlipAdjustment(
|
||||
transformHandleType,
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
nextX1,
|
||||
nextY1,
|
||||
nextX2,
|
||||
nextY2,
|
||||
finalX1,
|
||||
finalY1,
|
||||
finalX2,
|
||||
finalY2,
|
||||
isLinearElement(element),
|
||||
element.angle,
|
||||
);
|
||||
const [nextElementX, nextElementY] = adjustXYWithRotation(
|
||||
getSidesForTransformHandle(transformHandleType, isResizeFromCenter),
|
||||
element.x - flipDiffX,
|
||||
element.y - flipDiffY,
|
||||
element.angle,
|
||||
deltaX1,
|
||||
deltaY1,
|
||||
deltaX2,
|
||||
deltaY2,
|
||||
);
|
||||
|
||||
if (
|
||||
nextWidth !== 0 &&
|
||||
nextHeight !== 0 &&
|
||||
Number.isFinite(nextElementX) &&
|
||||
Number.isFinite(nextElementY)
|
||||
resizedElement.width !== 0 &&
|
||||
resizedElement.height !== 0 &&
|
||||
Number.isFinite(resizedElement.x) &&
|
||||
Number.isFinite(resizedElement.y)
|
||||
) {
|
||||
mutateElement(element, {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
x: nextElementX,
|
||||
y: nextElementY,
|
||||
...rescaledPoints,
|
||||
updateBoundElements(element, {
|
||||
newSize: { width: resizedElement.width, height: resizedElement.height },
|
||||
});
|
||||
mutateElement(element, resizedElement);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -173,57 +173,3 @@ export const getCursorForResizingElement = (resizingElement: {
|
||||
|
||||
return cursor ? `${cursor}-resize` : "";
|
||||
};
|
||||
|
||||
export const normalizeTransformHandleType = (
|
||||
element: ExcalidrawElement,
|
||||
transformHandleType: TransformHandleType,
|
||||
): TransformHandleType => {
|
||||
if (element.width >= 0 && element.height >= 0) {
|
||||
return transformHandleType;
|
||||
}
|
||||
|
||||
if (element.width < 0 && element.height < 0) {
|
||||
switch (transformHandleType) {
|
||||
case "nw":
|
||||
return "se";
|
||||
case "ne":
|
||||
return "sw";
|
||||
case "se":
|
||||
return "nw";
|
||||
case "sw":
|
||||
return "ne";
|
||||
}
|
||||
} else if (element.width < 0) {
|
||||
switch (transformHandleType) {
|
||||
case "nw":
|
||||
return "ne";
|
||||
case "ne":
|
||||
return "nw";
|
||||
case "se":
|
||||
return "sw";
|
||||
case "sw":
|
||||
return "se";
|
||||
case "e":
|
||||
return "w";
|
||||
case "w":
|
||||
return "e";
|
||||
}
|
||||
} else {
|
||||
switch (transformHandleType) {
|
||||
case "nw":
|
||||
return "sw";
|
||||
case "ne":
|
||||
return "se";
|
||||
case "se":
|
||||
return "ne";
|
||||
case "sw":
|
||||
return "nw";
|
||||
case "n":
|
||||
return "s";
|
||||
case "s":
|
||||
return "n";
|
||||
}
|
||||
}
|
||||
|
||||
return transformHandleType;
|
||||
};
|
||||
|
@ -7,7 +7,8 @@ export const showSelectedShapeActions = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
) =>
|
||||
Boolean(
|
||||
appState.editingElement ||
|
||||
getSelectedElements(elements, appState).length ||
|
||||
appState.elementType !== "selection",
|
||||
!appState.viewModeEnabled &&
|
||||
(appState.editingElement ||
|
||||
getSelectedElements(elements, appState).length ||
|
||||
appState.elementType !== "selection"),
|
||||
);
|
||||
|
@ -89,9 +89,6 @@ export const textWysiwyg = ({
|
||||
editable.dataset.type = "wysiwyg";
|
||||
// prevent line wrapping on Safari
|
||||
editable.wrap = "off";
|
||||
editable.className = `excalidraw ${
|
||||
appState.appearance === "dark" ? "Appearance_dark" : ""
|
||||
}`;
|
||||
|
||||
Object.assign(editable.style, {
|
||||
position: "fixed",
|
||||
@ -107,6 +104,8 @@ export const textWysiwyg = ({
|
||||
overflow: "hidden",
|
||||
// prevent line wrapping (`whitespace: nowrap` doesn't work on FF)
|
||||
whiteSpace: "pre",
|
||||
// must be specified because in dark mode canvas creates a stacking context
|
||||
zIndex: "var(--zIndex-wysiwyg)",
|
||||
});
|
||||
|
||||
updateWysiwygStyle();
|
||||
@ -160,7 +159,7 @@ export const textWysiwyg = ({
|
||||
|
||||
unbindUpdate();
|
||||
|
||||
document.body.removeChild(editable);
|
||||
editable.remove();
|
||||
};
|
||||
|
||||
const rebindBlur = () => {
|
||||
@ -206,7 +205,9 @@ export const textWysiwyg = ({
|
||||
passive: false,
|
||||
capture: true,
|
||||
});
|
||||
document.body.appendChild(editable);
|
||||
document
|
||||
.querySelector(".excalidraw-textEditorContainer")!
|
||||
.appendChild(editable);
|
||||
editable.focus();
|
||||
editable.select();
|
||||
};
|
||||
|
@ -1,54 +1,61 @@
|
||||
import React, { PureComponent } from "react";
|
||||
import throttle from "lodash.throttle";
|
||||
|
||||
import React, { PureComponent } from "react";
|
||||
import { ExcalidrawImperativeAPI } from "../../components/App";
|
||||
import { ErrorDialog } from "../../components/ErrorDialog";
|
||||
import { APP_NAME, ENV, EVENT } from "../../constants";
|
||||
|
||||
import {
|
||||
decryptAESGEM,
|
||||
SocketUpdateDataSource,
|
||||
getCollaborationLinkData,
|
||||
generateCollaborationLink,
|
||||
SOCKET_SERVER,
|
||||
} from "../data";
|
||||
import { isSavedToFirebase, saveToFirebase } from "../data/firebase";
|
||||
|
||||
import Portal from "./Portal";
|
||||
import { AppState, Collaborator, Gesture } from "../../types";
|
||||
import { ImportedDataState } from "../../data/types";
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import {
|
||||
importUsernameFromLocalStorage,
|
||||
saveUsernameToLocalStorage,
|
||||
STORAGE_KEYS,
|
||||
} from "../data/localStorage";
|
||||
import { resolvablePromise, withBatchedUpdates } from "../../utils";
|
||||
import {
|
||||
getElementMap,
|
||||
getSceneVersion,
|
||||
getSyncableElements,
|
||||
} from "../../packages/excalidraw/index";
|
||||
import RoomDialog from "./RoomDialog";
|
||||
import { ErrorDialog } from "../../components/ErrorDialog";
|
||||
import { ImportedDataState } from "../../data/types";
|
||||
import { ExcalidrawImperativeAPI } from "../../components/App";
|
||||
import { Collaborator, Gesture } from "../../types";
|
||||
import { resolvablePromise, withBatchedUpdates } from "../../utils";
|
||||
import {
|
||||
INITIAL_SCENE_UPDATE_TIMEOUT,
|
||||
SCENE,
|
||||
SYNC_FULL_SCENE_INTERVAL_MS,
|
||||
} from "../app_constants";
|
||||
import { EVENT_SHARE, trackEvent } from "../../analytics";
|
||||
import {
|
||||
decryptAESGEM,
|
||||
generateCollaborationLinkData,
|
||||
getCollaborationLink,
|
||||
SocketUpdateDataSource,
|
||||
SOCKET_SERVER,
|
||||
} from "../data";
|
||||
import {
|
||||
isSavedToFirebase,
|
||||
loadFromFirebase,
|
||||
saveToFirebase,
|
||||
} from "../data/firebase";
|
||||
import {
|
||||
importUsernameFromLocalStorage,
|
||||
saveUsernameToLocalStorage,
|
||||
STORAGE_KEYS,
|
||||
} from "../data/localStorage";
|
||||
import Portal from "./Portal";
|
||||
import RoomDialog from "./RoomDialog";
|
||||
import { createInverseContext } from "../../createInverseContext";
|
||||
import { t } from "../../i18n";
|
||||
import { UserIdleState } from "./types";
|
||||
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
|
||||
|
||||
interface CollabState {
|
||||
isCollaborating: boolean;
|
||||
modalIsShown: boolean;
|
||||
errorMessage: string;
|
||||
username: string;
|
||||
userState: UserIdleState;
|
||||
activeRoomLink: string;
|
||||
}
|
||||
|
||||
type CollabInstance = InstanceType<typeof CollabWrapper>;
|
||||
|
||||
export interface CollabAPI {
|
||||
isCollaborating: CollabState["isCollaborating"];
|
||||
/** function so that we can access the latest value from stale callbacks */
|
||||
isCollaborating: () => boolean;
|
||||
username: CollabState["username"];
|
||||
userState: CollabState["userState"];
|
||||
onPointerUpdate: CollabInstance["onPointerUpdate"];
|
||||
initializeSocketClient: CollabInstance["initializeSocketClient"];
|
||||
onCollabButtonClick: CollabInstance["onCollabButtonClick"];
|
||||
@ -60,31 +67,41 @@ type ReconciledElements = readonly ExcalidrawElement[] & {
|
||||
};
|
||||
|
||||
interface Props {
|
||||
children: (collab: CollabAPI) => React.ReactNode;
|
||||
// NOTE not type-safe because the refObject may in fact not be initialized
|
||||
// with ExcalidrawImperativeAPI yet
|
||||
excalidrawRef: React.MutableRefObject<ExcalidrawImperativeAPI>;
|
||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
}
|
||||
|
||||
const {
|
||||
Context: CollabContext,
|
||||
Consumer: CollabContextConsumer,
|
||||
Provider: CollabContextProvider,
|
||||
} = createInverseContext<{ api: CollabAPI | null }>({ api: null });
|
||||
|
||||
export { CollabContext, CollabContextConsumer };
|
||||
|
||||
class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
portal: Portal;
|
||||
excalidrawAPI: Props["excalidrawAPI"];
|
||||
isCollaborating: boolean = false;
|
||||
activeIntervalId: number | null;
|
||||
idleTimeoutId: number | null;
|
||||
|
||||
private socketInitializationTimer?: NodeJS.Timeout;
|
||||
private excalidrawRef: Props["excalidrawRef"];
|
||||
excalidrawAppState?: AppState;
|
||||
private lastBroadcastedOrReceivedSceneVersion: number = -1;
|
||||
private collaborators = new Map<string, Collaborator>();
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isCollaborating: false,
|
||||
modalIsShown: false,
|
||||
errorMessage: "",
|
||||
username: importUsernameFromLocalStorage() || "",
|
||||
userState: UserIdleState.ACTIVE,
|
||||
activeRoomLink: "",
|
||||
};
|
||||
this.portal = new Portal(this);
|
||||
this.excalidrawRef = props.excalidrawRef;
|
||||
this.excalidrawAPI = props.excalidrawAPI;
|
||||
this.activeIntervalId = null;
|
||||
this.idleTimeoutId = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@ -108,18 +125,32 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
|
||||
window.removeEventListener(EVENT.UNLOAD, this.onUnload);
|
||||
window.removeEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
|
||||
window.removeEventListener(
|
||||
EVENT.VISIBILITY_CHANGE,
|
||||
this.onVisibilityChange,
|
||||
);
|
||||
if (this.activeIntervalId) {
|
||||
window.clearInterval(this.activeIntervalId);
|
||||
this.activeIntervalId = null;
|
||||
}
|
||||
if (this.idleTimeoutId) {
|
||||
window.clearTimeout(this.idleTimeoutId);
|
||||
this.idleTimeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private onUnload = () => {
|
||||
this.destroySocketClient();
|
||||
this.destroySocketClient({ isUnload: true });
|
||||
};
|
||||
|
||||
private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
|
||||
const syncableElements = getSyncableElements(
|
||||
this.getSceneElementsIncludingDeleted(),
|
||||
);
|
||||
|
||||
if (
|
||||
this.state.isCollaborating &&
|
||||
this.isCollaborating &&
|
||||
!isSavedToFirebase(this.portal, syncableElements)
|
||||
) {
|
||||
// this won't run in time if user decides to leave the site, but
|
||||
@ -131,7 +162,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
event.returnValue = "";
|
||||
}
|
||||
|
||||
if (this.state.isCollaborating || this.portal.roomId) {
|
||||
if (this.isCollaborating || this.portal.roomId) {
|
||||
try {
|
||||
localStorage?.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
|
||||
@ -146,7 +177,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
|
||||
saveCollabRoomToFirebase = async (
|
||||
syncableElements: ExcalidrawElement[] = getSyncableElements(
|
||||
this.excalidrawRef.current!.getSceneElementsIncludingDeleted(),
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
),
|
||||
) => {
|
||||
try {
|
||||
@ -157,145 +188,188 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
};
|
||||
|
||||
openPortal = async () => {
|
||||
window.history.pushState({}, APP_NAME, await generateCollaborationLink());
|
||||
const elements = this.excalidrawRef.current!.getSceneElements();
|
||||
// remove deleted elements from elements array & history to ensure we don't
|
||||
// expose potentially sensitive user data in case user manually deletes
|
||||
// existing elements (or clears scene), which would otherwise be persisted
|
||||
// to database even if deleted before creating the room.
|
||||
this.excalidrawRef.current!.history.clear();
|
||||
this.excalidrawRef.current!.updateScene({
|
||||
elements,
|
||||
commitToHistory: true,
|
||||
});
|
||||
trackEvent(EVENT_SHARE, "session start");
|
||||
return this.initializeSocketClient();
|
||||
return this.initializeSocketClient(null);
|
||||
};
|
||||
|
||||
closePortal = () => {
|
||||
this.saveCollabRoomToFirebase();
|
||||
window.history.pushState({}, APP_NAME, window.location.origin);
|
||||
this.destroySocketClient();
|
||||
trackEvent(EVENT_SHARE, "session end");
|
||||
if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
|
||||
window.history.pushState({}, APP_NAME, window.location.origin);
|
||||
this.destroySocketClient();
|
||||
}
|
||||
};
|
||||
|
||||
private destroySocketClient = () => {
|
||||
this.collaborators = new Map();
|
||||
this.excalidrawRef.current!.updateScene({
|
||||
collaborators: this.collaborators,
|
||||
});
|
||||
this.setState({
|
||||
isCollaborating: false,
|
||||
activeRoomLink: "",
|
||||
});
|
||||
private destroySocketClient = (opts?: { isUnload: boolean }) => {
|
||||
if (!opts?.isUnload) {
|
||||
this.collaborators = new Map();
|
||||
this.excalidrawAPI.updateScene({
|
||||
collaborators: this.collaborators,
|
||||
});
|
||||
this.setState({
|
||||
activeRoomLink: "",
|
||||
});
|
||||
this.isCollaborating = false;
|
||||
}
|
||||
this.portal.close();
|
||||
};
|
||||
|
||||
private initializeSocketClient = async (): Promise<ImportedDataState | null> => {
|
||||
private initializeSocketClient = async (
|
||||
existingRoomLinkData: null | { roomId: string; roomKey: string },
|
||||
): Promise<ImportedDataState | null> => {
|
||||
if (this.portal.socket) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scenePromise = resolvablePromise<ImportedDataState | null>();
|
||||
let roomId;
|
||||
let roomKey;
|
||||
|
||||
const roomMatch = getCollaborationLinkData(window.location.href);
|
||||
|
||||
if (roomMatch) {
|
||||
const roomId = roomMatch[1];
|
||||
const roomKey = roomMatch[2];
|
||||
|
||||
// fallback in case you're not alone in the room but still don't receive
|
||||
// initial SCENE_UPDATE message
|
||||
this.socketInitializationTimer = setTimeout(() => {
|
||||
this.initializeSocket();
|
||||
scenePromise.resolve(null);
|
||||
}, INITIAL_SCENE_UPDATE_TIMEOUT);
|
||||
|
||||
const { default: socketIOClient }: any = await import(
|
||||
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
|
||||
if (existingRoomLinkData) {
|
||||
({ roomId, roomKey } = existingRoomLinkData);
|
||||
} else {
|
||||
({ roomId, roomKey } = await generateCollaborationLinkData());
|
||||
window.history.pushState(
|
||||
{},
|
||||
APP_NAME,
|
||||
getCollaborationLink({ roomId, roomKey }),
|
||||
);
|
||||
|
||||
this.portal.open(socketIOClient(SOCKET_SERVER), roomId, roomKey);
|
||||
|
||||
// All socket listeners are moving to Portal
|
||||
this.portal.socket!.on(
|
||||
"client-broadcast",
|
||||
async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
|
||||
if (!this.portal.roomKey) {
|
||||
return;
|
||||
}
|
||||
const decryptedData = await decryptAESGEM(
|
||||
encryptedData,
|
||||
this.portal.roomKey,
|
||||
iv,
|
||||
);
|
||||
|
||||
switch (decryptedData.type) {
|
||||
case "INVALID_RESPONSE":
|
||||
return;
|
||||
case SCENE.INIT: {
|
||||
if (!this.portal.socketInitialized) {
|
||||
const remoteElements = decryptedData.payload.elements;
|
||||
const reconciledElements = this.reconcileElements(
|
||||
remoteElements,
|
||||
);
|
||||
this.handleRemoteSceneUpdate(reconciledElements, {
|
||||
init: true,
|
||||
});
|
||||
this.initializeSocket();
|
||||
scenePromise.resolve({ elements: reconciledElements });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SCENE.UPDATE:
|
||||
this.handleRemoteSceneUpdate(
|
||||
this.reconcileElements(decryptedData.payload.elements),
|
||||
);
|
||||
break;
|
||||
case "MOUSE_LOCATION": {
|
||||
const {
|
||||
pointer,
|
||||
button,
|
||||
username,
|
||||
selectedElementIds,
|
||||
} = decryptedData.payload;
|
||||
const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
|
||||
decryptedData.payload.socketId ||
|
||||
// @ts-ignore legacy, see #2094 (#2097)
|
||||
decryptedData.payload.socketID;
|
||||
|
||||
const collaborators = new Map(this.collaborators);
|
||||
const user = collaborators.get(socketId) || {}!;
|
||||
user.pointer = pointer;
|
||||
user.button = button;
|
||||
user.selectedElementIds = selectedElementIds;
|
||||
user.username = username;
|
||||
collaborators.set(socketId, user);
|
||||
this.excalidrawRef.current!.updateScene({
|
||||
collaborators,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
this.portal.socket!.on("first-in-room", () => {
|
||||
if (this.portal.socket) {
|
||||
this.portal.socket.off("first-in-room");
|
||||
}
|
||||
this.initializeSocket();
|
||||
scenePromise.resolve(null);
|
||||
});
|
||||
|
||||
this.setState({
|
||||
isCollaborating: true,
|
||||
activeRoomLink: window.location.href,
|
||||
});
|
||||
|
||||
return scenePromise;
|
||||
}
|
||||
|
||||
return null;
|
||||
const scenePromise = resolvablePromise<ImportedDataState | null>();
|
||||
|
||||
this.isCollaborating = true;
|
||||
|
||||
const { default: socketIOClient }: any = await import(
|
||||
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
|
||||
);
|
||||
|
||||
this.portal.open(socketIOClient(SOCKET_SERVER), roomId, roomKey);
|
||||
|
||||
if (existingRoomLinkData) {
|
||||
this.excalidrawAPI.resetScene();
|
||||
|
||||
try {
|
||||
const elements = await loadFromFirebase(
|
||||
roomId,
|
||||
roomKey,
|
||||
this.portal.socket,
|
||||
);
|
||||
if (elements) {
|
||||
scenePromise.resolve({
|
||||
elements,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// log the error and move on. other peers will sync us the scene.
|
||||
console.error(error);
|
||||
}
|
||||
} else {
|
||||
const elements = this.excalidrawAPI.getSceneElements();
|
||||
// remove deleted elements from elements array & history to ensure we don't
|
||||
// expose potentially sensitive user data in case user manually deletes
|
||||
// existing elements (or clears scene), which would otherwise be persisted
|
||||
// to database even if deleted before creating the room.
|
||||
this.excalidrawAPI.history.clear();
|
||||
this.excalidrawAPI.updateScene({
|
||||
elements,
|
||||
commitToHistory: true,
|
||||
});
|
||||
}
|
||||
|
||||
// fallback in case you're not alone in the room but still don't receive
|
||||
// initial SCENE_UPDATE message
|
||||
this.socketInitializationTimer = setTimeout(() => {
|
||||
this.initializeSocket();
|
||||
scenePromise.resolve(null);
|
||||
}, INITIAL_SCENE_UPDATE_TIMEOUT);
|
||||
|
||||
// All socket listeners are moving to Portal
|
||||
this.portal.socket!.on(
|
||||
"client-broadcast",
|
||||
async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
|
||||
if (!this.portal.roomKey) {
|
||||
return;
|
||||
}
|
||||
const decryptedData = await decryptAESGEM(
|
||||
encryptedData,
|
||||
this.portal.roomKey,
|
||||
iv,
|
||||
);
|
||||
|
||||
switch (decryptedData.type) {
|
||||
case "INVALID_RESPONSE":
|
||||
return;
|
||||
case SCENE.INIT: {
|
||||
if (!this.portal.socketInitialized) {
|
||||
this.initializeSocket();
|
||||
const remoteElements = decryptedData.payload.elements;
|
||||
const reconciledElements = this.reconcileElements(remoteElements);
|
||||
this.handleRemoteSceneUpdate(reconciledElements, {
|
||||
init: true,
|
||||
});
|
||||
// noop if already resolved via init from firebase
|
||||
scenePromise.resolve({ elements: reconciledElements });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SCENE.UPDATE:
|
||||
this.handleRemoteSceneUpdate(
|
||||
this.reconcileElements(decryptedData.payload.elements),
|
||||
);
|
||||
break;
|
||||
case "MOUSE_LOCATION": {
|
||||
const {
|
||||
pointer,
|
||||
button,
|
||||
username,
|
||||
selectedElementIds,
|
||||
} = decryptedData.payload;
|
||||
const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
|
||||
decryptedData.payload.socketId ||
|
||||
// @ts-ignore legacy, see #2094 (#2097)
|
||||
decryptedData.payload.socketID;
|
||||
|
||||
const collaborators = new Map(this.collaborators);
|
||||
const user = collaborators.get(socketId) || {}!;
|
||||
user.pointer = pointer;
|
||||
user.button = button;
|
||||
user.selectedElementIds = selectedElementIds;
|
||||
user.username = username;
|
||||
collaborators.set(socketId, user);
|
||||
this.excalidrawAPI.updateScene({
|
||||
collaborators,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "IDLE_STATUS": {
|
||||
const { userState, socketId, username } = decryptedData.payload;
|
||||
const collaborators = new Map(this.collaborators);
|
||||
const user = collaborators.get(socketId) || {}!;
|
||||
user.userState = userState;
|
||||
user.username = username;
|
||||
this.excalidrawAPI.updateScene({
|
||||
collaborators,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
this.portal.socket!.on("first-in-room", () => {
|
||||
if (this.portal.socket) {
|
||||
this.portal.socket.off("first-in-room");
|
||||
}
|
||||
this.initializeSocket();
|
||||
scenePromise.resolve(null);
|
||||
});
|
||||
|
||||
this.initializeIdleDetector();
|
||||
|
||||
this.setState({
|
||||
activeRoomLink: window.location.href,
|
||||
});
|
||||
|
||||
return scenePromise;
|
||||
};
|
||||
|
||||
private initializeSocket = () => {
|
||||
@ -306,12 +380,60 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
private reconcileElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): ReconciledElements => {
|
||||
const newElements = this.portal.reconcileElements(elements);
|
||||
const currentElements = this.getSceneElementsIncludingDeleted();
|
||||
// create a map of ids so we don't have to iterate
|
||||
// over the array more than once.
|
||||
const localElementMap = getElementMap(currentElements);
|
||||
|
||||
const appState = this.excalidrawAPI.getAppState();
|
||||
|
||||
// Reconcile
|
||||
const newElements: readonly ExcalidrawElement[] = elements
|
||||
.reduce((elements, element) => {
|
||||
// if the remote element references one that's currently
|
||||
// edited on local, skip it (it'll be added in the next step)
|
||||
if (
|
||||
element.id === appState.editingElement?.id ||
|
||||
element.id === appState.resizingElement?.id ||
|
||||
element.id === appState.draggingElement?.id
|
||||
) {
|
||||
return elements;
|
||||
}
|
||||
|
||||
if (
|
||||
localElementMap.hasOwnProperty(element.id) &&
|
||||
localElementMap[element.id].version > element.version
|
||||
) {
|
||||
elements.push(localElementMap[element.id]);
|
||||
delete localElementMap[element.id];
|
||||
} else if (
|
||||
localElementMap.hasOwnProperty(element.id) &&
|
||||
localElementMap[element.id].version === element.version &&
|
||||
localElementMap[element.id].versionNonce !== element.versionNonce
|
||||
) {
|
||||
// resolve conflicting edits deterministically by taking the one with the lowest versionNonce
|
||||
if (localElementMap[element.id].versionNonce < element.versionNonce) {
|
||||
elements.push(localElementMap[element.id]);
|
||||
} else {
|
||||
// it should be highly unlikely that the two versionNonces are the same. if we are
|
||||
// really worried about this, we can replace the versionNonce with the socket id.
|
||||
elements.push(element);
|
||||
}
|
||||
delete localElementMap[element.id];
|
||||
} else {
|
||||
elements.push(element);
|
||||
delete localElementMap[element.id];
|
||||
}
|
||||
|
||||
return elements;
|
||||
}, [] as Mutable<typeof elements>)
|
||||
// add local elements that weren't deleted or on remote
|
||||
.concat(...Object.values(localElementMap));
|
||||
|
||||
// Avoid broadcasting to the rest of the collaborators the scene
|
||||
// we just received!
|
||||
// Note: this needs to be set before updating the scene as it
|
||||
// syncronously calls render.
|
||||
// synchronously calls render.
|
||||
this.setLastBroadcastedOrReceivedSceneVersion(getSceneVersion(newElements));
|
||||
|
||||
return newElements as ReconciledElements;
|
||||
@ -325,10 +447,10 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
}: { init?: boolean; initFromSnapshot?: boolean } = {},
|
||||
) => {
|
||||
if (init || initFromSnapshot) {
|
||||
this.excalidrawRef.current!.setScrollToCenter(elements);
|
||||
this.excalidrawAPI.setScrollToCenter(elements);
|
||||
}
|
||||
|
||||
this.excalidrawRef.current!.updateScene({
|
||||
this.excalidrawAPI.updateScene({
|
||||
elements,
|
||||
commitToHistory: !!init,
|
||||
});
|
||||
@ -337,7 +459,59 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
// when we receive any messages from another peer. This UX can be pretty rough -- if you
|
||||
// undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
|
||||
// right now we think this is the right tradeoff.
|
||||
this.excalidrawRef.current!.history.clear();
|
||||
this.excalidrawAPI.history.clear();
|
||||
};
|
||||
|
||||
private onPointerMove = () => {
|
||||
if (this.idleTimeoutId) {
|
||||
window.clearTimeout(this.idleTimeoutId);
|
||||
this.idleTimeoutId = null;
|
||||
}
|
||||
this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD);
|
||||
if (!this.activeIntervalId) {
|
||||
this.activeIntervalId = window.setInterval(
|
||||
this.reportActive,
|
||||
ACTIVE_THRESHOLD,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
private onVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
if (this.idleTimeoutId) {
|
||||
window.clearTimeout(this.idleTimeoutId);
|
||||
this.idleTimeoutId = null;
|
||||
}
|
||||
if (this.activeIntervalId) {
|
||||
window.clearInterval(this.activeIntervalId);
|
||||
this.activeIntervalId = null;
|
||||
}
|
||||
this.onIdleStateChange(UserIdleState.AWAY);
|
||||
} else {
|
||||
this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD);
|
||||
this.activeIntervalId = window.setInterval(
|
||||
this.reportActive,
|
||||
ACTIVE_THRESHOLD,
|
||||
);
|
||||
this.onIdleStateChange(UserIdleState.ACTIVE);
|
||||
}
|
||||
};
|
||||
|
||||
private reportIdle = () => {
|
||||
this.onIdleStateChange(UserIdleState.IDLE);
|
||||
if (this.activeIntervalId) {
|
||||
window.clearInterval(this.activeIntervalId);
|
||||
this.activeIntervalId = null;
|
||||
}
|
||||
};
|
||||
|
||||
private reportActive = () => {
|
||||
this.onIdleStateChange(UserIdleState.ACTIVE);
|
||||
};
|
||||
|
||||
private initializeIdleDetector = () => {
|
||||
document.addEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
|
||||
document.addEventListener(EVENT.VISIBILITY_CHANGE, this.onVisibilityChange);
|
||||
};
|
||||
|
||||
setCollaborators(sockets: string[]) {
|
||||
@ -353,7 +527,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
}
|
||||
}
|
||||
this.collaborators = collaborators;
|
||||
this.excalidrawRef.current!.updateScene({ collaborators });
|
||||
this.excalidrawAPI.updateScene({ collaborators });
|
||||
});
|
||||
}
|
||||
|
||||
@ -366,7 +540,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
};
|
||||
|
||||
public getSceneElementsIncludingDeleted = () => {
|
||||
return this.excalidrawRef.current!.getSceneElementsIncludingDeleted();
|
||||
return this.excalidrawAPI.getSceneElementsIncludingDeleted();
|
||||
};
|
||||
|
||||
onPointerUpdate = (payload: {
|
||||
@ -379,11 +553,12 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
this.portal.broadcastMouseLocation(payload);
|
||||
};
|
||||
|
||||
broadcastElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
state: AppState,
|
||||
) => {
|
||||
this.excalidrawAppState = state;
|
||||
onIdleStateChange = (userState: UserIdleState) => {
|
||||
this.setState({ userState });
|
||||
this.portal.broadcastIdleChange(userState);
|
||||
};
|
||||
|
||||
broadcastElements = (elements: readonly ExcalidrawElement[]) => {
|
||||
if (
|
||||
getSceneVersion(elements) >
|
||||
this.getLastBroadcastedOrReceivedSceneVersion()
|
||||
@ -402,7 +577,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
this.portal.broadcastScene(
|
||||
SCENE.UPDATE,
|
||||
getSyncableElements(
|
||||
this.excalidrawRef.current!.getSceneElementsIncludingDeleted(),
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
),
|
||||
true,
|
||||
);
|
||||
@ -431,8 +606,25 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
});
|
||||
};
|
||||
|
||||
/** PRIVATE. Use `this.getContextValue()` instead. */
|
||||
private contextValue: CollabAPI | null = null;
|
||||
|
||||
/** Getter of context value. Returned object is stable. */
|
||||
getContextValue = (): CollabAPI => {
|
||||
if (!this.contextValue) {
|
||||
this.contextValue = {} as CollabAPI;
|
||||
}
|
||||
|
||||
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;
|
||||
return this.contextValue;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { children } = this.props;
|
||||
const { modalIsShown, username, errorMessage, activeRoomLink } = this.state;
|
||||
|
||||
return (
|
||||
@ -456,14 +648,11 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
onClose={() => this.setState({ errorMessage: "" })}
|
||||
/>
|
||||
)}
|
||||
{children({
|
||||
isCollaborating: this.state.isCollaborating,
|
||||
username: this.state.username,
|
||||
onPointerUpdate: this.onPointerUpdate,
|
||||
initializeSocketClient: this.initializeSocketClient,
|
||||
onCollabButtonClick: this.onCollabButtonClick,
|
||||
broadcastElements: this.broadcastElements,
|
||||
})}
|
||||
<CollabContextProvider
|
||||
value={{
|
||||
api: this.getContextValue(),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -6,23 +6,21 @@ import {
|
||||
|
||||
import CollabWrapper from "./CollabWrapper";
|
||||
|
||||
import {
|
||||
getElementMap,
|
||||
getSyncableElements,
|
||||
} from "../../packages/excalidraw/index";
|
||||
import { getSyncableElements } from "../../packages/excalidraw/index";
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import { BROADCAST, SCENE } from "../app_constants";
|
||||
import { UserIdleState } from "./types";
|
||||
|
||||
class Portal {
|
||||
app: CollabWrapper;
|
||||
collab: CollabWrapper;
|
||||
socket: SocketIOClient.Socket | null = null;
|
||||
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
|
||||
roomId: string | null = null;
|
||||
roomKey: string | null = null;
|
||||
broadcastedElementVersions: Map<string, number> = new Map();
|
||||
|
||||
constructor(app: CollabWrapper) {
|
||||
this.app = app;
|
||||
constructor(collab: CollabWrapper) {
|
||||
this.collab = collab;
|
||||
}
|
||||
|
||||
open(socket: SocketIOClient.Socket, id: string, key: string) {
|
||||
@ -30,7 +28,7 @@ class Portal {
|
||||
this.roomId = id;
|
||||
this.roomKey = key;
|
||||
|
||||
// Initialize socket listeners (moving from App)
|
||||
// Initialize socket listeners
|
||||
this.socket.on("init-room", () => {
|
||||
if (this.socket) {
|
||||
this.socket.emit("join-room", this.roomId);
|
||||
@ -39,12 +37,12 @@ class Portal {
|
||||
this.socket.on("new-user", async (_socketId: string) => {
|
||||
this.broadcastScene(
|
||||
SCENE.INIT,
|
||||
getSyncableElements(this.app.getSceneElementsIncludingDeleted()),
|
||||
getSyncableElements(this.collab.getSceneElementsIncludingDeleted()),
|
||||
/* syncAll */ true,
|
||||
);
|
||||
});
|
||||
this.socket.on("room-user-change", (clients: string[]) => {
|
||||
this.app.setCollaborators(clients);
|
||||
this.collab.setCollaborators(clients);
|
||||
});
|
||||
}
|
||||
|
||||
@ -125,16 +123,33 @@ class Portal {
|
||||
data as SocketUpdateData,
|
||||
);
|
||||
|
||||
if (syncAll && this.app.state.isCollaborating) {
|
||||
if (syncAll && this.collab.isCollaborating) {
|
||||
await Promise.all([
|
||||
broadcastPromise,
|
||||
this.app.saveCollabRoomToFirebase(syncableElements),
|
||||
this.collab.saveCollabRoomToFirebase(syncableElements),
|
||||
]);
|
||||
} else {
|
||||
await broadcastPromise;
|
||||
}
|
||||
};
|
||||
|
||||
broadcastIdleChange = (userState: UserIdleState) => {
|
||||
if (this.socket?.id) {
|
||||
const data: SocketUpdateDataSource["IDLE_STATUS"] = {
|
||||
type: "IDLE_STATUS",
|
||||
payload: {
|
||||
socketId: this.socket.id,
|
||||
userState,
|
||||
username: this.collab.state.username,
|
||||
},
|
||||
};
|
||||
return this._broadcastSocketData(
|
||||
data as SocketUpdateData,
|
||||
true, // volatile
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
broadcastMouseLocation = (payload: {
|
||||
pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
|
||||
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
|
||||
@ -146,9 +161,9 @@ class Portal {
|
||||
socketId: this.socket.id,
|
||||
pointer: payload.pointer,
|
||||
button: payload.button || "up",
|
||||
selectedElementIds:
|
||||
this.app.excalidrawAppState?.selectedElementIds || {},
|
||||
username: this.app.state.username,
|
||||
selectedElementIds: this.collab.excalidrawAPI.getAppState()
|
||||
.selectedElementIds,
|
||||
username: this.collab.state.username,
|
||||
},
|
||||
};
|
||||
return this._broadcastSocketData(
|
||||
@ -157,62 +172,6 @@ class Portal {
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
reconcileElements = (
|
||||
sceneElements: readonly ExcalidrawElement[],
|
||||
): readonly ExcalidrawElement[] => {
|
||||
const currentElements = this.app.getSceneElementsIncludingDeleted();
|
||||
// create a map of ids so we don't have to iterate
|
||||
// over the array more than once.
|
||||
const localElementMap = getElementMap(currentElements);
|
||||
|
||||
// Reconcile
|
||||
return (
|
||||
sceneElements
|
||||
.reduce((elements, element) => {
|
||||
// if the remote element references one that's currently
|
||||
// edited on local, skip it (it'll be added in the next step)
|
||||
if (
|
||||
element.id === this.app.excalidrawAppState?.editingElement?.id ||
|
||||
element.id === this.app.excalidrawAppState?.resizingElement?.id ||
|
||||
element.id === this.app.excalidrawAppState?.draggingElement?.id
|
||||
) {
|
||||
return elements;
|
||||
}
|
||||
|
||||
if (
|
||||
localElementMap.hasOwnProperty(element.id) &&
|
||||
localElementMap[element.id].version > element.version
|
||||
) {
|
||||
elements.push(localElementMap[element.id]);
|
||||
delete localElementMap[element.id];
|
||||
} else if (
|
||||
localElementMap.hasOwnProperty(element.id) &&
|
||||
localElementMap[element.id].version === element.version &&
|
||||
localElementMap[element.id].versionNonce !== element.versionNonce
|
||||
) {
|
||||
// resolve conflicting edits deterministically by taking the one with the lowest versionNonce
|
||||
if (
|
||||
localElementMap[element.id].versionNonce < element.versionNonce
|
||||
) {
|
||||
elements.push(localElementMap[element.id]);
|
||||
} else {
|
||||
// it should be highly unlikely that the two versionNonces are the same. if we are
|
||||
// really worried about this, we can replace the versionNonce with the socket id.
|
||||
elements.push(element);
|
||||
}
|
||||
delete localElementMap[element.id];
|
||||
} else {
|
||||
elements.push(element);
|
||||
delete localElementMap[element.id];
|
||||
}
|
||||
|
||||
return elements;
|
||||
}, [] as Mutable<typeof sceneElements>)
|
||||
// add local elements that weren't deleted or on remote
|
||||
.concat(...Object.values(localElementMap))
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default Portal;
|
||||
|
@ -1,4 +1,4 @@
|
||||
@import "../../css/_variables";
|
||||
@import "../../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.RoomDialog-linkContainer {
|
||||
|